From 73166c09983dabd23f9cff026a53973d87010d4f Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover Date: Fri, 7 Aug 2026 11:04:36 -0400 Subject: [PATCH 1/3] Prove cross-cloud artifact and identity feasibility --- .github/workflows/ci.yml | 7 + CHANGELOG.md | 7 + HANDOFF.md | 39 ++- .../cloud-portability/phase1b/README.md | 88 ++++++ .../phase1b/ecr/.terraform.lock.hcl | 26 ++ .../cloud-portability/phase1b/ecr/main.tf | 33 +++ .../cloud-portability/phase1b/ecr/outputs.tf | 9 + .../phase1b/ecr/terraform.tfvars.example | 2 + .../phase1b/ecr/variables.tf | 21 ++ .../cloud-portability/phase1b/ecr/versions.tf | 21 ++ .../phase1b/smoke/.terraform.lock.hcl | 46 +++ .../cloud-portability/phase1b/smoke/main.tf | 198 +++++++++++++ .../phase1b/smoke/outputs.tf | 35 +++ .../phase1b/smoke/terraform.tfvars.example | 6 + .../phase1b/smoke/variables.tf | 66 +++++ .../phase1b/smoke/versions.tf | 29 ++ docs/decisions.md | 12 + docs/known-limitations.md | 4 + docs/oci-artifact-contract.md | 12 +- scripts/portability/__init__.py | 1 + scripts/portability/oci_copy.py | 169 +++++++++++ .../portability/prepare_phase1b_context.py | 124 ++++++++ .../scan_long_lived_credentials.py | 62 ++++ scripts/portability/wif_bigquery_probe.py | 169 +++++++++++ src/dander/bootstrap/project.py | 43 ++- tests/bootstrap/test_project_bootstrap.py | 95 +++++- tests/portability/test_phase1b_tools.py | 276 ++++++++++++++++++ ...oss-cloud-artifact-identity-feasibility.md | 36 +++ 28 files changed, 1611 insertions(+), 25 deletions(-) create mode 100644 acceptance/cloud-portability/phase1b/README.md create mode 100644 acceptance/cloud-portability/phase1b/ecr/.terraform.lock.hcl create mode 100644 acceptance/cloud-portability/phase1b/ecr/main.tf create mode 100644 acceptance/cloud-portability/phase1b/ecr/outputs.tf create mode 100644 acceptance/cloud-portability/phase1b/ecr/terraform.tfvars.example create mode 100644 acceptance/cloud-portability/phase1b/ecr/variables.tf create mode 100644 acceptance/cloud-portability/phase1b/ecr/versions.tf create mode 100644 acceptance/cloud-portability/phase1b/smoke/.terraform.lock.hcl create mode 100644 acceptance/cloud-portability/phase1b/smoke/main.tf create mode 100644 acceptance/cloud-portability/phase1b/smoke/outputs.tf create mode 100644 acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example create mode 100644 acceptance/cloud-portability/phase1b/smoke/variables.tf create mode 100644 acceptance/cloud-portability/phase1b/smoke/versions.tf create mode 100644 scripts/portability/__init__.py create mode 100644 scripts/portability/oci_copy.py create mode 100644 scripts/portability/prepare_phase1b_context.py create mode 100644 scripts/portability/scan_long_lived_credentials.py create mode 100644 scripts/portability/wif_bigquery_probe.py create mode 100644 tests/portability/test_phase1b_tools.py create mode 100644 tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27f8b9ec..90c016fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,13 @@ jobs: run: | terraform -chdir=bootstrap-admin init -backend=false -input=false terraform -chdir=bootstrap-admin validate + - name: Validate cross-cloud feasibility roots + run: | + terraform fmt -check -recursive ../acceptance/cloud-portability/phase1b + terraform -chdir=../acceptance/cloud-portability/phase1b/ecr init -backend=false -input=false + terraform -chdir=../acceptance/cloud-portability/phase1b/ecr validate + terraform -chdir=../acceptance/cloud-portability/phase1b/smoke init -backend=false -input=false + terraform -chdir=../acceptance/cloud-portability/phase1b/smoke validate - name: Static security analysis uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e03aa99..1abbf9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ capabilities enter through the next minor release. ## Unreleased +### Added + +- Publish source-free runtime images as one `linux/amd64,linux/arm64` OCI index and reject an + incomplete registry result before recording publication success. +- Add an isolated, unscheduled Phase 1B proof for digest-preserving GAR-to-ECR copy and keyless + Fargate-to-BigQuery credential refresh. This is feasibility evidence, not Fargate support. + ## 0.7.0 — 2026-08-07 (beta) ### Acceptance diff --git a/HANDOFF.md b/HANDOFF.md index ece17571..664d305d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,34 +2,41 @@ ## Finished -- Prepared stable Dander `0.7.0` from the accepted `0.7.0rc2` lineage. -- Published stable Salesforce `0.3.1` and ServiceNow `0.2.2` connector packages. -- Updated stable package, catalog, example, installation, upgrade, release, and handoff references. -- Kept Dander runtime implementation unchanged from the accepted candidate. +- Started Phase 1B from stable `v0.7.0` without touching the docs-only roadmap branch. +- Made source-free image publication require one `linux/amd64,linux/arm64` OCI index. +- Added digest-preserving GAR-to-ECR copy verification and a bounded WIF refresh probe. +- Added isolated ECR and manual Fargate proof roots with separate execution/task roles. +- Built one source-free GAR index; both `amd64` and `arm64` passed local runtime conformance. ## Try It -Install the built package outside the checkout, generate a project, and verify that exact stable -connector pins install without changing Dander `0.7.0`. +Follow `acceptance/cloud-portability/phase1b/README.md` only with disposable AWS/GCP accounts and +saved reviewed plans. No live apply is required for local validation. ## Checks -- Phase 1 source-free local/Cloud Run acceptance and final Terraform no-drift passed. -- All 815 tests, Ruff lint/format, strict mypy, package build, and external stable resolution passed. +- All 825 tests, Ruff, and strict mypy passed. +- Both Phase 1B Terraform roots initialized and validated with locked providers. +- Package build, distribution inspection, and clean external wheel install passed. +- Corrected GAR index `sha256:6cd545fa…be81f` passed both platform conformance and key scans; no + fixable high/critical CVE was found (four unfixed base-image findings remain visible). ## Decisions -- Promote the accepted candidate without a functional runtime change. -- Phase 1B remains a separate AWS artifact-copy and keyless-identity gate. +- A registry digest rewrite fails rather than being accepted as an equivalent image. +- Fargate receives no service or schedule; the proof task role has no AWS permission policy. +- Fargate remains feasibility-only until the later portable BigQuery vertical slice. ## Remaining -- Merge through protected main, tag `v0.7.0`, and publish through the protected environment. -- Verify public stable installation and create the GitHub Release. -- Begin Phase 1B in a separate branch without touching the retained project. +- Authenticate the new AWS account with short-lived CLI credentials. +- Build/copy the source-free index and run the reviewed live Fargate/WIF refresh proof. +- Run image/config/state/log scans and Cloud Run artifact parity. +- Destroy AWS proof resources and require isolated GCP `No changes.` +- Record acceptance, complete protected CI/review, and merge the focused PR. ## Review First -- `CHANGELOG.md` -- `pyproject.toml` -- `src/dander/plugins/catalog.py` +- `src/dander/bootstrap/project.py` +- `scripts/portability/oci_copy.py` +- `acceptance/cloud-portability/phase1b/smoke/main.tf` diff --git a/acceptance/cloud-portability/phase1b/README.md b/acceptance/cloud-portability/phase1b/README.md new file mode 100644 index 00000000..d33fd567 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/README.md @@ -0,0 +1,88 @@ +# Phase 1B — Artifact Copy and Cross-Cloud Identity Proof + +This bounded acceptance proves two assumptions before Dander adds provider factories: one +multi-platform OCI index can move from GAR to ECR without a rebuild, and an isolated Fargate task +can query BigQuery before and after a short-lived Google credential refresh without either cloud's +static access keys. + +This directory is proof infrastructure, not a supported Fargate launcher. It creates no ECS +service or schedule. Use only disposable accounts/projects, saved Terraform plans, and an operator +artifact directory outside the repository. Never commit generated credential configuration, +Terraform state, task descriptions, or logs. + +## Prerequisites + +- Dander `0.7.0`, Docker Buildx, Terraform 1.9+, `gcloud`, and AWS CLI authenticated with + short-lived browser/SSO credentials. +- `crane` pinned to `v0.20.6` (`go install + github.com/google/go-containerregistry/cmd/crane@v0.20.6`). +- A disposable billing-linked GCP project containing the bounded proof table. +- A disposable AWS account. Do not create an AWS access key. + +The tested GCP target is project `project-092b24a8-26a3-4438-8cd`, dataset `dander_raw`, table +`greenhouse_job_board_jobs`. The retained Dander project is out of scope. + +## Reviewed sequence + +1. Initialize `ecr/`, save and review its plan, then apply only that saved plan. Record + `repository_url`. +2. Generate a fresh project from the public package into an operator temp directory. Confirm it + has no `src/` directory. +3. Generate an AWS external-account file for the deterministic provider name and probe service + account. This command writes configuration, not a secret: + + ```console + gcloud iam workload-identity-pools create-cred-config \ + projects/1009770943166/locations/global/workloadIdentityPools/dander-phase1b-aws/providers/fargate \ + --service-account=dander-phase1b-aws@project-092b24a8-26a3-4438-8cd.iam.gserviceaccount.com \ + --aws --output-file="$OPERATOR_DIR/external-account.json" + ``` + +4. Prepare only the generated context. The helper validates `external_account`/`aws1`, rejects + private keys and client secrets, and requests a 600-second impersonated token: + + ```console + python scripts/portability/prepare_phase1b_context.py \ + --project-dir "$PROJECT_DIR" \ + --credential-config "$OPERATOR_DIR/external-account.json" + ``` + +5. From the generated project, use this branch's `dander image-publish` to build one + `linux/amd64,linux/arm64` index in staging GAR. Preserve `.dander/runtime-artifact.json` outside + the repository. +6. Authenticate Docker to GAR and ECR with short-lived tokens. Copy and verify without rebuilding: + + ```console + python scripts/portability/oci_copy.py \ + --source "$GAR_IMAGE_AT_DIGEST" \ + --destination "$ECR_REPOSITORY:phase1b-$SHORT_REVISION" \ + --record "$OPERATOR_DIR/oci-copy.json" + ``` + + The command fails unless the index digest and both runnable platform-manifest digests are + identical after the copy. +7. Set the verified ECR digest in `smoke/terraform.tfvars`, initialize `smoke/`, save and review its + plan, then apply only that saved plan. The execution role can pull/log; the separate task role + has no AWS permission policy and is the only role trusted by Google WIF. +8. Run exactly one Fargate task using the Terraform outputs for cluster, task definition, subnet, + and security group. Wait for terminal status and save `describe-tasks` plus the CloudWatch log + stream outside the repository. +9. Require two `query.completed` events followed by `credential.refresh_observed`. The second + expiry must be later than the first. No event may contain a token, URL, query body, or record. +10. Run `scan_long_lived_credentials.py` over the generated config, both Terraform states, task + definition/description, exported logs, and an extracted image filesystem. Run the normal image + vulnerability and secret scans as well. +11. Run the same GAR index in the isolated Cloud Run proof job and confirm its selected `amd64` + manifest matches the recorded index. Restore the isolated project through its reviewed + manifest-aware plan and require `No changes.` +12. Destroy the smoke stack and ECR root from reviewed destroy plans. If teardown is deferred, the + account must still contain no ECS service or schedule. + +## Pass criteria + +- One build; identical GAR/ECR index and `amd64`/`arm64` digests; no registry rewrite. +- The Fargate task uses the task role while ECS pull/logging uses the execution role. +- BigQuery succeeds before and after observed Google credential expiry/refresh. +- No Google service-account key, AWS access key, or secret value appears in inputs, state, task + definition, logs, or image. +- Both proof roots are destroyed and the isolated GCP platform finishes with no Terraform drift. diff --git a/acceptance/cloud-portability/phase1b/ecr/.terraform.lock.hcl b/acceptance/cloud-portability/phase1b/ecr/.terraform.lock.hcl new file mode 100644 index 00000000..4d4a6054 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.58.0" + constraints = ">= 5.0.0, < 7.0.0" + hashes = [ + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} diff --git a/acceptance/cloud-portability/phase1b/ecr/main.tf b/acceptance/cloud-portability/phase1b/ecr/main.tf new file mode 100644 index 00000000..cd1ada84 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/main.tf @@ -0,0 +1,33 @@ +resource "aws_ecr_repository" "runtime" { + name = var.repository_name + image_tag_mutability = "IMMUTABLE" + force_delete = true + + encryption_configuration { + encryption_type = "AES256" + } + + image_scanning_configuration { + scan_on_push = true + } +} + +resource "aws_ecr_lifecycle_policy" "runtime" { + repository = aws_ecr_repository.runtime.name + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Retain only the five newest feasibility images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 5 + } + action = { + type = "expire" + } + } + ] + }) +} diff --git a/acceptance/cloud-portability/phase1b/ecr/outputs.tf b/acceptance/cloud-portability/phase1b/ecr/outputs.tf new file mode 100644 index 00000000..4da006cd --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/outputs.tf @@ -0,0 +1,9 @@ +output "repository_url" { + description = "ECR repository to receive the digest-preserving OCI copy." + value = aws_ecr_repository.runtime.repository_url +} + +output "registry_id" { + description = "AWS registry identifier used during ECR login." + value = aws_ecr_repository.runtime.registry_id +} diff --git a/acceptance/cloud-portability/phase1b/ecr/terraform.tfvars.example b/acceptance/cloud-portability/phase1b/ecr/terraform.tfvars.example new file mode 100644 index 00000000..869724c2 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/terraform.tfvars.example @@ -0,0 +1,2 @@ +aws_region = "us-east-1" +repository_name = "dander-phase1b" diff --git a/acceptance/cloud-portability/phase1b/ecr/variables.tf b/acceptance/cloud-portability/phase1b/ecr/variables.tf new file mode 100644 index 00000000..00cd55ac --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/variables.tf @@ -0,0 +1,21 @@ +variable "aws_region" { + description = "AWS region for the isolated Phase 1B proof." + type = string + default = "us-east-1" + + validation { + condition = can(regex("^[a-z]{2}(?:-gov)?-[a-z]+-[0-9]+$", var.aws_region)) + error_message = "aws_region must be a valid AWS region name." + } +} + +variable "repository_name" { + description = "Private ECR repository that receives the copied OCI index." + type = string + default = "dander-phase1b" + + validation { + condition = can(regex("^[a-z0-9]+(?:[._/-][a-z0-9]+)*$", var.repository_name)) + error_message = "repository_name must be a valid private ECR repository name." + } +} diff --git a/acceptance/cloud-portability/phase1b/ecr/versions.tf b/acceptance/cloud-portability/phase1b/ecr/versions.tf new file mode 100644 index 00000000..239ce1c5 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/ecr/versions.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 1.9.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + managed-by = "dander" + purpose = "cloud-portability-phase1b" + } + } +} diff --git a/acceptance/cloud-portability/phase1b/smoke/.terraform.lock.hcl b/acceptance/cloud-portability/phase1b/smoke/.terraform.lock.hcl new file mode 100644 index 00000000..f5bd801b --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/.terraform.lock.hcl @@ -0,0 +1,46 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.58.0" + constraints = ">= 5.0.0, < 7.0.0" + hashes = [ + "h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=", + "zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250", + "zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f", + "zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48", + "zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba", + "zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7", + "zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56", + "zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6", + "zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80", + "zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75", + "zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a", + "zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c", + "zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae", + "zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca", + "zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "7.43.0" + constraints = ">= 6.0.0, < 8.0.0" + hashes = [ + "h1:AwFYLjEh05Jog9DYlEQDacVk2Sjt5/QrKAsymAW0k/Q=", + "zh:0050abe670a076cfc110662ba3e9f8c25366a63df9389dfbe090b5dc5e73a4d2", + "zh:31769c27700334d11e652ddb2a0dfc9efd65df43dbc51a06afc04e10db645bbe", + "zh:33f7211886d44a31d9dc89fbe11f0d2e282da22b509ae25dcfb8c6533eeacff4", + "zh:3ee98b297ec00ad10b1080fddfddbe72c38dec00eece6d56e706fea1e871d648", + "zh:620683cba53b964a882a419c38d40ab4595459b44dc54b9ed18c5bb6b97fbceb", + "zh:7c118cc8ba4c245807b3cbcfd4f5bdb81b1afce83fa1528020208117f7c9518c", + "zh:8263f9aeab9c8bc661a8f6a4963e585ca0a86ed6662424d8f34923fd01c6de18", + "zh:d2ba502aa3da2dad62ae81dd32c97bc279c56a97b9ac3bb3f806303dc0af598d", + "zh:db37f9f900b801ffaad1b7194c4b89de9351879e753b752ca37e12f3e989691f", + "zh:eca80c71ef4a559b94a779365a8d8b91a33d7a805a27ca21c176720085c31d30", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fa91060bb31207fc91645e7de881fa61f106b9d0dfed2f444f158b9cd9420c58", + ] +} diff --git a/acceptance/cloud-portability/phase1b/smoke/main.tf b/acceptance/cloud-portability/phase1b/smoke/main.tf new file mode 100644 index 00000000..d7bc31cd --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/main.tf @@ -0,0 +1,198 @@ +data "aws_caller_identity" "current" {} + +locals { + name = "dander-phase1b" +} + +resource "aws_cloudwatch_log_group" "probe" { + name = "/dander/phase1b" + retention_in_days = 7 +} + +resource "aws_ecs_cluster" "probe" { + name = local.name + + setting { + name = "containerInsights" + value = "enabled" + } +} + +data "aws_iam_policy_document" "ecs_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "execution" { + name = "${local.name}-execution" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json +} + +resource "aws_iam_role_policy_attachment" "execution" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "task" { + name = "${local.name}-task" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json + + # Deliberately no AWS permission policy: temporary task-role identity is exchanged by Google WIF. +} + +resource "aws_vpc" "probe" { + cidr_block = "10.77.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true +} + +resource "aws_internet_gateway" "probe" { + vpc_id = aws_vpc.probe.id +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.probe.id + cidr_block = "10.77.1.0/24" + map_public_ip_on_launch = true +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.probe.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.probe.id + } +} + +resource "aws_route_table_association" "public" { + route_table_id = aws_route_table.public.id + subnet_id = aws_subnet.public.id +} + +resource "aws_security_group" "task" { + name = local.name + description = "Outbound-only network access for the Dander Phase 1B probe" + vpc_id = aws_vpc.probe.id + + egress { + description = "HTTPS access to AWS, Google STS, and BigQuery APIs" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "google_iam_workload_identity_pool" "aws" { + workload_identity_pool_id = "dander-phase1b-aws" + display_name = "Dander Phase 1B AWS" + description = "Disposable keyless Fargate-to-Google identity proof" +} + +resource "google_iam_workload_identity_pool_provider" "aws" { + workload_identity_pool_id = google_iam_workload_identity_pool.aws.workload_identity_pool_id + workload_identity_pool_provider_id = "fargate" + display_name = "Dander Phase 1B Fargate" + description = "Trusts only the isolated Dander ECS task role" + + attribute_mapping = { + "google.subject" = "assertion.arn" + "attribute.aws_role" = "assertion.arn.extract('assumed-role/{role}/')" + } + attribute_condition = "assertion.arn.startsWith('arn:aws:sts::${data.aws_caller_identity.current.account_id}:assumed-role/${aws_iam_role.task.name}/')" + + aws { + account_id = data.aws_caller_identity.current.account_id + } +} + +resource "google_service_account" "probe" { + account_id = "dander-phase1b-aws" + display_name = "Dander Phase 1B AWS probe" + description = "Disposable BigQuery reader impersonated by the isolated Fargate task" +} + +resource "google_service_account_iam_member" "aws_workload_identity" { + service_account_id = google_service_account.probe.name + role = "roles/iam.workloadIdentityUser" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.aws.name}/attribute.aws_role/${aws_iam_role.task.name}" +} + +resource "google_project_iam_member" "bigquery_job_user" { + project = var.gcp_project_id + role = "roles/bigquery.jobUser" + member = "serviceAccount:${google_service_account.probe.email}" +} + +resource "google_bigquery_dataset_iam_member" "proof_reader" { + project = var.gcp_project_id + dataset_id = var.proof_dataset + role = "roles/bigquery.dataViewer" + member = "serviceAccount:${google_service_account.probe.email}" +} + +resource "aws_ecs_task_definition" "probe" { + family = local.name + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = 256 + memory = 512 + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + + runtime_platform { + cpu_architecture = var.cpu_architecture + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([ + { + name = "probe" + image = var.ecr_image + essential = true + readonlyRootFilesystem = true + entryPoint = ["python", "/app/phase1b_probe.py"] + command = [ + "--project", + var.gcp_project_id, + "--dataset", + var.proof_dataset, + "--table", + var.proof_table, + ] + environment = [ + { + name = "GOOGLE_APPLICATION_CREDENTIALS" + value = "/app/gcp-wif.json" + }, + { + name = "GOOGLE_CLOUD_PROJECT" + value = var.gcp_project_id + }, + ] + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.probe.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "fargate" + } + } + } + ]) + + depends_on = [ + aws_iam_role_policy_attachment.execution, + google_service_account_iam_member.aws_workload_identity, + google_project_iam_member.bigquery_job_user, + google_bigquery_dataset_iam_member.proof_reader, + ] +} diff --git a/acceptance/cloud-portability/phase1b/smoke/outputs.tf b/acceptance/cloud-portability/phase1b/smoke/outputs.tf new file mode 100644 index 00000000..171225af --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/outputs.tf @@ -0,0 +1,35 @@ +output "cluster_name" { + value = aws_ecs_cluster.probe.name +} + +output "task_definition_arn" { + value = aws_ecs_task_definition.probe.arn +} + +output "task_role_arn" { + value = aws_iam_role.task.arn +} + +output "execution_role_arn" { + value = aws_iam_role.execution.arn +} + +output "subnet_id" { + value = aws_subnet.public.id +} + +output "security_group_id" { + value = aws_security_group.task.id +} + +output "workload_identity_provider_name" { + value = google_iam_workload_identity_pool_provider.aws.name +} + +output "google_service_account_email" { + value = google_service_account.probe.email +} + +output "log_group_name" { + value = aws_cloudwatch_log_group.probe.name +} diff --git a/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example b/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example new file mode 100644 index 00000000..736316ca --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example @@ -0,0 +1,6 @@ +aws_region = "us-east-1" +gcp_project_id = "replace-with-disposable-project" +ecr_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/dander-phase1b@sha256:replace" +proof_dataset = "dander_raw" +proof_table = "greenhouse_job_board_jobs" +cpu_architecture = "ARM64" diff --git a/acceptance/cloud-portability/phase1b/smoke/variables.tf b/acceptance/cloud-portability/phase1b/smoke/variables.tf new file mode 100644 index 00000000..c21c4659 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/variables.tf @@ -0,0 +1,66 @@ +variable "aws_region" { + description = "AWS region for the isolated Fargate task." + type = string + default = "us-east-1" + + validation { + condition = can(regex("^[a-z]{2}(?:-gov)?-[a-z]+-[0-9]+$", var.aws_region)) + error_message = "aws_region must be a valid AWS region name." + } +} + +variable "gcp_project_id" { + description = "Disposable GCP project containing the bounded proof table." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.gcp_project_id)) + error_message = "gcp_project_id must be a valid GCP project ID." + } +} + +variable "ecr_image" { + description = "Immutable ECR OCI-index reference copied from staging GAR." + type = string + + validation { + condition = can(regex( + "^[0-9]{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$", + var.ecr_image, + )) + error_message = "ecr_image must be an immutable private-ECR sha256 reference." + } +} + +variable "proof_dataset" { + description = "Existing BigQuery dataset the probe may read." + type = string + default = "dander_raw" + + validation { + condition = length(var.proof_dataset) <= 1024 && can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.proof_dataset)) + error_message = "proof_dataset must be a valid BigQuery dataset ID." + } +} + +variable "proof_table" { + description = "Existing bounded BigQuery table the probe counts twice." + type = string + default = "greenhouse_job_board_jobs" + + validation { + condition = length(var.proof_table) <= 1024 && can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.proof_table)) + error_message = "proof_table must be a valid BigQuery table ID." + } +} + +variable "cpu_architecture" { + description = "Fargate architecture selected from the copied OCI index." + type = string + default = "ARM64" + + validation { + condition = contains(["ARM64", "X86_64"], var.cpu_architecture) + error_message = "cpu_architecture must be ARM64 or X86_64." + } +} diff --git a/acceptance/cloud-portability/phase1b/smoke/versions.tf b/acceptance/cloud-portability/phase1b/smoke/versions.tf new file mode 100644 index 00000000..b550cf52 --- /dev/null +++ b/acceptance/cloud-portability/phase1b/smoke/versions.tf @@ -0,0 +1,29 @@ +terraform { + required_version = ">= 1.9.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + google = { + source = "hashicorp/google" + version = ">= 6.0, < 8.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + managed-by = "dander" + purpose = "cloud-portability-phase1b" + } + } +} + +provider "google" { + project = var.gcp_project_id +} diff --git a/docs/decisions.md b/docs/decisions.md index d2fc2607..7be5f810 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -579,3 +579,15 @@ Resolves the "separate product decisions" the two entries above deferred, unbloc - The governed fact excludes deleted Opportunities and Accounts while retaining inactive owner dimensions. Existing installations may keep the `salesforce_accounts` pipeline resource ID; new examples use `salesforce_crm`. + +## 2026-08-07 — Cross-cloud feasibility uses one copied multi-platform artifact + +- Runtime image publication now requires both `linux/amd64` and `linux/arm64` manifests in one OCI + index. Phase 1B copies that exact index GAR-to-ECR with `crane`; any index or per-platform digest + rewrite fails the gate rather than being treated as equivalent packaging. +- The proof Fargate task has a pull/log execution role and a distinct policy-free task role. Google + trusts only the exact assumed task-role ARN through Workload Identity Federation and permits it + to impersonate a disposable service account with BigQuery job access and read access to one + proof dataset. +- A proof-only, source-free probe observes a 600-second impersonated Google credential expire and + refresh in one process. This does not make Fargate a supported launcher; that remains Phase 3. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 1df0eed7..66aba3b3 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -3,6 +3,10 @@ Dander `0.7.x` is beta and proves a focused GCP-native vertical slice. It remains pre-1.0 software; evaluate these limits before using it for an unattended system containing business-critical data. +- AWS ECS/Fargate is not yet a supported Dander launcher. Phase 1B contains an isolated artifact + and keyless-identity feasibility stack only; launcher lifecycle, scheduling, alerts, rollback, + and parity remain gated on the later portable BigQuery vertical slice. + ## Ingestion and schemas - Hosted SCD1 and sandbox replacement consume bounded batches. Direct SCD2, snapshot, diff --git a/docs/oci-artifact-contract.md b/docs/oci-artifact-contract.md index aa0b551d..8a515afb 100644 --- a/docs/oci-artifact-contract.md +++ b/docs/oci-artifact-contract.md @@ -10,10 +10,14 @@ Published images carry standard OCI source, documentation, license, version, rev annotations. The packaged `runtime-capabilities.json` describes the adapters present in that build; `dander runtime inspect` validates and reports it without accessing a cloud provider or secret. -`dander image-publish` builds through BuildKit with SBOM and provenance enabled, pushes the image, -resolves the registry digest, inspects the resulting OCI index, and only then writes the artifact -record atomically. If post-push verification fails, the command reports that the image may exist but -does not report a successful artifact publication. +`dander image-publish` builds one `linux/amd64,linux/arm64` index through BuildKit with SBOM and +provenance enabled, pushes the image, resolves the registry digest, inspects both required runnable +manifests, and only then writes the artifact record atomically. If post-push verification fails, the +command reports that the image may exist but does not report a successful artifact publication. + +Cross-registry promotion copies the digest-qualified index without rebuilding it. A promotion is +valid only when the destination resolves to the same index digest and reports the same runnable +platform-manifest map; registries that rewrite the index fail verification. The runtime executes as UID/GID `65532:65532`. CI runs the local conformance probe with a read-only root filesystem and a temporary `/tmp`, so runtime code must declare durable state through an diff --git a/scripts/portability/__init__.py b/scripts/portability/__init__.py new file mode 100644 index 00000000..1e8dc8ca --- /dev/null +++ b/scripts/portability/__init__.py @@ -0,0 +1 @@ +"""Repeatable tooling for cloud-portability acceptance proofs.""" diff --git a/scripts/portability/oci_copy.py b/scripts/portability/oci_copy.py new file mode 100644 index 00000000..97aac1cf --- /dev/null +++ b/scripts/portability/oci_copy.py @@ -0,0 +1,169 @@ +"""Copy one OCI index between registries and prove that no rebuild occurred.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Sequence + +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_DIGEST_REFERENCE = re.compile(r"^(?P[^\s@]+)@(?Psha256:[0-9a-f]{64})$") +_TAG_REFERENCE = re.compile(r"^[^\s@:]+(?:[.:][^\s@/:]+)*(?:/[^\s@/:]+)+:[^\s@/:]+$") +_REQUIRED_PLATFORMS = ("linux/amd64", "linux/arm64") +_RECORD_SCHEMA = "io.dander.portability.oci-copy/v1" + + +class CopyVerificationError(RuntimeError): + """Raised when an OCI copy cannot be proven content-identical.""" + + +class Runner(Protocol): + def __call__(self, command: Sequence[str]) -> bytes: + """Run one command and return stdout bytes.""" + + +def _run(command: Sequence[str]) -> bytes: + try: + result = subprocess.run( + tuple(command), + check=True, + capture_output=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as error: + executable = command[0] if command else "registry tool" + raise CopyVerificationError(f"{executable} could not complete the OCI copy") from error + return result.stdout + + +def _read_digest(reference: str, runner: Runner) -> str: + digest = runner(("crane", "digest", reference)).decode("utf-8").strip() + if not _DIGEST.fullmatch(digest): + raise CopyVerificationError(f"Registry returned an invalid digest for {reference!r}") + return digest + + +def _platform_manifests(raw: bytes) -> dict[str, str]: + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise CopyVerificationError("Registry returned an invalid OCI manifest") from error + if not isinstance(document, dict) or not isinstance(document.get("manifests"), list): + raise CopyVerificationError("Expected a multi-platform OCI index") + + platforms: dict[str, str] = {} + for descriptor in document["manifests"]: + if not isinstance(descriptor, dict) or not isinstance(descriptor.get("platform"), dict): + raise CopyVerificationError("OCI index contains an invalid descriptor") + platform = descriptor["platform"] + operating_system = platform.get("os") + architecture = platform.get("architecture") + if operating_system == "unknown" and architecture == "unknown": + continue + digest = descriptor.get("digest") + if ( + not isinstance(operating_system, str) + or not isinstance(architecture, str) + or not isinstance(digest, str) + or not _DIGEST.fullmatch(digest) + ): + raise CopyVerificationError("OCI index contains an invalid platform manifest") + variant = platform.get("variant") + key = f"{operating_system}/{architecture}" + if isinstance(variant, str) and variant: + key = f"{key}/{variant}" + if key in platforms: + raise CopyVerificationError(f"OCI index repeats platform {key!r}") + platforms[key] = digest + + missing = tuple( + required + for required in _REQUIRED_PLATFORMS + if not any(actual == required or actual.startswith(f"{required}/") for actual in platforms) + ) + if missing: + raise CopyVerificationError( + "OCI index is missing required platform manifests: " + ", ".join(missing) + ) + return dict(sorted(platforms.items())) + + +def copy_and_verify( + *, + source: str, + destination: str, + runner: Runner = _run, +) -> dict[str, object]: + """Copy ``source`` to ``destination`` and return a sanitized verification record.""" + source_match = _DIGEST_REFERENCE.fullmatch(source) + if source_match is None: + raise CopyVerificationError("Source must be an immutable digest-qualified image reference") + if _TAG_REFERENCE.fullmatch(destination) is None: + raise CopyVerificationError("Destination must be a tagged registry image reference") + + expected_digest = source_match.group("digest") + source_digest = _read_digest(source, runner) + if source_digest != expected_digest: + raise CopyVerificationError("Source registry digest does not match the requested image") + source_manifest = runner(("crane", "manifest", source)) + source_platforms = _platform_manifests(source_manifest) + + runner(("crane", "copy", source, destination)) + + destination_digest = _read_digest(destination, runner) + destination_manifest = runner(("crane", "manifest", destination)) + destination_platforms = _platform_manifests(destination_manifest) + if destination_digest != source_digest: + raise CopyVerificationError("Destination registry rewrote the OCI index digest") + if hashlib.sha256(destination_manifest).digest() != hashlib.sha256(source_manifest).digest(): + raise CopyVerificationError("Destination registry changed the OCI index document") + if destination_platforms != source_platforms: + raise CopyVerificationError("Destination platform manifests differ from the source") + + return { + "schema": _RECORD_SCHEMA, + "source": source, + "destination": destination.rsplit(":", 1)[0] + f"@{destination_digest}", + "index_digest": source_digest, + "platform_manifests": source_platforms, + "source_manifest_sha256": hashlib.sha256(source_manifest).hexdigest(), + "destination_manifest_sha256": hashlib.sha256(destination_manifest).hexdigest(), + "copied_without_rebuild": True, + } + + +def _write_record(path: Path, record: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True) + parser.add_argument("--destination", required=True) + parser.add_argument("--record", required=True, type=Path) + args = parser.parse_args() + try: + record = copy_and_verify(source=args.source, destination=args.destination) + _write_record(args.record, record) + except CopyVerificationError as error: + print(f"OCI copy verification failed: {error}") + return 1 + print(json.dumps(record, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/portability/prepare_phase1b_context.py b/scripts/portability/prepare_phase1b_context.py new file mode 100644 index 00000000..369427ab --- /dev/null +++ b/scripts/portability/prepare_phase1b_context.py @@ -0,0 +1,124 @@ +"""Add only the Phase 1B probe and non-secret external-account config to a generated project.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +from pathlib import Path + + +class ContextPreparationError(RuntimeError): + """Raised when the source-free proof context is invalid.""" + + +def _atomic_text(path: Path, content: str) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(content, encoding="utf-8") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _credential_config(path: Path, *, token_lifetime_seconds: int) -> dict[str, object]: + try: + config = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ContextPreparationError("Credential configuration is not valid JSON") from error + if not isinstance(config, dict) or config.get("type") != "external_account": + raise ContextPreparationError("Phase 1B requires an external-account credential config") + source = config.get("credential_source") + if not isinstance(source, dict) or source.get("environment_id") != "aws1": + raise ContextPreparationError("Credential configuration is not for an AWS workload") + if "private_key" in config or "client_secret" in config: + raise ContextPreparationError("Credential configuration contains a prohibited secret") + impersonation_url = config.get("service_account_impersonation_url") + if not isinstance(impersonation_url, str) or not impersonation_url.startswith( + "https://iamcredentials.googleapis.com/" + ): + raise ContextPreparationError("Credential configuration must impersonate a service account") + config["service_account_impersonation_options"] = { + "token_lifetime_seconds": token_lifetime_seconds + } + return config + + +def prepare_context( + *, + project_dir: Path, + credential_config: Path, + probe_script: Path, + token_lifetime_seconds: int = 600, +) -> None: + """Prepare an ephemeral generated project for the Phase 1B image build.""" + if (project_dir / "src").exists(): + raise ContextPreparationError("Phase 1B requires a generated source-free project") + dockerfile = project_dir / "Dockerfile" + dockerignore = project_dir / ".dockerignore" + required = (dockerfile, dockerignore, project_dir / "dander.yaml", probe_script) + if any(not path.is_file() for path in required): + raise ContextPreparationError("Generated project or proof tooling is incomplete") + if not 300 <= token_lifetime_seconds <= 900: + raise ContextPreparationError("Proof token lifetime must be between 300 and 900 seconds") + + config = _credential_config( + credential_config, + token_lifetime_seconds=token_lifetime_seconds, + ) + shutil.copyfile(probe_script, project_dir / "phase1b_probe.py") + _atomic_text( + project_dir / "gcp-wif.json", + json.dumps(config, indent=2, sort_keys=True) + "\n", + ) + + dockerfile_content = dockerfile.read_text(encoding="utf-8") + marker = "\nUSER 65532:65532\n" + if marker not in dockerfile_content: + raise ContextPreparationError("Generated Dockerfile has no non-root user boundary") + additions = ( + "\nCOPY --chown=65532:65532 phase1b_probe.py ./phase1b_probe.py\n" + "COPY --chown=65532:65532 gcp-wif.json ./gcp-wif.json\n" + ) + if "COPY --chown=65532:65532 phase1b_probe.py ./phase1b_probe.py" not in dockerfile_content: + dockerfile_content = dockerfile_content.replace(marker, additions + marker, 1) + _atomic_text(dockerfile, dockerfile_content) + + ignore_lines = dockerignore.read_text(encoding="utf-8").rstrip().splitlines() + for line in ("!phase1b_probe.py", "!gcp-wif.json"): + if line not in ignore_lines: + ignore_lines.append(line) + _atomic_text( + dockerignore, + "\n".join(ignore_lines) + "\n", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-dir", required=True, type=Path) + parser.add_argument("--credential-config", required=True, type=Path) + parser.add_argument( + "--probe-script", + type=Path, + default=Path(__file__).with_name("wif_bigquery_probe.py"), + ) + parser.add_argument("--token-lifetime-seconds", type=int, default=600) + args = parser.parse_args() + try: + prepare_context( + project_dir=args.project_dir.resolve(), + credential_config=args.credential_config.resolve(), + probe_script=args.probe_script.resolve(), + token_lifetime_seconds=args.token_lifetime_seconds, + ) + except ContextPreparationError as error: + print(f"Phase 1B context preparation failed: {error}") + return 1 + print(f"Prepared source-free Phase 1B context: {args.project_dir.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/portability/scan_long_lived_credentials.py b/scripts/portability/scan_long_lived_credentials.py new file mode 100644 index 00000000..2aa760ec --- /dev/null +++ b/scripts/portability/scan_long_lived_credentials.py @@ -0,0 +1,62 @@ +"""Fail when Phase 1B proof artifacts contain recognizable long-lived cloud credentials.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +_PATTERNS = { + "aws_static_access_key": re.compile(rb"\bAKIA[0-9A-Z]{16}\b"), + "aws_temporary_access_key": re.compile(rb"\bASIA[0-9A-Z]{16}\b"), + "aws_secret_key_value": re.compile( + rb"(?i)(?:aws_)?secret_access_key\s*[\"']?\s*[:=]\s*[\"'][A-Za-z0-9/+=]{40}[\"']" + ), + "gcp_private_key": re.compile( + rb"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----" + rb"(?:\\n|\r?\n)" + rb"(?:[A-Za-z0-9+/=]{20,}(?:\\n|\r?\n)){2,}" + rb"-----END (?:RSA |EC )?PRIVATE KEY-----" + ), +} + + +def scan(paths: list[Path]) -> list[dict[str, str]]: + """Return sanitized path/pattern findings without returning matched material.""" + findings: list[dict[str, str]] = [] + files: list[Path] = [] + for path in paths: + if path.is_dir(): + files.extend(candidate for candidate in path.rglob("*") if candidate.is_file()) + elif path.is_file(): + files.append(path) + for path in sorted(set(files)): + try: + content = path.read_bytes() + except OSError: + findings.append({"path": str(path), "pattern": "unreadable"}) + continue + for name, pattern in _PATTERNS.items(): + if pattern.search(content): + findings.append({"path": str(path), "pattern": name}) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", type=Path) + args = parser.parse_args() + findings = scan([path.resolve() for path in args.paths]) + print( + json.dumps( + {"schema": "io.dander.portability.credential-scan/v1", "findings": findings}, + sort_keys=True, + separators=(",", ":"), + ) + ) + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/portability/wif_bigquery_probe.py b/scripts/portability/wif_bigquery_probe.py new file mode 100644 index 00000000..c3ed3b5e --- /dev/null +++ b/scripts/portability/wif_bigquery_probe.py @@ -0,0 +1,169 @@ +"""Prove that an AWS task can refresh keyless Google credentials during one process.""" + +from __future__ import annotations + +import argparse +import json +import re +import time +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Protocol + +import google.auth +from google.cloud import bigquery + +if TYPE_CHECKING: + from collections.abc import Callable + +_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,1023}$") +_PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$") +_SCOPES = ("https://www.googleapis.com/auth/cloud-platform",) +_SCHEMA = "io.dander.portability.identity-probe/v1" + + +class ProbeError(RuntimeError): + """Raised when the live identity-refresh proof does not satisfy its contract.""" + + +class ExpiringCredentials(Protocol): + expiry: datetime | None + + +class QueryClient(Protocol): + def query_count(self, *, project: str, dataset: str, table: str) -> int: + """Return a bounded count from the proof relation.""" + + +class BigQueryProbeClient: + """Small adapter that keeps the exact proof query out of logs and task configuration.""" + + def __init__(self, client: bigquery.Client) -> None: + self._client = client + + def query_count(self, *, project: str, dataset: str, table: str) -> int: + if not _PROJECT_ID.fullmatch(project) or not all( + _IDENTIFIER.fullmatch(value) for value in (dataset, table) + ): + raise ProbeError("BigQuery proof target is invalid") + query = f"SELECT COUNT(*) AS row_count FROM `{project}.{dataset}.{table}`" + row = next(iter(self._client.query(query).result(max_results=1)), None) + if row is None: + raise ProbeError("BigQuery proof query returned no result") + value = row[0] + if not isinstance(value, int) or value < 0: + raise ProbeError("BigQuery proof query returned an invalid count") + return value + + +def _utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _expiry(credentials: ExpiringCredentials) -> datetime: + if credentials.expiry is None: + raise ProbeError("Google credentials did not expose an expiry") + return _utc(credentials.expiry) + + +def run_probe( + *, + credentials: ExpiringCredentials, + client: QueryClient, + project: str, + dataset: str, + table: str, + max_wait_seconds: int, + refresh_margin_seconds: int, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + sleep: Callable[[float], None] = time.sleep, + emit: Callable[[dict[str, object]], None], +) -> None: + """Query before and after expiry, requiring the same credentials object to refresh.""" + first_count = client.query_count(project=project, dataset=dataset, table=table) + first_expiry = _expiry(credentials) + first_time = _utc(now()) + wait_seconds = max(0.0, (first_expiry - first_time).total_seconds()) + refresh_margin_seconds + if wait_seconds > max_wait_seconds: + raise ProbeError("Issued credential lifetime exceeds the bounded proof window") + emit( + { + "schema": _SCHEMA, + "event": "query.completed", + "sequence": 1, + "row_count": first_count, + "credential_expiry": first_expiry.isoformat(), + } + ) + + sleep(wait_seconds) + + second_count = client.query_count(project=project, dataset=dataset, table=table) + second_expiry = _expiry(credentials) + if second_expiry <= first_expiry: + raise ProbeError("Google credentials did not refresh after expiry") + emit( + { + "schema": _SCHEMA, + "event": "query.completed", + "sequence": 2, + "row_count": second_count, + "credential_expiry": second_expiry.isoformat(), + } + ) + emit( + { + "schema": _SCHEMA, + "event": "credential.refresh_observed", + "previous_expiry": first_expiry.isoformat(), + "refreshed_expiry": second_expiry.isoformat(), + } + ) + + +def _emit(record: dict[str, object]) -> None: + print(json.dumps(record, sort_keys=True, separators=(",", ":")), flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", required=True) + parser.add_argument("--dataset", required=True) + parser.add_argument("--table", required=True) + parser.add_argument("--max-wait-seconds", type=int, default=900) + parser.add_argument("--refresh-margin-seconds", type=int, default=15) + args = parser.parse_args() + if not _PROJECT_ID.fullmatch(args.project) or not all( + _IDENTIFIER.fullmatch(value) for value in (args.dataset, args.table) + ): + _emit({"schema": _SCHEMA, "event": "probe.failed", "failure_code": "invalid_target"}) + return 2 + try: + credentials, _ = google.auth.default(scopes=_SCOPES) + client = BigQueryProbeClient(bigquery.Client(project=args.project, credentials=credentials)) + run_probe( + credentials=credentials, + client=client, + project=args.project, + dataset=args.dataset, + table=args.table, + max_wait_seconds=args.max_wait_seconds, + refresh_margin_seconds=args.refresh_margin_seconds, + emit=_emit, + ) + except Exception as error: # noqa: BLE001 - the probe must sanitize every library failure + _emit( + { + "schema": _SCHEMA, + "event": "probe.failed", + "failure_code": "identity_or_query_failed", + "failure_type": type(error).__name__, + } + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/dander/bootstrap/project.py b/src/dander/bootstrap/project.py index 740e0773..76bce959 100644 --- a/src/dander/bootstrap/project.py +++ b/src/dander/bootstrap/project.py @@ -9,11 +9,11 @@ import subprocess import time from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from collections.abc import Callable - from pathlib import Path _PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$") _BUCKET_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$") @@ -23,6 +23,7 @@ _PLATFORM_PART = re.compile(r"^[a-z0-9][a-z0-9_.-]*$") _ARTIFACT_SCHEMA = "io.dander.runtime.artifact/v1" _SOURCE_URL = "https://github.com/harrisonoconnorhover/dander" +_RUNTIME_PLATFORMS = ("linux/amd64", "linux/arm64") class ProjectBootstrapError(RuntimeError): @@ -236,7 +237,7 @@ def publish( "buildx", "build", "--platform", - "linux/amd64", + ",".join(_RUNTIME_PLATFORMS), "--build-arg", f"DANDER_BUILD_REVISION={revision}", "--build-arg", @@ -287,6 +288,18 @@ def publish( text=True, ) platforms = _platform_manifests(inspected.stdout, default_digest=digest) + missing_platforms = tuple( + required + for required in _RUNTIME_PLATFORMS + if not any( + actual == required or actual.startswith(f"{required}/") for actual in platforms + ) + ) + if missing_platforms: + raise ProjectBootstrapError( + "Runtime image index is missing required platform manifests: " + + ", ".join(missing_platforms) + ) record_path = self._repository_dir / ".dander" / "runtime-artifact.json" _write_artifact_record( record_path, @@ -343,12 +356,13 @@ def _content_digest(self) -> str: ) if (self._repository_dir / name).is_file() ] + files.extend(_explicit_docker_context_files(self._repository_dir)) for root in roots: files.extend( path for path in root.rglob("*") if path.is_file() and _is_build_context_file(path) ) try: - for path in sorted(files): + for path in sorted(set(files)): hasher.update(str(path.relative_to(self._repository_dir)).encode()) hasher.update(path.read_bytes()) except OSError as error: @@ -368,6 +382,29 @@ def _is_build_context_file(path: Path) -> bool: ) +def _explicit_docker_context_files(repository_dir: Path) -> tuple[Path, ...]: + dockerignore = repository_dir / ".dockerignore" + if not dockerignore.is_file(): + return () + try: + lines = dockerignore.read_text(encoding="utf-8").splitlines() + except OSError as error: + raise ProjectBootstrapError("Could not read the Docker build-context rules") from error + files: list[Path] = [] + for raw in lines: + rule = raw.strip() + if not rule.startswith("!") or any(character in rule for character in "*?["): + continue + relative = rule[1:].lstrip("/") + path = Path(relative) + if not relative or relative.endswith("/") or ".." in path.parts: + continue + candidate = repository_dir / path + if candidate.is_file() and _is_build_context_file(candidate): + files.append(candidate) + return tuple(files) + + def _platform_manifests(raw: str, *, default_digest: str) -> dict[str, str]: document = json.loads(raw) if not isinstance(document, dict): diff --git a/tests/bootstrap/test_project_bootstrap.py b/tests/bootstrap/test_project_bootstrap.py index 4a7f30cc..e7c69c24 100644 --- a/tests/bootstrap/test_project_bootstrap.py +++ b/tests/bootstrap/test_project_bootstrap.py @@ -56,6 +56,14 @@ def __call__( "digest": "sha256:" + "b" * 64, "platform": {"os": "linux", "architecture": "amd64"}, }, + { + "digest": "sha256:" + "d" * 64, + "platform": { + "os": "linux", + "architecture": "arm64", + "variant": "v8", + }, + }, { "digest": "sha256:" + "c" * 64, "platform": {"os": "unknown", "architecture": "unknown"}, @@ -109,7 +117,7 @@ def test_runtime_image_publisher_returns_an_immutable_digest(tmp_path: Path) -> build = next( command for command in runner.commands if command[:3] == ("docker", "buildx", "build") ) - assert "--platform" in build and "linux/amd64" in build and "--push" in build + assert "--platform" in build and "linux/amd64,linux/arm64" in build and "--push" in build assert "--sbom=true" in build assert "--provenance=mode=max" in build assert any(part.startswith("DANDER_BUILD_REVISION=") for part in build) @@ -119,7 +127,10 @@ def test_runtime_image_publisher_returns_an_immutable_digest(tmp_path: Path) -> assert record["schema"] == "io.dander.runtime.artifact/v1" assert record["image"] == image assert record["index_digest"] == "sha256:" + "a" * 64 - assert record["platform_manifests"] == {"linux/amd64": "sha256:" + "b" * 64} + assert record["platform_manifests"] == { + "linux/amd64": "sha256:" + "b" * 64, + "linux/arm64/v8": "sha256:" + "d" * 64, + } assert record["sbom_attached"] is True assert record["provenance_attached"] is True @@ -140,6 +151,58 @@ def test_runtime_image_publisher_accepts_installed_project_context(tmp_path: Pat assert image.endswith("@sha256:" + "a" * 64) +def test_runtime_image_publisher_rejects_an_incomplete_platform_index(tmp_path: Path) -> None: + for name in ("Dockerfile", "README.md", "dander.yaml"): + (tmp_path / name).write_text(name, encoding="utf-8") + for directory in ("connectors", "models"): + path = tmp_path / directory + path.mkdir() + (path / "content.txt").write_text(directory, encoding="utf-8") + + class _IncompleteIndexRunner(_Runner): + def __call__( + self, + args: tuple[str, ...], + *, + cwd: Path, + check: bool, + capture_output: bool = False, + text: bool = False, + input: str | None = None, + ) -> subprocess.CompletedProcess[str]: + result = super().__call__( + args, + cwd=cwd, + check=check, + capture_output=capture_output, + text=text, + input=input, + ) + if args[:4] != ("docker", "buildx", "imagetools", "inspect"): + return result + return subprocess.CompletedProcess( + args, + 0, + stdout=json.dumps( + { + "schemaVersion": 2, + "manifests": [ + { + "digest": "sha256:" + "b" * 64, + "platform": {"os": "linux", "architecture": "amd64"}, + } + ], + } + ), + ) + + with pytest.raises(ProjectBootstrapError, match="missing required.*linux/arm64"): + RuntimeImagePublisher(tmp_path, runner=_IncompleteIndexRunner()).publish( + project="unit-project", + region="us-central1", + ) + + def test_runtime_image_publisher_uses_bootstrap_impersonation_without_token_in_args( tmp_path: Path, ) -> None: @@ -231,6 +294,34 @@ def test_runtime_image_tag_tracks_graph_content(tmp_path: Path) -> None: assert all(":candidate-" in tag for tag in tags) +def test_runtime_image_tag_tracks_explicit_extra_build_context_files(tmp_path: Path) -> None: + for name in ("Dockerfile", "dander.yaml"): + (tmp_path / name).write_text(name, encoding="utf-8") + for directory in ("connectors", "models"): + path = tmp_path / directory + path.mkdir() + (path / "content.txt").write_text(directory, encoding="utf-8") + (tmp_path / ".dockerignore").write_text( + "*\n!Dockerfile\n!dander.yaml\n!connectors/\n!models/\n!phase1b_probe.py\n", + encoding="utf-8", + ) + probe = tmp_path / "phase1b_probe.py" + probe.write_text("print('before')\n", encoding="utf-8") + runner = _Runner() + publisher = RuntimeImagePublisher(tmp_path, runner=runner) + + publisher.publish(project="unit-project", region="us-central1", tag_prefix="candidate") + probe.write_text("print('after')\n", encoding="utf-8") + publisher.publish(project="unit-project", region="us-central1", tag_prefix="candidate") + + tags = [ + command[command.index("-t") + 1] + for command in runner.commands + if command[:3] == ("docker", "buildx", "build") + ] + assert tags[0] != tags[1] + + def test_active_admin_member_uses_the_authenticated_gcloud_user(tmp_path: Path) -> None: assert active_admin_member(cwd=tmp_path, runner=_Runner()) == ("user:operator@example.invalid") diff --git a/tests/portability/test_phase1b_tools.py b/tests/portability/test_phase1b_tools.py new file mode 100644 index 00000000..f48b5bbc --- /dev/null +++ b/tests/portability/test_phase1b_tools.py @@ -0,0 +1,276 @@ +"""Focused tests for the live Phase 1B artifact and identity proof tooling.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import pytest +from scripts.portability.oci_copy import CopyVerificationError, copy_and_verify +from scripts.portability.prepare_phase1b_context import ContextPreparationError, prepare_context +from scripts.portability.scan_long_lived_credentials import scan +from scripts.portability.wif_bigquery_probe import ProbeError, run_probe + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + +_INDEX_DIGEST = "sha256:" + "a" * 64 +_AMD64_DIGEST = "sha256:" + "b" * 64 +_ARM64_DIGEST = "sha256:" + "c" * 64 + + +def _manifest() -> bytes: + return json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "digest": _AMD64_DIGEST, + "platform": {"os": "linux", "architecture": "amd64"}, + }, + { + "digest": _ARM64_DIGEST, + "platform": { + "os": "linux", + "architecture": "arm64", + "variant": "v8", + }, + }, + { + "digest": "sha256:" + "d" * 64, + "platform": {"os": "unknown", "architecture": "unknown"}, + }, + ], + }, + separators=(",", ":"), + ).encode() + + +class _RegistryRunner: + def __init__( + self, + *, + destination_digest: str = _INDEX_DIGEST, + destination_manifest: bytes | None = None, + ) -> None: + self.destination_digest = destination_digest + self.destination_manifest = destination_manifest + self.commands: list[tuple[str, ...]] = [] + + def __call__(self, command: Sequence[str]) -> bytes: + args = tuple(command) + self.commands.append(args) + if args[:2] == ("crane", "copy"): + return b"" + if args[:2] == ("crane", "manifest"): + if "ecr" in args[2] and self.destination_manifest is not None: + return self.destination_manifest + return _manifest() + if args[:2] == ("crane", "digest"): + return (self.destination_digest if "ecr" in args[2] else _INDEX_DIGEST).encode() + raise AssertionError(args) + + +def test_oci_copy_preserves_index_and_platform_digests() -> None: + source = f"us-central1-docker.pkg.dev/unit/dander/dander@{_INDEX_DIGEST}" + destination = "123456789012.dkr.ecr.us-east-1.amazonaws.com/dander:phase1b" + runner = _RegistryRunner() + + record = copy_and_verify(source=source, destination=destination, runner=runner) + + assert record["index_digest"] == _INDEX_DIGEST + assert record["destination"] == destination.rsplit(":", 1)[0] + f"@{_INDEX_DIGEST}" + assert record["platform_manifests"] == { + "linux/amd64": _AMD64_DIGEST, + "linux/arm64/v8": _ARM64_DIGEST, + } + assert record["copied_without_rebuild"] is True + assert runner.commands[2] == ("crane", "copy", source, destination) + assert all("build" not in command for command in runner.commands) + + +def test_oci_copy_fails_when_destination_rewrites_the_index() -> None: + with pytest.raises(CopyVerificationError, match="rewrote"): + copy_and_verify( + source=f"gar.example.invalid/dander@{_INDEX_DIGEST}", + destination="ecr.example.invalid/dander:phase1b", + runner=_RegistryRunner(destination_digest="sha256:" + "e" * 64), + ) + + +def test_oci_copy_fails_when_destination_changes_the_index_document() -> None: + changed = _manifest().replace(b'"schemaVersion":2', b'"schemaVersion": 2') + with pytest.raises(CopyVerificationError, match="changed the OCI index"): + copy_and_verify( + source=f"gar.example.invalid/dander@{_INDEX_DIGEST}", + destination="ecr.example.invalid/dander:phase1b", + runner=_RegistryRunner(destination_manifest=changed), + ) + + +class _Credentials: + def __init__(self, expiry: datetime) -> None: + self.expiry: datetime | None = expiry + + +class _QueryClient: + def __init__(self, credentials: _Credentials) -> None: + self.credentials = credentials + self.calls = 0 + + def query_count(self, *, project: str, dataset: str, table: str) -> int: + assert (project, dataset, table) == ("unit-project", "raw", "records") + self.calls += 1 + if self.calls == 2: + assert self.credentials.expiry is not None + self.credentials.expiry += timedelta(minutes=10) + return 7 + + +def test_wif_probe_observes_expiry_refresh_without_logging_tokens() -> None: + started = datetime(2026, 8, 7, 12, 0, tzinfo=UTC) + credentials = _Credentials(started + timedelta(minutes=10)) + client = _QueryClient(credentials) + records: list[dict[str, object]] = [] + waits: list[float] = [] + + run_probe( + credentials=credentials, + client=client, + project="unit-project", + dataset="raw", + table="records", + max_wait_seconds=700, + refresh_margin_seconds=5, + now=lambda: started, + sleep=waits.append, + emit=records.append, + ) + + assert waits == [605.0] + assert [record["event"] for record in records] == [ + "query.completed", + "query.completed", + "credential.refresh_observed", + ] + assert "token" not in json.dumps(records).lower() + assert client.calls == 2 + + +def test_wif_probe_rejects_an_unbounded_credential_lifetime() -> None: + started = datetime(2026, 8, 7, 12, 0, tzinfo=UTC) + credentials = _Credentials(started + timedelta(hours=1)) + with pytest.raises(ProbeError, match="bounded proof window"): + run_probe( + credentials=credentials, + client=_QueryClient(credentials), + project="unit-project", + dataset="raw", + table="records", + max_wait_seconds=900, + refresh_margin_seconds=5, + now=lambda: started, + sleep=lambda _seconds: None, + emit=lambda _record: None, + ) + + +def _write_generated_project(path: Path) -> None: + path.mkdir() + (path / "Dockerfile").write_text( + "FROM python:3.12-slim\n\nUSER 65532:65532\n", + encoding="utf-8", + ) + (path / ".dockerignore").write_text("*\n!Dockerfile\n", encoding="utf-8") + (path / "dander.yaml").write_text("version: 1\n", encoding="utf-8") + + +def _write_aws_config(path: Path) -> None: + path.write_text( + json.dumps( + { + "type": "external_account", + "audience": ( + "//iam.googleapis.com/projects/123/locations/global/pools/p/providers/a" + ), + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": ( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" + "probe@unit.iam.gserviceaccount.com:generateAccessToken" + ), + "credential_source": {"environment_id": "aws1"}, + } + ), + encoding="utf-8", + ) + + +def test_prepare_context_keeps_the_project_source_free_and_bounds_token_lifetime( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + _write_generated_project(project) + config = tmp_path / "external.json" + _write_aws_config(config) + probe = tmp_path / "probe.py" + probe.write_text("print('probe')\n", encoding="utf-8") + + prepare_context( + project_dir=project, + credential_config=config, + probe_script=probe, + ) + prepare_context( + project_dir=project, + credential_config=config, + probe_script=probe, + ) + + assert not (project / "src").exists() + rendered = json.loads((project / "gcp-wif.json").read_text(encoding="utf-8")) + assert rendered["type"] == "external_account" + assert rendered["service_account_impersonation_options"] == {"token_lifetime_seconds": 600} + assert (project / "Dockerfile").read_text(encoding="utf-8").count( + "COPY --chown=65532:65532 phase1b_probe.py" + ) == 1 + assert (project / ".dockerignore").read_text(encoding="utf-8").count("!gcp-wif.json") == 1 + + +def test_prepare_context_rejects_a_service_account_key(tmp_path: Path) -> None: + project = tmp_path / "project" + _write_generated_project(project) + config = tmp_path / "service-account.json" + config.write_text('{"type":"service_account","private_key":"nope"}', encoding="utf-8") + probe = tmp_path / "probe.py" + probe.write_text("pass\n", encoding="utf-8") + + with pytest.raises(ContextPreparationError, match="external-account"): + prepare_context( + project_dir=project, + credential_config=config, + probe_script=probe, + ) + + +def test_long_lived_credential_scan_reports_only_path_and_pattern(tmp_path: Path) -> None: + safe = tmp_path / "external.json" + safe.write_text('{"type":"external_account"}', encoding="utf-8") + unsafe = tmp_path / "bad.json" + fake_key = ( + "-----BEGIN PRIVATE KEY-----\n" + "A" * 64 + "\n" + "B" * 64 + "\n-----END PRIVATE KEY-----" + ) + unsafe.write_text( + json.dumps({"type": "service_account", "private_key": fake_key}), + encoding="utf-8", + ) + + findings = scan([tmp_path]) + + assert findings == [ + {"path": str(unsafe), "pattern": "gcp_private_key"}, + ] + assert "redacted" not in json.dumps(findings) diff --git a/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md new file mode 100644 index 00000000..87599d23 --- /dev/null +++ b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md @@ -0,0 +1,36 @@ +# DANDER-77 — Cross-cloud artifact and identity feasibility + +Status: in progress + +## Outcome + +Prove the roadmap's Phase 1B gate before broad provider factories are introduced: publish one +source-free multi-platform OCI index, copy it from GAR to ECR without rebuilding, and run a manual +Fargate BigQuery probe across a real Google credential expiry/refresh using only workload identity. + +## Acceptance criteria + +- [x] `image-publish` builds `linux/amd64` and `linux/arm64` and rejects an incomplete index. +- [x] Digest-copy tooling fails on source mismatch, destination index rewrite, or platform drift. +- [x] The source-free proof context accepts only AWS external-account configuration and bounded + impersonated-token lifetime. +- [x] Isolated Terraform separates ECS execution and task roles, creates no service or schedule, + grants the Google identity only BigQuery job and one-dataset read access, and validates locally. +- [x] Probe and scanner tests cover credential refresh, bounded waits, sanitized output, and + recognizable long-lived cloud credentials. +- [x] One source-free OCI index was built in staging GAR; its recorded `amd64` and `arm64` + manifests both passed the read-only local runtime conformance command. +- [ ] The index and both platform manifests are identical in staging GAR and ECR. +- [ ] Cloud Run selects the recorded `amd64` content and the Fargate task selects a recorded + platform from the same index. +- [ ] A live Fargate task reads BigQuery before and after Google credential refresh. +- [ ] State, task definition, logs, configuration, and image scans find no static cloud key. +- [ ] Both AWS stacks are destroyed, the isolated GCP configuration is restored, and its final + Terraform plan reports `No changes.` + +## Boundaries + +- This is a feasibility proof, not an ECS/Fargate support claim. +- No retained-project mutation, background service, schedule, static key, or provider factory. +- Live evidence is recorded only after the new AWS account is authenticated with short-lived + credentials and every paid apply has a saved, reviewed plan. From c1398b6218a7aabe73070dd69e57a7ffcc44ce39 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover Date: Fri, 7 Aug 2026 11:20:47 -0400 Subject: [PATCH 2/3] Record Phase 1B Cloud Run parity --- HANDOFF.md | 7 ++++--- ...ER-77-cross-cloud-artifact-identity-feasibility.md | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 664d305d..c269db76 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,7 +6,7 @@ - Made source-free image publication require one `linux/amd64,linux/arm64` OCI index. - Added digest-preserving GAR-to-ECR copy verification and a bounded WIF refresh probe. - Added isolated ECR and manual Fargate proof roots with separate execution/task roles. -- Built one source-free GAR index; both `amd64` and `arm64` passed local runtime conformance. +- Proved the same index on Cloud Run as `x86_64`, deleted the temporary job, and restored no drift. ## Try It @@ -20,6 +20,7 @@ saved reviewed plans. No live apply is required for local validation. - Package build, distribution inspection, and clean external wheel install passed. - Corrected GAR index `sha256:6cd545fa…be81f` passed both platform conformance and key scans; no fixable high/critical CVE was found (four unfixed base-image findings remain visible). +- Protected PR checks passed; the isolated GCP final plan reported exactly `No changes.` ## Decisions @@ -30,8 +31,8 @@ saved reviewed plans. No live apply is required for local validation. ## Remaining - Authenticate the new AWS account with short-lived CLI credentials. -- Build/copy the source-free index and run the reviewed live Fargate/WIF refresh proof. -- Run image/config/state/log scans and Cloud Run artifact parity. +- Copy the accepted source-free index and run the reviewed live Fargate/WIF refresh proof. +- Run the AWS state, task-definition, log, configuration, and image scans. - Destroy AWS proof resources and require isolated GCP `No changes.` - Record acceptance, complete protected CI/review, and merge the focused PR. diff --git a/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md index 87599d23..e0aba7a0 100644 --- a/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md +++ b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md @@ -28,6 +28,17 @@ Fargate BigQuery probe across a real Google credential expiry/refresh using only - [ ] Both AWS stacks are destroyed, the isolated GCP configuration is restored, and its final Terraform plan reports `No changes.` +## Partial live evidence + +On 2026-08-07, the accepted GAR index +`sha256:6cd545fa8975ed6c49cf38786a1fcfeb96eaa8fa104f156974609d84031be81f` ran in +the isolated project as Cloud Run execution `dander-phase1b-conformance-nwngt`. The container +reported `x86_64`, emitted a successful `io.dander.runtime/v1` conformance result, and exited zero. +The temporary unscheduled job was deleted, both managed schedules remained paused, the evidence +and source-free context contained no recognizable long-lived cloud key, and the final +manifest-aware Terraform plan reported exactly `No changes.` The combined Cloud Run/Fargate and +teardown criteria remain open until the AWS half is complete. + ## Boundaries - This is a feasibility proof, not an ECS/Fargate support claim. From 0a36e187ac296053f39383472fb6f373af48384c Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover Date: Fri, 7 Aug 2026 15:44:52 -0400 Subject: [PATCH 3/3] Complete Phase 1B cross-cloud proof --- CHANGELOG.md | 8 ++ HANDOFF.md | 41 +++++----- .../cloud-portability/phase1b/README.md | 19 +++-- .../phase1b/smoke/terraform.tfvars.example | 4 +- .../phase1b/smoke/variables.tf | 4 +- docs/decisions.md | 6 +- .../portability/prepare_phase1b_context.py | 5 +- scripts/portability/wif_bigquery_probe.py | 75 ++++++++++++++++++- tests/portability/test_phase1b_tools.py | 44 ++++++++++- ...oss-cloud-artifact-identity-feasibility.md | 48 ++++++++---- uv.lock | 6 +- 11 files changed, 203 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abbf9f9..7d3b4768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ capabilities enter through the next minor release. - Add an isolated, unscheduled Phase 1B proof for digest-preserving GAR-to-ECR copy and keyless Fargate-to-BigQuery credential refresh. This is feasibility evidence, not Fargate support. +### Fixed + +- Adapt Fargate's short-lived ECS task-role credential endpoint to Google Auth's AWS environment + chain in-process, and use Google Auth's supported `service_account_impersonation` field so the + proof token is actually bounded to 600 seconds. +- Point the isolated BigQuery probe defaults at the disposable Salesforce Accounts table used by + the acceptance project; the probe returns only `COUNT(*)` and never record content. + ## 0.7.0 — 2026-08-07 (beta) ### Acceptance diff --git a/HANDOFF.md b/HANDOFF.md index c269db76..360215b2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,11 +2,11 @@ ## Finished -- Started Phase 1B from stable `v0.7.0` without touching the docs-only roadmap branch. -- Made source-free image publication require one `linux/amd64,linux/arm64` OCI index. -- Added digest-preserving GAR-to-ECR copy verification and a bounded WIF refresh probe. -- Added isolated ECR and manual Fargate proof roots with separate execution/task roles. -- Proved the same index on Cloud Run as `x86_64`, deleted the temporary job, and restored no drift. +- Completed Phase 1B from stable `v0.7.0` without touching the docs-only roadmap branch. +- Proved one source-free AMD64/ARM64 OCI index locally, on Cloud Run, and after byte-identical ECR copy. +- Proved ARM64 Fargate can refresh keyless Google credentials and query bounded BigQuery data twice. +- Corrected Fargate credential sourcing, Google Auth lifetime configuration, and the proof-table default. +- Destroyed every AWS/GCP proof resource and recorded final isolated Terraform `No changes.` ## Try It @@ -15,29 +15,26 @@ saved reviewed plans. No live apply is required for local validation. ## Checks -- All 825 tests, Ruff, and strict mypy passed. -- Both Phase 1B Terraform roots initialized and validated with locked providers. -- Package build, distribution inspection, and clean external wheel install passed. -- Corrected GAR index `sha256:6cd545fa…be81f` passed both platform conformance and key scans; no - fixable high/critical CVE was found (four unfixed base-image findings remain visible). -- Protected PR checks passed; the isolated GCP final plan reported exactly `No changes.` +- All 828 tests, Ruff, strict mypy, and four Terraform validations passed. +- Wheel/sdist build, inspection, outside-checkout installs, project generation, and Terraform validation passed. +- Both image platforms passed runtime conformance and had zero fixable High/Critical findings. +- Dependency and long-lived-credential scans passed; the GitPython lock is updated to fixed `3.1.58`. +- Cloud Run and Fargate exited zero; final AWS inventory was empty and isolated GCP reported no drift. ## Decisions -- A registry digest rewrite fails rather than being accepted as an equivalent image. -- Fargate receives no service or schedule; the proof task role has no AWS permission policy. -- Fargate remains feasibility-only until the later portable BigQuery vertical slice. +- Registry or platform-digest rewrites fail instead of being treated as equivalent packaging. +- ECS task-role credentials remain short-lived, process-only, and separate from pull/log execution access. +- Fargate remains feasibility-only until the portable BigQuery vertical slice. ## Remaining -- Authenticate the new AWS account with short-lived CLI credentials. -- Copy the accepted source-free index and run the reviewed live Fargate/WIF refresh proof. -- Run the AWS state, task-definition, log, configuration, and image scans. -- Destroy AWS proof resources and require isolated GCP `No changes.` -- Record acceptance, complete protected CI/review, and merge the focused PR. +- Review the final Phase 1B diff and protected PR checks. +- Merge focused PR #108 after CI passes. +- Begin Phase 2 only from merged `main` on a separate branch. ## Review First -- `src/dander/bootstrap/project.py` -- `scripts/portability/oci_copy.py` -- `acceptance/cloud-portability/phase1b/smoke/main.tf` +- `scripts/portability/wif_bigquery_probe.py` +- `scripts/portability/prepare_phase1b_context.py` +- `tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md` diff --git a/acceptance/cloud-portability/phase1b/README.md b/acceptance/cloud-portability/phase1b/README.md index d33fd567..8b796e7b 100644 --- a/acceptance/cloud-portability/phase1b/README.md +++ b/acceptance/cloud-portability/phase1b/README.md @@ -19,8 +19,9 @@ Terraform state, task descriptions, or logs. - A disposable billing-linked GCP project containing the bounded proof table. - A disposable AWS account. Do not create an AWS access key. -The tested GCP target is project `project-092b24a8-26a3-4438-8cd`, dataset `dander_raw`, table -`greenhouse_job_board_jobs`. The retained Dander project is out of scope. +The tested GCP target is project `project-092b24a8-26a3-4438-8cd`, dataset `raw`, table +`salesforce_accounts`. The probe returns only `COUNT(*)` over disposable test rows; it never logs +record content. The retained Dander project is out of scope. ## Reviewed sequence @@ -47,6 +48,12 @@ The tested GCP target is project `project-092b24a8-26a3-4438-8cd`, dataset `dand --credential-config "$OPERATOR_DIR/external-account.json" ``` + Current Google Auth reads the token lifetime from `service_account_impersonation`; the helper + removes the ignored legacy `service_account_impersonation_options` spelling. On Fargate, the + probe reads only the short-lived ECS task-role document from the fixed link-local credential + origin and exposes it to Google Auth in the current process. It never persists or emits those + values. + 5. From the generated project, use this branch's `dander image-publish` to build one `linux/amd64,linux/arm64` index in staging GAR. Preserve `.dander/runtime-artifact.json` outside the repository. @@ -64,9 +71,11 @@ The tested GCP target is project `project-092b24a8-26a3-4438-8cd`, dataset `dand 7. Set the verified ECR digest in `smoke/terraform.tfvars`, initialize `smoke/`, save and review its plan, then apply only that saved plan. The execution role can pull/log; the separate task role has no AWS permission policy and is the only role trusted by Google WIF. -8. Run exactly one Fargate task using the Terraform outputs for cluster, task definition, subnet, - and security group. Wait for terminal status and save `describe-tasks` plus the CloudWatch log - stream outside the repository. +8. For an accepted candidate, run exactly one Fargate task using the Terraform outputs for + cluster, task definition, subnet, and security group. Wait for terminal status and save + `describe-tasks` plus the CloudWatch log stream outside the repository. Failed candidates are + invalidated, corrected, rebuilt once, and rerun from the beginning; they are not acceptance + evidence. 9. Require two `query.completed` events followed by `credential.refresh_observed`. The second expiry must be later than the first. No event may contain a token, URL, query body, or record. 10. Run `scan_long_lived_credentials.py` over the generated config, both Terraform states, task diff --git a/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example b/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example index 736316ca..8c440f58 100644 --- a/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example +++ b/acceptance/cloud-portability/phase1b/smoke/terraform.tfvars.example @@ -1,6 +1,6 @@ aws_region = "us-east-1" gcp_project_id = "replace-with-disposable-project" ecr_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/dander-phase1b@sha256:replace" -proof_dataset = "dander_raw" -proof_table = "greenhouse_job_board_jobs" +proof_dataset = "raw" +proof_table = "salesforce_accounts" cpu_architecture = "ARM64" diff --git a/acceptance/cloud-portability/phase1b/smoke/variables.tf b/acceptance/cloud-portability/phase1b/smoke/variables.tf index c21c4659..16a24f29 100644 --- a/acceptance/cloud-portability/phase1b/smoke/variables.tf +++ b/acceptance/cloud-portability/phase1b/smoke/variables.tf @@ -35,7 +35,7 @@ variable "ecr_image" { variable "proof_dataset" { description = "Existing BigQuery dataset the probe may read." type = string - default = "dander_raw" + default = "raw" validation { condition = length(var.proof_dataset) <= 1024 && can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.proof_dataset)) @@ -46,7 +46,7 @@ variable "proof_dataset" { variable "proof_table" { description = "Existing bounded BigQuery table the probe counts twice." type = string - default = "greenhouse_job_board_jobs" + default = "salesforce_accounts" validation { condition = length(var.proof_table) <= 1024 && can(regex("^[A-Za-z_][A-Za-z0-9_]*$", var.proof_table)) diff --git a/docs/decisions.md b/docs/decisions.md index 7be5f810..d6040494 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -590,4 +590,8 @@ Resolves the "separate product decisions" the two entries above deferred, unbloc to impersonate a disposable service account with BigQuery job access and read access to one proof dataset. - A proof-only, source-free probe observes a 600-second impersonated Google credential expire and - refresh in one process. This does not make Fargate a supported launcher; that remains Phase 3. + refresh in one process. Because Google Auth's AWS supplier does not consume Fargate's ECS + credential endpoint directly, the probe validates that fixed link-local endpoint and exposes + its short-lived task-role values only in the current process. The generated external-account + config uses the library-supported `service_account_impersonation` lifetime field. This does not + make Fargate a supported launcher; that remains Phase 3. diff --git a/scripts/portability/prepare_phase1b_context.py b/scripts/portability/prepare_phase1b_context.py index 369427ab..c40a410d 100644 --- a/scripts/portability/prepare_phase1b_context.py +++ b/scripts/portability/prepare_phase1b_context.py @@ -39,9 +39,8 @@ def _credential_config(path: Path, *, token_lifetime_seconds: int) -> dict[str, "https://iamcredentials.googleapis.com/" ): raise ContextPreparationError("Credential configuration must impersonate a service account") - config["service_account_impersonation_options"] = { - "token_lifetime_seconds": token_lifetime_seconds - } + config.pop("service_account_impersonation_options", None) + config["service_account_impersonation"] = {"token_lifetime_seconds": token_lifetime_seconds} return config diff --git a/scripts/portability/wif_bigquery_probe.py b/scripts/portability/wif_bigquery_probe.py index c3ed3b5e..6ebb5fcc 100644 --- a/scripts/portability/wif_bigquery_probe.py +++ b/scripts/portability/wif_bigquery_probe.py @@ -4,8 +4,11 @@ import argparse import json +import os +import platform import re import time +import urllib.request from datetime import UTC, datetime from typing import TYPE_CHECKING, Protocol @@ -13,12 +16,15 @@ from google.cloud import bigquery if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, MutableMapping _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,1023}$") _PROJECT_ID = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$") _SCOPES = ("https://www.googleapis.com/auth/cloud-platform",) _SCHEMA = "io.dander.portability.identity-probe/v1" +_ECS_CREDENTIALS_PATH = re.compile(r"^/v2/credentials/[A-Za-z0-9-]{16,128}$") +_ECS_CREDENTIALS_ORIGIN = "http://169.254.170.2" +_TEMPORARY_ACCESS_KEY = re.compile(r"^ASIA[A-Z0-9]{16}$") class ProbeError(RuntimeError): @@ -34,6 +40,62 @@ def query_count(self, *, project: str, dataset: str, table: str) -> int: """Return a bounded count from the proof relation.""" +class CredentialFetcher(Protocol): + def __call__(self, url: str) -> object: + """Return one parsed ECS task-credential response.""" + + +def _fetch_ecs_credentials(url: str) -> object: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310 + if response.status != 200: + raise ProbeError("ECS task credentials were unavailable") + return json.load(response) + + +def prepare_fargate_task_credentials( + *, + environ: MutableMapping[str, str] = os.environ, + fetch: CredentialFetcher = _fetch_ecs_credentials, +) -> bool: + """Expose short-lived ECS task credentials to Google Auth without persisting them.""" + existing = tuple( + environ.get(name) + for name in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN") + ) + if all(existing): + return False + if any(existing): + raise ProbeError("AWS credential environment is incomplete") + + relative_uri = environ.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") + if relative_uri is None: + return False + if _ECS_CREDENTIALS_PATH.fullmatch(relative_uri) is None: + raise ProbeError("ECS task credential endpoint is invalid") + + document = fetch(f"{_ECS_CREDENTIALS_ORIGIN}{relative_uri}") + if not isinstance(document, dict): + raise ProbeError("ECS task credentials were invalid") + access_key = document.get("AccessKeyId") + secret_key = document.get("SecretAccessKey") + session_token = document.get("Token") + if ( + not isinstance(access_key, str) + or _TEMPORARY_ACCESS_KEY.fullmatch(access_key) is None + or not isinstance(secret_key, str) + or not secret_key + or not isinstance(session_token, str) + or not session_token + ): + raise ProbeError("ECS task credentials were invalid") + + environ["AWS_ACCESS_KEY_ID"] = access_key + environ["AWS_SECRET_ACCESS_KEY"] = secret_key + environ["AWS_SESSION_TOKEN"] = session_token + return True + + class BigQueryProbeClient: """Small adapter that keeps the exact proof query out of logs and task configuration.""" @@ -140,6 +202,17 @@ def main() -> int: _emit({"schema": _SCHEMA, "event": "probe.failed", "failure_code": "invalid_target"}) return 2 try: + task_credentials_loaded = prepare_fargate_task_credentials() + _emit( + { + "schema": _SCHEMA, + "event": "runtime.observed", + "architecture": platform.machine().lower(), + "aws_credential_source": ( + "ecs_task_role" if task_credentials_loaded else "aws_default_chain" + ), + } + ) credentials, _ = google.auth.default(scopes=_SCOPES) client = BigQueryProbeClient(bigquery.Client(project=args.project, credentials=credentials)) run_probe( diff --git a/tests/portability/test_phase1b_tools.py b/tests/portability/test_phase1b_tools.py index f48b5bbc..8f1b1727 100644 --- a/tests/portability/test_phase1b_tools.py +++ b/tests/portability/test_phase1b_tools.py @@ -10,7 +10,11 @@ from scripts.portability.oci_copy import CopyVerificationError, copy_and_verify from scripts.portability.prepare_phase1b_context import ContextPreparationError, prepare_context from scripts.portability.scan_long_lived_credentials import scan -from scripts.portability.wif_bigquery_probe import ProbeError, run_probe +from scripts.portability.wif_bigquery_probe import ( + ProbeError, + prepare_fargate_task_credentials, + run_probe, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -178,6 +182,41 @@ def test_wif_probe_rejects_an_unbounded_credential_lifetime() -> None: ) +def test_fargate_task_credentials_are_short_lived_and_never_emitted() -> None: + relative_uri = "/v2/credentials/12345678-1234-1234-1234-123456789012" + access_key = "ASIA" + "A" * 16 + secret_key = "short-lived-secret" + session_token = "short-lived-session-token" + environment = {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": relative_uri} + requested_urls: list[str] = [] + + def fetch(url: str) -> object: + requested_urls.append(url) + return { + "AccessKeyId": access_key, + "SecretAccessKey": secret_key, + "Token": session_token, + } + + assert prepare_fargate_task_credentials(environ=environment, fetch=fetch) is True + assert requested_urls == [f"http://169.254.170.2{relative_uri}"] + assert environment["AWS_ACCESS_KEY_ID"] == access_key + assert environment["AWS_SECRET_ACCESS_KEY"] == secret_key + assert environment["AWS_SESSION_TOKEN"] == session_token + + +@pytest.mark.parametrize( + "relative_uri", + ["https://example.invalid/credentials", "/latest/meta-data/iam/security-credentials"], +) +def test_fargate_task_credentials_reject_an_untrusted_endpoint(relative_uri: str) -> None: + with pytest.raises(ProbeError, match="endpoint is invalid"): + prepare_fargate_task_credentials( + environ={"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": relative_uri}, + fetch=lambda _url: pytest.fail("untrusted endpoint must not be fetched"), + ) + + def _write_generated_project(path: Path) -> None: path.mkdir() (path / "Dockerfile").write_text( @@ -233,7 +272,8 @@ def test_prepare_context_keeps_the_project_source_free_and_bounds_token_lifetime assert not (project / "src").exists() rendered = json.loads((project / "gcp-wif.json").read_text(encoding="utf-8")) assert rendered["type"] == "external_account" - assert rendered["service_account_impersonation_options"] == {"token_lifetime_seconds": 600} + assert rendered["service_account_impersonation"] == {"token_lifetime_seconds": 600} + assert "service_account_impersonation_options" not in rendered assert (project / "Dockerfile").read_text(encoding="utf-8").count( "COPY --chown=65532:65532 phase1b_probe.py" ) == 1 diff --git a/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md index e0aba7a0..2cde733e 100644 --- a/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md +++ b/tickets/DANDER-77-cross-cloud-artifact-identity-feasibility.md @@ -1,6 +1,6 @@ # DANDER-77 — Cross-cloud artifact and identity feasibility -Status: in progress +Status: complete ## Outcome @@ -20,24 +20,40 @@ Fargate BigQuery probe across a real Google credential expiry/refresh using only recognizable long-lived cloud credentials. - [x] One source-free OCI index was built in staging GAR; its recorded `amd64` and `arm64` manifests both passed the read-only local runtime conformance command. -- [ ] The index and both platform manifests are identical in staging GAR and ECR. -- [ ] Cloud Run selects the recorded `amd64` content and the Fargate task selects a recorded +- [x] The index and both platform manifests are identical in staging GAR and ECR. +- [x] Cloud Run selects the recorded `amd64` content and the Fargate task selects a recorded platform from the same index. -- [ ] A live Fargate task reads BigQuery before and after Google credential refresh. -- [ ] State, task definition, logs, configuration, and image scans find no static cloud key. -- [ ] Both AWS stacks are destroyed, the isolated GCP configuration is restored, and its final +- [x] A live Fargate task reads BigQuery before and after Google credential refresh. +- [x] State, task definition, logs, configuration, and image scans find no static cloud key. +- [x] Both AWS stacks are destroyed, the isolated GCP configuration is restored, and its final Terraform plan reports `No changes.` -## Partial live evidence - -On 2026-08-07, the accepted GAR index -`sha256:6cd545fa8975ed6c49cf38786a1fcfeb96eaa8fa104f156974609d84031be81f` ran in -the isolated project as Cloud Run execution `dander-phase1b-conformance-nwngt`. The container -reported `x86_64`, emitted a successful `io.dander.runtime/v1` conformance result, and exited zero. -The temporary unscheduled job was deleted, both managed schedules remained paused, the evidence -and source-free context contained no recognizable long-lived cloud key, and the final -manifest-aware Terraform plan reported exactly `No changes.` The combined Cloud Run/Fargate and -teardown criteria remain open until the AWS half is complete. +## Live evidence + +On 2026-08-07, the accepted source-free GAR index was +`sha256:5e026166092ec79d22562781596bc40d7f2e64f89030ac9126000d4614880c82`, with runnable manifests +`sha256:5eb1e1618378c8a9f1671e6746531976cf3225bdf1d8955fa0a4c63e09b192b7` (`linux/amd64`) and +`sha256:4488eecc4fd888379f741553302ef1688a8a3c6537f8b1f59f1c063b3e799fd0` (`linux/arm64`). Both +passed local conformance. Cloud Run execution `dander-phase1b-conformance-b4v57` selected the +recorded AMD64 manifest, reported `x86_64`, completed `io.dander.runtime/v1`, and exited zero. The +temporary unscheduled job was deleted and both managed schedules remained paused. + +The exact index and both runnable manifests copied to private ECR without a registry rewrite. +Fargate task `e3840b6301dd4cce8af5ebce6f7cee40` selected ARM64, reported `aarch64` and +`ecs_task_role`, counted 17 disposable Salesforce Account rows, refreshed its Google credential +from `2026-08-07T19:30:36Z` to `2026-08-07T19:40:51Z`, counted the same 17 rows again, and exited +zero. Its task role had no attached or inline AWS policies; the execution role was limited to the +standard ECS pull/log policy. + +The first live attempts were rejected rather than blessed: the original proof target did not +exist, Google Auth initially tried EC2 metadata instead of Fargate's ECS credential endpoint, and +an ignored impersonation-options spelling produced a 3,600-second token outside the bounded proof +window. Each defect was corrected before building the final candidate. The scanner found no +recognizable long-lived cloud key across generated configuration, Terraform state, task evidence, +logs, or either image filesystem. Reviewed teardown destroyed all 18 smoke resources and both ECR +resources; AWS has no proof cluster, active task definition, log group, role, or repository, and +the disposable Google WIF pool is `DELETED` with its IAM grants removed. The final isolated +manifest-aware Terraform plan reported exactly `No changes.` The retained project was untouched. ## Boundaries diff --git a/uv.lock b/uv.lock index 36a8d2ce..f43d8aa9 100644 --- a/uv.lock +++ b/uv.lock @@ -799,14 +799,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.57" +version = "3.1.58" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" }, ] [[package]]