Write the context root into the bundle tar #23

Merged
geusebio merged 1 commit from fix/context-root-tar-entry into main 2026-08-12 10:58:04 +00:00
Owner

Summary

Every sc deploy of a Dockerfile that does COPY . <existing-dir> produced an image that
could not serve. Found by running the deploy path end to end against the local stack rather
than by reading it: the build reported success, the push reported success, the deployment
reported running, and the service sat at 0/1 restarting for ever.

The platform builds with kaniko, and kaniko takes the mode of an existing destination
directory from the context tar's root entry
, defaulting it to 0750 owned by root when the
tar has none. docker build instead leaves an existing directory exactly as the base image
had it. packContext skipped "." in its walk, so there was never a root entry.

Same Dockerfile, two builders:

Build path /app
docker build 755 app:app
sc deploy → kaniko 750 root:root

A base image that serves as a de-privileged user then cannot traverse its own application
directory. matthewbaggett/php serves as uid 1000 app, so nginx answered

stat() "/app/public/index.php" failed (13: Permission denied)

the healthcheck never passed, and Swarm restarted the task on a loop —
task: non-zero exit (111): dockerexec: unhealthy container.

Isolating it

Two tars differing only in a ./ member, same Dockerfile
(FROM matthewbaggett/php:8.5 + WORKDIR /app + COPY . /app), straight through kaniko:

tar -czf with-root.tar.gz -C ctx .                      # has ./        -> /app 755
tar -czf no-root.tar.gz  -C ctx Dockerfile hello.txt    # no root entry -> /app 750 root:root

WORKDIR alone accounts for the ownership change (kaniko makes it root where docker keeps
app:app); the mode drop to 0750, which is the part that actually breaks serving, is
entirely the missing root entry.

The change

One entry, written outside the walk:

  • Outside the walk because the walk applies .dockerignore, and a pattern matching "."
    would fs.SkipDir the whole context.
  • Mode a fixed 0755, not the local directory's. This is the one entry whose mode lands
    on a directory the base image already created, so whether a built image can be served must
    not depend on the umask of whoever ran sc deploy.
  • Owner root, as for every other entry — the image is built as root, and a Dockerfile
    chowns whatever its runtime user needs to write.

This changes every context digest, since the root entry is content. Nothing in the agent
caches on it; worth a thought on the platform side if anything does.

Test plan

  • go test ./... — all packages pass.
  • New TestPackContextWritesTheContextRootFirst: asserts the archive's first entry is ./, a
    directory, mode 0755, owner 0:0 — with the local directory deliberately chmod 0700, so
    a tight umask escaping into the image would fail the test. Asserted on the tar headers
    rather than on the hash, because the hash only says something changed, not that it is right.
  • New TestPackContextRootEntryIsDeterministic: the root entry does not move when the local
    directory's mode does.
  • Existing determinism and .dockerignore tests unchanged and passing.

Verified end to end

Against the local stack, make sc-deploy of example-app/:

  • Before: built and pushed, then grey-ooo-test-webserver_app 0/1, restarting, with the
    Permission denied above in docker service logs.
  • After: /app is 755 root:root, the service reaches 1/1 Running, and answers
    HTTP 200 both inside the container and through the Swarm ingress on :8080.

Follow-up, not in this PR

The pin bump in someones.computer is a separate change in that repo.

