Skip to content

feat: support kubernetes init containers and least privilege - #34

Merged
amannocci merged 1 commit into
mainfrom
feat/kubernetes-init-containers
Oct 20, 2026
Merged

amannocci merged 1 commit into
mainfrom
feat/kubernetes-init-containers

Conversation

@amannocci

Copy link
Copy Markdown
Member

What is the change being made?

  • IGNITY_MODE (full default, init, services) splits the init phase and the services phase across two containers (a Kubernetes init container and the main container). They share files through a user-mounted volume, and no env is handed over.
  • /etc/ignity/envs/* become non-secret defaults applied before s6-svscan. The container env wins and is never written to disk. with-env is a passthrough by default: -k narrows the inherited env and -d loads an envdir for one process.
  • The CMD drops to USERMAP_UID/USERMAP_GID when PID 1 is root.
  • Least privilege is documented and tested: six capabilities (CHOWN, FOWNER, DAC_OVERRIDE, SETUID, SETGID, KILL) and a read-only root filesystem. IGNITY_ACCOUNTS_DIR (gen-accounts) lets an init container generate passwd/group for read-only roots.
  • fix-perms never follows symlinks and exits non-zero when chown/chmod fail.
  • stage1 no longer passes -- to s6-rmrf, which takes no option.
  • stage3 waits for services (eltest -d instead of -f), and a grace time of 0 skips the wait.
  • Tests: shared image fixture and image_fixture() factory, default Containerfile template, env_args/READ_ONLY helpers, tuple constants.

Behaviour changes:

  • A failing chown/chmod in fix-perms now aborts the boot.
  • with-env no longer restores env from a dump. It is POSIX sh and needs awk for -k.
  • /run/ignity/envs no longer exists.
  • An invalid IGNITY_MODE fails the container.
  • Without CAP_KILL, shutdown never ends (stage3 waits for all children).

Why is the change being made?

ignity was designed for Docker Swarm, with everything in one container. On Kubernetes the init phase can run in an init container and the services in the main container, so that secrets only given to the init container never reach the main one. The same work lets ignity run with minimal capabilities and a read-only root filesystem, which Pod Security policies ask for.

How has this been tested?

  • uv run poe test: 67 passed.
  • New tests/test_modes.py (two-container flow with a shared volume, failing init, invalid mode) and tests/test_least_privilege.py (minimal set, plus one test per capability showing what breaks without it).
  • New cases in the envs, boot, preboot and perms tests (env inheritance, with-env -k/-d, CMD privilege drop, account generation, symlink handling).
  • Not tested: the README pod example on a real cluster, and a non-root PID 1.

How has this been documented?

  • README: sections on Kubernetes init containers, scoped secrets, least privilege (capability table), read-only mode, account generation and non-root PID 1 (documented as an untested exception). Also fixes the IGNITY_SERVICES_GRACETIME default and a /run/ingity typo.
  • .claude/CLAUDE.md: architecture, environment variables and test helpers.

Related Issues

None.

Add IGNITY_MODE (full | init | services) to split the init phase and the
services phase across two containers sharing files through a volume. No
env is handed over, so secrets of the init container stay there.

- env: /etc/ignity/envs become non-secret defaults applied before
  s6-svscan, container env wins and is never written to disk
- with-env: passthrough, -k narrows the env, -d loads an envdir
- stage2-cmd: the CMD drops to USERMAP_UID/USERMAP_GID
- gen-accounts: IGNITY_ACCOUNTS_DIR for read-only root filesystems
- fix-perms: never follow symlinks and fail when chown/chmod fail
- stage1: s6-rmrf takes no option, fails on a read-only root
- stage3: wait for services (-d, not -f), skip the wait at grace time 0
- tests: shared image fixture, default Containerfile, tuple constants,
  mode and per-capability tests
- docs: Kubernetes, least privilege and read-only sections
@amannocci amannocci self-assigned this Oct 20, 2026
@amannocci
amannocci merged commit 6e9782c into main Oct 20, 2026
1 check passed
amannocci added a commit that referenced this pull request Oct 22, 2026
## What is the change being made?

- `stage3` globs the services with `elglob -0`, so a scandir without
services expands to nothing instead of the pattern itself.
- Each service is waited for in parallel (`forx -p`, one `s6-svwait -D`
per service), still bounded by `IGNITY_SERVICES_GRACETIME`. Without
services there is nothing to wait for.
- Tests: shutdown without services (`test_boot.py`) and with services
(`test_services.py`) leaves no `s6-svwait` error.
- CI: `astral-sh/setup-uv` and `peter-evans/create-pull-request` are
replaced by their `techcode-io/github-actions` proxies (`setup-uv/v1`,
`create-pull-request/v1`), as already done for `setup-github-bot`. The
`setup-uv` proxy pins uv to 0.13.5 by default.

## Why is the change being made?

Downstream images without user services print on every shutdown:

```
s6-svwait: fatal: unable to subscribe to events for /run/ignity/services-state/*: No such file or directory
```

Without `-0`, `elglob` keeps a pattern that matches nothing
(`.s6-svscan` is skipped as a dotfile), and `s6-svwait` got the literal
`*`. The bug was hidden until #34: the old `eltest -f` check never ran
the wait.

`astral-sh/setup-uv` and `peter-evans/create-pull-request` are
restricted actions: the proxies are the allowed way to use them.

## How has this been tested?

- By hand with podman, on an image built from source
(`debian:bookworm-slim`):
- no services: the error is gone, and the CMD exit code is kept (`sh -c
'exit 3'` gives 3);
  - with the `services` fixture: clean shutdown, exit code kept.
- Not run yet: `uv run poe test`, including the two new tests. The
proxies are covered by this PR's CI run, but `dependabot-plus-plus.yml`
only runs weekly or by hand (`workflow_dispatch`).

## How has this been documented?

No documentation change: the behaviour matches what is already
documented.

## Related Issues

None.
amannocci added a commit that referenced this pull request Oct 23, 2026
## What is the change being made?

- `stage2-cmd` runs the CMD as PID 1 (root) by default, even when
`USERMAP_UID` / `USERMAP_GID` are set. Dropping to
`USERMAP_UID:USERMAP_GID` is now opt-in with
`IGNITY_CMD_DROP_PRIVILEGES=1` (default `0`). When opted in, the
behaviour is unchanged: no drop for `0:0` or a non root PID 1,
supplementary groups set to the gid.
- `gen-accounts` checks that `USERMAP_UID` and `USERMAP_GID` are digits
(`grep -Eqzx "[0-9]+"`, like the `USER` check) instead of `eltest -ge`,
which let values like `1000:0` through.
- Tests: new `test_cmd_stays_root_with_usermap_by_default`. The tests
expecting the CMD to drop (`test_boot.py`, `test_least_privilege.py`)
set `IGNITY_CMD_DROP_PRIVILEGES=1`.
`test_accounts_reject_non_numeric_ids` also asserts the error message.

## Why is the change being made?

The CMD is mostly used to debug or test in the ignity ecosystem, where
root is expected, while `USERMAP_*` is mostly set for `perms`. This goes
back to the behaviour before 0.1.0 (#34). Users of 0.1.0 relying on the
CMD drop have to set `IGNITY_CMD_DROP_PRIVILEGES=1`: worth a line in the
release notes.

The `gen-accounts` bug was hidden by the drop: with
`USERMAP_UID=1000:0`, it wrote `exploit:x:1000:0:1000::...` (gid 0) in
the generated passwd, and `test_accounts_reject_non_numeric_ids` only
passed because `s6-applyuidgid` then failed on the CMD.

## How has this been tested?

- `uv run poe test`: 72 passed, run on top of `feat/split-packages`
before moving the commit onto `main`. Not rerun on this branch, CI
covers it.
- By hand with podman on an image built from source
(`debian:bookworm-slim`): `USERMAP_UID=1000:0` and `USERMAP_GID=0:0` are
rejected ("must be numbers", exit 1), `1000:1000` still writes the
account.

## How has this been documented?

- `README.md`: `IGNITY_CMD_DROP_PRIVILEGES` in the parameters table and
"How to customize `ignity` behaviour", "How to run the CMD as a non root
user", the least-privilege section (`SETUID` row, capabilities note) and
the Kubernetes examples.
- `.claude/CLAUDE.md`: `stage2-cmd` and the runtime variables.

## Related Issues

None.
@amannocci
amannocci deleted the feat/kubernetes-init-containers branch October 23, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant