Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions cmd/entire/cli/corecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"strings"

"charm.land/lipgloss/v2"
Expand Down Expand Up @@ -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 <h1> 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)
}
Expand Down
38 changes: 38 additions & 0 deletions cmd/entire/cli/corecmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
11 changes: 11 additions & 0 deletions internal/coreapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading