From 69ad9dbfab2fc4552bd3bea507783a4a5b9dee35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Mon, 28 Sep 2026 03:13:46 +0200 Subject: [PATCH 1/4] fix(registry): stabilize authored release hashes Honor SOURCE_DATE_EPOCH and sort discovered root layers. Identical publisher retries otherwise conflict on creation times or map order. Preserve immutable-tag checks; verify repeated pushes and live retries. --- apps/druid/adapters/cli/push.go | 19 +++++++++ apps/druid/adapters/cli/push_test.go | 27 ++++++++++++ docs/runtime-lifecycle-verification.md | 5 +++ internal/core/services/registry/oci.go | 4 ++ internal/core/services/registry/oci_test.go | 46 +++++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 apps/druid/adapters/cli/push_test.go diff --git a/apps/druid/adapters/cli/push.go b/apps/druid/adapters/cli/push.go index ba19365..edab2ef 100644 --- a/apps/druid/adapters/cli/push.go +++ b/apps/druid/adapters/cli/push.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" + "time" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/services/registry" @@ -69,6 +71,9 @@ var PushCommand = &cobra.Command{ } overrides := map[string]string{} + if err := reproducibleCreatedAnnotation(overrides, os.Getenv("SOURCE_DATE_EPOCH")); err != nil { + return err + } if pushMinRAM != "" { overrides["gg.druid.scroll.minRam"] = pushMinRAM } @@ -107,6 +112,20 @@ var PushCommand = &cobra.Command{ }, } +// ORAS otherwise stamps the current time, making an identical CI rebuild a +// different immutable revision. Only explicitly reproducible builds override it. +func reproducibleCreatedAnnotation(annotations map[string]string, epoch string) error { + if epoch == "" { + return nil + } + seconds, err := strconv.ParseInt(epoch, 10, 64) + if err != nil || seconds < 0 || seconds > 253402300799 { + return fmt.Errorf("SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp before year 10000") + } + annotations["org.opencontainers.image.created"] = time.Unix(seconds, 0).UTC().Format(time.RFC3339) + return nil +} + func init() { RootCmd.AddCommand(PushCommand) PushCommand.Flags().StringVarP(&pushMinRAM, "min-ram", "r", pushMinRAM, "Minimum RAM required to run the application. (Will be added as a manifest annotation gg.druid.scroll.minRam)") diff --git a/apps/druid/adapters/cli/push_test.go b/apps/druid/adapters/cli/push_test.go new file mode 100644 index 0000000..781e136 --- /dev/null +++ b/apps/druid/adapters/cli/push_test.go @@ -0,0 +1,27 @@ +package cli + +import "testing" + +func TestExplicitSourceDateMakesReleaseCreationDeterministic(t *testing.T) { + for i := 0; i < 2; i++ { + annotations := map[string]string{} + if err := reproducibleCreatedAnnotation(annotations, "0"); err != nil { + t.Fatal(err) + } + if annotations["org.opencontainers.image.created"] != "1970-01-01T00:00:00Z" { + t.Fatal("unstable creation annotation") + } + } + annotations := map[string]string{} + if err := reproducibleCreatedAnnotation(annotations, ""); err != nil { + t.Fatal(err) + } + if len(annotations) != 0 { + t.Fatal("ordinary pushes unexpectedly changed") + } + for _, invalid := range []string{"-1", "tomorrow", "253402300800"} { + if err := reproducibleCreatedAnnotation(annotations, invalid); err == nil { + t.Fatal("invalid source date accepted") + } + } +} diff --git a/docs/runtime-lifecycle-verification.md b/docs/runtime-lifecycle-verification.md index cbfdcfb..4f40ac2 100644 --- a/docs/runtime-lifecycle-verification.md +++ b/docs/runtime-lifecycle-verification.md @@ -15,6 +15,8 @@ Verified locally on 2026-09-28. This is runtime acceptance, not complete product - Backup mode snapshots all runtime data, including files outside explicit release chunk selections, and preserves the installed descriptor bytes. - Command admission shares the maintenance lock. Waiting for a command does not hold that lock, and waiters do not overwrite state after a release transition. - Protected-path copying preserves symlinks without dereferencing them and rejects paths traversing symlink parents. +- CLI authored pushes honor explicit `SOURCE_DATE_EPOCH` for the OCI creation annotation. Without it ORAS stamps current time; identical CI rebuilds then produce different digests. Normal pushes retain existing timestamp behavior. Invalid explicit timestamps are rejected. +- Root file layers are sorted before packing; Go map iteration must not change an identical release's manifest digest. ## Evidence @@ -27,6 +29,9 @@ Verified locally on 2026-09-28. This is runtime acceptance, not complete product - Verified real workload start and command execution; stopped backup; installed-descriptor read; acceptance of v2 followed by moving the tag to v3; installation of v2; preservation of protected runtime data; restoration of v1, runtime-created data outside declared chunks, and byte-identical installed descriptor. - Test harness failures were diagnosed separately: registry:2 needs an OCI Accept header, and explicit fixture chunks must include the version marker. - Recovery-restart tests use persistent workload fixtures. The previous finite `true` command legitimately stopped immediately and was not evidence of a failed restart. +- Authenticated local publisher acceptance demonstrated the timestamp problem with identical layers and different creation times, then passed with identical finalized digests after rebuilding with a fixed source date (7.10s, including fixture cleanup). Focused CLI timestamp tests passed. +- A later run exposed a second reproducibility issue: `.meta` and `scroll.yaml` layers changed order. A local OCI regression failed in 0.08s with identical layer digests in different orders. Sorting root paths fixed it; three 32-push regression runs and a race-enabled run passed. +- After both reproducibility fixes, normal dedicated-account sign-in through the gateway, private import, identical rebuild/retry, shared publication and anonymous visibility passed three consecutive times (8.53s, 6.25s, 6.16s), including fixture cleanup. ## Remaining product work diff --git a/internal/core/services/registry/oci.go b/internal/core/services/registry/oci.go index b90d726..4083118 100644 --- a/internal/core/services/registry/oci.go +++ b/internal/core/services/registry/oci.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync/atomic" @@ -810,6 +811,9 @@ func (c *OciClient) PushWithOptions(folder string, repo string, tag string, over } fsFileNames = append(fsFileNames, "manifest.json") } + // Map iteration order must not change the manifest digest of an identical + // release. These root paths are disjoint, so sorting preserves extraction. + sort.Strings(fsFileNames) fs, err := c.newFileStore(folder) if err != nil { diff --git a/internal/core/services/registry/oci_test.go b/internal/core/services/registry/oci_test.go index 6aa7168..07b5931 100644 --- a/internal/core/services/registry/oci_test.go +++ b/internal/core/services/registry/oci_test.go @@ -1,6 +1,7 @@ package registry import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -11,8 +12,53 @@ import ( "github.com/highcard-dev/daemon/internal/core/domain" ocidigest "github.com/opencontainers/go-digest" + v1 "github.com/opencontainers/image-spec/specs-go/v1" ) +func TestPushIdenticalReleaseHasStableDigest(t *testing.T) { + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "scroll.yaml"), []byte("name: fixture\napp_version: '1.0.0'\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(folder, ".meta"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, ".meta", "en-US.md"), []byte("---\nname: Fixture\n---\nFixture.\n"), 0644); err != nil { + t.Fatal(err) + } + srv := fakeRegistry(t) + client := &OciClient{credentialStore: NewCredentialStore(nil), plainHTTP: true} + repo := strings.TrimPrefix(srv.URL, "http://") + "/test/reproducible" + var first v1.Descriptor + var firstLayers []string + for i := 0; i < 32; i++ { + tag := fmt.Sprintf("build-%d", i) + desc, err := client.Push(folder, repo, tag, map[string]string{"org.opencontainers.image.created": "1970-01-01T00:00:00Z"}, true, nil) + if err != nil { + t.Fatal(err) + } + response, err := http.Get(srv.URL + "/v2/test/reproducible/manifests/" + tag) + if err != nil { + t.Fatal(err) + } + var manifest v1.Manifest + err = json.NewDecoder(response.Body).Decode(&manifest) + response.Body.Close() + if err != nil { + t.Fatal(err) + } + var layers []string + for _, layer := range manifest.Layers { + layers = append(layers, layer.Annotations["org.opencontainers.image.title"]+"@"+layer.Digest.String()) + } + if i == 0 { + first, firstLayers = desc, layers + } else if desc.Digest != first.Digest { + t.Fatalf("identical rebuild changed digest: first layers %v; rebuild layers %v", firstLayers, layers) + } + } +} + // fakeRegistry returns a plain-HTTP httptest server that implements the bare // minimum of the OCI Distribution spec so that oras.Copy can complete a push. func fakeRegistry(t *testing.T) *httptest.Server { From 9490beb01fd32716ea00191f9b97d6e4d14da374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Mon, 28 Sep 2026 03:23:51 +0200 Subject: [PATCH 2/4] feat(scroll): expose read-only semantic validator Reuse runtime validation through a bounded stdin protocol for Core. Do not read config, expand host variables, execute commands, or echo untrusted input. Reject invalid procedures, mounts and release metadata. --- apps/druid-scroll-validator/main.go | 28 ++++++++++++++ apps/druid-scroll-validator/main_test.go | 48 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/druid-scroll-validator/main.go create mode 100644 apps/druid-scroll-validator/main_test.go diff --git a/apps/druid-scroll-validator/main.go b/apps/druid-scroll-validator/main.go new file mode 100644 index 0000000..fd66c66 --- /dev/null +++ b/apps/druid-scroll-validator/main.go @@ -0,0 +1,28 @@ +// druid-scroll-validator validates bounded YAML through the runtime's semantic +// rules. It never expands environment variables, reads configuration, unpacks +// artifacts, contacts a daemon, or executes Scroll commands. +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/highcard-dev/daemon/internal/core/domain" + "gopkg.in/yaml.v2" +) + +const maxInputBytes = 4 * 1024 * 1024 + +func validate(input io.Reader, output io.Writer) { + data, err := io.ReadAll(io.LimitReader(input, maxInputBytes+1)) + scroll := &domain.Scroll{} + valid := err == nil && len(data) <= maxInputBytes && yaml.Unmarshal(data, &scroll.File) == nil && scroll.Validate(false) == nil + // Do not echo untrusted content or host details in validation errors. + _ = json.NewEncoder(output).Encode(struct { + Version int `json:"version"` + Valid bool `json:"valid"` + }{Version: 1, Valid: valid}) +} + +func main() { validate(os.Stdin, os.Stdout) } diff --git a/apps/druid-scroll-validator/main_test.go b/apps/druid-scroll-validator/main_test.go new file mode 100644 index 0000000..88aa10c --- /dev/null +++ b/apps/druid-scroll-validator/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +const validScroll = "name: fixture\ndesc: Fixture\nversion: 1.0.0\napp_version: '1'\ncommands:\n start:\n procedures:\n - image: busybox:1.36\n command: [sleep, '600']\n" + +func TestValidateUsesRuntimeSemanticsWithoutHostExpansion(t *testing.T) { + t.Setenv("SECRET_IMAGE", "") + for _, fixture := range []struct { + name, yaml string + valid bool + }{ + {"valid", validScroll, true}, + {"literal environment placeholder", strings.ReplaceAll(validScroll, "busybox:1.36", "$SECRET_IMAGE"), true}, + {"missing description", strings.ReplaceAll(validScroll, "desc: Fixture\n", ""), false}, + {"invalid version", strings.ReplaceAll(validScroll, "version: 1.0.0", "version: invalid"), false}, + {"empty procedures", "name: fixture\ndesc: Fixture\nversion: 1.0.0\napp_version: '1'\ncommands:\n start: {}\n", false}, + {"missing image", strings.ReplaceAll(validScroll, "image: busybox:1.36", "image: ''"), false}, + {"signal without target", strings.ReplaceAll(validScroll, "image: busybox:1.36", "type: signal\n signal: SIGTERM"), false}, + {"escaping mount", validScroll + " mounts:\n - path: /server\n sub_path: ../secret\n", false}, + {"relative mount", validScroll + " mounts:\n - path: server\n", false}, + {"unknown expected port", validScroll + " expectedPorts:\n - name: missing\n", false}, + {"duplicate ids", validScroll + " id: duplicate\n - image: busybox\n id: duplicate\n", false}, + {"legacy mode", validScroll + " mode: exec\n", false}, + {"malformed yaml", "commands: [", false}, + {"oversized input", strings.Repeat("x", maxInputBytes+1), false}, + } { + t.Run(fixture.name, func(t *testing.T) { + var output bytes.Buffer + validate(strings.NewReader(fixture.yaml), &output) + var result struct { + Version int `json:"version"` + Valid bool `json:"valid"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Version != 1 || result.Valid != fixture.valid { + t.Fatalf("got %+v, want valid=%v", result, fixture.valid) + } + }) + } +} From a9f5dd9ec361ee5b268dfa5b5c2a56ef60f350fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Mon, 28 Sep 2026 03:33:06 +0200 Subject: [PATCH 3/4] build(scroll): include semantic validator helper Build/install the read-only helper with the existing CLI workflow. Record runtime parity tests and the pinned Core image smoke result. --- Makefile | 2 ++ docs/runtime-lifecycle-verification.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Makefile b/Makefile index 3673c38..f4b6bfa 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ build: generate-api ## Build Druid and helper binaries CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid ./apps/druid CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid-coldstarter ./apps/druid-coldstarter CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid-dev ./apps/druid-dev + CGO_ENABLED=0 go build -trimpath -o ./bin/druid-scroll-validator ./apps/druid-scroll-validator k3d-build-pull-image: ## Build the unified Druid runtime image and import it into local k3d. docker build . -f Dockerfile --build-arg "VERSION=$(VERSION)" -t "$(DRUID_K8S_PULL_IMAGE)" @@ -65,6 +66,7 @@ install: build ## Build and install Druid binaries install -m 0755 ./bin/druid /usr/local/bin/druid install -m 0755 ./bin/druid-coldstarter /usr/local/bin/druid-coldstarter install -m 0755 ./bin/druid-dev /usr/local/bin/druid-dev + install -m 0755 ./bin/druid-scroll-validator /usr/local/bin/druid-scroll-validator generate-md-docs: go run ./docs_md/main.go diff --git a/docs/runtime-lifecycle-verification.md b/docs/runtime-lifecycle-verification.md index 4f40ac2..4a9f879 100644 --- a/docs/runtime-lifecycle-verification.md +++ b/docs/runtime-lifecycle-verification.md @@ -17,6 +17,7 @@ Verified locally on 2026-09-28. This is runtime acceptance, not complete product - Protected-path copying preserves symlinks without dereferencing them and rejects paths traversing symlink parents. - CLI authored pushes honor explicit `SOURCE_DATE_EPOCH` for the OCI creation annotation. Without it ORAS stamps current time; identical CI rebuilds then produce different digests. Normal pushes retain existing timestamp behavior. Invalid explicit timestamps are rejected. - Root file layers are sorted before packing; Go map iteration must not change an identical release's manifest digest. +- `druid-scroll-validator` exposes the existing runtime semantic rules as a bounded, read-only stdin/JSON protocol for Core. It does not expand host environment variables, initialize CLI config, extract archives or run commands. `make build` and `make install` include this helper. ## Evidence @@ -32,6 +33,8 @@ Verified locally on 2026-09-28. This is runtime acceptance, not complete product - Authenticated local publisher acceptance demonstrated the timestamp problem with identical layers and different creation times, then passed with identical finalized digests after rebuilding with a fixed source date (7.10s, including fixture cleanup). Focused CLI timestamp tests passed. - A later run exposed a second reproducibility issue: `.meta` and `scroll.yaml` layers changed order. A local OCI regression failed in 0.08s with identical layer digests in different orders. Sorting root paths fixed it; three 32-push regression runs and a race-enabled run passed. - After both reproducibility fixes, normal dedicated-account sign-in through the gateway, private import, identical rebuild/retry, shared publication and anonymous visibility passed three consecutive times (8.53s, 6.25s, 6.16s), including fixture cleanup. +- Validator negative cases cover empty procedures, missing images, invalid versions, missing descriptions, unsafe mounts, unknown expected ports, duplicate IDs, invalid signal procedures, legacy fields, malformed/oversized input and literal environment placeholders. Focused race tests and the full CLI Go suite pass. +- Core reuses this validator for create/import/promote and repository-wide publication. Its Core-only image builds the validator from pinned CLI commit `9490beb01fd32716ea00191f9b97d6e4d14da374`; the actual image passes offline semantic/fail-closed smoke tests, including execution in a read-only container with all capabilities dropped. The live local publisher passes again with validation enabled (8.29s). ## Remaining product work From e4aadf2816bfec4f4642cf2ae130ca82401b5fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Sat, 3 Oct 2026 19:14:34 +0200 Subject: [PATCH 4/4] fix(lifecycle): recover scoped runtime safeguards Preserve protected files during staged updates and keep failed maintenance stopped. Exclude the previously deferred general runtime repairs. Recover the reviewed September 29 local state from session history into a durable workspace. Full Go suite and focused race tests pass. --- .../cli/worker_glob_protection_test.go | 49 ++++++++ apps/druid/adapters/cli/worker_ownership.go | 84 +++++++++++++ .../adapters/cli/worker_ownership_test.go | 48 ++++++++ .../adapters/cli/worker_permissions_test.go | 58 +++++++++ apps/druid/adapters/cli/worker_pull.go | 50 ++++++-- apps/druid/adapters/cli/worker_test.go | 6 +- apps/druid/core/services/runtime_access.go | 19 --- .../services/runtime_backup_failure_test.go | 36 ++++++ apps/druid/core/services/runtime_lifecycle.go | 2 +- .../services/runtime_restore_failure_test.go | 54 +++++++++ .../core/services/runtime_supervisor_test.go | 67 ++--------- docs/runtime-lifecycle-verification.md | 111 +++++++++++------- internal/core/ports/services_ports.go | 1 + internal/runtime/docker/backend_names_test.go | 7 ++ internal/runtime/docker/names.go | 6 +- internal/runtime/docker/workers.go | 18 ++- internal/runtime/kubernetes/resources.go | 8 +- internal/runtime/kubernetes/resources_test.go | 5 +- internal/utils/copy.go | 102 ++++++++++++++++ internal/utils/fs.go | 6 +- test/integration/docker/permissions_test.go | 109 +++++++++++++++++ 21 files changed, 711 insertions(+), 135 deletions(-) create mode 100644 apps/druid/adapters/cli/worker_glob_protection_test.go create mode 100644 apps/druid/adapters/cli/worker_ownership.go create mode 100644 apps/druid/adapters/cli/worker_ownership_test.go create mode 100644 apps/druid/adapters/cli/worker_permissions_test.go create mode 100644 apps/druid/core/services/runtime_backup_failure_test.go create mode 100644 apps/druid/core/services/runtime_restore_failure_test.go create mode 100644 internal/utils/copy.go create mode 100644 test/integration/docker/permissions_test.go diff --git a/apps/druid/adapters/cli/worker_glob_protection_test.go b/apps/druid/adapters/cli/worker_glob_protection_test.go new file mode 100644 index 0000000..67c779b --- /dev/null +++ b/apps/druid/adapters/cli/worker_glob_protection_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWorkerUpdatePreservesGlobProtectedData(t *testing.T) { + for _, nested := range []bool{false, true} { + for _, declaration := range []string{"installed", "candidate"} { + t.Run(declaration+map[bool]string{false: "/flat", true: "/nested"}[nested], func(t *testing.T) { + root, candidate := t.TempDir(), t.TempDir() + chunks := "chunks:\n - name: config\n path: '*.cfg'\n skip_update: true\n" + directory := "" + if nested { + directory = "servers/one" + chunks = "chunks:\n - name: servers\n path: 'servers/*'\n chunks:\n - name: config\n path: '*.cfg'\n skip_update: true\n" + } + installedYAML, candidateYAML := "name: example\n", "name: example\n" + if declaration == "installed" { + installedYAML += chunks + } else { + candidateYAML += chunks + } + mustWrite(t, filepath.Join(root, "scroll.yaml"), installedYAML) + mustWrite(t, filepath.Join(candidate, "scroll.yaml"), candidateYAML) + mustWrite(t, filepath.Join(root, "data", directory, "edited.cfg"), "user edits") + mustWrite(t, filepath.Join(root, "data", directory, "removed.cfg"), "user save") + mustWrite(t, filepath.Join(root, "data", directory, "obsolete.txt"), "old release") + mustWrite(t, filepath.Join(candidate, "data", directory, "edited.cfg"), "new default") + mustWrite(t, filepath.Join(candidate, "data", directory, "absent.cfg"), "must not appear") + mustWrite(t, filepath.Join(candidate, "data", directory, "release.txt"), "new release") + if err := pullWorkerUpdate(root, candidate, nil, nil); err != nil { + t.Fatal(err) + } + assertFile(t, filepath.Join(root, "data", directory, "edited.cfg"), "user edits") + assertFile(t, filepath.Join(root, "data", directory, "removed.cfg"), "user save") + assertFile(t, filepath.Join(root, "data", directory, "release.txt"), "new release") + for _, name := range []string{"absent.cfg", "obsolete.txt"} { + if _, err := os.Stat(filepath.Join(root, "data", directory, name)); !os.IsNotExist(err) { + t.Fatalf("%s should be absent: %v", name, err) + } + } + assertFile(t, filepath.Join(root, "scroll.yaml"), candidateYAML) + }) + } + } +} diff --git a/apps/druid/adapters/cli/worker_ownership.go b/apps/druid/adapters/cli/worker_ownership.go new file mode 100644 index 0000000..0f9e30f --- /dev/null +++ b/apps/druid/adapters/cli/worker_ownership.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/highcard-dev/daemon/internal/utils" +) + +type workerDataOwner struct{ uid, gid int } + +func ownershipID(value string) (int, error) { + id, err := strconv.ParseUint(value, 10, 32) + if err != nil || id == 1<<32-1 { + return 0, fmt.Errorf("invalid ownership ID %q", value) + } + return int(id), nil +} + +// Match Docker user[:group] identity inside the worker image, including named +// users. Numeric users absent from passwd default to group 0, as Docker does. +func resolveWorkerDataOwner(value string) (*workerDataOwner, error) { + if value == "" { + return nil, nil + } + parts := strings.Split(value, ":") + if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && parts[1] == "" { + return nil, fmt.Errorf("invalid new-data-owner %q", value) + } + owner := &workerDataOwner{} + uid, numericErr := ownershipID(parts[0]) + var account *user.User + var err error + if numericErr == nil { + owner.uid = uid + account, _ = user.LookupId(parts[0]) + } else { + account, err = user.Lookup(parts[0]) + if err != nil { + return nil, fmt.Errorf("resolve new-data-owner: %w", err) + } + owner.uid, err = ownershipID(account.Uid) + if err != nil { + return nil, err + } + } + if account != nil { + owner.gid, err = ownershipID(account.Gid) + if err != nil { + return nil, err + } + } + if len(parts) == 2 { + owner.gid, err = ownershipID(parts[1]) + if err != nil { + group, lookupErr := user.LookupGroup(parts[1]) + if lookupErr != nil { + return nil, fmt.Errorf("resolve new-data-owner group: %w", lookupErr) + } + owner.gid, err = ownershipID(group.Gid) + if err != nil { + return nil, err + } + } + } + return owner, nil +} + +func applyWorkerDataOwner(root string, owner *workerDataOwner) error { + if owner == nil { + return nil + } + return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + // WalkDir and Lchown both avoid following symlinks outside the stage. + return utils.SetPathOwner(path, owner.uid, owner.gid) + }) +} diff --git a/apps/druid/adapters/cli/worker_ownership_test.go b/apps/druid/adapters/cli/worker_ownership_test.go new file mode 100644 index 0000000..4318792 --- /dev/null +++ b/apps/druid/adapters/cli/worker_ownership_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/highcard-dev/daemon/internal/utils" +) + +func TestWorkerOwnerResolution(t *testing.T) { + owner, err := resolveWorkerDataOwner("1234:2345") + if err != nil || owner.uid != 1234 || owner.gid != 2345 { + t.Fatalf("numeric owner: %+v %v", owner, err) + } + current, err := user.Current() + if err != nil { + t.Fatal(err) + } + owner, err = resolveWorkerDataOwner(current.Username) + if err != nil || owner.uid != os.Getuid() || owner.gid != os.Getgid() { + t.Fatalf("named owner: %+v %v", owner, err) + } + for _, invalid := range []string{"-1:0", ":0", "1000:", "1:2:3", "4294967295:0", "0:4294967295"} { + if _, err := resolveWorkerDataOwner(invalid); err == nil { + t.Errorf("accepted invalid owner %q", invalid) + } + } +} + +func TestWorkerOwnershipDoesNotFollowSymlinks(t *testing.T) { + root := t.TempDir() + if err := os.Symlink(filepath.Join(t.TempDir(), "absent"), filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + if err := applyWorkerDataOwner(root, &workerDataOwner{os.Getuid(), os.Getgid()}); err != nil { + t.Fatal(err) + } + // A dangling target makes any accidental target dereference fail. + target := filepath.Join(t.TempDir(), "copy") + if err := utils.CopyPath(root, target, true); err != nil { + t.Fatal(err) + } + if _, err := os.Readlink(filepath.Join(target, "link")); err != nil { + t.Fatal(err) + } +} diff --git a/apps/druid/adapters/cli/worker_permissions_test.go b/apps/druid/adapters/cli/worker_permissions_test.go new file mode 100644 index 0000000..4f1f82a --- /dev/null +++ b/apps/druid/adapters/cli/worker_permissions_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/highcard-dev/daemon/internal/utils" +) + +func TestWorkerUpdatePreservesProtectedPermissions(t *testing.T) { + root, candidate := t.TempDir(), t.TempDir() + yaml := "name: example\nchunks:\n - name: config\n path: config\n skip_update: true\n" + mustWrite(t, filepath.Join(root, "scroll.yaml"), yaml) + mustWrite(t, filepath.Join(candidate, "scroll.yaml"), yaml) + mustWrite(t, filepath.Join(root, "data/config/settings.cfg"), "user settings") + mustWrite(t, filepath.Join(candidate, "data/config/settings.cfg"), "new defaults") + want := map[string]os.FileMode{"data/config": 0770, "data/config/settings.cfg": 0660} + for path, mode := range want { + if err := os.Chmod(filepath.Join(root, path), mode); err != nil { + t.Fatal(err) + } + } + if err := pullWorkerUpdate(root, candidate, nil, nil); err != nil { + t.Fatal(err) + } + assertFile(t, filepath.Join(root, "data/config/settings.cfg"), "user settings") + for path, mode := range want { + info, err := os.Stat(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Errorf("protected %s mode=%#o, want %#o", path, info.Mode().Perm(), mode) + } + } +} + +func TestProtectedDataCopyPreservesReadOnlyDirectoryPermissions(t *testing.T) { + source, target := t.TempDir(), t.TempDir() + mustWrite(t, filepath.Join(source, "config", "settings"), "user settings") + if err := os.Chmod(filepath.Join(source, "config"), 0550); err != nil { + t.Fatal(err) + } + defer os.Chmod(filepath.Join(source, "config"), 0700) + defer os.Chmod(filepath.Join(target, "config"), 0700) + if err := utils.CopyPath(source, target, true); err != nil { + t.Fatal(err) + } + assertFile(t, filepath.Join(target, "config", "settings"), "user settings") + info, err := os.Stat(filepath.Join(target, "config")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0550 { + t.Fatalf("directory mode=%#o, want 0550", info.Mode().Perm()) + } +} diff --git a/apps/druid/adapters/cli/worker_pull.go b/apps/druid/adapters/cli/worker_pull.go index 20b5695..579df26 100644 --- a/apps/druid/adapters/cli/worker_pull.go +++ b/apps/druid/adapters/cli/worker_pull.go @@ -16,6 +16,7 @@ import ( "github.com/highcard-dev/daemon/internal/core/ports" coreservices "github.com/highcard-dev/daemon/internal/core/services" "github.com/highcard-dev/daemon/internal/core/services/registry" + "github.com/highcard-dev/daemon/internal/utils" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -52,6 +53,7 @@ func init() { WorkerPullCommand.Flags().StringVar(&workerPullAction.MountPath, "root", "/scroll", "Mounted runtime root path") WorkerPullCommand.Flags().StringVar(&workerPullAction.CallbackURL, "callback-url", "", "Daemon worker callback URL") WorkerPullCommand.Flags().StringVar(&workerPullAction.TokenFile, "callback-token-file", "", "Projected ServiceAccount token file for callbacks") + WorkerPullCommand.Flags().StringVar(&workerPullAction.NewDataOwner, "new-data-owner", "", "Default user[:group] for new materialized content; protected paths keep their owners") WorkerPullCommand.Flags().BoolVar(&workerPullAction.PreserveReleaseManifest, "preserve-release-manifest", false, "Restore the release manifest.json carried by a backup") WorkerPullCommand.Flags().StringVar(&workerPullMode, "mode", string(ports.RuntimeWorkerModeCreate), "Worker mode: create, update, restore, or inspect") WorkerPullCommand.MarkFlagRequired("artifact") @@ -71,6 +73,11 @@ func runWorkerPull(action ports.RuntimeWorkerAction) ports.RuntimeWorkerResult { if action.Mode == ports.RuntimeWorkerModeInspect { return inspectInstalledRelease(root) } + owner, err := resolveWorkerDataOwner(action.NewDataOwner) + if err != nil { + result.Error = err.Error() + return result + } oci := registry.NewOciClient(loadWorkerRegistryStore()) digest, err := oci.ResolveDigest(action.Artifact) if err == nil { @@ -78,7 +85,7 @@ func runWorkerPull(action ports.RuntimeWorkerAction) ports.RuntimeWorkerResult { } switch action.Mode { case ports.RuntimeWorkerModeUpdate: - err = pullWorkerUpdate(root, action.Artifact, oci) + err = pullWorkerUpdate(root, action.Artifact, oci, owner) case ports.RuntimeWorkerModeRestore: err = pullWorkerRestore(root, action.Artifact, oci) default: @@ -179,7 +186,7 @@ func pullWorkerCreate(root string, artifact string, oci ports.OciRegistryInterfa return oci.PullSelective(root, artifact, true, nil) } -func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterface) error { +func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterface, owner *workerDataOwner) error { tmp, err := os.MkdirTemp("", "druid-worker-update-*") if err != nil { return err @@ -197,7 +204,6 @@ func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterfa return err } skipData := map[string]bool{} - collectSkipUpdatePaths(skipData, "", scroll.Chunks) installedYAML, err := os.ReadFile(filepath.Join(root, "scroll.yaml")) if err != nil { return fmt.Errorf("read installed update protection: %w", err) @@ -208,7 +214,15 @@ func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterfa } // Honor both sides of this transition, including protected chunks removed // by the candidate. Do not rewrite the candidate's immutable Scroll metadata. - collectSkipUpdatePaths(skipData, "", installed.Chunks) + // Expand both declarations against both trees: preserve removed matches and + // preserve absence when a candidate introduces a protected matching path. + for _, base := range []string{root, tmp} { + for _, chunks := range [][]*domain.Chunks{installed.Chunks, scroll.Chunks} { + if err := collectSkipUpdatePaths(skipData, filepath.Join(base, domain.RuntimeDataDir), "", chunks); err != nil { + return err + } + } + } if err := os.MkdirAll(root, 0755); err != nil { return err } @@ -220,6 +234,9 @@ func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterfa if err := copyPath(tmp, stage); err != nil { return err } + if err := applyWorkerDataOwner(stage, owner); err != nil { + return err + } if err := preserveSkippedUpdateData(root, stage, skipData); err != nil { return err } @@ -254,7 +271,7 @@ func preserveSkippedUpdateData(root string, stage string, skipData map[string]bo } else if err != nil { return err } - if err := copyPath(source, target); err != nil { + if err := utils.CopyPath(source, target, true); err != nil { return err } } @@ -385,20 +402,29 @@ func replaceRestoredRootWithRename(root string, stage string, rename func(string return nil } -func collectSkipUpdatePaths(out map[string]bool, parent string, chunks []*domain.Chunks) { +func collectSkipUpdatePaths(out map[string]bool, dataDir string, parent string, chunks []*domain.Chunks) error { for _, chunk := range chunks { if chunk == nil { continue } - chunkPath := filepath.ToSlash(filepath.Clean(filepath.Join(parent, filepath.FromSlash(chunk.Path)))) - if chunkPath == "." { - chunkPath = "" + paths := []string{filepath.ToSlash(filepath.Clean(parent))} + if chunk.Path != "." { + var err error + paths, err = utils.ExpandChunkPaths(dataDir, parent, chunk.Path) + if err != nil { + return fmt.Errorf("resolve skip_update chunk %q: %w", chunk.Name, err) + } } - if chunk.SkipUpdate { - out[chunkPath] = true + for _, chunkPath := range paths { + if chunk.SkipUpdate { + out[chunkPath] = true + } + if err := collectSkipUpdatePaths(out, dataDir, chunkPath, chunk.Chunks); err != nil { + return err + } } - collectSkipUpdatePaths(out, chunkPath, chunk.Chunks) } + return nil } func copyPath(src string, dst string) error { diff --git a/apps/druid/adapters/cli/worker_test.go b/apps/druid/adapters/cli/worker_test.go index cec8ff3..fe1b089 100644 --- a/apps/druid/adapters/cli/worker_test.go +++ b/apps/druid/adapters/cli/worker_test.go @@ -143,7 +143,7 @@ func TestWorkerUpdatePreservesRemovedProtectedChunk(t *testing.T) { mustWrite(t, filepath.Join(root, "data", "world", "save.dat"), "user world") mustWrite(t, filepath.Join(root, "data", "obsolete.txt"), "old release") mustWrite(t, filepath.Join(candidate, "scroll.yaml"), "name: example\n") - if err := pullWorkerUpdate(root, candidate, nil); err != nil { + if err := pullWorkerUpdate(root, candidate, nil, nil); err != nil { t.Fatal(err) } assertFile(t, filepath.Join(root, "data", "world", "save.dat"), "user world") @@ -278,7 +278,9 @@ func runWorkerPullForSkipPathTest(t *testing.T, root string) map[string]bool { t.Fatal(err) } result := map[string]bool{} - collectSkipUpdatePaths(result, "", scroll.Chunks) + if err := collectSkipUpdatePaths(result, filepath.Join(root, domain.RuntimeDataDir), "", scroll.Chunks); err != nil { + t.Fatal(err) + } return result } diff --git a/apps/druid/core/services/runtime_access.go b/apps/druid/core/services/runtime_access.go index 5503735..9ccb55f 100644 --- a/apps/druid/core/services/runtime_access.go +++ b/apps/druid/core/services/runtime_access.go @@ -2,8 +2,6 @@ package services import ( "context" - "fmt" - "strings" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" @@ -87,12 +85,6 @@ func (s *RuntimeSupervisor) Backup(id string, artifact string, registryCredentia return nil, err } if err := session.Backup(context.Background(), artifact, registryCredentials); err != nil { - if wasRunning { - if _, restartErr := s.startScroll(id); restartErr != nil { - return nil, fmt.Errorf("backup failed: %w; failed to restart prior runtime: %v", err, restartErr) - } - return nil, err - } session.markError(err) return nil, err } @@ -112,19 +104,12 @@ func (s *RuntimeSupervisor) Restore(id string, artifact string, restart bool, re session.mu.Lock() root := session.runtimeScroll.Root session.mu.Unlock() - wasRunning := sessionWasRunning(session) if err := session.StopRuntimeForMaintenance(); err != nil { session.markError(err) return nil, err } materialized, err := s.runPullWorker(context.Background(), s.runtimeBackend, ports.RuntimeWorkerModeRestore, id, artifact, root, registryCredentials, "") if err != nil { - if wasRunning && restoreFailureCanRestart(err) { - if _, restartErr := s.startScroll(id); restartErr != nil { - return nil, fmt.Errorf("restore failed: %w; failed to restart prior runtime: %v", err, restartErr) - } - return nil, err - } session.markError(err) return nil, err } @@ -144,10 +129,6 @@ func sessionWasRunning(session *RuntimeSession) bool { return session.runtimeScroll.Status == domain.RuntimeScrollStatusRunning } -func restoreFailureCanRestart(err error) bool { - return !strings.Contains(err.Error(), "restore root may be partial:") -} - func (s *RuntimeSupervisor) ScrollFile(id string) (*domain.File, error) { session, err := s.sessionFor(id) if err != nil { diff --git a/apps/druid/core/services/runtime_backup_failure_test.go b/apps/druid/core/services/runtime_backup_failure_test.go new file mode 100644 index 0000000..c7c1141 --- /dev/null +++ b/apps/druid/core/services/runtime_backup_failure_test.go @@ -0,0 +1,36 @@ +package services + +import ( + "errors" + "strings" + "testing" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" +) + +func TestBackupUnknownFailureDoesNotRestart(t *testing.T) { + store := newTestStateStore(t) + runtime := &domain.RuntimeScroll{ + ID: "backup-unknown", Artifact: "registry.local/original:1", Root: "runtime://backup-unknown", + ScrollName: "original", ScrollYAML: strings.Replace(cachedScrollYAML("start"), "run: once", "run: persistent", 1), + Status: domain.RuntimeScrollStatusRunning, Procedures: domain.ProcedureStatusMap{}, + } + if err := store.CreateScroll(runtime); err != nil { + t.Fatal(err) + } + backend := &fakeWorkerBackend{backupErr: errors.New("backup worker connection lost"), + procedureStatusUpdates: []ports.ProcedureStatusUpdate{{Procedure: "start.0", Status: domain.ScrollLockStatusRunning}}} + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + if _, err := supervisor.Backup(runtime.ID, "registry.local/backups:1", nil); err == nil { + t.Fatal("expected backup error") + } + stored, err := store.GetScroll(runtime.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != domain.RuntimeScrollStatusError || stored.LastError == "" { + t.Fatalf("unconfirmed backup termination must remain stopped with an error; status=%s error=%q", stored.Status, stored.LastError) + } +} diff --git a/apps/druid/core/services/runtime_lifecycle.go b/apps/druid/core/services/runtime_lifecycle.go index 5b03664..e6870c3 100644 --- a/apps/druid/core/services/runtime_lifecycle.go +++ b/apps/druid/core/services/runtime_lifecycle.go @@ -67,7 +67,7 @@ func (s *RuntimeSupervisor) Stop(id string) (*domain.RuntimeScroll, error) { if err != nil { return nil, err } - if err := session.StopRuntimeForMaintenance(); err != nil { + if err := session.StopRuntime(); err != nil { session.markError(err) return nil, err } diff --git a/apps/druid/core/services/runtime_restore_failure_test.go b/apps/druid/core/services/runtime_restore_failure_test.go new file mode 100644 index 0000000..0e22d6b --- /dev/null +++ b/apps/druid/core/services/runtime_restore_failure_test.go @@ -0,0 +1,54 @@ +package services + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" +) + +func TestRestoreIndeterminateFailureDoesNotRestart(t *testing.T) { + for _, failure := range []string{"spawn", "worker exit", "lost callback", "error callback"} { + t.Run(failure, func(t *testing.T) { + store := newTestStateStore(t) + runtime := &domain.RuntimeScroll{ + ID: "restore-unknown", Artifact: "registry.local/original:1", Root: "runtime://restore-unknown", + ScrollName: "original", ScrollYAML: strings.Replace(cachedScrollYAML("start"), "run: once", "run: persistent", 1), + Status: domain.RuntimeScrollStatusRunning, Procedures: domain.ProcedureStatusMap{}, + } + if err := store.CreateScroll(runtime); err != nil { + t.Fatal(err) + } + callbacks := NewWorkerCallbackManager() + backend := &fakeWorkerBackend{procedureStatusUpdates: []ports.ProcedureStatusUpdate{{Procedure: "start.0", Status: domain.ScrollLockStatusRunning}}} + switch failure { + case "spawn": + backend.workerErr = errors.New("worker creation response lost") + case "worker exit": + done := make(chan error, 1) + done <- errors.New("worker killed during root replacement") + backend.workerDone = done + case "error callback": + backend.callbacks = callbacks + backend.workerResult = &ports.RuntimeWorkerResult{Error: "worker failed without recovery classification"} + } + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + supervisor.SetWorkerCallbacks(callbacks, "http://druid-cli:8083") + supervisor.SetWorkerTimeout(20 * time.Millisecond) + if _, err := supervisor.Restore(runtime.ID, "registry.local/backups:1", true, nil); err == nil { + t.Fatal("expected worker failure") + } + stored, err := store.GetScroll(runtime.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != domain.RuntimeScrollStatusError || stored.LastError == "" { + t.Fatalf("unknown restore outcome must remain stopped with an error, got status=%s error=%q", stored.Status, stored.LastError) + } + }) + } +} diff --git a/apps/druid/core/services/runtime_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index 16b7b06..29e5db3 100644 --- a/apps/druid/core/services/runtime_supervisor_test.go +++ b/apps/druid/core/services/runtime_supervisor_test.go @@ -1171,7 +1171,7 @@ func TestRuntimeSupervisorBackupStopsAndRestartsRunningScroll(t *testing.T) { } } -func TestRuntimeSupervisorBackupFailureRestartsPriorRunningScroll(t *testing.T) { +func TestRuntimeSupervisorBackupFailureKeepsPriorRunningScrollStopped(t *testing.T) { store := newTestStateStore(t) runtimeScroll := &domain.RuntimeScroll{ ID: "backup-recovery", @@ -1195,12 +1195,12 @@ func TestRuntimeSupervisorBackupFailureRestartsPriorRunningScroll(t *testing.T) if err != nil { t.Fatal(err) } - if recovered.Status != domain.RuntimeScrollStatusRunning { - t.Fatalf("recovered status = %s, want running", recovered.Status) + if recovered.Status != domain.RuntimeScrollStatusError { + t.Fatalf("failed backup status = %s, want error", recovered.Status) } } -func TestRuntimeSupervisorRestoreFailureRestartsPriorRunningScroll(t *testing.T) { +func TestRuntimeSupervisorRestoreFailureKeepsPriorRunningScrollStopped(t *testing.T) { store := newTestStateStore(t) runtimeScroll := &domain.RuntimeScroll{ ID: "restore-recovery", @@ -1226,8 +1226,8 @@ func TestRuntimeSupervisorRestoreFailureRestartsPriorRunningScroll(t *testing.T) if err != nil { t.Fatal(err) } - if recovered.Status != domain.RuntimeScrollStatusRunning { - t.Fatalf("recovered status = %s, want running", recovered.Status) + if recovered.Status != domain.RuntimeScrollStatusError { + t.Fatalf("failed restore status = %s, want error", recovered.Status) } } @@ -1326,52 +1326,6 @@ func TestRuntimeSupervisorSerializesBackupWithStartAndEnsure(t *testing.T) { } } -func TestRuntimeSupervisorStopKeepsRuntimeQueueQuiescent(t *testing.T) { - store := newTestStateStore(t) - runtimeScroll := &domain.RuntimeScroll{ - ID: "stop-quiescent", - Artifact: "registry.local/lab:1.0", - Root: "runtime://stop-quiescent", - ScrollName: "stop-quiescent", - ScrollYAML: updatedScrollYAML("stop-quiescent"), - Status: domain.RuntimeScrollStatusRunning, - Procedures: domain.ProcedureStatusMap{}, - } - if err := store.CreateScroll(runtimeScroll); err != nil { - t.Fatal(err) - } - var runs atomic.Int32 - backend := &fakeWorkerBackend{runCommand: func(command ports.RuntimeCommand) (*int, error) { - runs.Add(1) - return nil, errors.New("run should not persist in test") - }} - supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) - session, err := supervisor.sessionFor(runtimeScroll.ID) - if err != nil { - t.Fatal(err) - } - if err := session.AutoStartServe(); err != nil { - t.Fatal(err) - } - deadline := time.After(time.Second) - for runs.Load() == 0 { - select { - case <-deadline: - t.Fatal("runtime queue did not start") - default: - time.Sleep(10 * time.Millisecond) - } - } - if _, err := supervisor.Stop(runtimeScroll.ID); err != nil { - t.Fatal(err) - } - count := runs.Load() - time.Sleep(100 * time.Millisecond) - if got := runs.Load(); got != count { - t.Fatalf("runtime queue restarted after stop: runs=%d, want %d", got, count) - } -} - func TestRuntimeSupervisorStartResumesMaintenanceStoppedQueue(t *testing.T) { store := newTestStateStore(t) runtimeScroll := &domain.RuntimeScroll{ @@ -1657,6 +1611,7 @@ type fakeWorkerBackend struct { digest string workerErr error workerDone <-chan error + workerResult *ports.RuntimeWorkerResult action ports.RuntimeWorkerAction stopRoot string deleteRoot string @@ -1784,10 +1739,14 @@ func (f *fakeWorkerBackend) SpawnPullWorker(ctx context.Context, action ports.Ru if f.callbacks == nil { return done, nil } - err := f.callbacks.Complete(action.RuntimeID, ports.RuntimeWorkerResult{ + result := ports.RuntimeWorkerResult{ ScrollYAML: f.scrollYAML, ArtifactDigest: f.digest, - }) + } + if f.workerResult != nil { + result = *f.workerResult + } + err := f.callbacks.Complete(action.RuntimeID, result) return done, err } diff --git a/docs/runtime-lifecycle-verification.md b/docs/runtime-lifecycle-verification.md index 4a9f879..4251b31 100644 --- a/docs/runtime-lifecycle-verification.md +++ b/docs/runtime-lifecycle-verification.md @@ -1,43 +1,72 @@ # Runtime lifecycle acceptance -Verified locally on 2026-09-28. This is runtime acceptance, not complete product acceptance. - -## Contract implemented - -- Updates require an explicitly accepted `repository@sha256:<64 hex>` reference. Empty references and tags fail before stopping the workload. -- Reconciliation no longer changes an installed release. Explicit updates and restores own those transitions. -- The installed release endpoint reads the actual volume's `manifest.json` and canonical Scroll name through a read-only worker. It does not resolve the deployment tag or add another persisted baseline. -- Callers can send `expected_installed_digest` with an accepted update. The runtime checks the actual installed descriptor under its maintenance lock and rejects a stale approval with HTTP 409 before stopping or changing the workload. -- The owner-authenticated public listener exposes installed-release reads and explicit updates, using the same runtime handlers as management. Tests reject missing and cross-owner authorization before either operation. -- Updates stage the whole candidate, preserve protected paths, and remove obsolete unprotected files. Both installed and candidate `skip_update` declarations protect the current transition, including nested declarations removed by the candidate. -- Protection longevity is unresolved: if a release removes a protected declaration, this transition retains its data, but indefinite retention across later releases is not guaranteed. No hidden persisted protection list or finalized-metadata rewrite was introduced. -- Failed rollback retains recovery files and keeps the workload stopped. Recovery locations are included in the error. -- Backup mode snapshots all runtime data, including files outside explicit release chunk selections, and preserves the installed descriptor bytes. -- Command admission shares the maintenance lock. Waiting for a command does not hold that lock, and waiters do not overwrite state after a release transition. -- Protected-path copying preserves symlinks without dereferencing them and rejects paths traversing symlink parents. -- CLI authored pushes honor explicit `SOURCE_DATE_EPOCH` for the OCI creation annotation. Without it ORAS stamps current time; identical CI rebuilds then produce different digests. Normal pushes retain existing timestamp behavior. Invalid explicit timestamps are rejected. -- Root file layers are sorted before packing; Go map iteration must not change an identical release's manifest digest. -- `druid-scroll-validator` exposes the existing runtime semantic rules as a bounded, read-only stdin/JSON protocol for Core. It does not expand host environment variables, initialize CLI config, extract archives or run commands. `make build` and `make install` include this helper. - -## Evidence - -- Full `go test ./... -timeout=180s`: passed. -- Regressions were observed failing before fixes for removed protected chunks, rollback-file retention, omitted runtime-created backup files, command admission during maintenance, and symlink traversal. -- `TestKubernetesBackendCLIComplexLifecycle`, with rebuilt daemon/worker binaries and image, passed three times (100.42s, 103.78s, 104.95s). The final run includes command-admission, complete-backup, and symlink-hardening changes. -- Focused command-admission/maintenance tests passed with Go's race detector enabled. -- The installed-digest precondition passed focused runtime/HTTP/client tests and a further rebuilt-image Kubernetes lifecycle run (102.55s), including successful v2 acceptance, rejection of the stale original baseline with HTTP 409, unchanged v2 contents, and backup restoration. -- The Kubernetes test uses a unique namespace, PVC, registry, management ports, and daemon socket. It does not replace the shared daemon/operator or consume canonical ReservedPorts. -- Verified real workload start and command execution; stopped backup; installed-descriptor read; acceptance of v2 followed by moving the tag to v3; installation of v2; preservation of protected runtime data; restoration of v1, runtime-created data outside declared chunks, and byte-identical installed descriptor. -- Test harness failures were diagnosed separately: registry:2 needs an OCI Accept header, and explicit fixture chunks must include the version marker. -- Recovery-restart tests use persistent workload fixtures. The previous finite `true` command legitimately stopped immediately and was not evidence of a failed restart. -- Authenticated local publisher acceptance demonstrated the timestamp problem with identical layers and different creation times, then passed with identical finalized digests after rebuilding with a fixed source date (7.10s, including fixture cleanup). Focused CLI timestamp tests passed. -- A later run exposed a second reproducibility issue: `.meta` and `scroll.yaml` layers changed order. A local OCI regression failed in 0.08s with identical layer digests in different orders. Sorting root paths fixed it; three 32-push regression runs and a race-enabled run passed. -- After both reproducibility fixes, normal dedicated-account sign-in through the gateway, private import, identical rebuild/retry, shared publication and anonymous visibility passed three consecutive times (8.53s, 6.25s, 6.16s), including fixture cleanup. -- Validator negative cases cover empty procedures, missing images, invalid versions, missing descriptions, unsafe mounts, unknown expected ports, duplicate IDs, invalid signal procedures, legacy fields, malformed/oversized input and literal environment placeholders. Focused race tests and the full CLI Go suite pass. -- Core reuses this validator for create/import/promote and repository-wide publication. Its Core-only image builds the validator from pinned CLI commit `9490beb01fd32716ea00191f9b97d6e4d14da374`; the actual image passes offline semantic/fail-closed smoke tests, including execution in a read-only container with all capabilities dropped. The live local publisher passes again with validation enabled (8.29s). - -## Remaining product work - -- Server/UI check-and-apply wiring is implemented in the separate monorepo worktree but shared-stack integration, Team publication, and browser acceptance remain outside this runtime milestone. -- The shared browser fixture is blocked by all canonical local game ports being allocated. No existing deployment was retired. -- This changes the update API/CLI contract; callers must send the accepted immutable reference. Deploy the matching callers with this runtime revision. +## Current scope (2026-09-29, local only) + +This feature is evaluated against master `bc17ea075910c3390402e3323fec243975f5ee2b`. +Only Unified Scroll Lifecycle changes and problems introduced by those changes +belong in this diff. Pre-existing general bugs are deferred, not claimed fixed. + +The approved scope pruning is complete locally. Broad operation/attempt IDs, +durable admission/recovery stores, worker-completion/cleanup protocol changes, +snapshot ownership metadata, generic local materialization-copy changes, and +UI-package persistence repairs have been removed. Ordinary Stop/requeue and +platform-wide Docker host-gateway changes have also been excluded from the PR +delta. Late semantic validation, failed-stop Update retry, and queue-drain timeout +repairs remain deferred because those problems already exist on master. + +## Retained contract + +- Explicit updates require an accepted immutable digest, with an optional + expected-installed-digest precondition checked under the maintenance lock. +- Reconciliation does not silently update an installed release. The read-only + installed-release endpoint reads the volume's actual descriptor. +- Updates stage the candidate, remove obsolete unprotected files, and preserve + paths protected by either installed or candidate declarations. Globs expand + against both trees. Protected-copy modes, numeric owners and symlinks survive + the new staging path; Docker/Kubernetes update helpers do not recursively + overwrite those owners afterward. +- Removed protection declarations protect this transition, not indefinitely + across future releases. No hidden persisted protection list was introduced. +- Backups include runtime-created files and preserve the installed descriptor. + Successful maintenance can restart the workload. Failed backup/restore leaves + it stopped with an error; error text is not proof that restarting is safe. +- Command admission shares the maintenance lock, but waiting does not hold it. +- Explicit source timestamps and stable layer ordering support reproducible + Team publication. The semantic-validator binary supports Core lifecycle APIs. + +## Current verification + +After pruning: + +- `go test ./... -timeout=180s`: passes. +- Rebuilt-image `TestDockerUpdatePreservesProtectedOwner`: passes (15.77s), + including custom-owner workload access, symlinks, permission preservation, + new-data default ownership and failure before replacement when chown fails. +- Focused protected-copy and failure-restart tests: three race-enabled runs pass. +- Rebuilt-image `TestKubernetesBackendCLIComplexLifecycle`: passes (102.77s) + after pruning. Exercises a real workload in a disposable namespace, accepted + digest despite tag movement, stale-baseline rejection, protected data, complete + backup restoration and byte-identical installed descriptor. It does not run + the browser/Core/engine/operator chain. +- Earlier broader crash/recovery evidence does not apply to the narrowed code. + +## Remaining acceptance limits + +This is not a claim of full product merge readiness. Team workflow cutover, +full browser-to-workload acceptance and CI on the final local changes are still +outstanding. No shared services, production deployments, existing workloads, +remote branches or PRs were changed by this pruning pass. + +## Workspace recovery (2026-10-03) + +The temporary worktree and its temporary backup archives were lost. The retained +September 29 source/test state was reconstructed from successful session file +changes and complete file reads, on top of commit `a9f5dd9`. It now lives in +`druid-local/.worktrees/scroll-lifecycle/druid-cli` on the local branch +`recovery/scroll-lifecycle-20261003`. Reconstruction tooling and the recovered +schema are under `druid-local/.recovery/lifecycle-20261003`. + +The recovered full Go suite passes. Docker/Kubernetes results above are +historical September 29 evidence, not fresh acceptance of this recovered tree. +The temporary pre-pruning archives are unavailable; do not rely on their old +paths as backups. Deferred behavior remains excluded as described above. diff --git a/internal/core/ports/services_ports.go b/internal/core/ports/services_ports.go index a8efa86..24e6250 100644 --- a/internal/core/ports/services_ports.go +++ b/internal/core/ports/services_ports.go @@ -171,6 +171,7 @@ type RuntimeWorkerAction struct { MountPath string CallbackURL string TokenFile string + NewDataOwner string PreserveReleaseManifest bool RegistryCredentials []domain.RegistryCredential } diff --git a/internal/runtime/docker/backend_names_test.go b/internal/runtime/docker/backend_names_test.go index 5e155dc..086957e 100644 --- a/internal/runtime/docker/backend_names_test.go +++ b/internal/runtime/docker/backend_names_test.go @@ -2,6 +2,7 @@ package docker import ( "errors" + "runtime" "strings" "testing" @@ -150,6 +151,12 @@ func TestContainerSpecAddsHostGatewayExtraHost(t *testing.T) { if err != nil { t.Fatal(err) } + if runtime.GOOS != "linux" { + if len(hostConfig.ExtraHosts) != 0 { + t.Fatalf("extra hosts = %#v, want none on %s", hostConfig.ExtraHosts, runtime.GOOS) + } + return + } for _, extraHost := range hostConfig.ExtraHosts { if extraHost == dockerHostGatewayExtraHost { return diff --git a/internal/runtime/docker/names.go b/internal/runtime/docker/names.go index 7177146..7b8b8a2 100644 --- a/internal/runtime/docker/names.go +++ b/internal/runtime/docker/names.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "github.com/highcard-dev/daemon/internal/core/domain" @@ -28,7 +29,10 @@ const ( const dockerFailedProcedureRetention = 3 func dockerExtraHosts() []string { - return []string{dockerHostGatewayExtraHost} + if runtime.GOOS == "linux" { + return []string{dockerHostGatewayExtraHost} + } + return nil } func ContainerName(root string, commandName string) string { diff --git a/internal/runtime/docker/workers.go b/internal/runtime/docker/workers.go index 4b818b5..b21ecb2 100644 --- a/internal/runtime/docker/workers.go +++ b/internal/runtime/docker/workers.go @@ -113,7 +113,19 @@ func (b *Backend) SpawnPullWorker(ctx context.Context, action ports.RuntimeWorke if err := b.pullImage(ctx, b.config.WorkerImage); err != nil { return nil, err } - if action.Mode != ports.RuntimeWorkerModeInspect { + workerUser := "" + if action.Mode == ports.RuntimeWorkerModeUpdate { + // Staging protected data must retain its original ownership and modes. + // Do not chmod the installed tree before it has been copied. + image, _, err := b.client.ImageInspectWithRaw(ctx, b.config.WorkerImage) + if err != nil { + return nil, err + } + if image.Config != nil { + action.NewDataOwner = image.Config.User + } + workerUser = "0" + } else if action.Mode != ports.RuntimeWorkerModeInspect { if err := b.prepareWritableRoot(ctx, root); err != nil { return nil, err } @@ -153,6 +165,7 @@ func (b *Backend) SpawnPullWorker(ctx context.Context, action ports.RuntimeWorke Image: b.config.WorkerImage, Entrypoint: []string{"druid"}, Cmd: workerPullCommand(action, artifact), + User: workerUser, Env: dockerWorkerEnv([]string{ "DRUID_WORKER_TOKEN_FILE=" + action.TokenFile, "DRUID_RUNTIME_REGISTRY_CONFIG_JSON=" + string(registryConfig), @@ -207,5 +220,8 @@ func workerPullCommand(action ports.RuntimeWorkerAction, artifact string) []stri if action.PreserveReleaseManifest { command = append(command, "--preserve-release-manifest") } + if action.Mode == ports.RuntimeWorkerModeUpdate && action.NewDataOwner != "" { + command = append(command, "--new-data-owner", action.NewDataOwner) + } return command } diff --git a/internal/runtime/kubernetes/resources.go b/internal/runtime/kubernetes/resources.go index 2fcf36d..64845f7 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -55,7 +55,9 @@ if [ -n "${DRUID_RUNTIME_REGISTRY_CONFIG_JSON:-}" ]; then else druid "$@" fi -chown -R 1000:1000 "$DRUID_WORKER_ROOT"` +if [ "$DRUID_WORKER_MODE" != "update" ]; then + chown -R 1000:1000 "$DRUID_WORKER_ROOT" +fi` ) const ( @@ -88,6 +90,9 @@ func workerPullJobSpec(namespace string, jobName string, pvc string, image strin if action.PreserveReleaseManifest { command = append(command, "--preserve-release-manifest") } + if action.Mode == ports.RuntimeWorkerModeUpdate { + command = append(command, "--new-data-owner", "1000:1000") + } if action.Mode == ports.RuntimeWorkerModeInspect { // No credential shell or recursive ownership changes for read-only inspection. command = append([]string{"druid"}, command[4:]...) @@ -112,6 +117,7 @@ func workerPullJobSpec(namespace string, jobName string, pvc string, image strin container.Env = append(container.Env, corev1.EnvVar{Name: "DRUID_WORKER_TOKEN_FILE", Value: action.TokenFile}, corev1.EnvVar{Name: workerPullRootEnvName, Value: action.MountPath}, + corev1.EnvVar{Name: "DRUID_WORKER_MODE", Value: string(action.Mode)}, ) if registryConfigSecret != "" { container.Env = append(container.Env, corev1.EnvVar{ diff --git a/internal/runtime/kubernetes/resources_test.go b/internal/runtime/kubernetes/resources_test.go index 18e43c4..5cb3694 100644 --- a/internal/runtime/kubernetes/resources_test.go +++ b/internal/runtime/kubernetes/resources_test.go @@ -455,7 +455,7 @@ func TestWorkerPullJobSpecRunsDruidWorkerPull(t *testing.T) { assertFinishedJobTTL(t, job) container := job.Spec.Template.Spec.Containers[0] command := strings.Join(container.Command, " ") - for _, want := range []string{"druid --config /tmp/druid-registry.json", "worker pull", "--mode update", "--runtime-id deployment-123", "--callback-url", "--preserve-release-manifest", "chown -R 1000:1000"} { + for _, want := range []string{"druid --config /tmp/druid-registry.json", "worker pull", "--mode update", "--runtime-id deployment-123", "--callback-url", "--preserve-release-manifest", "--new-data-owner 1000:1000", `if [ "$DRUID_WORKER_MODE" != "update" ]; then`} { if !strings.Contains(command, want) { t.Fatalf("command = %#v, want %s", container.Command, want) } @@ -473,6 +473,9 @@ func TestWorkerPullJobSpecRunsDruidWorkerPull(t *testing.T) { if env[workerPullRootEnvName] != "/scroll" { t.Fatalf("%s = %q, want /scroll", workerPullRootEnvName, env[workerPullRootEnvName]) } + if env["DRUID_WORKER_MODE"] != "update" { + t.Fatalf("update must skip recursive ownership changes: %#v", container.Env) + } if container.SecurityContext == nil || container.SecurityContext.RunAsUser == nil || *container.SecurityContext.RunAsUser != 0 { t.Fatalf("worker pull must run as root to repair PVC ownership, securityContext = %#v", container.SecurityContext) } diff --git a/internal/utils/copy.go b/internal/utils/copy.go new file mode 100644 index 0000000..0af91f0 --- /dev/null +++ b/internal/utils/copy.go @@ -0,0 +1,102 @@ +package utils + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path/filepath" +) + +// CopyPath copies files, directories and symlinks without dereferencing links. +// Ordinary access bits are retained regardless of umask. preserveOwner additionally +// retains numeric UID/GID, failing if this process cannot set them. Callers must +// supply a trusted staging destination, not a live tree with symlink parents. +func CopyPath(src, dst string, preserveOwner bool) (err error) { + info, err := os.Lstat(src) + if err != nil { + return err + } + defer func() { + if err == nil && preserveOwner { + var header *tar.Header + header, err = tar.FileInfoHeader(info, "") + if err == nil { + err = SetPathOwner(dst, header.Uid, header.Gid) + } + } + }() + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + return os.Symlink(target, dst) + } + if info.IsDir() { + // Keep owner access while populating; finalize read-only directories last. + if err := os.MkdirAll(dst, 0700); err != nil { + return err + } + if err := os.Chmod(dst, info.Mode().Perm()|0700); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := CopyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), preserveOwner); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported copy source type: %s", info.Mode().Type()) + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer func() { + if closeErr := out.Close(); err == nil { + err = closeErr + } + }() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Chmod(info.Mode().Perm()) +} + +// SetPathOwner changes numeric ownership only when needed, never following a +// symlink. Source trees and external symlink targets must not be mutated. +func SetPathOwner(path string, uid, gid int) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + if uid == header.Uid && gid == header.Gid { + return nil + } + if err := os.Lchown(path, uid, gid); err != nil { + return fmt.Errorf("set materialized path owner: %w", err) + } + return nil +} diff --git a/internal/utils/fs.go b/internal/utils/fs.go index 994d09b..7184167 100644 --- a/internal/utils/fs.go +++ b/internal/utils/fs.go @@ -178,7 +178,7 @@ func expandChunkNode(dataDir string, parentPath string, chunk *domain.Chunks) ([ return nil, fmt.Errorf("chunk %q has empty path", chunk.Name) } - paths, err := expandChunkPaths(dataDir, parentPath, chunk.Path) + paths, err := ExpandChunkPaths(dataDir, parentPath, chunk.Path) if err != nil { return nil, fmt.Errorf("failed to expand chunk %q path %q: %w", chunk.Name, chunk.Path, err) } @@ -218,7 +218,9 @@ func expandChunkNode(dataDir string, parentPath string, chunk *domain.Chunks) ([ return expanded, nil } -func expandChunkPaths(dataDir string, parentPath string, chunkPath string) ([]string, error) { +// ExpandChunkPaths resolves nested chunk paths and globs using the same rules +// for release packaging and installed-data update protection. +func ExpandChunkPaths(dataDir string, parentPath string, chunkPath string) ([]string, error) { resolvedPath, err := resolveChunkPath(parentPath, chunkPath) if err != nil { return nil, err diff --git a/test/integration/docker/permissions_test.go b/test/integration/docker/permissions_test.go new file mode 100644 index 0000000..d361803 --- /dev/null +++ b/test/integration/docker/permissions_test.go @@ -0,0 +1,109 @@ +//go:build integration && docker + +package docker_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/highcard-dev/daemon/internal/core/ports" + dockerruntime "github.com/highcard-dev/daemon/internal/runtime/docker" + "github.com/highcard-dev/daemon/test/integration/internal/e2e" +) + +func TestDockerUpdatePreservesProtectedOwner(t *testing.T) { + e2e.RequireDocker(t) + name := fmt.Sprintf("druid-e2e-docker-volume-owner-%d", time.Now().UnixNano()) + baseImage := e2e.BuildDockerImage(t, "druid-cli-e2e:"+name) + image := baseImage + "-nonroot" + buildCtx, buildCancel := context.WithTimeout(context.Background(), time.Minute) + defer buildCancel() + build := exec.CommandContext(buildCtx, "docker", "build", "-t", image, "-") + build.Stdin = strings.NewReader("FROM " + baseImage + "\nUSER 1000:1000\n") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("non-root worker fixture: %v: %s", err, output) + } + t.Cleanup(func() { e2e.Run(t, "docker", "image", "rm", image) }) + e2e.Run(t, "docker", "volume", "create", name) + root := "docker-volume://" + name + t.Cleanup(func() { + // The only volume removed is this test's uniquely named fixture. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if output, err := exec.CommandContext(ctx, "docker", "volume", "rm", name).CombinedOutput(); err != nil { + t.Logf("fixture volume cleanup: %v: %s", err, output) + } + }) + yaml := "name: owner-fixture\nchunks:\n - name: config\n path: private.cfg\n skip_update: true\n - name: saves\n path: saves\n skip_update: true\n" + candidate := t.TempDir() + if err := os.WriteFile(filepath.Join(candidate, "scroll.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(candidate, "data"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(candidate, "data", "release.cfg"), []byte("new default"), 0600); err != nil { + t.Fatal(err) + } + e2e.Run(t, "docker", "run", "--rm", "--user", "0", "-v", name+":/scroll", "--entrypoint", "sh", image, "-c", "mkdir -p /scroll/data/saves; printf '%s' \"$1\" > /scroll/scroll.yaml; printf fixture > /scroll/data/private.cfg; chmod 600 /scroll/data/private.cfg; chown -R 1000:1000 /scroll; printf saved > /scroll/data/saves/save; chmod 700 /scroll/data/saves; chmod 600 /scroll/data/saves/save; ln -s /not-mounted /scroll/data/saves/link; chown -hR 1234:2345 /scroll/data/saves", "fixture", yaml) + backend, err := dockerruntime.NewWithConfig(dockerruntime.Config{WorkerImage: image}, ports.ProcedureStatusObserverFunc(func(ports.ProcedureStatusUpdate) {})) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + done, err := backend.SpawnPullWorker(ctx, ports.RuntimeWorkerAction{RuntimeID: name, RootRef: root, Artifact: candidate, Mode: ports.RuntimeWorkerModeUpdate}) + if err == nil { + err = <-done + } + if err != nil { + t.Fatal(err) + } + got := strings.TrimSpace(e2e.Run(t, "docker", "run", "--rm", "--user", "0", "-v", name+":/scroll:ro", "--entrypoint", "stat", image, "-c", "%a:%u:%g", "/scroll/data/private.cfg")) + if got != "600:1000:1000" { + t.Errorf("update changed protected metadata: %s", got) + } + // Check actual access as the original owner, not just metadata inspection. + content := e2e.Run(t, "docker", "run", "--rm", "--user", "1000:1000", "-v", name+":/scroll:ro", "--entrypoint", "cat", image, "/scroll/data/private.cfg") + if content != "fixture" { + t.Fatalf("protected content changed: %q", content) + } + for path, want := range map[string]string{"saves": "700:1234:2345", "saves/save": "600:1234:2345", "saves/link": "777:1234:2345", "release.cfg": "644:1000:1000"} { + got := strings.TrimSpace(e2e.Run(t, "docker", "run", "--rm", "--user", "0", "-v", name+":/scroll:ro", "--entrypoint", "stat", image, "-c", "%a:%u:%g", "/scroll/data/"+path)) + if got != want { + t.Errorf("%s metadata=%s, want %s", path, got, want) + } + } + if got := e2e.Run(t, "docker", "run", "--rm", "--user", "1234:2345", "-v", name+":/scroll", "--entrypoint", "sh", image, "-c", "cat /scroll/data/saves/save; printf writable > /scroll/data/saves/new"); got != "saved" { + t.Fatalf("custom-owner workload access: %q", got) + } + if got := e2e.Run(t, "docker", "run", "--rm", "--user", "1000:1000", "-v", name+":/scroll", "--entrypoint", "sh", image, "-c", "cat /scroll/data/release.cfg; printf writable > /scroll/data/new"); got != "new default" { + t.Fatalf("default-owner workload access: %q", got) + } + t.Run("owner-failure-keeps-installed-root", func(t *testing.T) { + if err := os.WriteFile(filepath.Join(candidate, "scroll.yaml"), []byte(strings.Replace(yaml, "owner-fixture", "rejected-fixture", 1)), 0644); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Deliberately remove chown capability only from this disposable worker. + // It must fail before swapping in a tree with incorrect protected owners. + out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--user", "0", "--cap-drop", "CHOWN", "-v", name+":/scroll", "-v", candidate+":/candidate:ro", "--entrypoint", "druid", image, "worker", "pull", "--mode", "update", "--root", "/scroll", "--runtime-id", name, "--artifact", "/candidate").CombinedOutput() + if err == nil || !strings.Contains(string(out), "set materialized path owner") { + t.Fatalf("expected ownership failure: %v %s", err, out) + } + got := e2e.Run(t, "docker", "run", "--rm", "--user", "1000:1000", "-v", name+":/scroll:ro", "--entrypoint", "cat", image, "/scroll/scroll.yaml") + if got != yaml { + t.Fatal("failed ownership preservation replaced installed YAML") + } + if got := e2e.Run(t, "docker", "run", "--rm", "--user", "1234:2345", "-v", name+":/scroll:ro", "--entrypoint", "cat", image, "/scroll/data/saves/new"); got != "writable" { + t.Fatal("failed ownership preservation changed installed data/access") + } + }) +}