Add idempotent GCP IAM apply runner
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
This commit is contained in:
parent
8f96b44539
commit
607baefc52
4 changed files with 377 additions and 0 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -41,6 +41,7 @@ jobs:
|
||||||
lib/evaluate.py \
|
lib/evaluate.py \
|
||||||
lib/llm.py \
|
lib/llm.py \
|
||||||
lib/post_extract.py \
|
lib/post_extract.py \
|
||||||
|
ops/apply_gcp_iam_split.py \
|
||||||
ops/check_gcp_infra_readiness.py \
|
ops/check_gcp_infra_readiness.py \
|
||||||
ops/plan_gcp_iam_split.py \
|
ops/plan_gcp_iam_split.py \
|
||||||
ops/sqlite_to_postgres_dump.py \
|
ops/sqlite_to_postgres_dump.py \
|
||||||
|
|
@ -53,6 +54,7 @@ jobs:
|
||||||
tests/test_decision_engine_replay.py \
|
tests/test_decision_engine_replay.py \
|
||||||
tests/test_evaluate_agent_routing.py \
|
tests/test_evaluate_agent_routing.py \
|
||||||
tests/test_gcp_cloudsql_restore_drill.py \
|
tests/test_gcp_cloudsql_restore_drill.py \
|
||||||
|
tests/test_gcp_iam_split_apply.py \
|
||||||
tests/test_gcp_iam_split_plan.py \
|
tests/test_gcp_iam_split_plan.py \
|
||||||
tests/test_gcp_readiness_workflow.py \
|
tests/test_gcp_readiness_workflow.py \
|
||||||
tests/test_phase1b_end_to_end.py \
|
tests/test_phase1b_end_to_end.py \
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,21 @@ python3 ops/plan_gcp_iam_split.py --format json
|
||||||
python3 ops/plan_gcp_iam_split.py --format shell
|
python3 ops/plan_gcp_iam_split.py --format shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For an idempotent retained apply attempt, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/apply_gcp_iam_split.py \
|
||||||
|
--output outputs/gcp-infra-hardening-20260707/proofs/gcp-iam-split-apply-dry-run.json
|
||||||
|
|
||||||
|
python3 ops/apply_gcp_iam_split.py \
|
||||||
|
--execute \
|
||||||
|
--output outputs/gcp-infra-hardening-20260707/proofs/gcp-iam-split-apply-execute.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command is dry-run only. The second command mutates IAM and must run
|
||||||
|
from an authenticated GCP admin shell. It is safe to re-run: existing service
|
||||||
|
accounts are skipped, and IAM binding commands are additive/idempotent.
|
||||||
|
|
||||||
The plan creates two separate accounts:
|
The plan creates two separate accounts:
|
||||||
|
|
||||||
- `sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com`
|
- `sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com`
|
||||||
|
|
|
||||||
318
ops/apply_gcp_iam_split.py
Normal file
318
ops/apply_gcp_iam_split.py
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply the Teleo GCP IAM split with retained proof.
|
||||||
|
|
||||||
|
Default mode is dry-run. Execute mode is intentionally idempotent where GCP
|
||||||
|
supports it: existing service accounts are skipped, IAM bindings are added with
|
||||||
|
Google's policy-binding commands, and every command result is retained without
|
||||||
|
printing secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plan_gcp_iam_split import (
|
||||||
|
BACKUP_BUCKET,
|
||||||
|
CLOUDSQL_INSTANCE,
|
||||||
|
PROJECT,
|
||||||
|
READINESS_PROJECT_ROLES,
|
||||||
|
READINESS_SERVICE_ACCOUNT,
|
||||||
|
READINESS_SERVICE_ACCOUNT_ID,
|
||||||
|
RESTORE_BUCKET_ROLES,
|
||||||
|
RESTORE_PROJECT_ROLES,
|
||||||
|
RESTORE_SERVICE_ACCOUNT,
|
||||||
|
RESTORE_SERVICE_ACCOUNT_ID,
|
||||||
|
build_plan,
|
||||||
|
github_wif_member,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperationResult:
|
||||||
|
name: str
|
||||||
|
command: list[str]
|
||||||
|
action: str
|
||||||
|
exitcode: int | None = None
|
||||||
|
stdout_tail: str = ""
|
||||||
|
stderr_tail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def tail(value: str, limit: int = 2000) -> str:
|
||||||
|
return value[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: list[str], *, execute: bool, name: str, action: str) -> OperationResult:
|
||||||
|
if not execute:
|
||||||
|
return OperationResult(name=name, command=command, action=action)
|
||||||
|
completed = subprocess.run(command, text=True, capture_output=True, check=False)
|
||||||
|
return OperationResult(
|
||||||
|
name=name,
|
||||||
|
command=command,
|
||||||
|
action=action,
|
||||||
|
exitcode=completed.returncode,
|
||||||
|
stdout_tail=tail(completed.stdout),
|
||||||
|
stderr_tail=tail(completed.stderr),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_account_exists(email: str) -> OperationResult:
|
||||||
|
return run_command(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"describe",
|
||||||
|
email,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=json",
|
||||||
|
],
|
||||||
|
execute=True,
|
||||||
|
name=f"describe_service_account:{email}",
|
||||||
|
action="probe",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_service_account(account_id: str, email: str, display_name: str, *, execute: bool) -> list[OperationResult]:
|
||||||
|
if execute:
|
||||||
|
probe = service_account_exists(email)
|
||||||
|
if probe.exitcode == 0:
|
||||||
|
probe.action = "already_exists"
|
||||||
|
return [probe]
|
||||||
|
if "Reauthentication failed" in probe.stderr_tail:
|
||||||
|
return [probe]
|
||||||
|
return [
|
||||||
|
run_command(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"create",
|
||||||
|
account_id,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--display-name",
|
||||||
|
display_name,
|
||||||
|
],
|
||||||
|
execute=execute,
|
||||||
|
name=f"create_service_account:{email}",
|
||||||
|
action="create_if_missing",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def add_service_account_iam_binding(
|
||||||
|
email: str,
|
||||||
|
member: str,
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
execute: bool,
|
||||||
|
name: str,
|
||||||
|
) -> OperationResult:
|
||||||
|
return run_command(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"iam",
|
||||||
|
"service-accounts",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
email,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
member,
|
||||||
|
"--role",
|
||||||
|
role,
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
execute=execute,
|
||||||
|
name=name,
|
||||||
|
action="ensure_binding",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_project_binding(email: str, role: str, *, execute: bool, prefix: str) -> OperationResult:
|
||||||
|
return run_command(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"projects",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
PROJECT,
|
||||||
|
"--member",
|
||||||
|
f"serviceAccount:{email}",
|
||||||
|
"--role",
|
||||||
|
role,
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
execute=execute,
|
||||||
|
name=f"{prefix}:{role}",
|
||||||
|
action="ensure_binding",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_bucket_binding(member: str, role: str, *, execute: bool, prefix: str) -> OperationResult:
|
||||||
|
return run_command(
|
||||||
|
[
|
||||||
|
"gcloud",
|
||||||
|
"storage",
|
||||||
|
"buckets",
|
||||||
|
"add-iam-policy-binding",
|
||||||
|
f"gs://{BACKUP_BUCKET}",
|
||||||
|
"--member",
|
||||||
|
member,
|
||||||
|
"--role",
|
||||||
|
role,
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
execute=execute,
|
||||||
|
name=f"{prefix}:{role}",
|
||||||
|
action="ensure_binding",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_cloudsql_instance_service_account(*, execute: bool) -> tuple[str | None, OperationResult]:
|
||||||
|
command = [
|
||||||
|
"gcloud",
|
||||||
|
"sql",
|
||||||
|
"instances",
|
||||||
|
"describe",
|
||||||
|
CLOUDSQL_INSTANCE,
|
||||||
|
"--project",
|
||||||
|
PROJECT,
|
||||||
|
"--format=value(serviceAccountEmailAddress)",
|
||||||
|
]
|
||||||
|
result = run_command(
|
||||||
|
command,
|
||||||
|
execute=execute,
|
||||||
|
name="describe_cloudsql_instance_service_account",
|
||||||
|
action="probe",
|
||||||
|
)
|
||||||
|
if not execute or result.exitcode != 0:
|
||||||
|
return None, result
|
||||||
|
value = result.stdout_tail.strip()
|
||||||
|
return value or None, result
|
||||||
|
|
||||||
|
|
||||||
|
def build_operations(*, execute: bool) -> list[OperationResult]:
|
||||||
|
operations: list[OperationResult] = []
|
||||||
|
operations.extend(
|
||||||
|
create_service_account(
|
||||||
|
READINESS_SERVICE_ACCOUNT_ID,
|
||||||
|
READINESS_SERVICE_ACCOUNT,
|
||||||
|
"Teleo read-only infra readiness",
|
||||||
|
execute=execute,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
operations.append(
|
||||||
|
add_service_account_iam_binding(
|
||||||
|
READINESS_SERVICE_ACCOUNT,
|
||||||
|
github_wif_member(),
|
||||||
|
"roles/iam.workloadIdentityUser",
|
||||||
|
execute=execute,
|
||||||
|
name="bind_github_wif_to_readiness_service_account",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
for role in READINESS_PROJECT_ROLES:
|
||||||
|
operations.append(add_project_binding(READINESS_SERVICE_ACCOUNT, role, execute=execute, prefix="readiness_project_role"))
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
operations.extend(
|
||||||
|
create_service_account(
|
||||||
|
RESTORE_SERVICE_ACCOUNT_ID,
|
||||||
|
RESTORE_SERVICE_ACCOUNT,
|
||||||
|
"Teleo Cloud SQL restore drill",
|
||||||
|
execute=execute,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
for role in RESTORE_PROJECT_ROLES:
|
||||||
|
operations.append(add_project_binding(RESTORE_SERVICE_ACCOUNT, role, execute=execute, prefix="restore_project_role"))
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
for role in RESTORE_BUCKET_ROLES:
|
||||||
|
operations.append(
|
||||||
|
add_bucket_binding(f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}", role, execute=execute, prefix="restore_bucket_role")
|
||||||
|
)
|
||||||
|
if operations[-1].exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
instance_service_account, probe = read_cloudsql_instance_service_account(execute=execute)
|
||||||
|
operations.append(probe)
|
||||||
|
if probe.exitcode not in (None, 0):
|
||||||
|
return operations
|
||||||
|
|
||||||
|
if execute and not instance_service_account:
|
||||||
|
operations.append(
|
||||||
|
OperationResult(
|
||||||
|
name="cloudsql_instance_service_account_missing",
|
||||||
|
command=[],
|
||||||
|
action="fail",
|
||||||
|
exitcode=1,
|
||||||
|
stderr_tail=f"Cloud SQL instance {CLOUDSQL_INSTANCE} did not return serviceAccountEmailAddress",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return operations
|
||||||
|
|
||||||
|
cloudsql_member = (
|
||||||
|
f"serviceAccount:{instance_service_account}"
|
||||||
|
if instance_service_account
|
||||||
|
else f"serviceAccount:$(gcloud sql instances describe {CLOUDSQL_INSTANCE} --project {PROJECT} --format=value(serviceAccountEmailAddress))"
|
||||||
|
)
|
||||||
|
operations.append(
|
||||||
|
add_bucket_binding(cloudsql_member, "roles/storage.objectAdmin", execute=execute, prefix="cloudsql_instance_bucket_role")
|
||||||
|
)
|
||||||
|
return operations
|
||||||
|
|
||||||
|
|
||||||
|
def write_output(path: Path, payload: dict[str, object]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--execute", action="store_true", help="apply IAM bindings; default is dry-run")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="outputs/gcp-infra-hardening-20260707/proofs/gcp-iam-split-apply.json",
|
||||||
|
help="proof JSON path",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
operations = build_operations(execute=args.execute)
|
||||||
|
failed = [operation for operation in operations if operation.exitcode not in (None, 0)]
|
||||||
|
payload = {
|
||||||
|
"artifact": "teleo_gcp_iam_split_apply",
|
||||||
|
"generated_at_utc": datetime.now(UTC).isoformat(),
|
||||||
|
"mode": "execute" if args.execute else "dry_run",
|
||||||
|
"project": PROJECT,
|
||||||
|
"plan": build_plan(),
|
||||||
|
"operations": [asdict(operation) for operation in operations],
|
||||||
|
"status": "failed" if failed else ("applied" if args.execute else "planned"),
|
||||||
|
"failed_operation_count": len(failed),
|
||||||
|
"next_checks": [
|
||||||
|
f"gh workflow run gcp-readiness.yml --repo living-ip/teleo-infrastructure --ref main -f service_account={READINESS_SERVICE_ACCOUNT}",
|
||||||
|
"EXECUTE=1 ops/run_gcp_cloudsql_restore_drill.sh",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
write_output(Path(args.output), payload)
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
42
tests/test_gcp_iam_split_apply.py
Normal file
42
tests/test_gcp_iam_split_apply.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_apply(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["python3", "ops/apply_gcp_iam_split.py", *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gcp_iam_apply_dry_run_writes_planned_operations(tmp_path: Path) -> None:
|
||||||
|
output = tmp_path / "iam-apply.json"
|
||||||
|
completed = run_apply("--output", str(output))
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
payload = json.loads(output.read_text())
|
||||||
|
assert payload["mode"] == "dry_run"
|
||||||
|
assert payload["status"] == "planned"
|
||||||
|
assert payload["failed_operation_count"] == 0
|
||||||
|
assert payload["plan"]["readiness_service_account"] == "sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com"
|
||||||
|
assert payload["plan"]["restore_service_account"] == "sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com"
|
||||||
|
assert any(operation["name"].startswith("readiness_project_role:") for operation in payload["operations"])
|
||||||
|
assert any(operation["name"].startswith("restore_bucket_role:") for operation in payload["operations"])
|
||||||
|
assert any(operation["name"] == "describe_cloudsql_instance_service_account" for operation in payload["operations"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_gcp_iam_apply_dry_run_does_not_shell_out_to_gcloud(tmp_path: Path) -> None:
|
||||||
|
output = tmp_path / "iam-apply.json"
|
||||||
|
completed = run_apply("--output", str(output))
|
||||||
|
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
payload = json.loads(output.read_text())
|
||||||
|
assert all(operation["exitcode"] is None for operation in payload["operations"])
|
||||||
|
assert all(operation["stdout_tail"] == "" for operation in payload["operations"])
|
||||||
|
assert all(operation["stderr_tail"] == "" for operation in payload["operations"])
|
||||||
Loading…
Reference in a new issue