Merge origin/main (29dab2d8) into settings-2-promotion-records
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 files changed,
+2636
−143
internal/host/agent.go+2 −6internal/host/bootstrap.go+53 −28internal/host/bootstrapfs_linux.go+165 −0internal/host/bootstrapfs_linux_test.go+180 −0internal/host/bootstrapfs_other.go+20 −0internal/host/registry_privilege_test.go+134 −0internal/host/registrychild_other.go+16 −0internal/host/registrychild_unix.go+84 −0internal/host/registrychild_unix_test.go+158 −0internal/host/registryowner.go+36 −69internal/host/registryowner_chown_other.go+11 −0internal/host/registryowner_chown_unix.go+85 −0internal/host/registryowner_chown_unix_test.go+53 −0internal/host/registryowner_descriptor_linux.go+16 −0internal/host/registryowner_descriptor_unix.go+16 −0internal/host/registryowner_test.go+8 −16internal/host/systemapply.go+1 −4internal/provision/plan9_b8_census_test.go+2 −2internal/registry/claimhold.go+5 −0internal/registry/claimrows.go+18 −0internal/registry/claimtimers.go+15 −0internal/registry/coldhandover.go+5 −0internal/registry/fleets.go+50 −0internal/registry/registry.go+329 −3internal/registry/remote.go+243 −0internal/registry/remote_dispatch.go+318 −0internal/registry/remote_test.go+177 −0internal/registry/timers.go+75 −0internal/release/adopt.go+6 −7internal/release/qualified.go+12 −0internal/release/qualified_test.go+8 −0internal/release/stage_stream_test.go+245 −0internal/release/versions.go+84 −8main.go+6 −0
CI passed — run details
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 8f1e5ceeb..55bab92ad 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -3991,12 +3991,8 @@ func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensu |
| if !*restartOnly { |
| ensureUnits() |
| } |
| - // openServiceRegistry, not registry.Open: this pass runs as root |
| - // (carlos-adopt/-stage/-restart/-config-sync have no User=), opening the |
| - // registry is what creates its -wal/-shm, and the helper hands them back |
| - // to the user the edge runs as as part of the open. That matters most for |
| - // -restart-only, which returns a dozen lines below — see registryowner.go |
| - // for why a converge on every pass is what it takes. |
| + // Root keeps SQLite in the service-user child for every adoption mode, |
| + // including the early return for restart-only. |
| if !claimsEnabled { |
| reg, err = openServiceRegistry(*regPath) |
| if err != nil { |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 2119f4351..4ed7efbf8 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -24,6 +24,7 @@ import ( |
| "os/exec" |
| "os/user" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| ) |
| @@ -466,13 +467,8 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| {filepath.Join(root, "etc", "sysusers.d"), 0o755}, |
| } |
| for _, d := range dirs { |
| - if err := os.MkdirAll(d.path, d.mode); err != nil { |
| - return fmt.Errorf("mkdir %s: %v", d.path, err) |
| - } |
| - // MkdirAll's mode is masked by umask, and a pre-existing dir keeps its |
| - // old mode; force it so re-runs converge (Eleven's staging lesson). |
| - if err := os.Chmod(d.path, d.mode); err != nil { |
| - return fmt.Errorf("chmod %s: %v", d.path, err) |
| + if err := bootstrapMakeDir(d.path, d.mode); err != nil { |
| + return fmt.Errorf("mkdir/chmod %s: %v", d.path, err) |
| } |
| } |
| @@ -485,7 +481,7 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| // file owns it, and an edge running as serviceUser cannot write a |
| // root-owned registry: that is discussion #8, three region boxes left |
| // unstartable on 2026-08-31. Creating it here puts it inside the |
| - // `chown -R /data` that privilegedPlan already runs, so it is |
| + // descriptor-based ownership walk that privilegedPlan runs, so it is |
| // serviceUser's before anything can race for it. |
| // |
| // An empty file is a valid empty SQLite database — the first opener |
| @@ -504,14 +500,11 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| // empty it. The script lives in the private platform-infrastructure repo; |
| // whoever widens what it copies has to exclude data/. |
| regPath := filepath.Join(dataDir, "registry.db") |
| - if _, err := os.Stat(regPath); os.IsNotExist(err) { |
| - f, err := os.OpenFile(regPath, os.O_CREATE|os.O_WRONLY, 0o640) |
| - if err != nil { |
| - return fmt.Errorf("create %s: %v", regPath, err) |
| - } |
| - if err := f.Close(); err != nil { |
| - return fmt.Errorf("create %s: %v", regPath, err) |
| - } |
| + created, err := bootstrapCreateRegistry(regPath) |
| + if err != nil { |
| + return fmt.Errorf("create %s: %v", regPath, err) |
| + } |
| + if created { |
| fmt.Fprintf(out, " created %s\n", regPath) |
| } |
| @@ -644,7 +637,7 @@ func unitNames() ([]string, error) { |
| } |
| // privilegedPlan is the exact non-systemctl privileged sequence a live |
| -// bootstrap runs — and the same sequence a staging run PRINTS instead of |
| +// bootstrap dispatches — and the same sequence a staging run PRINTS instead of |
| // running (systemctlPlan's discipline, extended to the user/group half when |
| // issue #103 added the consumers group): the two can never drift into |
| // describing different behaviour. |
| @@ -652,11 +645,9 @@ func unitNames() ([]string, error) { |
| // The sequence: service user, consumers group, ownership. Every chown |
| // converges TOWARD the target state, never through an intermediate that |
| // undoes a previous run's work: /run/carlos is recursively |
| -// serviceUser:socketsGroup — recursive on purpose, so a re-run (the |
| -// documented way to enroll another consumer) also converges the group on |
| -// every LIVE instance socket instead of resetting it to serviceUser's group |
| -// and stranding the already-enrolled consumers until each instance rebinds — |
| -// and apps/ (unit-tenant sockets, systemd-bound, no path consumers) is then |
| +// serviceUser:socketsGroup. Unsafe entries, including live sockets, are |
| +// logged and skipped; their creating units establish socket ownership. |
| +// The apps/ directory (unit-tenant sockets, no path consumers) is then |
| // put back to serviceUser:serviceUser. The chmod re-asserts the setgid bit |
| // the tmpfiles line promises; consumers enroll last, one usermod each |
| // (`-aG` is append — an existing member is a no-op, so re-runs converge). |
| @@ -682,8 +673,23 @@ func privilegedPlan(consumers []string) [][]string { |
| // guards that keep the raw commands idempotent: useradd and groupadd exit |
| // non-zero when their subject already exists, and "already provisioned" is |
| // bootstrap's normal re-run state, not an error. Everything else in the plan |
| -// is idempotent as-is. |
| +// is idempotent. Ownership and mode steps use descriptor helpers; the |
| +// command text is retained for staging output and is never sent to chown. |
| func runPrivileged(args []string) error { |
| + if len(args) == 4 && args[0] == "chown" && args[1] == "-R" { |
| + owner := strings.SplitN(args[2], ":", 2) |
| + if len(owner) != 2 { |
| + return fmt.Errorf("invalid ownership %q", args[2]) |
| + } |
| + uid, gid, err := bootstrapOwner(owner[0], owner[1]) |
| + if err != nil { |
| + return err |
| + } |
| + return bootstrapChownTree(args[3], uid, gid) |
| + } |
| + if len(args) == 3 && args[0] == "chmod" && args[1] == "2750" { |
| + return bootstrapMakeDir(args[2], 0o750|os.ModeSetgid) |
| + } |
| // The subject is derived from the command line itself (both commands |
| // take it as their final argument), never from a package constant — a |
| // guard keyed to a constant would silently skip any SECOND useradd or |
| @@ -705,14 +711,33 @@ func runPrivileged(args []string) error { |
| return nil |
| } |
| -// chownToService recursively gives a path to the service user (offcloud's |
| -// credentials file; the standard dirs go through privilegedPlan). |
| +func bootstrapOwner(owner, group string) (int, int, error) { |
| + u, err := user.Lookup(owner) |
| + if err != nil { |
| + return 0, 0, err |
| + } |
| + g, err := user.LookupGroup(group) |
| + if err != nil { |
| + return 0, 0, err |
| + } |
| + uid, err := strconv.Atoi(u.Uid) |
| + if err != nil { |
| + return 0, 0, err |
| + } |
| + gid, err := strconv.Atoi(g.Gid) |
| + return uid, gid, err |
| +} |
| + |
| func chownToService(path string) error { |
| - out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput() |
| + uid, gid, err := bootstrapOwner(serviceUser, serviceUser) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = ownRegistryFile(path, uid, gid) |
| if err != nil { |
| - return fmt.Errorf("chown %s: %v: %s", path, err, strings.TrimSpace(string(out))) |
| + log.Printf("ALERT: bootstrap: refusing ownership of %s: %v", path, err) |
| } |
| - return nil |
| + return err |
| } |
| func systemctl(args ...string) error { |
diff --git a/internal/host/bootstrapfs_linux.go b/internal/host/bootstrapfs_linux.go| new file mode 100644 |
| index 000000000..536952eac |
| --- /dev/null |
| +++ b/internal/host/bootstrapfs_linux.go |
| @@ -0,0 +1,165 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func bootstrapOpenDir(path string) (int, error) { |
| + return unix.Openat2(unix.AT_FDCWD, path, &unix.OpenHow{ |
| + Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW, |
| + Resolve: unix.RESOLVE_NO_SYMLINKS, |
| + }) |
| +} |
| + |
| +// bootstrapMakeDir anchors every component before creating or changing it. |
| +func bootstrapMakeDir(path string, mode os.FileMode) error { |
| + absolute, err := filepath.Abs(path) |
| + if err != nil { |
| + return err |
| + } |
| + fd, err := bootstrapOpenDir("/") |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { unix.Close(fd) }() |
| + parts := strings.Split(strings.TrimPrefix(absolute, "/"), "/") |
| + for _, name := range parts { |
| + if name == "" { |
| + continue |
| + } |
| + next, err := unix.Openat2(fd, name, &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS}) |
| + if errors.Is(err, unix.ENOENT) { |
| + if err = unix.Mkdirat(fd, name, uint32(mode.Perm())); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return err |
| + } |
| + next, err = unix.Openat2(fd, name, &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS}) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + unix.Close(fd) |
| + fd = next |
| + } |
| + bits := uint32(mode.Perm()) |
| + if mode&os.ModeSetgid != 0 { |
| + bits |= unix.S_ISGID |
| + } |
| + if mode&os.ModeSetuid != 0 { |
| + bits |= unix.S_ISUID |
| + } |
| + if mode&os.ModeSticky != 0 { |
| + bits |= unix.S_ISVTX |
| + } |
| + return unix.Fchmod(fd, bits) |
| +} |
| + |
| +func bootstrapCreateRegistry(path string) (bool, error) { |
| + parent, err := bootstrapOpenDir(filepath.Dir(path)) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer unix.Close(parent) |
| + fd, err := unix.Openat2(parent, filepath.Base(path), &unix.OpenHow{ |
| + Flags: unix.O_CREAT | unix.O_EXCL | unix.O_WRONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC, |
| + Mode: 0o640, |
| + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, |
| + }) |
| + if errors.Is(err, unix.EEXIST) { |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(parent, filepath.Base(path), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return false, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + log.Printf("ALERT: bootstrap: refusing unsafe registry file %s", path) |
| + } |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + defer unix.Close(fd) |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return false, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + return false, fmt.Errorf("unsafe registry file %s", path) |
| + } |
| + return true, nil |
| +} |
| + |
| +func bootstrapChownTree(path string, uid, gid int) error { |
| + fd, err := bootstrapOpenDir(path) |
| + if err != nil { |
| + return err |
| + } |
| + return bootstrapChownDir(os.NewFile(uintptr(fd), path), uid, gid) |
| +} |
| + |
| +func bootstrapChownDir(dir *os.File, uid, gid int) error { |
| + defer dir.Close() |
| + // The name '.' is resolved against the opened directory, not its mutable parent. |
| + if err := unix.Fchownat(int(dir.Fd()), ".", uid, gid, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return err |
| + } |
| + entries, err := dir.ReadDir(-1) |
| + if err != nil { |
| + return err |
| + } |
| + for _, entry := range entries { |
| + path := filepath.Join(dir.Name(), entry.Name()) |
| + // NO_XDEV refuses bind mounts as well as different-device mounts. |
| + fd, err := unix.Openat2(int(dir.Fd()), entry.Name(), &unix.OpenHow{ |
| + Flags: unix.O_PATH | unix.O_NOFOLLOW | unix.O_CLOEXEC, |
| + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, |
| + }) |
| + if err != nil { |
| + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENXIO) { |
| + log.Printf("ALERT: bootstrap: skipping unsafe ownership path %s: %v", path, err) |
| + continue |
| + } |
| + return err |
| + } |
| + f := os.NewFile(uintptr(fd), path) |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + switch st.Mode & unix.S_IFMT { |
| + case unix.S_IFDIR: |
| + child, err := unix.Openat2(fd, ".", &unix.OpenHow{Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW, Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV}) |
| + f.Close() |
| + if err != nil { |
| + return err |
| + } |
| + if err := bootstrapChownDir(os.NewFile(uintptr(child), path), uid, gid); err != nil { |
| + return err |
| + } |
| + case unix.S_IFREG: |
| + if st.Nlink != 1 { |
| + log.Printf("ALERT: bootstrap: skipping unsafe ownership path %s: multiple links", path) |
| + f.Close() |
| + continue |
| + } |
| + err := unix.Fchownat(fd, "", uid, gid, unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW) |
| + f.Close() |
| + if err != nil { |
| + return err |
| + } |
| + default: |
| + log.Printf("ALERT: bootstrap: skipping unsafe ownership path %s: special file", path) |
| + f.Close() |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/bootstrapfs_linux_test.go b/internal/host/bootstrapfs_linux_test.go| new file mode 100644 |
| index 000000000..54bcb6a87 |
| --- /dev/null |
| +++ b/internal/host/bootstrapfs_linux_test.go |
| @@ -0,0 +1,180 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "log" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestBootstrapRegistryPlantedLinks(t *testing.T) { |
| + for _, dangling := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "existing", true: "dangling"}[dangling], func(t *testing.T) { |
| + root := t.TempDir() |
| + victim := filepath.Join(t.TempDir(), "victim") |
| + if !dangling { |
| + if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Mkdir(filepath.Join(root, "data"), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(victim, filepath.Join(root, "data", "registry.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := bootstrap(root, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := os.ReadFile(victim) |
| + if dangling { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("dangling target created: %v", err) |
| + } |
| + } else if err != nil || string(got) != "preserve" { |
| + t.Fatalf("victim changed: %q %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestBootstrapRefusesWritableParentSymlink(t *testing.T) { |
| + for _, parent := range []string{"data", "data/.instances", "run/carlos"} { |
| + t.Run(strings.ReplaceAll(parent, "/", "_"), func(t *testing.T) { |
| + root, victim := t.TempDir(), t.TempDir() |
| + if err := os.Chmod(victim, 0o711); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(root, parent) |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(victim, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := bootstrap(root, nil); err == nil { |
| + t.Fatal("accepted parent symlink") |
| + } |
| + st, err := os.Stat(victim) |
| + if err != nil || st.Mode().Perm() != 0o711 { |
| + t.Fatalf("victim mode changed: %v %v", st, err) |
| + } |
| + entries, err := os.ReadDir(victim) |
| + if err != nil || len(entries) != 0 { |
| + t.Fatalf("victim populated: %v %v", entries, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestBootstrapOwnershipWalkSkipsUnsafeEntries(t *testing.T) { |
| + dir := t.TempDir() |
| + victim := filepath.Join(t.TempDir(), "victim") |
| + if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(victim, 0o750|os.ModeSetuid|os.ModeSetgid); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := os.Stat(victim) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"registry.db-wal", "registry.db-shm"} { |
| + if err := os.Link(victim, filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Symlink(victim, filepath.Join(dir, "registry.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mkfifo(filepath.Join(dir, "fifo"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + socket, err := net.Listen("unix", filepath.Join(dir, "socket")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer socket.Close() |
| + if err := os.Mkdir(filepath.Join(dir, "nested"), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + plain := filepath.Join(dir, "nested", "plain") |
| + if err := os.WriteFile(plain, []byte("ordinary"), 0o640); err != nil { |
| + t.Fatal(err) |
| + } |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + if err := bootstrapChownTree(dir, os.Getuid(), os.Getgid()); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.Stat(victim) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.Mode() != before.Mode() { |
| + t.Fatalf("hard-linked victim ownership operation cleared mode: %v -> %v", before.Mode(), after.Mode()) |
| + } |
| + got, err := os.ReadFile(victim) |
| + if err != nil || string(got) != "preserve" { |
| + t.Fatalf("victim changed: %q %v", got, err) |
| + } |
| + for _, name := range []string{"registry.db", "registry.db-wal", "registry.db-shm", "fifo", "socket"} { |
| + if !strings.Contains(logs.String(), filepath.Join(dir, name)) { |
| + t.Errorf("missing alert for %s: %s", name, logs.String()) |
| + } |
| + } |
| + got, err = os.ReadFile(plain) |
| + if err != nil || string(got) != "ordinary" { |
| + t.Fatalf("plain file changed: %q %v", got, err) |
| + } |
| +} |
| + |
| +func TestBootstrapOwnershipWalkDoesNotCrossBindMount(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("bind mount test requires root with CAP_SYS_ADMIN") |
| + } |
| + dir, source := t.TempDir(), t.TempDir() |
| + target := filepath.Join(dir, "bind") |
| + if err := os.Mkdir(target, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(source, 0o750|os.ModeSetgid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mount(source, target, "", unix.MS_BIND, ""); err != nil { |
| + if errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) { |
| + t.Skipf("bind mount test requires CAP_SYS_ADMIN: %v", err) |
| + } |
| + t.Fatal(err) |
| + } |
| + defer unix.Unmount(target, 0) |
| + before, err := os.Stat(source) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := bootstrapChownTree(dir, 65534, 65534); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.Stat(source) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var sourceStat unix.Stat_t |
| + if err := unix.Stat(source, &sourceStat); err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.Mode() != after.Mode() || sourceStat.Uid != uint32(os.Getuid()) { |
| + t.Fatalf("crossed bind mount: mode %v -> %v, uid %d", before.Mode(), after.Mode(), sourceStat.Uid) |
| + } |
| +} |
diff --git a/internal/host/bootstrapfs_other.go b/internal/host/bootstrapfs_other.go| new file mode 100644 |
| index 000000000..816cb91f8 |
| --- /dev/null |
| +++ b/internal/host/bootstrapfs_other.go |
| @@ -0,0 +1,20 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| +) |
| + |
| +func bootstrapMakeDir(path string, mode os.FileMode) error { |
| + return fmt.Errorf("bootstrap filesystem operations require Linux") |
| +} |
| + |
| +func bootstrapCreateRegistry(path string) (bool, error) { |
| + return false, fmt.Errorf("bootstrap filesystem operations require Linux") |
| +} |
| + |
| +func bootstrapChownTree(path string, uid, gid int) error { |
| + return fmt.Errorf("bootstrap filesystem operations require Linux") |
| +} |
diff --git a/internal/host/registry_privilege_test.go b/internal/host/registry_privilege_test.go| new file mode 100644 |
| index 000000000..a32453747 |
| --- /dev/null |
| +++ b/internal/host/registry_privilege_test.go |
| @@ -0,0 +1,134 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "os/user" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRootRegistryRefusesPlantedFilesBeforeSQLite(t *testing.T) { |
| + u, err := user.Current() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if u.Uid == "0" { |
| + t.Skip("this test simulates root repair with the current non-root service identity; real root credentials have separate coverage") |
| + } |
| + for _, name := range []string{"symlink", "dangling-symlink", "hardlink-wal", "hardlink-shm", "symlink-parent"} { |
| + t.Run(name, func(t *testing.T) { |
| + dir := t.TempDir() |
| + victim := filepath.Join(dir, "victim") |
| + data := filepath.Join(dir, "data") |
| + if err := os.Mkdir(data, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(data, "registry.db") |
| + if name != "dangling-symlink" { |
| + if err := os.WriteFile(victim, nil, 0640); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + switch name { |
| + case "symlink", "dangling-symlink": |
| + err = os.Symlink(victim, path) |
| + case "hardlink-wal": |
| + err = os.Link(victim, path+"-wal") |
| + case "hardlink-shm": |
| + err = os.Link(victim, path+"-shm") |
| + case "symlink-parent": |
| + alias := filepath.Join(dir, "alias") |
| + err = os.Symlink(data, alias) |
| + path = filepath.Join(alias, "registry.db") |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, beforeErr := os.Stat(victim) |
| + logged := captureLog(t) |
| + reg, openErr := openRegistryConverging(0, u.Username, path) |
| + if reg != nil { |
| + reg.Close() |
| + t.Fatal("unsafe registry reached SQLite") |
| + } |
| + if openErr == nil { |
| + t.Fatal("unsafe registry accepted") |
| + } |
| + if !strings.Contains(strings.ToLower(logged()), "alert") { |
| + t.Errorf("refused file had no alert: %s", logged()) |
| + } |
| + after, afterErr := os.Stat(victim) |
| + if errors.Is(beforeErr, os.ErrNotExist) { |
| + if !errors.Is(afterErr, os.ErrNotExist) { |
| + t.Fatalf("dangling link target created: %v", afterErr) |
| + } |
| + } else { |
| + if afterErr != nil { |
| + t.Fatal(afterErr) |
| + } |
| + beforeUID, beforeGID, _ := fileOwner(before) |
| + afterUID, afterGID, _ := fileOwner(after) |
| + if beforeUID != afterUID || beforeGID != afterGID || before.Mode() != after.Mode() || before.ModTime() != after.ModTime() { |
| + t.Fatal("victim ownership, mode or modification time changed") |
| + } |
| + contents, err := os.ReadFile(victim) |
| + if err != nil || !bytes.Equal(contents, []byte{}) { |
| + t.Fatalf("victim changed: %q, %v", contents, err) |
| + } |
| + } |
| + for _, suffix := range []string{"-wal", "-shm", "-journal"} { |
| + if _, err := os.Lstat(victim + suffix); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("SQLite created victim sidecar %s: %v", suffix, err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPrivilegedRegistryEntryPointsUseServiceBoundary(t *testing.T) { |
| + for _, tc := range []struct{ file, function string }{ |
| + {"agent.go", "runAdoptWithEnsureSystemUnits"}, |
| + {"systemapply.go", "openSystemRegistry"}, |
| + {"claimstop_run.go", "RunInstanceControl"}, |
| + } { |
| + t.Run(tc.function, func(t *testing.T) { |
| + f, err := parser.ParseFile(token.NewFileSet(), tc.file, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, decl := range f.Decls { |
| + fn, ok := decl.(*ast.FuncDecl) |
| + if !ok || fn.Name.Name != tc.function { |
| + continue |
| + } |
| + ast.Inspect(fn.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "openServiceRegistry" { |
| + found = true |
| + } |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Open" { |
| + if id, ok := sel.X.(*ast.Ident); ok && id.Name == "registry" { |
| + t.Error("privileged caller directly opens SQLite") |
| + } |
| + } |
| + return true |
| + }) |
| + } |
| + if !found { |
| + t.Fatal("privileged caller does not cross the service registry boundary") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/registrychild_other.go b/internal/host/registrychild_other.go| new file mode 100644 |
| index 000000000..7b9215f72 |
| --- /dev/null |
| +++ b/internal/host/registrychild_other.go |
| @@ -0,0 +1,16 @@ |
| +//go:build !unix |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func openRegistryChild(string, int, int) (*registry.Registry, error) { |
| + return nil, errors.New("registry: privilege-dropped workers require unix") |
| +} |
| + |
| +func RunRegistryChild([]string) error { |
| + return errors.New("registry: privilege-dropped workers require unix") |
| +} |
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go| new file mode 100644 |
| index 000000000..fd5fde016 |
| --- /dev/null |
| +++ b/internal/host/registrychild_unix.go |
| @@ -0,0 +1,84 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "syscall" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const registryChildCommand = "__registry-child" |
| + |
| +func openRegistryChild(path string, uid, gid int) (*registry.Registry, error) { |
| + if uid <= 0 || gid < 0 { |
| + return nil, errors.New("registry: invalid service credentials") |
| + } |
| + path, err := filepath.Abs(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + executable, err := os.Executable() |
| + if err != nil { |
| + return nil, err |
| + } |
| + cmd := exec.Command(executable, registryChildCommand, path, strconv.Itoa(uid), strconv.Itoa(gid)) |
| + return startRegistryChild(cmd, path, uid, gid) |
| +} |
| + |
| +func startRegistryChild(cmd *exec.Cmd, path string, uid, gid int) (*registry.Registry, error) { |
| + if uid <= 0 || gid < 0 { |
| + return nil, errors.New("registry: invalid service credentials") |
| + } |
| + cmd.Env = []string{} |
| + if value, ok := os.LookupEnv("CARLOS_HOST_CLAIM"); ok { |
| + cmd.Env = append(cmd.Env, "CARLOS_HOST_CLAIM="+value) |
| + } |
| + cmd.Dir = "/" |
| + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid), Groups: []uint32{}}} |
| + cmd.Stderr = os.Stderr |
| + input, err := cmd.StdinPipe() |
| + if err != nil { |
| + return nil, err |
| + } |
| + output, err := cmd.StdoutPipe() |
| + if err != nil { |
| + input.Close() |
| + return nil, err |
| + } |
| + if err = cmd.Start(); err != nil { |
| + input.Close() |
| + output.Close() |
| + return nil, err |
| + } |
| + return registry.OpenRemote(path, input, output, cmd.Wait) |
| +} |
| + |
| +// RunRegistryChild is the private re-exec entry point, before CLI dispatch. |
| +func RunRegistryChild(args []string) error { |
| + if len(args) != 3 { |
| + return errors.New("registry: invalid child arguments") |
| + } |
| + uid, err := strconv.Atoi(args[1]) |
| + if err != nil { |
| + return err |
| + } |
| + gid, err := strconv.Atoi(args[2]) |
| + if err != nil { |
| + return err |
| + } |
| + groups, err := os.Getgroups() |
| + if err != nil { |
| + return err |
| + } |
| + if uid <= 0 || os.Getuid() != uid || os.Geteuid() != uid || os.Getgid() != gid || os.Getegid() != gid || len(groups) != 0 { |
| + return fmt.Errorf("registry: child credentials were not dropped") |
| + } |
| + return registry.ServeRemote(args[0], os.Stdin, os.Stdout) |
| +} |
diff --git a/internal/host/registrychild_unix_test.go b/internal/host/registrychild_unix_test.go| new file mode 100644 |
| index 000000000..d6ba47cd1 |
| --- /dev/null |
| +++ b/internal/host/registrychild_unix_test.go |
| @@ -0,0 +1,158 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "io" |
| + "os" |
| + "os/exec" |
| + "os/user" |
| + "path/filepath" |
| + "strconv" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestRegistryChildHelper(t *testing.T) { |
| + for i, arg := range os.Args { |
| + if arg == "--registry-test-child" { |
| + if err := RunRegistryChild(os.Args[i+1:]); err != nil { |
| + os.Exit(1) |
| + } |
| + os.Exit(0) |
| + } |
| + } |
| +} |
| + |
| +func TestRegistryChildDropsCredentials(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root to set the child uid/gid and clear supplementary groups") |
| + } |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| + service, err := user.Lookup("nobody") |
| + if err != nil { |
| + t.Skip("requires an unprivileged nobody account") |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil || uid <= 0 { |
| + t.Skip("requires a nonzero service uid") |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir, err := os.MkdirTemp(os.TempDir(), "registry-child-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + if err := os.Chmod(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + executable, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + source, err := os.Open(executable) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer source.Close() |
| + bin := filepath.Join(dir, "test-worker") |
| + dest, err := os.OpenFile(bin, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = io.Copy(dest, source) |
| + closeErr := dest.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeErr != nil { |
| + t.Fatal(closeErr) |
| + } |
| + data := filepath.Join(dir, "data") |
| + if err := os.Mkdir(data, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(data, uid, gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(data, "registry.db") |
| + legacy, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := legacy.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := ownRegistryFile(path, uid, gid) |
| + if err != nil || !changed { |
| + t.Fatalf("repair root-owned registry: changed=%v, err=%v", changed, err) |
| + } |
| + cmd := exec.Command(bin, "-test.run=^TestRegistryChildHelper$", "--", "--registry-test-child", path, service.Uid, service.Gid) |
| + r, err := startRegistryChild(cmd, path, uid, gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { r.Close() }) |
| + if err := r.Upsert(registry.Route{Host: "dropped", Kind: "instance"}); err != nil { |
| + r.Close() |
| + t.Fatal(err) |
| + } |
| + owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| + row, err := r.Get("dropped") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.AdoptClaimRoute(t.Context(), *row, owner); err != nil { |
| + t.Fatal(err) |
| + } |
| + timer := registry.Timer{Host: "dropped", Name: "tick", Kind: "cron", Spec: "* * * * *"} |
| + if err := r.UpsertTimer(timer); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, err := r.GetTimer("dropped", "tick") |
| + if err != nil || saved == nil || saved.Registration == "" || saved.ClaimOwner != owner.ClaimOwner { |
| + t.Fatalf("worker lost claim mode: %+v, %v", saved, err) |
| + } |
| + timer.ClaimOwnership = owner |
| + timer.ClaimOwner = "other" |
| + if err := r.UpsertTimer(timer); !errors.Is(err, registry.ErrClaimOwnership) { |
| + t.Fatalf("worker accepted conflicting claim: %v", err) |
| + } |
| + if err := r.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + info, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gotUID, gotGID, ok := fileOwner(info) |
| + if !ok || gotUID != uid || gotGID != gid { |
| + t.Fatalf("registry owner = %d:%d, want %d:%d", gotUID, gotGID, uid, gid) |
| + } |
| +} |
| + |
| +func TestRegistryChildRefusesRootCredentials(t *testing.T) { |
| + if _, err := openRegistryChild(filepath.Join(t.TempDir(), "registry.db"), 0, 0); err == nil { |
| + t.Fatal("root worker accepted") |
| + } |
| +} |
| + |
| +func TestRegistryChildCommandEnvironment(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "true") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "must-not-reach-worker") |
| + cmd := exec.Command(filepath.Join(t.TempDir(), "missing-executable")) |
| + if _, err := startRegistryChild(cmd, "unused", 65534, 65534); err == nil { |
| + t.Fatal("missing worker started") |
| + } |
| + if len(cmd.Env) != 1 || cmd.Env[0] != "CARLOS_HOST_CLAIM=true" { |
| + t.Fatalf("worker environment = %q", cmd.Env) |
| + } |
| + if cmd.Dir != "/" || cmd.SysProcAttr.Credential.Uid == 0 || cmd.SysProcAttr.Credential.NoSetGroups || len(cmd.SysProcAttr.Credential.Groups) != 0 { |
| + t.Fatalf("unsafe worker command: %+v", cmd) |
| + } |
| +} |
diff --git a/internal/host/registryowner.go b/internal/host/registryowner.go| index 756b9e9d0..0bd7a53a9 100644 |
| --- a/internal/host/registryowner.go |
| +++ b/internal/host/registryowner.go |
| @@ -1,6 +1,7 @@ |
| package host |
| import ( |
| + "errors" |
| "fmt" |
| "log" |
| "os" |
| @@ -12,45 +13,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| -// The registry is written by TWO users. `carlos agent` (carlos-edge.service) |
| -// runs as serviceUser; every adoption-shaped pass — carlos-adopt, |
| -// carlos-stage, carlos-restart, carlos-config-sync — runs as root, because a |
| -// pass writes /opt/carlos and cycles units via systemctl. Both open |
| -// /data/registry.db, and SQLite creates the database and its -wal/-shm |
| -// sidecars owned by whoever got there first. |
| -// |
| -// On an established box the edge got there first, the files are carlos-owned, |
| -// and root writing to them is fine. On a FRESH box it is a race that root can |
| -// win: `carlos bootstrap` arms adopt's .path and .timer before the edge has |
| -// ever started, so adopt can create the registry as root and leave the edge |
| -// unable to start at all: |
| -// |
| -// agent: open registry /data/registry.db: attempt to write a readonly database (8) |
| -// |
| -// systemd gives up after five restarts in three seconds, so the box then sits |
| -// failed with no further attempts. All three region boxes brought up on |
| -// 2026-08-31 landed exactly there (discussion #8). |
| -// |
| -// Two fences, because one is not enough: |
| -// |
| -// 1. bootstrap creates the file itself, before the units that could race for |
| -// it are armed (see bootstrap.go) — so root never gets to create it. |
| -// 2. every root pass converges the ownership afterwards, below. Chowning by |
| -// hand once is NOT enough on a live box and the report says so: the armed |
| -// path/timer units fire again and re-create the sidecars as root in |
| -// between. Two of the three boxes needed the chown twice. |
| - |
| -// openServiceRegistry opens the registry for a root-side pass and converges |
| -// its ownership in the same breath. |
| -// |
| -// One function rather than two statements, because the two must not drift. |
| -// Opening the database is what CREATES the -wal/-shm, so the converge has to |
| -// follow the open — and `carlos adopt -restart-only` returns from RunAdopt a |
| -// few lines after it. A converge written as a separate statement that ever |
| -// slid below that early return would silently stop self-healing the restart |
| -// path, which is the pass that fires most often on a live box (carlos-restart |
| -// .path is per-request). Callers get the registry only through here, so there |
| -// is no ordering left to get wrong. |
| +// Root passes keep SQLite in a child running as the service user. |
| func openServiceRegistry(path string) (*registry.Registry, error) { |
| return openRegistryConverging(os.Geteuid(), serviceUser, path) |
| } |
| @@ -58,22 +21,31 @@ func openServiceRegistry(path string) (*registry.Registry, error) { |
| // openRegistryConverging is openServiceRegistry with the two facts it reads |
| // from the process passed in; see chownRegistryFiles. |
| func openRegistryConverging(euid int, svcUser, path string) (*registry.Registry, error) { |
| - reg, err := registry.Open(path) |
| + if euid != 0 { |
| + return registry.Open(path) |
| + } |
| + u, err := user.Lookup(svcUser) |
| if err != nil { |
| return nil, err |
| } |
| - chownRegistryFiles(euid, svcUser, path) |
| - return reg, nil |
| + uid, err := strconv.Atoi(u.Uid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + gid, err := strconv.Atoi(u.Gid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if uid <= 0 || gid < 0 { |
| + return nil, fmt.Errorf("registry: invalid service credentials") |
| + } |
| + if err := chownRegistryFiles(euid, svcUser, path); err != nil { |
| + return nil, err |
| + } |
| + return openRegistryChild(path, uid, gid) |
| } |
| -// chownRegistryToService leaves the registry and its WAL sidecars owned by |
| -// serviceUser. It is a no-op unless this process is root: a pass running as |
| -// the service user already creates everything with the right owner. |
| -// |
| -// Failures are logged, never fatal. An adoption pass that can read the |
| -// registry has real work to do — releases to stage, units to cycle — and |
| -// refusing to do it because a chown failed would turn a cosmetic problem into |
| -// an outage. The next pass tries again. |
| +// chownRegistryToService repairs old root-owned files through checked descriptors. |
| func chownRegistryToService(path string) { |
| // Geteuid reports -1 on platforms without the concept (the CLI |
| // cross-compiles to windows), which takes the same early exit. |
| @@ -84,44 +56,39 @@ func chownRegistryToService(path string) { |
| // effective uid and the service user's name — made arguments, so a test can |
| // drive the root arm, and both of its failure arms, from a test binary that |
| // is not root on a box that has no carlos user. |
| -func chownRegistryFiles(euid int, svcUser, path string) { |
| +func chownRegistryFiles(euid int, svcUser, path string) error { |
| if euid != 0 { |
| - return |
| + return nil |
| } |
| u, err := user.Lookup(svcUser) |
| if err != nil { |
| log.Printf("adopt: registry ownership: no %s user: %v", svcUser, err) |
| - return |
| + return err |
| } |
| uid, err := strconv.Atoi(u.Uid) |
| if err != nil { |
| - return |
| + return err |
| } |
| gid, err := strconv.Atoi(u.Gid) |
| if err != nil { |
| - return |
| + return err |
| } |
| + var refused error |
| for _, p := range registryFiles(path) { |
| - fi, err := os.Stat(p) |
| - if err != nil { |
| - // The -wal/-shm sidecars only exist while some connection is |
| - // open. Their absence is the normal state, not a problem. |
| + changed, err := ownRegistryFile(p, uid, gid) |
| + if errors.Is(err, os.ErrNotExist) { |
| continue |
| } |
| - if fuid, fgid, ok := fileOwner(fi); ok && fuid == uid && fgid == gid { |
| + if err != nil { |
| + log.Printf("adopt: ALERT: chown %s to %s: %v", p, svcUser, err) |
| + refused = errors.Join(refused, err) |
| continue |
| } |
| - if err := os.Chown(p, uid, gid); err != nil { |
| - // Logged and stepped over, never returned: the files after this |
| - // one may well be fixable, and none of it is worth the pass. |
| - log.Printf("adopt: chown %s to %s: %v", p, svcUser, err) |
| - continue |
| + if changed { |
| + log.Printf("adopt: %s was not owned by %s — fixed; the edge runs as %s and could not have written it", p, svcUser, svcUser) |
| } |
| - // Worth a line every time it fires: on a healthy box it never does, |
| - // so a run of these is the box telling you something armed root is |
| - // creating the registry ahead of the edge. |
| - log.Printf("adopt: %s was not owned by %s — fixed; the edge runs as %s and could not have written it", p, svcUser, svcUser) |
| } |
| + return refused |
| } |
| // registryFiles is the database and the two sidecars SQLite keeps beside it |
diff --git a/internal/host/registryowner_chown_other.go b/internal/host/registryowner_chown_other.go| new file mode 100644 |
| index 000000000..d90413cb5 |
| --- /dev/null |
| +++ b/internal/host/registryowner_chown_other.go |
| @@ -0,0 +1,11 @@ |
| +//go:build !unix |
| + |
| +package host |
| + |
| +import "errors" |
| + |
| +// ownRegistryFile is never reached off unix: chownRegistryFiles returns early |
| +// unless it runs as root, and the box side is Linux-only. |
| +func ownRegistryFile(string, int, int) (bool, error) { |
| + return false, errors.New("registry ownership is unix-only") |
| +} |
diff --git a/internal/host/registryowner_chown_unix.go b/internal/host/registryowner_chown_unix.go| new file mode 100644 |
| index 000000000..772e0d249 |
| --- /dev/null |
| +++ b/internal/host/registryowner_chown_unix.go |
| @@ -0,0 +1,85 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| + "syscall" |
| +) |
| + |
| +// errRegistryFileShape refuses a registry path that is not a plain, |
| +// single-link regular file. /data belongs to the service user, so anything |
| +// there may have been planted: a symlink or a hard link to a root-owned file |
| +// would otherwise hand that file to the service user when root chowns it. |
| +var errRegistryFileShape = errors.New("not a plain single-link regular file") |
| + |
| +// ownRegistryFile gives path to uid:gid without following a symlink and |
| +// without touching any file reachable by another name. It opens the path |
| +// O_NOFOLLOW, checks the opened file itself, and changes ownership through |
| +// that descriptor, so a swap between the check and the chown cannot redirect |
| +// it. A missing file reports os.ErrNotExist; changed is false when the file |
| +// was already owned as wanted. |
| +func ownRegistryFile(path string, uid, gid int) (changed bool, err error) { |
| + parent, name, err := openRegistryParent(path) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer parent.Close() |
| + fd, err := openRegistryDescriptor(int(parent.Fd()), name) |
| + var f *os.File |
| + if err == nil { |
| + f = os.NewFile(uintptr(fd), path) |
| + } |
| + if err != nil { |
| + if errors.Is(err, syscall.ELOOP) { |
| + return false, fmt.Errorf("%s: %w", path, errRegistryFileShape) |
| + } |
| + return false, err |
| + } |
| + defer f.Close() |
| + fi, err := f.Stat() |
| + if err != nil { |
| + return false, err |
| + } |
| + st, ok := fi.Sys().(*syscall.Stat_t) |
| + if !ok || !fi.Mode().IsRegular() || st.Nlink != 1 { |
| + return false, fmt.Errorf("%s: %w", path, errRegistryFileShape) |
| + } |
| + if int(st.Uid) == uid && int(st.Gid) == gid { |
| + return false, nil |
| + } |
| + if err := chownRegistryDescriptor(f, uid, gid); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// Walk each parent without following symlinks, keeping the final directory pinned. |
| +func openRegistryParent(path string) (*os.File, string, error) { |
| + absolute, err := filepath.Abs(path) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + parent, err := os.Open("/") |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + for _, part := range strings.Split(strings.TrimPrefix(filepath.Dir(absolute), "/"), "/") { |
| + if part == "" { |
| + continue |
| + } |
| + fd, err := unix.Openat(int(parent.Fd()), part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + parent.Close() |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + parent = os.NewFile(uintptr(fd), part) |
| + } |
| + return parent, filepath.Base(absolute), nil |
| +} |
diff --git a/internal/host/registryowner_chown_unix_test.go b/internal/host/registryowner_chown_unix_test.go| new file mode 100644 |
| index 000000000..1394519e8 |
| --- /dev/null |
| +++ b/internal/host/registryowner_chown_unix_test.go |
| @@ -0,0 +1,53 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// Root chowns the registry files in the service user's /data. A planted |
| +// symlink or hard link must be refused, never followed, so it cannot hand a |
| +// root-owned file to the service user. |
| +func TestOwnRegistryFileRefusesPlantedLinks(t *testing.T) { |
| + dir := t.TempDir() |
| + uid, gid := os.Getuid(), os.Getgid() |
| + victim := filepath.Join(dir, "victim") |
| + if err := os.WriteFile(victim, []byte("root's"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(dir, "registry.db-wal") |
| + if err := os.Symlink(victim, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ownRegistryFile(link, uid, gid); !errors.Is(err, errRegistryFileShape) { |
| + t.Fatalf("symlink followed or wrongly accepted: %v", err) |
| + } |
| + hard := filepath.Join(dir, "registry.db-shm") |
| + if err := os.Link(victim, hard); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ownRegistryFile(hard, uid, gid); !errors.Is(err, errRegistryFileShape) { |
| + t.Fatalf("hard link accepted: %v", err) |
| + } |
| + sub := filepath.Join(dir, "registry.db") |
| + if err := os.Mkdir(sub, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ownRegistryFile(sub, uid, gid); !errors.Is(err, errRegistryFileShape) { |
| + t.Fatalf("directory accepted: %v", err) |
| + } |
| + if _, err := ownRegistryFile(filepath.Join(dir, "absent"), uid, gid); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing file: %v", err) |
| + } |
| + plain := filepath.Join(dir, "plain.db") |
| + if err := os.WriteFile(plain, nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed, err := ownRegistryFile(plain, uid, gid); err != nil || changed { |
| + t.Fatalf("plain file already owned: changed=%v err=%v", changed, err) |
| + } |
| +} |
diff --git a/internal/host/registryowner_descriptor_linux.go b/internal/host/registryowner_descriptor_linux.go| new file mode 100644 |
| index 000000000..d98cd1b39 |
| --- /dev/null |
| +++ b/internal/host/registryowner_descriptor_linux.go |
| @@ -0,0 +1,16 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "golang.org/x/sys/unix" |
| + "os" |
| +) |
| + |
| +func openRegistryDescriptor(parent int, name string) (int, error) { |
| + return unix.Openat(parent, name, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| +} |
| + |
| +func chownRegistryDescriptor(f *os.File, uid, gid int) error { |
| + return unix.Fchownat(int(f.Fd()), "", uid, gid, unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW) |
| +} |
diff --git a/internal/host/registryowner_descriptor_unix.go b/internal/host/registryowner_descriptor_unix.go| new file mode 100644 |
| index 000000000..6392ef788 |
| --- /dev/null |
| +++ b/internal/host/registryowner_descriptor_unix.go |
| @@ -0,0 +1,16 @@ |
| +//go:build unix && !linux |
| + |
| +package host |
| + |
| +import ( |
| + "golang.org/x/sys/unix" |
| + "os" |
| +) |
| + |
| +func openRegistryDescriptor(parent int, name string) (int, error) { |
| + return unix.Openat(parent, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) |
| +} |
| + |
| +func chownRegistryDescriptor(f *os.File, uid, gid int) error { |
| + return f.Chown(uid, gid) |
| +} |
diff --git a/internal/host/registryowner_test.go b/internal/host/registryowner_test.go| index 782c837c9..3013160f3 100644 |
| --- a/internal/host/registryowner_test.go |
| +++ b/internal/host/registryowner_test.go |
| @@ -271,27 +271,19 @@ func TestRegistryOpenHintSilentOnOtherErrors(t *testing.T) { |
| } |
| } |
| -// TestOpenServiceRegistryConvergesOwnership pins the two halves as one |
| -// operation. `carlos adopt -restart-only` returns from RunAdopt within a few |
| -// lines of the open, so a converge that ever slid below that early return |
| -// would silently stop self-healing the pass that fires most often. Opening |
| -// through this helper is what makes the ordering unloseable — the test drives |
| -// the root arm with a service user that cannot resolve, so the one thing it |
| -// observes is that the open reached the converge at all. |
| -func TestOpenServiceRegistryConvergesOwnership(t *testing.T) { |
| +func TestOpenServiceRegistryRequiresServiceUser(t *testing.T) { |
| path := filepath.Join(t.TempDir(), "registry.db") |
| - logged := captureLog(t) |
| const noSuchUser = "carlos-no-such-service-user-8f21" |
| reg, err := openRegistryConverging(0, noSuchUser, path) |
| - if err != nil { |
| - t.Fatalf("open: %v", err) |
| + if reg != nil { |
| + reg.Close() |
| + t.Fatal("root opened a registry without a service identity") |
| } |
| - defer reg.Close() |
| - if _, err := reg.List(); err != nil { |
| - t.Fatalf("the helper returned an unusable registry: %v", err) |
| + if err == nil { |
| + t.Fatal("missing service user was accepted") |
| } |
| - if !strings.Contains(logged(), noSuchUser) { |
| - t.Errorf("opening the registry did not run the ownership converge; log was:\n%s", logged()) |
| + if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { |
| + t.Fatalf("root created the registry before resolving credentials: %v", statErr) |
| } |
| } |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 0ca5d6bc5..1527f9383 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -147,10 +147,7 @@ func openSystemStore(ctx context.Context) (release.Store, ed25519.PublicKey, err |
| return store, pub, nil |
| } |
| -// openSystemRegistry is the production openRegistry: the box's registry, |
| -// opened as the root passes open it so any WAL files the open creates end |
| -// up owned by the service user, and wrapped with its path so a refusal |
| -// that names an open error says which file. |
| +// openSystemRegistry keeps root's SQLite access in the service-user child. |
| func openSystemRegistry(dataDir string) (*registry.Registry, error) { |
| path := filepath.Join(dataDir, "registry.db") |
| reg, err := openServiceRegistry(path) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 5920578c8..132bfe747 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -535,7 +535,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Re-signs only the validated channel pointer; the digest-marker helper only reads. |
| {"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"}, |
| // Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers. |
| - {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "73ff8f69f13e3f140f9782132173f09f12b434d6e0bd34b0bc11d34baa4e13ed"}, |
| + {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "d36e02ac2f9ce58e2c297ad092903ae4c0bae74287c54830717839768d2b71c6"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
| @@ -624,7 +624,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/fleetapi/fleetapi.go", "store.Put", "record-transport", "def:Put", "68f7424cbbaf8fffde48429ae29198cf6e4fcf768c512657b305224febc11186"}, |
| {"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"}, |
| {"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"}, |
| - {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "ab8e1192c203cc203b5522740a086e5f6970080a23caf013c67f205addd6b470"}, |
| + {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "f75c2a0360970669bc72ef73285ee530dbb8ddc4457ddd73ad91f4939731b75c"}, |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
diff --git a/internal/registry/claimhold.go b/internal/registry/claimhold.go| index 43eb8097f..5f8f87b5f 100644 |
| --- a/internal/registry/claimhold.go |
| +++ b/internal/registry/claimhold.go |
| @@ -6,6 +6,11 @@ import "context" |
| // The caller supplies phase authority; this transaction binds the exact row |
| // and observed operation, and does not change the failed-host projection. |
| func (r *Registry) SetClaimHold(ctx context.Context, host string, owner ClaimOwnership, expected, next string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(ctx, remoteRequest{Method: "SetClaimHold", Host: host, Owner: owner, Expected: expected, Next: next}, nil) |
| + return err |
| + } |
| + |
| if !owner.Valid() || host == "" { |
| return ErrClaimOwnership |
| } |
diff --git a/internal/registry/claimrows.go b/internal/registry/claimrows.go| index f581b8901..3051588b8 100644 |
| --- a/internal/registry/claimrows.go |
| +++ b/internal/registry/claimrows.go |
| @@ -53,6 +53,11 @@ func (r *Registry) migrateClaimOwnership() error { |
| // AdoptClaimRoute is called only by the complete-census backfill coordinator. |
| // The full observed row is compared inside the write transaction. |
| func (r *Registry) AdoptClaimRoute(ctx context.Context, observed Route, owner ClaimOwnership) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(ctx, remoteRequest{Method: "AdoptClaimRoute", Observed: observed, Owner: owner}, nil) |
| + return err |
| + } |
| + |
| if !owner.Valid() || observed.ClaimOwnership != (ClaimOwnership{}) { |
| return ErrClaimOwnership |
| } |
| @@ -88,6 +93,14 @@ func (r *Registry) AdoptClaimRoute(ctx context.Context, observed Route, owner Cl |
| // A missing or replacement row is successful only when this journal's committed |
| // archive proves the predecessor was already removed by an earlier attempt. |
| func (r *Registry) RetireClaimRows(ctx context.Context, journal string, targets []ClaimRow, archive func([]ClaimRowArchive) error) error { |
| + if archive == nil { |
| + return ErrClaimOwnership |
| + } |
| + if r.remote != nil { |
| + _, err := r.remote.call(ctx, remoteRequest{Method: "RetireClaimRows", Journal: journal, Targets: targets}, func(out remoteResponse) error { return archive(out.Archives) }) |
| + return err |
| + } |
| + |
| if journal == "" || len(targets) == 0 || archive == nil { |
| return ErrClaimOwnership |
| } |
| @@ -189,6 +202,11 @@ func (r *Registry) RetireClaimRows(ctx context.Context, journal string, targets |
| } |
| func (r *Registry) AdoptClaimTimer(ctx context.Context, observed Timer, owner ClaimOwnership) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(ctx, remoteRequest{Method: "AdoptClaimTimer", ObservedValue: observed, Owner: owner}, nil) |
| + return err |
| + } |
| + |
| if !owner.Valid() || observed.ClaimOwnership != (ClaimOwnership{}) || observed.Registration == "" || owner.ClaimRowID != observed.Registration { |
| return ErrClaimOwnership |
| } |
diff --git a/internal/registry/claimtimers.go b/internal/registry/claimtimers.go| index 5c2a2e1b3..11464aded 100644 |
| --- a/internal/registry/claimtimers.go |
| +++ b/internal/registry/claimtimers.go |
| @@ -12,6 +12,11 @@ var ErrTimerChanged = errors.New("registry: timer registration changed") |
| // UpsertTimerRegistration compares the captured registration and returns the |
| // resulting registration in the same transaction. Empty means absent at read. |
| func (r *Registry) UpsertTimerRegistration(t Timer, expected string) (*Timer, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "UpsertTimerRegistration", T: t, Expected: expected}, nil) |
| + return out.Timer, err |
| + } |
| + |
| tx, err := r.sql.Begin() |
| if err != nil { |
| return nil, err |
| @@ -64,6 +69,11 @@ func (r *Registry) UpsertTimerRegistration(t Timer, expected string) (*Timer, er |
| } |
| func (r *Registry) SetTimerStatusRegistration(host, name, registration, status string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetTimerStatusRegistration", Host: host, Name: name, Registration: registration, Status: status}, nil) |
| + return err |
| + } |
| + |
| if registration == "" { |
| return ErrTimerChanged |
| } |
| @@ -72,6 +82,11 @@ func (r *Registry) SetTimerStatusRegistration(host, name, registration, status s |
| } |
| func (r *Registry) FireTimerNowRegistration(host, name, registration string, now int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "FireTimerNowRegistration", Host: host, Name: name, Registration: registration, Now: now}, nil) |
| + return err |
| + } |
| + |
| if registration == "" { |
| return ErrTimerChanged |
| } |
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go| index 6e832076a..a47e33f4d 100644 |
| --- a/internal/registry/coldhandover.go |
| +++ b/internal/registry/coldhandover.go |
| @@ -15,6 +15,11 @@ type ColdRows struct { |
| } |
| func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string, old ColdRows, next ColdRows) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(ctx, remoteRequest{Method: "RestoreClaimRows", Id: id, Retirement: retirement, Old: old, NextValue: next}, nil) |
| + return err |
| + } |
| + |
| if id == "" || retirement == "" || !old.Route.ClaimOwnership.Valid() || !next.Route.ClaimOwnership.Valid() || old.Route.Host != next.Route.Host || old.Route.ClaimIncarnation != next.Route.ClaimIncarnation || next.Route.ClaimStamp <= old.Route.ClaimStamp || len(old.Timers) != len(next.Timers) { |
| return ErrClaimOwnership |
| } |
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go| index 3593ddfef..31c67ad3b 100644 |
| --- a/internal/registry/fleets.go |
| +++ b/internal/registry/fleets.go |
| @@ -151,6 +151,11 @@ func (r *Registry) freshFleetsConn() (*sql.DB, error) { |
| // UpsertFleet inserts or updates a fleet's account/bucket linkage by name. |
| func (r *Registry) UpsertFleet(f Fleet) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "UpsertFleet", F: f}, nil) |
| + return err |
| + } |
| + |
| if !ValidTunnelName(f.Name) { |
| return fmt.Errorf("invalid fleet name %q", f.Name) |
| } |
| @@ -176,6 +181,11 @@ ON CONFLICT(name) DO UPDATE SET |
| // ON CONFLICT DO UPDATE would. created reports which caller won; a losing |
| // call makes no change to the existing row at all — not even a rev bump. |
| func (r *Registry) CreateFleet(f Fleet) (created bool, err error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "CreateFleet", F: f}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if !ValidTunnelName(f.Name) { |
| return false, fmt.Errorf("invalid fleet name %q", f.Name) |
| } |
| @@ -199,6 +209,11 @@ ON CONFLICT(name) DO NOTHING`, |
| // GetFleet returns the fleet named name, or (nil, nil) if there is none. |
| func (r *Registry) GetFleet(name string) (*Fleet, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "GetFleet", Name: name}, nil) |
| + return out.Fleet, err |
| + } |
| + |
| row := r.sql.QueryRow(`SELECT name, account, bucket, created_at FROM fleets WHERE name = ?`, name) |
| var f Fleet |
| err := row.Scan(&f.Name, &f.Account, &f.Bucket, &f.CreatedAt) |
| @@ -213,6 +228,11 @@ func (r *Registry) GetFleet(name string) (*Fleet, error) { |
| // ListFleets returns every fleet, ordered by name, over a fresh connection. |
| func (r *Registry) ListFleets() ([]Fleet, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ListFleets"}, nil) |
| + return out.Fleets, err |
| + } |
| + |
| db, err := r.freshFleetsConn() |
| if err != nil { |
| return nil, err |
| @@ -260,6 +280,11 @@ func (r *Registry) ListFleets() ([]Fleet, error) { |
| // string matching, is how a caller tells this ordinary conflict apart from |
| // an actual failure below. |
| func (r *Registry) AddFleetBox(b FleetBox) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "AddFleetBox", B: b}, nil) |
| + return err |
| + } |
| + |
| if !ValidTunnelName(b.Fleet) { |
| return fmt.Errorf("invalid fleet name %q", b.Fleet) |
| } |
| @@ -322,6 +347,11 @@ WHERE fleet_boxes.status <> 'active'`, |
| // rev: a sha256 token_hash is always 64 characters, so the rotation alone |
| // changes no length Fingerprint could otherwise key off. |
| func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetFleetBoxToken", Fleet: fleet, Label: label, TokenHash: tokenHash}, nil) |
| + return err |
| + } |
| + |
| if !ValidTunnelName(fleet) { |
| return fmt.Errorf("invalid fleet name %q", fleet) |
| } |
| @@ -348,6 +378,11 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error { |
| // SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same |
| // RowsAffected guard for an unknown (fleet, label). |
| func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetFleetBoxTags", Fleet: fleet, Label: label, Tags: tags}, nil) |
| + return err |
| + } |
| + |
| if !ValidTunnelName(fleet) { |
| return fmt.Errorf("invalid fleet name %q", fleet) |
| } |
| @@ -377,6 +412,11 @@ func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error { |
| // off status = 'active', so a typo'd status here would make a live box |
| // invisible to it. |
| func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetFleetBoxStatus", Fleet: fleet, Label: label, Status: status}, nil) |
| + return err |
| + } |
| + |
| if !ValidTunnelName(fleet) { |
| return fmt.Errorf("invalid fleet name %q", fleet) |
| } |
| @@ -403,6 +443,11 @@ func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error { |
| // ListFleetBoxes returns every box registered to fleet, ordered by label, |
| // over a fresh connection (same rationale as ListFleets). |
| func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ListFleetBoxes", Fleet: fleet}, nil) |
| + return out.FleetBoxes, err |
| + } |
| + |
| db, err := r.freshFleetsConn() |
| if err != nil { |
| return nil, err |
| @@ -457,6 +502,11 @@ func PlacementTarget(fleet string) string { |
| // cross-process poll List/Fingerprint exist for (the edge holds a long-lived |
| // Registry handle and reloads this whenever Fingerprint moves). |
| func (r *Registry) FleetChannelAuth() (map[string]string, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "FleetChannelAuth"}, nil) |
| + return out.StringMap, err |
| + } |
| + |
| db, err := r.freshFleetsConn() |
| if err != nil { |
| return nil, err |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 417f82818..c46cfb364 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -25,8 +25,9 @@ import ( |
| ) |
| type Registry struct { |
| - sql *sql.DB |
| - path string |
| + remote *remoteClient |
| + sql *sql.DB |
| + path string |
| } |
| // Route is one host a deployment serves: an app instance the platform |
| @@ -492,7 +493,12 @@ func (r *Registry) Path() string { return r.path } |
| // console) deliberately never call this — they hold one handle for the |
| // process lifetime — but a test that rebuilds a registry file from scratch |
| // must be able to let go of it first. |
| -func (r *Registry) Close() error { return r.sql.Close() } |
| +func (r *Registry) Close() error { |
| + if r.remote != nil { |
| + return r.remote.close() |
| + } |
| + return r.sql.Close() |
| +} |
| // migrate is additive (CREATE TABLE IF NOT EXISTS), so deploying new code |
| // over an existing registry is always safe. New columns go in the ALTER list — |
| @@ -734,6 +740,11 @@ type Account struct { |
| // which is what makes account identity survive a registry rebuild and be |
| // identical on every box in a deployment. |
| func (r *Registry) PutAccount(id int64, sqid, name string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "PutAccount", IdValue: id, Sqid: sqid, Name: name}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`INSERT INTO accounts (id, sqid, name) VALUES (?, ?, ?) |
| ON CONFLICT(id) DO UPDATE SET sqid = excluded.sqid, name = excluded.name`, id, sqid, name) |
| return err |
| @@ -752,6 +763,11 @@ ON CONFLICT(id) DO UPDATE SET sqid = excluded.sqid, name = excluded.name`, id, s |
| // runs the other way — domains imports registry — and a constant is a cheaper |
| // thing to keep in step than a cycle is to break. |
| func (r *Registry) DeleteAccount(sqid string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeleteAccount", Sqid: sqid}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM accounts WHERE sqid = ? AND id != ?`, sqid, opsAccountID) |
| return err |
| } |
| @@ -761,6 +777,11 @@ const opsAccountID = 1 |
| // AccountBySqid resolves an account's public identity to its id and name, or |
| // (0, "", nil) when absent — "no such account" is an answer, not an error. |
| func (r *Registry) AccountBySqid(sqid string) (int64, string, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "AccountBySqid", Sqid: sqid}, nil) |
| + return out.Number64, out.Text, err |
| + } |
| + |
| var id int64 |
| var name string |
| err := r.sql.QueryRow(`SELECT id, name FROM accounts WHERE sqid = ?`, sqid).Scan(&id, &name) |
| @@ -776,6 +797,11 @@ func (r *Registry) AccountBySqid(sqid string) (int64, string, error) { |
| // Accounts returns every materialized account, ordered by id (so ops is |
| // always first). |
| func (r *Registry) Accounts() ([]Account, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "Accounts"}, nil) |
| + return out.Accounts, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT id, sqid, name FROM accounts ORDER BY id`) |
| if err != nil { |
| return nil, err |
| @@ -831,6 +857,11 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| // matching, never retriggers. (Observed live in titogo, 2026-07-25.) List is |
| // reload-rare, so the ~1ms open cost is nothing. |
| func (r *Registry) List() ([]Route, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "List"}, nil) |
| + return out.Routes, err |
| + } |
| + |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| return nil, err |
| @@ -858,6 +889,11 @@ func (r *Registry) List() ([]Route, error) { |
| // Get returns the route for host, or (nil, nil) if there is none. |
| func (r *Registry) Get(host string) (*Route, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "Get", Host: host}, nil) |
| + return out.Route, err |
| + } |
| + |
| row := r.sql.QueryRow(`SELECT `+routeCols+` FROM routes WHERE host = ?`, host) |
| t, err := scanRoute(row.Scan) |
| if err == sql.ErrNoRows { |
| @@ -872,6 +908,11 @@ func (r *Registry) Get(host string) (*Route, error) { |
| // Delete removes a route by host (data-safe: it stops routing the host but |
| // never touches its DB). |
| func (r *Registry) Delete(host string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "Delete", Host: host}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM routes WHERE host = ?`, host) |
| return err |
| } |
| @@ -1000,6 +1041,11 @@ func (r *Registry) Delete(host string) error { |
| // Route53 latency records — a real incident this repo caught in review before |
| // it shipped, not a hypothetical. |
| func (r *Registry) Upsert(t Route) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "Upsert", TValue: t}, nil) |
| + return err |
| + } |
| + |
| if t.CreatedAt == 0 { |
| t.CreatedAt = time.Now().Unix() |
| } |
| @@ -1099,6 +1145,11 @@ func IsHold(status string) bool { return HoldStatuses[status] } |
| // 13's rollback hold rule relies on it); SetHold itself does not, because the |
| // marker lives outside this table. |
| func (r *Registry) SetHold(host, status, op string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetHold", Host: host, Status: status, Op: op}, nil) |
| + return err |
| + } |
| + |
| if !IsHold(status) || op == "" { |
| return fmt.Errorf("registry: %q with op %q is not a hold", status, op) |
| } |
| @@ -1129,6 +1180,11 @@ func (r *Registry) SetHold(host, status, op string) error { |
| // As with SetHold, every caller touches the stage-requested marker after a |
| // write that landed. |
| func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "SetHoldIf", Host: host, Status: status, Op: op, ExpectOp: expectOp}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if !IsHold(status) || op == "" { |
| return false, fmt.Errorf("registry: %q with op %q is not a hold", status, op) |
| } |
| @@ -1156,6 +1212,11 @@ func (r *Registry) SetHoldIf(host, status, op, expectOp string) (bool, error) { |
| // wraps it under WithHostHoldLock and touches the stage-requested marker |
| // after a hand-over that landed, which this package cannot reach. |
| func (r *Registry) HandHold(host, status, toOp, fromOp string) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "HandHold", Host: host, Status: status, ToOp: toOp, FromOp: fromOp}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if !IsHold(status) || toOp == "" || fromOp == "" || toOp == fromOp { |
| return false, fmt.Errorf("registry: hand %s from %q to %q as %q is not a hand-over between two ops' holds", host, fromOp, toOp, status) |
| } |
| @@ -1171,6 +1232,11 @@ func (r *Registry) HandHold(host, status, toOp, fromOp string) (bool, error) { |
| // under the op that holds it. false means nothing matched (a late write from a |
| // finished operation, an unheld row, or an empty op). |
| func (r *Registry) ReleaseHold(host, op string) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ReleaseHold", Host: host, Op: op}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if op == "" { |
| return false, nil |
| } |
| @@ -1186,6 +1252,11 @@ func (r *Registry) ReleaseHold(host, op string) (bool, error) { |
| // deletion at finalization (moves spec §7.9 step 3). A row held by another |
| // op, or not held, is not deleted and the answer is false. |
| func (r *Registry) DeleteIfHold(host, op string) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DeleteIfHold", Host: host, Op: op}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if op == "" { |
| return false, nil |
| } |
| @@ -1203,6 +1274,11 @@ func (r *Registry) DeleteIfHold(host, op string) (bool, error) { |
| // and nothing was deleted, timers included, so a timers clear never runs |
| // ahead of the row it belongs to or lands on a successor's rows. |
| func (r *Registry) DeleteIfHoldWithTimers(host, op string) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DeleteIfHoldWithTimers", Host: host, Op: op}, nil) |
| + return out.Changed, err |
| + } |
| + |
| if op == "" { |
| return false, nil |
| } |
| @@ -1231,6 +1307,11 @@ func (r *Registry) DeleteIfHoldWithTimers(host, op string) (bool, error) { |
| // host. Over a fresh connection, like List, so a reader in another process |
| // sees the latest commit. |
| func (r *Registry) HoldRows() ([]Route, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "HoldRows"}, nil) |
| + return out.Routes, err |
| + } |
| + |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| return nil, err |
| @@ -1265,6 +1346,11 @@ func (r *Registry) HoldRows() ([]Route, error) { |
| // writers give up after busy_timeout. Nothing is written: the transaction is |
| // rolled back. fn's error is returned. |
| func (r *Registry) WithHoldsFrozen(fn func(held []Route) error) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "WithHoldsFrozen"}, func(out remoteResponse) error { return fn(out.Held) }) |
| + return err |
| + } |
| + |
| ctx := context.Background() |
| conn, err := r.sql.Conn(ctx) |
| if err != nil { |
| @@ -1301,6 +1387,11 @@ func (r *Registry) WithHoldsFrozen(fn func(held []Route) error) error { |
| // read-modify-write Upsert racing a deploy's SetVersions (or the activator's |
| // SetNextDue) would clobber those columns. |
| func (r *Registry) SetAccount(host, account string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetAccount", Host: host, Account: account}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET account = ? WHERE host = ?`, account, host) |
| return err |
| } |
| @@ -1312,6 +1403,11 @@ func (r *Registry) SetAccount(host, account string) error { |
| // route` can report an unknown host rather than silently doing nothing — this |
| // is the one setter a human types a hostname into directly. |
| func (r *Registry) SetChannel(host, channel string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetChannel", Host: host, Channel: channel}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET channel = ? WHERE host = ?`, channel, host) |
| if err != nil { |
| return err |
| @@ -1333,6 +1429,11 @@ func (r *Registry) SetChannel(host, channel string) error { |
| // the edge's served set and the activator's managed set in one write, which |
| // is what quiesces the child ahead of the final mirror round. |
| func (r *Registry) SetStatus(host, status string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetStatus", Host: host, Status: status}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET status = ? WHERE host = ?`, status, host) |
| if err != nil { |
| return err |
| @@ -1369,6 +1470,11 @@ func (r *Registry) SetStatus(host, status string) error { |
| // lost race is a refusal that names the current value; the operator re-runs |
| // against reality. |
| func (r *Registry) SetCaps(host, from, to string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetCaps", Host: host, From: from, To: to}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET caps = ? WHERE host = ? AND caps = ?`, to, host, from) |
| if err != nil { |
| return err |
| @@ -1420,6 +1526,11 @@ func capsOrNone(caps string) string { |
| // replicator streams. A lost race is a refusal naming the current value, and |
| // the next pass re-decides against reality. |
| func (r *Registry) SetDBPath(host, from, to string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetDBPath", Host: host, From: from, To: to}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET db_path = ? WHERE host = ? AND db_path = ?`, to, host, from) |
| if err != nil { |
| return err |
| @@ -1454,6 +1565,11 @@ var ErrPathsChanged = errors.New("registry: instance paths changed underneath th |
| // swapped together because a row with one flat path and one nested path is |
| // neither layout, and every consumer would refuse it. |
| func (r *Registry) SetInstancePaths(host, fromDB, fromSock, toDB, toSock string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetInstancePaths", Host: host, FromDB: fromDB, FromSock: fromSock, ToDB: toDB, ToSock: toSock}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET db_path = ?, socket = ? WHERE host = ? AND db_path = ? AND socket = ?`, toDB, toSock, host, fromDB, fromSock) |
| if err != nil { |
| return err |
| @@ -1495,6 +1611,11 @@ func (r *Registry) SetInstancePaths(host, fromDB, fromSock, toDB, toSock string) |
| // (SetDBPath's): it is one operator, at one step of a documented migration, |
| // naming the value they want. |
| func (r *Registry) SetBacking(host, backing string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetBacking", Host: host, Backing: backing}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET backing = ? WHERE host = ?`, backing, host) |
| if err != nil { |
| return err |
| @@ -1535,6 +1656,11 @@ func (r *Registry) SetBacking(host, backing string) error { |
| // nothing, i.e. as the very bug this column closes. "" is accepted, because |
| // clearing the binding is a legitimate write (see ValidUnitName). |
| func (r *Registry) SetUnit(host, unit string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetUnit", Host: host, Unit: unit}, nil) |
| + return err |
| + } |
| + |
| if !ValidUnitName(unit) { |
| return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service) or \"\" to clear", unit) |
| } |
| @@ -1568,6 +1694,11 @@ func (r *Registry) SetUnit(host, unit string) error { |
| // to. It reaches here only from `carlos route --replication -`, never from |
| // a full-row Upsert, which preserves the column instead. |
| func (r *Registry) SetReplication(host, class string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetReplication", Host: host, Class: class}, nil) |
| + return err |
| + } |
| + |
| if !ValidReplication(class) { |
| return fmt.Errorf("replication %q: want one of %s, or \"\" for the default", class, strings.Join(ReplicationClasses(), ", ")) |
| } |
| @@ -1619,6 +1750,11 @@ func (r *Registry) SetReplication(host, class string) error { |
| // no-op on a host that does not exist would hide a reconcile pass writing to |
| // the wrong row. |
| func (r *Registry) SetUpstreams(host, addr, addrs, health string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetUpstreams", Host: host, Addr: addr, Addrs: addrs, Health: health}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET addr = ?, addrs = ?, health = ? WHERE host = ?`, |
| addr, addrs, health, host) |
| if err != nil { |
| @@ -1652,6 +1788,11 @@ func (r *Registry) SetUpstreams(host, addr, addrs, health string) error { |
| // reconcile pass that has just read the row must notice it vanishing |
| // underneath, not silently do nothing. |
| func (r *Registry) SetSteering(host, steering string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetSteering", Host: host, Steering: steering}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET steering = ? WHERE host = ?`, steering, host) |
| if err != nil { |
| return err |
| @@ -1677,6 +1818,11 @@ func (r *Registry) SetSteering(host, steering string) error { |
| // SetChannel's contract: the reconciler must notice the row vanishing |
| // underneath rather than silently do nothing. |
| func (r *Registry) SetReplicas(host, replicas string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetReplicas", Host: host, Replicas: replicas}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET replicas = ? WHERE host = ?`, replicas, host) |
| if err != nil { |
| return err |
| @@ -1700,6 +1846,11 @@ func (r *Registry) SetReplicas(host, replicas string) error { |
| // contract verbatim, because a reconcile pass that has just read the row |
| // must notice it vanishing underneath, not silently do nothing. |
| func (r *Registry) SetReplica(host string, replica bool) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetReplica", Host: host, Replica: replica}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET replica = ? WHERE host = ?`, b2iDB(replica), host) |
| if err != nil { |
| return err |
| @@ -1728,6 +1879,11 @@ func b2iDB(b bool) int { |
| // gate by design and so could not clear one). Errors on an unmatched host, |
| // SetBacking's contract. |
| func (r *Registry) SetGate(host, hash string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetGate", Host: host, Hash: hash}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET gate = ? WHERE host = ?`, hash, host) |
| if err != nil { |
| return err |
| @@ -1753,6 +1909,11 @@ func (r *Registry) SetGate(host, hash string) error { |
| // must never be left half-moved. Same "error on an unmatched host" contract |
| // as SetChannel, but any single failure rolls the whole batch back. |
| func (r *Registry) SetChannels(hosts []string, channel string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetChannels", Hosts: hosts, Channel: channel}, nil) |
| + return err |
| + } |
| + |
| if len(hosts) == 0 { |
| return nil |
| } |
| @@ -1796,6 +1957,11 @@ func (r *Registry) SetChannels(hosts []string, channel string) error { |
| // CLI's "-environment -" sentinel resolves to that before calling here, so |
| // this function itself has no notion of "clear" beyond writing "". |
| func (r *Registry) SetEnvironment(host, env string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetEnvironment", Host: host, Env: env}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET environment = ? WHERE host = ?`, env, host) |
| if err != nil { |
| return err |
| @@ -1818,6 +1984,11 @@ func (r *Registry) SetEnvironment(host, env string) error { |
| // CLI's "-tunnel -" sentinel resolves to that before calling here, so this |
| // function itself has no notion of "clear" beyond writing "". |
| func (r *Registry) SetTunnel(host, tunnel string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetTunnel", Host: host, Tunnel: tunnel}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET tunnel = ? WHERE host = ?`, tunnel, host) |
| if err != nil { |
| return err |
| @@ -1838,6 +2009,11 @@ func (r *Registry) SetTunnel(host, tunnel string) error { |
| // placement pass's door, distinct from SetPlacement (which writes the |
| // placement TARGET, not the box currently fulfilling it). |
| func (r *Registry) SetFleetBox(host, label string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetFleetBox", Host: host, Label: label}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET fleet_box = ? WHERE host = ?`, label, host) |
| if err != nil { |
| return err |
| @@ -1866,6 +2042,11 @@ func (r *Registry) SetFleetBox(host, label string) error { |
| // human or a placement pass typing a hostname here should get a refusal on a |
| // typo, not a silent no-op. |
| func (r *Registry) SetPlacement(host, target string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetPlacement", Host: host, Target: target}, nil) |
| + return err |
| + } |
| + |
| if target != "" { |
| if _, ok := FleetPlacement(target); !ok { |
| return fmt.Errorf("invalid placement target %q (want \"\" or \"fleet/<name>\")", target) |
| @@ -1892,6 +2073,11 @@ func (r *Registry) SetPlacement(host, target string) error { |
| // every route's existence-rail check to completion FIRST, and only once |
| // every route has cleared it does it call this to write. |
| func (r *Registry) SetEnvironments(hosts []string, env string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetEnvironments", Hosts: hosts, Env: env}, nil) |
| + return err |
| + } |
| + |
| if len(hosts) == 0 { |
| return nil |
| } |
| @@ -1936,6 +2122,11 @@ func (r *Registry) SetEnvironments(hosts []string, env string) error { |
| // single-column siblings; the caller (runRoute) has already run both rails' |
| // checks to completion before this is called. |
| func (r *Registry) SetChannelsAndEnvironments(hosts []string, channel, env string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetChannelsAndEnvironments", Hosts: hosts, Channel: channel, Env: env}, nil) |
| + return err |
| + } |
| + |
| if len(hosts) == 0 { |
| return nil |
| } |
| @@ -1974,6 +2165,11 @@ func (r *Registry) SetChannelsAndEnvironments(hosts []string, channel, env strin |
| // SetDNSStatus stamps a route's DNS health verdict + check time, narrowly |
| // (same rationale as SetAccount; this lands every few minutes on every host). |
| func (r *Registry) SetDNSStatus(host, status string, checkedAt int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetDNSStatus", Host: host, Status: status, CheckedAt: checkedAt}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET dns_status = ?, dns_checked_at = ? WHERE host = ?`, status, checkedAt, host) |
| return err |
| } |
| @@ -1981,6 +2177,11 @@ func (r *Registry) SetDNSStatus(host, status string, checkedAt int64) error { |
| // SetCertExpiresAt stamps a route's cached-cert NotAfter, narrowly. 0 = |
| // unknown (no cert in the cache, or one we couldn't parse). |
| func (r *Registry) SetCertExpiresAt(host string, at int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetCertExpiresAt", Host: host, At: at}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET cert_expires_at = ? WHERE host = ?`, at, host) |
| return err |
| } |
| @@ -1989,6 +2190,11 @@ func (r *Registry) SetCertExpiresAt(host string, at int64) error { |
| // so a slow deploy's read-modify-write can never clobber concurrent writes to |
| // other columns. |
| func (r *Registry) SetVersions(host, ver, prev string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetVersions", Host: host, Ver: ver, Prev: prev}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET version = ?, prev_version = ? WHERE host = ?`, ver, prev, host) |
| return err |
| } |
| @@ -2019,6 +2225,11 @@ func (r *Registry) SetVersions(host, ver, prev string) error { |
| // naming the rung again costs one command; runRouteHibernate prints what was |
| // dropped so it is a fact they are told, not one they discover. |
| func (r *Registry) SetHibernate(host string, on bool) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetHibernate", Host: host, On: on}, nil) |
| + return err |
| + } |
| + |
| if !on { |
| _, err := r.sql.Exec(`UPDATE routes SET hibernate = 0, replication = '' WHERE host = ?`, host) |
| return err |
| @@ -2030,6 +2241,11 @@ func (r *Registry) SetHibernate(host string, on bool) error { |
| // SetNextDue records the earliest server-side scheduled work for a host, so |
| // the activator can wake a sleeping instance for its own schedule. 0 = none. |
| func (r *Registry) SetNextDue(host string, due int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetNextDue", Host: host, Due: due}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET next_due = ? WHERE host = ?`, due, host) |
| return err |
| } |
| @@ -2054,6 +2270,11 @@ func (r *Registry) SetNextDue(host string, due int64) error { |
| // next_due = ? is what keeps consumption from eating it. A row that moved |
| // under us is simply left alone. |
| func (r *Registry) ClearNextDueIf(host string, due int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "ClearNextDueIf", Host: host, Due: due}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET next_due = 0 WHERE host = ? AND next_due = ?`, host, due) |
| return err |
| } |
| @@ -2068,6 +2289,11 @@ func (r *Registry) ClearNextDueIf(host string, due int64) error { |
| // it (host's workersToStart): only "ready", or the legacy "" some pre-status |
| // rows carry — a row still provisioning, or draining, must not be spawned. |
| func (r *Registry) AlwaysOnWorkers() ([]Route, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "AlwaysOnWorkers"}, nil) |
| + return out.Routes, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT ` + routeCols + ` FROM routes |
| WHERE kind = 'sidecar' AND hibernate = 0 AND (status = '' OR status = 'ready') ORDER BY host`) |
| if err != nil { |
| @@ -2092,6 +2318,11 @@ WHERE kind = 'sidecar' AND hibernate = 0 AND (status = '' OR status = 'ready') O |
| // instance-provisioning reconcile pass re-stamps this every tick and must |
| // notice a row that vanished under it rather than silently doing nothing. |
| func (r *Registry) SetSeed(host, seed string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetSeed", Host: host, Seed: seed}, nil) |
| + return err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE routes SET seed = ? WHERE host = ?`, seed, host) |
| if err != nil { |
| return err |
| @@ -2109,6 +2340,11 @@ func (r *Registry) SetSeed(host, seed string) error { |
| // SetArgs repoints a sidecar's argv — the narrow UPDATE the sidecar |
| // reconcile pass uses when the app's manifest changed. |
| func (r *Registry) SetArgs(host, args string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetArgs", Host: host, Args: args}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE routes SET args = ? WHERE host = ?`, args, host) |
| return err |
| } |
| @@ -2118,6 +2354,11 @@ func (r *Registry) SetArgs(host, args string) error { |
| // and same reason as SetArgs: the manifest is the truth, this is the copy |
| // the sweeper reads on every tick. |
| func (r *Registry) SetWorkerLimits(host string, idle, maxRun int) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetWorkerLimits", Host: host, Idle: idle, MaxRun: maxRun}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec( |
| `UPDATE routes SET idle_seconds = ?, max_run_seconds = ? WHERE host = ?`, |
| idle, maxRun, host) |
| @@ -2136,6 +2377,11 @@ func (r *Registry) SetWorkerLimits(host string, idle, maxRun int) error { |
| // adoption step (provision.Reconcile, design §4.2) should ever set it, and |
| // it does so only after matching the row's shape against the record. |
| func (r *Registry) SetProvisioned(host string, on bool) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetProvisioned", Host: host, On: on}, nil) |
| + return err |
| + } |
| + |
| v := 0 |
| if on { |
| v = 1 |
| @@ -2161,6 +2407,11 @@ func (r *Registry) SetProvisioned(host string, on bool) error { |
| // reconcile pass converges this column from the instance record and must |
| // notice a row that vanished under it rather than silently doing nothing. |
| func (r *Registry) SetStatusNamed(host string, on bool) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetStatusNamed", Host: host, On: on}, nil) |
| + return err |
| + } |
| + |
| v := 0 |
| if on { |
| v = 1 |
| @@ -2184,6 +2435,11 @@ func (r *Registry) SetStatusNamed(host string, on bool) error { |
| // are included because the due-time sweep is their ONLY timer wake source: |
| // nothing can dial a sidecar, so no request will ever wake one. |
| func (r *Registry) DueInstances(now int64) ([]Route, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DueInstances", Now: now}, nil) |
| + return out.Routes, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT `+routeCols+` FROM routes |
| WHERE kind IN ('instance','sidecar') AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| if err != nil { |
| @@ -2223,6 +2479,11 @@ type RouteAlias struct { |
| // ListAliases returns every route alias, ordered by alias. |
| func (r *Registry) ListAliases() ([]RouteAlias, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ListAliases"}, nil) |
| + return out.Aliases, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status, |
| dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`) |
| if err != nil { |
| @@ -2249,6 +2510,11 @@ dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`) |
| // leaving those columns out of the SET list rather than a CASE guard, since |
| // every writer of this table already knows it must not carry stamps). |
| func (r *Registry) UpsertAlias(a RouteAlias) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "UpsertAlias", A: a}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(` |
| INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| @@ -2260,6 +2526,11 @@ ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, a |
| // DeleteAlias removes an alias by its hostname. Idempotent: an alias already |
| // gone is a success, same contract as Delete on routes. |
| func (r *Registry) DeleteAlias(alias string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeleteAlias", Alias: alias}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM route_aliases WHERE alias = ?`, alias) |
| return err |
| } |
| @@ -2268,6 +2539,11 @@ func (r *Registry) DeleteAlias(alias string) error { |
| // narrowly (same rationale as SetDNSStatus on routes: it lands on its own |
| // cadence and must not race a concurrent UpsertAlias repoint). |
| func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetAliasDNSStatus", Alias: alias, Status: status, At: at}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE route_aliases SET dns_status = ?, dns_checked_at = ? WHERE alias = ?`, status, at, alias) |
| return err |
| } |
| @@ -2275,6 +2551,11 @@ func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error { |
| // SetAliasCertExpiresAt stamps an alias's cached-cert NotAfter, narrowly. |
| // 0 = unknown (same rationale as SetCertExpiresAt on routes). |
| func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetAliasCertExpiresAt", Alias: alias, At: at}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE route_aliases SET cert_expires_at = ? WHERE alias = ?`, at, alias) |
| return err |
| } |
| @@ -2293,6 +2574,11 @@ type WildcardDomain struct { |
| // ListWildcardDomains returns every wildcard domain, ordered by host. |
| func (r *Registry) ListWildcardDomains() ([]WildcardDomain, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ListWildcardDomains"}, nil) |
| + return out.Wildcards, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT host, account, app, created_at FROM wildcard_domains ORDER BY host`) |
| if err != nil { |
| return nil, err |
| @@ -2311,6 +2597,11 @@ func (r *Registry) ListWildcardDomains() ([]WildcardDomain, error) { |
| // UpsertWildcardDomain inserts or updates a row by host. |
| func (r *Registry) UpsertWildcardDomain(w WildcardDomain) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "UpsertWildcardDomain", W: w}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(` |
| INSERT INTO wildcard_domains (host, account, app, created_at) |
| VALUES (?, ?, ?, ?) |
| @@ -2322,6 +2613,11 @@ ON CONFLICT(host) DO UPDATE SET account=excluded.account, app=excluded.app, crea |
| // DeleteWildcardDomain removes host's row. Idempotent: a host already gone |
| // is a success, same contract as DeleteAlias. |
| func (r *Registry) DeleteWildcardDomain(host string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeleteWildcardDomain", Host: host}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM wildcard_domains WHERE host = ?`, host) |
| return err |
| } |
| @@ -2348,6 +2644,11 @@ type RoutePath struct { |
| // ListPaths returns every path route, ordered by host then prefix. |
| func (r *Registry) ListPaths() ([]RoutePath, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "ListPaths"}, nil) |
| + return out.Paths, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT host, prefix, target, account, app |
| FROM route_paths ORDER BY host, prefix`) |
| if err != nil { |
| @@ -2369,6 +2670,11 @@ FROM route_paths ORDER BY host, prefix`) |
| // Unlike UpsertAlias there are no stamp columns to preserve — nothing on this |
| // table is maintained on a separate cadence — so every column is overwritten. |
| func (r *Registry) UpsertPath(p RoutePath) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "UpsertPath", P: p}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(` |
| INSERT INTO route_paths (host, prefix, target, account, app) |
| VALUES (?, ?, ?, ?, ?) |
| @@ -2382,6 +2688,11 @@ ON CONFLICT(host, prefix) DO UPDATE SET |
| // success, the same contract as DeleteAlias — the reconcile pass deletes |
| // unconditionally from its own view of what is no longer desired. |
| func (r *Registry) DeletePath(host, prefix string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeletePath", Host: host, Prefix: prefix}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix) |
| return err |
| } |
| @@ -2474,6 +2785,11 @@ func (r *Registry) DeletePath(host, prefix string) error { |
| // never a missed change — the failure mode worth engineering against is the |
| // false NEGATIVE. |
| func (r *Registry) Fingerprint() (string, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "Fingerprint"}, nil) |
| + return out.Text, err |
| + } |
| + |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| return "", err |
| @@ -2704,6 +3020,11 @@ var snapshotClaimCols = map[string]bool{ |
| } |
| func (r *Registry) SnapshotFingerprint() (string, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "SnapshotFingerprint"}, nil) |
| + return out.Text, err |
| + } |
| + |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| return "", err |
| @@ -2838,6 +3159,11 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| // containing a backslash gets VACUUM INTO writing to a different on-disk |
| // path than the one os.Stat just checked, silently defeating the guard. |
| func (r *Registry) SnapshotTo(path string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SnapshotTo", Path: path}, nil) |
| + return err |
| + } |
| + |
| if _, err := os.Stat(path); err == nil { |
| return fmt.Errorf("registry: snapshot target already exists: %s", path) |
| } else if !os.IsNotExist(err) { |
diff --git a/internal/registry/remote.go b/internal/registry/remote.go| new file mode 100644 |
| index 000000000..4a400c961 |
| --- /dev/null |
| +++ b/internal/registry/remote.go |
| @@ -0,0 +1,243 @@ |
| +package registry |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "encoding/gob" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "sync" |
| + "time" |
| +) |
| + |
| +var errRemoteMethod = errors.New("registry: unsupported remote operation") |
| + |
| +var remoteErrors = []error{os.ErrNotExist, os.ErrPermission, sql.ErrNoRows, ErrClaimOwnership, ErrTimerChanged, ErrTimerKind, ErrPathsChanged, ErrFleetBoxActive, context.Canceled, context.DeadlineExceeded} |
| + |
| +func responseError(out remoteResponse) error { |
| + if !out.Failed { |
| + return nil |
| + } |
| + if out.ErrorKind > 0 && out.ErrorKind <= len(remoteErrors) { |
| + sentinel := remoteErrors[out.ErrorKind-1] |
| + if out.Error == sentinel.Error() { |
| + return sentinel |
| + } |
| + return fmt.Errorf("%s: %w", out.Error, sentinel) |
| + } |
| + return errors.New(out.Error) |
| +} |
| + |
| +func setResponseError(out *remoteResponse, err error) { |
| + if err == nil { |
| + return |
| + } |
| + out.Failed = true |
| + out.Error = err.Error() |
| + for i, sentinel := range remoteErrors { |
| + if errors.Is(err, sentinel) { |
| + out.ErrorKind = i + 1 |
| + break |
| + } |
| + } |
| +} |
| + |
| +type remoteClient struct { |
| + mu sync.Mutex |
| + enc *gob.Encoder |
| + dec *gob.Decoder |
| + input io.WriteCloser |
| + output io.ReadCloser |
| + wait func() error |
| + closed bool |
| +} |
| + |
| +// OpenRemote connects to an already privilege-dropped registry worker. |
| +func OpenRemote(path string, input io.WriteCloser, output io.ReadCloser, wait func() error) (*Registry, error) { |
| + c := &remoteClient{enc: gob.NewEncoder(input), dec: gob.NewDecoder(output), input: input, output: output, wait: wait} |
| + var ready remoteResponse |
| + err := c.dec.Decode(&ready) |
| + if err == nil { |
| + err = responseError(ready) |
| + } |
| + if err != nil { |
| + c.stop() |
| + return nil, err |
| + } |
| + return &Registry{path: path, remote: c}, nil |
| +} |
| + |
| +func (c *remoteClient) stop() error { |
| + if c.closed { |
| + return nil |
| + } |
| + c.closed = true |
| + c.input.Close() |
| + c.output.Close() |
| + if c.wait != nil { |
| + return c.wait() |
| + } |
| + return nil |
| +} |
| + |
| +func (c *remoteClient) close() error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.stop() |
| +} |
| + |
| +func (c *remoteClient) call(ctx context.Context, q remoteRequest, callback func(remoteResponse) error) (out remoteResponse, err error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.closed { |
| + return out, os.ErrClosed |
| + } |
| + if ctx != nil { |
| + if err = ctx.Err(); err != nil { |
| + return |
| + } |
| + if deadline, ok := ctx.Deadline(); ok { |
| + q.Deadline = deadline.UnixNano() |
| + } |
| + } |
| + // Closing the pipe cancels the worker context, including an in-flight SQL call. |
| + if ctx != nil { |
| + stopped := make(chan struct{}) |
| + stop := context.AfterFunc(ctx, func() { c.input.Close(); c.output.Close(); close(stopped) }) |
| + defer func() { |
| + if !stop() { |
| + <-stopped |
| + } |
| + }() |
| + defer func() { |
| + if ctx.Err() != nil { |
| + err = ctx.Err() |
| + c.stop() |
| + } |
| + }() |
| + } |
| + // A callback panic or a broken pipe must release any child transaction. |
| + complete := false |
| + defer func() { |
| + if !complete { |
| + c.stop() |
| + } |
| + }() |
| + if err = c.enc.Encode(q); err != nil { |
| + return |
| + } |
| + if err = c.dec.Decode(&out); err != nil { |
| + return |
| + } |
| + var callbackErr error |
| + if out.Callback != "" { |
| + if callback == nil { |
| + return out, errRemoteMethod |
| + } |
| + callbackErr = callback(out) |
| + if ctx != nil && ctx.Err() != nil { |
| + return out, ctx.Err() |
| + } |
| + ack := remoteRequest{Method: "callback"} |
| + if callbackErr != nil { |
| + ack.CallbackFailed = true |
| + ack.CallbackError = callbackErr.Error() |
| + } |
| + if err = c.enc.Encode(ack); err != nil { |
| + return |
| + } |
| + out = remoteResponse{} |
| + if err = c.dec.Decode(&out); err != nil { |
| + return |
| + } |
| + if out.Callback != "" { |
| + return out, errRemoteMethod |
| + } |
| + } |
| + complete = true |
| + if callbackErr != nil { |
| + return out, callbackErr |
| + } |
| + return out, responseError(out) |
| +} |
| + |
| +// ServeRemote owns SQLite in the worker. Callers must drop credentials first. |
| +func ServeRemote(path string, input io.Reader, output io.Writer) error { |
| + if os.Geteuid() == 0 { |
| + return errors.New("registry: remote worker refuses root") |
| + } |
| + enc, dec := gob.NewEncoder(output), gob.NewDecoder(input) |
| + r, err := Open(path) |
| + ready := remoteResponse{} |
| + setResponseError(&ready, err) |
| + if writeErr := enc.Encode(ready); writeErr != nil { |
| + if r != nil { |
| + r.Close() |
| + } |
| + return writeErr |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + defer r.Close() |
| + workerCtx, stopWorker := context.WithCancel(context.Background()) |
| + defer stopWorker() |
| + requests := make(chan remoteRequest) |
| + readErrors := make(chan error, 1) |
| + go func() { |
| + defer close(requests) |
| + defer stopWorker() |
| + for { |
| + var q remoteRequest |
| + if err := dec.Decode(&q); err != nil { |
| + readErrors <- err |
| + return |
| + } |
| + select { |
| + case requests <- q: |
| + case <-workerCtx.Done(): |
| + return |
| + } |
| + } |
| + }() |
| + for q := range requests { |
| + ctx := workerCtx |
| + cancel := func() {} |
| + if q.Deadline != 0 { |
| + ctx, cancel = context.WithDeadline(ctx, time.Unix(0, q.Deadline)) |
| + } |
| + out, callErr := r.dispatchRemote(ctx, q, func(event remoteResponse) error { |
| + if err := enc.Encode(event); err != nil { |
| + return err |
| + } |
| + ack, ok := <-requests |
| + if !ok { |
| + return workerCtx.Err() |
| + } |
| + if ack.Method != "callback" { |
| + return errRemoteMethod |
| + } |
| + if ack.CallbackFailed { |
| + return errors.New(ack.CallbackError) |
| + } |
| + return nil |
| + }) |
| + cancel() |
| + setResponseError(&out, callErr) |
| + if err = enc.Encode(out); err != nil { |
| + return err |
| + } |
| + } |
| + select { |
| + case err := <-readErrors: |
| + if errors.Is(err, io.EOF) { |
| + return nil |
| + } |
| + return err |
| + default: |
| + return workerCtx.Err() |
| + } |
| + |
| +} |
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go| new file mode 100644 |
| index 000000000..bd3281455 |
| --- /dev/null |
| +++ b/internal/registry/remote_dispatch.go |
| @@ -0,0 +1,318 @@ |
| +package registry |
| + |
| +import "context" |
| + |
| +type remoteRequest struct { |
| + Method string |
| + Deadline int64 |
| + CallbackError string |
| + CallbackFailed bool |
| + Host string |
| + Owner ClaimOwnership |
| + Expected string |
| + Next string |
| + Observed Route |
| + Journal string |
| + Targets []ClaimRow |
| + ObservedValue Timer |
| + T Timer |
| + Name string |
| + Registration string |
| + Status string |
| + Now int64 |
| + Id string |
| + Retirement string |
| + Old ColdRows |
| + NextValue ColdRows |
| + F Fleet |
| + B FleetBox |
| + Fleet string |
| + Label string |
| + TokenHash string |
| + Tags string |
| + IdValue int64 |
| + Sqid string |
| + TValue Route |
| + Op string |
| + ExpectOp string |
| + ToOp string |
| + FromOp string |
| + Account string |
| + Channel string |
| + From string |
| + To string |
| + FromDB string |
| + FromSock string |
| + ToDB string |
| + ToSock string |
| + Backing string |
| + Unit string |
| + Class string |
| + Addr string |
| + Addrs string |
| + Health string |
| + Steering string |
| + Replicas string |
| + Replica bool |
| + Hash string |
| + Hosts []string |
| + Env string |
| + Tunnel string |
| + Target string |
| + CheckedAt int64 |
| + At int64 |
| + Ver string |
| + Prev string |
| + On bool |
| + Due int64 |
| + Seed string |
| + Args string |
| + Idle int |
| + MaxRun int |
| + A RouteAlias |
| + Alias string |
| + W WildcardDomain |
| + P RoutePath |
| + Prefix string |
| + Path string |
| + Rows []Timer |
| + Limit int |
| + ExcludeHosts []string |
| + Ms int64 |
| + Attempts int |
| + NextAt int64 |
| + Kind string |
| + Alive map[string]bool |
| + DoneBefore int64 |
| + Keep map[string]bool |
| +} |
| + |
| +type remoteResponse struct { |
| + Failed bool |
| + Error string |
| + ErrorKind int |
| + Callback string |
| + Held []Route |
| + Archives []ClaimRowArchive |
| + Timer *Timer |
| + Changed bool |
| + Fleet *Fleet |
| + Fleets []Fleet |
| + FleetBoxes []FleetBox |
| + StringMap map[string]string |
| + Number64 int64 |
| + Text string |
| + Accounts []Account |
| + Routes []Route |
| + Route *Route |
| + Aliases []RouteAlias |
| + Wildcards []WildcardDomain |
| + Paths []RoutePath |
| + Timers []Timer |
| + Number int |
| + Times map[string]int64 |
| +} |
| + |
| +func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback func(remoteResponse) error) (out remoteResponse, err error) { |
| + switch q.Method { |
| + case "SetClaimHold": |
| + err = r.SetClaimHold(ctx, q.Host, q.Owner, q.Expected, q.Next) |
| + case "AdoptClaimRoute": |
| + err = r.AdoptClaimRoute(ctx, q.Observed, q.Owner) |
| + case "RetireClaimRows": |
| + err = r.RetireClaimRows(ctx, q.Journal, q.Targets, func(archives []ClaimRowArchive) error { |
| + return callback(remoteResponse{Callback: "archives", Archives: archives}) |
| + }) |
| + case "AdoptClaimTimer": |
| + err = r.AdoptClaimTimer(ctx, q.ObservedValue, q.Owner) |
| + case "UpsertTimerRegistration": |
| + out.Timer, err = r.UpsertTimerRegistration(q.T, q.Expected) |
| + case "SetTimerStatusRegistration": |
| + err = r.SetTimerStatusRegistration(q.Host, q.Name, q.Registration, q.Status) |
| + case "FireTimerNowRegistration": |
| + err = r.FireTimerNowRegistration(q.Host, q.Name, q.Registration, q.Now) |
| + case "RestoreClaimRows": |
| + err = r.RestoreClaimRows(ctx, q.Id, q.Retirement, q.Old, q.NextValue) |
| + case "UpsertFleet": |
| + err = r.UpsertFleet(q.F) |
| + case "CreateFleet": |
| + out.Changed, err = r.CreateFleet(q.F) |
| + case "GetFleet": |
| + out.Fleet, err = r.GetFleet(q.Name) |
| + case "ListFleets": |
| + out.Fleets, err = r.ListFleets() |
| + case "AddFleetBox": |
| + err = r.AddFleetBox(q.B) |
| + case "SetFleetBoxToken": |
| + err = r.SetFleetBoxToken(q.Fleet, q.Label, q.TokenHash) |
| + case "SetFleetBoxTags": |
| + err = r.SetFleetBoxTags(q.Fleet, q.Label, q.Tags) |
| + case "SetFleetBoxStatus": |
| + err = r.SetFleetBoxStatus(q.Fleet, q.Label, q.Status) |
| + case "ListFleetBoxes": |
| + out.FleetBoxes, err = r.ListFleetBoxes(q.Fleet) |
| + case "FleetChannelAuth": |
| + out.StringMap, err = r.FleetChannelAuth() |
| + case "PutAccount": |
| + err = r.PutAccount(q.IdValue, q.Sqid, q.Name) |
| + case "DeleteAccount": |
| + err = r.DeleteAccount(q.Sqid) |
| + case "AccountBySqid": |
| + out.Number64, out.Text, err = r.AccountBySqid(q.Sqid) |
| + case "Accounts": |
| + out.Accounts, err = r.Accounts() |
| + case "List": |
| + out.Routes, err = r.List() |
| + case "Get": |
| + out.Route, err = r.Get(q.Host) |
| + case "Delete": |
| + err = r.Delete(q.Host) |
| + case "Upsert": |
| + err = r.Upsert(q.TValue) |
| + case "SetHold": |
| + err = r.SetHold(q.Host, q.Status, q.Op) |
| + case "SetHoldIf": |
| + out.Changed, err = r.SetHoldIf(q.Host, q.Status, q.Op, q.ExpectOp) |
| + case "HandHold": |
| + out.Changed, err = r.HandHold(q.Host, q.Status, q.ToOp, q.FromOp) |
| + case "ReleaseHold": |
| + out.Changed, err = r.ReleaseHold(q.Host, q.Op) |
| + case "DeleteIfHold": |
| + out.Changed, err = r.DeleteIfHold(q.Host, q.Op) |
| + case "DeleteIfHoldWithTimers": |
| + out.Changed, err = r.DeleteIfHoldWithTimers(q.Host, q.Op) |
| + case "HoldRows": |
| + out.Routes, err = r.HoldRows() |
| + case "WithHoldsFrozen": |
| + err = r.WithHoldsFrozen(func(held []Route) error { return callback(remoteResponse{Callback: "holds", Held: held}) }) |
| + case "SetAccount": |
| + err = r.SetAccount(q.Host, q.Account) |
| + case "SetChannel": |
| + err = r.SetChannel(q.Host, q.Channel) |
| + case "SetStatus": |
| + err = r.SetStatus(q.Host, q.Status) |
| + case "SetCaps": |
| + err = r.SetCaps(q.Host, q.From, q.To) |
| + case "SetDBPath": |
| + err = r.SetDBPath(q.Host, q.From, q.To) |
| + case "SetInstancePaths": |
| + err = r.SetInstancePaths(q.Host, q.FromDB, q.FromSock, q.ToDB, q.ToSock) |
| + case "SetBacking": |
| + err = r.SetBacking(q.Host, q.Backing) |
| + case "SetUnit": |
| + err = r.SetUnit(q.Host, q.Unit) |
| + case "SetReplication": |
| + err = r.SetReplication(q.Host, q.Class) |
| + case "SetUpstreams": |
| + err = r.SetUpstreams(q.Host, q.Addr, q.Addrs, q.Health) |
| + case "SetSteering": |
| + err = r.SetSteering(q.Host, q.Steering) |
| + case "SetReplicas": |
| + err = r.SetReplicas(q.Host, q.Replicas) |
| + case "SetReplica": |
| + err = r.SetReplica(q.Host, q.Replica) |
| + case "SetGate": |
| + err = r.SetGate(q.Host, q.Hash) |
| + case "SetChannels": |
| + err = r.SetChannels(q.Hosts, q.Channel) |
| + case "SetEnvironment": |
| + err = r.SetEnvironment(q.Host, q.Env) |
| + case "SetTunnel": |
| + err = r.SetTunnel(q.Host, q.Tunnel) |
| + case "SetFleetBox": |
| + err = r.SetFleetBox(q.Host, q.Label) |
| + case "SetPlacement": |
| + err = r.SetPlacement(q.Host, q.Target) |
| + case "SetEnvironments": |
| + err = r.SetEnvironments(q.Hosts, q.Env) |
| + case "SetChannelsAndEnvironments": |
| + err = r.SetChannelsAndEnvironments(q.Hosts, q.Channel, q.Env) |
| + case "SetDNSStatus": |
| + err = r.SetDNSStatus(q.Host, q.Status, q.CheckedAt) |
| + case "SetCertExpiresAt": |
| + err = r.SetCertExpiresAt(q.Host, q.At) |
| + case "SetVersions": |
| + err = r.SetVersions(q.Host, q.Ver, q.Prev) |
| + case "SetHibernate": |
| + err = r.SetHibernate(q.Host, q.On) |
| + case "SetNextDue": |
| + err = r.SetNextDue(q.Host, q.Due) |
| + case "ClearNextDueIf": |
| + err = r.ClearNextDueIf(q.Host, q.Due) |
| + case "AlwaysOnWorkers": |
| + out.Routes, err = r.AlwaysOnWorkers() |
| + case "SetSeed": |
| + err = r.SetSeed(q.Host, q.Seed) |
| + case "SetArgs": |
| + err = r.SetArgs(q.Host, q.Args) |
| + case "SetWorkerLimits": |
| + err = r.SetWorkerLimits(q.Host, q.Idle, q.MaxRun) |
| + case "SetProvisioned": |
| + err = r.SetProvisioned(q.Host, q.On) |
| + case "SetStatusNamed": |
| + err = r.SetStatusNamed(q.Host, q.On) |
| + case "DueInstances": |
| + out.Routes, err = r.DueInstances(q.Now) |
| + case "ListAliases": |
| + out.Aliases, err = r.ListAliases() |
| + case "UpsertAlias": |
| + err = r.UpsertAlias(q.A) |
| + case "DeleteAlias": |
| + err = r.DeleteAlias(q.Alias) |
| + case "SetAliasDNSStatus": |
| + err = r.SetAliasDNSStatus(q.Alias, q.Status, q.At) |
| + case "SetAliasCertExpiresAt": |
| + err = r.SetAliasCertExpiresAt(q.Alias, q.At) |
| + case "ListWildcardDomains": |
| + out.Wildcards, err = r.ListWildcardDomains() |
| + case "UpsertWildcardDomain": |
| + err = r.UpsertWildcardDomain(q.W) |
| + case "DeleteWildcardDomain": |
| + err = r.DeleteWildcardDomain(q.Host) |
| + case "ListPaths": |
| + out.Paths, err = r.ListPaths() |
| + case "UpsertPath": |
| + err = r.UpsertPath(q.P) |
| + case "DeletePath": |
| + err = r.DeletePath(q.Host, q.Prefix) |
| + case "Fingerprint": |
| + out.Text, err = r.Fingerprint() |
| + case "SnapshotFingerprint": |
| + out.Text, err = r.SnapshotFingerprint() |
| + case "SnapshotTo": |
| + err = r.SnapshotTo(q.Path) |
| + case "UpsertTimer": |
| + err = r.UpsertTimer(q.T) |
| + case "ReplaceTimers": |
| + err = r.ReplaceTimers(q.Host, q.Rows) |
| + case "GetTimer": |
| + out.Timer, err = r.GetTimer(q.Host, q.Name) |
| + case "DeleteTimer": |
| + err = r.DeleteTimer(q.Host, q.Name) |
| + case "DeleteTimerRegistration": |
| + err = r.DeleteTimerRegistration(q.Host, q.Name, q.Registration) |
| + case "TimersFor": |
| + out.Timers, err = r.TimersFor(q.Host) |
| + case "DueTimers": |
| + out.Timers, err = r.DueTimers(q.Now, q.Limit, q.ExcludeHosts) |
| + case "DueUnheldTimers": |
| + out.Timers, err = r.DueUnheldTimers(q.Now, q.Limit, q.ExcludeHosts) |
| + case "FireTimerNow": |
| + err = r.FireTimerNow(q.Host, q.Name, q.Now) |
| + case "MarkTimerFired": |
| + out.Changed, err = r.MarkTimerFired(q.Host, q.Name, q.Registration, q.At, q.Status, q.Ms, q.Attempts, q.NextAt, q.Due) |
| + case "SetTimerStatus": |
| + err = r.SetTimerStatus(q.Host, q.Name, q.Status) |
| + case "CountTimers": |
| + out.Number, err = r.CountTimers(q.Host, q.Kind) |
| + case "PruneTimers": |
| + out.Number, err = r.PruneTimers(q.Alive, q.DoneBefore) |
| + case "DeleteTimersExcept": |
| + out.Number, err = r.DeleteTimersExcept(q.Host, q.Kind, q.Keep) |
| + case "EarliestTimers": |
| + out.Times, err = r.EarliestTimers() |
| + default: |
| + err = errRemoteMethod |
| + } |
| + return |
| +} |
diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go| new file mode 100644 |
| index 000000000..e714d0eba |
| --- /dev/null |
| +++ b/internal/registry/remote_test.go |
| @@ -0,0 +1,177 @@ |
| +package registry |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func remoteTestRegistry(t *testing.T) (*Registry, string) { |
| + t.Helper() |
| + if os.Geteuid() == 0 { |
| + t.Skip("in-process transport fixture must not open service files as root; credential tests use a dropped child") |
| + } |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + requestR, requestW := io.Pipe() |
| + responseR, responseW := io.Pipe() |
| + done := make(chan error, 1) |
| + go func() { |
| + err := ServeRemote(path, requestR, responseW) |
| + requestR.Close() |
| + responseW.Close() |
| + done <- err |
| + }() |
| + r, err := OpenRemote(path, requestW, responseR, func() error { return <-done }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { r.Close() }) |
| + return r, path |
| +} |
| + |
| +func TestRemoteRegistryRoundTrip(t *testing.T) { |
| + r, _ := remoteTestRegistry(t) |
| + route := Route{Host: "remote.example.com", Kind: "instance", Status: "ready"} |
| + if err := r.Upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SetVersions(route.Host, "v2", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := r.Get(route.Host) |
| + if err != nil || got == nil || got.Version != "v2" || got.PrevVersion != "v1" { |
| + t.Fatalf("route = %+v, %v", got, err) |
| + } |
| + timer, err := r.UpsertTimerRegistration(Timer{Host: route.Host, Name: "tick", Kind: "cron", Spec: "* * * * *"}, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, err := r.GetTimer(route.Host, "tick") |
| + if err != nil || saved == nil || saved.Registration == "" || saved.Registration != timer.Registration { |
| + t.Fatalf("timer = %+v, %v", saved, err) |
| + } |
| + if _, err := r.GetTimer(route.Host, "missing"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing sentinel = %v", err) |
| + } |
| + if err := r.SetClaimHold(t.Context(), route.Host, ClaimOwnership{}, "", "op"); !errors.Is(err, ErrClaimOwnership) { |
| + t.Fatalf("claim sentinel = %v", err) |
| + } |
| + if err := r.RetireClaimRows(t.Context(), "journal", nil, nil); !errors.Is(err, ErrClaimOwnership) { |
| + t.Fatalf("nil archive = %v", err) |
| + } |
| +} |
| + |
| +func TestRemoteHoldsFrozenKeepsWriteLock(t *testing.T) { |
| + r, path := remoteTestRegistry(t) |
| + if err := r.Upsert(Route{Host: "held", Kind: "instance"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + local, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer local.Close() |
| + finished := make(chan error, 1) |
| + callbackErr := errors.New("parent refused activation") |
| + err = r.WithHoldsFrozen(func(rows []Route) error { |
| + go func() { finished <- local.SetHold("held", "moving", "op") }() |
| + select { |
| + case err := <-finished: |
| + t.Fatalf("writer escaped frozen transaction: %v", err) |
| + case <-time.After(60 * time.Millisecond): |
| + } |
| + return callbackErr |
| + }) |
| + if err != callbackErr { |
| + t.Fatalf("callback error = %v", err) |
| + } |
| + select { |
| + case err := <-finished: |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case <-time.After(6 * time.Second): |
| + t.Fatal("writer remained locked after callback") |
| + } |
| +} |
| + |
| +func TestRemoteRetirementArchiveAndCancellation(t *testing.T) { |
| + for _, mode := range []string{"commit", "cancel", "empty-error"} { |
| + t.Run(mode, func(t *testing.T) { |
| + cancelRequest := mode == "cancel" |
| + callbackError := errors.New("") |
| + r, path := remoteTestRegistry(t) |
| + owner := ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| + if err := r.Upsert(Route{Host: "claim", Kind: "instance"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + row, err := r.Get("claim") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = r.AdoptClaimRoute(t.Context(), *row, owner); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + err = r.RetireClaimRows(ctx, "journal", []ClaimRow{{Kind: "route", Host: "claim", Ownership: owner}}, func(archives []ClaimRowArchive) error { |
| + if len(archives) != 1 || archives[0].Row.Ownership != owner { |
| + t.Fatalf("archive = %+v", archives) |
| + } |
| + if cancelRequest { |
| + cancel() |
| + <-ctx.Done() |
| + } |
| + if mode == "empty-error" { |
| + return callbackError |
| + } |
| + return nil |
| + }) |
| + if mode == "empty-error" && err != callbackError { |
| + t.Fatalf("empty callback error = %v", err) |
| + } |
| + if cancelRequest && !errors.Is(err, context.Canceled) { |
| + t.Fatalf("cancel = %v", err) |
| + } |
| + if mode == "commit" && err != nil { |
| + t.Fatal(err) |
| + } |
| + local, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer local.Close() |
| + current, err := local.Get("claim") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if (mode != "commit") != (current != nil) { |
| + t.Fatalf("cancel=%v: remaining row = %+v", cancelRequest, current) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRemoteCallbackPanicRollsBack(t *testing.T) { |
| + r, path := remoteTestRegistry(t) |
| + func() { |
| + defer func() { |
| + if recover() == nil { |
| + t.Error("callback did not panic") |
| + } |
| + }() |
| + r.WithHoldsFrozen(func([]Route) error { panic("parent panic") }) |
| + }() |
| + local, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer local.Close() |
| + if err := local.Upsert(Route{Host: "after-panic", Kind: "instance"}); err != nil { |
| + t.Fatalf("lock remained held: %v", err) |
| + } |
| +} |
diff --git a/internal/registry/timers.go b/internal/registry/timers.go| index 8ef7bd7cb..bb9891c3a 100644 |
| --- a/internal/registry/timers.go |
| +++ b/internal/registry/timers.go |
| @@ -52,6 +52,11 @@ func scanTimer(row interface{ Scan(...any) error }) (Timer, error) { |
| // UpsertTimer inserts t, or updates an existing row's spec/path. A changed spec/path or non-zero NextAt starts a new registration and resets attempts; unchanged declarations preserve delivery state. |
| func (r *Registry) UpsertTimer(t Timer) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "UpsertTimer", T: t}, nil) |
| + return err |
| + } |
| + |
| if !hostwrite.Enabled(os.Getenv) { |
| return r.upsertLegacyTimer(t) |
| } |
| @@ -119,6 +124,11 @@ AND timers.claim_stamp=excluded.claim_stamp AND timers.claim_owner=excluded.clai |
| // UpsertTimer is the wrong tool for that: it mints a fresh registration |
| // and resets the runner's bookkeeping on a rearm. |
| func (r *Registry) ReplaceTimers(host string, rows []Timer) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "ReplaceTimers", Host: host, Rows: rows}, nil) |
| + return err |
| + } |
| + |
| if !hostwrite.Enabled(os.Getenv) { |
| return r.replaceLegacyTimers(host, rows) |
| } |
| @@ -158,6 +168,11 @@ func (r *Registry) ReplaceTimers(host string, rows []Timer) error { |
| } |
| func (r *Registry) GetTimer(host, name string) (*Timer, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "GetTimer", Host: host, Name: name}, nil) |
| + return out.Timer, err |
| + } |
| + |
| t, err := scanTimer(r.sql.QueryRow(`SELECT `+timerCols+` FROM timers WHERE host=? AND name=?`, host, name)) |
| if errors.Is(err, sql.ErrNoRows) { |
| return nil, os.ErrNotExist |
| @@ -169,11 +184,21 @@ func (r *Registry) GetTimer(host, name string) (*Timer, error) { |
| } |
| func (r *Registry) DeleteTimer(host, name string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimer", Host: host, Name: name}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=?`, host, name) |
| return err |
| } |
| func (r *Registry) DeleteTimerRegistration(host, name, registration string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimerRegistration", Host: host, Name: name, Registration: registration}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=? AND registration=?`, host, name, registration) |
| return err |
| } |
| @@ -196,6 +221,11 @@ func (r *Registry) queryTimers(q string, args ...any) ([]Timer, error) { |
| } |
| func (r *Registry) TimersFor(host string) ([]Timer, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "TimersFor", Host: host}, nil) |
| + return out.Timers, err |
| + } |
| + |
| return r.queryTimers(`SELECT `+timerCols+` FROM timers WHERE host=? ORDER BY name`, host) |
| } |
| @@ -211,6 +241,11 @@ func (r *Registry) TimersFor(host string) ([]Timer, error) { |
| // where the LIMIT is applied, instead of after it. An empty (or nil) slice |
| // is no filter, which is the ordinary case: nothing in flight. |
| func (r *Registry) DueTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DueTimers", Now: now, Limit: limit, ExcludeHosts: excludeHosts}, nil) |
| + return out.Timers, err |
| + } |
| + |
| return r.dueTimers(now, limit, excludeHosts, false) |
| } |
| @@ -222,6 +257,11 @@ func (r *Registry) DueTimers(now int64, limit int, excludeHosts []string) ([]Tim |
| // returns it. The runner asks this only with its hold wiring armed; |
| // DueTimers stays main's query. |
| func (r *Registry) DueUnheldTimers(now int64, limit int, excludeHosts []string) ([]Timer, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DueUnheldTimers", Now: now, Limit: limit, ExcludeHosts: excludeHosts}, nil) |
| + return out.Timers, err |
| + } |
| + |
| return r.dueTimers(now, limit, excludeHosts, true) |
| } |
| @@ -253,6 +293,11 @@ func (r *Registry) dueTimers(now int64, limit int, excludeHosts []string, skipHe |
| // delivered (a `carlos schedule run` is its own occurrence, not an early |
| // delivery of the next natural one). |
| func (r *Registry) FireTimerNow(host, name string, now int64) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "FireTimerNow", Host: host, Name: name, Now: now}, nil) |
| + return err |
| + } |
| + |
| var id [16]byte |
| if _, err := rand.Read(id[:]); err != nil { |
| return err |
| @@ -267,6 +312,11 @@ func (r *Registry) FireTimerNow(host, name string, now int64) error { |
| // when the row advances, and the row's current DueAt when next is a retry |
| // of the occurrence just attempted — the runner decides which. |
| func (r *Registry) MarkTimerFired(host, name, registration string, at int64, status string, ms int64, attempts int, next, due int64) (bool, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "MarkTimerFired", Host: host, Name: name, Registration: registration, At: at, Status: status, Ms: ms, Attempts: attempts, NextAt: next, Due: due}, nil) |
| + return out.Changed, err |
| + } |
| + |
| res, err := r.sql.Exec(`UPDATE timers SET last_at=?, last_status=?, last_ms=?, attempts=?, next_at=?, due_at=? WHERE host=? AND name=? AND registration=?`, |
| at, status, ms, attempts, next, due, host, name, registration) |
| if err != nil { |
| @@ -280,11 +330,21 @@ func (r *Registry) MarkTimerFired(host, name, registration string, at int64, sta |
| // it parked (schedule.StatusNeverFires); last_at stays as it was, so the |
| // status reads as a condition, not a fire. |
| func (r *Registry) SetTimerStatus(host, name, status string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetTimerStatus", Host: host, Name: name, Status: status}, nil) |
| + return err |
| + } |
| + |
| _, err := r.sql.Exec(`UPDATE timers SET last_status=? WHERE host=? AND name=?`, status, host, name) |
| return err |
| } |
| func (r *Registry) CountTimers(host, kind string) (int, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "CountTimers", Host: host, Kind: kind}, nil) |
| + return out.Number, err |
| + } |
| + |
| var n int |
| err := r.sql.QueryRow(`SELECT COUNT(*) FROM timers WHERE host=? AND kind=?`, host, kind).Scan(&n) |
| return n, err |
| @@ -302,6 +362,11 @@ func (r *Registry) CountTimers(host, kind string) (int, error) { |
| // list is empty rather than hand the wipe over. A third caller should do the |
| // same, or say in its own comment why the wipe is what it wants. |
| func (r *Registry) PruneTimers(alive map[string]bool, doneBefore int64) (int, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "PruneTimers", Alive: alive, DoneBefore: doneBefore}, nil) |
| + return out.Number, err |
| + } |
| + |
| hosts := make([]any, 0, len(alive)) |
| for h := range alive { |
| hosts = append(hosts, h) |
| @@ -326,6 +391,11 @@ func (r *Registry) PruneTimers(alive map[string]bool, doneBefore int64) (int, er |
| } |
| func (r *Registry) DeleteTimersExcept(host, kind string, keep map[string]bool) (int, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "DeleteTimersExcept", Host: host, Kind: kind, Keep: keep}, nil) |
| + return out.Number, err |
| + } |
| + |
| names := make([]any, 0, len(keep)) |
| for n := range keep { |
| names = append(names, n) |
| @@ -357,6 +427,11 @@ func (r *Registry) DeleteTimersExcept(host, kind string, keep map[string]bool) ( |
| // the runner has not yet dated — and is excluded here rather than by the |
| // caller, matching DueTimers' own `next_at > 0` gate. |
| func (r *Registry) EarliestTimers() (map[string]int64, error) { |
| + if r.remote != nil { |
| + out, err := r.remote.call(nil, remoteRequest{Method: "EarliestTimers"}, nil) |
| + return out.Times, err |
| + } |
| + |
| rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`) |
| if err != nil { |
| return nil, err |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 7078040b3..f7aafa322 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -1854,14 +1854,13 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| // catches up. |
| return nil, fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform) |
| } |
| - body, _, err := GetQualified(ctx, s, ArtifactKey(acct, app, ver, art.File)) |
| + // Streamed to disk and hashed on the way (#269): a build read whole |
| + // outgrew the stage pass's memory cap. |
| + err := stageArtifactFrom(optDir, acct, ver, app, art.SHA256, func() (io.ReadCloser, error) { |
| + rc, _, err := OpenQualified(ctx, s, ArtifactKey(acct, app, ver, art.File)) |
| + return rc, err |
| + }) |
| if err != nil { |
| - return nil, fmt.Errorf("download %s: %w", ver, err) |
| - } |
| - if got := etagOf(body); got != art.SHA256 { |
| - return nil, fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver) |
| - } |
| - if err := StageArtifact(optDir, acct, ver, app, body); err != nil { |
| return nil, err |
| } |
| if chk.verified != nil { |
diff --git a/internal/release/qualified.go b/internal/release/qualified.go| index cb5c3a076..72f71f2ca 100644 |
| --- a/internal/release/qualified.go |
| +++ b/internal/release/qualified.go |
| @@ -26,6 +26,7 @@ import ( |
| "errors" |
| "fmt" |
| "github.com/carlosframework/platform/internal/controlname" |
| + "io" |
| "os" |
| "strings" |
| ) |
| @@ -88,6 +89,17 @@ func GetQualified(ctx context.Context, s Store, key string) (body []byte, etag s |
| return body, etag, err |
| } |
| +// OpenQualified is GetQualified for a streaming read (OpenObject): the same |
| +// key, the same hint on a miss. Staging reads a build this way (#269) so a |
| +// large one never sits in memory whole. |
| +func OpenQualified(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) { |
| + rc, etag, err := OpenObject(ctx, s, key) |
| + if err != nil && errors.Is(err, os.ErrNotExist) { |
| + return nil, "", fmt.Errorf("%w%s", err, migrationHint(key)) |
| + } |
| + return rc, etag, err |
| +} |
| + |
| // migrationHint turns a miss on an account-qualified APP key into the exact |
| // command that fixes it, for the one deployment shape that produces a |
| // surprising 404: upgraded from a pre-accounts binary, objects still at |
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go| index 755ca68ca..1419154c9 100644 |
| --- a/internal/release/qualified_test.go |
| +++ b/internal/release/qualified_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| "os" |
| "strings" |
| "testing" |
| @@ -50,6 +51,13 @@ func (l *legacyTripwire) Get(ctx context.Context, key string) ([]byte, string, e |
| return l.Store.Get(ctx, key) |
| } |
| +// Open is tripped too: staging streams the artifact (#269), and without this |
| +// a streamed read would reach the store under the tripwire unwatched. |
| +func (l *legacyTripwire) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + l.tripped("read", key) |
| + return OpenObject(ctx, l.Store, key) |
| +} |
| + |
| func (l *legacyTripwire) List(ctx context.Context, prefix string) ([]string, error) { |
| l.tripped("listed", prefix) |
| return l.Store.List(ctx, prefix) |
diff --git a/internal/release/stage_stream_test.go b/internal/release/stage_stream_test.go| new file mode 100644 |
| index 000000000..316dcd6a9 |
| --- /dev/null |
| +++ b/internal/release/stage_stream_test.go |
| @@ -0,0 +1,245 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// wholeArtifactRefuser fails the test if anything reads a release artifact |
| +// whole through Get. Manifests and pointers are small and still come through |
| +// Get; a build has to be streamed through Open (#269). |
| +type wholeArtifactRefuser struct { |
| + Store |
| + t *testing.T |
| +} |
| + |
| +func (w wholeArtifactRefuser) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.Contains(key, "/releases/") && !strings.HasSuffix(key, "manifest.json") { |
| + w.t.Errorf("artifact %s was read whole into memory; staging must stream it", key) |
| + } |
| + return w.Store.Get(ctx, key) |
| +} |
| + |
| +func (w wholeArtifactRefuser) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + return OpenObject(ctx, w.Store, key) |
| +} |
| + |
| +func TestStagingStreamsTheArtifact(t *testing.T) { |
| + dir := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + opt := t.TempDir() |
| + body := []byte("the whole build") |
| + shipRelease(t, dir, "bab", "km", "v1", adoptPlatform, body) |
| + |
| + if _, err := ensureStaged(ctx, wholeArtifactRefuser{Store: dir, t: t}, opt, "bab", "km", "v1", "h.example.com"); err != nil { |
| + t.Fatalf("ensureStaged: %v", err) |
| + } |
| + got, err := os.ReadFile(VersionPath(opt, "bab", "v1", "km")) |
| + if err != nil || !bytes.Equal(got, body) { |
| + t.Fatalf("staged %q (err %v), want %q", got, err, body) |
| + } |
| +} |
| + |
| +// patternReader yields n bytes of a fixed pattern without holding them. |
| +type patternReader struct{ n int64 } |
| + |
| +func (p *patternReader) Read(b []byte) (int, error) { |
| + if p.n <= 0 { |
| + return 0, io.EOF |
| + } |
| + if int64(len(b)) > p.n { |
| + b = b[:p.n] |
| + } |
| + for i := range b { |
| + b[i] = byte(i * 31) |
| + } |
| + p.n -= int64(len(b)) |
| + return len(b), nil |
| +} |
| + |
| +// The regression itself: a ~99 MB build read whole peaked past the stage |
| +// pass's 256 MB cap. Streamed, staging a build allocates a copy buffer, not |
| +// the build. |
| +func TestStageArtifactFromKeepsMemoryFlat(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("writes a 96 MB file") |
| + } |
| + const size = 96 << 20 |
| + h := sha256.New() |
| + if _, err := io.Copy(h, &patternReader{n: size}); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := hex.EncodeToString(h.Sum(nil)) |
| + opt := t.TempDir() |
| + |
| + runtime.GC() |
| + var before, after runtime.MemStats |
| + runtime.ReadMemStats(&before) |
| + err := stageArtifactFrom(opt, "bab", "v1", "big", want, func() (io.ReadCloser, error) { |
| + return io.NopCloser(&patternReader{n: size}), nil |
| + }) |
| + runtime.ReadMemStats(&after) |
| + if err != nil { |
| + t.Fatalf("stageArtifactFrom: %v", err) |
| + } |
| + if grew := after.TotalAlloc - before.TotalAlloc; grew > 8<<20 { |
| + t.Errorf("staging a %d MB build allocated %d MB; it must stream", size>>20, grew>>20) |
| + } |
| + if fi, err := os.Stat(VersionPath(opt, "bab", "v1", "big")); err != nil || fi.Size() != size || fi.Mode().Perm() != 0o755 { |
| + t.Fatalf("staged file: %v, err %v", fi, err) |
| + } |
| +} |
| + |
| +// failingReader yields some bytes, then fails, like a connection dropped |
| +// partway through a download. |
| +type failingReader struct{ sent bool } |
| + |
| +func (f *failingReader) Read(b []byte) (int, error) { |
| + if f.sent { |
| + return 0, errors.New("connection reset") |
| + } |
| + f.sent = true |
| + return copy(b, "half a build"), nil |
| +} |
| + |
| +// Bytes that fail to arrive whole, or arrive wrong, never replace what was |
| +// staged under the name and leave no temp file behind, exactly as verifying |
| +// in memory first did. |
| +func TestStageArtifactFromLeavesTheStagedCopyOnFailure(t *testing.T) { |
| + good := []byte("new build") |
| + sum := sha256.Sum256(good) |
| + for _, tc := range []struct { |
| + name string |
| + want string |
| + r func() io.Reader |
| + mismatch bool |
| + }{ |
| + {"wrong bytes", hex.EncodeToString(sum[:]), func() io.Reader { return strings.NewReader("not the build") }, true}, |
| + {"read fails partway", hex.EncodeToString(sum[:]), func() io.Reader { return &failingReader{} }, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("old build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + err := stageArtifactFrom(opt, "bab", "v1", "km", tc.want, func() (io.ReadCloser, error) { |
| + return io.NopCloser(tc.r()), nil |
| + }) |
| + if err == nil || !strings.Contains(err.Error(), "download v1") { |
| + t.Fatalf("err = %v, want a download error", err) |
| + } |
| + if errors.Is(err, errArtifactMismatch) != tc.mismatch { |
| + t.Errorf("errors.Is(err, errArtifactMismatch) = %v, want %v", !tc.mismatch, tc.mismatch) |
| + } |
| + path := VersionPath(opt, "bab", "v1", "km") |
| + if got, _ := os.ReadFile(path); string(got) != "old build" { |
| + t.Errorf("staged copy is %q, want the old build untouched", got) |
| + } |
| + if left, _ := filepath.Glob(path + ".tmp*"); len(left) != 0 { |
| + t.Errorf("temp files left behind: %v", left) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A box that cannot write the version store (the sandboxed agent) fails on |
| +// the directory before it opens the build, so it never downloads one only to |
| +// relay the stage to root. |
| +func TestStageArtifactFromChecksTheStoreBeforeOpening(t *testing.T) { |
| + opt := filepath.Join(t.TempDir(), "not-a-dir") |
| + if err := os.WriteFile(opt, nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + err := stageArtifactFrom(opt, "bab", "v1", "km", "", func() (io.ReadCloser, error) { |
| + t.Error("the build was opened though the version store cannot be written") |
| + return io.NopCloser(strings.NewReader("")), nil |
| + }) |
| + if err == nil { |
| + t.Fatal("staging into an unwritable store succeeded") |
| + } |
| +} |
| + |
| +// Two writers of one version overlap (Astra, stage-stream-artifact review): |
| +// A has written and hashed the whole build when B, a download that later |
| +// fails, starts writing. With a shared temp file B's truncate-and-write lands |
| +// in A's file before A renames it, and A publishes B's partial bytes under a |
| +// passing hash. Each writer's own temp file keeps A's bytes A's. |
| +func TestStageArtifactFromOverlappingWriters(t *testing.T) { |
| + opt := t.TempDir() |
| + good := []byte("the good build, whole") |
| + sum := sha256.Sum256(good) |
| + want := hex.EncodeToString(sum[:]) |
| + |
| + bWrote, release, bDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) |
| + a := &overlapReader{body: good, atEOF: func() { |
| + go func() { |
| + bDone <- stageArtifactFrom(opt, "bab", "v1", "km", want, func() (io.ReadCloser, error) { |
| + return io.NopCloser(&stallingReader{wrote: bWrote, release: release}), nil |
| + }) |
| + }() |
| + <-bWrote |
| + }} |
| + if err := stageArtifactFrom(opt, "bab", "v1", "km", want, func() (io.ReadCloser, error) { |
| + return io.NopCloser(a), nil |
| + }); err != nil { |
| + t.Fatalf("writer A: %v", err) |
| + } |
| + close(release) |
| + if err := <-bDone; err == nil { |
| + t.Fatal("writer B's failed download reported success") |
| + } |
| + path := VersionPath(opt, "bab", "v1", "km") |
| + if got, _ := os.ReadFile(path); !bytes.Equal(got, good) { |
| + t.Fatalf("staged %q, want writer A's verified bytes %q", got, good) |
| + } |
| + if left, _ := filepath.Glob(path + ".tmp*"); len(left) != 0 { |
| + t.Errorf("temp files left behind: %v", left) |
| + } |
| +} |
| + |
| +// overlapReader yields body, then runs atEOF once before reporting EOF. |
| +type overlapReader struct { |
| + body []byte |
| + atEOF func() |
| +} |
| + |
| +func (o *overlapReader) Read(b []byte) (int, error) { |
| + if len(o.body) > 0 { |
| + n := copy(b, o.body) |
| + o.body = o.body[n:] |
| + return n, nil |
| + } |
| + if o.atEOF != nil { |
| + o.atEOF() |
| + o.atEOF = nil |
| + } |
| + return 0, io.EOF |
| +} |
| + |
| +// stallingReader yields a partial build, signals once it has been written |
| +// (the second Read only comes after the first chunk's write), then waits for |
| +// release and fails. |
| +type stallingReader struct { |
| + n int |
| + wrote chan struct{} |
| + release chan struct{} |
| +} |
| + |
| +func (s *stallingReader) Read(b []byte) (int, error) { |
| + s.n++ |
| + if s.n == 1 { |
| + return copy(b, "partial"), nil |
| + } |
| + close(s.wrote) |
| + <-s.release |
| + return 0, errors.New("connection reset") |
| +} |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index eea8b9bbd..8f44118e0 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -14,7 +14,10 @@ package release |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "net" |
| @@ -126,28 +129,101 @@ func WriteFileAtomic(dst string, data []byte, mode os.FileMode) error { |
| // StageArtifact puts verified artifact bytes into the store under ver, so |
| // hosts can be pointed at it. Idempotent: re-staging overwrites. |
| func StageArtifact(optDir, acct, ver, app string, data []byte) error { |
| + dst, err := stagePath(optDir, acct, ver, app) |
| + if err != nil { |
| + return err |
| + } |
| + return WriteFileAtomic(dst, data, 0o755) |
| +} |
| + |
| +// errArtifactMismatch is stageArtifactFrom refusing bytes that do not hash |
| +// to the manifest's sha256. Nothing is left behind when it is returned. |
| +var errArtifactMismatch = errors.New("sha256 mismatch") |
| + |
| +// stageArtifactFrom is StageArtifact for a build read from the store as a |
| +// stream (#269). The bytes go straight into the temp file beside the |
| +// destination and are hashed on the way, so memory stays flat whatever the |
| +// build's size: the root stage pass runs under MemoryMax=256M, and reading a |
| +// ~99 MB build whole peaked past it. Only bytes that hash to wantSHA are |
| +// moved into place. A mismatch or a failed read removes the temp file and |
| +// leaves whatever was staged under the name untouched, exactly as verifying |
| +// in memory first did. |
| +// |
| +// open is called only once the version dir is writable, so a sandboxed |
| +// agent fails on the mkdir (stageBlocked relays that to root) without |
| +// opening the build at all. |
| +func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, open func() (io.ReadCloser, error)) error { |
| + dst, err := stagePath(optDir, acct, ver, app) |
| + if err != nil { |
| + return err |
| + } |
| + in, err := open() |
| + if err != nil { |
| + return fmt.Errorf("download %s: %w", ver, err) |
| + } |
| + defer in.Close() |
| + // A temp file of its own, not WriteFileAtomic's shared dst+".tmp": a |
| + // download is long enough for a concurrent writer of the same version (an |
| + // unserialized pass, the agent) to truncate a shared temp file between |
| + // this call's hash and its rename, which would publish the other writer's |
| + // partial bytes under a passing hash. |
| + out, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".tmp-*") |
| + if err != nil { |
| + return err |
| + } |
| + tmp := out.Name() |
| + h := sha256.New() |
| + if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil { |
| + out.Close() |
| + os.Remove(tmp) |
| + return fmt.Errorf("download %s: %w", ver, err) |
| + } |
| + if err := out.Close(); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if hex.EncodeToString(h.Sum(nil)) != wantSHA { |
| + os.Remove(tmp) |
| + return fmt.Errorf("download %s: %w (refusing to stage)", ver, errArtifactMismatch) |
| + } |
| + // CreateTemp makes the file 0600; the binary must be 0755 to exec. |
| + if err := os.Chmod(tmp, 0o755); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, dst); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// stagePath validates the names, makes the version dir traversable, and |
| +// returns where the build goes. |
| +func stagePath(optDir, acct, ver, app string) (string, error) { |
| if !ValidVersion(ver) { |
| - return fmt.Errorf("invalid version %q (allowed: letters, digits, . _ -)", ver) |
| + return "", fmt.Errorf("invalid version %q (allowed: letters, digits, . _ -)", ver) |
| } |
| if !ValidApp(app) { |
| - return fmt.Errorf("invalid app %q", app) |
| + return "", fmt.Errorf("invalid app %q", app) |
| } |
| if acct != "" && !ValidAccount(acct) { |
| - return fmt.Errorf("invalid account %q", acct) |
| + return "", fmt.Errorf("invalid account %q", acct) |
| } |
| - dir := filepath.Dir(VersionPath(optDir, acct, ver, app)) |
| + dst := VersionPath(optDir, acct, ver, app) |
| + dir := filepath.Dir(dst) |
| if err := os.MkdirAll(dir, 0o755); err != nil { |
| - return err |
| + return "", err |
| } |
| // MkdirAll's mode is masked by the caller's umask, so a tight operator |
| // umask (e.g. 077 from a hardened shell) leaves the version dir 0700 — |
| // the sandboxed service user then can't traverse in to exec its binary |
| // (203/EXEC "Permission denied", found staging a canary on Eleven). |
| - // Force it, like the write below forces the binary's +x. |
| + // Force it, like the write forces the binary's +x. |
| if err := os.Chmod(dir, 0o755); err != nil { |
| - return err |
| + return "", err |
| } |
| - return WriteFileAtomic(VersionPath(optDir, acct, ver, app), data, 0o755) |
| + return dst, nil |
| } |
| // StagedManifestPath is the staged binary's SIBLING file: |
diff --git a/main.go b/main.go| index 309bc5878..6fb967934 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -26,6 +26,12 @@ func main() { |
| os.Exit(2) |
| } |
| cmd, args := os.Args[1], os.Args[2:] |
| + if cmd == "__registry-child" { |
| + if err := host.RunRegistryChild(args); err != nil { |
| + log.Fatal(err) |
| + } |
| + return |
| + } |
| switch cmd { |
| // Asking for help is not an error: it prints on stdout and exits 0, so |
| // `carlos help | less` works. A bare `carlos` still fails (stderr, 2). |