Harden GCP Docker build and infra readiness (#38)
Some checks are pending
CI / lint-and-test (push) Waiting to run

This commit is contained in:
twentyOne2x 2026-07-07 11:13:41 +02:00 committed by GitHub
parent d1512d13a5
commit 630a1f6ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 392 additions and 3 deletions

View file

@ -1,11 +1,43 @@
substitutions:
_REGION: europe-west6
_REPOSITORY: teleo
_IMAGE: teleo-pipeline-gcp-staging
_TAG: manual-local
_REVISION: unknown
options:
logging: CLOUD_LOGGING_ONLY
machineType: E2_HIGHCPU_8
serviceAccount: projects/teleo-501523/serviceAccounts/sa-teleo-cloudbuild@teleo-501523.iam.gserviceaccount.com
steps:
- name: gcr.io/cloud-builders/docker
- id: build-staging-image
name: gcr.io/cloud-builders/docker
args:
- build
- -f
- Dockerfile.gcp-staging
- --label
- org.opencontainers.image.source=https://github.com/living-ip/teleo-infrastructure
- --label
- org.opencontainers.image.revision=${_REVISION}
- --label
- livingip.revision=${_REVISION}
- --label
- livingip.surface=teleo-infrastructure
- --label
- livingip.tier=gcp-staging
- -t
- europe-west6-docker.pkg.dev/teleo-501523/teleo/teleo-pipeline-gcp-staging:66ecbf3-20260706
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}
- .
- id: smoke-test-image-before-push
name: gcr.io/cloud-builders/docker
args:
- run
- --rm
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}
images:
- europe-west6-docker.pkg.dev/teleo-501523/teleo/teleo-pipeline-gcp-staging:66ecbf3-20260706
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}

View file

@ -0,0 +1,82 @@
# GCP CI/CD and Redundancy Hardening
This records the current GCP hardening contract for `teleo-501523`.
## What Is Live
- Artifact Registry Docker repositories exist in `europe-west6`:
- `teleo`
- `livingip-web`
- Both repositories use immutable tags and active vulnerability scanning.
- `cloudbuild.gcp-staging.yaml` builds the staging Teleo image, runs the image smoke test, then pushes to Artifact Registry.
- Cloud Build runs as the dedicated service account:
- `sa-teleo-cloudbuild@teleo-501523.iam.gserviceaccount.com`
- The dedicated Cloud Build account has only the roles required for the current build/publish path:
- `roles/artifactregistry.writer`
- `roles/logging.logWriter`
- `roles/storage.objectViewer`
- Backup buckets are versioned and use uniform bucket-level access:
- `gs://teleo-501523-prod-backups`
- `gs://teleo-501523-leoclean-backups`
- VM boot disks have a daily 7-day snapshot policy:
- `teleo-prod-1`
- `teleo-staging-1`
## How To Build
```bash
REVISION="$(git rev-parse HEAD)"
TAG="$(git rev-parse --short=7 HEAD)-manual-$(date -u +%Y%m%d-%H%M%S)"
gcloud builds submit \
--project=teleo-501523 \
--config=cloudbuild.gcp-staging.yaml \
--substitutions="_TAG=${TAG},_REVISION=${REVISION}"
```
Expected result:
- `build-staging-image` succeeds.
- `smoke-test-image-before-push` succeeds.
- A Docker image is pushed to:
`europe-west6-docker.pkg.dev/teleo-501523/teleo/teleo-pipeline-gcp-staging:${TAG}`
## How To Check Readiness
Run from the repository root:
```bash
python3 ops/check_gcp_infra_readiness.py
```
The check is read-only and prints no secret values. It verifies:
- Artifact Registry immutability and vulnerability scanning.
- Cloud Build config contract.
- Dedicated Cloud Build service account and roles.
- Compute disk snapshot policy attachment.
- Backup bucket versioning and uniform access.
- Whether Cloud SQL/Postgres exists.
- Whether a GCP-approved KB/Postgres access secret slot exists.
## Current Boundaries
The GCP Docker build/publish path is live and proved manually. Automatic Git-triggered CI/CD is not yet live because this project currently has no Cloud Build repository connection or trigger.
Database redundancy is not complete. The current project has backup buckets and VM disk snapshots, but it does not yet have a GCP Cloud SQL/Postgres pgvector standby or primary. Do not claim DB parity until one of these is true:
- the existing canonical KB database is replicated into GCP and read back; or
- GCP Cloud SQL/Postgres becomes the canonical database and production services read/write it; or
- an explicitly approved standby restore drill proves that a GCP database can be restored and queried from the retained backups.
Do not create an empty Cloud SQL instance and call it redundancy. It only counts after source data, restore/replication, access controls, and readback are proven.
## Communication Posture
Current GCP communication hardening is limited to the surfaces already read back:
- default SSH/RDP firewall rules are disabled;
- Teleo SSH access is scoped by instance tags and current source IP rules;
- the custom Teleo subnet has Private Google Access enabled.
Before production cutover, add a dedicated runtime communication contract for service-to-service paths, including exact source tags/service accounts, allowed ports, health checks, and rollback.

275
ops/check_gcp_infra_readiness.py Executable file
View file

@ -0,0 +1,275 @@
#!/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",
)
@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_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("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())