Skip to content

feat(env): add link / unlink / links commands for env variables#2120

Open
jlaneve wants to merge 4 commits into
mainfrom
ai-475-link
Open

feat(env): add link / unlink / links commands for env variables#2120
jlaneve wants to merge 4 commits into
mainfrom
ai-475-link

Conversation

@jlaneve
Copy link
Copy Markdown
Contributor

@jlaneve jlaneve commented May 4, 2026

Summary

Adds explicit per-deployment linking — including per-link value overrides — for workspace-scoped env variables.

Today, the only way to attach a workspace var to specific deployments via the CLI is --auto-link (all-or-nothing). To get "shared default with per-deployment differences" you have to use the UI. This PR closes that gap.

Variables only this PR; airflow-variable / connection / metrics-export linking will follow in focused PRs once this shape is validated.

Mental model

The platform stores overrides on the link itself, not as separate objects:

WorkspaceVar { value: "default", autoLinkDeployments: bool }
  ├── Link    → Deployment A  [override?: { value: "for-A" }]
  ├── Link    → Deployment B  [no override → uses workspace default]
  └── Exclude → Deployment C  (only meaningful with autoLinkDeployments=true)

When a deployment resolves the linked var, the override (if present) replaces the workspace default — but only for that deployment. The workspace value and other deployments are unaffected.

New commands

astro env variable link <key>   --workspace-id <ws> --deployment-id <dep> [--value <override>]
astro env variable link <key>   --workspace-id <ws> --deployment-id <dep> --exclude
astro env variable unlink <key> --workspace-id <ws> --deployment-id <dep> [--exclude]
astro env variable links <key>  --workspace-id <ws> [--format table|json|yaml] [--include-secrets]

