Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/dev/deploy-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ The destroy workflow must be dispatched from protected `dev` (or `main`) and
rejects a tenant branch such as `gcp-dev`. Configure the
`<environment>-destroy` Environment branch policy and its foundation
`purpose_contexts.destroy` tuple for the same protected branch; the standard
non-production choice is `dev`. A tenant-branch policy or WIF tuple leaves the
destroy job waiting at the Environment gate or rejected by the provider's exact
attribute condition.
non-production choice is `dev`. Foundation bootstrap and the `cicd-oidc` root
reject a destroy tuple on any other ref before writes. A tenant-branch
Environment policy blocks the destroy job at the Environment gate.

GCP may report Cloud SQL, Memorystore, or GKE deleted before Service Networking
and load-balancer dependencies observe the release. The destroy workflow retries
Expand Down
7 changes: 7 additions & 0 deletions platform/terraform/gcp/global/cicd-oidc/inventory.tf
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ variable "purpose_contexts" {
]))
error_message = "Trust tuples require exact repository, Environment, branch and workflow contexts."
}
validation {
condition = alltrue([
for context in lookup(var.purpose_contexts, "destroy", []) :
contains(["refs/heads/dev", "refs/heads/main"], context.ref) && endswith(context.workflow_ref, "@${context.ref}")
])
error_message = "Destroy trust tuples must bind protected refs/heads/dev or refs/heads/main; gcp-dev-destroy.yml rejects every other dispatch ref."
}
}

variable "name_prefix" {
Expand Down
43 changes: 43 additions & 0 deletions platform/terraform/gcp/global/cicd-oidc/tests/inventory.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,46 @@ run "case_insensitive_environment_ownership" {
}
expect_failures = [var.purpose_contexts]
}

# gcp-dev-destroy.yml rejects every dispatch ref except protected dev/main before
# auth, so a destroy tuple on the tenant deploy branch can never be satisfied.
run "destroy_on_protected_dev_with_tenant_deploy_branch" {
command = plan
variables {
purpose_contexts = {
deploy = [{
environment = "customer"
ref = "refs/heads/customer"
workflow_ref = "example/product/.github/workflows/deploy.yml@refs/heads/customer"
reusable_workflow_ref = ""
}]
destroy = [{
environment = "customer-destroy"
ref = "refs/heads/dev"
workflow_ref = "example/product/.github/workflows/gcp-dev-destroy.yml@refs/heads/dev"
reusable_workflow_ref = ""
}]
}
}
}

run "destroy_on_tenant_branch" {
command = plan
variables {
purpose_contexts = {
deploy = [{
environment = "customer"
ref = "refs/heads/customer"
workflow_ref = "example/product/.github/workflows/deploy.yml@refs/heads/customer"
reusable_workflow_ref = ""
}]
destroy = [{
environment = "customer-destroy"
ref = "refs/heads/customer"
workflow_ref = "example/product/.github/workflows/gcp-dev-destroy.yml@refs/heads/customer"
reusable_workflow_ref = ""
}]
}
}
expect_failures = [var.purpose_contexts]
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ variable "purpose_contexts" {
]))
error_message = "Trust tuples require exact repository, Environment, branch and workflow contexts."
}
validation {
condition = alltrue([
for context in lookup(var.purpose_contexts, "destroy", []) :
contains(["refs/heads/dev", "refs/heads/main"], context.ref) && endswith(context.workflow_ref, "@${context.ref}")
])
error_message = "Destroy trust tuples must bind protected refs/heads/dev or refs/heads/main; gcp-dev-destroy.yml rejects every other dispatch ref."
}
}

