From f6457271253f8d605c852060a606a74e62960964 Mon Sep 17 00:00:00 2001 From: Abhishek Chadha Date: Sat, 8 Aug 2026 12:04:42 +0530 Subject: [PATCH 1/3] Allow secrets for custom vercel environments --- src/secretsync/destinations/vercel.py | 11 ++++++++--- tests/integration/test_vercel.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/secretsync/destinations/vercel.py b/src/secretsync/destinations/vercel.py index 4542a1d..fdc7d71 100644 --- a/src/secretsync/destinations/vercel.py +++ b/src/secretsync/destinations/vercel.py @@ -30,7 +30,9 @@ SHARED_ENV_PATH = "/v1/env" DEFAULT_MAX_ITEMS = 100 SHARED_MAX_ITEMS = 50 -SENSITIVE_TARGETS = frozenset({"production", "preview"}) +# Vercel disallows Sensitive env vars only on Development. Custom environments +# (e.g. staging) and production/preview all allow sensitive. +FORBIDDEN_SENSITIVE_TARGETS = frozenset({"development"}) SCOPE_KIND_ENVIRONMENT = "environment" SCOPE_KIND_SHARED = "shared-environment" VALID_SCOPE_KINDS = frozenset({SCOPE_KIND_ENVIRONMENT, SCOPE_KIND_SHARED}) @@ -113,9 +115,12 @@ def _validate_scope( "vs deployment.variables so the connector sets type from kind" ) if kind is ValueKind.SECRET: - illegal = [t for t in targets if t not in SENSITIVE_TARGETS] + illegal = [t for t in targets if t in FORBIDDEN_SENSITIVE_TARGETS] if illegal: - return "sensitive (secret) variables are limited to production and preview targets" + return ( + "sensitive (secret) variables cannot target development " + "(use production, preview, or a custom environment)" + ) git_branch = scope.get("gitBranch") projects = scope.get("projects") diff --git a/tests/integration/test_vercel.py b/tests/integration/test_vercel.py index e03bce4..b72b6ce 100644 --- a/tests/integration/test_vercel.py +++ b/tests/integration/test_vercel.py @@ -95,7 +95,20 @@ async def test_secret_rejects_development_target() -> None: ) assert result.results[0].status == "failed" assert result.results[0].error is not None - assert "sensitive" in result.results[0].error.message.lower() + assert "development" in result.results[0].error.message.lower() + + +@pytest.mark.asyncio +async def test_secret_allows_custom_staging_target() -> None: + """Sensitive vars may target custom environments (e.g. staging), not only production/preview.""" + from secretsync.destinations.vercel import _validate_scope + + err = _validate_scope( + {"kind": "environment", "targets": ["staging", "preview"]}, + kind=ValueKind.SECRET, + destination_project="web", + ) + assert err is None @pytest.mark.asyncio From b44fc9cffc6dca2edc6fdec7491e815930b20f05 Mon Sep 17 00:00:00 2001 From: Abhishek Chadha Date: Mon, 10 Aug 2026 21:41:37 +0530 Subject: [PATCH 2/3] Handle sst secrets with fallbacks correctly --- src/secretsync/application/plan.py | 65 +++++++++++++- src/secretsync/destinations/sst.py | 74 ++++++++++++---- tests/integration/test_sst.py | 118 +++++++++++++++++++++++++- tests/unit/test_prune_reconcile.py | 131 ++++++++++++++++++++++++++++- 4 files changed, 369 insertions(+), 19 deletions(-) diff --git a/src/secretsync/application/plan.py b/src/secretsync/application/plan.py index 4cf3eba..433afdc 100644 --- a/src/secretsync/application/plan.py +++ b/src/secretsync/application/plan.py @@ -269,7 +269,70 @@ def _inventory_units( intended_names=frozenset(bucket["intended_names"]), ) ) - return units + return _ensure_sst_fallback_prune_units(config, selected, units) + + +def _ensure_sst_fallback_prune_units( + config: RootConfig, + selected: list[DeploymentDefinition], + units: list[_InventoryUnit], +) -> list[_InventoryUnit]: + """Ensure each selected SST destination has a fallback secret inventory unit. + + Stage lists omit ``# fallback`` keys (owned separately). Without an explicit + ``fallback: true`` deployment, prune would never see orphaned fallbacks. + Synthesize a unit with intended names from any fallback deployments (often + empty) so ``remote - intended`` deletes leftovers via ``secret remove --fallback``. + """ + sst_deps: dict[str, list[DeploymentDefinition]] = {} + for deployment in selected: + destination = config.destinations[deployment.destination] + if destination.connector != "sst": + continue + sst_deps.setdefault(deployment.destination, []).append(deployment) + + if not sst_deps: + return units + + result = list(units) + for destination_id, deps in sst_deps.items(): + if any( + u.destination_id == destination_id + and u.kind is ValueKind.SECRET + and bool(u.scope.get("fallback")) + for u in result + ): + continue + + intended: set[str] = set() + stage: str | None = None + owner: str | None = None + for deployment in deps: + raw_stage = deployment.scope.get("stage") + if stage is None and isinstance(raw_stage, str) and raw_stage.strip(): + stage = raw_stage.strip() + owner = deployment.name + if bool(deployment.scope.get("fallback")): + intended.update(deployment.secrets.values()) + if owner is None: + owner = deployment.name + if stage is None or owner is None: + continue + + scope: dict[str, JsonValue] = {"stage": stage, "fallback": True} + destination = config.destinations[destination_id] + result.append( + _InventoryUnit( + destination_id=destination_id, + connector_id=destination.connector, + scope=scope, + scope_key=freeze_scope_key(scope), + kind=ValueKind.SECRET, + deployment_ids=(owner,), + intended_names=frozenset(intended), + ) + ) + return result def freeze_scope_key(scope: Mapping[str, JsonValue]) -> str: diff --git a/src/secretsync/destinations/sst.py b/src/secretsync/destinations/sst.py index 01fd078..906c96a 100644 --- a/src/secretsync/destinations/sst.py +++ b/src/secretsync/destinations/sst.py @@ -76,25 +76,62 @@ def _parse_scope(scope: Mapping[str, JsonValue]) -> tuple[str, bool] | None: return stage.strip(), bool(scope.get("fallback", False)) -def parse_sst_secret_list_names(stdout: bytes) -> frozenset[str]: - """Extract secret names from `sst secret list` stdout; discard values immediately.""" - names: set[str] = set() +def _extract_secret_name(line: str) -> str | None: + """Parse one non-comment list line into a secret name; discard values.""" + lower = line.lower() + if lower in {"name", "names", "secret", "secrets"} or set(line) <= {"-", "─", "|", " "}: + return None + if "=" in line: + key = line.split("=", 1)[0].strip() + return key or None + parts = line.split() + return parts[0] if parts else None + + +def parse_sst_secret_list_sections(stdout: bytes) -> tuple[frozenset[str], frozenset[str]]: + """Return (stage_names, fallback_names) from `sst secret list` stdout. + + SST stage lists often print both a ``# fallback`` section and a + ``# /`` section. Keys before any section header are treated as + stage names (flat dotenv / table output). + """ + stage_names: set[str] = set() + fallback_names: set[str] = set() + # None = no header yet → stage (backward compatible with flat output) + in_fallback: bool | None = None + for raw_line in stdout.splitlines(): line = raw_line.decode("utf-8", errors="replace").strip() - if not line or line.startswith("#"): + if not line: continue - lower = line.lower() - if lower in {"name", "names", "secret", "secrets"} or set(line) <= {"-", "─", "|", " "}: + if line.startswith("#"): + header = line[1:].strip().lower() + if header == "fallback": + in_fallback = True + elif header: + # e.g. "# yellowbrick/staging" or other stage section labels + in_fallback = False continue - if "=" in line: - key = line.split("=", 1)[0].strip() - if key: - names.add(key) + name = _extract_secret_name(line) + if name is None: continue - parts = line.split() - if parts: - names.add(parts[0]) - return frozenset(names) + if in_fallback is True: + fallback_names.add(name) + else: + stage_names.add(name) + return frozenset(stage_names), frozenset(fallback_names) + + +def parse_sst_secret_list_names(stdout: bytes) -> frozenset[str]: + """Extract all secret names from `sst secret list` stdout (stage ∪ fallback).""" + stage_names, fallback_names = parse_sst_secret_list_sections(stdout) + return frozenset(stage_names | fallback_names) + + +def _sst_list_empty_inventory(stderr_summary: str, stdout_bytes: bytes) -> bool: + """True when SST reports an empty secret list (non-zero exit, not a real failure).""" + combined = f"{stderr_summary}\n{stdout_bytes.decode('utf-8', errors='replace')}" + return "no secrets found" in combined.lower() @dataclass @@ -204,6 +241,10 @@ async def list_names( except ProcessRunnerError as exc: raise ListNamesError(exc.safe) from exc if result.exit_code != 0: + # SST exits non-zero when the inventory is empty ("No secrets found"), + # including for --fallback after orphans were removed. Treat as empty. + if _sst_list_empty_inventory(result.stderr_summary, result.stdout_bytes): + return frozenset() raise ListNamesError( SafeConnectorError( code="PROCESS_FAILED", @@ -212,9 +253,10 @@ async def list_names( hint=result.stderr_summary or None, ) ) - names = parse_sst_secret_list_names(result.stdout_bytes) + stage_names, fallback_names = parse_sst_secret_list_sections(result.stdout_bytes) del result - return names + # Stage lists include a # fallback section; only return names owned by this scope. + return fallback_names if fallback else stage_names async def apply( self, diff --git a/tests/integration/test_sst.py b/tests/integration/test_sst.py index 354451f..6425168 100644 --- a/tests/integration/test_sst.py +++ b/tests/integration/test_sst.py @@ -25,13 +25,14 @@ class RecordingRunner: calls: list[SecureProcessRequest] = field(default_factory=list) exit_code: int = 0 stdout_bytes: bytes = b"" + stderr_summary: str = "" async def execute(self, request: SecureProcessRequest) -> ProcessResult: self.calls.append(request) return ProcessResult( exit_code=self.exit_code, duration_ms=1, - stderr_summary="", + stderr_summary=self.stderr_summary, stdout_bytes=self.stdout_bytes if request.capture_stdout else b"", ) @@ -182,6 +183,88 @@ async def test_list_names_parses_dotenv_stdout(tmp_path: Path) -> None: assert runner.calls[0].capture_stdout is True +_SECTIONED_LIST = b"""# fallback +TEST_SECRET=meow + +# yellowbrick/staging +STRIPE_API_KEY=meow +YB_DATABASE_URL=meow +""" + + +@pytest.mark.asyncio +async def test_list_names_excludes_fallback_section_for_stage_scope(tmp_path: Path) -> None: + runner = RecordingRunner(stdout_bytes=_SECTIONED_LIST) + dest = _dest(tmp_path, runner) + names = await dest.list_names( + { + "connector": "sst", + "workingDirectory": str(tmp_path), + "executable": "sst", + }, + {"stage": "staging", "fallback": False}, + OperationContext(correlation_id="c1"), + ) + assert names == frozenset({"STRIPE_API_KEY", "YB_DATABASE_URL"}) + assert "TEST_SECRET" not in names + assert "--fallback" not in runner.calls[0].arguments + + +@pytest.mark.asyncio +async def test_list_names_fallback_scope_returns_fallback_section_only( + tmp_path: Path, +) -> None: + runner = RecordingRunner(stdout_bytes=_SECTIONED_LIST) + dest = _dest(tmp_path, runner) + names = await dest.list_names( + { + "connector": "sst", + "workingDirectory": str(tmp_path), + "executable": "sst", + }, + {"stage": "staging", "fallback": True}, + OperationContext(correlation_id="c1"), + ) + assert names == frozenset({"TEST_SECRET"}) + assert "--fallback" in runner.calls[0].arguments + + +@pytest.mark.asyncio +async def test_list_names_empty_inventory_is_not_failure(tmp_path: Path) -> None: + """SST exits non-zero with 'No secrets found' when inventory is empty.""" + from secretsync.destinations.base import ListNamesError + + runner = RecordingRunner( + exit_code=1, + stderr_summary="✕ No secrets found", + ) + dest = _dest(tmp_path, runner) + names = await dest.list_names( + { + "connector": "sst", + "workingDirectory": str(tmp_path), + "executable": "sst", + }, + {"stage": "staging", "fallback": True}, + OperationContext(correlation_id="c1"), + ) + assert names == frozenset() + + runner_fail = RecordingRunner(exit_code=1, stderr_summary="network timeout") + dest_fail = _dest(tmp_path, runner_fail) + with pytest.raises(ListNamesError) as excinfo: + await dest_fail.list_names( + { + "connector": "sst", + "workingDirectory": str(tmp_path), + "executable": "sst", + }, + {"stage": "staging", "fallback": False}, + OperationContext(correlation_id="c1"), + ) + assert "SST secret list failed" in excinfo.value.safe.message + + @pytest.mark.asyncio async def test_delete_calls_secret_remove(tmp_path: Path) -> None: from secretsync.destinations.base import DeleteMutation @@ -211,3 +294,36 @@ async def test_delete_calls_secret_remove(tmp_path: Path) -> None: assert result.results[0].effect == "deleted" assert "remove" in runner.calls[0].arguments assert "Orphan" in runner.calls[0].arguments + assert "--fallback" not in runner.calls[0].arguments + + +@pytest.mark.asyncio +async def test_delete_fallback_passes_fallback_flag(tmp_path: Path) -> None: + from secretsync.destinations.base import DeleteMutation + + runner = RecordingRunner() + dest = _dest(tmp_path, runner, probe_ok=False) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={ + "connector": "sst", + "workingDirectory": str(tmp_path), + "executable": "sst", + }, + mutations=[], + deletes=[ + DeleteMutation( + mutation_id="dep:delete:TEST_SECRET", + name="TEST_SECRET", + scopes=({"stage": "staging", "fallback": True},), + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + assert result.results[0].effect == "deleted" + assert "remove" in runner.calls[0].arguments + assert "TEST_SECRET" in runner.calls[0].arguments + assert "--fallback" in runner.calls[0].arguments diff --git a/tests/unit/test_prune_reconcile.py b/tests/unit/test_prune_reconcile.py index 4202cc6..30cba62 100644 --- a/tests/unit/test_prune_reconcile.py +++ b/tests/unit/test_prune_reconcile.py @@ -1,9 +1,12 @@ from __future__ import annotations +from pathlib import Path + import pytest from secretsync.application.apply import run_clear from secretsync.application.plan import ( + _inventory_units, build_clear_plan_async, build_plan, build_plan_async, @@ -15,7 +18,13 @@ from secretsync.config.loader import ConfigLoader from secretsync.destinations.base import OperationContext from secretsync.destinations.fake import FakePruneFactory, _scope_key -from secretsync.destinations.sst import parse_sst_secret_list_names +from secretsync.destinations.sst import ( + SstFactory, + parse_sst_secret_list_names, + parse_sst_secret_list_sections, +) +from secretsync.domain.models import ValueKind +from secretsync.infrastructure.process import AsyncSecureProcessRunner, ProcessResult from tests.conftest import fixture_path PRUNE_ENV = { @@ -210,6 +219,126 @@ def test_parse_sst_secret_list_names_dotenv_and_table() -> None: assert "BETA" in parse_sst_secret_list_names(table) +def test_parse_sst_secret_list_sections_fallback_vs_stage() -> None: + stdout = b"""# fallback +TEST_SECRET=meow + +# yellowbrick/staging +STRIPE_API_KEY=meow +DISCORD_BOT_TOKEN=meow +YB_DATABASE_URL=meow +""" + stage, fallback = parse_sst_secret_list_sections(stdout) + assert fallback == frozenset({"TEST_SECRET"}) + assert stage == frozenset( + {"STRIPE_API_KEY", "DISCORD_BOT_TOKEN", "YB_DATABASE_URL"} + ) + assert parse_sst_secret_list_names(stdout) == stage | fallback + + +def test_parse_sst_secret_list_sections_flat_is_stage() -> None: + stage, fallback = parse_sst_secret_list_sections(b'FOO="bar"\nBAZ=qux\n') + assert stage == frozenset({"FOO", "BAZ"}) + assert fallback == frozenset() + + +def test_inventory_units_synthesizes_sst_fallback_for_prune() -> None: + """Stage-only SST YAML still gets a fallback inventory unit (empty intended).""" + config = ConfigLoader().load(fixture_path("valid_full.yaml")) + selected = [d for d in config.deployments if d.destination == "sst"] + assert selected + units = _inventory_units(config, selected) + fallback_units = [ + u + for u in units + if u.destination_id == "sst" + and u.kind is ValueKind.SECRET + and bool(u.scope.get("fallback")) + ] + assert len(fallback_units) == 1 + assert fallback_units[0].intended_names == frozenset() + assert fallback_units[0].scope.get("stage") == "production" + + +@pytest.mark.asyncio +async def test_prune_plans_sst_fallback_orphan_deletes(tmp_path: Path) -> None: + """Orphaned fallback secrets are planned for delete with fallback: true scope.""" + + class _ListRunner(AsyncSecureProcessRunner): + async def execute(self, request): # type: ignore[no-untyped-def] + args = list(request.arguments) + if "list" in args and "--fallback" in args: + stdout = b"# fallback\nTEST_SECRET=meow\n" + elif "list" in args: + stdout = b"# app/production\nDatabaseUrl=x\nStripeSecretKey=y\n" + else: + stdout = b"" + return ProcessResult( + exit_code=0, + duration_ms=1, + stdout_bytes=stdout, + stderr_summary="", + ) + + cfg_path = tmp_path / "secretsync.yaml" + cfg_path.write_text( + f""" +version: 1 +changeDetection: always-write +secrets: + databaseUrl: + env: YB_DATABASE_URL + stripeSecretKey: + env: STRIPE_SECRET_KEY +sets: + production: + include: [databaseUrl, stripeSecretKey] +destinations: + sst: + connector: sst + workingDirectory: "{tmp_path.as_posix()}" + executable: sst +deployments: + - name: sst-production + set: production + destination: sst + scope: + stage: production + fallback: false + secrets: + databaseUrl: DatabaseUrl + stripeSecretKey: StripeSecretKey +""", + encoding="utf-8", + ) + + services = create_services(PRUNE_ENV) + original = SstFactory.create + + def _create(self, services_arg): # type: ignore[no-untyped-def] + dest = original(self, services_arg) + dest.process_runner = _ListRunner() + dest._resolved_executable = Path("/usr/bin/true") + dest._argv_prefix = () + dest._probe_ok = False + return dest + + SstFactory.create = _create # type: ignore[method-assign] + try: + config = ConfigLoader().load(cfg_path) + composed = compose_from_config(config) + plan = await build_plan_async(services, config, composed, prune=True) + finally: + SstFactory.create = original # type: ignore[method-assign] + + fallback_deletes = [ + d for d in plan.deletes if d.target.scope.get("fallback") is True + ] + assert len(fallback_deletes) == 1 + assert fallback_deletes[0].target.name == "TEST_SECRET" + assert fallback_deletes[0].target.scope.get("fallback") is True + + def test_build_plan_still_sync_put_only() -> None: config = ConfigLoader().load(fixture_path("fake_prune.yaml")) composed = compose_from_config(config) From ad0d32ac64308ee6e0b2c11cbe946ffa18770533 Mon Sep 17 00:00:00 2001 From: Abhishek Chadha Date: Mon, 10 Aug 2026 21:42:16 +0530 Subject: [PATCH 3/3] Enable custom vercel environments --- README.md | 13 +- docs/ARCHITECTURE.md | 4 +- src/secretsync/destinations/vercel.py | 638 ++++++++++++++++++++++---- tests/integration/test_vercel.py | 184 ++++++++ 4 files changed, 748 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 7b14e6b..038951a 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,9 @@ deployments: destination: vercel scope: kind: environment - targets: [production] + # Builtins (production|preview|development) and/or custom environment slugs. + # Custom slugs (e.g. staging) are resolved to customEnvironmentIds via the API. + targets: [production, staging, preview] secrets: apiKey: API_KEY - name: vercel-shared @@ -123,8 +125,9 @@ deployments: destination: vercel scope: kind: shared-environment - targets: [production] - projects: [prj_abc, prj_def] # optional link set + targets: [production, staging] + # Required when targets include custom slugs (resolved per project, IDs unioned). + projects: [prj_abc, prj_def] secrets: sharedSecret: SHARED_SECRET ``` @@ -176,7 +179,9 @@ Useful flags: `--config`, `--format json`, `--verbose`, `--quiet`, `--deployment With `--prune`, SecretSync lists remote names at plan time (secrets and variables separately) and treats YAML as the full desired inventory for each destination scope + kind — remote entries not listed in the config are planned for deletion. Without `--prune`, apply is put-only. -For Vercel, a remote env var belongs to a deployment only when its target set **exactly** matches `scope.targets` (and, for shared env, `scope.projects`). A multi-target remote such as `[production, preview]` is owned by a deployment that declares that same multi-target scope — not by a production-only or preview-only sibling. +For Vercel, a remote env var belongs to a deployment only when its target set **exactly** matches `scope.targets` (and, for shared env, `scope.projects`). Ownership compares normalized slug sets: remote `target` builtins plus slugs resolved from `customEnvironmentIds`. A multi-target remote such as `[production, preview]` is owned by a deployment that declares that same multi-target scope — not by a production-only or preview-only sibling. Create custom environments (Dashboard → Environments) before syncing their slugs. + +For SST, `sst secret list --stage …` may print both a `# fallback` section and a stage section. Prune ownership follows `scope.fallback`: stage deployments (`fallback: false`) own only stage-section names. When pruning an SST destination, SecretSync also inventories fallback secrets (via `scope.fallback: true`) and deletes orphans with `sst secret remove --fallback`. Declare intentional fallbacks on a `fallback: true` deployment so they are not pruned. ## Supported Destinations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c05b8be..f0fe114 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,7 +65,9 @@ With `--prune` on `plan` / `apply` (or the TUI prune checkbox): There is no local last-applied state file — every prune plan reflects the live remote inventory. Auth/list failures fail the plan; they are not skipped. Connectors without `list_names` + delete support refuse prune with a clear error. -Vercel ownership is exact target-set equality (`scope.targets` == remote `target`), for both `environment` and `shared-environment`. Overlap matching would let a multi-target inventory unit prune sibling single-target rows. +Vercel ownership is exact target-set equality on normalized slugs (`scope.targets` == remote builtins ∪ custom-env slugs), for both `environment` and `shared-environment`. Yaml may list builtin targets (`production` / `preview` / `development`) and/or custom environment **slugs**; the connector resolves slugs to `customEnvironmentIds` (shared env: resolve on each `scope.projects` entry and union IDs). Overlap matching would let a multi-target inventory unit prune sibling single-target rows. + +SST ownership splits `sst secret list` stdout by section: `# fallback` keys belong only to inventory units with `scope.fallback: true`; `# /` (and flat/headerless output) belong to `fallback: false`. Deletes inherit that flag so fallback orphans are removed with `secret remove --fallback`. When prune selects any SST deployment, plan synthesis adds a fallback secret inventory unit for that destination (intended names from explicit `fallback: true` deployments, else empty) so orphaned fallbacks are reconciled even without a fallback deployment in YAML. ## Connector boundary diff --git a/src/secretsync/destinations/vercel.py b/src/secretsync/destinations/vercel.py index fdc7d71..5b0cdda 100644 --- a/src/secretsync/destinations/vercel.py +++ b/src/secretsync/destinations/vercel.py @@ -30,6 +30,9 @@ SHARED_ENV_PATH = "/v1/env" DEFAULT_MAX_ITEMS = 100 SHARED_MAX_ITEMS = 50 +# Vercel REST `target` only accepts these builtins. Custom env slugs (e.g. staging) +# must be sent as `customEnvironmentIds` after resolving via the project API. +BUILTIN_TARGETS = frozenset({"production", "preview", "development"}) # Vercel disallows Sensitive env vars only on Development. Custom environments # (e.g. staging) and production/preview all allow sensitive. FORBIDDEN_SENSITIVE_TARGETS = frozenset({"development"}) @@ -38,6 +41,176 @@ VALID_SCOPE_KINDS = frozenset({SCOPE_KIND_ENVIRONMENT, SCOPE_KIND_SHARED}) +class CustomEnvironmentError(Exception): + """Raised when a custom environment slug cannot be resolved to an id.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +def _split_targets(targets: Sequence[str]) -> tuple[list[str], list[str]]: + """Split scope.targets into builtin target names vs custom environment slugs.""" + builtins: list[str] = [] + custom_slugs: list[str] = [] + for target in targets: + if target in BUILTIN_TARGETS: + builtins.append(target) + else: + custom_slugs.append(target) + return builtins, custom_slugs + + +def _targets_api_fields( + builtins: Sequence[str], custom_ids: Sequence[str] +) -> dict[str, Any]: + """Build Vercel API fields satisfying anyOf(target, customEnvironmentIds).""" + fields: dict[str, Any] = {} + if builtins: + fields["target"] = list(builtins) + if custom_ids: + fields["customEnvironmentIds"] = list(custom_ids) + return fields + + +def _scope_target_strings(scope: Mapping[str, JsonValue]) -> list[str]: + targets_raw = scope.get("targets") + if not isinstance(targets_raw, list): + return [] + return [str(t) for t in targets_raw if isinstance(t, str)] + + +def _projects_for_custom_resolve( + scope: Mapping[str, JsonValue], + *, + destination_project: str | None, +) -> list[str]: + if _scope_kind(scope) == SCOPE_KIND_SHARED: + return sorted(_scope_projects(scope)) + if destination_project: + return [destination_project] + return [] + + +def _remote_target_slugs( + item: Mapping[str, Any], + id_to_slug: Mapping[str, str], +) -> set[str] | None: + """Normalize remote target + customEnvironmentIds to a set of yaml slugs.""" + remote_targets = item.get("target") or item.get("targets") or [] + if not isinstance(remote_targets, list): + return None + slugs = {str(t) for t in remote_targets} + custom_ids = item.get("customEnvironmentIds") or [] + if custom_ids is None: + custom_ids = [] + if not isinstance(custom_ids, list): + return None + for custom_id in custom_ids: + slug = id_to_slug.get(str(custom_id)) + if slug is None: + return None + slugs.add(slug) + return slugs + + +@dataclass +class _CustomEnvResolver: + """Caches GET /v9/projects/{project}/custom-environments per project.""" + + client: Any + team_id: str + correlation_id: str + _slug_by_project: dict[str, dict[str, str]] = field(default_factory=dict) + _id_to_slug: dict[str, str] = field(default_factory=dict) + requests_made: int = 0 + + @property + def id_to_slug(self) -> Mapping[str, str]: + return self._id_to_slug + + async def ensure_projects(self, projects: Sequence[str]) -> None: + for project in projects: + if project in self._slug_by_project: + continue + slug_to_id = await self._fetch(project) + self._slug_by_project[project] = slug_to_id + for slug, env_id in slug_to_id.items(): + self._id_to_slug[env_id] = slug + + async def _fetch(self, project: str) -> dict[str, str]: + url = ( + f"{VERCEL_API}/v9/projects/{quote(project, safe='')}/custom-environments" + ) + params: dict[str, str] = {"teamId": self.team_id} + response = await request_with_retries( + self.client, + "GET", + url, + params=params, + correlation_id=self.correlation_id, + ) + self.requests_made += 1 + if response.status_code != 200: + raise ListNamesError( + error_for_status(response, correlation_id=self.correlation_id) + ) + payload = response.json() + environments = payload.get("environments") if isinstance(payload, dict) else None + if not isinstance(environments, list): + return {} + result: dict[str, str] = {} + for env in environments: + if not isinstance(env, dict): + continue + slug = env.get("slug") + env_id = env.get("id") + if isinstance(slug, str) and slug and isinstance(env_id, str) and env_id: + result[slug] = env_id + return result + + async def resolve_ids( + self, projects: Sequence[str], slugs: Sequence[str] + ) -> list[str]: + """Resolve custom slugs on each project; return union of env ids.""" + if not slugs: + return [] + if not projects: + raise CustomEnvironmentError( + "custom environment targets require a project " + "(destination.project or scope.projects) to resolve customEnvironmentIds" + ) + await self.ensure_projects(projects) + ids: list[str] = [] + seen: set[str] = set() + for slug in slugs: + for project in projects: + env_id = self._slug_by_project[project].get(slug) + if env_id is None: + raise CustomEnvironmentError( + f"custom environment '{slug}' not found on project '{project}'; " + "create it in the Vercel dashboard (Environments) before syncing" + ) + if env_id not in seen: + ids.append(env_id) + seen.add(env_id) + return ids + + async def api_fields_for_scope( + self, + scope: Mapping[str, JsonValue], + *, + destination_project: str | None, + ) -> dict[str, Any]: + targets = _scope_target_strings(scope) + builtins, custom_slugs = _split_targets(targets) + custom_ids = await self.resolve_ids( + _projects_for_custom_resolve(scope, destination_project=destination_project), + custom_slugs, + ) + return _targets_api_fields(builtins, custom_ids) + + def _capabilities() -> DestinationCapabilities: return DestinationCapabilities( list_names=True, @@ -124,6 +297,7 @@ def _validate_scope( git_branch = scope.get("gitBranch") projects = scope.get("projects") + _, custom_slugs = _split_targets([str(t) for t in targets]) if scope_kind == SCOPE_KIND_ENVIRONMENT: if not destination_project: @@ -146,6 +320,11 @@ def _validate_scope( or not all(isinstance(p, str) and p for p in projects) ): return "scope.projects must be a non-empty array of non-empty strings" + if custom_slugs and not _scope_projects(scope): + return ( + "custom environment targets require scope.projects for " + "scope.kind=shared-environment (to resolve customEnvironmentIds per project)" + ) return None @@ -160,9 +339,12 @@ def _targets_and_type_match( scope: Mapping[str, JsonValue], *, kind: ValueKind, + id_to_slug: Mapping[str, str] | None = None, ) -> bool: """True when remote target set equals scope.targets (exact ownership). + Remote rows may store builtins in `target` and custom envs in + `customEnvironmentIds`; both are normalized to yaml slugs before compare. Overlap matching is wrong: a shared deployment with targets [production, preview] must not own (list/update/prune) rows that only target production or only preview. """ @@ -170,11 +352,8 @@ def _targets_and_type_match( if not isinstance(targets_raw, list): return False wanted = {str(t) for t in targets_raw} - remote_targets = item.get("target") or item.get("targets") or [] - if not isinstance(remote_targets, list): - return False - remote = {str(t) for t in remote_targets} - if wanted != remote: + remote = _remote_target_slugs(item, id_to_slug or {}) + if remote is None or wanted != remote: return False remote_type = str(item.get("type", "")) if kind is ValueKind.SECRET: @@ -187,9 +366,10 @@ def _env_matches_scope( scope: Mapping[str, JsonValue], *, kind: ValueKind = ValueKind.SECRET, + id_to_slug: Mapping[str, str] | None = None, ) -> bool: """True when a remote env entry belongs to the deployment inventory unit.""" - if not _targets_and_type_match(item, scope, kind=kind): + if not _targets_and_type_match(item, scope, kind=kind, id_to_slug=id_to_slug): return False scope_kind = _scope_kind(scope) @@ -294,6 +474,17 @@ async def list_names( scope_kind = _scope_kind(scope) try: async with client: + resolver = _CustomEnvResolver( + client=client, + team_id=team_id, + correlation_id=context.correlation_id, + ) + projects = _projects_for_custom_resolve( + scope, destination_project=project + ) + _, custom_slugs = _split_targets(_scope_target_strings(scope)) + if custom_slugs: + await resolver.ensure_projects(projects) if scope_kind == SCOPE_KIND_SHARED: envs, _ = await self._list_shared_envs( client, team_id=team_id, correlation_id=context.correlation_id @@ -310,10 +501,21 @@ async def list_names( raise ListNamesError(exc.safe) from exc except ListNamesError: raise + except CustomEnvironmentError as exc: + raise ListNamesError( + SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + correlation_id=context.correlation_id, + ) + ) from exc names = { str(item["key"]) for item in envs - if "key" in item and _env_matches_scope(item, scope, kind=kind) + if "key" in item + and _env_matches_scope( + item, scope, kind=kind, id_to_slug=resolver.id_to_slug + ) } return frozenset(names) @@ -409,6 +611,11 @@ async def apply( results: dict[str, MutationResult] = {} async with client: + resolver = _CustomEnvResolver( + client=client, + team_id=team_id, + correlation_id=context.correlation_id, + ) if env_puts or env_deletes: assert project is not None for chunk in _chunks(env_puts, max_items): @@ -420,6 +627,7 @@ async def apply( team_id=team_id, mutations=chunk, correlation_id=context.correlation_id, + resolver=resolver, ) requests_made += n results.update(chunk_results) @@ -430,6 +638,7 @@ async def apply( team_id=team_id, deletes=env_deletes, correlation_id=context.correlation_id, + resolver=resolver, ) requests_made += n results.update(delete_results) @@ -440,6 +649,7 @@ async def apply( team_id=team_id, mutations=shared_puts, correlation_id=context.correlation_id, + resolver=resolver, ) requests_made += n results.update(put_results) @@ -449,9 +659,11 @@ async def apply( team_id=team_id, deletes=shared_deletes, correlation_id=context.correlation_id, + resolver=resolver, ) requests_made += n results.update(delete_results) + requests_made += resolver.requests_made ordered = tuple(results[op.mutation_id] for op in all_ops) return ApplyDestinationResult(results=ordered, requests_made=requests_made) @@ -464,21 +676,56 @@ async def _upsert_chunk( team_id: str, mutations: Sequence[PutMutation], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: payload = [] + ready: list[PutMutation] = [] + early_failures: dict[str, MutationResult] = {} for mutation in mutations: scope = dict(mutation.scopes[0]) - targets_raw = scope["targets"] - assert isinstance(targets_raw, list) + try: + target_fields = await resolver.api_fields_for_scope( + scope, destination_project=project + ) + except CustomEnvironmentError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + except ListNamesError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue + except HttpRequestError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue entry: dict[str, Any] = { "key": mutation.name, "value": bytes(mutation.value).decode("utf-8"), "type": _env_type(mutation.kind), - "target": [str(t) for t in targets_raw], + **target_fields, } if scope.get("gitBranch"): entry["gitBranch"] = scope["gitBranch"] payload.append(entry) + ready.append(mutation) + + if not ready: + return early_failures, 0 params: dict[str, str] = {"upsert": "true", "teamId": team_id} url = f"{VERCEL_API}{API_PATH.format(project=quote(project, safe=''))}" @@ -495,18 +742,21 @@ async def _upsert_chunk( except HttpRequestError as exc: return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="failed", - error=SafeConnectorError( - code=exc.safe.code, - message=exc.safe.message, + **early_failures, + **{ + m.mutation_id: MutationResult( mutation_id=m.mutation_id, - correlation_id=correlation_id, - retryable=exc.safe.retryable, - ), - ) - for m in mutations + status="failed", + error=SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=exc.safe.retryable, + ), + ) + for m in ready + }, }, 1, ) @@ -514,12 +764,15 @@ async def _upsert_chunk( if response.status_code in {200, 201}: return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="applied", - effect="upserted", - ) - for m in mutations + **early_failures, + **{ + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="applied", + effect="upserted", + ) + for m in ready + }, }, 1, ) @@ -529,30 +782,34 @@ async def _upsert_chunk( client, project=project, team_id=team_id, - mutations=mutations, + mutations=ready, correlation_id=correlation_id, + resolver=resolver, ) - return edited, 1 + n + return {**early_failures, **edited}, 1 + n err = error_for_status( response, correlation_id=correlation_id, - secrets=[bytes(m.value).decode("utf-8", errors="replace") for m in mutations], + secrets=[bytes(m.value).decode("utf-8", errors="replace") for m in ready], ) return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="failed", - error=SafeConnectorError( - code=err.code, - message=err.message, + **early_failures, + **{ + m.mutation_id: MutationResult( mutation_id=m.mutation_id, - correlation_id=correlation_id, - retryable=err.retryable, - ), - ) - for m in mutations + status="failed", + error=SafeConnectorError( + code=err.code, + message=err.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=err.retryable, + ), + ) + for m in ready + }, }, 1, ) @@ -609,8 +866,16 @@ async def _upsert_shared( team_id: str, mutations: Sequence[PutMutation], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: try: + for mutation in mutations: + scope = dict(mutation.scopes[0]) + _, custom_slugs = _split_targets(_scope_target_strings(scope)) + if custom_slugs: + await resolver.ensure_projects( + _projects_for_custom_resolve(scope, destination_project=None) + ) envs, list_requests = await self._list_shared_envs( client, team_id=team_id, correlation_id=correlation_id ) @@ -638,6 +903,23 @@ async def _upsert_shared( }, 1, ) + except CustomEnvironmentError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + ), + ) + for m in mutations + }, + 0, + ) to_update: list[tuple[PutMutation, str]] = [] to_create: list[PutMutation] = [] @@ -646,7 +928,10 @@ async def _upsert_shared( env_id: str | None = None for item in envs: if item.get("key") == mutation.name and _env_matches_scope( - item, scope, kind=mutation.kind + item, + scope, + kind=mutation.kind, + id_to_slug=resolver.id_to_slug, ): env_id = str(item.get("id", "")) or None break @@ -664,17 +949,16 @@ async def _upsert_shared( team_id=team_id, updates=update_chunk, correlation_id=correlation_id, + resolver=resolver, ) requests += n results.update(chunk_results) - # Create batches share type + target + projectId at the request level. + # Create batches share type + logical targets + projectId at the request level. groups: dict[tuple[str, tuple[str, ...], tuple[str, ...]], list[PutMutation]] = {} for mutation in to_create: scope = dict(mutation.scopes[0]) - targets_raw = scope["targets"] - assert isinstance(targets_raw, list) - targets = tuple(sorted(str(t) for t in targets_raw)) + targets = tuple(sorted(_scope_target_strings(scope))) projects = tuple(sorted(_scope_projects(scope))) key = (_env_type(mutation.kind), targets, projects) groups.setdefault(key, []).append(mutation) @@ -689,6 +973,7 @@ async def _upsert_shared( targets=list(targets), projects=list(projects), correlation_id=correlation_id, + resolver=resolver, ) requests += n results.update(chunk_results) @@ -705,7 +990,52 @@ async def _create_shared( targets: list[str], projects: list[str], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: + builtins, custom_slugs = _split_targets(targets) + try: + custom_ids = await resolver.resolve_ids(projects, custom_slugs) + except CustomEnvironmentError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + ), + ) + for m in mutations + }, + 0, + ) + except ListNamesError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=exc.safe, + ) + for m in mutations + }, + 0, + ) + except HttpRequestError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=exc.safe, + ) + for m in mutations + }, + 0, + ) body: dict[str, Any] = { "evs": [ { @@ -715,7 +1045,7 @@ async def _create_shared( for m in mutations ], "type": env_type, - "target": targets, + **_targets_api_fields(builtins, custom_ids), } if projects: body["projectId"] = projects @@ -790,21 +1120,53 @@ async def _patch_shared( team_id: str, updates: Sequence[tuple[PutMutation, str]], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: payload_updates: dict[str, Any] = {} + early_failures: dict[str, MutationResult] = {} for mutation, env_id in updates: scope = dict(mutation.scopes[0]) - targets_raw = scope["targets"] - assert isinstance(targets_raw, list) + try: + target_fields = await resolver.api_fields_for_scope( + scope, destination_project=None + ) + except CustomEnvironmentError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + except ListNamesError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue + except HttpRequestError as exc: + early_failures[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue entry: dict[str, Any] = { "value": bytes(mutation.value).decode("utf-8"), "type": _env_type(mutation.kind), - "target": [str(t) for t in targets_raw], + **target_fields, } projects = sorted(_scope_projects(scope)) if projects: entry["projectId"] = projects payload_updates[env_id] = entry + if not payload_updates: + return early_failures, 0 url = f"{VERCEL_API}{SHARED_ENV_PATH}" params = {"teamId": team_id} try: @@ -819,52 +1181,68 @@ async def _patch_shared( except HttpRequestError as exc: return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="failed", - error=SafeConnectorError( - code=exc.safe.code, - message=exc.safe.message, + **early_failures, + **{ + m.mutation_id: MutationResult( mutation_id=m.mutation_id, - correlation_id=correlation_id, - retryable=exc.safe.retryable, - ), - ) - for m, _ in updates + status="failed", + error=SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=exc.safe.retryable, + ), + ) + for m, _ in updates + if m.mutation_id not in early_failures + }, }, 1, ) if response.status_code in {200, 201}: return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="applied", - effect="updated", - ) - for m, _ in updates + **early_failures, + **{ + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="applied", + effect="updated", + ) + for m, _ in updates + if m.mutation_id not in early_failures + }, }, 1, ) err = error_for_status( response, correlation_id=correlation_id, - secrets=[bytes(m.value).decode("utf-8", errors="replace") for m, _ in updates], + secrets=[ + bytes(m.value).decode("utf-8", errors="replace") + for m, _ in updates + if m.mutation_id not in early_failures + ], ) return ( { - m.mutation_id: MutationResult( - mutation_id=m.mutation_id, - status="failed", - error=SafeConnectorError( - code=err.code, - message=err.message, + **early_failures, + **{ + m.mutation_id: MutationResult( mutation_id=m.mutation_id, - correlation_id=correlation_id, - retryable=err.retryable, - ), - ) - for m, _ in updates + status="failed", + error=SafeConnectorError( + code=err.code, + message=err.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=err.retryable, + ), + ) + for m, _ in updates + if m.mutation_id not in early_failures + }, }, 1, ) @@ -876,8 +1254,16 @@ async def _delete_shared( team_id: str, deletes: Sequence[DeleteMutation], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: try: + for deletion in deletes: + scope = dict(deletion.scopes[0]) + _, custom_slugs = _split_targets(_scope_target_strings(scope)) + if custom_slugs: + await resolver.ensure_projects( + _projects_for_custom_resolve(scope, destination_project=None) + ) envs, list_requests = await self._list_shared_envs( client, team_id=team_id, correlation_id=correlation_id ) @@ -905,6 +1291,23 @@ async def _delete_shared( }, 1, ) + except CustomEnvironmentError as exc: + return ( + { + d.mutation_id: MutationResult( + mutation_id=d.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=d.mutation_id, + correlation_id=correlation_id, + ), + ) + for d in deletes + }, + 0, + ) results: dict[str, MutationResult] = {} pending: list[tuple[DeleteMutation, str]] = [] @@ -913,7 +1316,10 @@ async def _delete_shared( env_id: str | None = None for item in envs: if item.get("key") == deletion.name and _env_matches_scope( - item, scope, kind=deletion.kind + item, + scope, + kind=deletion.kind, + id_to_slug=resolver.id_to_slug, ): env_id = str(item.get("id", "")) or None break @@ -981,8 +1387,14 @@ async def _delete_many( team_id: str, deletes: Sequence[DeleteMutation], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: try: + for deletion in deletes: + scope = dict(deletion.scopes[0]) + _, custom_slugs = _split_targets(_scope_target_strings(scope)) + if custom_slugs: + await resolver.ensure_projects([project]) envs, list_requests = await self._list_envs( client, project=project, team_id=team_id, correlation_id=correlation_id ) @@ -1010,6 +1422,23 @@ async def _delete_many( }, 1, ) + except CustomEnvironmentError as exc: + return ( + { + d.mutation_id: MutationResult( + mutation_id=d.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=d.mutation_id, + correlation_id=correlation_id, + ), + ) + for d in deletes + }, + 0, + ) results: dict[str, MutationResult] = {} requests = list_requests @@ -1018,7 +1447,10 @@ async def _delete_many( env_id: str | None = None for item in envs: if item.get("key") == deletion.name and _env_matches_scope( - item, scope, kind=deletion.kind + item, + scope, + kind=deletion.kind, + id_to_slug=resolver.id_to_slug, ): env_id = str(item.get("id", "")) or None break @@ -1077,6 +1509,7 @@ async def _edit_fallback( team_id: str, mutations: Sequence[PutMutation], correlation_id: str, + resolver: _CustomEnvResolver, ) -> tuple[dict[str, MutationResult], int]: """Retrieve env metadata and PATCH each conflicting key.""" try: @@ -1122,7 +1555,12 @@ async def _edit_fallback( for item in envs: if item.get("key") != mutation.name: continue - if _env_matches_scope(item, scope, kind=mutation.kind): + if _env_matches_scope( + item, + scope, + kind=mutation.kind, + id_to_slug=resolver.id_to_slug, + ): env_id = str(item.get("id", "")) or None break if env_id is None: @@ -1142,12 +1580,40 @@ async def _edit_fallback( edit_url = ( f"{VERCEL_API}/v9/projects/{quote(project, safe='')}/env/{quote(env_id, safe='')}" ) - targets_raw = scope["targets"] - assert isinstance(targets_raw, list) + try: + target_fields = await resolver.api_fields_for_scope( + scope, destination_project=project + ) + except CustomEnvironmentError as exc: + results[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=exc.message, + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + except ListNamesError as exc: + results[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue + except HttpRequestError as exc: + results[mutation.mutation_id] = MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=exc.safe, + ) + continue body = { "value": bytes(mutation.value).decode("utf-8"), "type": _env_type(mutation.kind), - "target": [str(t) for t in targets_raw], + **target_fields, } edit_params: dict[str, str] = {"teamId": team_id} try: @@ -1211,7 +1677,7 @@ class VercelFactory: manifest: DestinationManifest = field( default_factory=lambda: DestinationManifest( id="vercel", - version="0.2.0+shared-env", + version="0.3.0+custom-env", capabilities=_capabilities(), ) ) diff --git a/tests/integration/test_vercel.py b/tests/integration/test_vercel.py index b72b6ce..3593620 100644 --- a/tests/integration/test_vercel.py +++ b/tests/integration/test_vercel.py @@ -908,3 +908,187 @@ async def test_shared_delete() -> None: assert body == {"ids": ["env_orphan"]} assert result.results[0].status == "applied" assert result.results[0].effect == "deleted" + + +def _mock_custom_envs(project: str, environments: list[dict[str, object]]) -> None: + respx.get(f"https://api.vercel.com/v9/projects/{project}/custom-environments").mock( + return_value=httpx.Response( + 200, + json={"accountLimit": {"total": 10}, "environments": environments}, + ) + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_project_upsert_custom_staging_uses_custom_environment_ids() -> None: + """Custom slug `staging` must not appear in API `target` — only customEnvironmentIds.""" + _mock_custom_envs( + "web", + [{"id": "env_staging", "slug": "staging", "type": "preview", "createdAt": 1, "updatedAt": 1}], + ) + route = respx.post("https://api.vercel.com/v10/projects/web/env").mock( + return_value=httpx.Response(200, json={"created": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[_mutation("STAGING_ONLY", targets=["staging"])], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + body = json.loads(route.calls[0].request.read()) + assert len(body) == 1 + assert "target" not in body[0] + assert body[0]["customEnvironmentIds"] == ["env_staging"] + assert "staging" not in json.dumps(body[0].get("target", [])) + + +@pytest.mark.asyncio +@respx.mock +async def test_project_upsert_mixed_builtin_and_custom_targets() -> None: + _mock_custom_envs( + "web", + [{"id": "env_staging", "slug": "staging", "type": "preview", "createdAt": 1, "updatedAt": 1}], + ) + route = respx.post("https://api.vercel.com/v10/projects/web/env").mock( + return_value=httpx.Response(200, json={"created": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[ + _mutation( + "COMMON", + targets=["production", "staging", "preview"], + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + body = json.loads(route.calls[0].request.read())[0] + assert set(body["target"]) == {"production", "preview"} + assert body["customEnvironmentIds"] == ["env_staging"] + assert "staging" not in body["target"] + + +@pytest.mark.asyncio +@respx.mock +async def test_list_names_owns_custom_environment_id_rows() -> None: + _mock_custom_envs( + "web", + [{"id": "env_staging", "slug": "staging", "type": "preview", "createdAt": 1, "updatedAt": 1}], + ) + respx.get("https://api.vercel.com/v9/projects/web/env").mock( + return_value=httpx.Response( + 200, + json={ + "envs": [ + { + "id": "1", + "key": "STAGING_SECRET", + "target": [], + "customEnvironmentIds": ["env_staging"], + "type": "sensitive", + }, + { + "id": "2", + "key": "PROD_ONLY", + "target": ["production"], + "type": "sensitive", + }, + ] + }, + ) + ) + dest = VercelFactory().create(_services()) + names = await dest.list_names( + _dest_config(project="web"), # type: ignore[arg-type] + {"kind": "environment", "targets": ["staging"]}, # type: ignore[arg-type] + OperationContext(correlation_id="c1"), + kind=ValueKind.SECRET, + ) + assert names == frozenset({"STAGING_SECRET"}) + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_create_unions_custom_env_ids_across_projects() -> None: + _mock_custom_envs( + "prj_a", + [ + { + "id": "env_cw_staging", + "slug": "staging", + "type": "preview", + "createdAt": 1, + "updatedAt": 1, + } + ], + ) + _mock_custom_envs( + "prj_b", + [ + { + "id": "env_yb_staging", + "slug": "staging", + "type": "preview", + "createdAt": 1, + "updatedAt": 1, + } + ], + ) + respx.get("https://api.vercel.com/v1/env").mock(return_value=_empty_shared_list()) + create = respx.post("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response(201, json={"created": [], "failed": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[ + _mutation( + "TEAM_STAGING", + scope_kind="shared-environment", + targets=["staging"], + projects=["prj_a", "prj_b"], + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + body = json.loads(create.calls[0].request.read()) + assert "target" not in body + assert set(body["customEnvironmentIds"]) == {"env_cw_staging", "env_yb_staging"} + assert body["projectId"] == ["prj_a", "prj_b"] + + +@pytest.mark.asyncio +@respx.mock +async def test_missing_custom_env_slug_fails_with_actionable_message() -> None: + _mock_custom_envs("web", []) + respx.post("https://api.vercel.com/v10/projects/web/env").mock( + return_value=httpx.Response(200, json={"created": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[_mutation("MISSING", targets=["staging"])], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "staging" in result.results[0].error.message + assert "web" in result.results[0].error.message + assert "create" in result.results[0].error.message.lower()