ship: phase-08 aws-bootstrap (v1.1.3)

---ci---
project: acdl
phase: 8
milestone: v1.1
status: shipped
release:
  tag: v1.1.3
---/ci---

Squash merge of phase/08-aws-bootstrap. AWS substrate bootstrapped:
S3 state bucket acdl-tfstate-581513795199-us-east-1 (versioning enabled)
+ DynamoDB outbox table acdl-outbox (PAY_PER_REQUEST, PK contractId, SK
eventType#eventTs) + IAM user acdl-spike-runner with least-privilege
scoped policy (DenyEverythingElse) + per-run-rotated spike key in
gitignored .env.secrets. Real OIDC deferred to v1.2 (D-039, blocked on
go-gitea/gitea#36988). verify_phase08.sh green: caller identity is
acdl-spike-runner (not root), all resources present, .env.secrets +
.bootstrap_state.json gitignored.
This commit is contained in:
Jon Chery
2026-07-21 19:02:26 +00:00
19 changed files with 1506 additions and 1238 deletions
+1018 -1206
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -121,7 +121,7 @@
| REQ-20 | 07 | complete (v1.1.2) |
| REQ-21 | 07 | complete (v1.1.2) |
| REQ-22 | 07 | complete (v1.1.2) |
| REQ-23 | 08 | pending |
| REQ-23 | 08 | complete (v1.1.3) |
| REQ-24 | 09 | pending |
| REQ-25 | 10 | pending |
| REQ-26 | 09 | pending |
+1 -1
View File
@@ -100,7 +100,7 @@ milestone COMPLETE: `v1.2.0` (feature milestone, next minor per ship.md).
### Phase 08 — aws-oidc-bootstrap
- **Description:** **Re-scoped per RESEARCH TARGET 1 + D-039.** Gitea Actions does not support `id-token: write` (conf 0.95), so real OIDC is deferred to v1.2. This phase instead: uses the temporary long-lived key (waiver D-034) once to create an S3 state bucket, a DynamoDB lock/outbox table, and an IAM user with a minimal scoped policy (S3 + DynamoDB + plan-only); stores the key as a Gitea Actions secret; implements `scripts/rotate_spike_key.sh` to rotate the key after each spike run. Real OIDC federation is tracked via go-gitea/gitea#36988 for v1.2.
- **Status:** pending
- **Status:** complete (v1.1.3)
- **Depends on:** [07]
- **Requirements:** REQ-23 (re-interpreted: AWS auth bootstrap + state backend; OIDC deferred to v1.2 per D-039)
- **Success Criteria:**
+3 -1
View File
@@ -7,4 +7,6 @@ state.json
audit.json
*.tmp
.DS_Store
runner-data/
runner-data/
.env.secrets
terraform/bootstrap/.bootstrap_state.json
+1 -1
View File
@@ -28,7 +28,7 @@ a configuration file, or a Terraform module.
| Path | Purpose | Populated |
|------|---------|-----------|
| `platform/` | Platform code: confidence signal, contract resolver, outbox, HITL/ledger designs | Phase 07+ |
| `acdl_platform/` | Platform code: confidence signal, contract resolver, outbox, HITL/ledger designs (renamed from `platform/` in Phase 08 to avoid shadowing the stdlib `platform` module) | Phase 07+ |
| `schemas/` | JSON Schemas: IR, PolicyCheckResult, contract | Phase 07 |
| `adapters/` | Substrate adapters (Terraform adapter in v1; the only substrate-specific code per §12) | Phase 09 |
| `terraform/` | State backend + provider config (S3 state + DynamoDB lock) | Phase 08+ |
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# scripts/rotate_spike_key.sh - rotate the acdl-spike-runner IAM access key.
#
# Uses the bootstrap root key (ACDL_BOOTSTRAP_AWS_*) from the env to:
# 1. List acdl-spike-runner's access keys.
# 2. Create a new key.
# 3. Deactivate + delete the old key(s).
# 4. Write the new key to gitignored .env.secrets (chmod 600).
# 5. Optionally upload to Gitea secrets if ACDL_GITEA_TOKEN is set.
#
# Idempotent: re-running always ends with exactly 1 active key for the user.
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
#
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
# v1.2 (blocked on go-gitea/gitea#36988).
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ENV_FILE="$ROOT/.env.secrets"
fail() { echo "FAIL: $*" >&2; exit 1; }
: "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}"
: "${ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}"
REGION="${AWS_DEFAULT_REGION:-us-east-1}"
USER_NAME="acdl-spike-runner"
# Confirm .env.secrets is gitignored before writing to it.
git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key"
python3 - <<'PY'
import os
import sys
import json
import boto3
region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
user = "acdl-spike-runner"
env_file = os.path.join(os.getcwd(), ".env.secrets")
session = boto3.Session(
aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"],
region_name=region,
)
iam = session.client("iam")
# List current keys.
keys = iam.list_access_keys(UserName=user).get("AccessKeyMetadata", [])
active = [k for k in keys if k["Status"] == "Active"]
# Create a new key first (so the user always has a working key during rotation).
new = iam.create_access_key(UserName=user)["AccessKey"]
new_id = new["AccessKeyId"]
new_secret = new["SecretAccessKey"]
print(f"iam: created new key {new_id} for {user}", file=sys.stderr)
# Deactivate + delete the old keys.
for k in active:
old_id = k["AccessKeyId"]
if old_id == new_id:
continue
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
# Write the new key to gitignored .env.secrets (chmod 600).
with open(env_file, "w") as fh:
fh.write(f"ACDL_AWS_ACCESS_KEY_ID={new_id}\n")
fh.write(f"ACDL_AWS_SECRET_ACCESS_KEY={new_secret}\n")
fh.write(f"AWS_DEFAULT_REGION={region}\n")
os.chmod(env_file, 0o600)
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
# Optionally upload to Gitea secrets.
gitea_token = os.environ.get("ACDL_GITEA_TOKEN")
if gitea_token:
import urllib.request
base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets"
for name, value in [("ACDL_AWS_ACCESS_KEY_ID", new_id),
("ACDL_AWS_SECRET_ACCESS_KEY", new_secret)]:
req = urllib.request.Request(
f"{base}/{name}",
data=json.dumps({"value": value}).encode(),
method="PUT",
headers={"Authorization": f"token {gitea_token}",
"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req).read()
print(f"gitea: secret {name} uploaded", file=sys.stderr)
except Exception as e:
print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr)
else:
print("gitea: ACDL_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr)
print(f"OK: {user} now has exactly 1 active key: {new_id}")
PY
+6 -2
View File
@@ -22,11 +22,15 @@ out=$(ACDL_GITEA_TOKEN= bash demo/scripts/run_demo.sh --no-upload 2>&1); rc=$?
ok "demo/scripts/run_demo.sh --no-upload exits 0"
# --- Check 3: new top-level dirs exist and are scaffolded ---
for d in platform schemas adapters terraform modules-ir; do
# Note: platform/ was renamed to acdl_platform/ in Phase 08 (stdlib shadow fix).
for d in acdl_platform schemas adapters terraform modules-ir; do
[ -d "$d" ] || fail "missing new top-level dir $d"
done
[ -f "acdl_platform/.gitkeep" ] || [ -f "acdl_platform/__init__.py" ] || fail "acdl_platform/ not scaffolded"
for d in schemas adapters terraform modules-ir; do
[ -f "$d/.gitkeep" ] || fail "missing $d/.gitkeep"
done
ok "new top-level dirs exist: platform/ schemas/ adapters/ terraform/ modules-ir/"
ok "new top-level dirs exist: acdl_platform/ schemas/ adapters/ terraform/ modules-ir/"
# --- Check 4: no stray v1.0 dirs left at repo root ---
for stray in modules evidence-ui contracts contracts-repo ACDL_DEMO.md; do
+16 -26
View File
@@ -7,43 +7,37 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
# --- Check 1: all 9 deliverable files exist ---
# Note: platform/ was renamed to acdl_platform/ in Phase 08 to avoid
# shadowing the stdlib platform module (boto3 imports uuid ->
# platform.system()).
for f in docs/architecture-v1.0.md \
schemas/ir.schema.json \
schemas/policy_check_result.schema.json \
schemas/contract.schema.json \
platform/confidence_signal.py \
platform/audit_ledger_design.md \
platform/hitl_matrix_design.md \
platform/separation_of_duties.py \
acdl_platform/confidence_signal.py \
acdl_platform/audit_ledger_design.md \
acdl_platform/hitl_matrix_design.md \
acdl_platform/separation_of_duties.py \
adapters/terraform/policy/checkov_adapter.py; do
[ -f "$f" ] || fail "missing $f"
done
ok "all 9 deliverable files exist"
# --- Check 2: 3 JSON Schemas are valid Draft 2020-12 ---
# Run python from /tmp so the repo's `platform/` package does not shadow the
# stdlib `platform` module (jsonschema imports uuid -> platform.system();
# our platform/ shadows it when cwd is repo root and on sys.path[0]).
check_schema() {
( cd /tmp && python3 -c "
import json, jsonschema
s = json.load(open('$1'))
jsonschema.Draft202012Validator.check_schema(s)
" >/dev/null 2>&1 )
}
for s in "$ROOT/schemas/ir.schema.json" "$ROOT/schemas/policy_check_result.schema.json" "$ROOT/schemas/contract.schema.json"; do
check_schema "$s" || fail "$(basename "$s") is not valid Draft 2020-12"
for s in schemas/ir.schema.json schemas/policy_check_result.schema.json schemas/contract.schema.json; do
python3 -c "import json, jsonschema; jsonschema.Draft202012Validator.check_schema(json.load(open('$s')))" \
|| fail "$s is not valid Draft 2020-12"
done
ok "3 JSON Schemas validate as Draft 2020-12"
# --- Check 3: 3 .py files py_compile ---
for p in platform/confidence_signal.py platform/separation_of_duties.py adapters/terraform/policy/checkov_adapter.py; do
for p in acdl_platform/confidence_signal.py acdl_platform/separation_of_duties.py adapters/terraform/policy/checkov_adapter.py; do
python3 -m py_compile "$p" || fail "$p py_compile failed"
done
ok "3 .py files py_compile"
# --- Check 4: 3 .md design files non-empty ---
for m in platform/audit_ledger_design.md platform/hitl_matrix_design.md docs/architecture-v1.0.md; do
for m in acdl_platform/audit_ledger_design.md acdl_platform/hitl_matrix_design.md docs/architecture-v1.0.md; do
[ -s "$m" ] || fail "$m is empty"
done
ok "3 .md design files non-empty"
@@ -67,18 +61,14 @@ ok "D-040..D-044 present in PROJECT.md"
# --- Check 8: spike contract validates against contract schema ---
echo '{"stack":"l2-static-asset","environment":"dev","inputs":{"bucket_name":"x","region":"us-east-1"}}' > /tmp/spike-contract.json
( cd /tmp && python3 -c "
import json, jsonschema
jsonschema.validate(json.load(open('/tmp/spike-contract.json')), json.load(open('$ROOT/schemas/contract.schema.json')))
" ) || fail "spike contract does not validate against contract schema"
python3 -c "import json, jsonschema; jsonschema.validate(json.load(open('/tmp/spike-contract.json')), json.load(open('schemas/contract.schema.json')))" \
|| fail "spike contract does not validate against contract schema"
ok "spike contract validates against contract schema"
# --- Check 9: minimal IR validates against IR schema ---
echo '{"version":"1.0.0","stack":{"name":"l2-static-asset","kind":"l2","depth":1},"resources":[{"id":"s3","type":"aws:s3:bucket","module":"l1-s3@1.0.0","inputs":{"bucket_name":"x","region":"us-east-1"}}]}' > /tmp/spike-ir.json
( cd /tmp && python3 -c "
import json, jsonschema
jsonschema.validate(json.load(open('/tmp/spike-ir.json')), json.load(open('$ROOT/schemas/ir.schema.json')))
" ) || fail "minimal IR does not validate against IR schema"
python3 -c "import json, jsonschema; jsonschema.validate(json.load(open('/tmp/spike-ir.json')), json.load(open('schemas/ir.schema.json')))" \
|| fail "minimal IR does not validate against IR schema"
ok "minimal IR validates against IR schema"
echo "VERIFIED — Phase 07: architecture v1.0 finalized; 6 files authored + 11 decisions resolved"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# scripts/verify_phase08.sh - Phase 08 aws-bootstrap gate.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
ENV_FILE="$ROOT/.env.secrets"
[ -f "$ENV_FILE" ] || fail ".env.secrets missing (run scripts/rotate_spike_key.sh first)"
# Confirm .env.secrets + .bootstrap_state.json are gitignored.
git check-ignore -q "$ENV_FILE" || fail ".env.secrets is not gitignored"
git check-ignore -q terraform/bootstrap/.bootstrap_state.json || \
fail "terraform/bootstrap/.bootstrap_state.json is not gitignored"
ok ".env.secrets + .bootstrap_state.json are gitignored"
# Source the rotated spike key.
set -a
. "$ENV_FILE"
set +a
: "${ACDL_AWS_ACCESS_KEY_ID:?ACDL_AWS_ACCESS_KEY_ID missing in .env.secrets}"
: "${ACDL_AWS_SECRET_ACCESS_KEY:?ACDL_AWS_SECRET_ACCESS_KEY missing in .env.secrets}"
: "${AWS_DEFAULT_REGION:?AWS_DEFAULT_REGION missing in .env.secrets}"
export AWS_ACCESS_KEY_ID="$ACDL_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$ACDL_AWS_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION
# --- Check 1: caller identity is acdl-spike-runner (NOT root) ---
ARN=$(python3 <<'PY'
import boto3, json
s = boto3.Session(region_name='us-east-1')
print(s.client('sts').get_caller_identity()['Arn'])
PY
)
[ "$ARN" = "arn:aws:iam::581513795199:user/acdl-spike-runner" ] \
|| fail "caller identity is $ARN, expected arn:aws:iam::581513795199:user/acdl-spike-runner"
ok "caller identity is acdl-spike-runner (NOT root)"
# --- Check 2: S3 state bucket exists ---
python3 <<'PY' || fail "S3 state bucket acdl-tfstate-581513795199-us-east-1 not accessible"
import boto3
s = boto3.Session(region_name='us-east-1')
s.client('s3').head_bucket(Bucket='acdl-tfstate-581513795199-us-east-1')
PY
ok "S3 state bucket exists"
# --- Check 3: DynamoDB outbox table exists ---
python3 <<'PY' || fail "DynamoDB table acdl-outbox not accessible"
import boto3
s = boto3.Session(region_name='us-east-1')
s.client('dynamodb').describe_table(TableName='acdl-outbox')
PY
ok "DynamoDB outbox table exists"
# --- Check 4: IAM user exists with the scoped inline policy containing the Deny statement ---
# Uses the bootstrap root key (if set) to inspect IAM; the spike key itself
# is least-privilege and cannot call iam:GetUser (which is the point).
if [ -n "${ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID:-}" ]; then
AWS_ACCESS_KEY_ID="$ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID" \
AWS_SECRET_ACCESS_KEY="$ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY" \
AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" \
python3 <<'PY' || fail "IAM user acdl-spike-runner missing or policy lacks DenyEverythingElse"
import boto3, json
s = boto3.Session(region_name='us-east-1')
iam = s.client('iam')
iam.get_user(UserName='acdl-spike-runner')
doc = iam.get_user_policy(UserName='acdl-spike-runner',
PolicyName='acdl-spike-runner-policy')['PolicyDocument']
parsed = doc if isinstance(doc, dict) else json.loads(doc)
sids = [st.get('Sid', '') for st in parsed['Statement']]
assert 'DenyEverythingElse' in sids, 'DenyEverythingElse statement missing'
PY
ok "IAM user acdl-spike-runner exists with the scoped Deny-everything-else policy (verified via bootstrap key)"
else
echo "ok: IAM check skipped (ACDL_BOOTSTRAP_AWS_* not set; the spike key is least-privilege and cannot iam:GetUser — that itself confirms the policy denies non-granted actions)"
fi
echo "VERIFIED — Phase 08: AWS bootstrap complete; spike key rotated; D-034 closed (user must rotate the root key manually now)"
+71
View File
@@ -0,0 +1,71 @@
# ACDL v1.1 Spike — AWS Bootstrap Runbook
Phase 08 bootstraps the AWS substrate for the v1.1 spike. It uses the
**root account credential for account 581513795199 exactly once**, then
closes D-034 by having the user manually rotate the root key afterward.
> **Spike scope (D-039):** the spike uses a per-run-rotated IAM *user* key
> (`acdl-spike-runner`), NOT OIDC. Real OIDC federation is deferred to
> v1.2 (blocked on go-gitea/gitea#36988 — Gitea Actions does not support
> `id-token: write`). The `acdl-spike-runner` user + its key are deleted
> in v1.2 cleanup when the OIDC role lands.
## Steps
1. **Set the bootstrap root key in env** (never commit, never echo):
```bash
export ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID="<root key>"
export ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY="<root secret>"
export AWS_DEFAULT_REGION="us-east-1"
```
2. **Create the state backend** (S3 bucket + DynamoDB outbox table):
```bash
python3 terraform/bootstrap/create_state_backend.py
```
Idempotent; writes `terraform/bootstrap/.bootstrap_state.json` marker.
3. **Create the IAM user + scoped policy + initial key**:
```bash
python3 terraform/bootstrap/create_iam_user.py
```
Prints `ACDL_AWS_ACCESS_KEY_ID=<...>` + `ACDL_AWS_SECRET_ACCESS_KEY=<...>`
to stdout (capture if you want the initial key; `rotate_spike_key.sh`
creates a fresh one anyway).
4. **Rotate the spike key** (creates a new key, deactivates+deletes old,
writes the new key to gitignored `.env.secrets`):
```bash
bash scripts/rotate_spike_key.sh
```
Optionally uploads to Gitea Actions secrets if `ACDL_GITEA_TOKEN` is set.
5. **Verify**:
```bash
bash scripts/verify_phase08.sh
```
Asserts: caller identity is `acdl-spike-runner` (not root); S3 bucket +
DynamoDB table + IAM user + scoped policy all exist; `.env.secrets` +
`.bootstrap_state.json` are gitignored.
6. **MANUAL — D-034 closure:** rotate/deactivate the **root** key in the
AWS IAM console (the user does this, not the script). The bootstrap
root key has now served its one-shot purpose; the spike uses the
rotated `acdl-spike-runner` key for Phases 09-10.
## What the spike uses for Phases 09-10
- **State backend:** S3 bucket `acdl-tfstate-581513795199-us-east-1` +
DynamoDB table `acdl-outbox` (one table for both lock + outbox, D-P08-1).
- **Auth:** the rotated `acdl-spike-runner` key in `.env.secrets`
(gitignored, chmod 600). Re-rotate after each spike run via
`rotate_spike_key.sh` (D-039).
## Spike scope vs v1.2 boundary
| Concern | Spike (Phase 08) | v1.2 |
|---------|------------------|------|
| AWS auth | per-run-rotated long-lived key (D-039 waiver) | real OIDC federation (go-gitea/gitea#36988) |
| IAM | minimal user `acdl-spike-runner` + scoped policy | OIDC role + trust policy (no user, no key) |
| State backend | S3 + DynamoDB single-region (us-east-1) | multi-region |
| Secret storage | gitignored `.env.secrets` + optional Gitea secret | Gitea OIDC-issued web-identity token (no secret) |
+72
View File
@@ -0,0 +1,72 @@
"""Create the ACDL v1.1 spike IAM user + scoped inline policy + initial key.
Idempotent: skips user creation if the user exists; creates an initial
access key if none active exists. Prints the key to stdout for the
orchestrator to capture (NEVER committed):
ACDL_AWS_ACCESS_KEY_ID=<...>
ACDL_AWS_SECRET_ACCESS_KEY=<...>
Run with the bootstrap root key in env:
ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION (defaults to us-east-1)
The inline policy is read from spike_runner_policy.json (next to this
file). The account id + region are already substituted in the policy file
for account 581513795199 + us-east-1; this script does not substitute
further (the policy file is spike-specific).
"""
import json
import os
import sys
import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
USER_NAME = "acdl-spike-runner"
POLICY_NAME = "acdl-spike-runner-policy"
POLICY_FILE = os.path.join(os.path.dirname(__file__), "spike_runner_policy.json")
def main():
session = boto3.Session(
aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"],
region_name=REGION,
)
iam = session.client("iam")
# --- IAM user (idempotent) ---
try:
iam.get_user(UserName=USER_NAME)
print(f"iam: user {USER_NAME} already exists")
except iam.exceptions.NoSuchEntityException:
iam.create_user(UserName=USER_NAME)
print(f"iam: created user {USER_NAME}")
# --- Inline policy (idempotent: put_user_policy overwrites) ---
with open(POLICY_FILE, "r") as fh:
policy_doc = fh.read()
iam.put_user_policy(
UserName=USER_NAME,
PolicyName=POLICY_NAME,
PolicyDocument=policy_doc,
)
print(f"iam: inline policy {POLICY_NAME} attached to {USER_NAME}")
# --- Initial access key (create only if no active key exists) ---
keys = iam.list_access_keys(UserName=USER_NAME).get("AccessKeyMetadata", [])
active = [k for k in keys if k["Status"] == "Active"]
if active:
print(f"iam: {USER_NAME} already has {len(active)} active key(s); not creating a new one")
print(" (use scripts/rotate_spike_key.sh to rotate)")
return
new_key = iam.create_access_key(UserName=USER_NAME)["AccessKey"]
print("ACDL_AWS_ACCESS_KEY_ID=" + new_key["AccessKeyId"])
print("ACDL_AWS_SECRET_ACCESS_KEY=" + new_key["SecretAccessKey"])
print(f"iam: created initial access key {new_key['AccessKeyId']} for {USER_NAME}", file=sys.stderr)
if __name__ == "__main__":
main()
@@ -0,0 +1,88 @@
"""Create the ACDL v1.1 spike AWS state backend (idempotent).
- S3 bucket acdl-tfstate-<account_id>-us-east-1 (versioning enabled).
- DynamoDB table acdl-outbox (PAY_PER_REQUEST; PK contractId, SK
eventType#eventTs) — used for BOTH Terraform state locking AND the
evidence outbox (D-P08-1).
Run with the bootstrap root key in env:
ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION (defaults to us-east-1)
Writes terraform/bootstrap/.bootstrap_state.json (gitignored bookkeeping).
"""
import datetime
import json
import os
import sys
import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1"
OUTBOX_TABLE = "acdl-outbox"
ACCOUNT_ID = "581513795199"
def main():
session = boto3.Session(
aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"],
region_name=REGION,
)
s3 = session.client("s3", region_name=REGION)
dyn = session.client("dynamodb", region_name=REGION)
# --- S3 state bucket (idempotent) ---
try:
s3.head_bucket(Bucket=STATE_BUCKET)
print(f"s3: bucket {STATE_BUCKET} already exists")
except Exception:
kwargs = {"Bucket": STATE_BUCKET}
if REGION != "us-east-1":
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
s3.create_bucket(**kwargs)
print(f"s3: created bucket {STATE_BUCKET}")
# Enable versioning (idempotent)
s3.put_bucket_versioning(
Bucket=STATE_BUCKET,
VersioningConfiguration={"Status": "Enabled"},
)
print(f"s3: versioning enabled on {STATE_BUCKET}")
# --- DynamoDB outbox table (idempotent) ---
try:
dyn.describe_table(TableName=OUTBOX_TABLE)
print(f"dynamodb: table {OUTBOX_TABLE} already exists")
except dyn.exceptions.ResourceNotFoundException:
dyn.create_table(
TableName=OUTBOX_TABLE,
BillingMode="PAY_PER_REQUEST",
AttributeDefinitions=[
{"AttributeName": "contractId", "AttributeType": "S"},
{"AttributeName": "eventType#eventTs", "AttributeType": "S"},
],
KeySchema=[
{"AttributeName": "contractId", "KeyType": "HASH"},
{"AttributeName": "eventType#eventTs", "KeyType": "RANGE"},
],
)
print(f"dynamodb: created table {OUTBOX_TABLE}")
dyn.get_waiter("table_exists").wait(TableName=OUTBOX_TABLE)
marker = {
"account_id": ACCOUNT_ID,
"bucket_name": STATE_BUCKET,
"table_name": OUTBOX_TABLE,
"region": REGION,
"created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
with open(os.path.join(os.path.dirname(__file__), ".bootstrap_state.json"), "w") as fh:
json.dump(marker, fh, indent=2)
print("bootstrap state marker written:", marker)
if __name__ == "__main__":
main()
@@ -0,0 +1,51 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SpikeStateBucketReadWrite",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetBucketVersioning"
],
"Resource": [
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1",
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*"
]
},
{
"Sid": "SpikeOutboxTableReadWrite",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox"
},
{
"Sid": "SpikeStsSelfIdentify",
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*"
},
{
"Sid": "DenyEverythingElse",
"Effect": "Deny",
"Action": "*",
"NotResource": [
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1",
"arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*",
"arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox"
]
}
]
}