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
+}