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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ deployments:

# Org secrets/variables: org is destination.organization, or the owner of
# repository (acme from acme/web). Org-only destinations work for this scope.
# Token needs admin:org (classic PAT). visibility defaults to private.
# Token needs admin:org (classic PAT). visibility is required.
- name: github-org
set: production
destination: github
scope:
kind: organization
visibility: private # all | private | selected (+ selected_repository_ids)
visibility: private # required: all | private | selected (+ selectedRepositoryIds)
secrets:
apiKey: API_KEY
variables:
Expand All @@ -104,8 +104,8 @@ Vercel destination modes (selected by `scope.kind`):
destinations:
vercel:
connector: vercel
teamId: team_xyz # required
project: prj_abc # optional; required for kind: environment
teamId: team_xyz # required
project: prj_abc # optional; required for kind: environment
auth:
tokenEnv: VERCEL_TOKEN

Expand All @@ -124,7 +124,7 @@ deployments:
scope:
kind: shared-environment
targets: [production]
projects: [prj_abc, prj_def] # optional link set
projects: [prj_abc, prj_def] # optional link set
secrets:
sharedSecret: SHARED_SECRET
```
Expand Down Expand Up @@ -176,6 +176,8 @@ 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.

## Supported Destinations

We currently support these 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,6 +65,8 @@ 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.

## Connector boundary

Connectors own batching and provider adaptation. The coordinator groups mutations by destination and deployment, then calls `apply` with `PutMutation` values and optional `DeleteMutation`s. Each mutation must receive exactly one result (`applied` / `failed` / `skipped`).
Expand Down Expand Up @@ -107,7 +109,7 @@ See security tests: [`tests/security/test_envfile_pipe.py`](../tests/security/te

## HTTP client

[`infrastructure/http.py`](../src/secretsync/infrastructure/http.py) wraps httpx with bounded retries (429/502/503/504), no wire body logging, and `redact_headers` / `response_debug_meta` for safe diagnostics. Errors map to `SafeConnectorError` codes without provider body text.
[`infrastructure/http.py`](../src/secretsync/infrastructure/http.py) wraps httpx with bounded retries (429/502/503/504), no Authorization wire logging, and `redact_headers` / `response_debug_meta` for safe diagnostics. HTTP failures map to `SafeConnectorError` with a bounded, secret-redacted provider error detail (also logged at DEBUG and written to the mutation audit line).

## Errors and reports

Expand Down
Binary file added docs/github-token-permissions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions examples/.env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@ AWS_REGION="us-east-1"
SECRET_ONE_PROD="op://secretsync-example/production/secret one"
SECRET_ONE_STAGING="op://secretsync-example/staging/secret one"
SECRET_TWO_COMMON="op://secretsync-example/common/secret two"

SECRET_THREE_ORG_PROD="op://secretsync-example/org/api key production"
SECRET_THREE_ORG_STAGING="op://secretsync-example/org/api key staging"


PUBLIC_APP_URL_PROD="public-app-url-prod.com"
PUBLIC_APP_URL_STAGING="public-app-url-staging.com"

ORG_NAME="achadha-team"

LOG_LEVEL_PROD="info"
LOG_LEVEL_STAGING="debug"
115 changes: 100 additions & 15 deletions examples/secretsync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,38 @@ secrets:
secretTwoCommon:
env: SECRET_TWO_COMMON

secretThreeOrgProd:
env: SECRET_THREE_ORG_PROD
secretThreeOrgStaging:
env: SECRET_THREE_ORG_STAGING

variables:
publicAppUrl:
env: PUBLIC_APP_URL
logLevel:
env: LOG_LEVEL
publicAppUrlProd:
env: PUBLIC_APP_URL_PROD
publicAppUrlStaging:
env: PUBLIC_APP_URL_STAGING
logLevelProd:
env: LOG_LEVEL_PROD
logLevelStaging:
env: LOG_LEVEL_STAGING
orgName:
env: ORG_NAME

sets:
common:
include: [secretTwoCommon, logLevel]
include: [secretTwoCommon, orgName]
production:
extends: common
include: [secretOneProd, publicAppUrl]
include: [secretOneProd, secretThreeOrgProd, publicAppUrlProd, logLevelProd]
staging:
extends: common
include: [secretOneStaging, publicAppUrl]
include:
[
secretOneStaging,
secretThreeOrgStaging,
publicAppUrlStaging,
logLevelStaging,
]

destinations:
github:
Expand All @@ -39,10 +56,15 @@ destinations:
tokenEnv: VERCEL_TOKEN
sst:
connector: sst
workingDirectory: ./examples/secretsync-sst-example
workingDirectory: ./secretsync-sst-example
executable: bunx

deployments:
### Examples for GitHub deployment

##########################################################################################
## Environment-level secrets [Production]
##########################################################################################
- name: github-production
set: production
destination: github
Expand All @@ -53,8 +75,10 @@ deployments:
secretOneProd: SECRET_ONE
secretTwoCommon: SECRET_TWO
variables:
logLevel: LOG_LEVEL
publicAppUrl: PUBLIC_APP_URL
logLevelProd: LOG_LEVEL
publicAppUrlProd: PUBLIC_APP_URL

## Environment-level secrets [Staging]
- name: github-staging
set: staging
destination: github
Expand All @@ -65,7 +89,25 @@ deployments:
secretOneStaging: SECRET_ONE
secretTwoCommon: SECRET_TWO
variables:
logLevel: LOG_LEVEL
logLevelStaging: LOG_LEVEL

### Organization-level secrets
# - name: github-org
# set: production
# destination: github
# scope:
# kind: organization
# visibility: all
# secrets:
# secretThreeOrgProd: SECRET_THREE_ORG
# variables:
# orgName: ORG_NAME

##########################################################################################
## Vercel deployments
##########################################################################################

## Vercel deployments [Production]
- name: vercel-production
set: production
destination: vercel
Expand All @@ -74,10 +116,12 @@ deployments:
targets: [production]
secrets:
secretOneProd: SECRET_ONE
secretTwoCommon: SECRET_TWO
variables:
publicAppUrl: PUBLIC_APP_URL
- name: vercel-preview
publicAppUrlProd: PUBLIC_APP_URL
logLevelProd: LOG_LEVEL

## Vercel deployments [Previews]
- name: vercel-staging
set: staging
destination: vercel
scope:
Expand All @@ -86,7 +130,48 @@ deployments:
secrets:
secretOneStaging: SECRET_ONE
variables:
publicAppUrl: PUBLIC_APP_URL
publicAppUrlStaging: PUBLIC_APP_URL
logLevelStaging: LOG_LEVEL

## Vercel organization-level secrets
- name: vercel-shared-production
set: production
destination: vercel
scope:
kind: shared-environment
targets: [production]
secrets:
secretThreeOrgProd: SECRET_THREE_ORG
variables:
logLevelProd: LOG_LEVEL

- name: vercel-shared-preview
set: staging
destination: vercel
scope:
kind: shared-environment
targets: [preview]
secrets:
secretThreeOrgStaging: SECRET_THREE_ORG
variables:
logLevelStaging: LOG_LEVEL

- name: vercel-shared-common
set: common
destination: vercel
scope:
kind: shared-environment
targets: [production, preview]
secrets:
secretTwoCommon: SECRET_TWO
variables:
orgName: ORG_NAME

##########################################################################################
## SST deployments
##########################################################################################

## SST deployments [Staging]
- name: sst-staging
set: staging
destination: sst
Expand Down
25 changes: 23 additions & 2 deletions src/secretsync/application/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any, Literal

import anyio
from loguru import logger

from secretsync.application.plan import build_plan_async
from secretsync.application.services import AppServices
Expand Down Expand Up @@ -135,8 +136,6 @@ async def run_apply_async(
run_id: str | None = None,
) -> ApplyReport:
"""Async apply entry used by the Textual TUI workers."""
from loguru import logger

started = services.clock.now()
validation = validate_config(
services,
Expand Down Expand Up @@ -513,6 +512,15 @@ def _audit_mutations(
for mutation in request.mutations:
result = by_id[mutation.mutation_id]
scope = mutation.scopes[0] if mutation.scopes else {}
if result.status == "failed" and result.error is not None:
logger.debug(
"mutation failed dest={} name={} [{}] {}{}",
destination_id,
mutation.name,
result.error.code,
result.error.message,
f" hint={result.error.hint}" if result.error.hint else "",
)
record_mutation_audit(
config_path=config_path,
run_id=run_id,
Expand All @@ -526,10 +534,21 @@ def _audit_mutations(
effect=result.effect,
correlation_id=correlation_id,
error_code=result.error.code if result.error else None,
error_message=result.error.message if result.error else None,
error_hint=result.error.hint if result.error else None,
)
for deletion in request.deletes:
result = by_id[deletion.mutation_id]
scope = deletion.scopes[0] if deletion.scopes else {}
if result.status == "failed" and result.error is not None:
logger.debug(
"mutation failed dest={} name={} [{}] {}{}",
destination_id,
deletion.name,
result.error.code,
result.error.message,
f" hint={result.error.hint}" if result.error.hint else "",
)
record_mutation_audit(
config_path=config_path,
run_id=run_id,
Expand All @@ -543,6 +562,8 @@ def _audit_mutations(
effect=result.effect,
correlation_id=correlation_id,
error_code=result.error.code if result.error else None,
error_message=result.error.message if result.error else None,
error_hint=result.error.hint if result.error else None,
)


Expand Down
12 changes: 10 additions & 2 deletions src/secretsync/application/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ def build_plan(
for deployment in selected:
available = composed_sets[deployment.set]
for logical_id, destination_name in deployment.secrets.items():
source = available.require(logical_id)
source = available.require(
logical_id,
deployment=deployment.name,
destination=deployment.destination,
)
puts.append(
PlannedPut(
mutation_id=stable_mutation_id(deployment.name, destination_name),
Expand All @@ -70,7 +74,11 @@ def build_plan(
)
)
for logical_id, destination_name in deployment.variables.items():
source = available.require(logical_id)
source = available.require(
logical_id,
deployment=deployment.name,
destination=deployment.destination,
)
puts.append(
PlannedPut(
mutation_id=stable_mutation_id(deployment.name, destination_name),
Expand Down
Loading