Add GitHub Artifact Registry publishing (#39)
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
630a1f6ea7
commit
d89ee7565a
3 changed files with 183 additions and 1 deletions
75
.github/workflows/gcp-artifact.yml
vendored
Normal file
75
.github/workflows/gcp-artifact.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: gcp-artifact
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: gcp-artifact-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PROJECT_ID: teleo-501523
|
||||
REGION: europe-west6
|
||||
ARTIFACT_REPOSITORY: teleo
|
||||
IMAGE_NAME: teleo-pipeline-gcp-staging
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com
|
||||
|
||||
jobs:
|
||||
build-smoke-push:
|
||||
name: Build, smoke-run, and push Docker image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }}
|
||||
|
||||
- uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Artifact Registry Docker auth
|
||||
run: |
|
||||
gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
|
||||
|
||||
- name: Build, smoke-run, and push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_SHA::7}"
|
||||
image_uri="${REGION}-docker.pkg.dev/${PROJECT_ID}/${ARTIFACT_REPOSITORY}/${IMAGE_NAME}:${tag}"
|
||||
|
||||
docker build \
|
||||
-f Dockerfile.gcp-staging \
|
||||
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||
--label "livingip.revision=${GITHUB_SHA}" \
|
||||
--label "livingip.surface=teleo-infrastructure" \
|
||||
--label "livingip.tier=gcp-staging" \
|
||||
-t "${image_uri}" \
|
||||
.
|
||||
|
||||
docker run --rm "${image_uri}"
|
||||
docker push "${image_uri}"
|
||||
|
||||
{
|
||||
echo "image_uri=${image_uri}"
|
||||
echo "revision=${GITHUB_SHA}"
|
||||
} > gcp-artifact-image.txt
|
||||
|
||||
- name: Upload image receipt
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gcp-artifact-image
|
||||
path: gcp-artifact-image.txt
|
||||
if-no-files-found: error
|
||||
|
|
@ -15,6 +15,11 @@ This records the current GCP hardening contract for `teleo-501523`.
|
|||
- `roles/artifactregistry.writer`
|
||||
- `roles/logging.logWriter`
|
||||
- `roles/storage.objectViewer`
|
||||
- GitHub Actions can publish Artifact Registry images through Workload Identity Federation:
|
||||
- workflow: `.github/workflows/gcp-artifact.yml`
|
||||
- provider: `projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github`
|
||||
- service account: `sa-artifact-builder@teleo-501523.iam.gserviceaccount.com`
|
||||
- repository scope: `living-ip/teleo-infrastructure`
|
||||
- Backup buckets are versioned and use uniform bucket-level access:
|
||||
- `gs://teleo-501523-prod-backups`
|
||||
- `gs://teleo-501523-leoclean-backups`
|
||||
|
|
@ -24,6 +29,16 @@ This records the current GCP hardening contract for `teleo-501523`.
|
|||
|
||||
## How To Build
|
||||
|
||||
Automatic Artifact Registry publishing runs on pushes to `main` through GitHub Actions. To run the same lane manually from GitHub:
|
||||
|
||||
```bash
|
||||
gh workflow run gcp-artifact.yml --repo living-ip/teleo-infrastructure --ref main
|
||||
```
|
||||
|
||||
The workflow authenticates to GCP with Workload Identity Federation, builds `Dockerfile.gcp-staging`, runs the image smoke test, pushes the image, and uploads `gcp-artifact-image.txt` as a run artifact.
|
||||
|
||||
For a local/manual Cloud Build proof:
|
||||
|
||||
```bash
|
||||
REVISION="$(git rev-parse HEAD)"
|
||||
TAG="$(git rev-parse --short=7 HEAD)-manual-$(date -u +%Y%m%d-%H%M%S)"
|
||||
|
|
@ -54,6 +69,7 @@ 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.
|
||||
- GitHub Actions WIF Artifact Registry publishing contract.
|
||||
- Compute disk snapshot policy attachment.
|
||||
- Backup bucket versioning and uniform access.
|
||||
- Whether Cloud SQL/Postgres exists.
|
||||
|
|
@ -61,7 +77,7 @@ The check is read-only and prints no secret values. It verifies:
|
|||
|
||||
## 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.
|
||||
The GCP Docker build/publish path is live through manual Cloud Build and GitHub Actions Workload Identity Federation. Native Cloud Build GitHub triggers are not configured because this project currently has no Cloud Build repository connection.
|
||||
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ CLOUDBUILD_REQUIRED_ROLES = (
|
|||
"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
|
||||
|
|
@ -151,6 +155,92 @@ def check_cloud_build_service_account() -> Check:
|
|||
)
|
||||
|
||||
|
||||
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(
|
||||
[
|
||||
|
|
@ -253,6 +343,7 @@ def main() -> int:
|
|||
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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue