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
22 changes: 22 additions & 0 deletions cmd/orun/command_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,19 @@ func errWorkspaceRequired() error {
return fmt.Errorf("%s", b.String())
}

// projectID returns rt.project as a public prj_… id, resolving a slug via the
// backend on first use (memoized). The repo link stores ids so linked repos
// pass through; an intent-declared project is a slug and MUST be resolved —
// config-surface routes 404 on slugs.
func (rt *secretsRuntime) projectID(ctx context.Context) (string, error) {
id, err := rt.client.ResolveProjectID(ctx, rt.org, rt.project)
if err != nil {
return "", err
}
rt.project = id
return id, nil
}

// targetScope maps the rung-selector flags to a configsurface.Scope plus a
// human label. With no selector: the project rung when defaultToProject (list),
// otherwise an actionable missing---env error naming the declared envs.
Expand All @@ -474,6 +487,9 @@ func (rt *secretsRuntime) targetScope(ctx context.Context, defaultToProject bool
if strings.TrimSpace(rt.project) == "" {
return configsurface.Scope{}, "", errRepoNotLinked(rt.backendURL)
}
if _, err := rt.projectID(ctx); err != nil {
return configsurface.Scope{}, "", err
}
return configsurface.Scope{Kind: configsurface.ScopeProject, Org: rt.org, Project: rt.project},
fmt.Sprintf("project %q", rt.project), nil
case secretsWorkspFlag:
Expand All @@ -483,6 +499,9 @@ func (rt *secretsRuntime) targetScope(ctx context.Context, defaultToProject bool
if strings.TrimSpace(rt.project) == "" {
return configsurface.Scope{}, "", errRepoNotLinked(rt.backendURL)
}
if _, err := rt.projectID(ctx); err != nil {
return configsurface.Scope{}, "", err
}
return configsurface.Scope{Kind: configsurface.ScopeProject, Org: rt.org, Project: rt.project},
fmt.Sprintf("project %q", rt.project), nil
default:
Expand All @@ -496,6 +515,9 @@ func (rt *secretsRuntime) environmentScope(ctx context.Context, env string) (con
if strings.TrimSpace(rt.project) == "" {
return configsurface.Scope{}, "", errRepoNotLinked(rt.backendURL)
}
if _, err := rt.projectID(ctx); err != nil {
return configsurface.Scope{}, "", err
}
envID, err := rt.client.ResolveEnvironmentID(ctx, rt.org, rt.project, env)
if err != nil {
return configsurface.Scope{}, "", err
Expand Down
80 changes: 80 additions & 0 deletions internal/configsurface/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,13 @@ type Environment struct {
Name string `json:"name,omitempty"`
}

// Project is the minimal projects-list row the CLI needs (slug→id resolution).
type Project struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name,omitempty"`
}

// Client speaks the config surface. It reuses the remotestate TokenSource so
// auth resolution (session refresh, OIDC exchange) stays in one place.
type Client struct {
Expand All @@ -373,6 +380,9 @@ type Client struct {
// envCache caches environment slug→id per (org, project) for the lifetime
// of one CLI invocation.
envCache map[string]map[string]string
// projectCache caches project slug→id per org for the lifetime of this
// client (one CLI invocation).
projectCache map[string]map[string]string
}

// NewClient creates a config-surface client for baseURL.
Expand All @@ -383,6 +393,7 @@ func NewClient(baseURL, version string, tokenSrc remotestate.TokenSource) *Clien
userAgent: "orun-cli/" + version,
httpClient: &http.Client{Timeout: defaultTimeout},
envCache: map[string]map[string]string{},
projectCache: map[string]map[string]string{},
}
}

Expand Down Expand Up @@ -575,6 +586,75 @@ func (c *Client) ListEnvironments(ctx context.Context, org, project string) ([]E
return out, nil
}

// ListProjects calls GET /v1/organizations/{org}/projects.
func (c *Client) ListProjects(ctx context.Context, org string) ([]Project, error) {
if strings.TrimSpace(org) == "" {
return nil, fmt.Errorf("configsurface: project listing needs an organization")
}
path := "/v1/organizations/" + urlSegment(org) + "/projects"
var body json.RawMessage
if err := c.doJSON(ctx, http.MethodGet, path, nil, &body, true); err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
items, _, err := decodeItems(body, "projects")
if err != nil {
return nil, fmt.Errorf("list projects: decoding response: %w", err)
}
var out []Project
if err := json.Unmarshal(items, &out); err != nil {
return nil, fmt.Errorf("list projects: decoding response: %w", err)
}
return out, nil
}

// ResolveProjectID resolves a project slug to its public prj_… id via the
// projects list, caching per org for this invocation. A value that already
// looks like a public id (prj_…) passes through. Config-surface routes take
// project IDS only — an intent-declared project is a SLUG, and without this
// hop every headless run without a local repo link 404s.
func (c *Client) ResolveProjectID(ctx context.Context, org, project string) (string, error) {
project = strings.TrimSpace(project)
if project == "" {
return "", fmt.Errorf("configsurface: empty project")
}
if strings.HasPrefix(project, "prj_") {
return project, nil
}
if byslug, ok := c.projectCache[org]; ok {
if id, ok := byslug[strings.ToLower(project)]; ok {
return id, nil
}
return "", c.unknownProjectError(project, byslug)
}
projects, err := c.ListProjects(ctx, org)
if err != nil {
return "", err
}
byslug := make(map[string]string, len(projects))
for _, p := range projects {
if p.Slug != "" && p.ID != "" {
byslug[strings.ToLower(p.Slug)] = p.ID
}
}
c.projectCache[org] = byslug
if id, ok := byslug[strings.ToLower(project)]; ok {
return id, nil
}
return "", c.unknownProjectError(project, byslug)
}

func (c *Client) unknownProjectError(project string, byslug map[string]string) error {
if len(byslug) == 0 {
return fmt.Errorf("project %q not found: the workspace has no projects", project)
}
slugs := make([]string, 0, len(byslug))
for s := range byslug {
slugs = append(slugs, s)
}
sort.Strings(slugs)
return fmt.Errorf("project %q not found; available: %s", project, strings.Join(slugs, ", "))
}

// ResolveEnvironmentID resolves an environment slug to its public env_… id
// via the environments list, caching per (org, project) for this invocation.
// A value that already looks like a public id (env_…) passes through.
Expand Down
Loading