From aa73e6ee356454b104fc9539bf586ebeb273f8f8 Mon Sep 17 00:00:00 2001 From: Daniel Vydra Date: Tue, 23 Jun 2026 15:33:54 +1000 Subject: [PATCH 1/3] chore(cor-573): scaffold CLI scheduled-maintenance 503 copy From 058e835dd2f10c179b3a8f4aaf822641c5f37c19 Mon Sep 17 00:00:00 2001 From: Daniel Vydra Date: Tue, 23 Jun 2026 15:43:55 +1000 Subject: [PATCH 2/3] fix(cor-573): map control-plane 503 to scheduled-maintenance copy renderCoreError special-cases an HTTP 503 from the control plane to a friendly "Entire is under scheduled maintenance" message instead of the raw ogen status string, so a draining/maintenance host (the COR-562 cutover scenario) reads cleanly. Adds coreapi.HTTPStatus to extract the status code. Copy is shared verbatim with the edge 503 page (COR-566) and the SPA banner. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/corecmd.go | 15 +++++++++++--- cmd/entire/cli/corecmd_test.go | 38 ++++++++++++++++++++++++++++++++++ internal/coreapi/client.go | 11 ++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/corecmd.go b/cmd/entire/cli/corecmd.go index d9e649c78a..7f95596f75 100644 --- a/cmd/entire/cli/corecmd.go +++ b/cmd/entire/cli/corecmd.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/http" "strings" "charm.land/lipgloss/v2" @@ -305,18 +306,26 @@ func markRequired(cmd *cobra.Command, names ...string) { } } +// maintenanceMessage is the shared 503 scheduled-maintenance copy, kept +// identical to the Cloudflare edge 503 page (COR-566) and the SPA banner. +const maintenanceMessage = "Entire is under scheduled maintenance — please try again shortly" + // renderCoreError converts a Core API error into the server's // problem-detail message (so users see "organization name already taken" // rather than ogen's decode-wrapped string), falling back to the raw error -// for transport/local failures. It returns a plain error, not a -// SilentError: main.go prints plain errors, and runCore has already set -// SilenceUsage, so the message reaches the user without a usage dump. (A +// for transport/local failures. A 503 is special-cased to the +// scheduled-maintenance copy regardless of body. It returns a plain error, +// not a SilentError: main.go prints plain errors, and runCore has already +// set SilenceUsage, so the message reaches the user without a usage dump. (A // SilentError here would be swallowed — main.go skips printing those — // leaving e.g. a 409 conflict with no output.) func renderCoreError(err error) error { if err == nil { return nil } + if status, ok := coreapi.HTTPStatus(err); ok && status == http.StatusServiceUnavailable { + return errors.New(maintenanceMessage) //nolint:staticcheck // user-facing copy, shared verbatim across CLI/SPA/edge + } if msg := coreapi.APIError(err); msg != "" { return errors.New(msg) } diff --git a/cmd/entire/cli/corecmd_test.go b/cmd/entire/cli/corecmd_test.go index 38ef54970a..0c9ddfe20a 100644 --- a/cmd/entire/cli/corecmd_test.go +++ b/cmd/entire/cli/corecmd_test.go @@ -2,7 +2,11 @@ package cli import ( "bytes" + "errors" + "net/http" "testing" + + "github.com/entireio/cli/internal/coreapi" ) // printTable/printFields render plain (no color/escape) when the writer @@ -39,3 +43,37 @@ func TestPrintFields(t *testing.T) { t.Errorf("printFields output:\n%q\nwant:\n%q", got, want) } } + +func TestRenderCoreError_ScheduledMaintenance(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in error + }{ + {"bare 503", &coreapi.ErrorModelStatusCode{StatusCode: http.StatusServiceUnavailable}}, + {"503 with problem-detail body", &coreapi.ErrorModelStatusCode{ + StatusCode: http.StatusServiceUnavailable, + Response: coreapi.ErrorModel{Detail: coreapi.OptString{Set: true, Value: "db unreachable"}}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := renderCoreError(tc.in) + if got == nil || got.Error() != maintenanceMessage { + t.Errorf("renderCoreError(%s) = %v, want %q", tc.name, got, maintenanceMessage) + } + }) + } +} + +func TestRenderCoreError_PassThrough(t *testing.T) { + t.Parallel() + if got := renderCoreError(nil); got != nil { + t.Errorf("renderCoreError(nil) = %v, want nil", got) + } + transport := errors.New("dial tcp: connection refused") + if got := renderCoreError(transport); !errors.Is(got, transport) { + t.Errorf("renderCoreError(transport) = %v, want passthrough", got) + } +} diff --git a/internal/coreapi/client.go b/internal/coreapi/client.go index 0edc57e8cc..2a3d7e0ea2 100644 --- a/internal/coreapi/client.go +++ b/internal/coreapi/client.go @@ -227,3 +227,14 @@ func APIError(err error) string { return fmt.Sprintf("control-plane request failed with status %d", statusErr.StatusCode) } } + +// HTTPStatus reports the HTTP status code carried by a control-plane API +// error and whether err was one. It returns (0, false) for transport or +// local failures that never reached the server. +func HTTPStatus(err error) (int, bool) { + var statusErr *ErrorModelStatusCode + if !errors.As(err, &statusErr) { + return 0, false + } + return statusErr.StatusCode, true +} From 0e92d8685033748e61117ca26a63d652029e07ba Mon Sep 17 00:00:00 2001 From: Daniel Vydra Date: Tue, 23 Jun 2026 16:20:46 +1000 Subject: [PATCH 3/3] fix(cor-573): use bare canonical 503 headline; note drain-path boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the " — please try again shortly" suffix so the CLI 503 message is byte-identical to the COR-566 edge page

and the SPA banner heading (per the COR-573 spec: special-case 503 with the bare headline). Add a boundary comment: a draining/black-holed host (COR-572) times out rather than returning 503, so only an explicit 503 maps to the maintenance copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/corecmd.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/corecmd.go b/cmd/entire/cli/corecmd.go index 7f95596f75..f0e0750b42 100644 --- a/cmd/entire/cli/corecmd.go +++ b/cmd/entire/cli/corecmd.go @@ -306,9 +306,9 @@ func markRequired(cmd *cobra.Command, names ...string) { } } -// maintenanceMessage is the shared 503 scheduled-maintenance copy, kept -// identical to the Cloudflare edge 503 page (COR-566) and the SPA banner. -const maintenanceMessage = "Entire is under scheduled maintenance — please try again shortly" +// maintenanceMessage is the canonical 503 headline, byte-identical to the +// COR-566 edge page

and the SPA maintenance banner heading. +const maintenanceMessage = "Entire is under scheduled maintenance" // renderCoreError converts a Core API error into the server's // problem-detail message (so users see "organization name already taken" @@ -323,6 +323,8 @@ func renderCoreError(err error) error { if err == nil { return nil } + // Only an explicit 503 maps to maintenance copy. A draining or black-holed + // host (COR-572) surfaces as a timeout, not a 503, and stays a generic error. if status, ok := coreapi.HTTPStatus(err); ok && status == http.StatusServiceUnavailable { return errors.New(maintenanceMessage) //nolint:staticcheck // user-facing copy, shared verbatim across CLI/SPA/edge }