Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ jobs:
# accidentally widen the page surface) and intentionally does NOT
# match ``cancelled`` / ``timed_out`` (concurrency cancellation
# should not page anyone).
if: needs.agent-evals.result == 'failure' && github.event_name == 'schedule'
#
# ``always()`` is required so the runner evaluates the full
# condition even when the ``needs:`` job was skipped (the default
# ``success()`` short-circuit would otherwise produce a CANCELLED
# status on every PR run that did not carry the ``run-evals``
# label, polluting the rollup with a fake failure).
if: always() && needs.agent-evals.result == 'failure' && github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
Expand Down
39 changes: 8 additions & 31 deletions cli/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,39 +43,16 @@ linters:
- name: cognitive-complexity
arguments: [15]
exclusions:
# Absorb existing CLI complexity via path-scoped exclusions. The
# complexity linters (gocyclo, funlen, gocognit, nestif, revive
# cognitive-complexity / unused-receiver) catch shapes the existing
# CLI carries: the compose generator and the doctor / verify
# subcommands hit these thresholds today. The rules still apply to
# any new package not listed below.
# Table tests and OS-conditional branches in test files are excluded
# from all five complexity rules: the signal lives in production
# code, not in test data enumeration. This matches the industry
# standard (golangci-lint docs explicitly recommend this set; the
# same exclusion is shipped by kubernetes, docker, prometheus,
# hashicorp, etc.). Production code, in contrast, has zero
# path-scoped exclusions.
rules:
- path: 'internal/compose/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/verify/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/diagnostics/'
linters: [gocyclo, funlen, gocognit, nestif]
- path: 'internal/health/'
linters: [gocyclo, funlen, gocognit, nestif]
- path: 'internal/ui/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/selfupdate/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/backup/'
linters: [gocyclo, funlen, gocognit, nestif]
- path: 'internal/config/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/scaffold/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/completion/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'internal/docker/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: 'cmd/'
linters: [gocyclo, funlen, gocognit, nestif, revive]
- path: '_test\.go$'
linters: [gocognit, revive]
linters: [gocyclo, funlen, gocognit, nestif, revive]

