366 lines
12 KiB
Python
Executable file
366 lines
12 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Read-only GCP infrastructure readiness check for Teleo.
|
|
|
|
The check intentionally prints only non-secret metadata. It verifies the CI/CD,
|
|
artifact, VM backup, bucket, and DB/KB redundancy surfaces that are easy to
|
|
accidentally round up to "ready" without live readbacks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
PROJECT = "teleo-501523"
|
|
REGION = "europe-west6"
|
|
ZONE = "europe-west6-a"
|
|
ARTIFACT_REPOS = ("teleo", "livingip-web")
|
|
BACKUP_BUCKETS = ("teleo-501523-prod-backups", "teleo-501523-leoclean-backups")
|
|
DISKS = ("teleo-prod-1", "teleo-staging-1")
|
|
SNAPSHOT_POLICY = "teleo-daily-7d-snapshots"
|
|
KB_HOST = "teleo@77.42.65.182"
|
|
CLOUDBUILD_SERVICE_ACCOUNT = f"sa-teleo-cloudbuild@{PROJECT}.iam.gserviceaccount.com"
|
|
CLOUDBUILD_REQUIRED_ROLES = (
|
|
"roles/artifactregistry.writer",
|
|
"roles/logging.logWriter",
|
|
"roles/storage.objectViewer",
|
|
)
|
|
GITHUB_ARTIFACT_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
|
|
GITHUB_WIF_POOL = "github-actions"
|
|
GITHUB_WIF_PROVIDER = "living-ip-github"
|
|
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
|
|
|
|
|
|
@dataclass
|
|
class Check:
|
|
name: str
|
|
status: str
|
|
detail: str
|
|
required_tier: str = "T3_live_readonly"
|
|
current_tier: str = "T3_live_readonly"
|
|
|
|
|
|
def run_json(args: list[str]) -> object:
|
|
output = subprocess.check_output(args, text=True)
|
|
return json.loads(output or "null")
|
|
|
|
|
|
def run_text(args: list[str]) -> str:
|
|
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT).strip()
|
|
|
|
|
|
def safe_check(name: str, fn) -> Check:
|
|
try:
|
|
return fn()
|
|
except subprocess.CalledProcessError as exc:
|
|
return Check(name, "fail", exc.output.strip()[-500:] if exc.output else str(exc))
|
|
except Exception as exc: # pragma: no cover - defensive operator readback
|
|
return Check(name, "fail", str(exc))
|
|
|
|
|
|
def check_artifact_repositories() -> Check:
|
|
repos = run_json(
|
|
[
|
|
"gcloud",
|
|
"artifacts",
|
|
"repositories",
|
|
"list",
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
names = {repo.get("name", "").split("/")[-1]: repo for repo in repos}
|
|
missing = [repo for repo in ARTIFACT_REPOS if repo not in names]
|
|
if missing:
|
|
return Check("artifact_repositories", "fail", f"missing={missing}")
|
|
weak = []
|
|
for name in ARTIFACT_REPOS:
|
|
repo = names[name]
|
|
immutable = repo.get("dockerConfig", {}).get("immutableTags") is True
|
|
scanning = repo.get("vulnerabilityScanningConfig", {}).get("enablementState") == "SCANNING_ACTIVE"
|
|
if not immutable or not scanning:
|
|
weak.append(f"{name}:immutable={immutable}:scanning={scanning}")
|
|
if weak:
|
|
return Check("artifact_repositories", "fail", " ".join(weak))
|
|
return Check(
|
|
"artifact_repositories",
|
|
"pass",
|
|
"present_immutable_scanning="
|
|
+ ",".join(sorted(names[name].get("name", "") for name in ARTIFACT_REPOS)),
|
|
)
|
|
|
|
|
|
def check_cloud_build_contract() -> Check:
|
|
config = Path("cloudbuild.gcp-staging.yaml")
|
|
dockerfile = Path("Dockerfile.gcp-staging")
|
|
smoke = Path("docker/gcp-staging-smoke.sh")
|
|
missing = [str(path) for path in (config, dockerfile, smoke) if not path.exists()]
|
|
if missing:
|
|
return Check("cloud_build_contract", "fail", f"missing={missing}", current_tier="T1_model")
|
|
|
|
text = config.read_text()
|
|
required = [
|
|
"smoke-test-image-before-push",
|
|
"${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}",
|
|
f"serviceAccount: projects/{PROJECT}/serviceAccounts/{CLOUDBUILD_SERVICE_ACCOUNT}",
|
|
"images:",
|
|
]
|
|
absent = [item for item in required if item not in text]
|
|
if absent:
|
|
return Check("cloud_build_contract", "fail", f"missing_contract={absent}", current_tier="T1_model")
|
|
|
|
return Check(
|
|
"cloud_build_contract",
|
|
"pass",
|
|
"cloudbuild.gcp-staging.yaml builds, smoke-runs, then publishes Artifact Registry image",
|
|
current_tier="T2_runtime",
|
|
)
|
|
|
|
|
|
def check_cloud_build_service_account() -> Check:
|
|
service_account = run_json(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"describe",
|
|
CLOUDBUILD_SERVICE_ACCOUNT,
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
policy = run_json(["gcloud", "projects", "get-iam-policy", PROJECT, "--format=json"])
|
|
member = f"serviceAccount:{CLOUDBUILD_SERVICE_ACCOUNT}"
|
|
roles = sorted(
|
|
binding.get("role", "")
|
|
for binding in policy.get("bindings", [])
|
|
if member in binding.get("members", [])
|
|
)
|
|
missing = [role for role in CLOUDBUILD_REQUIRED_ROLES if role not in roles]
|
|
if missing:
|
|
return Check(
|
|
"cloud_build_service_account",
|
|
"fail",
|
|
f"service_account={service_account.get('email')} missing_roles={missing}",
|
|
)
|
|
return Check(
|
|
"cloud_build_service_account",
|
|
"pass",
|
|
f"service_account={service_account.get('email')} roles={','.join(roles)}",
|
|
)
|
|
|
|
|
|
def check_github_actions_artifact_ci() -> Check:
|
|
workflow = Path(".github/workflows/gcp-artifact.yml")
|
|
if not workflow.exists():
|
|
return Check(
|
|
"github_actions_artifact_ci",
|
|
"blocked",
|
|
"missing .github/workflows/gcp-artifact.yml",
|
|
required_tier="T3_live_readonly",
|
|
current_tier="T0_spec",
|
|
)
|
|
|
|
text = workflow.read_text()
|
|
required_text = [
|
|
"google-github-actions/auth@v2",
|
|
"workloadIdentityPools/github-actions/providers/living-ip-github",
|
|
GITHUB_ARTIFACT_SERVICE_ACCOUNT,
|
|
"docker run --rm",
|
|
"docker push",
|
|
]
|
|
absent = [item for item in required_text if item not in text]
|
|
if absent:
|
|
return Check("github_actions_artifact_ci", "fail", f"workflow_missing={absent}")
|
|
|
|
project_number = run_text(["gcloud", "projects", "describe", PROJECT, "--format=value(projectNumber)"])
|
|
provider = run_json(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"workload-identity-pools",
|
|
"providers",
|
|
"describe",
|
|
GITHUB_WIF_PROVIDER,
|
|
"--project",
|
|
PROJECT,
|
|
"--location",
|
|
"global",
|
|
"--workload-identity-pool",
|
|
GITHUB_WIF_POOL,
|
|
"--format=json",
|
|
]
|
|
)
|
|
condition = provider.get("attributeCondition", "")
|
|
if GITHUB_REPOSITORY not in condition:
|
|
return Check("github_actions_artifact_ci", "fail", f"provider_condition={condition!r}")
|
|
|
|
service_account_policy = run_json(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"get-iam-policy",
|
|
GITHUB_ARTIFACT_SERVICE_ACCOUNT,
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
expected_member = (
|
|
f"principalSet://iam.googleapis.com/projects/{project_number}/locations/global/"
|
|
f"workloadIdentityPools/{GITHUB_WIF_POOL}/attribute.repository/{GITHUB_REPOSITORY}"
|
|
)
|
|
wif_bound = any(
|
|
binding.get("role") == "roles/iam.workloadIdentityUser"
|
|
and expected_member in binding.get("members", [])
|
|
for binding in service_account_policy.get("bindings", [])
|
|
)
|
|
if not wif_bound:
|
|
return Check("github_actions_artifact_ci", "fail", "missing_workloadIdentityUser_binding")
|
|
|
|
project_policy = run_json(["gcloud", "projects", "get-iam-policy", PROJECT, "--format=json"])
|
|
artifact_member = f"serviceAccount:{GITHUB_ARTIFACT_SERVICE_ACCOUNT}"
|
|
artifact_writer = any(
|
|
binding.get("role") == "roles/artifactregistry.writer"
|
|
and artifact_member in binding.get("members", [])
|
|
for binding in project_policy.get("bindings", [])
|
|
)
|
|
if not artifact_writer:
|
|
return Check("github_actions_artifact_ci", "fail", "missing_artifactregistry_writer_role")
|
|
|
|
return Check(
|
|
"github_actions_artifact_ci",
|
|
"pass",
|
|
f"workflow=.github/workflows/gcp-artifact.yml provider={provider.get('name')} service_account={GITHUB_ARTIFACT_SERVICE_ACCOUNT}",
|
|
)
|
|
|
|
|
|
def check_snapshot_policy() -> Check:
|
|
policy = run_json(
|
|
[
|
|
"gcloud",
|
|
"compute",
|
|
"resource-policies",
|
|
"describe",
|
|
SNAPSHOT_POLICY,
|
|
"--region",
|
|
REGION,
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
disks = run_json(
|
|
[
|
|
"gcloud",
|
|
"compute",
|
|
"disks",
|
|
"list",
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
policy_uri = policy["selfLink"]
|
|
missing = [
|
|
disk["name"]
|
|
for disk in disks
|
|
if disk["name"] in DISKS and policy_uri not in disk.get("resourcePolicies", [])
|
|
]
|
|
if missing:
|
|
return Check("compute_disk_snapshots", "fail", f"policy_not_attached={missing}")
|
|
retention = policy.get("snapshotSchedulePolicy", {}).get("retentionPolicy", {})
|
|
return Check(
|
|
"compute_disk_snapshots",
|
|
"pass",
|
|
f"policy={SNAPSHOT_POLICY} attached_to={','.join(DISKS)} retention_days={retention.get('maxRetentionDays')}",
|
|
)
|
|
|
|
|
|
def check_backup_buckets() -> Check:
|
|
problems = []
|
|
details = []
|
|
for bucket in BACKUP_BUCKETS:
|
|
versioning = run_text(["gsutil", "versioning", "get", f"gs://{bucket}"])
|
|
ubla = run_text(["gsutil", "uniformbucketlevelaccess", "get", f"gs://{bucket}"])
|
|
versioning_ok = "Enabled" in versioning
|
|
ubla_ok = "Enabled: True" in ubla
|
|
if not versioning_ok or not ubla_ok:
|
|
problems.append(bucket)
|
|
details.append(f"{bucket}:versioning={versioning_ok}:ubla={ubla_ok}")
|
|
if problems:
|
|
return Check("backup_buckets", "fail", " ".join(details))
|
|
return Check("backup_buckets", "pass", " ".join(details))
|
|
|
|
|
|
def check_cloud_sql() -> Check:
|
|
instances = run_json(
|
|
[
|
|
"gcloud",
|
|
"sql",
|
|
"instances",
|
|
"list",
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
]
|
|
)
|
|
if not instances:
|
|
return Check(
|
|
"cloud_sql_pgvector",
|
|
"blocked",
|
|
"no Cloud SQL/Postgres instance exists in GCP; canonical KB still lives outside this GCP project",
|
|
required_tier="T3_live_readonly",
|
|
current_tier="T0_spec",
|
|
)
|
|
names = ",".join(instance["name"] for instance in instances)
|
|
return Check("cloud_sql_pgvector", "pass", f"instances={names}")
|
|
|
|
|
|
def check_kb_credential_slot() -> Check:
|
|
secrets = run_json(["gcloud", "secrets", "list", "--project", PROJECT, "--format=json"])
|
|
names = {secret["name"].split("/")[-1] for secret in secrets}
|
|
candidates = sorted(name for name in names if any(token in name.lower() for token in ("kb", "postgres", "pg", "ssh")))
|
|
if not candidates:
|
|
return Check(
|
|
"kb_gcp_access_secret",
|
|
"blocked",
|
|
f"no KB/Postgres/SSH secret slot found for approved GCP-to-KB access; current blocker remains {KB_HOST} auth",
|
|
required_tier="T3_live_readonly",
|
|
current_tier="T0_spec",
|
|
)
|
|
return Check("kb_gcp_access_secret", "pass", "candidate_secret_names=" + ",".join(candidates))
|
|
|
|
|
|
def main() -> int:
|
|
checks = [
|
|
safe_check("artifact_repositories", check_artifact_repositories),
|
|
safe_check("cloud_build_contract", check_cloud_build_contract),
|
|
safe_check("cloud_build_service_account", check_cloud_build_service_account),
|
|
safe_check("github_actions_artifact_ci", check_github_actions_artifact_ci),
|
|
safe_check("compute_disk_snapshots", check_snapshot_policy),
|
|
safe_check("backup_buckets", check_backup_buckets),
|
|
safe_check("cloud_sql_pgvector", check_cloud_sql),
|
|
safe_check("kb_gcp_access_secret", check_kb_credential_slot),
|
|
]
|
|
payload = {
|
|
"project": PROJECT,
|
|
"region": REGION,
|
|
"zone": ZONE,
|
|
"checks": [check.__dict__ for check in checks],
|
|
"pass_count": sum(check.status == "pass" for check in checks),
|
|
"blocked_count": sum(check.status == "blocked" for check in checks),
|
|
"fail_count": sum(check.status == "fail" for check in checks),
|
|
}
|
|
print(json.dumps(payload, indent=2))
|
|
return 1 if payload["fail_count"] else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|