variable "project_number" {
Expand Down
8 changes: 6 additions & 2 deletions scripts/bootstrap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,12 @@ operator-owned file outside the repository. Supply the project ID and number,
numeric GitHub repository and owner IDs, bucket names, and exact purpose
Environment/branch/workflow tuples. Obtain IDs with `gcloud projects describe`
and `gh api repos/<owner>/<repo>`; bootstrap verifies them before writes. Keep
image build and validation on protected `dev`/`main` refs. Deploy and destroy
use the selected tenant branch. No secret payload belongs in this file.
image build and validation on protected `dev`/`main` refs. Deploy uses the
selected tenant branch. Destroy must bind protected `refs/heads/dev` or
`refs/heads/main`, because `gcp-dev-destroy.yml` rejects every other dispatch
ref before authentication; bootstrap rejects any other destroy tuple before
writes. Configure the `<environment>-destroy` GitHub Environment branch policy
to allow that same protected branch. No secret payload belongs in this file.

```bash
./scripts/bootstrap/deploy.py gcp-foundation --inputs /path/to/foundation.tfvars.json --dry-run
Expand Down
16 changes: 16 additions & 0 deletions scripts/bootstrap/gcp_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@

from bootstrap_core import confirm, gcloud_resource_exists, get_repo_root, run_cmd

# gcp-dev-destroy.yml rejects every other dispatch ref before auth; the
# cicd-oidc purpose_contexts validation enforces the same set.
DESTROY_PROTECTED_REFS = ("refs/heads/dev", "refs/heads/main")


def _require_protected_destroy_refs(purpose_contexts: dict[str, list[dict[str, str]]]) -> None:
"""Reject destroy tuples the destroy workflow's protected-ref guard can never satisfy."""
for context in purpose_contexts.get("destroy", []):
ref = context.get("ref")
if ref not in DESTROY_PROTECTED_REFS or not str(context.get("workflow_ref", "")).endswith(f"@{ref}"):
raise ValueError(
f"Destroy purpose tuple must bind {' or '.join(DESTROY_PROTECTED_REFS)} "
f"(gcp-dev-destroy.yml rejects other dispatch refs); got ref={ref!r}"
)


def bootstrap_gcp_foundation(inputs_path: str, *, dry_run: bool = False) -> None:
"""Apply explicit foundation inputs before runners, image bakes, or platform."""
Expand All @@ -27,6 +42,7 @@ def bootstrap_gcp_foundation(inputs_path: str, *, dry_run: bool = False) -> None
missing = sorted(required - inputs.keys())
if missing:
raise ValueError("Missing foundation inputs: " + ", ".join(missing))
_require_protected_destroy_refs(inputs["purpose_contexts"])
project = inputs["project_id"]
bucket = inputs["terraform_state_bucket_name"]
region = inputs.get("region", "us-central1")
Expand Down
62 changes: 54 additions & 8 deletions scripts/bootstrap/tests/test_gcp_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@

import pytest

from bootstrap_core import set_assume_yes
from gcp_foundation import bootstrap_gcp_foundation
from bootstrap_core import get_repo_root, set_assume_yes
from gcp_foundation import DESTROY_PROTECTED_REFS, bootstrap_gcp_foundation


@pytest.fixture
def inputs(tmp_path):
path = tmp_path / "foundation.json"
def _destroy(ref, workflow_ref=None):
return {
"destroy": [
{
"environment": "example-destroy",
"ref": ref,
"workflow_ref": workflow_ref or f"example/shifter/.github/workflows/gcp-dev-destroy.yml@{ref}",
"reusable_workflow_ref": "",
}
]
}


def _write_inputs(path, purpose_contexts):
path.write_text(
json.dumps(
{
Expand All @@ -24,7 +35,7 @@ def inputs(tmp_path):
"github_repo": "shifter",
"github_repository_id": "456",
"github_owner_id": "789",
"purpose_contexts": {},
"purpose_contexts": purpose_contexts,
"terraform_state_bucket_name": "example-project-state",
"release_evidence_bucket_name": "example-project-evidence",
}
Expand All @@ -33,6 +44,11 @@ def inputs(tmp_path):
return path


@pytest.fixture
def inputs(tmp_path):
return _write_inputs(tmp_path / "foundation.json", _destroy("refs/heads/dev"))


def test_new_foundation_creates_backend_before_saved_plan_apply(inputs, monkeypatch):
calls = []

Expand Down Expand Up @@ -84,8 +100,38 @@ def test_incomplete_inputs_fail_before_commands(tmp_path, monkeypatch):
assert calls == []


def test_dry_run_executes_no_commands(inputs, monkeypatch):
@pytest.mark.parametrize("ref", DESTROY_PROTECTED_REFS)
def test_dry_run_executes_no_commands(ref, tmp_path, monkeypatch):
calls = []
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: calls.append(args))
bootstrap_gcp_foundation(str(_write_inputs(tmp_path / "foundation.json", _destroy(ref))), dry_run=True)
assert calls == []


@pytest.mark.parametrize(
"purpose_contexts",
[
_destroy("refs/heads/balrog"),
_destroy("refs/heads/dev", "example/shifter/.github/workflows/gcp-dev-destroy.yml@refs/heads/balrog"),
],
)
def test_unprotected_destroy_tuple_fails_before_commands(purpose_contexts, tmp_path, monkeypatch):
calls = []
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: calls.append(args))
bootstrap_gcp_foundation(str(inputs), dry_run=True)
with pytest.raises(ValueError, match="Destroy purpose tuple"):
bootstrap_gcp_foundation(str(_write_inputs(tmp_path / "foundation.json", purpose_contexts)))
assert calls == []


def test_destroy_refs_match_workflow_guard_and_terraform_contract():
root = get_repo_root()
workflow = (root / ".github/workflows/gcp-dev-destroy.yml").read_text()
assert f"{'|'.join(DESTROY_PROTECTED_REFS)}) ;;" in workflow
allowed = "contains([" + ", ".join(f'"{ref}"' for ref in DESTROY_PROTECTED_REFS) + "], context.ref)"
for inventory in (
"platform/terraform/gcp/global/cicd-oidc/inventory.tf",
"platform/terraform/gcp/modules/cicd-oidc-identity/inventory.tf",
):
assert allowed in (root / inventory).read_text()
example = json.loads((root / "scripts/bootstrap/gcp-foundation.example.tfvars.json").read_text())
assert all(context["ref"] in DESTROY_PROTECTED_REFS for context in example["purpose_contexts"]["destroy"])
Loading