instancectl, host: the held-release route is registered only with both flags on (whole-branch fixes, item 6, Minor)
instancectl registered /claim/held-release whenever claim control existed, so with moves off the claim root answered 503 where main's absent route answers 404. The server now takes the held-release handler apart from the claim handler (ClaimHeldRelease, RunWithClaim's third argument) and registers the route only when it is set; the host passes the claim root for it only with CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 both on (heldReleaseRouteHandler), nil otherwise. Tests: claim mode with moves off answers 404 and the claim handler is not reached; with both flags on the route is routed; the host helper is nil with either flag off and the root with both on; the held-release socket tests serve the root with the route registered, as the host does, and the "claim root of old" case still serves without it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+93
−14
internal/host/claimlaunch_transport_test.go+14 −1internal/host/claimstop_run.go+15 −1internal/host/movesheldrelease_linux_test.go+2 −2internal/host/movesheldrelease_production_test.go+28 −0internal/instancectl/claim.go+6 −2internal/instancectl/claimunit_test.go+12 −1internal/instancectl/server.go+16 −7
diff --git a/internal/host/claimlaunch_transport_test.go b/internal/host/claimlaunch_transport_test.go| index 63b51d64c..53f11f41c 100644 |
| --- a/internal/host/claimlaunch_transport_test.go |
| +++ b/internal/host/claimlaunch_transport_test.go |
| @@ -27,6 +27,19 @@ import ( |
| // serveClaimRoot runs the production instance-control mux in claim mode with |
| // h as its claim handler, on a real unix socket. |
| func serveClaimRoot(t *testing.T, h http.Handler) string { |
| + t.Helper() |
| + return serveClaimRootWith(t, h, nil) |
| +} |
| + |
| +// serveClaimRootWithHeldRelease serves h as the claim root with the moves |
| +// held-release route registered to it too, as the host registers it with |
| +// both flags on (whole-branch fixes, item 6). |
| +func serveClaimRootWithHeldRelease(t *testing.T, h http.Handler) string { |
| + t.Helper() |
| + return serveClaimRootWith(t, h, h) |
| +} |
| + |
| +func serveClaimRootWith(t *testing.T, h, heldRelease http.Handler) string { |
| t.Helper() |
| dir, err := os.MkdirTemp("", "cl") |
| if err != nil { |
| @@ -39,7 +52,7 @@ func serveClaimRoot(t *testing.T, h http.Handler) string { |
| t.Fatal(err) |
| } |
| ctx, cancel := context.WithCancel(context.Background()) |
| - server := &instancectl.Server{ClaimStop: h, PeerOK: func(net.Conn) error { return nil }} |
| + server := &instancectl.Server{ClaimStop: h, ClaimHeldRelease: heldRelease, PeerOK: func(net.Conn) error { return nil }} |
| done := make(chan struct{}) |
| go func() { defer close(done); _ = server.Serve(ctx, ln) }() |
| t.Cleanup(func() { cancel(); <-done }) |
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go| index 97bfdf93a..385be1fff 100644 |
| --- a/internal/host/claimstop_run.go |
| +++ b/internal/host/claimstop_run.go |
| @@ -3,12 +3,14 @@ package host |
| import ( |
| "context" |
| "log" |
| + "net/http" |
| "os" |
| "path/filepath" |
| "time" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -68,7 +70,19 @@ func RunInstanceControl(args []string) { |
| fenceDone := make(chan struct{}) |
| go func() { defer close(fenceDone); runHostFencePass(ctx) }() |
| defer func() { cancel(); <-fenceDone }() |
| - instancectl.RunWithClaim(args, handler, instanceRegistryHostApp(reg)) |
| + instancectl.RunWithClaim(args, handler, heldReleaseRouteHandler(handler, claimEntryEnv(ctx)), instanceRegistryHostApp(reg)) |
| +} |
| + |
| +// heldReleaseRouteHandler is the handler instancectl registers for the moves |
| +// held-release route: the claim root (which answers the path itself, |
| +// refusing while no held release is wired) only with both CARLOS_HOST_CLAIM |
| +// and CARLOS_MOVES_PHASE1 on; otherwise nil, so the route is not registered |
| +// and main's 404 stands (whole-branch fixes, item 6). |
| +func heldReleaseRouteHandler(handler http.Handler, getenv func(string) string) http.Handler { |
| + if handler == nil || getenv == nil || !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { |
| + return nil |
| + } |
| + return handler |
| } |
| func instanceRegistryHostApp(shared *registry.Registry) func(string) (string, string, error) { |
diff --git a/internal/host/movesheldrelease_linux_test.go b/internal/host/movesheldrelease_linux_test.go| index 1ee8a683e..292891856 100644 |
| --- a/internal/host/movesheldrelease_linux_test.go |
| +++ b/internal/host/movesheldrelease_linux_test.go |
| @@ -131,7 +131,7 @@ func TestHeldReleaseOverTheRootSocket(t *testing.T) { |
| authenticated++ |
| return hrPeer, nil |
| } |
| - sock := serveClaimRoot(t, ClaimRootWithLaunch{HeldRelease: f.root}) |
| + sock := serveClaimRootWithHeldRelease(t, ClaimRootWithLaunch{HeldRelease: f.root}) |
| client := newHeldReleaseRootClient(sock) |
| stale := f.request(heldReleasePublish) |
| @@ -236,7 +236,7 @@ func TestHeldReleaseRootLogsOnlyValidatedFields(t *testing.T) { |
| prev := log.Writer() |
| log.SetOutput(&logs) |
| t.Cleanup(func() { log.SetOutput(prev) }) |
| - client := newHeldReleaseRootClient(serveClaimRoot(t, ClaimRootWithLaunch{HeldRelease: f.root})) |
| + client := newHeldReleaseRootClient(serveClaimRootWithHeldRelease(t, ClaimRootWithLaunch{HeldRelease: f.root})) |
| forged := f.request(heldReleasePublish) |
| forged.Host = "a.go.example.com\nmoves: FORGED publication of v9" |
diff --git a/internal/host/movesheldrelease_production_test.go b/internal/host/movesheldrelease_production_test.go| new file mode 100644 |
| index 000000000..01dc00e5c |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_production_test.go |
| @@ -0,0 +1,28 @@ |
| +package host |
| + |
| +import "testing" |
| + |
| +// The root's held-release route is registered only with both flags on |
| +// (whole-branch fixes, item 6): heldReleaseRouteHandler is nil with |
| +// CARLOS_MOVES_PHASE1 off, so instancectl registers nothing and main's 404 |
| +// stands; with both on it is the claim root, which answers the path |
| +// itself (refusing while no held release is wired). |
| +func TestHeldReleaseRouteOnlyWithBothFlags(t *testing.T) { |
| + root := ClaimRootWithLaunch{} |
| + off := func(k string) string { |
| + if k == "CARLOS_HOST_CLAIM" { |
| + return "1" |
| + } |
| + return "" |
| + } |
| + if h := heldReleaseRouteHandler(root, off); h != nil { |
| + t.Fatalf("moves off: %v", h) |
| + } |
| + both := func(string) string { return "1" } |
| + if h := heldReleaseRouteHandler(root, both); h == nil { |
| + t.Fatal("both flags on: no route") |
| + } |
| + if h := heldReleaseRouteHandler(root, func(string) string { return "" }); h != nil { |
| + t.Fatalf("claim off: %v", h) |
| + } |
| +} |
diff --git a/internal/instancectl/claim.go b/internal/instancectl/claim.go| index 0513258c1..bd829c6b9 100644 |
| --- a/internal/instancectl/claim.go |
| +++ b/internal/instancectl/claim.go |
| @@ -15,6 +15,10 @@ func RequestConn(ctx context.Context) net.Conn { |
| func requestConnContext(ctx context.Context, c net.Conn) context.Context { |
| return context.WithValue(ctx, requestConnKey{}, c) |
| } |
| -func RunWithClaim(args []string, handler http.Handler, hostApp func(string) (string, string, error)) { |
| - runWithClaim(args, handler, hostApp) |
| + |
| +// RunWithClaim serves the claim verbs through handler and, when |
| +// heldRelease is not nil, the moves held-release route through it (the |
| +// host passes it only with both flags on; nil leaves the route absent). |
| +func RunWithClaim(args []string, handler, heldRelease http.Handler, hostApp func(string) (string, string, error)) { |
| + runWithClaim(args, handler, heldRelease, hostApp) |
| } |
diff --git a/internal/instancectl/claimunit_test.go b/internal/instancectl/claimunit_test.go| index c5b27b3f1..ac2278b82 100644 |
| --- a/internal/instancectl/claimunit_test.go |
| +++ b/internal/instancectl/claimunit_test.go |
| @@ -262,8 +262,19 @@ func TestClaimHeldReleaseRouteExistsOnlyInClaimMode(t *testing.T) { |
| if rec.Code != http.StatusNotFound { |
| t.Fatalf("flag-off mux answered /claim/held-release with %d", rec.Code) |
| } |
| + // Claim mode with moves off (whole-branch fixes, item 6): the route is |
| + // not registered, so main's 404 stands rather than the claim handler's |
| + // refusal. |
| reached := "" |
| - claim := (&Server{ClaimStop: http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { reached = r.URL.Path })}).Handler() |
| + stop := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { reached = r.URL.Path }) |
| + claimOnly := (&Server{ClaimStop: stop}).Handler() |
| + rec = httptest.NewRecorder() |
| + claimOnly.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/held-release", strings.NewReader("{}"))) |
| + if rec.Code != http.StatusNotFound || reached != "" { |
| + t.Fatalf("claim mode with moves off answered /claim/held-release with %d (reached %q)", rec.Code, reached) |
| + } |
| + // Both flags on: the held-release handler is registered and routed. |
| + claim := (&Server{ClaimStop: stop, ClaimHeldRelease: stop}).Handler() |
| rec = httptest.NewRecorder() |
| claim.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/held-release", strings.NewReader("{}"))) |
| if reached != "/claim/held-release" { |
diff --git a/internal/instancectl/server.go b/internal/instancectl/server.go| index 3cf77f637..28ceb4180 100644 |
| --- a/internal/instancectl/server.go |
| +++ b/internal/instancectl/server.go |
| @@ -34,6 +34,12 @@ import ( |
| // request reaches systemctl as a string. |
| type Server struct { |
| ClaimStop http.Handler |
| + // ClaimHeldRelease is the moves held-release route's handler (moves |
| + // Task 7b), registered only when set: the host sets it with both |
| + // CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 on, so with moves off the |
| + // route does not exist and main's 404 stands (whole-branch fixes, |
| + // item 6). |
| + ClaimHeldRelease http.Handler |
| // Systemctl runs one systemctl invocation and returns its combined |
| // output; production is exec.Command, tests record. |
| Systemctl func(ctx context.Context, args ...string) (string, error) |
| @@ -127,13 +133,13 @@ const ( |
| ) |
| // Run is `carlos instance-control`. |
| -func Run(args []string) { runWithClaim(args, nil, nil) } |
| +func Run(args []string) { runWithClaim(args, nil, nil, nil) } |
| func RunWithRegistry(args []string, hostApp func(string) (string, string, error)) { |
| - runWithClaim(args, nil, hostApp) |
| + runWithClaim(args, nil, nil, hostApp) |
| } |
| -func runWithClaim(args []string, claim http.Handler, hostApp func(string) (string, string, error)) { |
| +func runWithClaim(args []string, claim, heldRelease http.Handler, hostApp func(string) (string, string, error)) { |
| fs := flag.NewFlagSet("instance-control", flag.ExitOnError) |
| fs.Usage = cliflag.Usage(fs, "usage: carlos instance-control (socket-activated by carlos-instance-control.socket)") |
| _ = fs.Parse(args) |
| @@ -141,7 +147,8 @@ func runWithClaim(args []string, claim http.Handler, hostApp func(string) (strin |
| log.Fatal("instance-control: this is the root service behind carlos-instance-control.socket; it is not for operators") |
| } |
| s := &Server{ |
| - ClaimStop: claim, |
| + ClaimStop: claim, |
| + ClaimHeldRelease: heldRelease, |
| Systemctl: func(ctx context.Context, args ...string) (string, error) { |
| out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| return string(out), err |
| @@ -277,9 +284,11 @@ func (s *Server) Handler() http.Handler { |
| mux.Handle("POST /claim/journal", s.ClaimStop) |
| mux.Handle("POST /claim/writers-stopped", s.ClaimStop) |
| mux.Handle("POST /claim/launch", s.ClaimStop) |
| - // moves Task 7b: the held release under the claim. The claim |
| - // handler refuses it unless CARLOS_MOVES_PHASE1 wired it. |
| - mux.Handle("POST /claim/held-release", s.ClaimStop) |
| + } |
| + if s.ClaimHeldRelease != nil { |
| + // moves Task 7b: the held release under the claim, only with both |
| + // flags on (item 6); off, the route is absent as on main. |
| + mux.Handle("POST /claim/held-release", s.ClaimHeldRelease) |
| } |
| mux.HandleFunc("POST /start", s.claimAwareLegacy(s.handleStart)) |
| mux.HandleFunc("POST /seed", s.claimAwareLegacy(s.handleSeed)) |