260 lines
8.2 KiB
Python
260 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Emit the least-privilege GCP IAM split for Teleo CI/CD and DB drills.
|
|
|
|
This script is intentionally dry-run by default. It does not read or print
|
|
secret values; it only emits the service accounts, roles, and commands needed
|
|
to separate Docker publishing from infra readiness and Cloud SQL restore drills.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shlex
|
|
from dataclasses import asdict, dataclass
|
|
|
|
PROJECT = "teleo-501523"
|
|
PROJECT_NUMBER = "785938879453"
|
|
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
|
|
WIF_POOL = "github-actions"
|
|
WIF_PROVIDER = "living-ip-github"
|
|
BACKUP_BUCKET = "teleo-501523-prod-backups"
|
|
CLOUDSQL_INSTANCE = "teleo-pgvector-standby"
|
|
|
|
ARTIFACT_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
|
|
READINESS_SERVICE_ACCOUNT_ID = "sa-teleo-readiness"
|
|
READINESS_SERVICE_ACCOUNT = f"{READINESS_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
|
RESTORE_SERVICE_ACCOUNT_ID = "sa-teleo-restore-drill"
|
|
RESTORE_SERVICE_ACCOUNT = f"{RESTORE_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
|
|
|
|
READINESS_PROJECT_ROLES = [
|
|
"roles/artifactregistry.reader",
|
|
"roles/iam.serviceAccountViewer",
|
|
"roles/iam.workloadIdentityPoolViewer",
|
|
"roles/iam.securityReviewer",
|
|
"roles/compute.viewer",
|
|
"roles/cloudsql.viewer",
|
|
"roles/storage.viewer",
|
|
"roles/secretmanager.viewer",
|
|
]
|
|
|
|
RESTORE_PROJECT_ROLES = [
|
|
"roles/cloudsql.editor",
|
|
]
|
|
|
|
RESTORE_BUCKET_ROLES = [
|
|
"roles/storage.objectAdmin",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Binding:
|
|
scope: str
|
|
member: str
|
|
role: str
|
|
reason: str
|
|
|
|
|
|
def shell_join(parts: list[str]) -> str:
|
|
return " ".join(shlex.quote(part) for part in parts)
|
|
|
|
|
|
def github_wif_member() -> str:
|
|
return (
|
|
f"principalSet://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
|
|
f"workloadIdentityPools/{WIF_POOL}/attribute.repository/{GITHUB_REPOSITORY}"
|
|
)
|
|
|
|
|
|
def build_plan() -> dict[str, object]:
|
|
readiness_bindings = [
|
|
Binding(
|
|
scope=f"projects/{PROJECT}",
|
|
member=f"serviceAccount:{READINESS_SERVICE_ACCOUNT}",
|
|
role=role,
|
|
reason="read-only GitHub readiness probe",
|
|
)
|
|
for role in READINESS_PROJECT_ROLES
|
|
]
|
|
restore_bindings = [
|
|
Binding(
|
|
scope=f"projects/{PROJECT}",
|
|
member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}",
|
|
role=role,
|
|
reason="operator-triggered Cloud SQL import operation",
|
|
)
|
|
for role in RESTORE_PROJECT_ROLES
|
|
]
|
|
restore_bucket_bindings = [
|
|
Binding(
|
|
scope=f"gs://{BACKUP_BUCKET}",
|
|
member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}",
|
|
role=role,
|
|
reason="upload and verify generated restore SQL in the backup bucket",
|
|
)
|
|
for role in RESTORE_BUCKET_ROLES
|
|
]
|
|
|
|
commands = [
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"create",
|
|
READINESS_SERVICE_ACCOUNT_ID,
|
|
"--project",
|
|
PROJECT,
|
|
"--display-name",
|
|
"Teleo read-only infra readiness",
|
|
]
|
|
),
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"add-iam-policy-binding",
|
|
READINESS_SERVICE_ACCOUNT,
|
|
"--project",
|
|
PROJECT,
|
|
"--member",
|
|
github_wif_member(),
|
|
"--role",
|
|
"roles/iam.workloadIdentityUser",
|
|
]
|
|
),
|
|
*[
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"projects",
|
|
"add-iam-policy-binding",
|
|
PROJECT,
|
|
"--member",
|
|
binding.member,
|
|
"--role",
|
|
binding.role,
|
|
]
|
|
)
|
|
for binding in readiness_bindings
|
|
],
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"create",
|
|
RESTORE_SERVICE_ACCOUNT_ID,
|
|
"--project",
|
|
PROJECT,
|
|
"--display-name",
|
|
"Teleo Cloud SQL restore drill",
|
|
]
|
|
),
|
|
*[
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"projects",
|
|
"add-iam-policy-binding",
|
|
PROJECT,
|
|
"--member",
|
|
binding.member,
|
|
"--role",
|
|
binding.role,
|
|
]
|
|
)
|
|
for binding in restore_bindings
|
|
],
|
|
*[
|
|
shell_join(
|
|
[
|
|
"gcloud",
|
|
"storage",
|
|
"buckets",
|
|
"add-iam-policy-binding",
|
|
binding.scope,
|
|
"--member",
|
|
binding.member,
|
|
"--role",
|
|
binding.role,
|
|
]
|
|
)
|
|
for binding in restore_bucket_bindings
|
|
],
|
|
"CLOUDSQL_INSTANCE_SA=$(gcloud sql instances describe "
|
|
+ shlex.quote(CLOUDSQL_INSTANCE)
|
|
+ " --project "
|
|
+ shlex.quote(PROJECT)
|
|
+ " --format='value(serviceAccountEmailAddress)')",
|
|
"gcloud storage buckets add-iam-policy-binding "
|
|
+ shlex.quote(f"gs://{BACKUP_BUCKET}")
|
|
+ ' --member "serviceAccount:${CLOUDSQL_INSTANCE_SA}" --role roles/storage.objectAdmin',
|
|
shell_join(
|
|
[
|
|
"gh",
|
|
"workflow",
|
|
"run",
|
|
"gcp-readiness.yml",
|
|
"--repo",
|
|
GITHUB_REPOSITORY,
|
|
"--ref",
|
|
"main",
|
|
"-f",
|
|
f"service_account={READINESS_SERVICE_ACCOUNT}",
|
|
]
|
|
),
|
|
]
|
|
|
|
return {
|
|
"artifact": "teleo_gcp_iam_split_plan",
|
|
"project": PROJECT,
|
|
"claim_ceiling": "dry_run_plan_only_until_applied_by_authenticated_gcp_admin",
|
|
"kept_narrow": {
|
|
"artifact_service_account": ARTIFACT_SERVICE_ACCOUNT,
|
|
"reason": "Docker image publishing only; do not add broad infra-read or restore roles here.",
|
|
},
|
|
"readiness_service_account": READINESS_SERVICE_ACCOUNT,
|
|
"restore_service_account": RESTORE_SERVICE_ACCOUNT,
|
|
"github_wif_provider": f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{WIF_POOL}/providers/{WIF_PROVIDER}",
|
|
"github_wif_member": github_wif_member(),
|
|
"readiness_project_roles": READINESS_PROJECT_ROLES,
|
|
"restore_project_roles": RESTORE_PROJECT_ROLES,
|
|
"restore_bucket_roles": RESTORE_BUCKET_ROLES,
|
|
"cloudsql_instance_service_account_binding": {
|
|
"bucket": f"gs://{BACKUP_BUCKET}",
|
|
"role": "roles/storage.objectAdmin",
|
|
"reason": "Cloud SQL imports read the SQL object through the Cloud SQL instance service account.",
|
|
},
|
|
"bindings": {
|
|
"readiness": [asdict(binding) for binding in readiness_bindings],
|
|
"restore_operator": [asdict(binding) for binding in restore_bindings],
|
|
"restore_bucket": [asdict(binding) for binding in restore_bucket_bindings],
|
|
},
|
|
"commands": commands,
|
|
"post_apply_checks": [
|
|
f"gh workflow run gcp-readiness.yml --repo {GITHUB_REPOSITORY} --ref main -f service_account={READINESS_SERVICE_ACCOUNT}",
|
|
"EXECUTE=1 ops/run_gcp_cloudsql_restore_drill.sh",
|
|
"retain target-counts.sql query readback before claiming GCP DB redundancy",
|
|
],
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--format", choices=("json", "shell"), default="json")
|
|
args = parser.parse_args()
|
|
|
|
plan = build_plan()
|
|
if args.format == "shell":
|
|
print("# Dry-run command plan. Review before executing with an authenticated GCP admin.")
|
|
for command in plan["commands"]:
|
|
print(command)
|
|
else:
|
|
print(json.dumps(plan, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|