diff --git a/cmd/orun/command_auth.go b/cmd/orun/command_auth.go index 3386a817..279f0918 100644 --- a/cmd/orun/command_auth.go +++ b/cmd/orun/command_auth.go @@ -82,6 +82,18 @@ func runAuthStatus() error { creds, err := cliauth.LoadSession() if err != nil { if errors.Is(err, os.ErrNotExist) { + // Headless runs authenticate from the environment, not a stored + // session — reporting "not logged in" with ORUN_TOKEN set sends + // operators chasing a non-existent auth problem (hit live). + if strings.TrimSpace(os.Getenv("ORUN_TOKEN")) != "" { + color := ui.ColorEnabledForWriter(os.Stdout) + fmt.Printf("%s headless: authenticated via ORUN_TOKEN (no stored session)\n", ui.Green(color, "✓")) + if backendURL != "" { + fmt.Printf("Backend URL: %s\n", backendURL) + } + fmt.Println("Resolve a workspace's identity with `orun workspace `.") + return nil + } return fmt.Errorf("not logged in; run `orun auth login` or `orun auth login --device`") } return err diff --git a/cmd/orun/command_cloud.go b/cmd/orun/command_cloud.go index 8f051591..13e667d2 100644 --- a/cmd/orun/command_cloud.go +++ b/cmd/orun/command_cloud.go @@ -138,6 +138,12 @@ func cloudSessionToken(ctx context.Context, backendURL string) (string, error) { } func errNotLoggedIn() error { + // With ORUN_TOKEN set, "set ORUN_TOKEN" is insulting noise: this path is + // then reached because the operation needs something the token cannot + // provide (e.g. a memberships list — workspace-scoped keys have none). + if strings.TrimSpace(os.Getenv("ORUN_TOKEN")) != "" { + return fmt.Errorf("ORUN_TOKEN is set, but this operation needs a stored session or a membership listing the token does not have (workspace-scoped keys list no memberships — resolve identity with `orun workspace ` instead)") + } return fmt.Errorf("not logged in to Orun Cloud; run `orun auth login` (or set ORUN_TOKEN for headless runs)") } diff --git a/cmd/orun/command_secrets.go b/cmd/orun/command_secrets.go index 1cdd7d17..baec372d 100644 --- a/cmd/orun/command_secrets.go +++ b/cmd/orun/command_secrets.go @@ -715,7 +715,12 @@ func renderSecretsWriteError(err error, key string) error { case apiErr.IsLocked(): return fmt.Errorf("cannot write %s: %w\nhint: the key is locked at a higher rung (or conflicts with an existing row); see `orun secrets list --chain --env `", key, err) case apiErr.IsNotFound(): - return fmt.Errorf("cannot write %s: %w\nhint: the scope was not found or you lack access; check `orun cloud status`", key, err) + // Resource-hiding masks authorization as not_found. The "scope + // missing" reading is usually WRONG and cost a live bootstrap a + // half-hour of misdiagnosis: when reads on the same scope work + // (listing succeeded moments before), the credential's ROLE is + // below the write floor — secret writes need an ADMIN-role key. + return fmt.Errorf("cannot write %s: %w\nhint: not_found on a WRITE usually means the credential lacks write access (denials are hidden as not_found). If reads on this scope work, the API key's role is below ADMIN — re-mint it with the admin role. A genuinely missing scope would also fail reads: `orun secrets list --org --project`", key, err) } } return err diff --git a/cmd/orun/command_workspace.go b/cmd/orun/command_workspace.go index af2b239c..5cd9fab1 100644 --- a/cmd/orun/command_workspace.go +++ b/cmd/orun/command_workspace.go @@ -40,6 +40,13 @@ always names the rung that actually won.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 1 { + // "show"/"current" are what people naturally type for the + // bare behavior — resolving them as workspace NAMES sent a + // nonsense request upstream (GET /organizations/show, live). + switch args[0] { + case "show", "current": + return runWorkspaceShow() + } return runWorkspaceShowRemote(cmd.Context(), args[0]) } return runWorkspaceShow() diff --git a/cmd/orun/commands_root.go b/cmd/orun/commands_root.go index 6f01543b..41200e42 100644 --- a/cmd/orun/commands_root.go +++ b/cmd/orun/commands_root.go @@ -255,6 +255,18 @@ func init() { registerAuthCommand(rootCmd) registerCloudCommand(rootCmd) registerWorkspaceCommand(rootCmd) + // `orun version` — the spelling every other CLI accepts. Muscle memory + // (and agent runbooks) type it constantly; erroring with "unknown + // command" reads as a broken install (hit live: an operator reinstalled + // a perfectly good binary over it). + rootCmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print the orun version (same as --version)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("%s version %s\n", cliName, version) + }, + }) registerBackendCommand(rootCmd) registerValidateCommand(rootCmd) registerDebugCommand(rootCmd) diff --git a/internal/composition/archive.go b/internal/composition/archive.go index 34454ed1..ae8bc190 100644 --- a/internal/composition/archive.go +++ b/internal/composition/archive.go @@ -238,4 +238,4 @@ func secureExtractPath(root, name string) (string, error) { return "", fmt.Errorf("archive entry escapes destination: %s", name) } return targetPath, nil -} \ No newline at end of file +} diff --git a/internal/composition/package_ops.go b/internal/composition/package_ops.go index 4730e07b..b810da43 100644 --- a/internal/composition/package_ops.go +++ b/internal/composition/package_ops.go @@ -26,9 +26,9 @@ const ( compositionPackageLayerType = "application/vnd.sourceplane.orun.composition.package.layer.v1.tar+gzip" // Stack OCI media types (orun.io/v1 / kind: Stack format). - stackArtifactType = "application/vnd.orun.stack.v1" - compositionsLayerMediaType = "application/vnd.orun.stack.compositions.layer.v1+tar+gzip" - examplesLayerMediaType = "application/vnd.orun.stack.examples.layer.v1+tar+gzip" + stackArtifactType = "application/vnd.orun.stack.v1" + compositionsLayerMediaType = "application/vnd.orun.stack.compositions.layer.v1+tar+gzip" + examplesLayerMediaType = "application/vnd.orun.stack.examples.layer.v1+tar+gzip" ) // BuildPackageArchive validates a composition package directory and writes a .tgz archive to disk. diff --git a/internal/composition/publish_test.go b/internal/composition/publish_test.go index 28d95c5c..b82cecfd 100644 --- a/internal/composition/publish_test.go +++ b/internal/composition/publish_test.go @@ -78,12 +78,12 @@ func TestSanitizeTag(t *testing.T) { func TestNormalizeOCIRef(t *testing.T) { cases := map[string]string{ - "sourceplane/devops-compositions": "ghcr.io/sourceplane/devops-compositions:latest", - "ghcr.io/acme/aws-vpc": "ghcr.io/acme/aws-vpc:latest", - "ghcr.io/acme/aws-vpc:v1": "ghcr.io/acme/aws-vpc:v1", - "oci://ghcr.io/acme/aws-vpc:v1": "ghcr.io/acme/aws-vpc:v1", - "localhost:5000/acme/x:dev": "localhost:5000/acme/x:dev", - "": "", + "sourceplane/devops-compositions": "ghcr.io/sourceplane/devops-compositions:latest", + "ghcr.io/acme/aws-vpc": "ghcr.io/acme/aws-vpc:latest", + "ghcr.io/acme/aws-vpc:v1": "ghcr.io/acme/aws-vpc:v1", + "oci://ghcr.io/acme/aws-vpc:v1": "ghcr.io/acme/aws-vpc:v1", + "localhost:5000/acme/x:dev": "localhost:5000/acme/x:dev", + "": "", } for in, want := range cases { if got := NormalizeOCIRef(in); got != want { diff --git a/internal/composition/registry.go b/internal/composition/registry.go index 6f379884..d4e60f18 100644 --- a/internal/composition/registry.go +++ b/internal/composition/registry.go @@ -13,7 +13,9 @@ import ( "path/filepath" "sort" "strings" + "time" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/santhosh-tekuri/jsonschema/v5" "github.com/sourceplane/orun/internal/model" "gopkg.in/yaml.v3" @@ -1441,9 +1443,24 @@ func resolveOCIDigest(remoteRef string) (string, error) { return "", err } - desc, err := repo.Resolve(ctx, ref) - if err != nil { - return "", fmt.Errorf("failed to resolve OCI ref %s: %w", remoteRef, err) + // Registries throw transient 5xx/timeouts; a single-shot resolve took + // down a whole apply (and poisoned its retry) over a GHCR 503 that + // cleared seconds later. Three attempts with backoff; non-transient + // errors (404, auth) fail on the first try. + var desc ocispec.Descriptor + for attempt := 1; ; attempt++ { + desc, err = repo.Resolve(ctx, ref) + if err == nil { + break + } + msg := err.Error() + transient := strings.Contains(msg, "503") || strings.Contains(msg, "502") || + strings.Contains(msg, "500") || strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection reset") || strings.Contains(msg, "EOF") + if !transient || attempt >= 3 { + return "", fmt.Errorf("failed to resolve OCI ref %s: %w", remoteRef, err) + } + time.Sleep(time.Duration(attempt*3) * time.Second) } return desc.Digest.String(), nil }