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/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) + } + }) + } +} 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/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 cbfdcfb..4251b31 100644 --- a/docs/runtime-lifecycle-verification.md +++ b/docs/runtime-lifecycle-verification.md @@ -1,35 +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. - -## 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. - -## 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/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 { 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") + } + }) +}