open-swe/infra/lib/aspects/kebab-naming-aspect.ts
Adam Moussa aef6b26912
feat: Secrets Manager + SSM config store for open-swe (T11) (#10)
Create the per-env config surface the EC2 box reads at boot via
fetch-config.sh / seed_store.sh:

- ConfigStore construct (infra/lib/constructs/config-store.ts):
  - 28 value-LESS Secrets Manager shells  open-swe-<env>/<VAR>
    (RemovalPolicy.RETAIN, no SecretString/generateSecretString — real
    values are set out-of-band by put-config.sh, never in IaC/state).
  - 8 IaC-managed SSM params  /open-swe-<env>/<VAR>  with real,
    stable/derivable values (SANDBOX_TYPE, DEFAULT_REPO_OWNER/NAME,
    ALLOWED_GITHUB_ORGS, DASHBOARD_*_URL/ORIGINS, LLM_MODEL_ID).
  - OUT_OF_BAND_SSM documents the ~30 params CDK intentionally does NOT
    own (operationally-variable / env-specific-unknown).
- Wire ConfigStore into OpenSwe<Env>Stack.
- KebabNamingAspect: exempt Secrets Manager + SSM names, which carry the
  literal UPPER_SNAKE env-var segment (open-swe-dev/DASHBOARD_JWT_SECRET).
- deploy/seahaven/put-config.sh: out-of-band populator (placeholders only,
  OPENSWE_PUT_<VAR> env indirection; no real values committed).

Synth-only; not deployed. Instance-role read grants on open-swe-<env>/*
already exist from T6 — no IAM/trust changes here.
2026-06-26 16:07:23 -04:00

119 lines
4.5 KiB
TypeScript

import { Annotations, CfnResource, IAspect, Stack, Token } from "aws-cdk-lib";
import { IConstruct } from "constructs";
/**
* One "/"-delimited segment must be lower kebab-case: `a-b-c`, digits allowed.
*/
const KEBAB_SEGMENT = /^[a-z0-9]+(-[a-z0-9]+)*$/;
/**
* CloudFormation property keys that carry an *explicit physical name*. The
* codegen'd L1 stores these either camelCased (`roleName`) or CFN-cased
* (`RoleName`) depending on the construct, so the aspect matches keys
* case-insensitively.
*
* We deliberately validate physical NAMES + the stack name only — not CDK
* logical construct ids (those are conventionally PascalCase, e.g.
* `InfraDeployRole`, and validating them would be wrong).
*/
const NAME_PROPERTY_KEYS = [
"RoleName",
"BucketName",
"FunctionName",
"TableName",
"LogGroupName",
"QueueName",
"TopicName",
"SecretName",
"StreamName",
"RepositoryName",
"DBInstanceIdentifier",
"DBClusterIdentifier",
"StateMachineName",
"RuleName",
"UserPoolName",
];
// NOTE: `PolicyName` is intentionally NOT checked — CDK auto-generates inline
// `DefaultPolicy` names (e.g. "InstanceRoleDefaultPolicyF15F...") from the
// logical id; those are not explicit, user-controlled physical names and are
// outside the naming convention's scope.
const NAME_KEYS_LC = new Set(NAME_PROPERTY_KEYS.map((k) => k.toLowerCase()));
/**
* Resource types whose physical NAME is a REQUIRED deviation from kebab-case:
* the open-swe config store names secrets `open-swe-<env>/<ENV_VAR_NAME>` and SSM
* params `/open-swe-<env>/<ENV_VAR_NAME>`, where the last segment is the LITERAL
* UPPER_SNAKE environment-variable name. The boot hook
* (deploy/seahaven/fetch-config.sh) strips the prefix and exports that segment
* verbatim, so a lossless store→env round-trip needs the exact env-var name —
* it cannot be kebab-cased. These two resource types are therefore exempt; every
* OTHER explicitly-named resource is still validated. (The `open-swe-<env>`
* prefix is code-generated from `prefix(env)` and is always kebab-case.)
*/
const KEBAB_EXEMPT_RESOURCE_TYPES = new Set([
"AWS::SecretsManager::Secret",
"AWS::SSM::Parameter",
]);
/**
* `true` when every non-empty "/"-delimited segment is kebab-case.
*
* Path-style names are tolerated so the same check works for Secrets Manager
* (`open-swe-dev/foo`), SSM params (`/open-swe-dev/foo`) and log groups
* (`/open-swe/dev/agent`): each segment is validated independently, and a
* leading slash (empty first segment) is ignored.
*/
export function isKebabCase(value: string): boolean {
return value
.split("/")
.filter((seg) => seg.length > 0)
.every((seg) => KEBAB_SEGMENT.test(seg));
}
/**
* Aspect that FAILS synth (`Annotations.addError`) when an explicitly-named
* resource — or a stack name — is not kebab-case. Enforces the org naming
* convention (naming-conventions.md) deterministically at synth time so a
* non-conforming name can never reach a deploy. Wired in bin/app.ts via
* `Aspects.of(app).add(new KebabNamingAspect())`.
*/
export class KebabNamingAspect implements IAspect {
public visit(node: IConstruct): void {
if (node instanceof Stack) {
const name = node.stackName;
if (!Token.isUnresolved(name) && !isKebabCase(name)) {
Annotations.of(node).addError(
`Stack name "${name}" is not kebab-case (open-swe naming convention).`,
);
}
return;
}
if (node instanceof CfnResource) {
// The config store's Secret/Parameter names carry the literal UPPER_SNAKE
// env-var name per the fetch-config naming contract — a required deviation.
if (KEBAB_EXEMPT_RESOURCE_TYPES.has(node.cfnResourceType)) {
return;
}
// `_cfnProperties` is the props as set on the L1; resolve to collapse any
// intrinsic tokens (refs/getatt) so only literal strings are checked.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = (node as any)._cfnProperties ?? {};
const resolved = Stack.of(node).resolve(raw) ?? {};
for (const [key, value] of Object.entries(resolved)) {
if (
NAME_KEYS_LC.has(key.toLowerCase()) &&
typeof value === "string" &&
!Token.isUnresolved(value) &&
!isKebabCase(value)
) {
Annotations.of(node).addError(
`Resource "${node.node.path}" property ${key}="${value}" is not kebab-case ` +
`(open-swe naming convention).`,
);
}
}
}
}
}