From 0bfd20aa48d940bfb71e39d8cd970947000b9639 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 05:11:58 +0000 Subject: [PATCH 1/2] Add aws-ssm Parameter Store connector via optional boto3 extra Introduce an aws-ssm destination that upserts SecureString/String parameters under a pathPrefix scope using boto3. Package boto3 as the aws extra (with all aggregating optional extras) so the core install stays lean, and cover put/list/delete with unit and integration tests. Co-authored-by: Abhishek Chadha --- README.md | 32 +- docs/ARCHITECTURE.md | 10 +- pyproject.toml | 7 +- src/secretsync/application/health.py | 61 +- src/secretsync/application/services.py | 2 + src/secretsync/destinations/__init__.py | 2 + src/secretsync/destinations/aws_ssm.py | 651 +++++++++++++++++++ src/secretsync/destinations/registry.py | 1 + src/secretsync/init_templates.py | 16 + tests/contract/test_provider_capabilities.py | 14 + tests/integration/test_aws_ssm.py | 234 +++++++ tests/unit/test_aws_ssm.py | 113 ++++ tests/unit/test_registry.py | 2 + uv.lock | 92 +++ 14 files changed, 1227 insertions(+), 10 deletions(-) create mode 100644 src/secretsync/destinations/aws_ssm.py create mode 100644 tests/integration/test_aws_ssm.py create mode 100644 tests/unit/test_aws_ssm.py diff --git a/README.md b/README.md index 7b14e6b..9988aef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Local-first CLI that pushes secrets from your vault-backed environment into depl Vaults solve storage, rotation, access control, and audit. The gap is **delivery**: getting the same secret into GitHub Actions, Vercel, and SST without paste-into-Slack, clipboard history, or hand-copying the same key into three dashboards — and without half-rotated deploys when one destination is forgotten. -SecretSync is a thin CLI for that gap. You declare routing in a checked-in YAML file (reviewable in PRs; plaintext stays out of git), inject values from your vault into the process environment (`op run`, Doppler, etc.), and push. Destination quirks — GitHub repo/environment/org scopes, Vercel deployment targets, SST stages — stay behind connectors so the config stays simple. +SecretSync is a thin CLI for that gap. You declare routing in a checked-in YAML file (reviewable in PRs; plaintext stays out of git), inject values from your vault into the process environment (`op run`, Doppler, etc.), and push. Destination quirks — GitHub repo/environment/org scopes, Vercel deployment targets, SST stages, AWS SSM path prefixes — stay behind connectors so the config stays simple. Full secrets platforms (Infisical and similar) can do this and more, but they are overkill when you already trust a vault and only need to say which names land where. Plaintext should only move through process memory, authenticated provider APIs, or one-shot env injection — never config, plans, logs, or temp files. @@ -18,6 +18,11 @@ Full secrets platforms (Infisical and similar) can do this and more, but they ar ## Install with uv uv tool install secretsync-cli +# Optional AWS connectors (Parameter Store via boto3): +# uv tool install 'secretsync-cli[aws]' +# Or every optional extra: +# uv tool install 'secretsync-cli[all]' + # Scaffold config + 1Password-style env template secretsync init ``` @@ -98,6 +103,30 @@ Kind is declared once under `secrets` or `variables`. Connectors map kind to the > > **Breaking:** Vercel destinations require `teamId`. Project env deployments need `scope.kind: environment` (and destination `project`). Team shared env uses `scope.kind: shared-environment` with optional `scope.projects`. +AWS SSM Parameter Store (requires `secretsync-cli[aws]` or `[all]`): + +```yaml +destinations: + ssm: + connector: aws-ssm + region: us-east-1 # optional; else AWS_REGION / session default + # keyId: alias/aws/ssm # optional KMS key for SecureString + # tier: Standard + +deployments: + - name: ssm-production + set: production + destination: ssm + scope: + pathPrefix: /myapp/production + secrets: + apiKey: API_KEY # → /myapp/production/API_KEY (SecureString) + variables: + logLevel: LOG_LEVEL # → /myapp/production/LOG_LEVEL (String) +``` + +Auth uses the standard AWS credential chain (`AWS_PROFILE` or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`). No `auth.tokenEnv`. + Vercel destination modes (selected by `scope.kind`): ```yaml @@ -185,6 +214,7 @@ We currently support these destinations. - [GitHub Actions](https://github.com/features/actions) - [Vercel](https://vercel.com/) - [SST](https://sst.dev/) +- [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) (`aws-ssm`; install `[aws]` or `[all]`) ## Audit diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c05b8be..9759e2f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,10 +20,10 @@ This document describes how SecretSync is structured and how secrets move — es | validate -> plan -> apply | - +--------+--------+--------+ - | | | | - GitHub Vercel SST fakes - (HTTPS) (HTTPS) (process) + +--------+--------+--------+--------+ + | | | | | + GitHub Vercel SST aws-ssm fakes + (HTTPS) (HTTPS) (process) (boto3) ``` Both Click and Textual use the same [`AppServices`](../src/secretsync/application/services.py) composition root. There is no second plan/apply implementation. @@ -35,7 +35,7 @@ Both Click and Textual use the same [`AppServices`](../src/secretsync/applicatio | `config/` | Pydantic YAML schema + loader + set composition | | `application/` | validate, plan, apply coordinator | | `sources/` | Environment secret source | -| `destinations/` | Connector protocol, registry, GitHub/Vercel/SST/fakes | +| `destinations/` | Connector protocol, registry, GitHub/Vercel/SST/aws-ssm/fakes | | `infrastructure/` | HTTP client, process runner, dotenv encoder, redaction | | `presentation/` | Human + versioned JSON renderers (value-free) | | `tui/` | Textual screens + CSS | diff --git a/pyproject.toml b/pyproject.toml index aca3673..eb65683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" license-files = ["LICENSE"] authors = [{ name = "SecretSync contributors" }] requires-python = ">=3.12" -keywords = ["secrets", "github-actions", "vercel", "sst", "1password", "cli"] +keywords = ["secrets", "github-actions", "vercel", "sst", "aws", "ssm", "1password", "cli"] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Environment :: Console", @@ -30,6 +30,10 @@ dependencies = [ "loguru>=0.7.3", ] +[project.optional-dependencies] +aws = ["boto3>=1.35,<2"] +all = ["secretsync-cli[aws]"] + [project.urls] Homepage = "https://github.com/achadha235/secretsync" Repository = "https://github.com/achadha235/secretsync" @@ -61,6 +65,7 @@ dev = [ "ruff>=0.12,<1", "mypy>=1.17,<2", "types-pyyaml>=6.0.12", + "boto3>=1.35,<2", ] [tool.uv] diff --git a/src/secretsync/application/health.py b/src/secretsync/application/health.py index aa95462..933927f 100644 --- a/src/secretsync/application/health.py +++ b/src/secretsync/application/health.py @@ -6,7 +6,9 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from typing import Any +import anyio import httpx from loguru import logger @@ -46,6 +48,7 @@ async def run_health( results.append(await _check_github(environ, github_token_env)) results.append(await _check_vercel(environ, vercel_token_env)) results.append(await _check_aws(environ)) + results.append(await _check_aws_ssm_boto(environ)) return HealthReport(results=tuple(results)) @@ -103,11 +106,15 @@ async def _check_vercel(environ: Mapping[str, str], token_env: str) -> HealthChe ) -async def _check_aws(environ: Mapping[str, str]) -> HealthCheckResult: - name = "SST / AWS" +def _aws_credentials_present(environ: Mapping[str, str]) -> bool: has_profile = bool(environ.get("AWS_PROFILE")) has_keys = bool(environ.get("AWS_ACCESS_KEY_ID") and environ.get("AWS_SECRET_ACCESS_KEY")) - if not has_profile and not has_keys: + return has_profile or has_keys + + +async def _check_aws(environ: Mapping[str, str]) -> HealthCheckResult: + name = "SST / AWS" + if not _aws_credentials_present(environ): msg = ( "AWS_PROFILE (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) not set, " "skipping check for SST connector" @@ -156,6 +163,54 @@ async def _check_aws(environ: Mapping[str, str]) -> HealthCheckResult: ) +async def _check_aws_ssm_boto(environ: Mapping[str, str]) -> HealthCheckResult: + """Optional boto3 STS probe for the aws-ssm connector.""" + name = "AWS SSM (boto3)" + if not _aws_credentials_present(environ): + msg = ( + "AWS_PROFILE (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) not set, " + "skipping check for aws-ssm connector" + ) + logger.info(msg) + return HealthCheckResult(name=name, status="skip", message=msg) + + try: + import boto3 # type: ignore[import-untyped] + except ImportError: + msg = ( + "boto3 not installed, skipping check for aws-ssm connector " + "(install secretsync-cli[aws] or [all])" + ) + logger.info(msg) + return HealthCheckResult(name=name, status="skip", message=msg) + + region = environ.get("AWS_REGION") or environ.get("AWS_DEFAULT_REGION") + kwargs: dict[str, Any] = {} + if region: + kwargs["region_name"] = region + logger.debug("Running boto3 sts get_caller_identity for aws-ssm health") + try: + client = boto3.client("sts", **kwargs) + + def _probe() -> Any: + return client.get_caller_identity() + + identity = await anyio.to_thread.run_sync(_probe) + if identity.get("Account"): + return HealthCheckResult(name=name, status="ok", message="AWS SSM (boto3): OK") + return HealthCheckResult( + name=name, + status="fail", + message="AWS SSM (boto3): FAIL (empty identity)", + ) + except Exception as exc: # noqa: BLE001 + return HealthCheckResult( + name=name, + status="fail", + message=f"AWS SSM (boto3): FAIL ({type(exc).__name__})", + ) + + def health_token_envs_from_config(config_path: Path, environ: Mapping[str, str]) -> tuple[str, str]: """Best-effort read tokenEnv names from yaml; fall back to defaults.""" github, vercel = "GITHUB_TOKEN", "VERCEL_TOKEN" diff --git a/src/secretsync/application/services.py b/src/secretsync/application/services.py index 3130c47..64a4e2c 100644 --- a/src/secretsync/application/services.py +++ b/src/secretsync/application/services.py @@ -8,6 +8,7 @@ from typing import Any from secretsync.config.loader import ConfigLoader +from secretsync.destinations.aws_ssm import AwsSsmFactory from secretsync.destinations.fake import builtin_fake_factories from secretsync.destinations.github_actions import GitHubActionsFactory from secretsync.destinations.registry import ConnectorRegistry @@ -42,6 +43,7 @@ def create_services(environ: Mapping[str, str]) -> AppServices: GitHubActionsFactory(), VercelFactory(), SstFactory(), + AwsSsmFactory(), ] return AppServices( config_loader=ConfigLoader(), diff --git a/src/secretsync/destinations/__init__.py b/src/secretsync/destinations/__init__.py index 046f93a..cee9841 100644 --- a/src/secretsync/destinations/__init__.py +++ b/src/secretsync/destinations/__init__.py @@ -1,5 +1,6 @@ """Destination connectors.""" +from secretsync.destinations.aws_ssm import AwsSsmFactory from secretsync.destinations.fake import builtin_fake_factories from secretsync.destinations.github_actions import GitHubActionsFactory from secretsync.destinations.registry import KNOWN_CONNECTOR_IDS, ConnectorRegistry @@ -8,6 +9,7 @@ __all__ = [ "KNOWN_CONNECTOR_IDS", + "AwsSsmFactory", "ConnectorRegistry", "GitHubActionsFactory", "SstFactory", diff --git a/src/secretsync/destinations/aws_ssm.py b/src/secretsync/destinations/aws_ssm.py new file mode 100644 index 0000000..4f30632 --- /dev/null +++ b/src/secretsync/destinations/aws_ssm.py @@ -0,0 +1,651 @@ +"""AWS Systems Manager Parameter Store destination via boto3.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any + +import anyio + +from secretsync.destinations.base import ( + ApplyDestinationRequest, + ApplyDestinationResult, + BatchCapability, + DeleteMutation, + DestinationCapabilities, + DestinationManifest, + Issue, + ListNamesError, + MutationResult, + OperationContext, + PutMutation, + PutSemantics, + SafeConnectorError, +) +from secretsync.domain.models import JsonValue, ValueKind +from secretsync.infrastructure.redaction import sanitize_provider_message + +BOTO3_INSTALL_HINT = ( + "Install the AWS extra: pip install 'secretsync-cli[aws]' " + "(or 'secretsync-cli[all]')." +) +VALID_TIERS = frozenset({"Standard", "Advanced", "Intelligent-Tiering"}) +# Relative segment or multi-segment path (no leading slash). Full names validated after join. +RELATIVE_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*$") +# Fully qualified parameter name after pathPrefix join. +FULL_NAME_RE = re.compile(r"^/(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]+$") +RESERVED_PREFIX_RE = re.compile(r"^/(aws|ssm)(/|$)", re.IGNORECASE) +DELETE_BATCH_MAX = 10 + +SsmClientFactory = Callable[[str | None], Any] + + +class Boto3MissingError(Exception): + """Raised when the optional boto3 extra is not installed.""" + + def __init__(self, safe: SafeConnectorError) -> None: + self.safe = safe + super().__init__(safe.message) + + +def _capabilities() -> DestinationCapabilities: + return DestinationCapabilities( + list_names=True, + read_values=False, + put_semantics=PutSemantics.UPSERT, + put_batch=BatchCapability(supported=False), + delete_batch=BatchCapability(supported=True, max_items=DELETE_BATCH_MAX), + multiple_scopes_per_mutation=False, + batch_across_scopes=False, + ) + + +def _import_boto3() -> Any: + try: + import boto3 # type: ignore[import-untyped] + except ImportError as exc: + raise Boto3MissingError( + SafeConnectorError( + code="DEPENDENCY_MISSING", + message="boto3 is required for the aws-ssm connector", + hint=BOTO3_INSTALL_HINT, + ) + ) from exc + return boto3 + + +def _default_ssm_client(region: str | None) -> Any: + boto3_mod = _import_boto3() + kwargs: dict[str, Any] = {} + if region: + kwargs["region_name"] = region + return boto3_mod.client("ssm", **kwargs) + + +def _region(config: Mapping[str, JsonValue]) -> str | None: + raw = config.get("region") + if raw is None: + return None + if not isinstance(raw, str) or not raw.strip(): + return "" + return raw.strip() + + +def _key_id(config: Mapping[str, JsonValue]) -> str | None: + raw = config.get("keyId") + if raw is None: + return None + if not isinstance(raw, str) or not raw.strip(): + return "" + return raw.strip() + + +def _tier(config: Mapping[str, JsonValue]) -> str | None: + raw = config.get("tier") + if raw is None: + return None + if not isinstance(raw, str) or not raw.strip(): + return "" + return raw.strip() + + +def _path_prefix(scope: Mapping[str, JsonValue]) -> str | None: + raw = scope.get("pathPrefix") + if not isinstance(raw, str) or not raw.strip(): + return None + return raw.strip().rstrip("/") + + +def parameter_type_for_kind(kind: ValueKind) -> str: + if kind is ValueKind.SECRET: + return "SecureString" + return "String" + + +def join_parameter_name(path_prefix: str, relative_name: str) -> str: + """Join destination pathPrefix with a relative remote name.""" + prefix = path_prefix if path_prefix.startswith("/") else f"/{path_prefix}" + prefix = prefix.rstrip("/") + name = relative_name.strip().lstrip("/") + return f"{prefix}/{name}" + + +def relative_parameter_name(path_prefix: str, full_name: str) -> str | None: + """Strip pathPrefix from a full parameter name; None if outside the prefix.""" + prefix = path_prefix if path_prefix.startswith("/") else f"/{path_prefix}" + prefix = prefix.rstrip("/") + if full_name == prefix: + return None + if not full_name.startswith(prefix + "/"): + return None + return full_name[len(prefix) + 1 :] + + +def validate_relative_name(name: str) -> str | None: + if not RELATIVE_NAME_RE.match(name): + return ( + f"Invalid Parameter Store name '{name}'; use path segments matching " + "a-zA-Z0-9_.- (optionally separated by '/')" + ) + return None + + +def validate_full_name(full_name: str) -> str | None: + if not FULL_NAME_RE.match(full_name): + return f"Invalid Parameter Store path '{full_name}'" + if RESERVED_PREFIX_RE.match(full_name): + return f"Parameter names must not be prefixed with 'aws' or 'ssm': {full_name}" + if full_name.count("/") > 15: + return f"Parameter hierarchy exceeds 15 levels: {full_name}" + return None + + +def _config_issues(config: Mapping[str, JsonValue]) -> list[Issue]: + issues: list[Issue] = [] + region = _region(config) + if region == "": + issues.append( + Issue(code="DESTINATION_INVALID", message="aws-ssm region must be a non-empty string") + ) + key_id = _key_id(config) + if key_id == "": + issues.append( + Issue(code="DESTINATION_INVALID", message="aws-ssm keyId must be a non-empty string") + ) + tier = _tier(config) + if tier == "": + issues.append( + Issue(code="DESTINATION_INVALID", message="aws-ssm tier must be a non-empty string") + ) + elif tier is not None and tier not in VALID_TIERS: + issues.append( + Issue( + code="DESTINATION_INVALID", + message=( + f"aws-ssm tier must be one of: {', '.join(sorted(VALID_TIERS))}" + ), + ) + ) + return issues + + +def _client_error_safe( + exc: BaseException, + *, + correlation_id: str | None = None, + mutation_id: str | None = None, + secrets: Sequence[str] | None = None, +) -> SafeConnectorError: + code = "PROVIDER_ERROR" + message = sanitize_provider_message(str(exc), list(secrets) if secrets else None) + retryable = False + error_code = getattr(exc, "response", None) + if isinstance(error_code, dict): + err = error_code.get("Error") + if isinstance(err, dict): + aws_code = str(err.get("Code", "")) + aws_msg = str(err.get("Message", message)) + message = sanitize_provider_message( + f"{aws_code}: {aws_msg}" if aws_code else aws_msg, + list(secrets) if secrets else None, + ) + if aws_code in {"ThrottlingException", "TooManyRequestsException"}: + retryable = True + code = "PROVIDER_THROTTLED" + return SafeConnectorError( + code=code, + message=message[:512], + mutation_id=mutation_id, + correlation_id=correlation_id, + retryable=retryable, + ) + + +@dataclass +class AwsSsmDestination: + manifest: DestinationManifest + environ: Mapping[str, str] + client_factory: SsmClientFactory = field(default=_default_ssm_client) + _clients: dict[str | None, Any] = field(default_factory=dict) + + def check_kind_support(self, kind: ValueKind) -> Issue | None: + del kind + return None + + async def validate(self, config: Mapping[str, JsonValue]) -> list[Issue]: + return _config_issues(config) + + def _get_client(self, region: str | None) -> Any: + if region not in self._clients: + self._clients[region] = self.client_factory(region) + return self._clients[region] + + async def list_names( + self, + config: Mapping[str, JsonValue], + scope: Mapping[str, JsonValue], + context: OperationContext, + *, + kind: ValueKind = ValueKind.SECRET, + ) -> frozenset[str]: + issues = _config_issues(config) + if issues: + raise ListNamesError( + SafeConnectorError( + code=issues[0].code, + message=issues[0].message, + hint=issues[0].hint, + correlation_id=context.correlation_id, + ) + ) + path_prefix = _path_prefix(scope) + if path_prefix is None: + raise ListNamesError( + SafeConnectorError( + code="DESTINATION_INVALID", + message="aws-ssm scope.pathPrefix is required", + correlation_id=context.correlation_id, + ) + ) + param_type = parameter_type_for_kind(kind) + region = _region(config) or None + try: + client = self._get_client(region) + except Boto3MissingError as exc: + raise ListNamesError( + SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + hint=exc.safe.hint, + correlation_id=context.correlation_id, + ) + ) from exc + + names: set[str] = set() + next_token: str | None = None + try: + while True: + kwargs: dict[str, Any] = { + "ParameterFilters": [ + { + "Key": "Name", + "Option": "BeginsWith", + "Values": [path_prefix], + }, + { + "Key": "Type", + "Option": "Equals", + "Values": [param_type], + }, + ], + "MaxResults": 50, + } + if next_token: + kwargs["NextToken"] = next_token + + def _describe(call_kwargs: dict[str, Any]) -> Any: + return client.describe_parameters(**call_kwargs) + + response = await anyio.to_thread.run_sync(_describe, kwargs) + for item in response.get("Parameters") or []: + full = item.get("Name") + if not isinstance(full, str): + continue + relative = relative_parameter_name(path_prefix, full) + if relative is not None: + names.add(relative) + next_token = response.get("NextToken") + if not next_token: + break + except Boto3MissingError as exc: + raise ListNamesError( + SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + hint=exc.safe.hint, + correlation_id=context.correlation_id, + ) + ) from exc + except Exception as exc: # noqa: BLE001 — map provider failures + raise ListNamesError( + _client_error_safe(exc, correlation_id=context.correlation_id) + ) from exc + return frozenset(names) + + async def apply( + self, + request: ApplyDestinationRequest, + context: OperationContext, + ) -> ApplyDestinationResult: + config = request.destination_config + all_ids = [m.mutation_id for m in request.mutations] + [ + d.mutation_id for d in request.deletes + ] + issues = _config_issues(config) + if issues: + return _all_failed_ids( + all_ids, + SafeConnectorError( + code=issues[0].code, + message=issues[0].message, + hint=issues[0].hint, + correlation_id=context.correlation_id, + ), + ) + + region = _region(config) or None + try: + client = self._get_client(region) + except Boto3MissingError as exc: + return _all_failed_ids( + all_ids, + SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + hint=exc.safe.hint, + correlation_id=context.correlation_id, + ), + ) + + results: dict[str, MutationResult] = {} + requests_made = 0 + key_id = _key_id(config) + tier = _tier(config) + + for mutation in request.mutations: + result, n = await self._put_one( + client=client, + mutation=mutation, + key_id=key_id if key_id else None, + tier=tier if tier else None, + correlation_id=context.correlation_id, + ) + results[mutation.mutation_id] = result + requests_made += n + + delete_chunks: list[list[DeleteMutation]] = [] + current: list[DeleteMutation] = [] + for deletion in request.deletes: + if len(current) >= DELETE_BATCH_MAX: + delete_chunks.append(current) + current = [] + current.append(deletion) + if current: + delete_chunks.append(current) + + for chunk in delete_chunks: + chunk_results, n = await self._delete_chunk( + client=client, + deletes=chunk, + correlation_id=context.correlation_id, + ) + results.update(chunk_results) + requests_made += n + + ordered = tuple(results[mid] for mid in all_ids) + return ApplyDestinationResult(results=ordered, requests_made=requests_made) + + async def _put_one( + self, + *, + client: Any, + mutation: PutMutation, + key_id: str | None, + tier: str | None, + correlation_id: str, + ) -> tuple[MutationResult, int]: + if not mutation.scopes: + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message="Missing aws-ssm scope on mutation", + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ), + 0, + ) + path_prefix = _path_prefix(dict(mutation.scopes[0])) + if path_prefix is None: + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message="aws-ssm scope.pathPrefix is required", + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ), + 0, + ) + name_err = validate_relative_name(mutation.name) + if name_err: + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=name_err, + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ), + 0, + ) + full_name = join_parameter_name(path_prefix, mutation.name) + full_err = validate_full_name(full_name) + if full_err: + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=full_err, + mutation_id=mutation.mutation_id, + correlation_id=correlation_id, + ), + ), + 0, + ) + + value_text = bytes(mutation.value).decode("utf-8") + param_type = parameter_type_for_kind(mutation.kind) + kwargs: dict[str, Any] = { + "Name": full_name, + "Value": value_text, + "Type": param_type, + "Overwrite": True, + } + if param_type == "SecureString" and key_id: + kwargs["KeyId"] = key_id + if tier: + kwargs["Tier"] = tier + + try: + + def _put() -> Any: + return client.put_parameter(**kwargs) + + await anyio.to_thread.run_sync(_put) + except Exception as exc: # noqa: BLE001 + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="failed", + error=_client_error_safe( + exc, + correlation_id=correlation_id, + mutation_id=mutation.mutation_id, + secrets=[value_text], + ), + ), + 1, + ) + finally: + kwargs.pop("Value", None) + del value_text + + return ( + MutationResult( + mutation_id=mutation.mutation_id, + status="applied", + effect="upserted", + ), + 1, + ) + + async def _delete_chunk( + self, + *, + client: Any, + deletes: Sequence[DeleteMutation], + correlation_id: str, + ) -> tuple[dict[str, MutationResult], int]: + resolved: list[tuple[DeleteMutation, str]] = [] + results: dict[str, MutationResult] = {} + for deletion in deletes: + if not deletion.scopes: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message="Missing aws-ssm scope on delete", + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + path_prefix = _path_prefix(dict(deletion.scopes[0])) + if path_prefix is None: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message="aws-ssm scope.pathPrefix is required", + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + name_err = validate_relative_name(deletion.name) + if name_err: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=name_err, + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + full_name = join_parameter_name(path_prefix, deletion.name) + full_err = validate_full_name(full_name) + if full_err: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code="DESTINATION_INVALID", + message=full_err, + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + ), + ) + continue + resolved.append((deletion, full_name)) + + if not resolved: + return results, 0 + + names = [full for _, full in resolved] + try: + + def _delete() -> Any: + return client.delete_parameters(Names=names) + + await anyio.to_thread.run_sync(_delete) + except Exception as exc: # noqa: BLE001 + error = _client_error_safe(exc, correlation_id=correlation_id) + for deletion, _ in resolved: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code=error.code, + message=error.message, + hint=error.hint, + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + retryable=error.retryable, + ), + ) + return results, 1 + + for deletion, _ in resolved: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="applied", + effect="deleted", + ) + return results, 1 + + +def _all_failed_ids( + mutation_ids: Sequence[str], error: SafeConnectorError +) -> ApplyDestinationResult: + return ApplyDestinationResult( + results=tuple( + MutationResult(mutation_id=mid, status="failed", error=error) for mid in mutation_ids + ), + requests_made=0, + ) + + +@dataclass(frozen=True, slots=True) +class AwsSsmFactory: + manifest: DestinationManifest = field( + default_factory=lambda: DestinationManifest( + id="aws-ssm", + version="0.1.0", + capabilities=_capabilities(), + ) + ) + + def create(self, services: Any) -> AwsSsmDestination: + return AwsSsmDestination( + manifest=self.manifest, + environ=services.environ, + ) diff --git a/src/secretsync/destinations/registry.py b/src/secretsync/destinations/registry.py index 368ca94..5d53aa3 100644 --- a/src/secretsync/destinations/registry.py +++ b/src/secretsync/destinations/registry.py @@ -14,6 +14,7 @@ "github-actions", "vercel", "sst", + "aws-ssm", } ) diff --git a/src/secretsync/init_templates.py b/src/secretsync/init_templates.py index d08c4ef..19d7fc5 100644 --- a/src/secretsync/init_templates.py +++ b/src/secretsync/init_templates.py @@ -44,6 +44,12 @@ connector: sst workingDirectory: . executable: sst + # Requires: pip/uv install 'secretsync-cli[aws]' (or [all]) + # ssm: + # connector: aws-ssm + # region: us-east-1 + # # keyId: alias/aws/ssm + # # tier: Standard deployments: - name: github-production @@ -92,6 +98,16 @@ secrets: secretOneStaging: SecretOne secretTwoCommon: SecretTwo + # - name: ssm-production + # set: production + # destination: ssm + # scope: + # pathPrefix: /myapp/production + # secrets: + # secretOneProd: API_KEY + # variables: + # # top-level variables: map to Parameter Store String type + # # logLevel: LOG_LEVEL """ ENV_SECRETSYNC_TPL = """\ diff --git a/tests/contract/test_provider_capabilities.py b/tests/contract/test_provider_capabilities.py index 61eec43..e8a5378 100644 --- a/tests/contract/test_provider_capabilities.py +++ b/tests/contract/test_provider_capabilities.py @@ -33,3 +33,17 @@ def test_sst_capabilities_named_pipe() -> None: assert manifest.capabilities.multiple_scopes_per_mutation is False assert manifest.capabilities.list_names is True assert manifest.capabilities.delete_batch.supported is True + + +def test_aws_ssm_capabilities() -> None: + from secretsync.destinations.aws_ssm import AwsSsmFactory + + manifest = AwsSsmFactory().manifest + assert manifest.id == "aws-ssm" + assert manifest.capabilities.put_batch.supported is False + assert manifest.capabilities.put_semantics.value == "upsert" + assert manifest.capabilities.list_names is True + assert manifest.capabilities.read_values is False + assert manifest.capabilities.delete_batch.supported is True + assert manifest.capabilities.delete_batch.max_items == 10 + assert manifest.capabilities.multiple_scopes_per_mutation is False diff --git a/tests/integration/test_aws_ssm.py b/tests/integration/test_aws_ssm.py new file mode 100644 index 0000000..059a009 --- /dev/null +++ b/tests/integration/test_aws_ssm.py @@ -0,0 +1,234 @@ +"""Integration tests for aws-ssm with a recording mock boto3 client.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from secretsync.destinations.aws_ssm import AwsSsmDestination, AwsSsmFactory +from secretsync.destinations.base import ( + ApplyDestinationRequest, + DeleteMutation, + ListNamesError, + OperationContext, + PutMutation, +) +from secretsync.domain.models import ValueKind + + +@dataclass +class FakeSsmClient: + puts: list[dict[str, Any]] = field(default_factory=list) + deletes: list[list[str]] = field(default_factory=list) + describe_pages: list[dict[str, Any]] = field(default_factory=list) + describe_calls: list[dict[str, Any]] = field(default_factory=list) + fail_put_with: Exception | None = None + fail_delete_with: Exception | None = None + fail_describe_with: Exception | None = None + _describe_idx: int = 0 + + def put_parameter(self, **kwargs: Any) -> dict[str, Any]: + if self.fail_put_with is not None: + raise self.fail_put_with + self.puts.append(dict(kwargs)) + return {"Version": 1, "Tier": kwargs.get("Tier", "Standard")} + + def delete_parameters(self, *, Names: list[str]) -> dict[str, Any]: + if self.fail_delete_with is not None: + raise self.fail_delete_with + self.deletes.append(list(Names)) + return {"DeletedParameters": list(Names), "InvalidParameters": []} + + def describe_parameters(self, **kwargs: Any) -> dict[str, Any]: + if self.fail_describe_with is not None: + raise self.fail_describe_with + self.describe_calls.append(dict(kwargs)) + if self._describe_idx >= len(self.describe_pages): + return {"Parameters": []} + page = self.describe_pages[self._describe_idx] + self._describe_idx += 1 + return page + + +def _dest(client: FakeSsmClient, *, region: str | None = "us-east-1") -> AwsSsmDestination: + seen: dict[str | None, str | None] = {} + + def factory(requested_region: str | None) -> FakeSsmClient: + seen["region"] = requested_region + assert requested_region == region + return client + + dest = AwsSsmDestination( + manifest=AwsSsmFactory().manifest, + environ={"AWS_REGION": region or "us-east-1"}, + client_factory=factory, + ) + return dest + + +def _put( + name: str, + value: bytes = b"SECRET_CANARY_ssm", + *, + kind: ValueKind = ValueKind.SECRET, + path_prefix: str = "/myapp/prod", +) -> PutMutation: + return PutMutation( + mutation_id=f"dep:{name}", + name=name, + value=bytearray(value), + scopes=({"pathPrefix": path_prefix},), + kind=kind, + ) + + +def _delete(name: str, *, path_prefix: str = "/myapp/prod") -> DeleteMutation: + return DeleteMutation( + mutation_id=f"dep:del:{name}", + name=name, + scopes=({"pathPrefix": path_prefix},), + ) + + +@pytest.mark.asyncio +async def test_put_secure_string_and_string() -> None: + client = FakeSsmClient() + dest = _dest(client) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={ + "connector": "aws-ssm", + "region": "us-east-1", + "keyId": "alias/my-key", + "tier": "Advanced", + }, + mutations=[ + _put("API_KEY", b"super-secret"), + _put("LOG_LEVEL", b"info", kind=ValueKind.VARIABLE), + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.requests_made == 2 + assert all(r.status == "applied" and r.effect == "upserted" for r in result.results) + assert client.puts[0]["Name"] == "/myapp/prod/API_KEY" + assert client.puts[0]["Type"] == "SecureString" + assert client.puts[0]["KeyId"] == "alias/my-key" + assert client.puts[0]["Overwrite"] is True + assert client.puts[0]["Tier"] == "Advanced" + assert client.puts[0]["Value"] == "super-secret" + assert client.puts[1]["Name"] == "/myapp/prod/LOG_LEVEL" + assert client.puts[1]["Type"] == "String" + assert "KeyId" not in client.puts[1] + + +@pytest.mark.asyncio +async def test_put_failure_redacts_secret_in_error() -> None: + client = FakeSsmClient(fail_put_with=RuntimeError("boom super-secret leaked")) + dest = _dest(client) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={"connector": "aws-ssm", "region": "us-east-1"}, + mutations=[_put("API_KEY", b"super-secret")], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "super-secret" not in result.results[0].error.message + assert "***" in result.results[0].error.message + + +@pytest.mark.asyncio +async def test_list_names_strips_prefix_and_filters_type() -> None: + client = FakeSsmClient( + describe_pages=[ + { + "Parameters": [ + {"Name": "/myapp/prod/API_KEY", "Type": "SecureString"}, + {"Name": "/myapp/prod/nested/TOKEN", "Type": "SecureString"}, + {"Name": "/other/SKIP", "Type": "SecureString"}, + ], + "NextToken": "page2", + }, + { + "Parameters": [ + {"Name": "/myapp/prod/THIRD", "Type": "SecureString"}, + ], + }, + ] + ) + dest = _dest(client) + names = await dest.list_names( + {"connector": "aws-ssm", "region": "us-east-1"}, + {"pathPrefix": "/myapp/prod"}, + OperationContext(correlation_id="c1"), + kind=ValueKind.SECRET, + ) + assert names == frozenset({"API_KEY", "nested/TOKEN", "THIRD"}) + assert len(client.describe_calls) == 2 + filters = client.describe_calls[0]["ParameterFilters"] + assert {"Key": "Name", "Option": "BeginsWith", "Values": ["/myapp/prod"]} in filters + assert {"Key": "Type", "Option": "Equals", "Values": ["SecureString"]} in filters + + +@pytest.mark.asyncio +async def test_list_names_requires_path_prefix() -> None: + dest = _dest(FakeSsmClient()) + with pytest.raises(ListNamesError) as excinfo: + await dest.list_names( + {"connector": "aws-ssm", "region": "us-east-1"}, + {}, + OperationContext(correlation_id="c1"), + ) + assert "pathPrefix" in excinfo.value.safe.message + + +@pytest.mark.asyncio +async def test_delete_batches_by_ten() -> None: + client = FakeSsmClient() + dest = _dest(client) + deletes = [_delete(f"NAME_{i}") for i in range(12)] + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={"connector": "aws-ssm", "region": "us-east-1"}, + mutations=[], + deletes=deletes, + ), + OperationContext(correlation_id="c1"), + ) + assert result.requests_made == 2 + assert all(r.status == "applied" and r.effect == "deleted" for r in result.results) + assert len(client.deletes) == 2 + assert len(client.deletes[0]) == 10 + assert len(client.deletes[1]) == 2 + assert client.deletes[0][0] == "/myapp/prod/NAME_0" + + +@pytest.mark.asyncio +async def test_missing_scope_fails_put() -> None: + client = FakeSsmClient() + dest = _dest(client) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={"connector": "aws-ssm", "region": "us-east-1"}, + mutations=[ + PutMutation( + mutation_id="dep:X", + name="X", + value=bytearray(b"v"), + scopes=(), + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.requests_made == 0 + assert client.puts == [] diff --git a/tests/unit/test_aws_ssm.py b/tests/unit/test_aws_ssm.py new file mode 100644 index 0000000..26bfcda --- /dev/null +++ b/tests/unit/test_aws_ssm.py @@ -0,0 +1,113 @@ +"""Unit tests for aws-ssm helpers and missing-boto3 path.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from secretsync.destinations.aws_ssm import ( + BOTO3_INSTALL_HINT, + AwsSsmDestination, + AwsSsmFactory, + _default_ssm_client, + join_parameter_name, + parameter_type_for_kind, + relative_parameter_name, + validate_full_name, + validate_relative_name, +) +from secretsync.destinations.base import ( + ApplyDestinationRequest, + OperationContext, + PutMutation, +) +from secretsync.domain.errors import SafeError +from secretsync.domain.models import ValueKind + + +def test_parameter_type_for_kind() -> None: + assert parameter_type_for_kind(ValueKind.SECRET) == "SecureString" + assert parameter_type_for_kind(ValueKind.VARIABLE) == "String" + + +def test_join_and_relative_parameter_name() -> None: + assert join_parameter_name("/myapp/prod", "API_KEY") == "/myapp/prod/API_KEY" + assert join_parameter_name("myapp/prod", "nested/KEY") == "/myapp/prod/nested/KEY" + assert relative_parameter_name("/myapp/prod", "/myapp/prod/API_KEY") == "API_KEY" + assert relative_parameter_name("/myapp/prod", "/myapp/prod/nested/KEY") == "nested/KEY" + assert relative_parameter_name("/myapp/prod", "/other/API_KEY") is None + assert relative_parameter_name("/myapp/prod", "/myapp/prod") is None + + +def test_validate_names() -> None: + assert validate_relative_name("API_KEY") is None + assert validate_relative_name("nested/KEY-1") is None + assert validate_relative_name("/absolute") is not None + assert validate_relative_name("bad name") is not None + assert validate_full_name("/myapp/prod/API_KEY") is None + assert validate_full_name("/aws/foo") is not None + assert validate_full_name("/ssm/foo") is not None + + +@pytest.mark.asyncio +async def test_validate_rejects_bad_tier() -> None: + dest = AwsSsmFactory().create(services=type("S", (), {"environ": {}})()) + issues = await dest.validate({"connector": "aws-ssm", "tier": "Premium"}) + assert any("tier" in i.message for i in issues) + + +@pytest.mark.asyncio +async def test_missing_boto3_apply_error() -> None: + from secretsync.destinations.aws_ssm import Boto3MissingError + + def boom(region: str | None) -> Any: + del region + raise Boto3MissingError( + SafeError( + code="DEPENDENCY_MISSING", + message="boto3 is required for the aws-ssm connector", + hint=BOTO3_INSTALL_HINT, + ) + ) + + dest = AwsSsmDestination( + manifest=AwsSsmFactory().manifest, + environ={}, + client_factory=boom, + ) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config={"connector": "aws-ssm"}, + mutations=[ + PutMutation( + mutation_id="dep:API_KEY", + name="API_KEY", + value=bytearray(b"secret"), + scopes=({"pathPrefix": "/myapp/prod"},), + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.requests_made == 0 + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert result.results[0].error.code == "DEPENDENCY_MISSING" + assert "secretsync-cli[aws]" in (result.results[0].error.hint or "") + + +def test_default_client_factory_imports_boto3() -> None: + # Dev group installs boto3; ensure the default factory can construct a client object. + client = _default_ssm_client("us-east-1") + assert client is not None + assert client.meta.service_model.service_name == "ssm" + + +def test_factory_create() -> None: + services = type("S", (), {"environ": {"AWS_REGION": "us-east-1"}})() + dest = AwsSsmFactory().create(services) + assert dest.manifest.id == "aws-ssm" + assert dest.check_kind_support(ValueKind.SECRET) is None + assert dest.check_kind_support(ValueKind.VARIABLE) is None diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index 451bec8..ea7568a 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -58,6 +58,7 @@ def test_registry_with_fakes_create_and_list() -> None: assert by_id["github-actions"]["status"] == "planned" assert by_id["vercel"]["status"] == "planned" assert by_id["sst"]["status"] == "planned" + assert by_id["aws-ssm"]["status"] == "planned" statuses = [m["status"] for m in manifests] assert statuses == [ "registered", @@ -66,6 +67,7 @@ def test_registry_with_fakes_create_and_list() -> None: "planned", "planned", "planned", + "planned", ] assert by_id["fake-prune"]["status"] == "registered" diff --git a/uv.lock b/uv.lock index d2410b8..c82b644 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "boto3" +version = "1.43.62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/c7/f7732c5e1abf7270a6bbbce47338d25ea66a30df658cffd1d17bb5f735fb/boto3-1.43.62.tar.gz", hash = "sha256:0bf920e0739346e81c7310b685a3f783bf1fcc62ce7d5c7016508fa25c0d261f", size = 112668, upload-time = "2026-07-31T19:35:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/5e1a392c817e395b140c18c12a00c0c65c69f8d63da26ad4387aebf2172b/boto3-1.43.62-py3-none-any.whl", hash = "sha256:0bb298e7ffd72b91615df44bf71c417df80a29d844971e5d665b8bd743a4bb35", size = 140025, upload-time = "2026-07-31T19:35:15.347Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/36af6d99269a701f83809b87a01f4728699eb825ebdedee3a3d515b18f61/botocore-1.43.62.tar.gz", hash = "sha256:94efc419c9f0f41dc2415e4b6b62f04ae21b3ce3930fac47214c4d3f361ea8b8", size = 15818261, upload-time = "2026-07-31T19:35:06.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/65/d5dae96de68ffc55acf87c3bae76e9dabeeca92aadf1223f20e9a7860aef/botocore-1.43.62-py3-none-any.whl", hash = "sha256:76de153de1ba3e242b2e6df6a13ab8a3fb35d17db562462969e661457b63166e", size = 15502622, upload-time = "2026-07-31T19:35:02.697Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -267,6 +295,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "librt" version = "0.13.0" @@ -666,6 +703,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -762,6 +811,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + [[package]] name = "secretsync-cli" version = "0.1.2" @@ -777,8 +838,17 @@ dependencies = [ { name = "textual" }, ] +[package.optional-dependencies] +all = [ + { name = "boto3" }, +] +aws = [ + { name = "boto3" }, +] + [package.dev-dependencies] dev = [ + { name = "boto3" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -791,17 +861,21 @@ dev = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.9,<5" }, + { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.35,<2" }, { name = "click", specifier = ">=8.4,<9" }, { name = "httpx", specifier = ">=0.28,<1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic", specifier = ">=2.13,<3" }, { name = "pynacl", specifier = ">=1.5,<2" }, { name = "pyyaml", specifier = ">=6.0.2,<7" }, + { name = "secretsync-cli", extras = ["aws"], marker = "extra == 'all'" }, { name = "textual", specifier = ">=8.2,<9" }, ] +provides-extras = ["aws", "all"] [package.metadata.requires-dev] dev = [ + { name = "boto3", specifier = ">=1.35,<2" }, { name = "mypy", specifier = ">=1.17,<2" }, { name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest-asyncio", specifier = ">=1,<2" }, @@ -811,6 +885,15 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "textual" version = "8.2.8" @@ -867,6 +950,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0" From 07d9e6c779be63f899cce68f74538f24a2abaec3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 06:49:26 +0000 Subject: [PATCH 2/2] Format aws_ssm.py for ruff format CI check Co-authored-by: Abhishek Chadha --- src/secretsync/destinations/aws_ssm.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/secretsync/destinations/aws_ssm.py b/src/secretsync/destinations/aws_ssm.py index 4f30632..5892262 100644 --- a/src/secretsync/destinations/aws_ssm.py +++ b/src/secretsync/destinations/aws_ssm.py @@ -28,8 +28,7 @@ from secretsync.infrastructure.redaction import sanitize_provider_message BOTO3_INSTALL_HINT = ( - "Install the AWS extra: pip install 'secretsync-cli[aws]' " - "(or 'secretsync-cli[all]')." + "Install the AWS extra: pip install 'secretsync-cli[aws]' (or 'secretsync-cli[all]')." ) VALID_TIERS = frozenset({"Standard", "Advanced", "Intelligent-Tiering"}) # Relative segment or multi-segment path (no leading slash). Full names validated after join. @@ -183,9 +182,7 @@ def _config_issues(config: Mapping[str, JsonValue]) -> list[Issue]: issues.append( Issue( code="DESTINATION_INVALID", - message=( - f"aws-ssm tier must be one of: {', '.join(sorted(VALID_TIERS))}" - ), + message=(f"aws-ssm tier must be one of: {', '.join(sorted(VALID_TIERS))}"), ) ) return issues