formatters:
enable:
Expand Down
206 changes: 106 additions & 100 deletions cli/cmd/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,47 +282,70 @@ func backupAPIRequest(ctx context.Context, port int, method, path string, body [
if path != "" && path != "/restore" {
return nil, 0, fmt.Errorf("unexpected API path %q", path)
}

base := fmt.Sprintf("http://localhost:%d/api/v1/admin/backups", port)
apiURL, err := url.JoinPath(base, path)
apiURL, err := url.JoinPath(fmt.Sprintf("http://localhost:%d/api/v1/admin/backups", port), path)
if err != nil {
return nil, 0, fmt.Errorf("building URL: %w", err)
}

ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := buildBackupRequest(ctx, method, apiURL, body, jwtSecret)
if err != nil {
return nil, 0, err
}
resp, err := backupClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("backend unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB limit
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
}
return respBody, resp.StatusCode, nil
}

// buildBackupRequest constructs the HTTP request, setting Content-Type
// for any JSON body and attaching a short-lived Bearer token when the
// caller supplied a JWT signing secret.
func buildBackupRequest(ctx context.Context, method, apiURL string, body []byte, jwtSecret string) (*http.Request, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}

req, err := http.NewRequestWithContext(ctx, method, apiURL, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("building request: %w", err)
return nil, fmt.Errorf("building request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if jwtSecret != "" {
token, err := buildLocalJWT(jwtSecret)
if err != nil {
return nil, 0, fmt.Errorf("building JWT: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if jwtSecret == "" {
return req, nil
}

resp, err := backupClient.Do(req)
token, err := buildLocalJWT(jwtSecret)
if err != nil {
return nil, 0, fmt.Errorf("backend unreachable: %w", err)
return nil, fmt.Errorf("building JWT: %w", err)
}
defer func() { _ = resp.Body.Close() }()
req.Header.Set("Authorization", "Bearer "+token)
return req, nil
}

respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB limit
// resolveBackupTimeout returns the effective backup timeout for cmd.
// Precedence: explicit flag > env/config (resolved into Tunables) >
// the literal default. flagName must be the Cobra flag name ("timeout").
func resolveBackupTimeout(cmd *cobra.Command, flagValue, flagName string, fallback time.Duration) (time.Duration, error) {
value := flagValue
if !cmd.Flags().Changed(flagName) {
value = fallback.String()
}
d, err := time.ParseDuration(value)
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
return 0, fmt.Errorf("invalid --%s %q: %w", flagName, value, err)
}
return respBody, resp.StatusCode, nil
if d <= 0 {
return 0, fmt.Errorf("invalid --%s %q: must be > 0", flagName, value)
}
return d, nil
}

// parseAPIResponse decodes the ApiResponse envelope and returns the raw data
Expand Down Expand Up @@ -389,20 +412,9 @@ func runBackupCreate(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
opts := GetGlobalOpts(ctx)

// Flag default is intentionally a literal so `--help` shows the
// compile-time baseline; the env/config override is applied here
// when the user did not pass --timeout explicitly. Precedence:
// explicit flag > env/config (resolved into Tunables) > literal default.
timeoutStr := backupCreateTimeout
if !cmd.Flags().Changed("timeout") {
timeoutStr = opts.Tunables.BackupCreateTimeout.String()
}
timeout, err := time.ParseDuration(timeoutStr)
timeout, err := resolveBackupTimeout(cmd, backupCreateTimeout, "timeout", opts.Tunables.BackupCreateTimeout)
if err != nil {
return fmt.Errorf("invalid --timeout %q: %w", timeoutStr, err)
}
if timeout <= 0 {
return fmt.Errorf("invalid --timeout %q: must be > 0", timeoutStr)
return err
}

state, err := config.Load(opts.DataDir)
Expand Down Expand Up @@ -473,60 +485,57 @@ func runBackupList(cmd *cobra.Command, _ []string) error {
if err := validateBackupListFlags(); err != nil {
return err
}

ctx := cmd.Context()
opts := GetGlobalOpts(ctx)

state, err := config.Load(opts.DataDir)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
out := ui.NewUIWithOptions(cmd.OutOrStdout(), opts.UIOptions())
errOut := ui.NewUIWithOptions(cmd.ErrOrStderr(), opts.UIOptions())

body, statusCode, err := backupAPIRequest(ctx, state.BackendPort, http.MethodGet, "", nil, 10*time.Second, state.JWTSecret)
if err != nil {
return fmt.Errorf("listing backups: %w", err)
}

if statusCode < 200 || statusCode >= 300 {
msg := sanitizeAPIMessage(apiErrorMessage(body, "failed to list backups"))
errOut.Error(msg)
return errors.New(msg)
}

data, err := parseAPIResponse(body)
backups, err := fetchBackupList(ctx, state, errOut)
if err != nil {
errOut.Error(sanitizeAPIMessage(err.Error()))
return err
}

var backups []backupInfo
if err := json.Unmarshal(data, &backups); err != nil {
errOut.Error(fmt.Sprintf("parsing backup list: %v", err))
return fmt.Errorf("parsing backup list: %w", err)
}

if len(backups) == 0 {
errOut.Warn("No backups found")
errOut.HintNextStep("Run 'synthorg backup' to create one")
return nil
}

// --sort: sort by criterion.
sortBackups(backups, backupListSort)

// --limit: truncate to N most recent.
if backupListLimit > 0 && len(backups) > backupListLimit {
backups = backups[:backupListLimit]
}

printBackupTable(out, backups)
out.HintTip("Run 'synthorg backup restore <id> --confirm' to restore a backup")
out.HintGuidance("Use --limit N to show fewer results, or --sort size to find the largest.")
return nil
}

// fetchBackupList calls the admin/backups API and decodes the envelope.
func fetchBackupList(ctx context.Context, state config.State, errOut *ui.UI) ([]backupInfo, error) {
body, statusCode, err := backupAPIRequest(ctx, state.BackendPort, http.MethodGet, "", nil, 10*time.Second, state.JWTSecret)
if err != nil {
return nil, fmt.Errorf("listing backups: %w", err)
}
if statusCode < 200 || statusCode >= 300 {
msg := sanitizeAPIMessage(apiErrorMessage(body, "failed to list backups"))
errOut.Error(msg)
return nil, errors.New(msg)
}
data, err := parseAPIResponse(body)
if err != nil {
errOut.Error(sanitizeAPIMessage(err.Error()))
return nil, err
}
var backups []backupInfo
if err := json.Unmarshal(data, &backups); err != nil {
errOut.Error(fmt.Sprintf("parsing backup list: %v", err))
return nil, fmt.Errorf("parsing backup list: %w", err)
}
return backups, nil
}

// sortBackups sorts a backup list by the specified criterion.
// Uses SliceStable with BackupID tie-breaker for deterministic output.
func sortBackups(backups []backupInfo, criterion string) {
Expand Down Expand Up @@ -557,82 +566,79 @@ func sortBackups(backups []backupInfo, criterion string) {

func runBackupRestore(cmd *cobra.Command, args []string) error {
backupID := args[0]

// Validate backup ID format before anything else.
if !isValidBackupID(backupID) {
return fmt.Errorf("invalid backup ID %q: must be a 12-character hex string", backupID)
}

opts := GetGlobalOpts(cmd.Context())
errOut := ui.NewUIWithOptions(cmd.ErrOrStderr(), opts.UIOptions())

// Check --confirm flag.
confirm, err := cmd.Flags().GetBool("confirm")
if err != nil {
return fmt.Errorf("reading --confirm flag: %w", err)
}
if !confirm {
errOut.Error("Restore requires the --confirm flag as a safety gate")
errOut.HintNextStep(fmt.Sprintf("Run 'synthorg backup restore %s --confirm' to proceed", backupID))
return NewExitError(ExitUsage, errors.New("--confirm flag is required"))
}

timeoutStr := backupRestoreTimeout
if !cmd.Flags().Changed("timeout") {
timeoutStr = opts.Tunables.BackupRestoreTimeout.String()
}
timeout, parseErr := time.ParseDuration(timeoutStr)
if parseErr != nil {
return fmt.Errorf("invalid --timeout %q: %w", timeoutStr, parseErr)
if err := assertRestoreConfirmFlag(cmd, errOut, backupID); err != nil {
return err
}
if timeout <= 0 {
return fmt.Errorf("invalid --timeout %q: must be > 0", timeoutStr)
timeout, err := resolveBackupTimeout(cmd, backupRestoreTimeout, "timeout", opts.Tunables.BackupRestoreTimeout)
if err != nil {
return err
}

state, err := config.Load(opts.DataDir)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

// Validate paths early, consistent with stop.go.
safeDir, err := safeStateDir(state)
if err != nil {
return err
}

out := ui.NewUIWithOptions(cmd.OutOrStdout(), opts.UIOptions())

// --dry-run: show what would be restored and exit.
if backupRestoreDryRun {
out.Step("Dry run: would restore from backup " + backupID)
out.KeyValue("Backup ID", backupID)
out.KeyValue("Data directory", safeDir)
out.KeyValue("Restart", boolToYesNo(!backupRestoreNoRestart))
out.HintNextStep("Remove --dry-run to execute the restore")
return nil
return renderRestoreDryRun(out, backupID, safeDir)
}
return executeRestoreRequest(cmd, out, errOut, state, safeDir, backupID, timeout)
}

// executeRestoreRequest posts the restore call and dispatches to the
// success / error renderer.
func executeRestoreRequest(cmd *cobra.Command, out, errOut *ui.UI, state config.State, safeDir, backupID string, timeout time.Duration) error {
out.Step("Restoring from backup " + backupID + "...")

reqBody, err := json.Marshal(restoreRequest{BackupID: backupID, Confirm: true})
if err != nil {
return fmt.Errorf("building restore request: %w", err)
}

body, statusCode, err := backupAPIRequest(
cmd.Context(), state.BackendPort, http.MethodPost, "/restore", reqBody, timeout, state.JWTSecret,
)
if err != nil {
return fmt.Errorf("restoring backup: %w", err)
}

if statusCode < 200 || statusCode >= 300 {
return handleRestoreError(errOut, body, statusCode, backupID)
}

return renderRestoreSuccess(cmd, out, errOut, body, safeDir)
}

// assertRestoreConfirmFlag checks that --confirm was passed. Without
// it, restore is rejected: the user must opt in to a destructive
// rollback.
func assertRestoreConfirmFlag(cmd *cobra.Command, errOut *ui.UI, backupID string) error {
confirm, err := cmd.Flags().GetBool("confirm")
if err != nil {
return fmt.Errorf("reading --confirm flag: %w", err)
}
if !confirm {
errOut.Error("Restore requires the --confirm flag as a safety gate")
errOut.HintNextStep(fmt.Sprintf("Run 'synthorg backup restore %s --confirm' to proceed", backupID))
return NewExitError(ExitUsage, errors.New("--confirm flag is required"))
}
return nil
}

// renderRestoreDryRun prints what a restore would do without executing.
func renderRestoreDryRun(out *ui.UI, backupID, safeDir string) error {
out.Step("Dry run: would restore from backup " + backupID)
out.KeyValue("Backup ID", backupID)
out.KeyValue("Data directory", safeDir)
out.KeyValue("Restart", boolToYesNo(!backupRestoreNoRestart))
out.HintNextStep("Remove --dry-run to execute the restore")
return nil
}

// renderRestoreSuccess parses and displays a successful restore response,
// then stops containers if a restart is required.
func renderRestoreSuccess(cmd *cobra.Command, out, errOut *ui.UI, body []byte, safeDir string) error {
Expand Down
4 changes: 4 additions & 0 deletions cli/cmd/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ func writeConfigJSON(t *testing.T, dir string, backendPort int) {
"persistence_backend": "sqlite",
"memory_backend": "mem0",
"jwt_secret": "test-backup-secret-at-least-32-chars",
// encrypt_secrets defaults to true (DefaultState), which now
// requires a master_key. These tests target backup behaviour,
// not encryption, so opt out.
"encrypt_secrets": false,
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
Expand Down
Loading
Loading