carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 54a7421fa234473d077347d37665c4e94a899ed7 parent ebeaf95
7 files changed, +93 −14
  • internal/host/claimlaunch_transport_test.go +14 −1
  • internal/host/claimstop_run.go +15 −1
  • internal/host/movesheldrelease_linux_test.go +2 −2
  • internal/host/movesheldrelease_production_test.go +28 −0
  • internal/instancectl/claim.go +6 −2
  • internal/instancectl/claimunit_test.go +12 −1
  • internal/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))