Skip to content
Draft
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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,19 @@ 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
set: production
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
```
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; `# <app>/<stage>` (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

Expand Down
65 changes: 64 additions & 1 deletion src/secretsync/application/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 58 additions & 16 deletions src/secretsync/destinations/sst.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
``# <app>/<stage>`` 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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading