GCP Customer Edge fallback guide
本頁內容尚未翻譯。
This guide supports the complete customer prompt. It
creates one Secure Mesh Site v2 with separate SLO and SLI VPCs, outbound SLO NAT and one n2-standard-8 VM without a
public IP. It excludes HA and workloads. The nine files below are a reviewed starting recipe; choose customer inputs and
re-resolve current release/image identities before deployment.
1. Discover and review before changing anything
Section titled “1. Discover and review before changing anything”Obtain an invitation to the private F5Networks/terraform-provider-f5xc-preview repository and authenticated release access. A private repository can return 404 when access is missing. Confirm the native platform has an asset in the newest non-draft release, including prereleases. Enumerate every authenticated release page on each fresh run; do not use a remembered version, cached lockfile, existing mirror or the stable-only latest endpoint. A missing native asset blocks that run; it does not authorize falling back to an older release.
Check Terraform 1.5+, native archive/SHA256/HTTP/JSON tooling, GCP CLI identity, project API access, and Application
Default Credentials separately. Do not print tokens. Inventory all subnets and secondary ranges, choose two
non-overlapping private /26 ranges, verify N2 vCPU quota and machine availability in the selected zone, and inspect
effective firewall policy. Higher-level deny rules may override the VPC rules in this recipe.
Optional GCP service account
Section titled “Optional GCP service account”Use the complete prompt’s IAM preflight before local setup.
Identify the caller, Terraform identity, service-account owning project, deployment project, quota project and credential
method. Return five gates: identity selection, impersonation access, deployment permissions, APIs/quota project and
CLI/ADC authentication. Each reports PASS, BLOCKED or UNKNOWN, evidence, observation time, owner and next action.
Planning returns the table and one private IAM-team request in chat; it creates no files, including plan.md or
temporary HTTP responses. Keep tokens in one process using an HTTP library; never expand them into command arguments
such as curl headers or token query URLs. File readers must not return credential bytes into the conversation.
Test effective permissions on the exact resource as the identity being checked. The caller needs
iam.serviceAccounts.getAccessToken on the requested service account. A Token Creator grant to the service account
itself does not grant the human caller access. A correct owning-project grant can work, although a service-account-level
grant is narrower. Use resource-specific testIamPermissions and authenticated read-only requests; a generic 403,
unreadable policy, conditional uncertainty, malformed response or transport failure is UNKNOWN, never an assumed
Token Creator diagnosis. Token Creator does not grant compute access. Preserve approved custom roles;
roles/compute.admin is a broad option requiring administrator approval. This recipe needs no attached VM identity.
The private IAM-team request identifies the caller, exact target and projects, observed missing permission, administrator command and recheck procedure. Do not send it automatically or ask the sales engineer to change IAM. Keep IAM administration separate from local setup and deployment approval. After administrator confirmation, recheck every 30 seconds for at most ten minutes, preserving the original deadline across resumes. Timeout pauses and preserves evidence; it does not prove the grant is wrong. Group membership changes may take longer.
The helper’s optional iam-preflight command emits JSON with the same five gates without saving files or changing IAM.
When requested impersonation is blocked or unknown, hold all Compute Engine discovery, including public-image reads,
quotas and network inventories. Do not substitute the human caller even when results are labelled as caller evidence.
Caller identity/IAM/API checks and separate provider/XC discovery may continue; return the blocker promptly and resume
Compute Engine checks as the service account only after the operator grant and fresh access verification.
Run it only when the helper is already available or its download was separately approved; the prompt is self-contained.
Use the actual selected values, keeping output private:
python3 gcp-ce-support.py iam-preflight \ --caller 'user:operator@example.com' \ --service-account '<SERVICE_ACCOUNT_EMAIL>' \ --service-account-project example-project \ --deployment-project example-deployment \ --quota-project example-quota \ --credential-method impersonated-adc \ --image-name '<resolved-exact-f5xc-ce-crt-image-name>'Exit 0 means all prerequisite gates passed; exit 2 means prerequisites remain blocked or unknown. The helper keeps regional capacity UNKNOWN until equivalent native zone, machine and quota checks verify it as the selected identity. Independently check the selected image, regional capacity, CIDRs and XC/private-provider access before deployment. Explicit key sources that this helper cannot independently verify remain unknown and require equivalent native credential-aware checks.
A shared/public image can be readable while its image-use permission test returns an empty result. Keep this
discrepancy UNKNOWN; metadata access proves reading, not image use. When the caller, impersonation, deployment-project
permissions, APIs and CLI/ADC checks pass, include a scoped image/licence probe in the reviewed implementation plan.
Successful creation from the exact pinned image as the selected identity proves that capability; independently verify
probe deletion before proceeding. Never create a probe during planning or invent an IAM grant for the image publisher.
Size the probe at least as large as the pinned image’s diskSizeGb; omit the explicit size for GCP’s image minimum or
use the approved CE boot-disk size. The immediately deleted probe does not need an upgrade reserve.
The helper’s propagation protocol returns REVIEW_REQUIRED for this remaining image uncertainty, preserving all gates
without restarting IAM polling or authorizing deployment.
Unavailable organization/folder policy inspection remains UNKNOWN; it does not establish a deny or require broad
organization permissions. For new VPCs, include a phased approved plan: inspect and apply a saved plan for only the two
networks and two subnets plus their random_id naming dependency (five resources), check effective firewalls on those
exact owned networks, then inspect and apply a saved plan for the remaining ten resources. The combined scope stays
fifteen resources; optional SSH adds one to the second plan. Stop on an incompatible inherited
rule. Do not bypass policies or use an unreviewed target/recomputed plan.
After explicit local setup approval, save an owner-only checkpoint outside the workspace containing the canonical prompt
hash, selected identities/projects/method, gate observations, outstanding requests, separate approvals, installation
receipts and propagation deadline. Never save credential values or full policies.
Checkpoint-only approval permits only that checkpoint, not a native session-memory plan.md or planning documents.
Keep planning read-only and artifact-free by default. Planning-file consent never permits temporary diagnostics,
backups or stdout/stderr files. Capture both streams in memory and report only non-secret categories/status;
redacting or deleting a capture later does not authorize creating it. Before any native planning documents are created, offer a
separate planning-file approval naming the absolute workspace and exact relative paths. Use only the documents
and metadata required by the selected platform’s structured planning workflow. Approval includes only those
named files and necessary parent directories. Require an explicit affirmative response approving that exact scope.
Cancelled, dismissed, declined, unanswered or timed-out
interactions grant no approval. Automatic tool permissions and workflow transitions cannot substitute for scoped
consent; keep an ambiguous planning outcome in chat and pause file creation and writing-agent dispatch until
explicit planning-file approval. Workflow agents inherit that scope; implementation tasks wait for deployment
approval. Keep every acceptance gate in concise documents and adapt complete write arguments to the
platform’s supported limits. Verify actual files; retain and repair failed requests instead of repeating them
unchanged. A preview never proves a write. Reject traversal and symlink escapes and inventory hidden files at
each approval boundary. Return all five gates and the private IAM-team request in chat. Authentication,
installations, checkpoints, IAM administration, licence probes and deployment retain separate approvals.
Planning-file approval alone cannot authorize checkpoint creation.
Automatic platform chat/tool history is allowed. The helper provides
save_iam_checkpoint and resume_iam_checkpoint for this protocol; resume always obtains fresh observations and
invalidates deployment approval when selection or prompt changes. Checkpoint schema version 2 records separate
planning_files approval and planning_scope with workspace and an allowlisted paths list. Pass that scope as
the keyword argument planning_scope to both helper functions; they validate containment without creating files.
Use the canonical prompt’s JSON template for all platforms. The core fields are schema_version,
prompt_sha256, selection, gates, outstanding_request, approvals, planning_scope,
installation_receipts and propagation_deadline. The six string selection fields are caller,
service_account, service_account_project, deployment_project, quota_project and credential_method.
The approval map contains only boolean local_setup, planning_files, iam_administration and deployment;
local_setup records checkpoint persistence only. It cannot authorize other setup. Planning scope is null or
an object containing the exact workspace and approved paths. Use the five gate identifiers and row fields
from the template. Preserve the numeric Unix deadline: first administrator confirmation plus 600 seconds.
Do not rename fields, nest status objects in the boolean approval map or copy placeholder values.
Legacy version 1 remains readable and never implies planning-file approval. Workspace/path changes revoke planning-file and
dependent deployment approval; resume revalidates existing path links. Prompt changes also revoke planning approval.
Preserve approved installation receipts across
restarts. Administrator confirmation initiates rechecks; passing authentication never authorizes deployment.
Use a private form-encoded POST to https://oauth2.googleapis.com/tokeninfo, and omit x-goog-user-project from public
OAuth/OpenID identity requests. The quota header can make OpenID userinfo fail with 403 despite working impersonation;
it belongs on GCP resource/API requests. Save initial pending gates and the original deadline before propagation polling.
Save the five complete prerequisite rows, with evidence, caller, target, owner, action and observation time.
Image-use uncertainty keeps the aggregate deployment-permissions gate unknown until the approved probe succeeds.
Use /register/namespaces/system/tokens for JWT registration-token inventory and exact token absence checks.
A 404 from /config/namespaces/system/tokens does not establish absence of registration tokens.
Use the full exact canonical prompt hash from its file/attachment or an independently supplied hash. An excerpt hash
cannot identify the prompt. Record missing provenance as unknown until resolved. Verify the actual terminal shell and
complete multiline output before long commands; prefer an already installed Bash 5+ over macOS Bash 3.2 when native
terminal input is corrupted. Keep terminal repairs task-local and preserve credentials, installations and deadlines.
Include both cloud-platform and userinfo.email scopes in short-lived impersonation requests so token-info can
independently verify the service-account identity without revealing the token.
Inspect existing credentials during read-only planning. Login, ADC-file creation and local authentication changes require explicit setup approval, followed by discovery as the selected identity and review of the deployment plan. For approved setup, the following native CLI commands create impersonated ADC on the machine running Terraform:
gcloud auth logingcloud auth application-default login --impersonate-service-account=SERVICE_ACCOUNT_EMAILThe published Google provider uses this ADC automatically. Use --impersonate-service-account=SERVICE_ACCOUNT_EMAIL
and --project=TARGET_PROJECT_ID on every supporting GCP CLI command, including licence probes and independent cleanup
verification. Do not silently fall back to the user’s compute permissions. Existing primary-user ADC with the provider’s
impersonate_service_account or GOOGLE_IMPERSONATE_SERVICE_ACCOUNT is an alternative; do not combine that with already
impersonated ADC. Resolve conflicting explicit credentials and GOOGLE_APPLICATION_CREDENTIALS in the approved task
environment without changing unrelated CLI profiles.
If the organization requires an existing JSON key, privately set GOOGLE_APPLICATION_CREDENTIALS to its path outside
the Terraform directory. After setup approval, configure the task CLI for that same account with
gcloud auth activate-service-account SERVICE_ACCOUNT_EMAIL --key-file=PRIVATE_KEY_PATH --project=TARGET_PROJECT_ID.
Never create/download a key for this recipe, print its contents, or put it in HCL/tfvars. Protect key files and local
credential stores. Verify CLI and ADC independently using authenticated read-only requests without displaying tokens.
Customer-owned service accounts, IAM bindings and credentials survive CE teardown.
GCP authentication is separate from private GitHub release access and the XC token. A private .env can supply
F5XCProvider_URL and F5XCProvider_API_TOKEN, but Terraform does not automatically load .env files. Load only the
required named values privately for each command. See Google’s
Terraform authentication instructions and
service-account roles.
Continue discovery
Section titled “Continue discovery”Compare images by name and creation time in f5-7626-networks-public; pin the selected f5xc-ce-crt-* full name and immutable ID. Inspect image licences read-only. Inspect existing Terraform state and exact owned GCP/XC resources to distinguish fresh, resumed and orphaned deployments. Do not adopt or delete unowned resources.
Keep XC credentials in an inherited environment or a private credential file outside the Terraform directory. Verify access with a validated read-only API response. Report credential presence only; never expand the value into a presence message. Never display, byte-dump, trace or log a credential source, including through redaction filters. Diagnose shell and path behavior without reading secret bytes.
Present a scoped plan including exact provider version/asset/checksum, image, project/zone, names, CIDRs, cost, optional SSH and cleanup. Require explicit plan approval before installation, file creation, the licence probe or deployment. During planning, use read-only list/get/describe/help operations. Never run a mutating command to test syntax, assume a dry-run flag, or background and cancel a creation command. Licence acceptance remains unverified until the approved probe. All following steps belong to that approved implementation.
2. Resolve and install the exact provider
Section titled “2. Resolve and install the exact provider”On an MDM-managed Mac, use no sudo or privilege requests. Reuse installed tools; any approved nonprivileged installation must use the IT-controlled brew. Diagnose wrapper TLS failures using existing corporate CA path metadata and CLI settings, with the existing trust source scoped to the affected process or an in-memory SSL context. Preserve verification and system trust; create no CA files during artifact-free planning. Recheck the authenticated API identity and private repository access before inferring invalid credentials from auth-status output or requesting another login.
Enumerate all GitHub release pages, filter out drafts, include prereleases, and select the newest publication timestamp.
Do not copy stale version constraints from the provider README or use a loose constraint. The following illustrates a
POSIX shell with gh, jq, python3, and archive tooling; select appropriate native commands on your own host.
set -euo pipefailumask 077REPO=F5Networks/terraform-provider-f5xc-preview# Use a verified authenticated identity. Unset shadowing tokens only if discovery# established that the stored identity is the correct accessible credential.gh api --paginate --slurp "repos/$REPO/releases" > releases.jsonPLATFORM=$(terraform version -json | jq -er '.platform')python3 gcp-ce-support.py release releases.json --platform "$PLATFORM" > selection.jsonTAG=$(jq -er '.tag' selection.json)VERSION=$(jq -er '.version' selection.json)ZIP=$(jq -er '.archive' selection.json)SUMS=$(jq -er '.checksums' selection.json)gh api "repos/$REPO/releases/tags/$TAG" > release.jsonjq -e --arg zip "$ZIP" --arg sums "$SUMS" '([.assets[] | select(.name == $zip)] | length) == 1 and ([.assets[] | select(.name == $sums)] | length) == 1' release.jsongh release download "$TAG" --repo "$REPO" --pattern "$ZIP" --pattern "$SUMS"python3 gcp-ce-support.py checksum "$ZIP" "$SUMS"# Only after checksum success: extract the archive into a task-owned directory,# inspect its members, and install its provider executable in the native mirror.DEST="$HOME/.terraform.d/plugins/registry.terraform.io/F5Networks/f5xc/$VERSION/$PLATFORM"mkdir -p "$DEST"unzip "$ZIP" -d extractedinstall -m 0755 "extracted/terraform-provider-f5xc_v$VERSION" "$DEST/"Reject missing/duplicate/malformed checksums and checksum mismatches before extraction or installation. Inspect archive
members for unsafe paths. Handle platform-specific executable permissions/quarantine using native tools as needed. Pin
versions.tf to = <the exact resolved version> before init. The template uses an unresolved constraint instead of a
fixed release default; discovery must replace it with the exact selected latest preview version before init. Recheck the catalog before implementation and review an updated plan if a
newer release appeared. Reject an absent/loose constraint or an older cached lockfile. Verify the selected release’s
schema and revalidate before using it; keep this binding through saved-plan apply and teardown.
3. Probe marketplace licence acceptance
Section titled “3. Probe marketplace licence acceptance”After approval, create a uniquely named temporary disk from the pinned image in the approved project/zone. A disk tests marketplace acceptance without relying on a default VPC. Record its exact identity, inspect its licence, delete only that owned probe and prove absence, including after failure. The image may require at least its source disk size (currently 69 GB). If terms are not accepted, the customer must accept them before deployment can proceed. Do not treat a failed probe as approval to alter marketplace policy.
4. Write the nine Terraform files
Section titled “4. Write the nine Terraform files”After approval, use terraform/ and, when enabled, docs/ relative to the active private workspace. Confirm the
Terraform files, saved plan binaries and review JSON under terraform/plans/, and any generated docs/terraform.md
appear in its explorer. Keep these artifacts private with permissions instead of hiding them in a sibling directory. Keep credentials outside it; checkpoint and provider scratch may remain outside the workspace. Planning
creates no workspace files except separately approved native planning documents. The defaults intentionally require explicit project, region, zone,
CIDRs and image. Fill those inputs with checked customer values. No XC token belongs in .tfvars or outputs. All state,
saved plans, VM metadata and raw logs may carry secrets and must remain private. The registration JWT is wrapped with
sensitive(); that redacts presentation but does not encrypt state or VM metadata.
The registration token can leave the entire VM metadata map unknown in a saved plan. Verify the reviewed source and saved-plan references, then require private live checks of the static keys, matching token-only bootstrap, root ownership, 0600 mode and state sensitivity immediately after apply. An unknown map does not waive those checks. Include durable registration-deadline and poll-state paths in artifact approval before writing them.
Check terraform-docs first. If installed, generate docs/terraform.md during approved file preparation without another
docs-choice question. If missing, first ask whether generated docs are wanted and explain that yes requires installing
the tool as part of approved local setup. No skips installation and docs without blocking deployment; do not create an
empty docs/ directory. Preserve an explicit opt-out and the choice in the approved checkpoint. For enabled docs,
regenerate after configuration changes, covering requirements, providers, resources, inputs and outputs without
credentials, raw state or plan contents. Retain the files and any generated docs through teardown for review.
The VM blocks inherited project-wide SSH keys. Any optional SSH uses the explicitly supplied key.
Inspect the pinned image’s guestOsFeatures. When UEFI_COMPATIBLE is absent, omit shielded_instance_config
entirely. An explicit all-false block can cause perpetual drift because the API omits unsupported settings.
Resolve that with a reviewed configuration-only removal and a fresh no-change plan; do not hide it with lifecycle
exclusions or repeatedly apply it.
The operating CE uses a 120 GB pd-balanced boot disk by default, rather than the source image minimum.
F5’s sizing reference
documents 80 GB minimum for Mesh nodes; 120 GB is this recipe’s policy to reserve 50% more capacity for software,
logs and upgrades. It is not an F5 recommendation for exactly 120 GB. During planning, use at least
max(120, ceil(1.5 * max(current F5 minimum, image diskSizeGb) / 10) * 10) GB and increase for measured workload needs.
Verify the selected zone, storage quota and current rate. Persistent Disk size is independent of the N2 machine flavor;
Google’s capacity guidance supports 120 GB.
The us-east4 zonal Balanced PD catalog rate checked on 2026-10-23 is $0.11/GiB-month: about $13.20/month for 120 GB,
$4.40/month more than 80 GB, excluding snapshots and taxes. Recheck regional pricing during planning.
The cloud-init includes read-only df/lsblk diagnostics between CE_DISK_DIAGNOSTICS_BEGIN/END serial markers.
After registration, independently check the actual GCP disk capacity/type and supported guest telemetry or these
diagnostics. Record provisioned size, usable filesystem size and free space separately. A larger block disk does not
prove filesystem expansion or a successful future upgrade; missing guest evidence stays unknown. Do not guess partition
or filesystem resize commands. Use Google’s resize guidance
and the CE image’s supported expansion procedure when needed.
The cloud-init writes /etc/vpm/user_data as root with 0600 permissions. JWT .content is a string; .id is not the
registration token. Interface order assigns ens4 to SLO and ens5 to SLI. Registration fills node_list; the site
lifecycle exclusions prevent Terraform from stripping that reported configuration. These exclusions hide some drift, so
independently verify the site via its API after registration.
The firewall uses F5’s current Secure Mesh v2 CE
reference, checked on
2026-10-22. Worldwide TCP and UDP ranges are separate: 159.60.191.0/24 is TCP only. UDP 500 and ESP are outside this
single-site use case. The broad HTTPS rule supports CDN-backed software; effective organization policies and domain
filtering still need discovery.
versions.tf
Section titled “versions.tf”terraform { required_version = ">= 1.5.0"
required_providers { f5xc = { source = "registry.terraform.io/F5Networks/f5xc" # Unresolved template constraint: replace with the exact latest preview before init. version = ">= 0.0.0" } google = { source = "hashicorp/google" version = "~> 6.0" } random = { source = "hashicorp/random" version = "~> 3.6" } }}providers.tf
Section titled “providers.tf”# NOTE: the argument is `url` (not `api_url`) and it MUST include the /api suffix.# Credentials come from F5XCProvider_URL and F5XCProvider_API_TOKEN in the environment,# keep credentials out of configuration; state and VM metadata remain private.provider "f5xc" { timeout = "60s"
# Back off politely if the tenant rate-limits us. throttling = {}}
provider "google" { project = var.gcp_project_id region = var.gcp_region}variables.tf
Section titled “variables.tf”variable "gcp_project_id" { description = "Existing GCP project to deploy the CE into." type = string}
variable "gcp_region" { description = "GCP region for the VPC subnets and the CE instance." type = string}
variable "gcp_zone" { description = "GCP zone for the CE instance. Must be inside var.gcp_region." type = string}
variable "name_prefix" { description = <<-EOT Prefix for every created object. A random suffix is appended so repeated PoC runs cannot collide in a shared project or a shared XC tenant. EOT type = string default = "ce-poc-gcp"
validation { condition = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name_prefix)) error_message = "name_prefix must be DNS-1035: start with a letter, then lowercase letters, digits or dashes." }}
variable "slo_cidr" { description = "CIDR for the SLO (outside) subnet. Must not overlap sli_cidr." type = string}
variable "sli_cidr" { description = "CIDR for the SLI (inside) subnet. Must not overlap slo_cidr." type = string}
variable "machine_type" { description = <<-EOT CE machine type. Minimum supported size is 8 vCPU / 32 GB. Prefer n2-* over t2d-*: n2 has AVX-512, which F5 requires if you ever enable the Distributed Cloud WAF on this site. EOT type = string default = "n2-standard-8"}
variable "boot_disk_size_gb" { description = "CE boot disk size in GB. Default 120 reserves 50% above F5's 80 GB minimum; increase for newer image or sizing requirements." type = number default = 120
validation { condition = var.boot_disk_size_gb >= 120 && var.boot_disk_size_gb == floor(var.boot_disk_size_gb) error_message = "Use a whole-number boot disk size of at least 120 GB to preserve operating and upgrade headroom." }}
variable "boot_disk_type" { description = "Boot disk type. F5's doc says pd-standard; pd-balanced is the pragmatic choice." type = string default = "pd-balanced"}
variable "ce_image" { description = <<-EOT Fully-qualified CE image to boot.
PINNED ON PURPOSE. F5 publishes NO image family for CE images, and image creation timestamps are NOT in version order -- f5xc-ce-crt-20251002-0027-byol was created AFTER f5xc-ce-crt-20260201-byol. A `most_recent = true` lookup can therefore silently select an older software build. Pin it, bump deliberately.
To see what is currently published: gcloud compute images list --project=f5-7626-networks-public \ --filter="name~'^f5xc-ce-crt-'" --no-standard-images \ --format="table(name,creationTimestamp)" --sort-by=creationTimestamp EOT type = string}
variable "xc_namespace" { description = "XC namespace for the site object. Site objects live in `system`." type = string default = "system"}
variable "xc_api_url" { description = "F5XC tenant API URL. Used only to build a clickable console link in the outputs." type = string default = ""}
variable "ssh_public_key" { description = "Optional SSH public key for break-glass access. Not required for registration." type = string default = ""}
variable "ssh_source_ranges" { description = "Source CIDRs allowed to SSH to the CE. Empty list disables the rule." type = list(string) default = []}network.tf
Section titled “network.tf”# A GCP VM cannot have two NICs on the same VPC network, so a dual-NIC CE# requires two separate VPCs: one for SLO (outside) and one for SLI (inside).
resource "random_id" "suffix" { byte_length = 3}
locals { name = "${var.name_prefix}-${random_id.suffix.hex}"
# Console host, derived from the API URL so the output link is clickable. tenant_host = replace(replace(trimsuffix(trimsuffix(var.xc_api_url, "/"), "/api"), "https://", ""), "http://", "")}
# ---------- SLO (outside) ----------
resource "google_compute_network" "slo" { name = "${local.name}-slo-vpc" auto_create_subnetworks = false description = "SLO (outside) network for F5XC CE ${local.name}"}
resource "google_compute_subnetwork" "slo" { #checkov:skip=CKV_GCP_26:Optional persistent VPC Flow Logs are excluded from this disposable PoC; qualification captures private diagnostics. #checkov:skip=CKV_GCP_74:SLO reaches required external services through Cloud NAT. name = "${local.name}-slo-subnet" ip_cidr_range = var.slo_cidr region = var.gcp_region network = google_compute_network.slo.id}
# ---------- SLI (inside) ----------
resource "google_compute_network" "sli" { #checkov:skip=CKV2_GCP_18:SLI has no workloads or external NAT; implied ingress deny is intentional. CE egress rules belong to SLO. name = "${local.name}-sli-vpc" auto_create_subnetworks = false description = "SLI (inside) network for F5XC CE ${local.name}"}
resource "google_compute_subnetwork" "sli" { #checkov:skip=CKV_GCP_26:Optional persistent VPC Flow Logs are excluded from this disposable PoC; qualification captures private diagnostics. #checkov:skip=CKV_GCP_74:SLI contains only the inside CE NIC and no Google API consumers. name = "${local.name}-sli-subnet" ip_cidr_range = var.sli_cidr region = var.gcp_region network = google_compute_network.sli.id}
# ---------- Egress for registration ----------## The CE has no external IP. All outbound traffic (registration, RE tunnels,# DNS, NTP, software download during provisioning) leaves through Cloud NAT on# the SLO network. Without this the node boots but never registers.
resource "google_compute_router" "slo" { name = "${local.name}-slo-router" region = var.gcp_region network = google_compute_network.slo.id}
resource "google_compute_router_nat" "slo" { name = "${local.name}-slo-nat" router = google_compute_router.slo.name region = var.gcp_region nat_ip_allocate_option = "AUTO_ONLY" source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"
subnetwork { name = google_compute_subnetwork.slo.id source_ip_ranges_to_nat = ["ALL_IP_RANGES"] }
log_config { enable = true filter = "ERRORS_ONLY" }}firewall.tf
Section titled “firewall.tf”# GCP's implied rules are: egress allow-all, ingress deny-all.## The CE initiates every connection it needs and those are stateful, so the# implied egress rule is already sufficient in a default project. The explicit# rules below exist so this still works in a project that has a default-deny# egress policy -- common in enterprise projects, and otherwise the cause of a# CE that boots fine and silently never registers.## Reference: "F5 Customer Edge IP Address and Domain Reference for Firewall or# Proxy Settings", section "Secure Mesh v2 CE Sites".
locals { # Worldwide Secure Mesh v2 ranges checked 2026-10-22. TCP and UDP differ. re_ranges_tcp = [ "5.182.215.0/25", "84.54.61.0/25", "23.158.32.0/25", "84.54.62.0/25", "185.94.143.0/25", "185.94.142.0/24", "159.60.190.0/24", "159.60.168.0/24", "159.60.180.0/24", "159.60.174.0/24", "159.60.175.0/24", "159.60.176.0/24", "159.60.177.0/24", "159.60.179.0/24", "159.60.181.0/24", "159.60.183.0/24", "5.182.213.0/24", "5.182.212.0/25", "5.182.214.0/25", "84.54.60.0/25", "185.56.154.0/25", "159.60.160.0/24", "159.60.161.0/24", "159.60.162.0/24", "159.60.163.0/24", "159.60.188.0/24", "159.60.182.0/24", "159.60.178.0/24", "103.135.56.0/24", "103.135.57.0/25", "103.135.59.0/25", "103.135.58.0/24", "159.60.189.0/24", "159.60.166.0/24", "159.60.164.0/24", "159.60.170.0/24", "159.60.172.0/24", "159.60.191.0/24", "159.60.184.0/24", "159.60.185.0/24", "159.60.186.0/24", ] re_ranges_udp = [ "5.182.215.0/25", "84.54.61.0/25", "23.158.32.0/25", "84.54.62.0/25", "185.94.143.0/25", "185.94.142.0/24", "159.60.190.0/24", "159.60.168.0/24", "159.60.180.0/24", "159.60.174.0/24", "159.60.175.0/24", "159.60.176.0/24", "159.60.177.0/24", "159.60.179.0/24", "159.60.181.0/24", "159.60.183.0/24", "5.182.213.0/24", "5.182.212.0/25", "5.182.214.0/25", "84.54.60.0/25", "185.56.154.0/25", "159.60.160.0/24", "159.60.161.0/24", "159.60.162.0/24", "159.60.163.0/24", "159.60.188.0/24", "159.60.182.0/24", "159.60.178.0/24", "103.135.56.0/24", "103.135.57.0/25", "103.135.59.0/25", "103.135.58.0/24", "159.60.189.0/24", "159.60.166.0/24", "159.60.164.0/24", "159.60.170.0/24", "159.60.172.0/24", "159.60.184.0/24", "159.60.185.0/24", "159.60.186.0/24", ]
ce_tag = local.name}
# Site registration and updates.resource "google_compute_firewall" "slo_egress_registration" { name = "${local.name}-slo-egress-registration" network = google_compute_network.slo.id direction = "EGRESS" priority = 1000 destination_ranges = ["159.60.141.140/32"] target_tags = [local.ce_tag]
allow { protocol = "tcp" ports = ["443"] }}
# Regional Edge tunnels. UDP 500 and ESP are deliberately NOT opened -- those# are only needed for CE-to-CE Site Mesh Group connectivity, which a single# site does not have.resource "google_compute_firewall" "slo_egress_re_tcp" { name = "${local.name}-slo-egress-re-tcp" network = google_compute_network.slo.id direction = "EGRESS" priority = 1000 destination_ranges = local.re_ranges_tcp target_tags = [local.ce_tag]
allow { protocol = "tcp" ports = ["80", "443"] }}resource "google_compute_firewall" "slo_egress_re_udp" { name = "${local.name}-slo-egress-re-udp" network = google_compute_network.slo.id direction = "EGRESS" priority = 1000 destination_ranges = local.re_ranges_udp target_tags = [local.ce_tag]
allow { protocol = "udp" ports = ["4500", "123"] }}
# Software download, image pull, blindfold, identity authority. These are# *.volterra.io FQDNs behind CDNs with no stable IP set, so 443 is open wide.resource "google_compute_firewall" "slo_egress_https" { name = "${local.name}-slo-egress-https" network = google_compute_network.slo.id direction = "EGRESS" priority = 1100 destination_ranges = ["0.0.0.0/0"] target_tags = [local.ce_tag]
allow { protocol = "tcp" ports = ["443"] }}
# DNS (8.8.8.8 / 8.8.4.4) and NTP (time.google.com) -- the SMSv2 defaults.resource "google_compute_firewall" "slo_egress_dns_ntp" { name = "${local.name}-slo-egress-dns-ntp" network = google_compute_network.slo.id direction = "EGRESS" priority = 1000 destination_ranges = [ "8.8.8.8/32", "8.8.4.4/32", "216.239.35.0/32", "216.239.35.4/32", "216.239.35.8/32", "216.239.35.12/32", ] target_tags = [local.ce_tag]
allow { protocol = "udp" ports = ["53", "123"] }
allow { protocol = "tcp" ports = ["53"] }}
# Nothing inbound is required for registration: the CE dials out and return# traffic is stateful. SSH is optional break-glass only.resource "google_compute_firewall" "slo_ingress_ssh" { count = length(var.ssh_source_ranges) > 0 ? 1 : 0
name = "${local.name}-slo-ingress-ssh" network = google_compute_network.slo.id direction = "INGRESS" priority = 1000 source_ranges = var.ssh_source_ranges target_tags = [local.ce_tag]
allow { protocol = "tcp" ports = ["22"] }}site.tf
Section titled “site.tf”Verify block_all_services=true against the selected latest release: it has been documented as Enable all Node Local Services (WebUI, SSH and DNS),
despite its negative field name. Disclose this default during planning and confirm it against the selected release.
GCP SSH reachability is a separate approval; do not silently invert the XC service choice.
# Secure Mesh Site v2.## SYNTAX: this provider is built on the Terraform Plugin Framework, so every# nested structure is an ATTRIBUTE assigned with `=`, not an HCL block.
resource "f5xc_securemesh_site_v2" "ce" { name = local.name namespace = var.xc_namespace
description = "Dual-NIC CE proof of concept, created by Terraform"
labels = { deployment = "poc" }
# GCP supports exactly one orchestration mode: not_managed. F5XC will not # create the VM -- Terraform does, and the node registers itself. node_list # is populated by the API after registration, which is why it is not set here. provider_choice = { gcp = { orchestration_choice = { not_managed = {} } } }
# Verify "Enable all Node Local Services" against the selected latest release. # Disclose that policy during planning; the field name does not mean disabled. # Confirm selected-release semantics; GCP SSH access is approved separately. blocked_services_choice = { block_all_services = true }
logs_receiver_choice = { logs_streaming_disabled = true }
node_ha_choice = { disable_ha = true }
dns_ntp_config = { dns_server_choice = { f5_dns_default = true } ntp_server_choice = { f5_ntp_default = true } }
re_select = { re_selection_choice = { geo_proximity = true } }
admin_user_credentials = { ssh_key = var.ssh_public_key }
timeouts = { create = "30m" update = "30m" delete = "30m" }
lifecycle { # REQUIRED FOR IDEMPOTENCY -- do not remove. # # Once the node registers, F5XC populates these four fields on the site # object from what the node reports about itself. Terraform did not set # them, so the next plan tries to remove them again. # # node_list (inside provider_choice) is the dangerous one: applying that # diff strips the node's discovered SLO/SLI interface configuration out of # the site object. # # provider_choice - contains node_list: the interfaces found at # registration (ens4 -> SLO, ens5 -> SLI) # labels - hardware facts (hw-vendor, host-os-version, ...) # operating_system_version - actual OS version running on the node # volterra_software_version - actual CE software version running ignore_changes = [ provider_choice, labels, operating_system_version, volterra_software_version, ] }}token.tf
Section titled “token.tf”# Node registration token.## Must be type JWT (not NORMAL). The JWT embeds the site name and tenant, which# is why the SMSv2 cloud-init needs nothing but `token:`.## The value handed to the node is `.content`, NOT `.id`.# `.id` is "<namespace>/<name>" -- a node booted with that never registers.# This is a real bug in at least one widely-copied community example.
resource "f5xc_token" "ce" { name = "${local.name}-token" namespace = var.xc_namespace
description = "JWT node registration token for ${local.name}"
type = ["JWT"] site_name = f5xc_securemesh_site_v2.ce.name}instance.tf
Section titled “instance.tf”locals { # SMSv2 cloud-init. This is the entire payload for a DHCP dual-NIC GCP node. # # Do NOT use the SMSv1 shape (/etc/vpm/config.yaml with nested Vpm:/Kubernetes: # keys, ClusterName, Latitude, Longitude, CertifiedHardwareEndpoint). ce_user_data = <<-EOT #cloud-config write_files: - path: /etc/vpm/user_data permissions: "0600" owner: root content: | token: ${sensitive(f5xc_token.ce.content)} runcmd: - | { printf 'CE_DISK_DIAGNOSTICS_BEGIN\n' df -Pk / /var /var/lib /var/log lsblk -b -o NAME,SIZE,TYPE,MOUNTPOINT printf 'CE_DISK_DIAGNOSTICS_END\n' } > /dev/console 2>&1 EOT}
resource "google_compute_instance" "ce" { #checkov:skip=CKV_GCP_36:IP forwarding is required for the CE router. #checkov:skip=CKV_GCP_38:Disposable test CE uses Google-managed disk encryption; customer-supplied keys are outside this PoC. #checkov:skip=CKV_GCP_39:The pinned CE image lacks UEFI_COMPATIBLE; Shielded VM is unsupported. name = local.name machine_type = var.machine_type zone = var.gcp_zone
# Required. The CE is a router: it forwards packets whose source/destination # is not its own IP. Without this GCP silently drops that traffic and the # SLI -> SLO path does not work. Many public examples omit it. can_ip_forward = true
tags = [local.ce_tag]
boot_disk { initialize_params { image = var.ce_image size = var.boot_disk_size_gb type = var.boot_disk_type } }
# NIC ORDER IS THE CONTRACT. # nic0 (first block) -> ens4 in the guest -> SLO # nic1 (second block) -> ens5 in the guest -> SLI # # There is no NIC-role setting in the cloud-init; interface ORDER assigns the # roles. The node self-reports both interfaces on registration and F5XC fills # in node_list with the correct SLO/SLI roles -- verified on a real run. This # is why node_list is deliberately NOT declared in site.tf. # # Note the guest device names are ens4/ens5 (predictable network interface # naming), not eth0/eth1. # # No access_config block: the node has no external IP and egresses via Cloud NAT. network_interface { subnetwork = google_compute_subnetwork.slo.id }
network_interface { subnetwork = google_compute_subnetwork.sli.id }
metadata = { user-data = local.ce_user_data
# GCP's valid values are GlobalDefault, ZonalOnly and ZonalPreferred. # Some F5 docs print "ZonePreferred", which is not valid -- GCP ignores # unrecognised values and falls back to the project default. VmDnsSetting = "ZonalPreferred"
# Optional SSH must not inherit project-wide public keys. block-project-ssh-keys = "true"
ssh-keys = var.ssh_public_key != "" ? "admin:${var.ssh_public_key}" : null }
# Shielded VM is deliberately not configured: the CE images do not carry the # UEFI_COMPATIBLE guest OS feature, so it cannot be enabled.
lifecycle { # The user-data carries a JWT that changes whenever the token is replaced. # Without this, a plan after token rotation would destroy and recreate a # perfectly healthy CE. ignore_changes = [metadata["user-data"]] }
depends_on = [ f5xc_securemesh_site_v2.ce, f5xc_token.ce, google_compute_router_nat.slo, ]}outputs.tf
Section titled “outputs.tf”output "site_name" { description = "Name of the Secure Mesh Site v2 object in F5XC." value = f5xc_securemesh_site_v2.ce.name}
output "node_name" { description = "Name of the CE instance in GCP." value = google_compute_instance.ce.name}
output "slo_internal_ip" { description = "SLO (outside) internal IP of the CE node." value = google_compute_instance.ce.network_interface[0].network_ip}
output "sli_internal_ip" { description = "SLI (inside) internal IP of the CE node." value = google_compute_instance.ce.network_interface[1].network_ip}
output "console_url" { description = "Direct link to this site in the F5XC console." value = "https://${local.tenant_host}/web/workspaces/multi-cloud-network-connect/sites/sites/${f5xc_securemesh_site_v2.ce.name}"}Create terraform.tfvars with your approved values:
gcp_project_id = "<approved-project>"gcp_region = "<approved-region>"gcp_zone = "<approved-zone>"name_prefix = "<unique-short-prefix>"slo_cidr = "<checked-private-/26>"sli_cidr = "<different-checked-private-/26>"ce_image = "projects/f5-7626-networks-public/global/images/<approved-image>"# Optional narrow SSH source ranges and public key only when approved.5. Validate and apply an inspected saved plan
Section titled “5. Validate and apply an inspected saved plan”Load private credentials for each Terraform/API invocation without logging values. Run terraform init -input=false,
terraform fmt, terraform fmt -check, terraform validate and terraform providers schema -json. Check attribute
object syntax and required one-of choices against the selected provider. Create terraform plan -input=false -out=tfplan; inspect terraform show tfplan privately for the exact scope, NIC order, image, NAT dependency and
firewall rules. The count is 15 resources by default or 16 with optional SSH; it includes random_id and XC objects.
Apply only that inspected plan with terraform apply -input=false tfplan. If it changes unapproved resources, stop and revise the scoped plan. Record the VM creation time to anchor the original registration deadline.
6. Poll with visible elapsed time
Section titled “6. Poll with visible elapsed time”Allow roughly 30-50 minutes overall, normally 20-45 minutes for registration/upgrades. Cold provider downloads and init
can take longer; teardown often takes several minutes. Announce each wait. Keep the session active and collect output
through bounded tool waits. The poller prints every attempt at 30-second intervals and saves responses to a private
directory. Success requires consecutive valid ONLINE observations at least 30 seconds apart; timeout returns 2
and failures return 1. An initial ONLINE response can briefly return to PROVISIONING.
"""Fallback helpers: exact checksums and bounded, fail-closed XC polling."""
# pylint: disable=invalid-name,too-many-lines
import argparseimport hashlibimport jsonimport osimport reimport shleximport subprocessimport timeimport urllib.errorimport urllib.parseimport urllib.requestfrom collections.abc import Callablefrom datetime import UTC, datetimefrom http import HTTPStatusfrom pathlib import Path
CHECKSUM_COLUMNS = 2MAX_FAILURES = 3MAX_TIMEOUT = 3600ONLINE_STABILITY_SECONDS = 30IMPERSONATION_SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email",]
def newest_release(releases: list[dict]) -> dict: """Select the latest published release, including prereleases.""" candidates = [ release for release in releases if not release.get("draft") and release.get("published_at") ] if not candidates: msg = "No published non-draft provider release" raise ValueError(msg) return max(candidates, key=lambda release: release["published_at"])
def platform_asset(release: dict, platform: str) -> dict: """Require a unique asset for the discovered native platform.""" matches = [ asset for asset in release.get("assets", []) if asset["name"].endswith(f"_{platform}.zip") ] if len(matches) != 1: msg = f"No unique supported provider asset for {platform}" raise ValueError(msg) return matches[0]
def release_manifest(pages: list, platform: str) -> dict: """Resolve paginated release data to one native archive and checksum file.""" releases = [release for page in pages for release in page] release = newest_release(releases) tag = release["tag_name"] version = tag.removeprefix("v") archive = platform_asset(release, platform) sums = f"terraform-provider-f5xc_{version}_SHA256SUMS" if sum(asset["name"] == sums for asset in release.get("assets", [])) != 1: msg = "Missing or duplicate exact checksum file asset" raise ValueError(msg) return { "tag": tag, "version": version, "platform": platform, "archive": archive["name"], "checksums": sums, "asset_digest": archive.get("digest"), }
def verify_checksum(archive: Path, checksums: str) -> None: """Verify one exact checksum entry before trusting archive bytes.""" matches = [] for line in checksums.splitlines(): parts = line.split() if ( len(parts) == CHECKSUM_COLUMNS and parts[1].removeprefix("*") == archive.name ): matches.append(parts[0]) if len(matches) != 1 or not re.fullmatch(r"[0-9a-fA-F]{64}", matches[0]): msg = "Missing, duplicate or malformed exact asset checksum" raise ValueError(msg) with archive.open("rb") as stream: digest = hashlib.file_digest(stream, "sha256").hexdigest() if digest != matches[0].lower(): msg = "CHECKSUM MISMATCH; do not extract or install" raise ValueError(msg)
def redact(text: str, secrets: list[str]) -> str: """Redact known secrets and JWT-shaped values from a sanitized excerpt.""" for secret in secrets: if secret: text = text.replace(secret, "[REDACTED]") text = re.sub( r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", "[REDACTED_JWT]", text ) return re.sub(r"(APIToken\s+|Bearer\s+)[^\s\"']+", r"\1[REDACTED]", text)
# Each response class has a separate fail-closed return.# pylint: disable=too-many-return-statementsdef classify(status: int | None, body: bytes) -> tuple[str, str]: # noqa: PLR0911 """Distinguish validated success, pending, auth, transport and response failure.""" if status is None: return "TRANSPORT_ERROR", "request failed" if status in {401, 403}: return "AUTH_ERROR", str(status) if status != HTTPStatus.OK: return "HTTP_ERROR", str(status) try: payload = json.loads(body) state = payload["spec"]["site_state"] if not isinstance(state, str) or not state: return "INVALID", "missing state" except (ValueError, KeyError, TypeError): return "INVALID", "invalid JSON or site-state shape" if state == "ONLINE": return "ONLINE", state if state in {"FAILED", "ERROR_IN_ORCHESTRATION"}: return "FAILED", state if state in {"WAITING_FOR_REGISTRATION", "PROVISIONING", "UPGRADING"}: return "PENDING", state return "INVALID", "unrecognized site state"
def poll( fetch: Callable[[], tuple[int | None, bytes]], timeout: float = MAX_TIMEOUT, monotonic: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, emit: Callable[[str], None] = print,) -> int: """0 stable ONLINE, 2 timeout, 1 terminal/auth/repeated response failure.""" start = monotonic() failures = 0 online_since: float | None = None while monotonic() - start < timeout: poll_started = monotonic() status, body = fetch() kind, detail = classify(status, body) elapsed = monotonic() - start emit(f"elapsed={elapsed:.0f}s {kind} {detail}") if elapsed >= timeout: break if kind == "ONLINE": online_since = elapsed if online_since is None else online_since if elapsed - online_since >= ONLINE_STABILITY_SECONDS: return 0 emit("WAIT ONLINE stability; require a fresh read at least 30s later") else: online_since = None if kind in {"FAILED", "AUTH_ERROR"}: return 1 failures = ( failures + 1 if kind in {"INVALID", "TRANSPORT_ERROR", "HTTP_ERROR"} else 0 ) if failures >= MAX_FAILURES: emit("STOP repeated response failures; preserve evidence and diagnose") return 1 sleep(max(0, min(poll_started + 30, start + timeout) - monotonic())) emit("TIMEOUT registration incomplete; preserve evidence and bound diagnostics") return 2
# Effective permission tests, never policy-binding inference, define this protocol.IAM_PERMISSION = "iam.serviceAccounts.getAccessToken"COMPUTE_PERMISSIONS = ( "compute.instances.create", "compute.instances.get", "compute.instances.list", "compute.instances.update", "compute.instances.delete", "compute.instances.setMetadata", "compute.instances.setTags", "compute.instances.setLabels", "compute.disks.create", "compute.disks.get", "compute.disks.list", "compute.disks.use", "compute.disks.delete", "compute.disks.resize", "compute.networks.create", "compute.networks.get", "compute.networks.list", "compute.networks.update", "compute.networks.delete", "compute.networks.use", "compute.networks.updatePolicy", "compute.subnetworks.create", "compute.subnetworks.get", "compute.subnetworks.list", "compute.subnetworks.update", "compute.subnetworks.delete", "compute.subnetworks.use", "compute.firewalls.create", "compute.firewalls.get", "compute.firewalls.list", "compute.firewalls.update", "compute.firewalls.delete", "compute.routers.create", "compute.routers.get", "compute.routers.list", "compute.routers.update", "compute.routers.delete", "compute.globalOperations.get", "compute.regionOperations.get", "compute.zoneOperations.get",)IAM_GATES = ( "identity_selection", "impersonation_access", "deployment_permissions", "apis_quota_project", "cli_adc_authentication",)SELECTION_FIELDS = ( "caller", "service_account", "service_account_project", "deployment_project", "quota_project", "credential_method",)METHODS = {"existing-adc", "impersonated-adc", "provider-impersonation", "json-key"}PROPAGATION_SECONDS = 600
def validate_selection(selection: dict) -> None: """Reject command/URL injection and incomplete selected identities.""" project_pattern = r"[a-z][a-z0-9-]{4,62}" email_pattern = r"[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+" valid = ( set(selection) == set(SELECTION_FIELDS) and all(isinstance(value, str) for value in selection.values()) and re.fullmatch( rf"(?:user|serviceAccount):{email_pattern}", selection["caller"] ) and selection["credential_method"] in METHODS and all( re.fullmatch(project_pattern, selection[field]) for field in ( "deployment_project", "quota_project", ) ) ) account = selection.get("service_account", "") if account: valid = ( valid and bool( re.fullmatch( rf"[a-z][a-z0-9-]*@{project_pattern}\.iam\.gserviceaccount\.com", account, ) ) and account.endswith( f"@{selection['service_account_project']}.iam.gserviceaccount.com" ) ) else: valid = valid and not selection.get("service_account_project") valid = valid and selection.get("credential_method") == "existing-adc" if not valid: msg = "Invalid or incomplete IAM identity selection" raise ValueError(msg)
def permission_result( response: tuple[int | None, bytes], required: list[str]) -> tuple[str, str]: """Return missing permissions only after a validated effective response.""" status, body = response if status != HTTPStatus.OK: return ( "UNKNOWN", "permission transport/HTTP failure; investigate authentication and policy", ) try: payload = json.loads(body) permissions = payload.get("permissions", []) if ( not isinstance(payload, dict) or "error" in payload or not isinstance(permissions, list) ): return "UNKNOWN", "invalid permission response" if any(not isinstance(value, str) for value in permissions): return "UNKNOWN", "invalid permission response" missing = sorted(set(required) - set(permissions)) if missing: return "BLOCKED", "missing effective permissions: " + ", ".join(missing) except (ValueError, TypeError, AttributeError): return "UNKNOWN", "invalid permission response" return "PASS", "all requested effective permissions returned"
def iam_preflight( selection: dict, observe: Callable[[str], tuple[str, str]], now: Callable[[], str] | None = None,) -> dict: """Collect five gates; observers return sanitized, fixed-vocabulary evidence.""" validate_selection(selection) stamp = (now or (lambda: datetime.now(UTC).isoformat()))() targets = ( selection["caller"], selection["service_account"] or selection["caller"], selection["deployment_project"], selection["quota_project"], selection["service_account"] or selection["caller"], ) owners = ( "operator", "IAM team", "project IAM team", "project/API team", "operator", ) actions = ( "confirm caller, Terraform identity and credential method", "investigate exact caller access on the requested service account", "review missing permissions using approved custom roles", "review API state, quota identity and regional capacity", "approve scoped local setup and verify CLI/ADC independently", ) gates: list[dict] = [] for gate, target, owner, action in zip( IAM_GATES, targets, owners, actions, strict=True ): status, reason = observe(gate) if status not in {"PASS", "BLOCKED", "UNKNOWN"}: status, reason = "UNKNOWN", "invalid observation status" if gates and gates[0]["status"] != "PASS" and gate != "identity_selection": status, reason = ( "UNKNOWN", "identity selection unverified; dependent evidence invalid", ) gates.append( { "gate": gate, "status": status, "reason": reason, "caller": selection["caller"], "target_resource": target, "owner": owner, "next_action": "continue read-only discovery" if status == "PASS" else action, "observed_at": stamp, } ) if gate == "deployment_permissions" and "licence probe" in reason: gates[-1]["owner"] = "operator" gates[-1]["next_action"] = ( "review a scoped image/licence probe; do not infer an IAM grant" ) if gates[0]["status"] == "PASS" and any( gates[i]["status"] != "PASS" for i in (1, 4) ): gates[0].update( status="UNKNOWN", reason="caller verified; effective Terraform identity remains unverified", next_action=actions[0], ) return {"schema_version": 1, "selection": dict(selection), "gates": gates}
def iam_team_request(result: dict) -> dict | None: """Build one private administrator handoff without an automatic IAM write.""" failed = [gate for gate in result["gates"] if gate["status"] != "PASS"] if not failed: return None selection = result["selection"] command = None impersonation = result["gates"][1] if ( impersonation["status"] == "BLOCKED" and IAM_PERMISSION in impersonation["reason"] ): command = [ "gcloud", "iam", "service-accounts", "add-iam-policy-binding", selection["service_account"], "--project=" + selection["service_account_project"], "--member=" + selection["caller"], "--role=roles/iam.serviceAccountTokenCreator", ] return { "selection": dict(selection), "observations": failed, "administrator_command_argv": command, "administrator_command": shlex.join(command) if command else None, "required_role": "roles/iam.serviceAccountTokenCreator" if command else None, "instruction": ( "Grant to the caller, not the service account itself. Preserve approved custom roles; investigate UNKNOWN evidence before any grant." if command else "Preserve approved custom roles; investigate the specific observations without assuming an IAM grant. Resolve readable-image uncertainty only through a separately reviewed implementation phase." ), "recheck": "After administrator confirmation, rerun all five gates every 30 seconds for at most ten minutes. Timeout pauses; group changes may take longer.", }
CHECKPOINT_VERSION = 2
def validate_planning_scope(scope: dict | None) -> dict | None: """Normalize an explicit allowlist without creating any directories or files.""" if scope is None: return None if not isinstance(scope, dict) or set(scope) != {"workspace", "paths"}: msg = "Planning scope requires only workspace and paths" raise ValueError(msg) workspace, paths = scope["workspace"], scope["paths"] if ( not isinstance(workspace, str) or not Path(workspace).is_absolute() or not isinstance(paths, list) ): msg = "Planning scope requires an absolute workspace and unique approved relative paths" raise ValueError(msg) if ( not paths or any(not isinstance(path, str) for path in paths) or len(paths) != len(set(paths)) or any( not path or Path(path).is_absolute() or "\\" in path or any(part in {"", ".", ".."} for part in path.split("/")) or re.search(r"[\x00-\x1f\x7f]", path) for path in paths ) ): msg = "Planning paths must be a nonempty unique list of safe relative filenames" raise ValueError(msg) root = Path(workspace) if not root.is_dir() or root.is_symlink() or ".." in root.parts: msg = "Planning workspace must be an existing directory without traversal or symlink" raise ValueError(msg) root = root.resolve() for path in paths: target = root / path if target.is_dir(): msg = "Planning paths must name files, not directories" raise ValueError(msg) # Check existing parent links as well as the final file, even when absent. if not target.resolve().is_relative_to(root): msg = "Planning path escapes its approved workspace" raise ValueError(msg) # Even an internal link could redirect a write to an unapproved filename. if any( parent.is_symlink() for parent in (target, *target.parents) if parent != root and parent.is_relative_to(root) ): msg = "Planning paths must not contain symlinks" raise ValueError(msg) return {"workspace": str(root), "paths": sorted(paths)}
def save_iam_checkpoint( path: Path, prompt: Path, result: dict, approvals: dict, installations: list[str], deadline: float | None = None, *, planning_scope: dict | None = None,) -> None: """Save only selected protocol fields after separate local-setup approval.""" if approvals.get("local_setup") is not True: msg = "Explicit local setup approval required for a checkpoint" raise ValueError(msg) if path.parent.stat().st_mode & 0o077 or path.is_symlink(): msg = "Checkpoint parent must be private and target must not be a symlink" raise ValueError(msg) allowed = {"iam_administration", "local_setup", "deployment", "planning_files"} if set(approvals) - allowed or any( not isinstance(value, bool) for value in approvals.values() ): msg = "Approvals must be scoped boolean records" raise ValueError(msg) scope = validate_planning_scope(planning_scope) if approvals.get("planning_files") is True and scope is None: msg = "Planning-file approval requires an explicit workspace/path scope" raise ValueError(msg) # Installation records are SHA256 receipts, not free-form secret-bearing logs. if any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in installations): msg = "Installation receipts must be SHA256 digests" raise ValueError(msg) checkpoint = { "schema_version": CHECKPOINT_VERSION, "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(), "selection": result["selection"], "gates": result["gates"], "outstanding_request": iam_team_request(result), "approvals": approvals, "planning_scope": scope, "installation_receipts": installations, "propagation_deadline": deadline, } # Caller passes only results generated by this module, never raw responses. descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: json.dump(checkpoint, stream, indent=2)
def resume_iam_checkpoint( path: Path, prompt: Path, selection: dict, observe: Callable[[str], tuple[str, str]], *, planning_scope: dict | None = None,) -> dict: """Always obtain fresh gates; identity or prompt changes revoke deployment approval.""" if ( path.is_symlink() or path.stat().st_mode & 0o077 or path.parent.stat().st_mode & 0o077 ): msg = "Checkpoint and parent must be private" raise ValueError(msg) checkpoint = json.loads(path.read_text(encoding="utf-8")) version = checkpoint.get("schema_version") if ( isinstance(version, bool) or not isinstance(version, int) or version not in {1, CHECKPOINT_VERSION} ): msg = "Unsupported IAM checkpoint schema" raise ValueError(msg) saved_scope = ( validate_planning_scope(checkpoint.get("planning_scope")) if version == CHECKPOINT_VERSION else None ) approvals = dict(checkpoint["approvals"]) allowed = {"iam_administration", "local_setup", "deployment", "planning_files"} if ( set(approvals) - allowed or any(not isinstance(value, bool) for value in approvals.values()) or approvals.get("local_setup") is not True ): msg = "Invalid or unscoped checkpoint approvals" raise ValueError(msg) if ( version == CHECKPOINT_VERSION and approvals.get("planning_files") is True and saved_scope is None ): msg = "Planning-file approval requires an explicit workspace/path scope" raise ValueError(msg) scope = validate_planning_scope(planning_scope) scope_changed = saved_scope != scope result = iam_preflight(selection, observe) changed = ( checkpoint["selection"] != selection or checkpoint["prompt_sha256"] != hashlib.sha256(prompt.read_bytes()).hexdigest() ) if version == 1: approvals["planning_files"] = False if changed or scope_changed: approvals["deployment"] = False approvals["planning_files"] = False return { "preflight": result, "approvals": approvals, "selection_or_prompt_changed": changed, "planning_scope_changed": scope_changed, "planning_scope": scope, "installation_receipts": checkpoint["installation_receipts"], "propagation_deadline": checkpoint["propagation_deadline"], }
def wait_iam_propagation( recheck: Callable[[], dict], confirmed: bool, deadline: float, wall_time: Callable[[], float] = time.time, sleep: Callable[[float], None] = time.sleep,) -> dict: """Use the original confirmation deadline, including resumed sessions.""" if not confirmed: return { "status": "PAUSED", "reason": "administrator confirmation required", "observations": [], } if deadline > wall_time() + PROPAGATION_SECONDS: msg = "Propagation deadline cannot exceed ten minutes" raise ValueError(msg) observations = [] while wall_time() < deadline: started = wall_time() result = recheck() observations.append(result) if wall_time() < deadline and all( gate["status"] == "PASS" for gate in result["gates"] ): return {"status": "PASS", "observations": observations} if ( wall_time() < deadline and all(result["gates"][i]["status"] == "PASS" for i in (0, 1, 4)) and all( gate["status"] == "PASS" or (gate["status"] == "UNKNOWN" and marker in gate["reason"]) for gate, marker in zip( result["gates"][2:4], ("licence probe", "regional capacity"), strict=True, ) ) ): return {"status": "REVIEW_REQUIRED", "observations": observations} sleep(max(0, min(started + 30, deadline) - wall_time())) return { "status": "PAUSED", "reason": "propagation timeout; preserve evidence, grant correctness unknown", "observations": observations, }
class GcpIamObserver: """Read-only live adapter; tokens and raw bodies never enter protocol output."""
def __init__(self, selection: dict, image_name: str = "") -> None: """Capture caller authentication privately in the task environment.""" validate_selection(selection) if image_name and not re.fullmatch(r"f5xc-ce-crt-[a-z0-9-]+", image_name): msg = "Invalid selected CE image name" raise ValueError(msg) self.image_name = image_name self.selection = selection self.tokens: dict[str, str] = {} self.identities: dict[str, str] = {} self.token_attempted = False caller = selection["caller"].split(":", 1)[1] self.tokens["caller"] = self.command_token( [ "gcloud", "--account=" + caller, "--impersonate-service-account=", "auth", "print-access-token", "--quiet", ] ) self.identities["caller"] = self.token_identity(self.tokens["caller"])
@staticmethod def command_token(argv: list[str]) -> str: """Capture tokens and errors privately, without inheriting tracing/output.""" try: result = subprocess.run(argv, capture_output=True, timeout=25, check=False) # noqa: S603 - fixed executable and validated arguments return result.stdout.decode().strip() if result.returncode == 0 else "" except (OSError, UnicodeError, subprocess.TimeoutExpired): return ""
def request( self, url: str, token: str, data: dict | bytes | None = None ) -> tuple[int | None, bytes]: """Use only fixed Google HTTPS API origins; never log requests or errors.""" parsed = urllib.parse.urlsplit(url) if parsed.scheme != "https" or parsed.hostname not in { "iam.googleapis.com", "iamcredentials.googleapis.com", "cloudresourcemanager.googleapis.com", "serviceusage.googleapis.com", "compute.googleapis.com", "oauth2.googleapis.com", }: return None, b"" if not token: return None, b"" headers = { "Authorization": "Bearer " + token, "Content-Type": "application/x-www-form-urlencoded" if isinstance(data, bytes) else "application/json", } if parsed.hostname != "oauth2.googleapis.com": headers["X-Goog-User-Project"] = self.selection["quota_project"] body = json.dumps(data).encode() if isinstance(data, dict) else data request = urllib.request.Request( # noqa: S310 - validated HTTPS Google API origin url, headers=headers, data=body, ) try: with urllib.request.urlopen(request, timeout=25) as response: # noqa: S310 - internal fixed HTTPS API URLs return response.status, response.read() except urllib.error.HTTPError as error: error.close() return error.code, b"" # Never return arbitrary server credential echoes. except (OSError, urllib.error.URLError): return None, b""
def token_identity(self, token: str) -> str: """Independently verify email through Google's private token-info request.""" status, body = self.request( "https://oauth2.googleapis.com/tokeninfo", token, urllib.parse.urlencode({"access_token": token}).encode(), ) try: payload = json.loads(body) return ( payload.get("email", "") if status == HTTPStatus.OK and isinstance(payload, dict) else "" ) except (ValueError, TypeError): return ""
def effective_token(self) -> str: """Generate ephemeral impersonation without any IAM policy changes.""" if self.token_attempted: return self.tokens.get("effective", "") self.token_attempted = True account = self.selection["service_account"] if account and self.selection["credential_method"] in { "impersonated-adc", "provider-impersonation", }: status, body = self.request( "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" + account + ":generateAccessToken", self.tokens["caller"], { "scope": IMPERSONATION_SCOPES, "lifetime": "600s", }, ) try: payload = json.loads(body) token = payload["accessToken"] expires = datetime.fromisoformat(payload["expireTime"]) if ( status != HTTPStatus.OK or not isinstance(token, str) or not token or expires <= datetime.now(UTC) ): token = "" except (ValueError, TypeError, KeyError): token = "" else: token = self.tokens["caller"] self.tokens["effective"] = token self.identities["effective"] = self.token_identity(token) return token
def permissions( self, resource: str, permissions: list[str], identity: str ) -> tuple[str, str]: """Test permissions against the exact resource and verified identity.""" token = ( self.tokens["caller"] if identity == "caller" else self.effective_token() ) expected = ( self.selection["caller"].split(":", 1)[1] if identity == "caller" else self.selection["service_account"] or self.selection["caller"].split(":", 1)[1] ) if self.identities.get(identity) != expected: return "UNKNOWN", "authenticated identity unavailable or mismatched" suffix = ( "/testIamPermissions" if resource.startswith("https://compute.googleapis.com/") else ":testIamPermissions" ) return permission_result( self.request(resource + suffix, token, {"permissions": permissions}), permissions, )
def observe(self, gate: str) -> tuple[str, str]: # noqa: PLR0911 - separate fail-closed gates """Implement the five-gate observation protocol with fail-closed evidence.""" if gate == "identity_selection": if self.identities["caller"] != self.selection["caller"].split(":", 1)[1]: return "UNKNOWN", "caller authentication expired or mismatched" return ( "PASS", "caller verified; explicit selection recorded; effective identity checked in dependent gates", ) if gate == "impersonation_access": if self.selection["credential_method"] not in { "impersonated-adc", "provider-impersonation", }: return "PASS", "impersonation not used by selected method" result = self.permissions( "https://iam.googleapis.com/v1/projects/-/serviceAccounts/" + self.selection["service_account"], [IAM_PERMISSION], "caller", ) if result[0] == "PASS" and not self.effective_token(): return "UNKNOWN", "permission granted; token generation unverified" return result if gate == "deployment_permissions": result = self.permissions( "https://cloudresourcemanager.googleapis.com/v1/projects/" + self.selection["deployment_project"], list(COMPUTE_PERMISSIONS), "effective", ) if result[0] != "PASS": return result if not self.image_name: return ( "UNKNOWN", "project permissions verified; resolve exact external CE image and test image-use permission", ) return self.image_gate() if gate == "apis_quota_project": return self.api_gate() return self.auth_gate()
def image_gate(self) -> tuple[str, str]: """Keep readable external-image permission discrepancies unresolved.""" resource = ( "https://compute.googleapis.com/compute/v1/projects/" "f5-7626-networks-public/global/images/" + self.image_name ) result = self.permissions(resource, ["compute.images.useReadOnly"], "effective") if result[0] != "BLOCKED": return result status, body = self.request(resource, self.effective_token()) try: payload = json.loads(body) readable = ( status == HTTPStatus.OK and isinstance(payload, dict) and payload.get("name") == self.image_name and isinstance(payload.get("id"), str) and payload["id"].isdigit() and payload.get("status") == "READY" ) except (ValueError, TypeError): readable = False if readable: return ( "UNKNOWN", "external image metadata verified but image-use permission test empty; review a separately approved licence probe before VM deployment; no IAM grant inferred", ) return result
def api_gate(self) -> tuple[str, str]: """Verify API enabled states and explicit quota-project permission.""" required_apis = { (self.selection["deployment_project"], "compute.googleapis.com"), (self.selection["quota_project"], "iamcredentials.googleapis.com"), } if self.selection["service_account_project"]: required_apis.add( (self.selection["service_account_project"], "iam.googleapis.com") ) for project, api in sorted(required_apis): # Read API configuration as caller; do not use human compute permissions. status, body = self.request( f"https://serviceusage.googleapis.com/v1/projects/{project}/services/{api}", self.tokens["caller"], ) try: payload = json.loads(body) state = payload.get("state") if status != HTTPStatus.OK or state not in {"ENABLED", "DISABLED"}: return "UNKNOWN", "API state unavailable or invalid" if state == "DISABLED": return ( "BLOCKED", "required API disabled: " + api + " in " + project, ) except (ValueError, TypeError, AttributeError): return "UNKNOWN", "API state unavailable or invalid" caller_quota = self.permissions( "https://cloudresourcemanager.googleapis.com/v1/projects/" + self.selection["quota_project"], ["serviceusage.services.use"], "caller", ) if caller_quota[0] != "PASS": return caller_quota result = self.permissions( "https://cloudresourcemanager.googleapis.com/v1/projects/" + self.selection["quota_project"], ["serviceusage.services.use"], "effective", ) if result[0] != "PASS": return result return ( "UNKNOWN", "APIs and quota-project permission verified; regional capacity unverified; complete native quota/machine discovery", )
@staticmethod def adc_metadata() -> dict: """Extract only non-secret ADC method and quota metadata for verification.""" config = Path( os.environ.get("CLOUDSDK_CONFIG", str(Path.home() / ".config/gcloud")) ) try: payload = json.loads( (config / "application_default_credentials.json").read_text( encoding="utf-8" ) ) return {key: payload.get(key) for key in ("type", "quota_project_id")} except (OSError, ValueError, TypeError, AttributeError): return {}
def auth_gate(self) -> tuple[str, str]: # noqa: PLR0911 - explicit credential failure cases """Verify ADC independently; unsupported explicit sources remain unknown.""" if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") or os.environ.get( "GOOGLE_CREDENTIALS" ): return "UNKNOWN", "explicit credentials unverified; never fall back" if self.selection["credential_method"] == "impersonated-adc" and os.environ.get( "GOOGLE_IMPERSONATE_SERVICE_ACCOUNT" ): return "BLOCKED", "conflicting double impersonation configuration" if self.selection["credential_method"] == "json-key": return "UNKNOWN", "key ADC needs native read-only verification" metadata = self.adc_metadata() if metadata.get("quota_project_id") != self.selection["quota_project"]: return "UNKNOWN", "ADC quota project unverified or different from selection" if ( self.selection["credential_method"] == "provider-impersonation" and metadata.get("type") == "impersonated_service_account" ): return "BLOCKED", "conflicting double impersonation configuration" configured_target = os.environ.get("GOOGLE_IMPERSONATE_SERVICE_ACCOUNT") if configured_target and configured_target != self.selection["service_account"]: return "BLOCKED", "configured impersonation target differs from selection" adc = self.command_token( ["gcloud", "auth", "application-default", "print-access-token", "--quiet"] ) adc_identity = self.token_identity(adc) expected = ( self.selection["service_account"] or self.selection["caller"].split(":", 1)[1] ) if self.selection["credential_method"] == "provider-impersonation": if adc_identity != self.selection["caller"].split(":", 1)[1]: return "UNKNOWN", "primary ADC caller unverified or mismatched" # Prove ADC caller can independently generate the selected identity. status, body = self.request( "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" + expected + ":generateAccessToken", adc, { "scope": IMPERSONATION_SCOPES, "lifetime": "600s", }, ) try: adc = json.loads(body)["accessToken"] if status == HTTPStatus.OK else "" adc_identity = ( self.token_identity(adc) if status == HTTPStatus.OK else "" ) except (ValueError, TypeError, KeyError): adc_identity = "" if self.identities.get("effective") != expected or adc_identity != expected: return "UNKNOWN", "CLI/ADC authentication unavailable or mismatched" status, body = self.request( "https://compute.googleapis.com/compute/v1/projects/" + self.selection["deployment_project"], adc, ) try: payload = json.loads(body) if ( status == HTTPStatus.OK and payload.get("name") == self.selection["deployment_project"] ): return ( "PASS", "independent CLI/ADC identity and authenticated compute read verified", ) except (ValueError, TypeError, AttributeError): pass return "UNKNOWN", "authenticated compute read unavailable or invalid"
def run_iam_command(args: argparse.Namespace) -> int: """Emit a private structured preflight without workspace files or IAM mutations.""" selection = {field: getattr(args, field) for field in SELECTION_FIELDS} observer = GcpIamObserver(selection, args.image_name) result = iam_preflight(selection, observer.observe) result["iam_team_request"] = iam_team_request(result) print(json.dumps(result, indent=2)) return 0 if all(gate["status"] == "PASS" for gate in result["gates"]) else 2
def main() -> None: """Run a checksum check or poll with private evidence and a fixed deadline.""" parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) iam = commands.add_parser("iam-preflight") iam.add_argument( "--image-name", default="", help="Resolved exact f5xc-ce-crt image name; absent image leaves deployment permissions UNKNOWN", ) for field in SELECTION_FIELDS: iam.add_argument( "--" + field.replace("_", "-"), default="", required=field not in {"service_account", "service_account_project"}, ) release = commands.add_parser("release") release.add_argument("pages", type=Path) release.add_argument("--platform", required=True) checksum = commands.add_parser("checksum") checksum.add_argument("archive", type=Path) checksum.add_argument("checksums", type=Path) polling = commands.add_parser("poll") polling.add_argument("site") polling.add_argument( "--timeout-seconds", type=int, default=3600, help="Remaining original registration budget, never restart at 60 minutes", ) polling.add_argument("--evidence-dir", type=Path, required=True) args = parser.parse_args() if args.command == "iam-preflight": raise SystemExit(run_iam_command(args)) if args.command == "release": pages = json.loads(args.pages.read_text(encoding="utf-8")) manifest = release_manifest(pages, args.platform) print(json.dumps(manifest, sort_keys=True)) return if args.command == "checksum": verify_checksum(args.archive, args.checksums.read_text(encoding="utf-8")) print("Exact asset checksum verified") return if not 0 < args.timeout_seconds <= MAX_TIMEOUT: parser.error("Timeout must be the remaining original budget, 1..3600 seconds") if not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", args.site): parser.error("Invalid site name") base = os.environ.get("F5XCProvider_URL", "").rstrip("/") # noqa: SIM112 - provider-defined name token = os.environ.get("F5XCProvider_API_TOKEN", "") # noqa: SIM112 - provider-defined name if not base.startswith("https://") or not base.endswith("/api") or not token: parser.error("Private HTTPS /api URL and API token environment are required") os.umask(0o077) args.evidence_dir.mkdir(mode=0o700, parents=True, exist_ok=True) if args.evidence_dir.stat().st_mode & 0o077: parser.error("Evidence directory must be private (0700)") url = f"{base}/config/namespaces/system/sites/{args.site}" sequence = 0
def fetch() -> tuple[int | None, bytes]: nonlocal sequence sequence += 1 request = urllib.request.Request( # noqa: S310 - HTTPS /api URL validated above url, headers={"Authorization": f"APIToken {token}"} ) try: with urllib.request.urlopen(request, timeout=25) as response: # noqa: S310 - HTTPS URL validated above status, body = response.status, response.read() except urllib.error.HTTPError as error: status, body = error.code, error.read() except (OSError, urllib.error.URLError): status, body = None, b"" stamp = time.time_ns() target = args.evidence_dir / f"poll-{stamp}-{sequence}.json" receipt = { "time_ns": stamp, "status": status, "body": body.decode("utf-8", errors="replace"), } target.write_text(json.dumps(receipt), encoding="utf-8") return status, body
raise SystemExit(poll(fetch, timeout=args.timeout_seconds))
if __name__ == "__main__": main()# Credentials must already be loaded privately; never print them.SITE=$(terraform output -raw site_name)python3 gcp-ce-support.py poll "$SITE" --evidence-dir /path/to/private/poll-evidence --timeout-seconds 3600Pass only the remaining original budget if polling starts after VM creation or a command restarts. Do not restart the 60-minute deadline. A pending state or response failure clears the ONLINE stability window. A state can oscillate between provisioning and upgrading; that is pending. Transport/HTTP errors, invalid JSON and unknown states are distinct failures. Authentication errors stop immediately; three repeated invalid/transport/HTTP responses stop for diagnostics. No timeout is success.
After the 60-minute budget, preserve evidence and perform at most ten minutes of read-only checks: owned VM and serial logs, NAT mappings, effective firewall rules, site/node responses and JWT age. Do not print serial logs or metadata publicly.
7. Verify all completion gates
Section titled “7. Verify all completion gates”- Validate two consecutive
spec.site_state=ONLINEresponses at least 30 seconds apart on/config/namespaces/system/sites/<owned-site>. - Match the owned VM NICs to the node on
/config/namespaces/system/securemesh_site_v2s/<owned-site>. Checkspec.gcp.not_managed.node_list:ens4usessite_local_networkwithis_primary=true, andens5usessite_local_inside_networkwithis_primary=false. Do not infer SLI from an emptysli_addressdisplay field. - Run a post-registration
terraform plan -input=false -detailed-exitcode. Exit 0 is no drift; 1 is error; 2 is drift. Do not apply drift blindly.
Recheck ONLINE after the plan before declaring completion. A regression resumes polling within the original
VM-creation deadline and requires fresh NIC and post-registration plan evidence after stability returns. An expired
budget pauses for bounded diagnostics; it never authorizes redeployment or a new deadline.
Read NIC roles under each interface_list[].network_option, not at the interface’s top level. The name, device, MAC and
is_primary fields sit alongside that choice; verify the owned VM mapping without inventing a reported IP field.
Record Regional Edge identities and connected_re separately, validating response shape. ONLINE alone does not establish RE connectivity. Report each met/unmet gate with timestamps and immutable provider/image identities.
8. Review recovery before mutating
Section titled “8. Review recovery before mutating”If JWT registration fails or expires, inspect state ownership and token validity privately. Do not automatically taint, rotate a token, reset or replace the VM. The VM ignores user-data changes: replacing the token alone leaves metadata stale, and resetting can boot stale data again. Present a recovery plan explaining exact token/metadata/replacement effects and obtain approval for those changes. Preserve the failed attempt and retest the changed candidate.
9. Destroy and independently verify absence
Section titled “9. Destroy and independently verify absence”When teardown is authorized, create terraform plan -destroy -input=false -out=destroy.tfplan, inspect it for only
state-owned resources, and apply that saved plan. Prove the state is empty. Independently query each exact owned
instance, boot disk, VPC, subnet, router/NAT, firewall rule and licence probe, and both XC site representations plus
token.
Use validated exact-object absence responses or validated lists without the exact object. A transport error, malformed body or empty unchecked output is not cleanup evidence. Bound XC deletion checks and retain failed cleanup evidence. Do not delete by broad prefixes or remove unrelated resources. Cleanup remains incomplete until the owned GCP resources and XC site are independently absent. If transport fails while polling deletion, preserve the failure and re-read the exact resource and operation. The deletion may already have succeeded. Create and inspect a new saved destroy plan for only remaining state-owned resources; refresh can reconcile an object already absent. Apply that new plan under the existing scoped teardown approval. Do not replay the stale plan, remove unverified state entries or broaden cleanup. Authentication, ownership or scope uncertainty still pauses dependent work.