## Summary Every `sc deploy` of a Dockerfile that does `COPY . <existing-dir>` produced an image that could not serve. Found by running the deploy path end to end against the local stack rather than by reading it: the build reported success, the push reported success, the deployment reported *running*, and the service sat at 0/1 restarting for ever. The platform builds with kaniko, and **kaniko takes the mode of an existing destination directory from the context tar's root entry**, defaulting it to `0750` owned by root when the tar has none. `docker build` instead leaves an existing directory exactly as the base image had it. `packContext` skipped `"."` in its walk, so there was never a root entry. Same Dockerfile, two builders: | Build path | `/app` | |---|---| | `docker build` | `755 app:app` | | `sc deploy` → kaniko | **`750 root:root`** | A base image that serves as a de-privileged user then cannot traverse its own application directory. `matthewbaggett/php` serves as uid 1000 `app`, so nginx answered ```text stat() "/app/public/index.php" failed (13: Permission denied) ``` the healthcheck never passed, and Swarm restarted the task on a loop — `task: non-zero exit (111): dockerexec: unhealthy container`. ## Isolating it Two tars differing only in a `./` member, same Dockerfile (`FROM matthewbaggett/php:8.5` + `WORKDIR /app` + `COPY . /app`), straight through kaniko: ```bash tar -czf with-root.tar.gz -C ctx . # has ./ -> /app 755 tar -czf no-root.tar.gz -C ctx Dockerfile hello.txt # no root entry -> /app 750 root:root ``` `WORKDIR` alone accounts for the ownership change (kaniko makes it root where docker keeps `app:app`); the mode drop to `0750`, which is the part that actually breaks serving, is entirely the missing root entry. ## The change One entry, written outside the walk: - **Outside the walk** because the walk applies `.dockerignore`, and a pattern matching `"."` would `fs.SkipDir` the whole context. - **Mode a fixed `0755`, not the local directory's.** This is the one entry whose mode lands on a directory the base image already created, so whether a built image can be served must not depend on the umask of whoever ran `sc deploy`. - **Owner root**, as for every other entry — the image is built as root, and a Dockerfile chowns whatever its runtime user needs to write. **This changes every context digest**, since the root entry is content. Nothing in the agent caches on it; worth a thought on the platform side if anything does. ## Test plan - `go test ./...` — all packages pass. - New `TestPackContextWritesTheContextRootFirst`: asserts the archive's first entry is `./`, a directory, mode `0755`, owner `0:0` — with the local directory deliberately `chmod 0700`, so a tight umask escaping into the image would fail the test. Asserted on the tar headers rather than on the hash, because the hash only says something changed, not that it is right. - New `TestPackContextRootEntryIsDeterministic`: the root entry does not move when the local directory's mode does. - Existing determinism and `.dockerignore` tests unchanged and passing. ## Verified end to end Against the local stack, `make sc-deploy` of `example-app/`: - **Before:** built and pushed, then `grey-ooo-test-webserver_app` 0/1, restarting, with the `Permission denied` above in `docker service logs`. - **After:** `/app` is `755 root:root`, the service reaches **1/1 Running**, and answers **HTTP 200** both inside the container and through the Swarm ingress on `:8080`. ## Follow-up, not in this PR The pin bump in `someones.computer` is a separate change in that repo.
Write the context root into the bundle tar
All checks were successful
CI / build (pull_request) Successful in 1m36s
78b29bc22e
Every `sc deploy` of a Dockerfile that does `COPY . <existing-dir>` produced
an image that could not serve. The platform builds with kaniko, and
kaniko takes the mode of an existing destination directory from the
context tar's **root entry** — defaulting it to 0750 owned by root when
the tar has none. `docker build` instead leaves an existing directory
exactly as the base image had it.

packContext skipped "." in its walk, so there was never a root entry. The
result, against the same Dockerfile:

    docker build   /app  755 app:app
    sc deploy      /app  750 root:root

A base image that serves as a de-privileged user then cannot traverse its
own application directory. `matthewbaggett/php` serves as uid 1000 `app`,
so nginx answered

    stat() "/app/public/index.php" failed (13: Permission denied)

the healthcheck never passed, and Swarm restarted the task for ever —
after a build and push that had both reported success.

The root entry carries a fixed 0755 rather than the local directory's
mode: it is the one entry whose mode lands on a directory the base image
already created, and whether a built image can be served must not depend
on the umask of whoever ran `sc deploy`. Ownership is root, as for every
other entry.

It is written outside the walk because the walk applies .dockerignore, and
a pattern matching "." would prune the whole context.

Note this changes every context digest, since the root entry is content.

Verified end to end against the local stack: example-app now builds,
pushes, and reaches 1/1 Running, answering HTTP 200 through the Swarm
ingress. Before the change the same deploy sat at 0/1 restarting.
Sign in to join this conversation.
No reviewers
No labels
in-progress
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Grey.ooo/someones.computer_agent!23
No description provided.