Conversation
|
I like adding support for these options, and the warnings help explain the remaining limitations. Can you add dynamic tests confirming the capabilities, tmpfs mounts, and limits are actually applied? For |
Thanks. Both are actionable. Dynamic tests. Agreed these need to assert the options actually land, not just that the right flags were assembled. All four are observable from inside the container via container exec, which is the same mechanism the existing DNS dynamic suite uses:
So each one gets an assertion against what the kernel reports, not against the argv we built. network_mode. You're right, and I'd like to go slightly further than none if you agree. Today any network_mode prints a note and the container joins the default network. For none that is the bad case — the user asked for no networking and silently got some, which is strictly less isolated than requested. But host, service: and container: are also not honoured; they just fail as "doesn't behave as configured" rather than as a safety downgrade. My inclination is to fail for every network_mode value container run cannot express, with the value named in the message, and keep bridge passing since that is effectively what you get. That turns all of them into an actionable error instead of one being an error and three staying notes. If you'd rather scope this PR to none only and leave the rest as warnings, that's fine too — just say which and I'll match it. |
…laim
Three problems found by a fresh-context review of this branch, all verified
against the installed apple/container sources rather than inferred:
ulimits: the decoder tried [String: String] then [String: Int] and fell back to
nil. Compose also allows a {soft, hard} pair, which matched neither, so a file
using the long form silently lost its whole ulimits map including any sibling
entries in short form. container run --ulimit takes <type>=<soft>[:<hard>]
(Parser.rlimit) and its type names match Compose's exactly, so the long form is
directly expressible. Both forms now normalise through UlimitValue, and an entry
that cannot be read throws instead of nilling the map.
tmpfs: same silent-nil shape for a value that is neither a list nor a string;
now throws. Options are also trimmed, so "/run:noexec, mode=0755" no longer
drops mode by failing its prefix test.
Capabilities: the comment claimed cap_drop was emitted before cap_add "so that
cap_drop: [ALL] followed by a narrow cap_add behaves as Compose specifies".
That is false. container collects the two flags into separate arrays
(Flags.swift) and computes the effective set in RuntimeService.effectiveCapabilities
- drop-ALL clears the base, adds are applied, individual drops removed - so the
command-line order carries no meaning. Two tests asserted that ordering; they
now assert that every declared capability reaches its flag, which is the real
invariant.
The six new keys also went through the run-args builder without variable
interpolation while every neighbouring key resolved ${VAR}; they now take the
environment and resolve it.
…ke effect
**`network_mode: none` now stops the run.** Every unsupported mode was reported
and the container started anyway on the default network. For `host`,
`service:<name>` and `container:<id>` that is wrong in the direction of "does not
behave as configured". For `none` it is wrong in the other direction: the file
asks for no networking and the container comes up connected, which is less
isolation than was requested and is not something to find out from a note in the
log. `rejectedNetworkMode` is the seam, normalising case and surrounding space so
`None` and ` NONE ` cannot slip through; the other modes keep their note.
Scoped to `none` deliberately. Widening it to every unsupported mode would change
behaviour for existing compose files, and that is a call for the maintainer, not
a side effect of this PR — the set is one function and one line away.
**The hardening keys are now asserted from inside the container**, against what
the kernel reports rather than against the argv that was assembled. Those are two
different claims, and only the static suite covered the first:
cap_drop: ALL + cap_add: NET_BIND_SERVICE CapBnd is exactly 1 << 10 — asserted
as the whole mask, so anything
`cap_drop: ALL` failed to remove
shows up as an extra bit
tmpfs: /scratch present in /proc/mounts as tmpfs
shm_size: 64M /dev/shm carries size=65536k
ulimits nofile 1234:5678 both columns in /proc/self/limits
init: true PID 1 is not the service command
Driven through compose rather than `container run`, so the mapping is what is
under test. Six static tests for the rejection, one dynamic test for the rest.
The multi-line literal kept the continuation lines' indentation, so the error reached the terminal with runs of spaces inside it. Verified end to end: a compose file with network_mode: none now exits 1 with the message on one line and starts no container.
9cf6c94 to
db6cf6d
Compare
Both predate the change and assert that `network_mode: none` produces a note. It now throws instead, so the note is deliberately absent and those expectations were inverted. `networkModeIsReported` now checks both directions: `none` produces no note because the throw is the whole report, and `host` — unsupported but survivable — still does. The document-driven one asserts the rejection seam returns the value for the `fixer` service rather than looking for a warning that no longer exists. Static suite back to green: 273 tests in 25 suites.
|
Pushed, rebased onto current Dynamic tests. Everything is asserted from
The capability check asserts the whole mask rather than "contains", which is what makes
I scoped it to Two existing assertions expected Static suite: 273 tests / 25 suites green, up from 267 / 24. |
feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits
Summary
Six container-hardening compose keys are currently absent from
Service'sCodingKeys. Swift'sCodableignores unknown keys, so today they are dropped with no warning: a compose file thatdeclares
cap_drop: [ALL]and a read-only rootfs withtmpfsmounts runs with the defaultcapability set and no tmpfs, and nothing in the output says so.
All six have a
container runequivalent. This adds them, plus reporting for two things that donot.
container runcap_add/cap_drop--cap-add/--cap-droptmpfs--mount type=tmpfs,target=…shm_size--shm-sizeinit--initulimits--ulimit <type>=<soft>[:<hard>]network_modeWhy the mapping is a pure function
The mapping lives in
ComposeUp.hardeningRunArgs(for:environment:)rather than inline in theargument builder, and the tests call it directly.
The reason is concrete. The existing tests cover Codable parsing and the already-extracted pure
helpers (
clampMemoryLimit,composePortToRunArg,networkRunArg), butrunCommandArgshas notest seam:
grep -rn runCommandArgsreturns 33 hits inComposeUp.swiftand 0 inTests/.That gap is why
healthcheck.timeoutcan be decoded (Healthcheck.swift:48,61,79), asserted on bytwo parsing tests, and still never reach
waitUntilServiceIsHealthy— no test can see thedifference. Adding six more keys the same way would have reproduced it six more times.
Measured against
container1.0.0, not inferredTwo findings shaped the implementation. Both are reproducible on macOS 27.0 /
container1.0.0.--tmpfssilently mounts at the literal path when given Compose-style options.There is no error.
/runis not mounted, and a directory named/run:noexec,nosuidexistsinstead. A read-only container then fails to write
/runwith a permission error that pointsnowhere near the cause. This change therefore uses
--mount type=tmpfsexclusively.--mount type=tmpfsaccepts onlytarget,modeandsize.So
noexec,nosuid,nodev,uidandgidcannot be expressed. They are dropped — butreported, never silently:
That second warning is not hypothetical. The mount is root-owned, so:
A service running as a non-root user with a read-only rootfs — PostgreSQL putting its socket in
/run/postgresqlis the usual case — will fail to start, and the reason is worth one line ofoutput.
network_modecontainer runhas no way to express "no network": a container started without--networkstilljoins the default network and gets an address.
network_modeis therefore parsed only so it can bereported, and produces no run arguments.
Capability ordering is deliberately not asserted
--cap-addand--cap-dropare collected into two separate arrays (Flags.swift:231-241) and theeffective set is computed in
RuntimeService.effectiveCapabilities—cap_drop: ALLclears thebase, adds are applied, then individual drops are removed. The order the flags appear in on the
command line carries no meaning, so the tests assert that every declared capability reaches its
flag rather than asserting a sequence.
ulimitsCompose allows both
nofile: 65535andnofile: {soft: 20000, hard: 40000}.container runtakes<type>=<soft>[:<hard>](Parser.rlimit) and its type names match Compose's exactly, so both formsmap directly. They are normalised through a small
UlimitValuedecoder.An entry that cannot be read throws rather than nilling the map. Nilling would drop the sibling
entries with it, which is the same silent-loss shape this change set exists to remove.
tmpfsdoesthe same for a value that is neither a list nor a string.
Tests
Two suites:
HardeningArgsTests— per-key parsing and flag mapping, including theulimitslong form,whitespace after a comma in tmpfs options,
${VAR}interpolation, and the two throwing paths.HardeningComposeIntegrationTests— the same keys through a whole compose document that sharesthem via a YAML anchor and merge keys. A parser that failed to resolve
<<:would pass everyper-key test and fail here.
Verification on macOS 27.0 / Swift 6.4, branched from
main@6e6aaf0:swift buildswift test(static suites)mainis 236 in 21)git diff main..HEAD --checkEvery new behaviour was mutation-checked: reverting it turns the covering test red. The capability
ordering was the one case where a test stayed green under mutation for a good reason, which is what
led to dropping that assertion.
Notes and limits
container execon the host does not guarantee the guest process is reaped; notintroduced here, but relevant to anything built on these flags.
size=is passed through as written.containerinterprets it in MiB, so a byte-valuedsize=1000000truncates to 0. Compose's own units are not translated; left as-is to avoidguessing at intent.
security_optandloggingremain unsupported:container inspect's configuration schema hasno field for the former, and there is no log-driver concept for the latter.
stop_grace_periodis deliberately untouched — feat: add/support stop_grace_period #150 is already open for it.