--value and --exclude are mutually exclusive (excludes don't carry override values).

Upsert semantics

link is idempotent and partial-update:

  • If not yet linked, the link is created (with override if --value is passed).
  • If already linked, the override is replaced when --value is passed.
  • Re-running without --value is a no-op for an existing link's override (the platform's PATCH preserves omitted fields).
  • To remove an existing override: unlink then link without --value. Two-step, explicit. (Tracked upstream as AINF-1809 — the API doesn't currently support "explicit clear" in a single PATCH.)

link --exclude is also idempotent (excluding an already-excluded deployment is a no-op success). unlink and unlink --exclude keep reject-on-not-present, since removal of something that isn't there is usually a typo.

Example workflows

1. Dev / staging / prod from one workspace var

# Link prod with an override
astro env variable link DATABASE_URL --workspace-id $WS \
  --deployment-id prod-dep --value "postgres://prod-host:5432/app"

# Confirm the link has the override
astro env variable links DATABASE_URL --workspace-id $WS

# Verify what the prod deployment actually sees
astro env variable list --deployment-id prod-dep
# → DATABASE_URL    postgres://prod-host:5432/app

2. Rotate the override

link is upsert, so just re-run with the new value:

astro env variable link DATABASE_URL --workspace-id $WS \
  --deployment-id prod-dep --value "postgres://new-host:5432/app"

3. Shared-by-default, opt out one deployment

# Var is auto-linked to every deployment
astro env variable create --workspace-id $WS --key LOG_LEVEL --value DEBUG --auto-link

# Exclude the noisy one
astro env variable link LOG_LEVEL --workspace-id $WS \
  --deployment-id low-noise-dep --exclude

# That deployment no longer sees the var
astro env variable list --deployment-id low-noise-dep | grep LOG_LEVEL
# → (no match)

4. Auto-link with one specific override

Keep auto-link on so every deployment gets the workspace default, but give one deployment a different value.

astro env variable create --workspace-id $WS --key API_BASE_URL \
  --value "https://api.example.com" --auto-link

astro env variable link API_BASE_URL --workspace-id $WS \
  --deployment-id staging-dep --value "https://staging-api.example.com"

# Every deployment sees the workspace default EXCEPT staging-dep
astro env variable list --deployment-id staging-dep
# → API_BASE_URL    https://staging-api.example.com

This is one of the cases the platform investigation explicitly confirmed: explicit Links and autoLinkDeployments=true coexist, and the explicit link's override wins for that deployment.

5. Auditing

# Human-readable
astro env variable links DATABASE_URL --workspace-id $WS

# Machine-readable for scripts
astro env variable links DATABASE_URL --workspace-id $WS --format json | \
  jq '.links[] | {dep: .deploymentId, override: .overrideValue}'

6. Cleanup

# Remove a single explicit link (and its override)
astro env variable unlink DATABASE_URL --workspace-id $WS --deployment-id prod-dep

# Remove a single exclude (re-include in auto-link)
astro env variable unlink LOG_LEVEL --workspace-id $WS --deployment-id low-noise-dep --exclude

# Clear an override but keep the link: unlink, then re-link without --value
astro env variable unlink DATABASE_URL --workspace-id $WS --deployment-id prod-dep
astro env variable link   DATABASE_URL --workspace-id $WS --deployment-id prod-dep

Wire-level details

For reviewers who want to dig in:

  • link and unlink PATCH /environment-objects/{id} with the modified Links array. The platform doesn't expose an incremental "add one link" endpoint, so we GET-modify-PATCH.
  • link --exclude uses the dedicated POST /environment-objects/{id}/exclude-linking endpoint (single-shot, incremental).
  • unlink --exclude PATCHes with the trimmed excludeLinks array (no incremental endpoint exists for removal).
  • AINF-1792 mitigation: every PATCH echoes autoLinkDeployments back. The platform clears that field when an update body omits it. Stress-tested with 6 mixed link/unlink/exclude/unexclude operations on an autoLinkDeployments=true var; the flag survived all 6.
  • Platform requires the typed body: every PATCH must include environmentVariable: { value: ... } even when only mutating links. The CLI re-sends the existing workspace value so this is a no-op for the value while satisfying the type-discriminator check.

Validation surface

Input Result
--deployment-id "" rejected pre-API: "deployment-id cannot be empty"
--deployment-id "garbage" rejected pre-API: "is not a valid deployment ID (expected a CUID)"
Both --value and --exclude cobra rejects mutual exclusion
Linking a deployment-scoped var rejected: "linking commands require --workspace-id"
Linking a var that doesn't exist "environment object not found: "
link on already-linked deployment upsert (no error)
link --exclude on already-excluded deployment idempotent no-op (no error)
Linking a deployment that's already excluded "has deployment in its exclude list; remove the exclude first"
Excluding a deployment that's already explicitly linked "is explicitly linked to deployment ; unlink first to exclude"
unlink / unlink --exclude on not-present rejected (typo protection)
Cross-workspace mismatch platform "workspace not found"
Nonexistent deployment platform "deployment with id not found"

Related upstream issues

  • AINF-1808 — link API returns 500 instead of 4xx on invalid scopeEntityId. Worked around with CLI pre-validation.
  • AINF-1809 — PATCH should support clearing per-link overrides. Worked around with the unlink+link two-step.
  • AINF-1792autoLinkDeployments cleared when omitted from PATCH. Mitigated by echoing the field on every PATCH.

Verified end-to-end

Real workspace + multiple real deployments, every operation cleaned up after.

Path Result
link without override
link --value <override>, override visible at deployment scope
Re-link with new --value (upsert replaces override)
Re-link without --value (no-op for existing override)
link --value "" (empty-string override)
Multi-line override (newlines preserved)
Unicode override
5000-char override
Override with $, quotes, backslashes ✅ exact bytes preserved
link to two deployments, distinct overrides, isolation between them
Unlink one deployment of a multi-link, the other survives
link --exclude on already-excluded deployment ✅ idempotent
Auto-link + exclude → deployment doesn't see var
Auto-link + explicit link --value → override wins at deployment
Secret var override hidden, --include-secrets reveals it
AINF-1792: every PATCH path preserves autoLinkDeployments=true ✅ stress-tested across 6 mixed ops
Clear-override workflow: unlink + re-link without --value

What is not in this PR

  • airflow-variable / connection / metrics-export link — follow-up; each has its own override-flag set.
  • Bulk linking via --from-file — could chain naturally later (sibling PR feat(env): add --from-file bulk import for env and airflow variables #2119 adds bulk import for create/update; same pattern would extend here).
  • An explicit update-link verb — link is upsert, so this is unnecessary.
  • astro env variable links (no key) to audit every workspace var at once.

Test plan

  • go test ./... green
  • go vet ./... clean
  • golangci-lint run ./... clean
  • 11 unit tests in cloud/env/link_test.go (link/unlink/exclude/unexclude/list/upsert-override/upsert-preserves-other-links/idempotent-exclude/scope-validation/deployment-id-validation)
  • Five rounds of adversarial binary smoke testing against a real workspace + multiple real deployments
  • AINF-1792 stress test: 6 mixed PATCH operations on an autoLinkDeployments=true var, flag survives all 6
  • AINF-1808 and AINF-1809 filed for the platform-side gaps surfaced during smoke testing

🤖 Generated with Claude Code

Adds three new subcommands under 'astro env variable':
  link <key>   --deployment-id <dep> [--value <override>] [--exclude]
  unlink <key> --deployment-id <dep> [--exclude]
  links <key>

link/unlink default to the workspace object's Links list (with optional
per-link override value). With --exclude they target excludeLinks instead,
useful for opting specific deployments out of an --auto-link var.
links shows the consolidated state (workspace value, autoLink flag, all
links with their overrides, all excludes).

The CLI uses the platform's dedicated POST .../exclude-linking endpoint
for adding excludes; everything else goes through PATCH on the env-object,
echoing autoLinkDeployments back to dodge AINF-1792 (the platform clears
that field when an update body omits it).

Verified end-to-end against a real workspace + deployment:
- link/unlink with and without override
- override visible at deployment scope
- exclude removes from auto-link resolved set
- explicit link with override coexists with auto-link, override wins
- every PATCH path preserves autoLinkDeployments=true

Variables only this PR; airflow-variable / connection / metrics-export
linking will follow once shape is validated.
@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented May 4, 2026

Coverage Report for CI Build 6

Coverage increased (+0.1%) to 39.834%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 170 uncovered changes across 2 files (252 of 422 lines covered, 59.72%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
cmd/cloud/env_var_link.go 152 35 23.03%
cloud/env/link.go 267 214 80.15%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 66198
Covered Lines: 26369
Line Coverage: 39.83%
Coverage Strength: 9.36 hits per line

💛 - Coveralls

jlaneve and others added 2 commits May 4, 2026 12:36
…ted overrides

Two issues found in adversarial smoke testing:

1. ListVarLinks hardcoded includeSecrets=false on its underlying GET, so
   per-link override values for secret vars were always redacted, even when
   the user passed --include-secrets. Plumb the flag through.

2. The table renderer showed "-" both for "no override" and for "override
   redacted because var is secret and --include-secrets is off" - the user
   couldn't tell which. Now renders "(hidden, use --include-secrets)" for
   the redacted case so the ambiguity is surfaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty or non-CUID --deployment-id values were being passed straight to the
platform, which responds with an opaque "Internal server error" instead of
a useful message. Add validateDeploymentID to LinkVar/UnlinkVar/ExcludeVar/
UnexcludeVar so the CLI rejects with:
  - "--deployment-id cannot be empty" for empty
  - "%q is not a valid deployment ID (expected a CUID)" for malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jlaneve jlaneve marked this pull request as ready for review May 4, 2026 16:51
@jlaneve jlaneve requested a review from a team as a code owner May 4, 2026 16:51
Replaces the duplicate-link rejection with upsert semantics. The platform's
PATCH on links/excludeLinks merges per-entry rather than fully replacing the
array, so absent fields are preserved (Option A). This shapes the contract:

  link <key>             # ensure linked; preserves any existing override
  link <key> --value v   # ensure linked with override v (replaces existing)
  link <key> --exclude   # ensure excluded; idempotent no-op if already

Removing an existing override requires unlink + relink (two steps). Filed
AINF-1809 upstream so the API can support explicit-clear in a future PATCH.

unlink and unlink --exclude keep their reject-on-not-present behavior since
removal of something that isn't there is usually a typo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@neel-astro neel-astro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly looks good to me, apart from a comment around command structure

Comment thread cloud/env/link.go
if depID == "" {
return errors.New("--deployment-id cannot be empty")
}
if !organization.IsCUID(depID) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move IsCUID to utils instead of referencing cross-border from the organization package

Comment thread cloud/env/link.go
report.WorkspaceValue = current.EnvironmentVariable.Value
report.IsSecret = current.EnvironmentVariable.IsSecret
}
if current.Links != nil {
Copy link
Copy Markdown
Contributor

@neel-astro neel-astro May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still do not understand why codegen creates pointers to an array :), not a comment for the logic, just a frustration :)

Comment thread cmd/cloud/env_var_link.go

func newEnvVarLinksCmd(out io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "links <id-or-key>",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have been using the format of <resource> <action>, keeping the resource as singular throughout the CLI commands with parent resource ID passed as flags instead of args (ex: astro deployment team list --deployment-id or astro deployment token organization-token list --deployment-id), so I think a more apt version would be astro env var link list --variable-id or astro env var link list --variable-key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar comment for link and unlink command, where the variable id or key would be a parent resource identifier, so pass that on as a flags instead of args

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants