diff --git a/cmd/entire/cli/corecmd.go b/cmd/entire/cli/corecmd.go index d9e649c78a..f0e0750b42 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,28 @@ func markRequired(cmd *cobra.Command, names ...string) { } } +// 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" // 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 } + // 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 + } 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 +}