From 1a2706d4834b2da632699ff4e2d383553486fdbd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 4 Oct 2026 02:04:21 -0400 Subject: [PATCH 1/2] feat(iac): add managed recovery primitives --- CHANGELOG.md | 19 + README.md | 9 +- cmd/plugin/plugin.json | 56 +- go.mod | 43 +- go.sum | 80 ++ internal/app_platform_runner.go | 739 ++++++++++++++++ internal/app_platform_runner_test.go | 832 ++++++++++++++++++ internal/drivers/database.go | 169 +++- internal/drivers/database_test.go | 173 +++- internal/drivers/database_user.go | 400 +++++++++ internal/drivers/database_user_test.go | 705 +++++++++++++++ internal/drivers/droplet.go | 23 +- internal/drivers/droplet_pricing.go | 198 +++++ internal/drivers/droplet_pricing_test.go | 339 +++++++ internal/host_conformance_test.go | 63 ++ internal/iacserver.go | 5 +- internal/iacserver_mapper_test.go | 4 +- internal/iacserver_pricing_test.go | 125 +++ internal/iacserver_runner.go | 92 ++ internal/iacserver_runner_test.go | 374 ++++++++ internal/iacserver_test.go | 4 +- internal/plugin_test.go | 46 + internal/provider.go | 55 +- internal/released_host_test.go | 531 +++++++++++ .../resourcedriver_sensitive_inputs_test.go | 773 ++++++++++++++++ internal/resourcedriver_server.go | 36 +- internal/testdata/app-platform-runner/main.go | 44 + internal/validate_plan.go | 31 +- internal/validate_plan_pricing_test.go | 214 +++++ plugin.json | 56 +- recovery_workflow_test.go | 90 ++ 31 files changed, 6263 insertions(+), 65 deletions(-) create mode 100644 internal/app_platform_runner.go create mode 100644 internal/app_platform_runner_test.go create mode 100644 internal/drivers/database_user.go create mode 100644 internal/drivers/database_user_test.go create mode 100644 internal/drivers/droplet_pricing.go create mode 100644 internal/drivers/droplet_pricing_test.go create mode 100644 internal/iacserver_pricing_test.go create mode 100644 internal/iacserver_runner.go create mode 100644 internal/iacserver_runner_test.go create mode 100644 internal/released_host_test.go create mode 100644 internal/resourcedriver_sensitive_inputs_test.go create mode 100644 internal/testdata/app-platform-runner/main.go create mode 100644 internal/validate_plan_pricing_test.go create mode 100644 recovery_workflow_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a02a7ed..f4c2d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,25 @@ Eliminates the runtime-failure surface (sentinel-stub returning `ErrApplyV1Remov ## [Unreleased] +### Added + +- Parent-bound `digitalocean.database_user` resources and explicit user/admin + rotation epochs, with sensitive output routing and persisted prior-state + recovery through the released Workflow SDK. +- Targeted App Platform one-off jobs with exact component/deployment ownership, + bounded status/log/cancellation, and restartable cleanup after ambiguous + responses, including provider-encrypted secret normalization. +- Read-only live Droplet pricing checks for exact slug/region and maximum + monthly cost, enforced before mutation rather than from a static catalog. +- Credential-free CI executes the released native wfctl saved-plan/state proof + and requires genuine cleanup/redaction evidence; skipped proof is not success. + +### Changed + +- Minimum supported Workflow engine is `0.86.1` for the public prior-state SDK. + Provider behavior remains plugin-owned; no app-specific recovery integration + or live deployment is implied by this release. + ### Changed - **App Platform deployment strategy naming** — the legacy diff --git a/README.md b/README.md index f483f97..74932a1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ DigitalOcean IaC provider for the [GoCodeAlone/workflow](https://github.com/GoCo | `infra.app_domain` | App Platform domain binding | | `infra.k8s_cluster` | DigitalOcean Kubernetes (DOKS) | | `infra.database` | Managed database (PostgreSQL, MySQL, Redis, MongoDB) | +| `digitalocean.database_user` | Parent-bound managed database user with explicit credential rotation | | `infra.cache` | Managed Redis cache | | `infra.load_balancer` | Load balancer | | `infra.vpc` | Virtual Private Cloud | @@ -25,6 +26,12 @@ DigitalOcean IaC provider for the [GoCodeAlone/workflow](https://github.com/GoCo | `infra.iam_role` | IAM role (declarative) | | `infra.api_gateway` | API gateway | +`digitalocean.database_user` is a provider-owned resource type. wfctl v0.86.1 +supports its driver and saved-plan execution, but its YAML desired-resource +discovery currently omits provider-specific types. Before applying a YAML plan, +verify that every declared resource appears; do not accept an incomplete plan. +Generic host discovery needs an upstream correction, not a provider type alias. + ## Quick start See [`examples/minimal/config.yaml`](examples/minimal/config.yaml) for a minimal working configuration. @@ -144,7 +151,7 @@ The nested shape matches Workflow DNS replay fixtures while keeping existing fla ## Requirements -- workflow engine ≥ `0.57.1` +- workflow engine ≥ `0.86.1` - `DIGITALOCEAN_TOKEN` environment variable set to a valid DO personal access token ## Contributing diff --git a/cmd/plugin/plugin.json b/cmd/plugin/plugin.json index 7589567..9416236 100644 --- a/cmd/plugin/plugin.json +++ b/cmd/plugin/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.80.1", + "minEngineVersion": "0.86.1", "required_secrets": [ { "name": "DIGITALOCEAN_TOKEN", @@ -24,11 +24,14 @@ "workflow.plugin.external.iac.IaCProviderValidator", "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderLogCapture", + "workflow.plugin.external.iac.IaCProviderRunner", + "workflow.plugin.external.iac.IaCProviderJobCanceler", "workflow.plugin.external.iac.IaCProviderRequirementMapper", "workflow.plugin.external.iac.IaCProviderRegionLister", "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.IaCProviderFinalizer", "workflow.plugin.external.iac.ResourceDriver", + "workflow.plugin.external.iac.ResourceSensitiveInputDeclarer", "workflow.plugin.external.iac.IaCStateBackend" ], "keywords": [ @@ -88,6 +91,7 @@ "infra.app_domain", "infra.k8s_cluster", "infra.database", + "digitalocean.database_user", "infra.cache", "infra.load_balancer", "infra.vpc", @@ -102,6 +106,56 @@ "infra.api_gateway" ], "configSchema": { + "digitalocean.database_user": { + "description": "Managed database user with provider-generated credentials and opt-in epoch-controlled password rotation.", + "fields": { + "database_id": { + "type": "string", + "required": true, + "description": "Parent managed database's canonical UUID." + }, + "username": { + "type": "string", + "required": true, + "description": "Nonempty database username. The provider escapes it in API paths and resource identifiers." + }, + "rotation_epoch": { + "type": "string", + "required": false, + "description": "Nonempty opaque epoch. Changing it requests password rotation; returned password and TLS URI are sensitive outputs, not desired inputs." + } + } + }, + "infra.database": { + "description": "Managed database. Administrator password rotation is opt-in and separate from ordinary configuration updates.", + "fields": { + "admin_rotation_epoch": { + "type": "string", + "required": false, + "description": "Nonempty opaque epoch. Changing it requests administrator password rotation; omission preserves legacy behavior." + } + } + }, + "infra.droplet": { + "description": "DigitalOcean Droplet with optional fresh catalog price validation before plan, create, or destructive replacement.", + "fields": { + "size": { + "type": "string", + "required": false, + "description": "Explicit size slug required when max_monthly_usd is present." + }, + "region": { + "type": "string", + "required": false, + "description": "Explicit region slug required when max_monthly_usd is present." + }, + "max_monthly_usd": { + "type": "number", + "required": false, + "description": "Positive finite monthly USD cap. A fresh Sizes API quote must confirm exact size and region availability and price at or below this cap; uncapped resources make no price lookup." + } + } + }, "infra.container_service": { "expose": { "type": "string", diff --git a/go.mod b/go.mod index 455d2bd..b3239fa 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,19 @@ module github.com/GoCodeAlone/workflow-plugin-digitalocean -go 1.26.4 +go 1.26.5 require ( - github.com/GoCodeAlone/workflow v0.85.2 + github.com/GoCodeAlone/workflow v0.86.1 github.com/GoCodeAlone/workflow-plugin-control-plane v0.1.0 github.com/aws/aws-sdk-go-v2 v1.41.6 github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 github.com/digitalocean/godo v1.189.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 golang.org/x/oauth2 v0.36.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 ) @@ -21,9 +22,9 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect github.com/GoCodeAlone/go-plugin v1.7.0 // indirect - github.com/GoCodeAlone/modular v1.13.5 // indirect - github.com/GoCodeAlone/modular/modules/auth v1.17.0 // indirect - github.com/GoCodeAlone/modular/modules/cache v1.17.0 // indirect + github.com/GoCodeAlone/modular v1.13.6 // indirect + github.com/GoCodeAlone/modular/modules/auth v1.19.0 // indirect + github.com/GoCodeAlone/modular/modules/cache v1.19.0 // indirect github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.10.0 // indirect github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0 // indirect github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0 // indirect @@ -32,7 +33,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring v1.9.4 // indirect github.com/Workiva/go-datastructures v1.1.7 // indirect - github.com/andybalholm/brotli v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect @@ -73,13 +74,13 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.24.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -112,8 +113,8 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/compress v1.18.6 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/miekg/dns v1.1.72 // indirect @@ -146,8 +147,8 @@ require ( github.com/tidwall/btree v1.8.1 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/redcon v1.6.2 // indirect - github.com/tochemey/goakt/v4 v4.2.12 // indirect - github.com/tochemey/olric v0.3.10 // indirect + github.com/tochemey/goakt/v4 v4.3.1 // indirect + github.com/tochemey/olric v0.3.11 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect @@ -173,16 +174,16 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/arch v0.28.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 3865314..6e15c87 100644 --- a/go.sum +++ b/go.sum @@ -11,10 +11,16 @@ github.com/GoCodeAlone/go-plugin v1.7.0 h1:EwnhqPlXiNmp85S+MXnKKvm3YlfA6O4NzBb4+ github.com/GoCodeAlone/go-plugin v1.7.0/go.mod h1:HbGQRZUIa+jbDfjsaZIMJYvrz+LnxL0mJpggfynSTMk= github.com/GoCodeAlone/modular v1.13.5 h1:Q+AR3MMAQJctvPJzlRSmTywArbrsXiMM3gzyJN8reBs= github.com/GoCodeAlone/modular v1.13.5/go.mod h1:+JEPUYOxGaD332EMZ5PbJCz5rxwvFu4Tm6MrnZT0vxM= +github.com/GoCodeAlone/modular v1.13.6 h1:AIdfdR/KTPHX8T5KJBXUHMokliutUHjl6jRfkqdh/7Q= +github.com/GoCodeAlone/modular v1.13.6/go.mod h1:Vs92YltRNqA/v3peoHdqHTCjD10ww7rY3iURf1Ju450= github.com/GoCodeAlone/modular/modules/auth v1.17.0 h1:GbKG6s/2qe6N9YZ8vtvYsNon56MLWECncPxWvAsazSc= github.com/GoCodeAlone/modular/modules/auth v1.17.0/go.mod h1:E9dDIxiAxIrXK8gn/rEhaqI5OYe6Aw/uGpRyI7iyxj8= +github.com/GoCodeAlone/modular/modules/auth v1.19.0 h1:ughD47BqEAhg7+3trucwcs5KzEpq9gIS/6jY4nkeGtA= +github.com/GoCodeAlone/modular/modules/auth v1.19.0/go.mod h1:pGPazN7Irlza8kUxE7WsXri44PhURKYBGym1tlBUl+k= github.com/GoCodeAlone/modular/modules/cache v1.17.0 h1:1cColHYfF7aFZhxBjS4RuZ2wBOYWAIVr5GkJ3nk5VIA= github.com/GoCodeAlone/modular/modules/cache v1.17.0/go.mod h1:RURzRp+vpRzKR2LjZXwQ4QGc1cvE+53SYODcxY6AR3Y= +github.com/GoCodeAlone/modular/modules/cache v1.19.0 h1:s+3aFI7wncV0NhbPrJheFyVYr1WXwLJXOvSAP9RRaYU= +github.com/GoCodeAlone/modular/modules/cache v1.19.0/go.mod h1:bj/yS2FPdEDb8Vdmd5hjs5W4hchzMAC57eoIB3YY9S4= github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.10.0 h1:2ljVafd/1LYchF47WrnA1+ji8mcmVXMJ4F5qDrhZZi4= github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.10.0/go.mod h1:AKLcRGsw5gp2Q1zhuK0TBnMJOsaRe3aJ2OKnLFE2O5w= github.com/GoCodeAlone/modular/modules/jsonschema v1.17.0 h1:zoWioqUvuNNDfnjHA1sHixdlHfBreJdGhnnEBtxkzI8= @@ -23,6 +29,10 @@ github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0 h1:+2M/ecyCxDiXfJ github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.10.0/go.mod h1:tlVH1mA5yuU8CB7R7+HXIRaBixZoNid6h+5tew5u3FU= github.com/GoCodeAlone/workflow v0.85.2 h1:u65GfzC0c1bkicOOvyPbgcB1rhodexWd7f+wR6V6o1E= github.com/GoCodeAlone/workflow v0.85.2/go.mod h1:tqWHdWHCn2ESB4Rj6FiRjKchof9zjcs11o8AtfOiZaU= +github.com/GoCodeAlone/workflow v0.86.0 h1:9NatmWg2120RHDFesltawOAS7jJvzyJ4k9Q+5WueySg= +github.com/GoCodeAlone/workflow v0.86.0/go.mod h1:WaH+HhX8lrmoGu/vQjkyloZTydUWZVVRo9KFJsZAUtI= +github.com/GoCodeAlone/workflow v0.86.1 h1:674lDosRSsPrPf/Z/IA2Qv2UXA4ji1Xb0TbYE9Ijoek= +github.com/GoCodeAlone/workflow v0.86.1/go.mod h1:WaH+HhX8lrmoGu/vQjkyloZTydUWZVVRo9KFJsZAUtI= github.com/GoCodeAlone/workflow-plugin-control-plane v0.1.0 h1:XtogtQXVBING2iu5jQGzfubi9rOes/erVWH5uwiPsok= github.com/GoCodeAlone/workflow-plugin-control-plane v0.1.0/go.mod h1:2c3Ow7rncEZttzq3XddC+mTD0x5CBE4TdPdvbnH4iYk= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= @@ -44,6 +54,8 @@ github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antithesishq/antithesis-sdk-go v0.7.2 h1:oEEedg1Xgi8drRjqB0f9tfjhLoInE0IYZfZ6zAhQUbY= github.com/antithesishq/antithesis-sdk-go v0.7.2/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= @@ -180,6 +192,8 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -195,32 +209,58 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonpointer v0.24.0 h1:AA6mCjHYHmZ+1RU2Js089EaOK/iwXXNwQsTgnsTha2M= +github.com/go-openapi/jsonpointer v0.24.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE= +github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0= github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/fileutils v0.27.0 h1:ib5jMUqGq5tY1EyO4inlrabsaeDAleFU+XD1FXQcgp8= +github.com/go-openapi/swag/fileutils v0.27.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= +github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= +github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/mangling v0.27.0 h1:rpPJuqQHa6z2pDiP3iIpXOyNXlSs9cQCxnJSAxzdfOc= +github.com/go-openapi/swag/mangling v0.27.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= +github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= +github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -357,8 +397,12 @@ github.com/kapetan-io/tackle v0.15.0 h1:J+D04RuxEKtybzCjuzgcmQuFBiEpYa+5vPU2mAgA github.com/kapetan-io/tackle v0.15.0/go.mod h1:pDr4mjpo2RQO/q/je1dGuGwnBVwZcsRp60wgDV2hA3c= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -429,6 +473,8 @@ github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.14.2 h1:Q7dRhCY03Y00rETFW3KV+KGaCIajlDfWgWUVgbMxyuk= github.com/nats-io/nats-server/v2 v2.14.2/go.mod h1:lWpb1bSpRELZfRdlMkdz8E7lbXKKyNe8RIn0vvepIHs= +github.com/nats-io/nats-server/v2 v2.14.3 h1:+xjydPt7rkit67G+04TN0mcO2n+8nveZE7tK/PPV53A= +github.com/nats-io/nats-server/v2 v2.14.3/go.mod h1:5IlCtBzfwyzQzPMjmoJ9W2/LKmnJRtNyuOs/OT+NHDY= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -545,8 +591,12 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/tochemey/goakt/v4 v4.2.12 h1:9rJpjC6H1O3M/tOgL+AhzOmvaK78hgmVMAmZMfFOgQA= github.com/tochemey/goakt/v4 v4.2.12/go.mod h1:5aF+SJ7KBPJS1o93XWbHAOBUs6ZMuIVzJ2KARKIGvKA= +github.com/tochemey/goakt/v4 v4.3.1 h1:l6JBN/kr1qN9tFki2dfDUDIwhmBnirxEp7h8CGZNyq8= +github.com/tochemey/goakt/v4 v4.3.1/go.mod h1:QcHNrLicr/4+dqzI0OEJ//8rv0TxpvbqHsEkWSuCcAc= github.com/tochemey/olric v0.3.10 h1:S5Ypz0QxZiLFv7DUrm9q40+mh9FO24WniZRabU33aQE= github.com/tochemey/olric v0.3.10/go.mod h1:l/LsIx3UPjIKh4OYOihM7+7a66hAmdKJ+fX6YJhfH7k= +github.com/tochemey/olric v0.3.11 h1:GMvXyIc6eeBgFknHVLyVS1GMBQoi8HhNU/ml1cZ1Nm4= +github.com/tochemey/olric v0.3.11/go.mod h1:lauJDgUkt283EIMzO1mMh9wLLVQSwREtBpF/lYX3JKU= github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -584,10 +634,16 @@ go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/etcd/api/v3 v3.6.12 h1:OLOZUKEuAA36TR48F0cIaa8FdzrWygjyfrJxXg4iDgs= go.etcd.io/etcd/api/v3 v3.6.12/go.mod h1:p14EIQXHbuOQbVvL/WEes5uqKnxP9AgKJgpjbMVvzvE= +go.etcd.io/etcd/api/v3 v3.6.13 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0= +go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk= go.etcd.io/etcd/client/pkg/v3 v3.6.12 h1:36zzB+pQOdHbhN+kH2iJz/K8bJn0ZLtLfPPO7jozTDo= go.etcd.io/etcd/client/pkg/v3 v3.6.12/go.mod h1:hh2+ZXtfLzs3o6mn92ntgNPBrTJJOvXqICM5g3L3DMY= +go.etcd.io/etcd/client/pkg/v3 v3.6.13 h1:7QeMOisYByx8dBA7/CKcwCaPWfjb5C0xpmrIov/8WyY= +go.etcd.io/etcd/client/pkg/v3 v3.6.13/go.mod h1:Dn2zUBOCu/6xYcd6iAjB7LgoY16OTQjDZfWHLwvuQj4= go.etcd.io/etcd/client/v3 v3.6.12 h1:kMSP6JcPZMqSJiX+TXdUIBU/4eXEZWBAaui4VihMbIc= go.etcd.io/etcd/client/v3 v3.6.12/go.mod h1:CMs6fJWYiZQk4ytFjd4lE1diOvvRMmtbbn/alZXd3dQ= +go.etcd.io/etcd/client/v3 v3.6.13 h1:0E+9ZYGpMsi9KlOJVoCdONh9PUDawKDTy5mSNY8wOEI= +go.etcd.io/etcd/client/v3 v3.6.13/go.mod h1:rtVI3vwobljb8xlTGcp1Yhz7hBIuBWULXwB848kqJGw= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -634,6 +690,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -641,6 +699,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -654,6 +714,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -665,6 +727,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -688,11 +752,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -700,6 +768,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -709,6 +779,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -717,10 +789,16 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= @@ -753,6 +831,8 @@ k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821 h1:m2wZhD5+vJZyCVkTvUHIfa k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI= k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= diff --git a/internal/app_platform_runner.go b/internal/app_platform_runner.go new file mode 100644 index 0000000..086351c --- /dev/null +++ b/internal/app_platform_runner.go @@ -0,0 +1,739 @@ +package internal + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strings" + "time" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" +) + +var errAppJobCleanupPending = errors.New("app platform job cleanup pending; retry with the same handle") + +var appJobComponentPattern = regexp.MustCompile(`^wfctl-job-[0-9a-f]{20}$`) +var appJobSecretRefPattern = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_-]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}$`) +var appJobEnvKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +type appPlatformJobClient interface { + Get(context.Context, string) (*godo.App, *godo.Response, error) + Update(context.Context, string, *godo.AppUpdateRequest) (*godo.App, *godo.Response, error) + GetDeployment(context.Context, string, string) (*godo.Deployment, *godo.Response, error) + ListDeployments(context.Context, string, *godo.ListOptions) ([]*godo.Deployment, *godo.Response, error) + CancelDeployment(context.Context, string, string) (*godo.Deployment, *godo.Response, error) + ListJobInvocations(context.Context, string, *godo.ListJobInvocationsOptions) ([]*godo.JobInvocation, *godo.Response, error) + GetJobInvocation(context.Context, string, string, *godo.GetJobInvocationOptions) (*godo.JobInvocation, *godo.Response, error) + CancelJobInvocation(context.Context, string, string, *godo.CancelJobInvocationOptions) (*godo.JobInvocation, *godo.Response, error) + GetJobInvocationLogs(context.Context, string, string, *godo.GetJobInvocationLogsOptions) (*godo.AppLogs, *godo.Response, error) +} + +type appPlatformRunner struct { + client appPlatformJobClient + now func() time.Time +} + +func newAppPlatformRunner(client appPlatformJobClient) *appPlatformRunner { + return &appPlatformRunner{client: client, now: time.Now} +} + +// Handles carry only identities, digests and deadlines, never an app snapshot or credentials. +type appPlatformJobMetadata struct { + AppID string `json:"app_id"` + AppName string `json:"app_name"` + Component string `json:"component"` + PriorSpecDigest string `json:"prior_spec_digest"` + ComponentDigest string `json:"component_digest"` + IntentDigest string `json:"intent_digest,omitempty"` + ComponentAcknowledged bool `json:"component_acknowledged,omitempty"` + DeploymentID string `json:"deployment_id,omitempty"` + JobID string `json:"job_id,omitempty"` + Deadline time.Time `json:"deadline"` + LaunchFailed bool `json:"launch_failed,omitempty"` +} + +func (r *appPlatformRunner) RunJob(ctx context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + if err := spec.Validate(); err != nil { + return nil, err + } + if spec.Target == nil || spec.Target.Type != "infra.container_service" || strings.TrimSpace(spec.RunCommand) == "" || spec.Cron != "" || (spec.Kind != "" && spec.Kind != "PRE_DEPLOY") { + return nil, appJobValidation("targeted one-off job requires an App Platform parent, command and timeout") + } + if len(spec.Alerts) > 0 || len(spec.LogDestinations) > 0 || spec.Termination != nil { + return nil, appJobValidation("unsupported one-off job options") + } + base, digest, ok := strings.Cut(spec.Image, "@") + if !ok || !validAppJobDigest(digest) { + return nil, appJobValidation("job image must use a canonical sha256 digest") + } + image, err := drivers.ParseImageRef(base) + if err != nil { + return nil, appJobValidation("job image registry reference is invalid") + } + image.Tag = "" + image.Digest = digest + app, _, err := r.client.Get(ctx, spec.Target.ProviderID) + if err != nil { + return nil, appJobFailure("read target", err) + } + if app == nil || app.ID != spec.Target.ProviderID || app.Spec == nil || app.Spec.Name != spec.Target.Name { + return nil, appJobValidation("job target does not match the managed app identity") + } + envs, err := appJobEnvs(spec, app.Spec) + if err != nil { + return nil, err + } + if _, _, err := r.client.ListJobInvocations(ctx, app.ID, &godo.ListJobInvocationsOptions{Page: 1, PerPage: 1}); err != nil { + return nil, appJobFailure("preflight job invocations", err) + } + var token [10]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, appJobFailure("generate ownership marker", err) + } + component := "wfctl-job-" + hex.EncodeToString(token[:]) + if appJobComponentExists(app.Spec, component) { + return nil, appJobValidation("job ownership marker conflicts with an existing component") + } + updated, err := cloneAppJobSpec(app.Spec) + if err != nil { + return nil, err + } + job := &godo.AppJobSpec{Name: component, Kind: godo.AppJobSpecKind_PreDeploy, Image: image, RunCommand: spec.RunCommand, Timeout: fmt.Sprintf("%ds", spec.TimeoutSeconds), Envs: envs, InstanceCount: 1} + updated.Jobs = append(updated.Jobs, job) + metadata := appPlatformJobMetadata{AppID: app.ID, AppName: app.Spec.Name, Component: component, PriorSpecDigest: appJobDigest(app.Spec), ComponentDigest: appJobDigest(job), IntentDigest: appJobIntentDigest(job), Deadline: r.now().Add(time.Duration(spec.TimeoutSeconds) * time.Second)} + handle := interfaces.JobHandle{ID: component, Name: spec.Name, Provider: "digitalocean"} + result, _, err := r.client.Update(ctx, app.ID, &godo.AppUpdateRequest{Spec: updated}) + if err != nil { + // A failed response can follow an accepted write. Return a recoverable handle, + // rather than retrying a mutation or discarding its cleanup identity. + metadata.LaunchFailed = true + if readback, _, readErr := r.client.Get(ctx, app.ID); readErr == nil { + result = readback + } + } + if result != nil && result.ID == metadata.AppID && result.Spec != nil && result.Spec.Name == metadata.AppName { + if accepted := ownedAppJobComponent(result.Spec, metadata); accepted != nil { + // SECRET values are encrypted by the API on first submission. Persist + // the acknowledged component digest, not the pre-encryption request hash. + metadata.ComponentDigest = appJobDigest(accepted) + metadata.ComponentAcknowledged = true + } + for _, deployment := range []*godo.Deployment{result.InProgressDeployment, result.ActiveDeployment} { + if bindAppJobDeployment(&metadata, deployment) { + break + } + } + } + return appJobHandle(handle, metadata), nil +} + +// Intent is stable across SECRET encryption, env ordering and duration spelling. +// The exact acknowledged digest remains the authority for subsequent drift checks. +func appJobIntentDigest(job *godo.AppJobSpec) string { + if job == nil { + return "" + } + timeout, err := time.ParseDuration(job.Timeout) + if err != nil { + return "" + } + normalized := *job + normalized.Timeout = timeout.String() + normalized.Envs = nil + keys := make(map[string]bool, len(job.Envs)) + for _, env := range job.Envs { + if env == nil || keys[env.Key] { + return "" + } + keys[env.Key] = true + copy := *env + if copy.Type == godo.AppVariableType_Secret { + copy.Value = "" + } + normalized.Envs = append(normalized.Envs, ©) + } + sort.Slice(normalized.Envs, func(i, j int) bool { return normalized.Envs[i].Key < normalized.Envs[j].Key }) + return appJobDigest(&normalized) +} + +func ownedAppJobComponent(spec *godo.AppSpec, m appPlatformJobMetadata) *godo.AppJobSpec { + if spec == nil || spec.Name != m.AppName { + return nil + } + nonJobs := *spec + nonJobs.Jobs = nil + if appJobComponentExists(&nonJobs, m.Component) { + return nil + } + var accepted *godo.AppJobSpec + for _, job := range spec.Jobs { + if job == nil || job.Name != m.Component { + continue + } + if accepted != nil { + return nil + } + if m.IntentDigest != "" && appJobIntentDigest(job) != m.IntentDigest { + return nil + } + // Legacy handles have no normalization-stable intent, so retain their + // existing exact-digest authority and never infer a new acknowledgement. + if (m.IntentDigest == "" || m.ComponentAcknowledged) && appJobDigest(job) != m.ComponentDigest { + return nil + } + accepted = job + } + return accepted +} + +func bindAppJobDeployment(m *appPlatformJobMetadata, deployment *godo.Deployment) bool { + if deployment == nil || deployment.ID == "" || (m.DeploymentID != "" && deployment.ID != m.DeploymentID) { + return false + } + job := ownedAppJobComponent(deployment.Spec, *m) + if job == nil { + return false + } + parent, err := cloneAppJobSpec(deployment.Spec) + if err != nil { + return false + } + var jobs []*godo.AppJobSpec + for _, candidate := range parent.Jobs { + if candidate != nil && candidate.Name == m.Component { + continue + } + jobs = append(jobs, candidate) + } + parent.Jobs = jobs + if appJobDigest(parent) != m.PriorSpecDigest { + return false + } + m.DeploymentID = deployment.ID + if m.IntentDigest != "" { + m.ComponentDigest = appJobDigest(job) + m.ComponentAcknowledged = true + } + return true +} + +func (r *appPlatformRunner) JobStatus(ctx context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + m, err := decodeAppJobHandle(handle) + if err != nil { + return nil, err + } + reply := &interfaces.JobStatusReply{Handle: handle, State: interfaces.JobStatePending, ExitCode: -1} + invocation, err := r.appJobInvocation(ctx, &m) + reply.Handle = *appJobHandle(handle, m) + if err != nil { + return reply, err + } + if invocation != nil { + switch invocation.Phase { + case godo.JOBINVOCATIONPHASE_Succeeded: + reply.State = interfaces.JobStateSucceeded + reply.ExitCode = 0 + case godo.JOBINVOCATIONPHASE_Failed, godo.JOBINVOCATIONPHASE_Skipped: + reply.State = interfaces.JobStateFailed + case godo.JOBINVOCATIONPHASE_Canceled: + reply.State = interfaces.JobStateCancelled + case godo.JOBINVOCATIONPHASE_Running: + reply.State = interfaces.JobStateRunning + } + } else if m.DeploymentID != "" { + deployment, _, readErr := r.client.GetDeployment(ctx, m.AppID, m.DeploymentID) + if readErr != nil { + return reply, appJobFailure("read deployment", readErr) + } + if !bindAppJobDeployment(&m, deployment) { + return reply, appJobValidation("deployment identity mismatch") + } + if deployment.Phase == godo.DeploymentPhase_Error { + reply.State = interfaces.JobStateFailed + } + if deployment.Phase == godo.DeploymentPhase_Canceled { + reply.State = interfaces.JobStateCancelled + } + } else if m.LaunchFailed { + app, _, readErr := r.client.Get(ctx, m.AppID) + if readErr != nil { + return reply, appJobFailure("read ambiguous launch", readErr) + } + if app == nil || app.Spec == nil { + return reply, appJobValidation("missing app after ambiguous launch") + } + if !appJobComponentExists(app.Spec, m.Component) { + reply.State = interfaces.JobStateFailed + } + } + if !appJobTerminal(reply.State) && !r.now().Before(m.Deadline) { + if err := r.cancelInvocation(ctx, &m, invocation); err != nil { + return reply, err + } + reply.State = interfaces.JobStateCancelled + reply.Message = "job timeout exceeded" + } + reply.Handle = *appJobHandle(handle, m) + if appJobTerminal(reply.State) { + if err := r.cleanup(ctx, m); err != nil { + return reply, err + } + } + return reply, nil +} + +func (r *appPlatformRunner) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + m, err := decodeAppJobHandle(handle) + if err != nil { + return err + } + invocation, err := r.appJobInvocation(ctx, &m) + if err != nil { + return err + } + if err := r.cancelInvocation(ctx, &m, invocation); err != nil { + return err + } + return r.cleanup(ctx, m) +} + +func (r *appPlatformRunner) cancelInvocation(ctx context.Context, m *appPlatformJobMetadata, invocation *godo.JobInvocation) error { + if invocation == nil { + // Removing a not-yet-discovered component does not prove its deployment + // cannot launch it. Keep cleanup debt until invocation or terminal deployment readback. + if m.DeploymentID == "" { + return errAppJobCleanupPending + } + deployment, _, err := r.client.GetDeployment(ctx, m.AppID, m.DeploymentID) + if err != nil { + return appJobFailure("read cancellation deployment", err) + } + if !bindAppJobDeployment(m, deployment) { + return appJobValidation("cancellation deployment ownership mismatch") + } + if deployment.Phase != godo.DeploymentPhase_Error && deployment.Phase != godo.DeploymentPhase_Canceled && deployment.Phase != godo.DeploymentPhase_Superseded { + if _, _, err := r.client.CancelDeployment(ctx, m.AppID, m.DeploymentID); err != nil { + return appJobFailure("cancel pending deployment", err) + } + readback, _, err := r.client.GetDeployment(ctx, m.AppID, m.DeploymentID) + if err != nil || !bindAppJobDeployment(m, readback) || (readback.Phase != godo.DeploymentPhase_Canceled && readback.Phase != godo.DeploymentPhase_Error && readback.Phase != godo.DeploymentPhase_Superseded) { + return errAppJobCleanupPending + } + } + return nil + } + if invocation.Phase == godo.JOBINVOCATIONPHASE_Succeeded || invocation.Phase == godo.JOBINVOCATIONPHASE_Failed || invocation.Phase == godo.JOBINVOCATIONPHASE_Canceled || invocation.Phase == godo.JOBINVOCATIONPHASE_Skipped { + return nil + } + deployment, _, err := r.client.GetDeployment(ctx, m.AppID, m.DeploymentID) + if err != nil { + return appJobFailure("read invocation deployment", err) + } + if !bindAppJobDeployment(m, deployment) { + return appJobValidation("invocation deployment ownership mismatch") + } + if _, _, err := r.client.CancelJobInvocation(ctx, m.AppID, invocation.ID, &godo.CancelJobInvocationOptions{JobName: m.Component}); err != nil { + return appJobFailure("cancel invocation", err) + } + readback, _, err := r.client.GetJobInvocation(ctx, m.AppID, invocation.ID, &godo.GetJobInvocationOptions{JobName: m.Component}) + if err != nil { + return appJobFailure("read cancellation", err) + } + if readback == nil || readback.ID != invocation.ID || readback.JobName != m.Component || (readback.Phase != godo.JOBINVOCATIONPHASE_Canceled && readback.Phase != godo.JOBINVOCATIONPHASE_Succeeded && readback.Phase != godo.JOBINVOCATIONPHASE_Failed && readback.Phase != godo.JOBINVOCATIONPHASE_Skipped) { + return errAppJobCleanupPending + } + return nil +} + +func (r *appPlatformRunner) appJobInvocation(ctx context.Context, m *appPlatformJobMetadata) (*godo.JobInvocation, error) { + if m.DeploymentID == "" { + app, _, err := r.client.Get(ctx, m.AppID) + if err != nil { + return nil, appJobFailure("recover launch deployment", err) + } + if app == nil || app.ID != m.AppID || app.Spec == nil || app.Spec.Name != m.AppName { + return nil, appJobValidation("ambiguous launch app identity mismatch") + } + for _, deployment := range []*godo.Deployment{app.InProgressDeployment, app.ActiveDeployment} { + if deployment == nil || deployment.ID == "" || deployment.Spec == nil { + continue + } + candidate := *m + if !bindAppJobDeployment(&candidate, deployment) { + continue + } + readback, _, err := r.client.GetDeployment(ctx, m.AppID, candidate.DeploymentID) + if err != nil { + return nil, appJobFailure("recover owned deployment", err) + } + candidate = *m + candidate.DeploymentID = deployment.ID + if !bindAppJobDeployment(&candidate, readback) { + continue + } + *m = candidate + if m.DeploymentID != "" { + break + } + } + if m.DeploymentID == "" && !appJobComponentExists(app.Spec, m.Component) && appJobDigest(app.Spec) == m.PriorSpecDigest { + // Cleanup can advance app deployment hints before its readback is lost. + // Absence alone cannot prove the historical deployment stopped executing. + if err := r.recoverAppJobHistoricalDeployment(ctx, m); err != nil { + return nil, err + } + } + } + if m.JobID != "" { + invocation, _, err := r.client.GetJobInvocation(ctx, m.AppID, m.JobID, &godo.GetJobInvocationOptions{JobName: m.Component}) + if err != nil { + return nil, appJobFailure("read invocation", err) + } + if err := r.bindAppJobInvocation(ctx, m, invocation); err != nil { + return nil, err + } + return invocation, nil + } + opts := &godo.ListJobInvocationsOptions{Page: 1, PerPage: 100, JobNames: []string{m.Component}, DeploymentID: m.DeploymentID} + var found *godo.JobInvocation + for { + invocations, response, err := r.client.ListJobInvocations(ctx, m.AppID, opts) + if err != nil { + return nil, appJobFailure("list invocations", err) + } + for _, invocation := range invocations { + if invocation == nil || invocation.JobName != m.Component { + continue + } + if m.DeploymentID != "" && invocation.DeploymentID != m.DeploymentID { + continue + } + if found != nil && found.ID != invocation.ID { + return nil, appJobValidation("multiple invocations for one job handle") + } + found = invocation + } + if response == nil || response.Links == nil || response.Links.IsLastPage() { + break + } + opts.Page++ + } + if found != nil { + if err := r.bindAppJobInvocation(ctx, m, found); err != nil { + return nil, err + } + } + return found, nil +} + +func (r *appPlatformRunner) recoverAppJobHistoricalDeployment(ctx context.Context, m *appPlatformJobMetadata) error { + var found *appPlatformJobMetadata + opts := &godo.ListOptions{Page: 1, PerPage: 100} + for ; opts.Page <= 100; opts.Page++ { + deployments, response, err := r.client.ListDeployments(ctx, m.AppID, opts) + if err != nil { + return appJobFailure("list historical deployments", err) + } + for _, deployment := range deployments { + if deployment == nil || deployment.ID == "" { + continue + } + if deployment.Spec != nil && !appJobComponentExists(deployment.Spec, m.Component) { + continue + } + readback, _, err := r.client.GetDeployment(ctx, m.AppID, deployment.ID) + if err != nil { + return appJobFailure("read historical deployment", err) + } + candidate := *m + candidate.DeploymentID = deployment.ID + if !bindAppJobDeployment(&candidate, readback) { + if readback != nil && appJobComponentExists(readback.Spec, m.Component) { + return appJobValidation("historical deployment ownership mismatch") + } + continue + } + if found != nil && found.DeploymentID != candidate.DeploymentID { + return appJobValidation("multiple historical deployments for one job handle") + } + found = &candidate + } + if response == nil || response.Links == nil || response.Links.IsLastPage() { + if found != nil { + *m = *found + } + return nil + } + } + return errAppJobCleanupPending +} + +func (r *appPlatformRunner) bindAppJobInvocation(ctx context.Context, m *appPlatformJobMetadata, invocation *godo.JobInvocation) error { + if invocation == nil || invocation.ID == "" || invocation.JobName != m.Component || invocation.DeploymentID == "" || (m.JobID != "" && invocation.ID != m.JobID) || (m.DeploymentID != "" && invocation.DeploymentID != m.DeploymentID) { + return appJobValidation("job invocation identity mismatch") + } + if m.DeploymentID == "" { + deployment, _, err := r.client.GetDeployment(ctx, m.AppID, invocation.DeploymentID) + if err != nil { + return appJobFailure("bind invocation deployment", err) + } + candidate := *m + candidate.DeploymentID = invocation.DeploymentID + if !bindAppJobDeployment(&candidate, deployment) { + return appJobValidation("invocation deployment ownership mismatch") + } + *m = candidate + } + m.JobID = invocation.ID + m.DeploymentID = invocation.DeploymentID + return nil +} + +func (r *appPlatformRunner) cleanup(ctx context.Context, m appPlatformJobMetadata) error { + app, _, err := r.client.Get(ctx, m.AppID) + if err != nil { + return fmt.Errorf("%w: target read failed", errAppJobCleanupPending) + } + if app == nil || app.ID != m.AppID || app.Spec == nil || app.Spec.Name != m.AppName { + return fmt.Errorf("%w: app identity changed", errAppJobCleanupPending) + } + restored, err := cloneAppJobSpec(app.Spec) + if err != nil { + return err + } + var jobs []*godo.AppJobSpec + owned := 0 + for _, job := range restored.Jobs { + if job != nil && job.Name == m.Component { + if appJobDigest(job) != m.ComponentDigest { + return fmt.Errorf("%w: owned component changed", errAppJobCleanupPending) + } + owned++ + continue + } + jobs = append(jobs, job) + } + if owned > 1 { + return fmt.Errorf("%w: duplicate ownership marker", errAppJobCleanupPending) + } + restored.Jobs = jobs + if appJobDigest(restored) != m.PriorSpecDigest { + return fmt.Errorf("%w: concurrent app spec drift", errAppJobCleanupPending) + } + if owned == 0 { + return nil + } + // App Platform's full-spec Update has no CAS. Never restore a saved snapshot; + // remove only our unchanged component from a freshly verified live spec. + _, _, writeErr := r.client.Update(ctx, m.AppID, &godo.AppUpdateRequest{Spec: restored}) + readback, _, readErr := r.client.Get(ctx, m.AppID) + if readErr != nil || readback == nil || readback.Spec == nil || readback.ID != m.AppID || appJobComponentExists(readback.Spec, m.Component) || appJobDigest(readback.Spec) != m.PriorSpecDigest { + return fmt.Errorf("%w: absence and spec equivalence not verified", errAppJobCleanupPending) + } + if writeErr != nil { + return nil + } // Readback resolves a response lost after the write. + return nil +} + +func (r *appPlatformRunner) JobLogs(ctx context.Context, handle interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + if sink == nil { + return appJobValidation("job log sink is required") + } + m, err := decodeAppJobHandle(handle) + if err != nil { + return err + } + invocation, err := r.appJobInvocation(ctx, &m) + if err != nil { + return err + } + if invocation == nil { + return appJobValidation("job invocation is not available yet") + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + logs, _, err := r.client.GetJobInvocationLogs(ctx, m.AppID, invocation.ID, &godo.GetJobInvocationLogsOptions{JobName: m.Component, TailLines: 200}) + if err != nil { + return appJobFailure("get job logs", err) + } + if logs != nil { + remaining := maxLogCaptureBytes + for _, rawURL := range logs.HistoricURLs { + if remaining == 0 { + break + } + data, err := fetchCaptureLogContent(ctx, rawURL, 200) + if err != nil { + return err + } + if len(data) > remaining { + data = data[:remaining] + } + if err := sink.WriteLogChunk(interfaces.LogChunk{Data: data, Source: "historic"}); err != nil { + return err + } + remaining -= len(data) + } + } + return sink.WriteLogChunk(interfaces.LogChunk{EOF: true}) +} + +func appJobEnvs(spec interfaces.JobSpec, parent *godo.AppSpec) ([]*godo.AppVariableDefinition, error) { + var envs []*godo.AppVariableDefinition + for key, value := range spec.EnvVars { + if !appJobEnvKeyPattern.MatchString(key) { + return nil, appJobValidation("invalid job environment key") + } + if _, exists := spec.EnvVarsSecret[key]; exists { + return nil, appJobValidation("job environment key is both plain and secret") + } + envs = append(envs, &godo.AppVariableDefinition{Key: key, Value: value, Type: godo.AppVariableType_General, Scope: godo.AppVariableScope_RunTime}) + } + for key, ref := range spec.EnvVarsSecret { + parts := appJobSecretRefPattern.FindStringSubmatch(ref) + if !appJobEnvKeyPattern.MatchString(key) || len(parts) != 3 { + return nil, appJobValidation("job secret environment requires an App Platform reference") + } + if parts[2] != "" { + if !appJobComponentExists(parent, parts[1]) { + return nil, appJobValidation("job secret reference component does not exist") + } + } else { + found := false + for _, env := range parent.Envs { + if env != nil && env.Key == parts[1] && env.Type == godo.AppVariableType_Secret { + found = true + break + } + } + if !found { + return nil, appJobValidation("job secret reference variable does not exist") + } + } + envs = append(envs, &godo.AppVariableDefinition{Key: key, Value: ref, Type: godo.AppVariableType_Secret, Scope: godo.AppVariableScope_RunTime}) + } + sort.Slice(envs, func(i, j int) bool { return envs[i].Key < envs[j].Key }) + return envs, nil +} + +func appJobComponentExists(spec *godo.AppSpec, name string) bool { + if spec == nil { + return false + } + for _, c := range spec.Services { + if c != nil && c.Name == name { + return true + } + } + for _, c := range spec.Workers { + if c != nil && c.Name == name { + return true + } + } + for _, c := range spec.Jobs { + if c != nil && c.Name == name { + return true + } + } + for _, c := range spec.StaticSites { + if c != nil && c.Name == name { + return true + } + } + for _, c := range spec.Functions { + if c != nil && c.Name == name { + return true + } + } + for _, c := range spec.Databases { + if c != nil && c.Name == name { + return true + } + } + return false +} + +func cloneAppJobSpec(spec *godo.AppSpec) (*godo.AppSpec, error) { + data, err := json.Marshal(spec) + if err != nil { + return nil, appJobFailure("encode app spec", err) + } + var result godo.AppSpec + if err := json.Unmarshal(data, &result); err != nil { + return nil, appJobFailure("decode app spec", err) + } + return &result, nil +} + +func appJobDigest(value any) string { + data, _ := json.Marshal(value) + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func validAppJobDigest(digest string) bool { + if !strings.HasPrefix(digest, "sha256:") { + return false + } + value := strings.TrimPrefix(digest, "sha256:") + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == value +} + +func appJobHandle(handle interfaces.JobHandle, m appPlatformJobMetadata) *interfaces.JobHandle { + data, _ := json.Marshal(m) + handle.Metadata = map[string]string{"app_platform_job": string(data)} + return &handle +} + +func decodeAppJobHandle(handle interfaces.JobHandle) (appPlatformJobMetadata, error) { + var m appPlatformJobMetadata + data := handle.Metadata["app_platform_job"] + if len(data) > 8192 { + return m, appJobValidation("job handle metadata exceeds the size limit") + } + decoder := json.NewDecoder(strings.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&m); err != nil { + return m, appJobValidation("invalid job handle metadata") + } + if err := decoder.Decode(new(any)); err != io.EOF { + return m, appJobValidation("invalid trailing job handle metadata") + } + if handle.Provider != "digitalocean" || handle.ID != m.Component || !appJobComponentPattern.MatchString(m.Component) || m.AppID == "" || m.AppName == "" || m.Deadline.IsZero() || !validAppJobDigest("sha256:"+m.PriorSpecDigest) || !validAppJobDigest("sha256:"+m.ComponentDigest) { + return m, appJobValidation("job handle identity is incomplete") + } + if m.IntentDigest != "" && !validAppJobDigest("sha256:"+m.IntentDigest) { + return m, appJobValidation("job handle intent is invalid") + } + return m, nil +} + +func appJobTerminal(state interfaces.JobState) bool { + return state == interfaces.JobStateSucceeded || state == interfaces.JobStateFailed || state == interfaces.JobStateCancelled +} + +func appJobValidation(message string) error { + return fmt.Errorf("app platform job: %s: %w", message, interfaces.ErrValidation) +} +func appJobFailure(operation string, err error) error { + return &appPlatformJobFailure{operation: operation, cause: err} +} + +type appPlatformJobFailure struct { + operation string + cause error +} + +func (e *appPlatformJobFailure) Error() string { return "app platform job " + e.operation + " failed" } +func (e *appPlatformJobFailure) Unwrap() error { return e.cause } diff --git a/internal/app_platform_runner_test.go b/internal/app_platform_runner_test.go new file mode 100644 index 0000000..16d7fc1 --- /dev/null +++ b/internal/app_platform_runner_test.go @@ -0,0 +1,832 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" +) + +type appJobAPIFake struct { + app *godo.App + phase godo.JobInvocationPhase + deploymentPhase godo.DeploymentPhase + updates int + cancels int + updateErr error + loseUpdateResponse bool + ignoreCleanup bool + logs *godo.AppLogs + lastJobName string + noInvocation bool + deploymentCancels int + encryptSecretValues bool + getCalls int + failGetAt int + deployment *godo.Deployment + normalizeJobs bool + loseCleanupReadback bool + deploymentListErr error + deploymentReadErr error + deploymentHistory []*godo.Deployment + ignoreDeployCancel bool +} + +func newAppJobAPIFake() *appJobAPIFake { + return &appJobAPIFake{app: &godo.App{ID: "app-uuid", Spec: &godo.AppSpec{Name: "staging", Services: []*godo.AppServiceSpec{{Name: "server", RunCommand: "/server"}}}}, phase: godo.JOBINVOCATIONPHASE_Running, deploymentPhase: godo.DeploymentPhase_Deploying} +} + +func jobClone[T any](t T) T { + data, err := json.Marshal(t) + if err != nil { + panic(err) + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + panic(err) + } + return result +} + +func (f *appJobAPIFake) Get(_ context.Context, id string) (*godo.App, *godo.Response, error) { + f.getCalls++ + if f.getCalls == f.failGetAt { + return nil, nil, errors.New("temporary readback outage") + } + if f.loseCleanupReadback && f.updates >= 2 && len(f.app.Spec.Jobs) == 0 { + f.loseCleanupReadback = false + return nil, nil, errors.New("cleanup readback lost after removal") + } + if id != f.app.ID { + return nil, nil, errors.New("wrong app ID") + } + return jobClone(f.app), nil, nil +} + +func (f *appJobAPIFake) Update(_ context.Context, id string, req *godo.AppUpdateRequest) (*godo.App, *godo.Response, error) { + f.updates++ + if id != f.app.ID { + return nil, nil, errors.New("wrong app ID") + } + if f.updateErr != nil { + return nil, nil, f.updateErr + } + if !f.ignoreCleanup || len(req.Spec.Jobs) > 0 { + f.app.Spec = jobClone(req.Spec) + } + if len(req.Spec.Jobs) > 0 { + f.lastJobName = req.Spec.Jobs[len(req.Spec.Jobs)-1].Name + if f.encryptSecretValues { + for _, job := range f.app.Spec.Jobs { + for _, env := range job.Envs { + if env.Type == godo.AppVariableType_Secret { + env.Value = "EV[fixture-provider-encrypted-reference]" + } + } + } + } + if f.normalizeJobs { + for _, job := range f.app.Spec.Jobs { + timeout, err := time.ParseDuration(job.Timeout) + if err != nil { + return nil, nil, err + } + job.Timeout = timeout.String() + for i, j := 0, len(job.Envs)-1; i < j; i, j = i+1, j-1 { + job.Envs[i], job.Envs[j] = job.Envs[j], job.Envs[i] + } + } + } + } + f.app.InProgressDeployment = &godo.Deployment{ID: "deployment-uuid", Phase: f.deploymentPhase, Spec: jobClone(f.app.Spec)} + if len(req.Spec.Jobs) > 0 { + f.deployment = jobClone(f.app.InProgressDeployment) + } else if f.deployment != nil { + f.app.InProgressDeployment.ID = "cleanup-deployment-uuid" + } + if f.loseUpdateResponse { + return nil, nil, errors.New("response lost after write") + } + return jobClone(f.app), nil, nil +} + +func (f *appJobAPIFake) GetDeployment(_ context.Context, appID, deploymentID string) (*godo.Deployment, *godo.Response, error) { + if f.deploymentReadErr != nil { + return nil, nil, f.deploymentReadErr + } + for _, deployment := range f.deploymentHistory { + if appID == f.app.ID && deployment != nil && deployment.ID == deploymentID { + return jobClone(deployment), nil, nil + } + } + if appID != f.app.ID || deploymentID != "deployment-uuid" { + return nil, nil, errors.New("wrong deployment") + } + deployment := f.deployment + if deployment == nil { + deployment = f.app.InProgressDeployment + } + if deployment == nil { + return nil, nil, errors.New("missing deployment") + } + result := jobClone(deployment) + result.Phase = f.deploymentPhase + return result, nil, nil +} + +func (f *appJobAPIFake) ListDeployments(_ context.Context, appID string, _ *godo.ListOptions) ([]*godo.Deployment, *godo.Response, error) { + if appID != f.app.ID { + return nil, nil, errors.New("wrong app ID") + } + if f.deploymentListErr != nil { + return nil, nil, f.deploymentListErr + } + if f.deploymentHistory != nil { + return jobClone(f.deploymentHistory), nil, nil + } + return jobClone([]*godo.Deployment{f.deployment, f.app.InProgressDeployment}), nil, nil +} + +func (f *appJobAPIFake) ListJobInvocations(_ context.Context, id string, opts *godo.ListJobInvocationsOptions) ([]*godo.JobInvocation, *godo.Response, error) { + if id != f.app.ID { + return nil, nil, errors.New("wrong app ID") + } + if f.lastJobName == "" || f.noInvocation || f.deploymentPhase == godo.DeploymentPhase_Error { + return nil, nil, nil + } + if len(opts.JobNames) > 0 && opts.JobNames[0] != f.lastJobName { + return nil, nil, nil + } + return []*godo.JobInvocation{{ID: "invocation-uuid", JobName: f.lastJobName, DeploymentID: "deployment-uuid", Phase: f.phase}}, nil, nil +} + +func (f *appJobAPIFake) CancelDeployment(_ context.Context, appID, deploymentID string) (*godo.Deployment, *godo.Response, error) { + if appID != f.app.ID || deploymentID != "deployment-uuid" { + return nil, nil, errors.New("wrong deployment cancellation") + } + f.deploymentCancels++ + if !f.ignoreDeployCancel { + f.deploymentPhase = godo.DeploymentPhase_Canceled + } + return &godo.Deployment{ID: deploymentID, Phase: f.deploymentPhase}, nil, nil +} + +func (f *appJobAPIFake) GetJobInvocation(ctx context.Context, id, invocationID string, opts *godo.GetJobInvocationOptions) (*godo.JobInvocation, *godo.Response, error) { + if id != f.app.ID || invocationID != "invocation-uuid" { + return nil, nil, errors.New("wrong invocation") + } + return &godo.JobInvocation{ID: invocationID, JobName: opts.JobName, DeploymentID: "deployment-uuid", Phase: f.phase}, nil, ctx.Err() +} + +func (f *appJobAPIFake) CancelJobInvocation(_ context.Context, id, invocationID string, opts *godo.CancelJobInvocationOptions) (*godo.JobInvocation, *godo.Response, error) { + if id != f.app.ID || invocationID != "invocation-uuid" || len(f.app.Spec.Jobs) == 0 || opts.JobName != f.app.Spec.Jobs[len(f.app.Spec.Jobs)-1].Name { + return nil, nil, errors.New("wrong cancellation target") + } + f.cancels++ + f.phase = godo.JOBINVOCATIONPHASE_Canceled + return &godo.JobInvocation{ID: invocationID, JobName: opts.JobName, DeploymentID: "deployment-uuid", Phase: f.phase}, nil, nil +} + +func (f *appJobAPIFake) GetJobInvocationLogs(_ context.Context, id, invocationID string, opts *godo.GetJobInvocationLogsOptions) (*godo.AppLogs, *godo.Response, error) { + if id != f.app.ID || invocationID != "invocation-uuid" || opts.JobName == "" || opts.TailLines != 200 || opts.Follow { + return nil, nil, errors.New("wrong logs target") + } + return f.logs, nil, nil +} + +func validAppJobSpec() interfaces.JobSpec { + return interfaces.JobSpec{Name: "migrate", Target: &interfaces.ResourceRef{Name: "staging", Type: "infra.container_service", ProviderID: "app-uuid"}, Image: "ghcr.io/org/server@sha256:" + strings.Repeat("a", 64), RunCommand: "/server migrate", TimeoutSeconds: 300, EnvVarsSecret: map[string]string{"DATABASE_URL": "${db.DATABASE_URL}"}} +} + +func TestAppPlatformRunnerValidationBeforeMutation(t *testing.T) { + cases := map[string]func(*interfaces.JobSpec){ + "missing target": func(s *interfaces.JobSpec) { s.Target = nil }, + "wrong target type": func(s *interfaces.JobSpec) { s.Target.Type = "infra.droplet" }, + "missing ID": func(s *interfaces.JobSpec) { s.Target.ProviderID = "" }, + "wrong parent name": func(s *interfaces.JobSpec) { s.Target.Name = "other" }, + "mutable image": func(s *interfaces.JobSpec) { s.Image = "ghcr.io/org/server:latest" }, + "bad digest": func(s *interfaces.JobSpec) { s.Image = "ghcr.io/org/server@sha256:abcd" }, + "empty command": func(s *interfaces.JobSpec) { s.RunCommand = " " }, + "no timeout": func(s *interfaces.JobSpec) { s.TimeoutSeconds = 0 }, + "long timeout": func(s *interfaces.JobSpec) { s.TimeoutSeconds = 3601 }, + "literal secret": func(s *interfaces.JobSpec) { s.EnvVarsSecret["DATABASE_URL"] = "known-credential-value" }, + "missing binding": func(s *interfaces.JobSpec) { s.EnvVarsSecret["DATABASE_URL"] = "${unknown.PASSWORD}" }, + "secret as plain env": func(s *interfaces.JobSpec) { s.EnvVars = map[string]string{"DATABASE_URL": "not-a-ref"} }, + "scheduled": func(s *interfaces.JobSpec) { s.Cron = "* * * * *" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + spec := validAppJobSpec() + mutate(&spec) + _, err := newAppPlatformRunner(api).RunJob(context.Background(), spec) + if !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("want validation error, got %v", err) + } + if api.updates != 0 { + t.Fatalf("invalid job mutated app %d times", api.updates) + } + if strings.Contains(err.Error(), "known-credential-value") { + t.Fatal("secret leaked in error") + } + }) + } +} + +func launchAppJob(t *testing.T, api *appJobAPIFake) (*appPlatformRunner, interfaces.JobHandle) { + t.Helper() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + runner := newAppPlatformRunner(api) + handle, err := runner.RunJob(context.Background(), validAppJobSpec()) + if err != nil { + t.Fatal(err) + } + if handle == nil { + t.Fatal("missing durable handle") + } + return runner, *handle +} + +func TestAppPlatformRunnerTerminalCleanupAndRestart(t *testing.T) { + for _, phase := range []godo.JobInvocationPhase{godo.JOBINVOCATIONPHASE_Succeeded, godo.JOBINVOCATIONPHASE_Failed, godo.JOBINVOCATIONPHASE_Canceled, godo.JOBINVOCATIONPHASE_Skipped} { + t.Run(string(phase), func(t *testing.T) { + api := newAppJobAPIFake() + _, handle := launchAppJob(t, api) + if len(api.app.Spec.Jobs) != 1 { + t.Fatal("expected one owned job") + } + job := api.app.Spec.Jobs[0] + if !strings.HasPrefix(job.Name, "wfctl-job-") || job.Name == handle.Name || job.Timeout != "300s" || job.Image.Digest != "sha256:"+strings.Repeat("a", 64) || job.Image.Tag != "" { + t.Fatalf("unsafe job component: %+v", job) + } + if len(job.Envs) != 1 || job.Envs[0].Value != "${db.DATABASE_URL}" || job.Envs[0].Type != godo.AppVariableType_Secret { + t.Fatal("provider-side reference was not preserved") + } + api.phase = phase + reply, err := newAppPlatformRunner(api).JobStatus(context.Background(), jobClone(handle)) + if err != nil { + t.Fatal(err) + } + if phase == godo.JOBINVOCATIONPHASE_Succeeded && reply.State != interfaces.JobStateSucceeded { + t.Fatalf("got state %q", reply.State) + } + if phase != godo.JOBINVOCATIONPHASE_Succeeded && reply.State == interfaces.JobStateSucceeded { + t.Fatal("failure reported success") + } + if len(api.app.Spec.Jobs) != 0 || api.app.Spec.Services[0].RunCommand != "/server" { + t.Fatal("cleanup did not restore non-job spec") + } + if reply.Handle.Metadata["app_platform_job"] == "" { + t.Fatal("missing persisted metadata") + } + if _, err := newAppPlatformRunner(api).JobStatus(context.Background(), reply.Handle); err != nil { + t.Fatalf("restart retry: %v", err) + } + }) + } +} + +func TestAppPlatformRunnerJobCleanupDriftAndRetry(t *testing.T) { + api := newAppJobAPIFake() + runner, handle := launchAppJob(t, api) + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + api.app.Spec.Services[0].RunCommand = "/new-server" + reply, err := runner.JobStatus(context.Background(), handle) + if !errors.Is(err, errAppJobCleanupPending) || reply == nil { + t.Fatalf("want cleanup debt and handle, got %+v %v", reply, err) + } + if api.updates != 1 || len(api.app.Spec.Jobs) != 1 { + t.Fatal("blindly overwrote concurrent change") + } + api.app.Spec.Services[0].RunCommand = "/server" + if _, err := newAppPlatformRunner(api).JobStatus(context.Background(), handle); err != nil { + t.Fatal(err) + } + if len(api.app.Spec.Jobs) != 0 { + t.Fatal("retry left temporary job") + } +} + +func TestAppPlatformRunnerJobCleanupReadbackRequired(t *testing.T) { + api := newAppJobAPIFake() + runner, handle := launchAppJob(t, api) + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + api.ignoreCleanup = true + if _, err := runner.JobStatus(context.Background(), handle); !errors.Is(err, errAppJobCleanupPending) { + t.Fatalf("want cleanup debt on successful PUT with retained component, got %v", err) + } + api.ignoreCleanup = false + if _, err := newAppPlatformRunner(api).JobStatus(context.Background(), handle); err != nil { + t.Fatal(err) + } +} + +func TestAppPlatformRunnerJobCancelAndTimeout(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(fmt.Sprint(timeout), func(t *testing.T) { + api := newAppJobAPIFake() + runner, handle := launchAppJob(t, api) + if timeout { + runner.now = func() time.Time { return time.Now().Add(time.Hour) } + reply, err := runner.JobStatus(context.Background(), handle) + if err != nil || reply.State != interfaces.JobStateCancelled { + t.Fatalf("timeout: %+v %v", reply, err) + } + } else if err := runner.CancelJob(context.Background(), handle); err != nil { + t.Fatal(err) + } + if api.cancels != 1 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("cancel did not target invocation and remove job") + } + }) + } +} + +func TestAppPlatformRunnerDeploymentFailureCleanup(t *testing.T) { + api := newAppJobAPIFake() + runner, handle := launchAppJob(t, api) + api.deploymentPhase = godo.DeploymentPhase_Error + reply, err := runner.JobStatus(context.Background(), handle) + if err != nil || reply.State != interfaces.JobStateFailed || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("deploy failure: %+v %v", reply, err) + } +} + +func TestAppPlatformRunnerLostUpdateResponseRetainsHandle(t *testing.T) { + api := newAppJobAPIFake() + api.loseUpdateResponse = true + runner, handle := launchAppJob(t, api) + if api.updates != 1 { + t.Fatal("ambiguous write was repeated") + } + api.loseUpdateResponse = false + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + if _, err := runner.JobStatus(context.Background(), handle); err != nil { + t.Fatal(err) + } + if len(api.app.Spec.Jobs) != 0 { + t.Fatal("ambiguous launch left temporary job") + } +} + +func TestAppPlatformRunnerJobCancelBeforeInvocation(t *testing.T) { + api := newAppJobAPIFake() + api.noInvocation = true + runner, handle := launchAppJob(t, api) + if err := runner.CancelJob(context.Background(), handle); err != nil { + t.Fatal(err) + } + if api.deploymentCancels != 1 || api.cancels != 0 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("pending deployment was not cancelled before cleanup") + } +} + +func appJobCancelCleanupReadbackLoss(t *testing.T, knownID, encrypted bool) (*appJobAPIFake, interfaces.JobHandle) { + t.Helper() + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + api.encryptSecretValues, api.noInvocation, api.loseCleanupReadback = encrypted, true, true + if !knownID { + api.loseUpdateResponse, api.failGetAt = true, 2 + } + _, original := launchAppJob(t, api) + m, err := decodeAppJobHandle(original) + if err != nil || (m.DeploymentID != "") != knownID || m.ComponentAcknowledged != knownID { + t.Fatal("fixture did not preserve original launch acknowledgement state") + } + assertAppJobHandleSecretFree(t, original) + api.loseUpdateResponse = false + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(original)); err == nil { + t.Fatal("fixture did not lose cleanup readback") + } + if api.loseCleanupReadback || len(api.app.Spec.Jobs) != 0 || api.deploymentCancels != 1 || api.updates != 2 || api.app.InProgressDeployment.ID == api.deployment.ID { + t.Fatal("fixture did not remove owned job, advance app deployment hint and lose cleanup readback") + } + return api, jobClone(original) +} + +func TestAppPlatformRunnerCancelCleanupReadbackLossOriginalDurableHandle(t *testing.T) { + for _, fixture := range []struct { + name string + knownID, encrypted bool + }{{"encrypted-ambiguous", false, true}, {"known-id", true, true}, {"unencrypted-ambiguous", false, false}} { + t.Run(fixture.name, func(t *testing.T) { + api, original := appJobCancelCleanupReadbackLoss(t, fixture.knownID, fixture.encrypted) + encoded, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + for retry := 0; retry < 3; retry++ { + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(original)); err != nil { + t.Fatalf("restart retry %d with original durable handle did not converge: %v", retry, err) + } + } + after, err := json.Marshal(original) + if err != nil || string(after) != string(encoded) || api.deploymentCancels != 1 || api.cancels != 0 || api.updates != 2 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("retry changed the durable handle or repeated cancellation/cleanup mutation") + } + }) + } +} + +func TestAppPlatformRunnerCancelHistoricalAbsenceRecoveryGuards(t *testing.T) { + for _, change := range []string{"app-id", "app-name", "live-parent", "list-error", "read-error", "unrelated-history", "missing-spec", "intent", "historical-parent", "wrong-readback-id", "multiple-owned", "conflicting-owned", "pending-owned"} { + t.Run(change, func(t *testing.T) { + api, original := appJobCancelCleanupReadbackLoss(t, false, true) + app, deployment := jobClone(api.app), jobClone(api.deployment) + switch change { + case "app-id": + api.app.ID = "unrelated-app" + case "app-name": + api.app.Spec.Name = "unrelated-app" + case "live-parent": + api.app.Spec.Services[0].RunCommand = "/changed-server" + case "list-error": + api.deploymentListErr = errors.New("history unavailable") + case "read-error": + api.deploymentReadErr = errors.New("historical deployment unavailable") + case "unrelated-history": + api.deployment.Spec.Jobs[0].Name = "unrelated-job" + case "missing-spec": + api.deployment.Spec = nil + case "intent": + api.deployment.Spec.Jobs[0].RunCommand = "/unrelated-job" + case "historical-parent": + api.deployment.Spec.Services[0].RunCommand = "/changed-server" + case "wrong-readback-id": + api.deployment.ID = "wrong-readback-id" + case "multiple-owned", "conflicting-owned": + second := jobClone(deployment) + second.ID, second.Phase = "second-owned-deployment", godo.DeploymentPhase_Deploying + if change == "conflicting-owned" { + second.Spec.Jobs[0].RunCommand = "/changed-job" + } + first := jobClone(deployment) + first.Phase = godo.DeploymentPhase_Canceled + api.deploymentHistory = []*godo.Deployment{first, second} + case "pending-owned": + api.deploymentPhase, api.ignoreDeployCancel = godo.DeploymentPhase_Deploying, true + } + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(original)); err == nil { + t.Fatal("absence acknowledged without authoritative identity, unchanged spec and terminal owned deployment") + } + wantCancels := 1 + if change == "pending-owned" { + wantCancels++ + } + if api.updates != 2 || api.cancels != 0 || api.deploymentCancels != wantCancels || len(api.app.Spec.Jobs) != 0 { + t.Fatal("unproven historical recovery mutated the live app or unrelated work") + } + api.app, api.deployment = app, deployment + api.deploymentHistory, api.deploymentListErr, api.deploymentReadErr = nil, nil, nil + api.deploymentPhase, api.ignoreDeployCancel = godo.DeploymentPhase_Canceled, false + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(original)); err != nil { + t.Fatalf("restored authoritative historical cancellation did not converge: %v", err) + } + if api.updates != 2 || api.deploymentCancels != wantCancels { + t.Fatal("restored readback repeated cleanup/cancellation") + } + }) + } +} + +type appJobDeploymentPages struct { + *appJobAPIFake + pages int + endless bool +} + +func (f *appJobDeploymentPages) ListDeployments(ctx context.Context, appID string, opts *godo.ListOptions) ([]*godo.Deployment, *godo.Response, error) { + if appID != f.app.ID || opts.PerPage != 100 || opts.Page < 1 || (!f.endless && opts.Page > 2) { + return nil, nil, errors.New("invalid history page request") + } + f.pages++ + if opts.Page == 1 || f.endless { + return []*godo.Deployment{jobClone(f.app.InProgressDeployment)}, &godo.Response{Links: &godo.Links{Pages: &godo.Pages{Next: "https://api.digitalocean.com/v2/apps/app-uuid/deployments?page=2"}}}, nil + } + return []*godo.Deployment{{ID: f.deployment.ID}}, nil, ctx.Err() +} + +func TestAppPlatformRunnerCancelHistoricalRecoveryReadsAllPagesAndExactDeployment(t *testing.T) { + api, original := appJobCancelCleanupReadbackLoss(t, false, true) + client := &appJobDeploymentPages{appJobAPIFake: api} + if err := newAppPlatformRunner(client).CancelJob(t.Context(), jobClone(original)); err != nil { + t.Fatalf("original handle did not recover through paginated ID-only history: %v", err) + } + if client.pages != 2 || api.updates != 2 || api.deploymentCancels != 1 { + t.Fatal("historical readback skipped a page or repeated mutation") + } +} + +func TestAppPlatformRunnerCancelHistoricalRecoveryPaginationBound(t *testing.T) { + api, original := appJobCancelCleanupReadbackLoss(t, false, true) + client := &appJobDeploymentPages{appJobAPIFake: api, endless: true} + if err := newAppPlatformRunner(client).CancelJob(t.Context(), jobClone(original)); err == nil { + t.Fatal("incomplete unbounded history acknowledged cancellation") + } + if client.pages != 100 || api.updates != 2 || api.deploymentCancels != 1 { + t.Fatal("history pagination was not bounded or repeated mutation") + } +} + +func TestAppPlatformRunnerAmbiguousLaunchDeploymentFailure(t *testing.T) { + api := newAppJobAPIFake() + api.noInvocation = true + api.loseUpdateResponse = true + runner, handle := launchAppJob(t, api) + api.loseUpdateResponse = false + api.deploymentPhase = godo.DeploymentPhase_Error + reply, err := runner.JobStatus(context.Background(), handle) + if err != nil || reply.State != interfaces.JobStateFailed || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("lost response then failed deployment did not converge: %+v %v", reply, err) + } +} + +func TestAppPlatformRunnerJobLogsAfterCleanupPreservePayload(t *testing.T) { + api := newAppJobAPIFake() + runner, handle := launchAppJob(t, api) + const payload = "consumer data: AWS_SECRET_ACCESS_KEY=payload-value\n" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = fmt.Fprint(w, payload) })) + defer server.Close() + api.logs = &godo.AppLogs{HistoricURLs: []string{server.URL}} + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + reply, err := runner.JobStatus(t.Context(), handle) + if err != nil { + t.Fatal(err) + } + sink := &captureSink{} + if err := newAppPlatformRunner(api).JobLogs(t.Context(), reply.Handle, sink); err != nil { + t.Fatal(err) + } + if sink.String() != payload { + t.Fatal("job log consumer payload was changed or lost") + } +} + +func TestAppPlatformRunnerJobCleanupProviderEncryptedReferences(t *testing.T) { + for _, lostResponse := range []bool{false, true} { + t.Run(fmt.Sprint(lostResponse), func(t *testing.T) { + api := newAppJobAPIFake() + api.encryptSecretValues = true + api.loseUpdateResponse = lostResponse + _, handle := launchAppJob(t, api) + api.loseUpdateResponse = false + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + if _, err := newAppPlatformRunner(api).JobStatus(t.Context(), handle); err != nil { + t.Fatal(err) + } + if len(api.app.Spec.Jobs) != 0 { + t.Fatal("encrypted reference normalization left an owned job") + } + }) + } +} + +func TestAppPlatformRunnerRejectedLaunchCannotAdoptOrCancelUnrelatedDeployment(t *testing.T) { + api := newAppJobAPIFake() + api.app.InProgressDeployment = &godo.Deployment{ID: "deployment-uuid", Phase: godo.DeploymentPhase_Deploying, Spec: jobClone(api.app.Spec)} + api.updateErr = errors.New("launch rejected before write") + _, handle := launchAppJob(t, api) + m, err := decodeAppJobHandle(handle) + if err != nil { + t.Fatal(err) + } + if m.DeploymentID != "" || !m.LaunchFailed || len(api.app.Spec.Jobs) != 0 { + t.Fatal("rejected launch adopted an unrelated deployment") + } + _ = newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(handle)) + if api.deploymentCancels != 0 || api.cancels != 0 || api.updates != 1 { + t.Fatal("rejected launch mutated unrelated app work") + } +} + +func TestAppPlatformRunnerCancellationRechecksExactDeploymentBinding(t *testing.T) { + for _, change := range []string{"marker", "command", "running-command", "secret", "parent", "duplicate", "missing-spec", "identity"} { + t.Run(change, func(t *testing.T) { + api := newAppJobAPIFake() + api.noInvocation = change != "running-command" + _, handle := launchAppJob(t, api) + original := jobClone(api.deployment) + switch change { + case "marker": + api.deployment.Spec.Jobs[0].Name = "unrelated" + case "command", "running-command": + api.deployment.Spec.Jobs[0].RunCommand = "/unrelated" + case "secret": + api.deployment.Spec.Jobs[0].Envs[0].Value = "EV[changed-secret]" + case "parent": + api.deployment.Spec.Services[0].RunCommand = "/unrelated-server" + case "duplicate": + api.deployment.Spec.Jobs = append(api.deployment.Spec.Jobs, jobClone(api.deployment.Spec.Jobs[0])) + case "missing-spec": + api.deployment.Spec = nil + case "identity": + api.deployment.ID = "other-deployment" + } + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(handle)); err == nil { + t.Fatal("unbound deployment cancellation was accepted") + } + if api.deploymentCancels != 0 || api.cancels != 0 || api.updates != 1 || len(api.app.Spec.Jobs) != 1 { + t.Fatal("unbound deployment was cancelled or cleaned up") + } + api.deployment = original + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(handle)); err != nil { + t.Fatal(err) + } + if api.deploymentCancels+api.cancels != 1 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("restored exact deployment did not converge") + } + }) + } +} + +func TestAppPlatformRunnerAmbiguousEncryptedNormalizedIntent(t *testing.T) { + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + api.encryptSecretValues, api.loseUpdateResponse, api.normalizeJobs, api.failGetAt = true, true, true, 2 + spec := validAppJobSpec() + spec.EnvVars = map[string]string{"MODE": "migration"} + handle, err := newAppPlatformRunner(api).RunJob(t.Context(), spec) + if err != nil { + t.Fatal(err) + } + api.loseUpdateResponse = false + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + reply, err := newAppPlatformRunner(api).JobStatus(t.Context(), jobClone(*handle)) + if err != nil || reply.State != interfaces.JobStateSucceeded || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("equivalent duration/env ordering prevented encrypted recovery: %v", err) + } + assertAppJobHandleSecretFree(t, reply.Handle) +} + +func ambiguousEncryptedAppJob(t *testing.T) (*appJobAPIFake, interfaces.JobHandle) { + t.Helper() + api := newAppJobAPIFake() + api.encryptSecretValues, api.loseUpdateResponse, api.failGetAt = true, true, 2 + _, handle := launchAppJob(t, api) + api.loseUpdateResponse = false + assertAppJobHandleSecretFree(t, handle) + return api, jobClone(handle) +} + +func assertAppJobHandleSecretFree(t *testing.T, handle interfaces.JobHandle) { + t.Helper() + data, err := json.Marshal(handle) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"${db.DATABASE_URL}", "EV[fixture-provider-encrypted-reference]", "EV[changed-secret]"} { + if strings.Contains(string(data), secret) { + t.Fatal("job handle persisted secret/reference bytes") + } + } +} + +func TestAppPlatformRunnerAmbiguousEncryptedLaunchRecoversAfterRestart(t *testing.T) { + for _, outcome := range []string{"deployment-failed", "invocation-succeeded", "invocation-failed", "interrupted-cancel", "interrupted-timeout"} { + t.Run(outcome, func(t *testing.T) { + api, handle := ambiguousEncryptedAppJob(t) + original, err := decodeAppJobHandle(handle) + if err != nil { + t.Fatal(err) + } + acceptedDigest := appJobDigest(api.app.Spec.Jobs[0]) + if original.DeploymentID != "" || original.ComponentDigest == acceptedDigest { + t.Fatal("fixture did not lose both encrypted acknowledgement and readback") + } + var reply *interfaces.JobStatusReply + switch outcome { + case "deployment-failed": + api.noInvocation = true + api.deploymentPhase = godo.DeploymentPhase_Error + case "invocation-succeeded": + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + case "invocation-failed": + api.phase = godo.JOBINVOCATIONPHASE_Failed + } + if outcome == "interrupted-cancel" { + err = newAppPlatformRunner(api).CancelJob(t.Context(), handle) + } else { + runner := newAppPlatformRunner(api) + if outcome == "interrupted-timeout" { + runner.now = func() time.Time { return original.Deadline.Add(time.Second) } + } + reply, err = runner.JobStatus(t.Context(), handle) + } + if err != nil || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("encrypted ambiguous launch did not converge: %v; components=%d", err, len(api.app.Spec.Jobs)) + } + if reply != nil { + recovered, err := decodeAppJobHandle(reply.Handle) + if err != nil || recovered.ComponentDigest != acceptedDigest || recovered.DeploymentID != "deployment-uuid" { + t.Fatal("acknowledged digest/deployment was not persisted after recovery") + } + assertAppJobHandleSecretFree(t, reply.Handle) + if _, err := newAppPlatformRunner(api).JobStatus(t.Context(), jobClone(reply.Handle)); err != nil { + t.Fatalf("recovered handle retry failed: %v", err) + } + } + if reply != nil { + handle = reply.Handle + } + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(handle)); err != nil { + t.Fatalf("durable same-identity handle retry failed: %v", err) + } + }) + } +} + +func TestAppPlatformRunnerEncryptedRecoveryAcknowledgesImmutableDeploymentNotAppHint(t *testing.T) { + api, handle := ambiguousEncryptedAppJob(t) + api.noInvocation, api.deploymentPhase = true, godo.DeploymentPhase_Error + // The app's embedded hint may lag normalization; only the exact deployment + // readback can supply the digest persisted for subsequent cleanup checks. + api.app.InProgressDeployment.Spec.Jobs[0].Envs[0].Value = "${db.DATABASE_URL}" + reply, err := newAppPlatformRunner(api).JobStatus(t.Context(), handle) + if err != nil || reply.State != interfaces.JobStateFailed || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("immutable encrypted acknowledgement did not recover: %v", err) + } + assertAppJobHandleSecretFree(t, reply.Handle) +} + +func TestAppPlatformRunnerAmbiguousEncryptedRecoveryDoesNotWaiveDrift(t *testing.T) { + for _, change := range []string{"live-secret", "command", "image", "timeout", "env-key", "env-type", "env-scope", "parent", "duplicate", "extra-source"} { + t.Run(change, func(t *testing.T) { + api, handle := ambiguousEncryptedAppJob(t) + api.noInvocation = true + api.deploymentPhase = godo.DeploymentPhase_Error + originalApp, originalDeployment := jobClone(api.app.Spec), jobClone(api.deployment) + job := api.deployment.Spec.Jobs[0] + switch change { + case "live-secret": + api.app.Spec.Jobs[0].Envs[0].Value = "EV[changed-secret]" + case "command": + job.RunCommand = "/unrelated" + case "image": + job.Image.Digest = "sha256:" + strings.Repeat("b", 64) + case "timeout": + job.Timeout = "1s" + case "env-key": + job.Envs[0].Key = "OTHER" + case "env-type": + job.Envs[0].Type = godo.AppVariableType_General + case "env-scope": + job.Envs[0].Scope = godo.AppVariableScope_BuildTime + case "parent": + api.app.Spec.Services[0].RunCommand = "/new-server" + case "duplicate": + api.deployment.Spec.Jobs = append(api.deployment.Spec.Jobs, jobClone(job)) + case "extra-source": + job.GitHub = &godo.GitHubSourceSpec{Repo: "other/source"} + } + _, statusErr := newAppPlatformRunner(api).JobStatus(t.Context(), handle) + cancelErr := newAppPlatformRunner(api).CancelJob(t.Context(), handle) + if api.updates != 1 || api.cancels != 0 || api.deploymentCancels != 0 || len(api.app.Spec.Jobs) != 1 { + t.Fatal("ambiguous recovery waived ownership or drift checks") + } + for _, err := range []error{statusErr, cancelErr} { + if err != nil && strings.Contains(err.Error(), "EV[") { + t.Fatal("recovery error exposed encrypted secret bytes") + } + } + api.app.Spec, api.deployment = originalApp, originalDeployment + api.app.InProgressDeployment = jobClone(originalDeployment) + reply, err := newAppPlatformRunner(api).JobStatus(t.Context(), jobClone(handle)) + if err != nil || reply.State != interfaces.JobStateFailed || len(api.app.Spec.Jobs) != 0 { + t.Fatalf("restored unchanged intent did not converge: %v", err) + } + }) + } +} + +func TestAppPlatformRunnerLegacyOpaqueHandleRetainsExactBinding(t *testing.T) { + api := newAppJobAPIFake() + api.noInvocation = true + _, handle := launchAppJob(t, api) + var metadata map[string]any + if err := json.Unmarshal([]byte(handle.Metadata["app_platform_job"]), &metadata); err != nil { + t.Fatal(err) + } + delete(metadata, "intent_digest") + delete(metadata, "component_acknowledged") + data, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + handle.Metadata["app_platform_job"] = string(data) + if err := newAppPlatformRunner(api).CancelJob(t.Context(), jobClone(handle)); err != nil { + t.Fatal(err) + } + if api.deploymentCancels != 1 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("legacy acknowledged handle did not clean up its exact deployment") + } +} diff --git a/internal/drivers/database.go b/internal/drivers/database.go index 5e6ad44..3d8e131 100644 --- a/internal/drivers/database.go +++ b/internal/drivers/database.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "net/url" "sort" "strings" @@ -59,6 +60,8 @@ type DatabaseDriver struct { pendingFirewallUpdates []deferredFirewallUpdate } +var _ interfaces.ResourceStateUpdater = (*DatabaseDriver)(nil) + // NewDatabaseDriver creates a DatabaseDriver backed by a real godo client. // The godo Apps client is wired automatically to support type=app trusted_source // name → UUID resolution at apply time. @@ -81,6 +84,10 @@ func NewDatabaseDriverWithClients(c DatabaseClient, apps appNameLister, region s } func (d *DatabaseDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + epoch, epochPresent, err := databaseEpochFromConfig(spec.Config, "admin_rotation_epoch") + if err != nil { + return nil, err + } engine := strFromConfig(spec.Config, "engine", "pg") version := strFromConfig(spec.Config, "version", "") size := strFromConfig(spec.Config, "size", "db-s-1vcpu-1gb") @@ -116,6 +123,9 @@ func (d *DatabaseDriver) Create(ctx context.Context, spec interfaces.ResourceSpe db, _, err := d.client.Create(ctx, req) if err != nil { + if epochPresent { + return nil, databaseAPIError("database create", err) + } return nil, fmt.Errorf("database create %q: %w", spec.Name, WrapGodoError(err)) } if db == nil || db.ID == "" { @@ -129,6 +139,13 @@ func (d *DatabaseDriver) Create(ctx context.Context, spec interfaces.ResourceSpe }) } + if epochPresent { + out, err := databaseAdminOutput(db, db.Connection, epoch) + if err != nil { + return nil, err + } + return out, nil + } return dbOutput(db), nil } @@ -156,9 +173,12 @@ func (d *DatabaseDriver) Read(ctx context.Context, ref interfaces.ResourceRef) ( } db, _, err := d.client.Get(ctx, ref.ProviderID) if err != nil { - return nil, fmt.Errorf("database read %q: %w", ref.Name, WrapGodoError(err)) + return nil, databaseAPIError("database read", err) } - return dbOutput(db), nil + if db == nil { + return nil, databaseValidationError("database API returned incomplete database") + } + return dbReadOutput(db), nil } // findDatabaseByName iterates the paginated database list and returns the first @@ -169,16 +189,16 @@ func (d *DatabaseDriver) findDatabaseByName(ctx context.Context, name string) (* return nil, err } if db.ID == "" { - return dbOutput(db), nil + return dbReadOutput(db), nil } hydrated, _, err := d.client.Get(ctx, db.ID) if err != nil { - return nil, fmt.Errorf("database read %q after name lookup: %w", name, WrapGodoError(err)) + return nil, databaseAPIError("database read after name lookup", err) } if hydrated == nil { - return dbOutput(db), nil + return dbReadOutput(db), nil } - return dbOutput(hydrated), nil + return dbReadOutput(hydrated), nil } func (d *DatabaseDriver) lookupDatabaseByName(ctx context.Context, name string) (*godo.Database, error) { @@ -219,10 +239,53 @@ func (d *DatabaseDriver) resolveProviderID(ctx context.Context, ref interfaces.R } func (d *DatabaseDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return d.UpdateWithState(ctx, ref, spec, nil) +} + +func (d *DatabaseDriver) UpdateWithState(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) { + epoch, epochPresent, err := databaseEpochFromConfig(spec.Config, "admin_rotation_epoch") + if err != nil { + return nil, err + } + if prior != nil || epochPresent { + if err := interfaces.ValidateUpdatePriorState(ref, spec, prior); err != nil { + return nil, err + } + } + if epochPresent && !canonicalDatabaseID(ref.ProviderID) { + return nil, databaseValidationError("database rotation requires canonical database UUID") + } providerID, err := d.resolveProviderID(ctx, ref) if err != nil { return nil, err } + var rotate bool + var rotationDB *godo.Database + var resetter databaseAuthResetter + if epochPresent { + previous, _, err := databaseEpochFromConfig(prior.Outputs, "admin_rotation_epoch") + if err != nil { + return nil, err + } + rotate = previous != epoch + if rotate { + var ok bool + resetter, ok = d.client.(databaseAuthResetter) + if !ok { + return nil, databaseValidationError("database client does not support admin auth reset") + } + rotationDB, err = readDatabaseParent(ctx, d.client, providerID) + if err != nil { + return nil, err + } + if rotationDB.Connection == nil || !validDatabaseUsername(rotationDB.Connection.User) { + return nil, databaseValidationError("database admin connection is incomplete") + } + if _, err := databaseTLSURI(rotationDB, rotationDB.Connection.User, "preflight"); err != nil { + return nil, err + } + } + } // Resolve firewall rules BEFORE mutating the database. buildUpdateFirewallRules // can fail for config errors (wrong type) or app name→UUID resolution failures, // so validating upfront avoids partial applies caused by those preflight issues. @@ -251,6 +314,9 @@ func (d *DatabaseDriver) Update(ctx context.Context, ref interfaces.ResourceRef, NumNodes: numNodes, }) if err != nil { + if epochPresent { + return nil, databaseAPIError("database update", err) + } return nil, fmt.Errorf("database update %q: %w", ref.Name, WrapGodoError(err)) } // Sync firewall rules when trusted_sources key is present in config. @@ -260,6 +326,9 @@ func (d *DatabaseDriver) Update(ctx context.Context, ref interfaces.ResourceRef, Rules: fwRules, }) if err != nil { + if epochPresent { + return nil, databaseAPIError("database update firewall", err) + } return nil, fmt.Errorf("database update firewall %q: %w", ref.Name, WrapGodoError(err)) } } @@ -270,7 +339,41 @@ func (d *DatabaseDriver) Update(ctx context.Context, ref interfaces.ResourceRef, }) } ref.ProviderID = providerID // pass healed ID to Read - return d.Read(ctx, ref) + if rotate { + username := rotationDB.Connection.User + rotationDB, err = readDatabaseParent(ctx, d.client, providerID) + if err != nil { + return nil, err + } + if rotationDB.Connection == nil || rotationDB.Connection.User != username { + return nil, databaseValidationError("database admin identity changed during update") + } + if _, err := databaseTLSURI(rotationDB, username, "preflight"); err != nil { + return nil, err + } + user, _, err := resetter.ResetUserAuth(ctx, providerID, url.PathEscape(username), &godo.DatabaseResetUserAuthRequest{}) + if err != nil { + return nil, databaseAPIError("database admin reset auth", err) + } + if err := validateDatabaseUser(user, username); err != nil { + return nil, err + } + connection := *rotationDB.Connection + connection.Password = user.Password + out, err := databaseAdminOutput(rotationDB, &connection, epoch) + if err != nil { + return nil, err + } + return out, nil + } + out, err := d.Read(ctx, ref) + if err == nil { + if epochPresent { + out.Outputs["admin_rotation_epoch"] = epoch + } + preserveDatabaseState(out, prior, "admin_rotation_epoch") + } + return out, err } func (d *DatabaseDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) error { @@ -286,11 +389,27 @@ func (d *DatabaseDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) } func (d *DatabaseDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + epoch, epochPresent, err := databaseEpochFromConfig(desired.Config, "admin_rotation_epoch") + if err != nil { + return nil, err + } if current == nil { return &interfaces.DiffResult{NeedsUpdate: true}, nil } var changes []interfaces.FieldChange var needsReplace bool + if epochPresent { + if !canonicalDatabaseID(current.ProviderID) { + return nil, databaseValidationError("database rotation requires canonical database UUID state") + } + previous, _, err := databaseEpochFromConfig(current.Outputs, "admin_rotation_epoch") + if err != nil { + return nil, err + } + if previous != epoch { + changes = append(changes, interfaces.FieldChange{Path: "admin_rotation_epoch", Old: previous, New: epoch}) + } + } for _, field := range []struct { key string def string @@ -365,6 +484,42 @@ func (d *DatabaseDriver) SensitiveKeys() []string { return []string{"uri", "password", "user"} } +func (d *DatabaseDriver) SensitiveInputPaths(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []string{"/password", "/uri"}, nil +} + +// Optional capability keeps the existing DatabaseClient and old mocks compatible. +type databaseAuthResetter interface { + ResetUserAuth(context.Context, string, string, *godo.DatabaseResetUserAuthRequest) (*godo.DatabaseUser, *godo.Response, error) +} + +func dbReadOutput(db *godo.Database) *interfaces.ResourceOutput { + out := dbOutput(db) + for _, key := range []string{"uri", "password", "user"} { + delete(out.Outputs, key) + } + return out +} + +func databaseAdminOutput(db *godo.Database, connection *godo.DatabaseConnection, epoch string) (*interfaces.ResourceOutput, error) { + if connection == nil || !validDatabaseUsername(connection.User) { + return nil, databaseValidationError("database admin connection is incomplete") + } + snapshot := *db + snapshot.Connection = connection + uri, err := databaseTLSURI(&snapshot, connection.User, connection.Password) + if err != nil { + return nil, err + } + out := dbOutput(&snapshot) + out.Outputs["uri"], out.Outputs["password"] = uri, connection.Password + out.Outputs["admin_rotation_epoch"] = epoch + return out, nil +} + // resolveAppNamesMap builds a name→UUID map for all non-UUID app names found // in the raw trusted_sources list. A single paginated listing pass is made // (potentially multiple page requests) regardless of how many type=app rules diff --git a/internal/drivers/database_test.go b/internal/drivers/database_test.go index b5d5254..4886474 100644 --- a/internal/drivers/database_test.go +++ b/internal/drivers/database_test.go @@ -2,8 +2,12 @@ package drivers_test import ( "context" + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" + "net/url" "strings" "testing" @@ -12,6 +16,168 @@ import ( "github.com/digitalocean/godo" ) +func TestDatabaseDriver_AdminRotationEpoch(t *testing.T) { + const id = "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30" + const password = "ADMIN_SECRET:/?#@%+" + resets, resizes := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v2/databases/"+id: + _ = json.NewEncoder(w).Encode(map[string]any{"database": map[string]any{ + "id": id, "name": "db", "engine": "pg", "status": "online", "size": "db-s-1vcpu-1gb", + "connection": map[string]any{"host": "db.example.test", "port": 25060, "database": "defaultdb", "user": "doadmin", "password": password, "uri": "postgres://" + password, "ssl": true}, + }}) + case r.Method == http.MethodPut && r.URL.Path == "/v2/databases/"+id+"/resize": + resizes++ + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/v2/databases/"+id+"/users/doadmin/reset_auth": + resets++ + _ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"name": "doadmin", "password": password}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + d := drivers.NewDatabaseDriver(godoClientForTest(t, srv), "nyc3") + spec := interfaces.ResourceSpec{Name: "db", Type: "infra.database", Config: map[string]any{"admin_rotation_epoch": "2", "size": "db-s-1vcpu-1gb"}} + current := &interfaces.ResourceOutput{Name: "db", ProviderID: id, Outputs: map[string]any{"admin_rotation_epoch": "1", "size": "db-s-1vcpu-1gb"}} + diff, err := d.Diff(t.Context(), spec, current) + if err != nil || !diff.NeedsUpdate { + t.Fatalf("epoch diff = %v, %v", diff, err) + } + ref := interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: id} + out, err := d.UpdateWithState(t.Context(), ref, spec, databaseOutputState(ref, current)) + if err != nil { + t.Fatal(err) + } + if resets != 1 || out.Outputs["admin_rotation_epoch"] != "2" { + t.Fatalf("rotation metadata/reset count incorrect: %d", resets) + } + if out.Outputs["password"] != password || !out.Sensitive["password"] || !out.Sensitive["uri"] { + t.Fatal("rotation credentials not routed") + } + u, err := url.Parse(out.Outputs["uri"].(string)) + if err != nil { + t.Fatal("invalid admin URI") + } + got, _ := u.User.Password() + if got != password || u.User.Username() != "doadmin" || u.Query().Get("sslmode") != "require" { + t.Fatal("admin URI lost escaping or TLS") + } + if _, err := d.Diff(t.Context(), spec, out); err != nil { + t.Fatal(err) + } + if _, err := d.UpdateWithState(t.Context(), ref, spec, databaseOutputState(ref, out)); err != nil { + t.Fatal(err) + } + if resets != 1 { + t.Fatalf("same epoch reset again: %d", resets) + } + fresh := drivers.NewDatabaseDriver(godoClientForTest(t, srv), "nyc3") + diff, err = fresh.Diff(t.Context(), spec, out) + if err != nil || diff.NeedsUpdate { + t.Fatalf("persisted epoch diff = %v, %v", diff, err) + } + read, err := fresh.Read(t.Context(), interfaces.ResourceRef{Name: "db", ProviderID: id}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"uri", "password", "admin_rotation_epoch"} { + if _, ok := read.Outputs[key]; ok { + t.Fatalf("refresh overwrote routed/local field %s", key) + } + } + if resizes != 2 { + t.Fatalf("unexpected resize count %d", resizes) + } +} + +func TestDatabaseDriver_AdminRotationRequiresPriorEpochAndCapability(t *testing.T) { + d := drivers.NewDatabaseDriverWithClient(&mockDatabaseClient{db: testDatabase()}, "nyc3") + spec := interfaces.ResourceSpec{Name: "db", Config: map[string]any{"admin_rotation_epoch": "2"}} + if _, err := d.Update(t.Context(), interfaces.ResourceRef{Name: "db", ProviderID: "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30"}, spec); err == nil { + t.Fatal("rotation without prior epoch/capability accepted") + } + for _, value := range []any{"", 2, true, []any{"KNOWN_SECRET"}} { + spec.Config["admin_rotation_epoch"] = value + _, err := d.Diff(t.Context(), spec, nil) + if err == nil || strings.Contains(err.Error(), "KNOWN_SECRET") { + t.Fatal("invalid epoch accepted or disclosed") + } + } +} + +func TestDatabaseDriver_AdminRotationSafeAPIDiagnostics(t *testing.T) { + for _, operation := range []string{"cluster_create", "parent", "resize", "admin_reset"} { + t.Run(operation, func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + f.status[operation] = http.StatusForbidden + d := drivers.NewDatabaseDriver(client, "nyc3") + spec := interfaces.ResourceSpec{Name: "db", Type: "infra.database", Config: map[string]any{"admin_rotation_epoch": "2"}} + var err error + if operation == "cluster_create" { + _, err = d.Create(t.Context(), spec) + } else { + _, err = d.Diff(t.Context(), spec, &interfaces.ResourceOutput{ProviderID: databaseUserParent, Outputs: map[string]any{"admin_rotation_epoch": "1"}}) + if err != nil { + t.Fatal(err) + } + ref := interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + _, err = d.UpdateWithState(t.Context(), ref, spec, databasePriorState(ref, "admin_rotation_epoch", "1")) + } + if !errors.Is(err, interfaces.ErrForbidden) || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatalf("unsafe API diagnostics or lost category: %v", err) + } + var cause *godo.ErrorResponse + if !errors.As(err, &cause) { + t.Fatal("API cause lost") + } + }) + } +} + +func TestDatabaseDriver_AdminRotationCreateAndLegacyNoReset(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseDriver(client, "nyc3") + spec := interfaces.ResourceSpec{Name: "db", Config: map[string]any{"admin_rotation_epoch": "initial"}} + out, err := d.Create(t.Context(), spec) + if err != nil { + t.Fatal(err) + } + if out.Outputs["admin_rotation_epoch"] != "initial" || out.Outputs["password"] != databaseUserSecret { + t.Fatal("initial epoch/credentials missing") + } + if f.count("admin_reset") != 0 { + t.Fatal("new cluster unnecessarily reset admin") + } + spec.Config = map[string]any{} + if _, err := d.Update(t.Context(), interfaces.ResourceRef{ProviderID: databaseUserParent}, spec); err != nil { + t.Fatal(err) + } + if f.count("admin_reset") != 0 { + t.Fatal("legacy update reset admin without epoch") + } +} + +func TestDatabaseDriver_AdminRotationReturnsPostResizeMetadata(t *testing.T) { + _, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseDriver(client, "nyc3") + spec := interfaces.ResourceSpec{Name: "db", Type: "infra.database", Config: map[string]any{"admin_rotation_epoch": "2", "size": "db-s-2vcpu-4gb"}} + current := &interfaces.ResourceOutput{ProviderID: databaseUserParent, Outputs: map[string]any{"admin_rotation_epoch": "1", "size": "db-s-1vcpu-1gb"}} + if _, err := d.Diff(t.Context(), spec, current); err != nil { + t.Fatal(err) + } + ref := interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + out, err := d.UpdateWithState(t.Context(), ref, spec, databaseOutputState(ref, current)) + if err != nil { + t.Fatal(err) + } + if out.Outputs["size"] != "db-s-2vcpu-4gb" { + t.Fatal("rotation persisted stale pre-resize metadata") + } +} + type mockDatabaseClient struct { db *godo.Database listDB *godo.Database @@ -480,8 +646,11 @@ func TestDatabaseDriver_Read_NameBasedHydratesConnectionOutputs(t *testing.T) { if mock.getCalls != 1 { t.Fatalf("Get calls = %d, want 1 to hydrate connection outputs", mock.getCalls) } - if uri, _ := out.Outputs["uri"].(string); uri == "" { - t.Fatalf("uri output missing after adoption/name-based read: %#v", out.Outputs) + if host, _ := out.Outputs["host"].(string); host == "" { + t.Fatal("host output missing after adoption/name-based read") + } + if _, ok := out.Outputs["uri"]; ok { + t.Fatal("refresh must preserve routed URI by omitting credential output") } } diff --git a/internal/drivers/database_user.go b/internal/drivers/database_user.go new file mode 100644 index 0000000..c5ab657 --- /dev/null +++ b/internal/drivers/database_user.go @@ -0,0 +1,400 @@ +package drivers + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" + "github.com/google/uuid" +) + +// DatabaseUserClient is the parent-bound subset of godo.DatabasesService used +// by database users. User path arguments are escaped before SDK dispatch. +type DatabaseUserClient interface { + Get(context.Context, string) (*godo.Database, *godo.Response, error) + CreateUser(context.Context, string, *godo.DatabaseCreateUserRequest) (*godo.DatabaseUser, *godo.Response, error) + GetUser(context.Context, string, string) (*godo.DatabaseUser, *godo.Response, error) + ResetUserAuth(context.Context, string, string, *godo.DatabaseResetUserAuthRequest) (*godo.DatabaseUser, *godo.Response, error) + DeleteUser(context.Context, string, string) (*godo.Response, error) +} + +type DatabaseUserDriver struct { + client DatabaseUserClient +} + +var _ interfaces.ResourceDriver = (*DatabaseUserDriver)(nil) +var _ interfaces.ResourceSensitiveInputDeclarer = (*DatabaseUserDriver)(nil) +var _ interfaces.ResourceStateUpdater = (*DatabaseUserDriver)(nil) + +func NewDatabaseUserDriver(c *godo.Client) *DatabaseUserDriver { + return NewDatabaseUserDriverWithClient(c.Databases) +} + +func NewDatabaseUserDriverWithClient(c DatabaseUserClient) *DatabaseUserDriver { + return &DatabaseUserDriver{client: c} +} + +func (d *DatabaseUserDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + cfg, err := parseDatabaseUserConfig(spec.Config) + if err != nil { + return nil, err + } + db, err := readDatabaseParent(ctx, d.client, cfg.databaseID) + if err != nil { + return nil, err + } + if _, err := databaseTLSURI(db, cfg.username, "preflight"); err != nil { + return nil, err + } + user, _, err := d.client.CreateUser(ctx, cfg.databaseID, &godo.DatabaseCreateUserRequest{Name: cfg.username}) + if err != nil { + return nil, databaseAPIError("database user create", err) + } + out, err := databaseUserOutput(spec.Name, cfg.databaseID, cfg.username, db, user, true) + if err != nil { + return nil, err + } + if cfg.epochPresent { + out.Outputs["rotation_epoch"] = cfg.epoch + } + return out, nil +} + +func (d *DatabaseUserDriver) Read(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + id, username, err := parseDatabaseUserProviderID(ref.ProviderID) + if err != nil { + return nil, err + } + db, err := readDatabaseParent(ctx, d.client, id) + if err != nil { + return nil, err + } + user, _, err := d.client.GetUser(ctx, id, url.PathEscape(username)) + if err != nil { + return nil, databaseAPIError("database user read", err) + } + // Omitting credentials and epoch preserves Workflow's routed references + // and provider-local epoch metadata during refresh, even after a restart. + return databaseUserOutput(ref.Name, id, username, db, user, false) +} + +func (d *DatabaseUserDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return d.UpdateWithState(ctx, ref, spec, nil) +} + +func (d *DatabaseUserDriver) UpdateWithState(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) { + cfg, err := parseDatabaseUserConfig(spec.Config) + if err != nil { + return nil, err + } + if prior != nil || cfg.epochPresent { + if err := interfaces.ValidateUpdatePriorState(ref, spec, prior); err != nil { + return nil, err + } + } + id, username, err := parseDatabaseUserProviderID(ref.ProviderID) + if err != nil { + return nil, err + } + if id != cfg.databaseID || username != cfg.username { + return nil, databaseValidationError("database user update requires unchanged parent and username") + } + if !cfg.epochPresent { + out, err := d.Read(ctx, ref) + if err == nil { + preserveDatabaseState(out, prior, "rotation_epoch") + } + return out, err + } + previous, _, err := databaseEpochFromConfig(prior.Outputs, "rotation_epoch") + if err != nil { + return nil, err + } + if previous == cfg.epoch { + out, err := d.Read(ctx, ref) + if err == nil { + out.Outputs["rotation_epoch"] = cfg.epoch + preserveDatabaseState(out, prior, "rotation_epoch") + } + return out, err + } + db, err := readDatabaseParent(ctx, d.client, id) + if err != nil { + return nil, err + } + if _, err := databaseTLSURI(db, username, "preflight"); err != nil { + return nil, err + } + user, _, err := d.client.GetUser(ctx, id, url.PathEscape(username)) + if err != nil { + return nil, databaseAPIError("database user rotation read", err) + } + if err := validateDatabaseUser(user, username); err != nil { + return nil, err + } + user, _, err = d.client.ResetUserAuth(ctx, id, url.PathEscape(username), &godo.DatabaseResetUserAuthRequest{}) + if err != nil { + return nil, databaseAPIError("database user reset auth", err) + } + out, err := databaseUserOutput(spec.Name, id, username, db, user, true) + if err != nil { + return nil, err + } + out.Outputs["rotation_epoch"] = cfg.epoch + return out, nil +} + +func (d *DatabaseUserDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) error { + id, username, err := parseDatabaseUserProviderID(ref.ProviderID) + if err != nil { + return err + } + _, err = d.client.DeleteUser(ctx, id, url.PathEscape(username)) + if err != nil { + var response *godo.ErrorResponse + if errors.As(err, &response) && response.Response != nil && response.Response.StatusCode == http.StatusNotFound { + return nil + } + return databaseAPIError("database user delete", err) + } + return nil +} + +func (d *DatabaseUserDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + cfg, err := parseDatabaseUserConfig(desired.Config) + if err != nil { + return nil, err + } + if current == nil { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + id, username, err := parseDatabaseUserProviderID(current.ProviderID) + if err != nil { + return nil, err + } + var changes []interfaces.FieldChange + if id != cfg.databaseID { + changes = append(changes, interfaces.FieldChange{Path: "database_id", Old: id, New: cfg.databaseID, ForceNew: true}) + } + if username != cfg.username { + changes = append(changes, interfaces.FieldChange{Path: "username", Old: username, New: cfg.username, ForceNew: true}) + } + if cfg.epochPresent { + previous, _, err := databaseEpochFromConfig(current.Outputs, "rotation_epoch") + if err != nil { + return nil, err + } + if previous != cfg.epoch { + changes = append(changes, interfaces.FieldChange{Path: "rotation_epoch", Old: previous, New: cfg.epoch}) + } + } + return &interfaces.DiffResult{NeedsUpdate: len(changes) > 0, NeedsReplace: hasForceNewChange(changes), Changes: changes}, nil +} + +func (d *DatabaseUserDriver) HealthCheck(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.HealthResult, error) { + _, err := d.Read(ctx, ref) + if err != nil { + return &interfaces.HealthResult{Healthy: false, Message: err.Error()}, err + } + return &interfaces.HealthResult{Healthy: true}, nil +} + +func (d *DatabaseUserDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + return nil, errors.New("database user does not support scale") +} + +func (d *DatabaseUserDriver) SensitiveKeys() []string { return []string{"password", "uri"} } +func (d *DatabaseUserDriver) ProviderIDFormat() interfaces.ProviderIDFormat { + return interfaces.IDFormatFreeform +} +func (d *DatabaseUserDriver) SensitiveInputPaths(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []string{"/password", "/uri"}, nil +} + +type databaseUserConfig struct { + databaseID string + username string + epoch string + epochPresent bool +} + +func parseDatabaseUserConfig(config map[string]any) (databaseUserConfig, error) { + id, ok := config["database_id"].(string) + if !ok || !canonicalDatabaseID(id) { + return databaseUserConfig{}, databaseValidationError("database_id must be a canonical database UUID") + } + username, ok := config["username"].(string) + if !ok || !validDatabaseUsername(username) { + return databaseUserConfig{}, databaseValidationError("username must be a nonempty database username") + } + epoch, present, err := databaseEpochFromConfig(config, "rotation_epoch") + if err != nil { + return databaseUserConfig{}, err + } + return databaseUserConfig{databaseID: id, username: username, epoch: epoch, epochPresent: present}, nil +} + +func canonicalDatabaseID(id string) bool { + parsed, err := uuid.Parse(id) + return err == nil && parsed.String() == id +} + +func validDatabaseUsername(username string) bool { + if strings.TrimSpace(username) == "" || username == "." || username == ".." || !utf8.ValidString(username) { + return false + } + return !strings.ContainsFunc(username, unicode.IsControl) +} + +func parseDatabaseUserProviderID(providerID string) (string, string, error) { + id, encoded, ok := strings.Cut(providerID, "/") + if !ok || !canonicalDatabaseID(id) || strings.Contains(encoded, "/") { + return "", "", databaseValidationError("provider_id must be /") + } + username, err := url.PathUnescape(encoded) + if err != nil || !validDatabaseUsername(username) || url.PathEscape(username) != encoded { + return "", "", databaseValidationError("provider_id must contain a canonical escaped username") + } + return id, username, nil +} + +func validateDatabaseUser(user *godo.DatabaseUser, username string) error { + if user == nil || user.Name != username { + return databaseValidationError("database API returned incomplete or mismatched user identity") + } + return nil +} + +func readDatabaseParent(ctx context.Context, client interface { + Get(context.Context, string) (*godo.Database, *godo.Response, error) +}, id string) (*godo.Database, error) { + db, _, err := client.Get(ctx, id) + if err != nil { + return nil, databaseAPIError("database parent read", err) + } + if db == nil || db.ID != id { + return nil, databaseValidationError("database API returned incomplete or mismatched parent identity") + } + return db, nil +} + +func databaseUserOutput(name, id, username string, db *godo.Database, user *godo.DatabaseUser, credentials bool) (*interfaces.ResourceOutput, error) { + if err := validateDatabaseUser(user, username); err != nil { + return nil, err + } + outputs := map[string]any{"database_id": id, "username": username, "role": user.Role} + if db.Connection != nil { + outputs["host"] = db.Connection.Host + outputs["port"] = db.Connection.Port + outputs["database"] = db.Connection.Database + } + if credentials { + uri, err := databaseTLSURI(db, username, user.Password) + if err != nil { + return nil, err + } + outputs["uri"], outputs["password"] = uri, user.Password + } + return &interfaces.ResourceOutput{Name: name, Type: "digitalocean.database_user", ProviderID: id + "/" + url.PathEscape(username), Outputs: outputs, Sensitive: map[string]bool{"uri": true, "password": true}, Status: db.Status}, nil +} + +func databaseTLSURI(db *godo.Database, username, password string) (string, error) { + if db == nil || db.Connection == nil || password == "" { + return "", databaseValidationError("database credential response is incomplete") + } + c := db.Connection + if c.Host == "" || strings.ContainsAny(c.Host, "/?#@% \t\r\n") || (strings.Contains(c.Host, ":") && net.ParseIP(c.Host) == nil) || c.Port < 1 || c.Port > 65535 || c.Database == "" || strings.ContainsFunc(c.Database, unicode.IsControl) { + return "", databaseValidationError("database connection endpoint is invalid") + } + uri := url.URL{User: url.UserPassword(username, password), Host: net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), Path: "/" + c.Database, RawPath: "/" + url.PathEscape(c.Database)} + query := url.Values{} + switch db.EngineSlug { + case "pg": + uri.Scheme = "postgresql" + query.Set("sslmode", "require") + case "mysql": + uri.Scheme = "mysql" + query.Set("tls", "true") + default: + return "", databaseValidationError("database credential URI requires PostgreSQL or MySQL") + } + uri.RawQuery = query.Encode() + return uri.String(), nil +} + +func databaseEpochFromConfig(config map[string]any, key string) (string, bool, error) { + value, present := config[key] + if !present { + return "", false, nil + } + epoch, ok := value.(string) + if !ok || strings.TrimSpace(epoch) == "" { + return "", false, databaseValidationError(key + " must be a nonempty string") + } + return epoch, true, nil +} + +func preserveDatabaseState(out *interfaces.ResourceOutput, prior *interfaces.ResourceState, epochKey string) { + if prior == nil { + return + } + if _, present := out.Outputs[epochKey]; !present { + if epoch, ok := prior.Outputs[epochKey].(string); ok && strings.TrimSpace(epoch) != "" { + out.Outputs[epochKey] = epoch + } + } + for key, sensitiveOutput := range out.Sensitive { + if value := prior.Outputs[key]; sensitiveOutput && sensitive.IsPlaceholder(value) { + out.Outputs[key] = value + // References are already routed; marking them sensitive would replace + // stored credentials with the reference text on a no-reset update. + delete(out.Sensitive, key) + } + } +} + +type databaseOperationFailure struct { + operation string + cause error + classification error +} + +func (e *databaseOperationFailure) Error() string { + if e.classification != nil { + return e.operation + ": " + e.classification.Error() + } + return e.operation + " failed" +} + +func (e *databaseOperationFailure) Unwrap() []error { + if e.classification != nil { + return []error{e.classification, e.cause} + } + return []error{e.cause} +} + +func databaseAPIError(operation string, cause error) error { + var response *godo.ErrorResponse + var classification error + if errors.As(cause, &response) && response.Response != nil { + classification = sentinelForStatus(response.Response.StatusCode) + } + return &databaseOperationFailure{operation: operation, cause: cause, classification: classification} +} + +func databaseValidationError(message string) error { + return fmt.Errorf("%s: %w", message, interfaces.ErrValidation) +} diff --git a/internal/drivers/database_user_test.go b/internal/drivers/database_user_test.go new file mode 100644 index 0000000..22cb977 --- /dev/null +++ b/internal/drivers/database_user_test.go @@ -0,0 +1,705 @@ +package drivers_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/iac/jitsubst" + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/iac/sensitiveinputs" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" + "github.com/digitalocean/godo" +) + +const databaseUserParent = "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30" +const databaseUserSecret = "KNOWN_DATABASE_SECRET:/?#@%+" + +type databaseUserAPI struct { + mu sync.Mutex + username string + parentID string + password string + host string + size string + status map[string]int + calls map[string]int +} + +func newDatabaseUserAPI(t *testing.T, username string) (*databaseUserAPI, *godo.Client) { + t.Helper() + f := &databaseUserAPI{username: username, parentID: databaseUserParent, password: databaseUserSecret, host: "db.example.test", size: "db-s-1vcpu-1gb", status: map[string]int{}, calls: map[string]int{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + path := r.URL.EscapedPath() + base := "/v2/databases/" + databaseUserParent + operation := "" + switch { + case r.Method == http.MethodPost && path == "/v2/databases": + operation = "cluster_create" + case r.Method == http.MethodGet && path == base: + operation = "parent" + case r.Method == http.MethodPut && path == base+"/resize": + operation = "resize" + case r.Method == http.MethodPut && path == base+"/firewall": + operation = "firewall" + case r.Method == http.MethodPost && path == base+"/users/doadmin/reset_auth": + operation = "admin_reset" + case r.Method == http.MethodPost && path == base+"/users": + operation = "create" + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error("invalid create body") + } + if body["name"] != username || len(body) != 1 { + t.Error("unexpected user create payload") + } + case path == base+"/users/"+url.PathEscape(username): + switch r.Method { + case http.MethodGet: + operation = "read" + case http.MethodDelete: + operation = "delete" + } + case r.Method == http.MethodPost && path == base+"/users/"+url.PathEscape(username)+"/reset_auth": + operation = "reset" + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body) != 0 { + t.Error("unexpected reset payload") + } + } + if operation == "" { + t.Errorf("unexpected API request %s %s", r.Method, path) + w.WriteHeader(500) + return + } + f.calls[operation]++ + w.Header().Set("Content-Type", "application/json") + if status := f.status[operation]; status != 0 { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"id": "test_error", "message": databaseUserSecret}) + return + } + if operation == "delete" || operation == "resize" || operation == "firewall" { + if operation == "resize" { + var req godo.DatabaseResizeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error("invalid resize payload") + } + f.size = req.SizeSlug + } + w.WriteHeader(http.StatusNoContent) + return + } + if operation == "parent" || operation == "cluster_create" { + _ = json.NewEncoder(w).Encode(map[string]any{"database": map[string]any{ + "id": f.parentID, "name": "db", "engine": "pg", "status": "online", "size": f.size, + "connection": map[string]any{"host": f.host, "port": 25060, "database": "db/name?#", "user": "doadmin", "password": databaseUserSecret, "uri": "DO_NOT_COPY_ADMIN_URI", "ssl": true}, + }}) + return + } + name := f.username + if operation == "admin_reset" { + name = "doadmin" + } + _ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"name": name, "role": "normal", "password": f.password}}) + })) + t.Cleanup(srv.Close) + return f, godoClientForTest(t, srv) +} + +func (f *databaseUserAPI) count(operation string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls[operation] +} + +func databaseUserSpec(username string) interfaces.ResourceSpec { + return interfaces.ResourceSpec{Name: "app-user", Type: "digitalocean.database_user", Config: map[string]any{"database_id": databaseUserParent, "username": username, "rotation_epoch": "1"}} +} + +func databaseUserRef(username string) interfaces.ResourceRef { + return interfaces.ResourceRef{Name: "app-user", Type: "digitalocean.database_user", ProviderID: databaseUserParent + "/" + url.PathEscape(username)} +} + +func databasePriorState(ref interfaces.ResourceRef, epochKey, epoch string) *interfaces.ResourceState { + return &interfaces.ResourceState{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Outputs: map[string]any{ + epochKey: epoch, "password": sensitive.Placeholder(ref.Name, "password"), "uri": sensitive.Placeholder(ref.Name, "uri"), + }} +} + +func databaseOutputState(ref interfaces.ResourceRef, out *interfaces.ResourceOutput) *interfaces.ResourceState { + return &interfaces.ResourceState{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Outputs: out.Outputs} +} + +func updateDatabaseWithState(t *testing.T, driver interfaces.ResourceDriver, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) { + t.Helper() + updater, ok := driver.(interfaces.ResourceStateUpdater) + if !ok { + t.Fatal("driver does not implement public ResourceStateUpdater") + } + return updater.UpdateWithState(t.Context(), ref, spec, prior) +} + +func TestDatabaseRotation_UpdateWithStateRestartAndStaleDiff(t *testing.T) { + for _, admin := range []bool{false, true} { + for _, scenario := range []string{"restart-without-diff", "stale-diff-says-rotate", "stale-diff-says-noop"} { + t.Run(strconv.FormatBool(admin)+"/"+scenario, func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + var driver interfaces.ResourceDriver = drivers.NewDatabaseUserDriver(client) + ref, spec, key, reset := databaseUserRef("app-user"), databaseUserSpec("app-user"), "rotation_epoch", "reset" + if admin { + driver = drivers.NewDatabaseDriver(client, "nyc3") + ref = interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + key, reset = "admin_rotation_epoch", "admin_reset" + spec = interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{key: "2", "trusted_sources": []any{}}} + } + spec.Config[key] = "2" + prior := databasePriorState(ref, key, "1") + if scenario != "restart-without-diff" { + staleEpoch := "2" + if scenario == "stale-diff-says-rotate" { + staleEpoch, prior.Outputs[key] = "1", "2" + } + if _, err := driver.Diff(t.Context(), spec, &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Outputs: map[string]any{key: staleEpoch}}); err != nil { + t.Fatal(err) + } + } + before, _ := json.Marshal(prior) + out, err := updateDatabaseWithState(t, driver, ref, spec, prior) + if err != nil { + t.Fatal(err) + } + want := 1 + if prior.Outputs[key] == "2" { + want = 0 + if value, exists := out.Outputs["password"]; exists && !sensitive.IsPlaceholder(value) { + t.Fatal("no-op update re-emitted credentials") + } + } + if f.count(reset) != want || out.Outputs[key] != "2" { + t.Fatalf("dispatch ignored persisted epoch: resets=%d, want=%d", f.count(reset), want) + } + after, _ := json.Marshal(prior) + if string(before) != string(after) { + t.Fatal("update mutated authoritative prior state") + } + }) + } + } +} + +func TestDatabaseRotation_UpdateWithStateRejectsUnboundPriorBeforeMutation(t *testing.T) { + for _, admin := range []bool{false, true} { + for _, invalid := range []string{"nil", "name", "type", "provider-id", "spec-name", "spec-type", "epoch"} { + t.Run(strconv.FormatBool(admin)+"/"+invalid, func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + var driver interfaces.ResourceDriver = drivers.NewDatabaseUserDriver(client) + ref, spec, key := databaseUserRef("app-user"), databaseUserSpec("app-user"), "rotation_epoch" + if admin { + driver = drivers.NewDatabaseDriver(client, "nyc3") + ref = interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + key = "admin_rotation_epoch" + spec = interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{key: "2", "trusted_sources": []any{}}} + } + prior := databasePriorState(ref, key, "1") + switch invalid { + case "nil": + prior = nil + case "name": + prior.Name = "other" + case "type": + prior.Type = "other" + case "provider-id": + prior.ProviderID = "other" + case "spec-name": + spec.Name = "other" + case "spec-type": + spec.Type = "other" + case "epoch": + prior.Outputs[key] = []any{databaseUserSecret} + } + _, err := updateDatabaseWithState(t, driver, ref, spec, prior) + if !errors.Is(err, interfaces.ErrValidation) || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatalf("unbound prior state not rejected safely: %v", err) + } + if f.count("reset")+f.count("admin_reset")+f.count("resize")+f.count("firewall")+f.count("parent") != 0 { + t.Fatal("invalid prior state reached API") + } + }) + } + } +} + +func TestDatabaseRotation_LegacyUpdateCannotUseDiffAsAuthority(t *testing.T) { + for _, admin := range []bool{false, true} { + t.Run(strconv.FormatBool(admin), func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + var driver interfaces.ResourceDriver = drivers.NewDatabaseUserDriver(client) + ref, spec, key := databaseUserRef("app-user"), databaseUserSpec("app-user"), "rotation_epoch" + if admin { + driver = drivers.NewDatabaseDriver(client, "nyc3") + ref = interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + key = "admin_rotation_epoch" + spec = interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{key: "2", "trusted_sources": []any{}}} + } + if _, err := driver.Diff(t.Context(), spec, &interfaces.ResourceOutput{ProviderID: ref.ProviderID, Outputs: map[string]any{key: "0"}}); err != nil { + t.Fatal(err) + } + if _, err := driver.Update(t.Context(), ref, spec); !errors.Is(err, interfaces.ErrValidation) { + t.Fatalf("legacy explicit rotation used private authority: %v", err) + } + if f.count("reset")+f.count("admin_reset")+f.count("resize")+f.count("firewall") != 0 { + t.Fatal("legacy rotation mutated API") + } + delete(spec.Config, key) + if _, err := driver.Update(t.Context(), ref, spec); err != nil { + t.Fatal(err) + } + if f.count("reset")+f.count("admin_reset") != 0 { + t.Fatal("ordinary legacy update rotated credentials") + } + }) + } +} + +func TestDatabaseRotation_FailedResetPreservesEpochAndSecretRefs(t *testing.T) { + for _, admin := range []bool{false, true} { + t.Run(strconv.FormatBool(admin), func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + var driver interfaces.ResourceDriver = drivers.NewDatabaseUserDriver(client) + ref, spec, key, reset := databaseUserRef("app-user"), databaseUserSpec("app-user"), "rotation_epoch", "reset" + if admin { + driver = drivers.NewDatabaseDriver(client, "nyc3") + ref = interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + key, reset = "admin_rotation_epoch", "admin_reset" + spec = interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{key: "2"}} + } + spec.Config[key] = "2" + prior := databasePriorState(ref, key, "1") + before, _ := json.Marshal(prior) + f.status[reset] = http.StatusForbidden + out, err := updateDatabaseWithState(t, driver, ref, spec, prior) + if out != nil || !errors.Is(err, interfaces.ErrForbidden) || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatalf("failed reset exposed credentials or acknowledged epoch: %v", err) + } + after, _ := json.Marshal(prior) + if string(before) != string(after) || strings.Contains(string(after), databaseUserSecret) { + t.Fatal("failed reset changed prior epoch or secret references") + } + f.mu.Lock() + delete(f.status, reset) + f.mu.Unlock() + out, err = updateDatabaseWithState(t, driver, ref, spec, prior) + if err != nil || out.Outputs[key] != "2" || f.count(reset) != 2 { + t.Fatalf("failed reset suppressed retry: %v", err) + } + store := secrets.NewFileProvider(t.TempDir()) + routed, _, err := sensitive.Route(t.Context(), store, out.Name, out) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(routed) + if strings.Contains(string(encoded), databaseUserSecret) || !sensitive.IsPlaceholder(routed["password"]) || !sensitive.IsPlaceholder(routed["uri"]) { + t.Fatal("successful retry did not route sensitive outputs") + } + }) + } +} + +func TestDatabaseRotation_NoResetPreservesRoutedReferencesWithoutRewritingSecrets(t *testing.T) { + for _, admin := range []bool{false, true} { + for _, epochPresent := range []bool{false, true} { + t.Run(strconv.FormatBool(admin)+"/"+strconv.FormatBool(epochPresent), func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + var driver interfaces.ResourceDriver = drivers.NewDatabaseUserDriver(client) + ref, spec, key := databaseUserRef("app-user"), databaseUserSpec("app-user"), "rotation_epoch" + if admin { + driver = drivers.NewDatabaseDriver(client, "nyc3") + ref = interfaces.ResourceRef{Name: "db", Type: "infra.database", ProviderID: databaseUserParent} + key = "admin_rotation_epoch" + spec = interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{key: "1"}} + } + if !epochPresent { + delete(spec.Config, key) + } + prior := databasePriorState(ref, key, "1") + store := secrets.NewFileProvider(t.TempDir()) + for _, key := range []string{"password", "uri"} { + if err := store.Set(t.Context(), sensitive.SecretKey(ref.Name, key), databaseUserSecret); err != nil { + t.Fatal(err) + } + } + out, err := updateDatabaseWithState(t, driver, ref, spec, prior) + if err != nil { + t.Fatal(err) + } + if out.Outputs[key] != "1" || out.Outputs["password"] != prior.Outputs["password"] || out.Outputs["uri"] != prior.Outputs["uri"] { + t.Fatal("state-aware non-rotation update dropped persisted epoch or routed references") + } + routed, hydrated, err := sensitive.Route(t.Context(), store, ref.Name, out) + if err != nil || len(hydrated) != 0 || routed["password"] != prior.Outputs["password"] { + t.Fatalf("non-rotation update re-routed existing references: %v", err) + } + for _, key := range []string{"password", "uri"} { + value, err := store.Get(t.Context(), sensitive.SecretKey(ref.Name, key)) + if err != nil || value != databaseUserSecret { + t.Fatal("non-rotation update overwrote stored credentials with reference text") + } + } + if f.count("reset")+f.count("admin_reset") != 0 { + t.Fatal("non-rotation update reset credentials") + } + }) + } + } +} + +func TestDatabaseUser_CreateTLSAndEscapedIdentity(t *testing.T) { + username := "app/+?#%:@ user" + f, client := newDatabaseUserAPI(t, username) + d := drivers.NewDatabaseUserDriver(client) + out, err := d.Create(t.Context(), databaseUserSpec(username)) + if err != nil { + t.Fatal(err) + } + if out.Name != "app-user" || out.Type != "digitalocean.database_user" || out.ProviderID != databaseUserRef(username).ProviderID || d.ProviderIDFormat() != interfaces.IDFormatFreeform { + t.Fatal("incorrect parent-bound identity") + } + if out.Outputs["database_id"] != databaseUserParent || out.Outputs["username"] != username || out.Outputs["rotation_epoch"] != "1" { + t.Fatal("missing identity/epoch metadata") + } + if out.Outputs["password"] != databaseUserSecret || !out.Sensitive["uri"] || !out.Sensitive["password"] { + t.Fatal("credential routing missing") + } + u, err := url.Parse(out.Outputs["uri"].(string)) + if err != nil { + t.Fatal("URI not parseable") + } + password, _ := u.User.Password() + if password != databaseUserSecret || u.User.Username() != username || u.Host != "db.example.test:25060" || u.Path != "/db/name?#" || u.EscapedPath() != "/db%2Fname%3F%23" || u.Query().Get("sslmode") != "require" { + t.Fatal("URI escaping/TLS/credentials incorrect") + } + if f.count("parent") != 1 || f.count("create") != 1 || f.count("reset") != 0 { + t.Fatal("unexpected create API operations") + } +} + +func TestDatabaseUser_ReadOmitsCredentialsAndEpoch(t *testing.T) { + username := "app/+?#%:@ user" + _, client := newDatabaseUserAPI(t, username) + d := drivers.NewDatabaseUserDriver(client) + out, err := d.Read(t.Context(), databaseUserRef(username)) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"uri", "password", "rotation_epoch"} { + if _, ok := out.Outputs[key]; ok { + t.Fatalf("refresh overwrites persisted %s", key) + } + } + if out.ProviderID != databaseUserRef(username).ProviderID || out.Outputs["username"] != username { + t.Fatal("read identity mismatch") + } +} + +func TestDatabaseUser_ExplicitEpochRotationOnceAndReload(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + spec := databaseUserSpec("app-user") + out, err := d.Create(t.Context(), spec) + if err != nil { + t.Fatal(err) + } + spec.Config["rotation_epoch"] = "2" + diff, err := d.Diff(t.Context(), spec, out) + if err != nil || !diff.NeedsUpdate || diff.NeedsReplace { + t.Fatalf("epoch diff = %v, %v", diff, err) + } + rotated, err := d.UpdateWithState(t.Context(), databaseUserRef("app-user"), spec, databaseOutputState(databaseUserRef("app-user"), out)) + if err != nil { + t.Fatal(err) + } + if f.count("reset") != 1 || rotated.Outputs["rotation_epoch"] != "2" || rotated.Outputs["password"] != databaseUserSecret { + t.Fatal("explicit rotation missing") + } + if _, err := d.UpdateWithState(t.Context(), databaseUserRef("app-user"), spec, databaseOutputState(databaseUserRef("app-user"), rotated)); err != nil { + t.Fatal(err) + } + if f.count("reset") != 1 { + t.Fatal("same epoch reset repeatedly") + } + store := secrets.NewFileProvider(t.TempDir()) + sanitized, _, err := sensitive.Route(t.Context(), store, rotated.Name, rotated) + if err != nil { + t.Fatal(err) + } + snapshot := *rotated + snapshot.Outputs = sanitized + encoded, err := json.Marshal(&snapshot) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), databaseUserSecret) { + t.Fatal("routed state contains known secret") + } + var restored interfaces.ResourceOutput + if err := json.Unmarshal(encoded, &restored); err != nil { + t.Fatal(err) + } + fresh := drivers.NewDatabaseUserDriver(client) + diff, err = fresh.Diff(t.Context(), spec, &restored) + if err != nil || diff.NeedsUpdate { + t.Fatalf("persisted epoch not respected: %v, %v", diff, err) + } + noRotation, err := fresh.UpdateWithState(t.Context(), databaseUserRef("app-user"), spec, databaseOutputState(databaseUserRef("app-user"), &restored)) + if err != nil { + t.Fatal(err) + } + if f.count("reset") != 1 { + t.Fatal("restart repeated rotation") + } + if value, ok := noRotation.Outputs["password"]; ok && !sensitive.IsPlaceholder(value) { + t.Fatal("no-op re-emitted password") + } + key := sensitive.SecretKey(rotated.Name, "password") + if err := store.Delete(t.Context(), key); err != nil { + t.Fatal(err) + } + consumer := interfaces.ResourceSpec{Config: map[string]any{"password": restored.Outputs["password"]}} + lookup := func(key string) (string, error) { return store.Get(t.Context(), key) } + _, err = jitsubst.ResolveSpecWithSecretLookup(consumer, nil, nil, nil, lookup) + if !errors.Is(err, secrets.ErrNotFound) || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatal("missing routed credential did not fail closed") + } + if f.count("reset") != 1 { + t.Fatal("missing routed credential caused implicit rotation") + } + spec.Config["rotation_epoch"] = "3" + if _, err := fresh.Diff(t.Context(), spec, &restored); err != nil { + t.Fatal(err) + } + recovered, err := fresh.UpdateWithState(t.Context(), databaseUserRef("app-user"), spec, databaseOutputState(databaseUserRef("app-user"), &restored)) + if err != nil { + t.Fatal(err) + } + if _, _, err := sensitive.Route(t.Context(), store, recovered.Name, recovered); err != nil { + t.Fatal(err) + } + if _, err := jitsubst.ResolveSpecWithSecretLookup(consumer, nil, nil, nil, lookup); err != nil { + t.Fatal(err) + } + if f.count("reset") != 2 { + t.Fatal("explicit recovery epoch did not rotate once") + } +} + +func TestDatabaseUser_CancellationAndFailedResetDoNotAcknowledgeEpoch(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := d.Create(ctx, databaseUserSpec("app-user")); !errors.Is(err, context.Canceled) { + t.Fatal("cancellation cause lost") + } + if f.count("create") != 0 { + t.Fatal("canceled operation mutated user") + } + current := &interfaces.ResourceOutput{ProviderID: databaseUserRef("app-user").ProviderID, Outputs: map[string]any{"rotation_epoch": "0"}} + if _, err := d.Diff(t.Context(), databaseUserSpec("app-user"), current); err != nil { + t.Fatal(err) + } + f.password = "" + if _, err := d.UpdateWithState(t.Context(), databaseUserRef("app-user"), databaseUserSpec("app-user"), databaseOutputState(databaseUserRef("app-user"), current)); err == nil { + t.Fatal("incomplete reset acknowledged epoch") + } + f.mu.Lock() + f.password = databaseUserSecret + f.mu.Unlock() + if _, err := d.UpdateWithState(t.Context(), databaseUserRef("app-user"), databaseUserSpec("app-user"), databaseOutputState(databaseUserRef("app-user"), current)); err != nil { + t.Fatal(err) + } + if f.count("reset") != 2 { + t.Fatal("incomplete reset falsely acknowledged epoch") + } +} + +func TestDatabaseUser_MalformedConfigAndIdentityBeforeAPI(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + for _, change := range []struct { + key string + value any + }{ + {"database_id", ""}, {"database_id", "not-uuid"}, {"database_id", "9zz10173-e9ea-4176-9dbc-a4cee4c4ff30"}, {"database_id", 1}, + {"username", ""}, {"username", true}, {"username", "\x00"}, {"rotation_epoch", ""}, {"rotation_epoch", 2}, + } { + spec := databaseUserSpec("app-user") + spec.Config[change.key] = change.value + if _, err := d.Create(t.Context(), spec); err == nil { + t.Fatalf("accepted malformed %s", change.key) + } + } + for _, id := range []string{"", databaseUserParent, databaseUserParent + "/", "bad/user", databaseUserParent + "/user/extra", databaseUserParent + "/%ZZ", databaseUserParent + "/%61pp-user", databaseUserParent + "/a%2fb", databaseUserParent + "/%00"} { + if _, err := d.Read(t.Context(), interfaces.ResourceRef{ProviderID: id}); err == nil { + t.Fatal("accepted malformed provider ID") + } + } + if f.count("parent") != 0 || f.count("create") != 0 || f.count("read") != 0 { + t.Fatal("malformed input reached API") + } +} + +func TestDatabaseUser_IdentityMismatchStopsMutation(t *testing.T) { + for _, mismatch := range []string{"parent", "user", "desired"} { + t.Run(mismatch, func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + spec := databaseUserSpec("app-user") + current := &interfaces.ResourceOutput{ProviderID: databaseUserRef("app-user").ProviderID, Outputs: map[string]any{"rotation_epoch": "0"}} + if _, err := d.Diff(t.Context(), spec, current); err != nil { + t.Fatal(err) + } + switch mismatch { + case "parent": + f.parentID = "bbd10173-e9ea-4176-9dbc-a4cee4c4ff30" + case "user": + f.username = "other" + case "desired": + spec.Config["database_id"] = "bbd10173-e9ea-4176-9dbc-a4cee4c4ff30" + } + if _, err := d.UpdateWithState(t.Context(), databaseUserRef("app-user"), spec, databaseOutputState(databaseUserRef("app-user"), current)); err == nil { + t.Fatal("identity mismatch accepted") + } + if f.count("reset") != 0 { + t.Fatal("identity mismatch mutated user") + } + }) + } +} + +func TestDatabaseUser_APIErrorClassificationAndSafeDiagnostics(t *testing.T) { + for _, operation := range []string{"parent", "create", "read", "reset", "delete"} { + for _, tc := range []struct { + status int + sentinel error + }{{404, interfaces.ErrResourceNotFound}, {409, interfaces.ErrResourceAlreadyExists}, {403, interfaces.ErrForbidden}, {429, interfaces.ErrRateLimited}, {500, interfaces.ErrTransient}} { + t.Run(operation+"/"+http.StatusText(tc.status), func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + f.status[operation] = tc.status + d := drivers.NewDatabaseUserDriver(client) + spec, ref := databaseUserSpec("app-user"), databaseUserRef("app-user") + var err error + switch operation { + case "parent", "create": + _, err = d.Create(t.Context(), spec) + case "read": + _, err = d.Read(t.Context(), ref) + case "reset": + _, err = d.Diff(t.Context(), spec, &interfaces.ResourceOutput{ProviderID: ref.ProviderID, Outputs: map[string]any{}}) + if err != nil { + t.Fatal(err) + } + _, err = d.UpdateWithState(t.Context(), ref, spec, &interfaces.ResourceState{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Outputs: map[string]any{}}) + case "delete": + err = d.Delete(t.Context(), ref) + } + if operation == "delete" && tc.status == 404 { + if err != nil { + t.Fatal("delete 404 not idempotent") + } + return + } + if !errors.Is(err, tc.sentinel) { + t.Fatalf("lost API classification: %v", err) + } + if strings.Contains(err.Error(), databaseUserSecret) { + t.Fatal("error leaked API source bytes") + } + var cause *godo.ErrorResponse + if !errors.As(err, &cause) { + t.Fatal("API cause lost") + } + }) + } + } +} + +func TestDatabaseUser_MissingCredentialOrInvalidHostFailsSafely(t *testing.T) { + for _, invalid := range []string{"password", "host"} { + t.Run(invalid, func(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + if invalid == "password" { + f.password = "" + } else { + f.host = "https://" + databaseUserSecret + } + _, err := drivers.NewDatabaseUserDriver(client).Create(t.Context(), databaseUserSpec("app-user")) + if err == nil || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatal("incomplete credential/endpoint accepted or disclosed") + } + if invalid == "host" && f.count("create") != 0 { + t.Fatal("invalid endpoint passed mutation preflight") + } + }) + } +} + +func TestDatabaseUser_DeleteIdempotentAndUnseededRotationFails(t *testing.T) { + f, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + if _, err := d.Update(t.Context(), databaseUserRef("app-user"), databaseUserSpec("app-user")); err == nil { + t.Fatal("unseeded epoch rotation accepted") + } + if f.count("reset") != 0 { + t.Fatal("unseeded rotation reached API") + } + if err := d.Delete(t.Context(), databaseUserRef("app-user")); err != nil { + t.Fatal(err) + } + f.mu.Lock() + f.status["delete"] = 404 + f.mu.Unlock() + if err := d.Delete(t.Context(), databaseUserRef("app-user")); err != nil { + t.Fatal(err) + } + if f.count("delete") != 2 { + t.Fatal("delete was not parent-bound") + } +} + +func TestDatabaseUser_DiffIdentityReplacementAndSensitiveInputs(t *testing.T) { + _, client := newDatabaseUserAPI(t, "app-user") + d := drivers.NewDatabaseUserDriver(client) + current := &interfaces.ResourceOutput{ProviderID: databaseUserRef("app-user").ProviderID, Outputs: map[string]any{"rotation_epoch": "1"}} + spec := databaseUserSpec("other") + diff, err := d.Diff(t.Context(), spec, current) + if err != nil || !diff.NeedsReplace { + t.Fatalf("identity rename did not replace: %v, %v", diff, err) + } + for _, driver := range []interfaces.ResourceSensitiveInputDeclarer{d, drivers.NewDatabaseDriver(client, "nyc3")} { + paths, err := driver.SensitiveInputPaths(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"password", "uri"} { + err := sensitiveinputs.ValidateReferences(map[string]any{key: databaseUserSecret}, paths) + if err == nil || strings.Contains(err.Error(), databaseUserSecret) { + t.Fatal("literal credential accepted or disclosed") + } + if err := sensitiveinputs.ValidateReferences(map[string]any{key: "secret_ref://named/credential"}, paths); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/internal/drivers/droplet.go b/internal/drivers/droplet.go index 93200c6..4869e49 100644 --- a/internal/drivers/droplet.go +++ b/internal/drivers/droplet.go @@ -33,6 +33,7 @@ type DropletDriver struct { storage StorageClient // optional; required only when spec.Config["volumes"] is non-empty storageActions StorageActionsClient // required for Replace; nil-safe in non-Replace paths actions ActionsClient // required for Replace; nil-safe in non-Replace paths + sizes DropletSizesClient // required only for explicit monthly USD caps region string // test-only timeout overrides (zero = use production defaults) replaceTimeout time.Duration @@ -48,6 +49,7 @@ func NewDropletDriver(c *godo.Client, region string) *DropletDriver { storage: c.Storage, storageActions: c.StorageActions, actions: c.Actions, + sizes: c.Sizes, region: region, } } @@ -59,6 +61,7 @@ func NewDropletDriver(c *godo.Client, region string) *DropletDriver { // // Existing tests that pass (DropletsClient, region) continue to compile unchanged. // Tests that exercise Replace must additionally pass StorageActionsClient and ActionsClient. +// Capped specs additionally require a DropletSizesClient. func NewDropletDriverWithClient(c DropletsClient, region string, optional ...interface{}) *DropletDriver { d := &DropletDriver{client: c, region: region} for _, o := range optional { @@ -78,6 +81,8 @@ func NewDropletDriverWithClient(c DropletsClient, region string, optional ...int d.storageActions = v case ActionsClient: d.actions = v + case DropletSizesClient: + d.sizes = v } } return d @@ -112,6 +117,10 @@ func (d *DropletDriver) Create(ctx context.Context, spec interfaces.ResourceSpec func (d *DropletDriver) createWithResolvedVolumes( ctx context.Context, spec interfaces.ResourceSpec, resolvedIDs []string, ) (*interfaces.ResourceOutput, error) { + quote, err := QuoteDropletPrice(ctx, d.sizes, spec) + if err != nil { + return nil, err + } size := strFromConfig(spec.Config, "size", "s-1vcpu-2gb") image := strFromConfig(spec.Config, "image", "ubuntu-24-04-x64") region := strFromConfig(spec.Config, "region", d.region) @@ -180,7 +189,11 @@ func (d *DropletDriver) createWithResolvedVolumes( return nil, fmt.Errorf("droplet create %q: wait ready (id=%d): %w", spec.Name, droplet.ID, waitErr) } } - return d.dropletOutput(ctx, ready), nil + out := d.dropletOutput(ctx, ready) + if quote != nil { + out.Outputs["price_quote"] = quote + } + return out, nil } // dropletReady reports whether a Droplet is considered fully provisioned @@ -286,7 +299,10 @@ func (d *DropletDriver) Delete(ctx context.Context, ref interfaces.ResourceRef) // ssh_keys. Operators must taint the Droplet manually to roll a new value // for any of these. See the inline comment near the end of this function // for the full rationale. -func (d *DropletDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { +func (d *DropletDriver) Diff(ctx context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + if _, err := QuoteDropletPrice(ctx, d.sizes, desired); err != nil { + return nil, err + } if current == nil { return &interfaces.DiffResult{NeedsUpdate: true}, nil } @@ -782,6 +798,9 @@ var _ interfaces.ResourceReplacer = (*DropletDriver)(nil) // so the workflow engine's error-prefix backstop sees the recognized // " replace " family and passes the error through unchanged. func (d *DropletDriver) Replace(ctx context.Context, oldRef interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + if _, err := QuoteDropletPrice(ctx, d.sizes, spec); err != nil { + return nil, fmt.Errorf("droplet replace: pricing preflight: %w", err) + } oldID, err := providerIDToInt(oldRef.ProviderID) if err != nil { return nil, fmt.Errorf("droplet replace %q: invalid old ProviderID %q: %w", oldRef.Name, oldRef.ProviderID, err) diff --git a/internal/drivers/droplet_pricing.go b/internal/drivers/droplet_pricing.go new file mode 100644 index 0000000..a898baa --- /dev/null +++ b/internal/drivers/droplet_pricing.go @@ -0,0 +1,198 @@ +package drivers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "net/url" + "reflect" + "slices" + "strconv" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" +) + +// DropletSizesClient matches the read-only godo Sizes API. +type DropletSizesClient interface { + List(context.Context, *godo.ListOptions) ([]godo.Size, *godo.Response, error) +} + +// DropletPriceQuote is diagnostic metadata, never authority for a later apply. +type DropletPriceQuote struct { + SizeSlug string `json:"size_slug"` + Region string `json:"region"` + MonthlyUSD float64 `json:"monthly_usd"` + MaxMonthlyUSD float64 `json:"max_monthly_usd"` + Source string `json:"source"` + CheckedAt time.Time `json:"checked_at"` +} + +// QuoteDropletPrice fetches a fresh catalog for an explicitly capped spec. +// Uncapped specs preserve legacy behavior and never call the Sizes API. +func QuoteDropletPrice(ctx context.Context, client DropletSizesClient, spec interfaces.ResourceSpec) (*DropletPriceQuote, error) { + value, capped := spec.Config["max_monthly_usd"] + if !capped { + return nil, nil + } + capUSD, capExact, err := dropletMonthlyCap(value) + if err != nil { + return nil, err + } + slug, slugOK := spec.Config["size"].(string) + region, regionOK := spec.Config["region"].(string) + if !slugOK || !validPriceSlug(slug) { + return nil, dropletPricingInvalid("capped droplet requires an explicit size slug") + } + if !regionOK || !validPriceSlug(region) { + return nil, dropletPricingInvalid("capped droplet requires an explicit region slug") + } + if isNilLike(client) { + return nil, dropletPricingInvalid("live Sizes client is required for capped droplets") + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + var selected *godo.Size + rows := 0 + for page := 1; ; page++ { + if err := ctx.Err(); err != nil { + return nil, dropletPricingAPIError(err) + } + sizes, response, err := client.List(ctx, &godo.ListOptions{Page: page, PerPage: 200}) + if err != nil { + return nil, dropletPricingAPIError(err) + } + rows += len(sizes) + for _, size := range sizes { + if size.Slug != slug { + continue + } + if selected != nil { + return nil, dropletPricingInvalid("live Sizes catalog contains an ambiguous size slug") + } + copy := size + selected = © + } + next := "" + if response != nil && response.Links != nil && response.Links.Pages != nil { + next = response.Links.Pages.Next + } + if next == "" { + if response != nil && response.Meta != nil && response.Meta.Total != rows { + return nil, dropletPricingInvalid("live Sizes catalog is incomplete") + } + break + } + // Parse only the page number; never follow a server-supplied URL. + // All requests continue through the injected Sizes client/base URL. + nextURL, err := url.Parse(next) + if err != nil { + return nil, dropletPricingInvalid("live Sizes pagination is malformed") + } + nextPage, err := strconv.Atoi(nextURL.Query().Get("page")) + if err != nil || nextPage != page+1 || page >= 100 { + return nil, dropletPricingInvalid("live Sizes pagination is incomplete or cyclic") + } + } + if selected == nil { + return nil, dropletPricingInvalid("selected size is absent from the live Sizes catalog") + } + if !selected.Available || !slices.Contains(selected.Regions, region) { + return nil, dropletPricingInvalid("selected size is not available in the selected region") + } + monthly := selected.PriceMonthly + if monthly <= 0 || math.IsNaN(monthly) || math.IsInf(monthly, 0) { + return nil, dropletPricingInvalid("selected size has an invalid monthly USD price") + } + // Use the SDK's monthly field, not an hourly estimate, and never round a + // decimal cap upward before comparing it with the authoritative quote. + monthlyExact, ok := new(big.Rat).SetString(strconv.FormatFloat(monthly, 'g', -1, 64)) + if !ok || monthlyExact.Cmp(capExact) > 0 { + return nil, dropletPricingInvalid("selected size exceeds max_monthly_usd") + } + return &DropletPriceQuote{SizeSlug: slug, Region: region, MonthlyUSD: monthly, MaxMonthlyUSD: capUSD, Source: "digitalocean.sizes", CheckedAt: time.Now().UTC()}, nil +} + +func validPriceSlug(slug string) bool { + if slug == "" { + return false + } + for _, ch := range slug { + if ch != '-' && (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') { + return false + } + } + return true +} + +func dropletMonthlyCap(value any) (float64, *big.Rat, error) { + invalid := func() (float64, *big.Rat, error) { + return 0, nil, dropletPricingInvalid("max_monthly_usd must be a positive finite number") + } + var number string + if n, ok := value.(json.Number); ok { + number = n.String() + if !json.Valid([]byte(number)) { + return invalid() + } + } else { + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return invalid() + } + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number = strconv.FormatInt(rv.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + number = strconv.FormatUint(rv.Uint(), 10) + case reflect.Float32: + number = strconv.FormatFloat(rv.Float(), 'g', -1, 32) + case reflect.Float64: + number = strconv.FormatFloat(rv.Float(), 'g', -1, 64) + default: + return invalid() + } + } + usd, err := strconv.ParseFloat(number, 64) + if err != nil || usd <= 0 || math.IsNaN(usd) || math.IsInf(usd, 0) { + return invalid() + } + exact, ok := new(big.Rat).SetString(number) + if !ok || exact.Sign() <= 0 { + return invalid() + } + return usd, exact, nil +} + +func dropletPricingInvalid(message string) error { + return fmt.Errorf("droplet pricing: %s: %w", message, interfaces.ErrValidation) +} + +type dropletPricingFailure struct{ cause, classification error } + +func (e *dropletPricingFailure) Error() string { + if e.classification != nil { + return "droplet pricing lookup: " + e.classification.Error() + } + return "droplet pricing lookup failed" +} + +func (e *dropletPricingFailure) Unwrap() []error { + if e.classification != nil { + return []error{e.classification, e.cause} + } + return []error{e.cause} +} + +func dropletPricingAPIError(cause error) error { + var response *godo.ErrorResponse + var classification error + if errors.As(cause, &response) && response.Response != nil { + classification = sentinelForStatus(response.Response.StatusCode) + } + return &dropletPricingFailure{cause: cause, classification: classification} +} diff --git a/internal/drivers/droplet_pricing_test.go b/internal/drivers/droplet_pricing_test.go new file mode 100644 index 0000000..0ad4e2f --- /dev/null +++ b/internal/drivers/droplet_pricing_test.go @@ -0,0 +1,339 @@ +package drivers_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" +) + +type pricingSizesClient struct { + sizes []godo.Size + err error + calls int + log *callLog +} + +func (c *pricingSizesClient) List(ctx context.Context, _ *godo.ListOptions) ([]godo.Size, *godo.Response, error) { + c.calls++ + c.log.record("Sizes.List") + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return c.sizes, nil, c.err +} + +func cappedDropletSpec() interfaces.ResourceSpec { + return interfaces.ResourceSpec{Name: "host", Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3", "max_monthly_usd": 24}} +} + +func availableDropletSize(price float64) godo.Size { + return godo.Size{Slug: "s-2vcpu-4gb", Regions: []string{"nyc3"}, Available: true, PriceMonthly: price} +} + +func TestDropletPricing_GuardsDiffCreateAndReplaceBeforeMutation(t *testing.T) { + for _, operation := range []string{"diff", "create", "replace"} { + t.Run(operation, func(t *testing.T) { + log := &callLog{} + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24.01)}, log: log} + droplets := newRecordingDropletsClient().withCallLog(log) + droplets.getResponse = &godo.Droplet{ID: 100, VolumeIDs: []string{"vol-a"}} + sa := newRecordingStorageActionsClient().withCallLog(log) + d := drivers.NewDropletDriverWithClient(droplets, "nyc3", sizes, sa, newRecordingActionsClient()) + var err error + switch operation { + case "diff": + _, err = d.Diff(t.Context(), cappedDropletSpec(), nil) + case "create": + _, err = d.Create(t.Context(), cappedDropletSpec()) + case "replace": + _, err = d.Replace(t.Context(), interfaces.ResourceRef{Name: "host", ProviderID: "100"}, cappedDropletSpec()) + } + if err == nil { + t.Fatal("over-cap profile accepted") + } + if droplets.createCalled || droplets.deleteCalled || len(sa.detachCalls) != 0 { + t.Fatal("over-cap profile mutated cloud state") + } + if sizes.calls != 1 || len(log.events) != 1 || log.events[0] != "Sizes.List" { + t.Fatal("pricing was not checked before driver execution") + } + }) + } +} + +func TestDropletPricing_ExactLiveQuoteAtCap(t *testing.T) { + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}} + before := cappedDropletSpec() + quote, err := drivers.QuoteDropletPrice(t.Context(), sizes, before) + if err != nil { + t.Fatal(err) + } + if quote == nil || quote.SizeSlug != "s-2vcpu-4gb" || quote.Region != "nyc3" || quote.MonthlyUSD != 24 || quote.MaxMonthlyUSD != 24 || quote.Source != "digitalocean.sizes" || quote.CheckedAt.IsZero() || quote.CheckedAt.Location().String() != "UTC" { + t.Fatal("incorrect live quote") + } + if len(before.Config) != 3 { + t.Fatal("pricing rewrote declarative config") + } +} + +func TestDropletPricing_NoStaticOrCachedAuthorization(t *testing.T) { + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}} + droplets := newRecordingDropletsClient() + d := drivers.NewDropletDriverWithClient(droplets, "nyc3", sizes) + if _, err := d.Diff(t.Context(), cappedDropletSpec(), nil); err != nil { + t.Fatal(err) + } + sizes.sizes[0].PriceMonthly = 25 + if _, err := d.Create(t.Context(), cappedDropletSpec()); err == nil { + t.Fatal("stale plan quote authorized create") + } + if droplets.createCalled || sizes.calls != 2 { + t.Fatal("fresh pricing guard did not stop create") + } + sizes.sizes = nil + if _, err := drivers.QuoteDropletPrice(t.Context(), sizes, cappedDropletSpec()); err == nil { + t.Fatal("static known slug bypassed empty live catalog") + } +} + +func TestDropletPricing_ApplyRechecksPlanBeforeAnyMutation(t *testing.T) { + for _, operation := range []string{"create", "replace"} { + for _, failure := range []string{"changed_price", "unavailable", "api"} { + t.Run(operation+"/"+failure, func(t *testing.T) { + log := &callLog{} + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}, log: log} + droplets := newRecordingDropletsClient().withCallLog(log) + droplets.getResponse = &godo.Droplet{ID: 100, VolumeIDs: []string{"vol-a"}} + sa := newRecordingStorageActionsClient().withCallLog(log) + d := drivers.NewDropletDriverWithClient(droplets, "nyc3", sizes, sa, newRecordingActionsClient()) + if _, err := d.Diff(t.Context(), cappedDropletSpec(), nil); err != nil { + t.Fatal(err) + } + switch failure { + case "changed_price": + sizes.sizes[0].PriceMonthly = 25 + case "unavailable": + sizes.sizes[0].Available = false + case "api": + sizes.err = errors.New("KNOWN_PRICE_SECRET") + } + var err error + if operation == "create" { + _, err = d.Create(t.Context(), cappedDropletSpec()) + } else { + _, err = d.Replace(t.Context(), interfaces.ResourceRef{Name: "host", ProviderID: "100"}, cappedDropletSpec()) + } + if err == nil || strings.Contains(err.Error(), "KNOWN_PRICE_SECRET") { + t.Fatal("changed or failed lookup accepted or disclosed") + } + if sizes.err != nil && !errors.Is(err, sizes.err) { + t.Fatal("lookup failure cause lost") + } + if droplets.createCalled || droplets.deleteCalled || len(sa.detachCalls) != 0 || !slices.Equal(log.events, []string{"Sizes.List", "Sizes.List"}) { + t.Fatal("apply reused plan authorization or mutated before lookup") + } + }) + } + } +} + +func TestDropletPricing_SuccessfulApplyQuoteAndReplaceOrder(t *testing.T) { + for _, operation := range []string{"create", "replace"} { + t.Run(operation, func(t *testing.T) { + log := &callLog{} + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}, log: log} + droplets := newRecordingDropletsClient().withCallLog(log) + droplets.getResponse = &godo.Droplet{ID: 100, VolumeIDs: []string{"vol-a"}} + sa := newRecordingStorageActionsClient().withCallLog(log) + d := drivers.NewDropletDriverWithClient(droplets, "nyc3", sizes, sa, newRecordingActionsClient().withCallLog(log)) + var out *interfaces.ResourceOutput + var err error + want := []string{"Sizes.List", "Droplets.Create"} + if operation == "create" { + out, err = d.Create(t.Context(), cappedDropletSpec()) + } else { + out, err = d.Replace(t.Context(), interfaces.ResourceRef{Name: "host", ProviderID: "100"}, cappedDropletSpec()) + want = []string{"Sizes.List", "Droplets.Get", "StorageActions.DetachByDropletID", "Actions.Get", "Droplets.Delete", "Sizes.List", "Droplets.Create"} + } + if err != nil { + t.Fatal(err) + } + quote, ok := out.Outputs["price_quote"].(*drivers.DropletPriceQuote) + if !ok || quote.MonthlyUSD != 24 || quote.CheckedAt.IsZero() { + t.Fatal("successful apply did not return typed live quote") + } + if !slices.Equal(log.events, want) { + t.Fatalf("incorrect price/mutation ordering: %v", log.events) + } + if droplets.createReq.Size != "s-2vcpu-4gb" || droplets.createReq.Region != "nyc3" { + t.Fatal("quote and create target disagree") + } + }) + } +} + +func TestDropletPricing_InvalidCapsAndMissingSelectionBeforeAPI(t *testing.T) { + sizes := &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}} + for _, cap := range []any{nil, "24", true, -1, 0, math.NaN(), math.Inf(1), math.Inf(-1), json.Number("24/1"), json.Number("24junk"), json.Number("+24"), []int{24}, map[string]any{"password": "KNOWN_PRICE_SECRET"}} { + spec := cappedDropletSpec() + spec.Config["max_monthly_usd"] = cap + _, err := drivers.QuoteDropletPrice(t.Context(), sizes, spec) + if err == nil || strings.Contains(err.Error(), "KNOWN_PRICE_SECRET") { + t.Fatal("invalid cap accepted or disclosed") + } + } + for _, key := range []string{"size", "region"} { + for _, value := range []any{nil, "", " ", "invalid/KNOWN_PRICE_SECRET", true} { + spec := cappedDropletSpec() + spec.Config[key] = value + if _, err := drivers.QuoteDropletPrice(t.Context(), sizes, spec); err == nil || strings.Contains(err.Error(), "KNOWN_PRICE_SECRET") { + t.Fatal("invalid selection accepted or disclosed") + } + } + spec := cappedDropletSpec() + delete(spec.Config, key) + if _, err := drivers.QuoteDropletPrice(t.Context(), sizes, spec); err == nil { + t.Fatal("missing selection accepted") + } + } + if sizes.calls != 0 { + t.Fatal("malformed inputs reached API") + } +} + +func TestDropletPricing_RejectsUnknownUnavailableRegionAndMalformedPrices(t *testing.T) { + for _, tc := range []struct { + name string + size godo.Size + }{ + {"unknown", godo.Size{Slug: "s-2vcpu-8gb", Available: true, Regions: []string{"nyc3"}, PriceMonthly: 24}}, + {"unavailable", godo.Size{Slug: "s-2vcpu-4gb", Regions: []string{"nyc3"}, PriceMonthly: 24}}, + {"region", godo.Size{Slug: "s-2vcpu-4gb", Available: true, Regions: []string{"sfo3"}, PriceMonthly: 24}}, + {"zero", availableDropletSize(0)}, {"negative", availableDropletSize(-1)}, + {"nan", availableDropletSize(math.NaN())}, {"infinite", availableDropletSize(math.Inf(1))}, + {"overcap", availableDropletSize(math.Nextafter(24, 25))}, + } { + t.Run(tc.name, func(t *testing.T) { + quote, err := drivers.QuoteDropletPrice(t.Context(), &pricingSizesClient{sizes: []godo.Size{tc.size}}, cappedDropletSpec()) + if err == nil || quote != nil { + t.Fatal("invalid live size authorized") + } + }) + } +} + +func TestDropletPricing_UncappedLegacyAndMissingClient(t *testing.T) { + spec := cappedDropletSpec() + delete(spec.Config, "max_monthly_usd") + quote, err := drivers.QuoteDropletPrice(t.Context(), nil, spec) + if err != nil || quote != nil { + t.Fatal("uncapped legacy requires pricing") + } + droplets := newRecordingDropletsClient() + if _, err := drivers.NewDropletDriverWithClient(droplets, "nyc3").Create(t.Context(), spec); err != nil { + t.Fatal(err) + } + if !droplets.createCalled { + t.Fatal("legacy create changed") + } + for _, sizes := range []drivers.DropletSizesClient{nil, (*pricingSizesClient)(nil)} { + if _, err := drivers.QuoteDropletPrice(t.Context(), sizes, cappedDropletSpec()); err == nil { + t.Fatal("capped missing pricing client accepted") + } + } +} + +func TestDropletPricing_GodoPaginationAndSanitizedFailures(t *testing.T) { + for _, mode := range []string{"pages", "duplicate", "second_failure", "malformed", "forbidden", "cycle", "incomplete", "malformed_next"} { + t.Run(mode, func(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != "/v2/sizes" || r.URL.Query().Get("per_page") != "200" { + t.Error("unexpected sizes API request") + } + page := r.URL.Query().Get("page") + if mode == "forbidden" || (mode == "second_failure" && page == "2") { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"message":"KNOWN_PRICE_SECRET"}`) + return + } + if mode == "malformed" { + _, _ = fmt.Fprint(w, `{"sizes":[{"price_monthly":"KNOWN_PRICE_SECRET"}]}`) + return + } + rows := []godo.Size{availableDropletSize(24)} + next := "" + if page == "1" { + next = "/v2/sizes?page=2&per_page=200" + if mode == "pages" { + rows = []godo.Size{{Slug: "s-other"}} + } + if mode == "cycle" { + next = "/v2/sizes?page=1" + } + if mode == "malformed_next" { + next = "/v2/sizes?page=UNKNOWN" + } + if mode == "incomplete" { + _ = json.NewEncoder(w).Encode(map[string]any{"sizes": rows, "meta": map[string]any{"total": 2}}) + return + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"sizes": rows, "links": map[string]any{"pages": map[string]any{"next": next}}}) + })) + defer srv.Close() + quote, err := drivers.QuoteDropletPrice(t.Context(), godoClientForTest(t, srv).Sizes, cappedDropletSpec()) + if mode == "pages" { + if err != nil || quote == nil || calls != 2 { + t.Fatalf("page traversal failed: %v", err) + } + return + } + if err == nil || quote != nil || strings.Contains(err.Error(), "KNOWN_PRICE_SECRET") { + t.Fatal("unsafe/incomplete catalog authorized") + } + if mode == "forbidden" || mode == "second_failure" { + var cause *godo.ErrorResponse + if !errors.Is(err, interfaces.ErrForbidden) || !errors.As(err, &cause) { + t.Fatal("API classification/cause lost") + } + } + if mode == "cycle" && calls != 1 { + t.Fatal("cyclic page link was followed") + } + }) + } +} + +func TestDropletPricing_CanceledLookupAndExactNumericCaps(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := drivers.QuoteDropletPrice(ctx, &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}}, cappedDropletSpec()) + if !errors.Is(err, context.Canceled) { + t.Fatal("cancellation cause lost") + } + for _, cap := range []any{24, int64(24), float64(24), float32(24), json.Number("2.4e1")} { + spec := cappedDropletSpec() + spec.Config["max_monthly_usd"] = cap + if _, err := drivers.QuoteDropletPrice(t.Context(), &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}}, spec); err != nil { + t.Fatal("valid numeric cap rejected") + } + } + spec := cappedDropletSpec() + spec.Config["max_monthly_usd"] = json.Number("23.999999999999999999") + if _, err := drivers.QuoteDropletPrice(t.Context(), &pricingSizesClient{sizes: []godo.Size{availableDropletSize(24)}}, spec); err == nil { + t.Fatal("rounded-up cap authorized over-cap profile") + } +} diff --git a/internal/host_conformance_test.go b/internal/host_conformance_test.go index 1b8883d..051bd6d 100644 --- a/internal/host_conformance_test.go +++ b/internal/host_conformance_test.go @@ -11,6 +11,7 @@ import ( "github.com/GoCodeAlone/workflow/plugin/external" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/digitalocean/godo" ) func TestWorkflowHostConformance_LoadsTypedIaCPlugin(t *testing.T) { @@ -50,6 +51,11 @@ func TestWorkflowHostConformance_LoadsTypedIaCPlugin(t *testing.T) { if !registryHasService(registry, pb.IaCProviderRequired_ServiceDesc.ServiceName) { t.Fatalf("contract registry missing required service %q: %v", pb.IaCProviderRequired_ServiceDesc.ServiceName, registry.GetContracts()) } + for _, name := range []string{pb.IaCProviderRunner_ServiceDesc.ServiceName, pb.IaCProviderJobCanceler_ServiceDesc.ServiceName, pb.ResourceSensitiveInputDeclarer_ServiceDesc.ServiceName} { + if !registryHasService(registry, name) { + t.Fatalf("host did not discover %q", name) + } + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Cleanup(cancel) @@ -72,6 +78,63 @@ func TestWorkflowHostConformance_LoadsTypedIaCPlugin(t *testing.T) { } } +func TestWorkflowHostConformance_AppPlatformRunnerLifecycle(t *testing.T) { + if os.Getenv("WORKFLOW_IAC_HOST_CONFORMANCE") != "1" { + t.Skip("set WORKFLOW_IAC_HOST_CONFORMANCE=1 to run host compatibility smoke") + } + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + apiServer := appJobHTTPFake(t, api) + repoRoot := testRepoRoot(t) + pluginName := readPluginName(t, filepath.Join(repoRoot, "plugin.json")) + pluginsDir := filepath.Join(t.TempDir(), "plugins") + pluginDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + for _, file := range []string{"plugin.json", "plugin.contracts.json"} { + copyFile(t, filepath.Join(repoRoot, file), filepath.Join(pluginDir, file)) + } + // Only the HTTP dependency is redirected. The fixture launches the same + // NewIaCServer and published SDK used by the production plugin entrypoint. + build := exec.Command("go", "build", "-o", filepath.Join(pluginDir, pluginName), "./internal/testdata/app-platform-runner") + build.Dir = repoRoot + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build real-server fixture: %v\n%s", err, output) + } + t.Setenv("WORKFLOW_DO_JOB_TEST_API", apiServer.URL) + t.Setenv("WORKFLOW_DO_JOB_TEST_MANIFEST", filepath.Join(repoRoot, "plugin.json")) + mgr := external.NewExternalPluginManager(pluginsDir, nil) + t.Cleanup(mgr.Shutdown) + adapter, err := mgr.LoadPlugin(pluginName) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{pb.IaCProviderRunner_ServiceDesc.ServiceName, pb.IaCProviderJobCanceler_ServiceDesc.ServiceName} { + if !registryHasService(adapter.ContractRegistry(), name) { + t.Fatalf("missing runner contract %s", name) + } + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + spec := validAppJobSpec() + client := pb.NewIaCProviderRunnerClient(adapter.Conn()) + handle, err := client.RunJob(ctx, &pb.JobSpec{Name: spec.Name, Target: &pb.ResourceRef{Name: spec.Target.Name, Type: spec.Target.Type, ProviderId: spec.Target.ProviderID}, Image: spec.Image, RunCommand: spec.RunCommand, TimeoutSeconds: 300, EnvVarsSecret: spec.EnvVarsSecret}) + if err != nil { + t.Fatal(err) + } + reply, err := client.JobStatus(ctx, handle) + if err != nil || reply.GetState() != pb.JobState_JOB_STATE_RUNNING { + t.Fatalf("native process status: %v %v", reply, err) + } + if _, err := pb.NewIaCProviderJobCancelerClient(adapter.Conn()).CancelJob(ctx, reply.GetHandle()); err != nil { + t.Fatal(err) + } + if api.cancels != 1 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("launched provider did not cancel and clean up") + } +} + func readPluginName(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) diff --git a/internal/iacserver.go b/internal/iacserver.go index 017fe49..715bfff 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -70,6 +70,7 @@ type doIaCServer struct { // dispatch methods are declared in resourcedriver_server.go // (Task 11 of the strict-contracts force-cutover plan). pb.UnimplementedResourceDriverServer + pb.UnimplementedResourceSensitiveInputDeclarerServer // pb.UnimplementedPluginServiceServer satisfies the // mustEmbedUnimplementedPluginServiceServer() forward-compat // requirement on pb.PluginServiceServer. All PluginService methods @@ -448,12 +449,12 @@ func (s *doIaCServer) RepairDirtyMigration(ctx context.Context, req *pb.RepairDi // ValidatePlan satisfies pb.IaCProviderValidatorServer. Note the Go // interface returns []PlanDiagnostic only (no error); this method // therefore never errors at the gRPC layer beyond marshalling failures. -func (s *doIaCServer) ValidatePlan(_ context.Context, req *pb.ValidatePlanRequest) (*pb.ValidatePlanResponse, error) { +func (s *doIaCServer) ValidatePlan(ctx context.Context, req *pb.ValidatePlanRequest) (*pb.ValidatePlanResponse, error) { plan, err := planFromPB(req.GetPlan()) if err != nil { return nil, fmt.Errorf("digitalocean iacserver: decode ValidatePlan plan: %w", err) } - diags := s.provider.ValidatePlan(plan) + diags := s.provider.validatePlanContext(ctx, plan) out := make([]*pb.PlanDiagnostic, 0, len(diags)) for _, d := range diags { out = append(out, &pb.PlanDiagnostic{ diff --git a/internal/iacserver_mapper_test.go b/internal/iacserver_mapper_test.go index 70feda9..85c7e85 100644 --- a/internal/iacserver_mapper_test.go +++ b/internal/iacserver_mapper_test.go @@ -177,8 +177,8 @@ func TestPluginManifestAdvertisesRequirementMapper(t *testing.T) { if err := json.Unmarshal(data, &manifest); err != nil { t.Fatalf("parse plugin.json: %v", err) } - if manifest.MinEngineVersion != "0.80.1" { - t.Fatalf("minEngineVersion = %q, want 0.80.1", manifest.MinEngineVersion) + if manifest.MinEngineVersion != "0.86.1" { + t.Fatalf("minEngineVersion = %q, want 0.86.1", manifest.MinEngineVersion) } const mapperService = "workflow.plugin.external.iac.IaCProviderRequirementMapper" for _, svc := range manifest.IaCServices { diff --git a/internal/iacserver_pricing_test.go b/internal/iacserver_pricing_test.go new file mode 100644 index 0000000..83f7c79 --- /dev/null +++ b/internal/iacserver_pricing_test.go @@ -0,0 +1,125 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/interfaces" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +func TestDOIaCServer_DropletPricingCancellation(t *testing.T) { + for _, mode := range []string{"cancel", "deadline"} { + t.Run(mode, func(t *testing.T) { + var block atomic.Bool + var calls atomic.Int32 + started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/v2/sizes" { + t.Error("unexpected pricing request") + } + if block.Load() { + close(started) + select { + case <-r.Context().Done(): + close(canceled) + case <-release: + } + return + } + _, _ = fmt.Fprint(w, `{"sizes":[{"slug":"s-2vcpu-4gb","available":true,"regions":["nyc3"],"price_monthly":24}]}`) + })) + t.Cleanup(func() { close(release); srv.Close() }) + p := NewDOProvider() + if err := p.Initialize(contextWithPricingHTTPClient(t, srv), map[string]any{"token": "fake", "region": "nyc3"}); err != nil { + t.Fatal(err) + } + listener := bufconn.Listen(iacServerTestBufSize) + t.Cleanup(func() { _ = listener.Close() }) + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, newDOIaCServer(p)); err != nil { + t.Fatal(err) + } + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///pricing", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + client := pb.NewIaCProviderValidatorClient(conn) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Name: "host", Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3", "max_monthly_usd": 24}}}}} + wirePlan, err := planToPB(plan) + if err != nil { + t.Fatal(err) + } + warmupCtx, warmupCancel := context.WithTimeout(t.Context(), iacServerTestRPCDeadline) + defer warmupCancel() + response, err := client.ValidatePlan(warmupCtx, &pb.ValidatePlanRequest{Plan: wirePlan}) + if err != nil || len(response.GetDiagnostics()) != 1 || response.Diagnostics[0].Severity != pb.PlanDiagnosticSeverity_PLAN_DIAGNOSTIC_INFO { + t.Fatalf("typed quote RPC failed: %v", err) + } + var quote drivers.DropletPriceQuote + if err := json.Unmarshal([]byte(response.Diagnostics[0].Message), "e); err != nil || quote.MonthlyUSD != 24 || quote.CheckedAt.IsZero() { + t.Fatal("RPC did not expose a typed live quote diagnostic") + } + delete(plan.Actions[0].Resource.Config, "max_monthly_usd") + uncapped, err := planToPB(plan) + if err != nil { + t.Fatal(err) + } + if response, err := client.ValidatePlan(warmupCtx, &pb.ValidatePlanRequest{Plan: uncapped}); err != nil || len(response.GetDiagnostics()) != 0 || calls.Load() != 1 { + t.Fatal("uncapped RPC performed pricing lookup") + } + block.Store(true) + ctx, cancel := context.WithCancel(t.Context()) + wantCode := codes.Canceled + if mode == "deadline" { + cancel() + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + wantCode = codes.DeadlineExceeded + } + defer cancel() + returned := make(chan error, 1) + go func() { _, err := client.ValidatePlan(ctx, &pb.ValidatePlanRequest{Plan: wirePlan}); returned <- err }() + select { + case <-started: + case <-time.After(iacServerTestRPCDeadline): + t.Fatal("cancellation fixture never reached pricing API") + } + if mode == "cancel" { + cancel() + } + select { + case err := <-returned: + if status.Code(err) != wantCode { + t.Fatalf("RPC cancellation code = %v, want %v", status.Code(err), wantCode) + } + case <-time.After(iacServerTestRPCDeadline): + t.Fatal("RPC did not terminate") + } + select { + case <-canceled: + case <-time.After(2 * time.Second): + t.Fatal("gRPC cancellation did not reach pricing HTTP request") + } + }) + } +} diff --git a/internal/iacserver_runner.go b/internal/iacserver_runner.go new file mode 100644 index 0000000..65b7dff --- /dev/null +++ b/internal/iacserver_runner.go @@ -0,0 +1,92 @@ +package internal + +import ( + "context" + "fmt" + "net/url" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/digitalocean/godo" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var _ interfaces.IaCProviderRunner = (*DOProvider)(nil) +var _ interfaces.IaCProviderJobCanceler = (*DOProvider)(nil) +var _ interfaces.IaCProviderRunner = (*doIaCServer)(nil) +var _ interfaces.IaCProviderJobCanceler = (*doIaCServer)(nil) + +func (p *DOProvider) appPlatformJobRunner() (*appPlatformRunner, error) { + if p.client == nil { + return nil, status.Error(codes.FailedPrecondition, "digitalocean provider must be initialized before running jobs") + } + return newAppPlatformRunner(godoAppPlatformJobClient{AppsService: p.client.Apps, client: p.client}), nil +} + +// godo exposes invocation cancellation but not the deployment cancellation +// needed before an invocation exists. Keep that typed API adapter in this provider. +type godoAppPlatformJobClient struct { + godo.AppsService + client *godo.Client +} + +func (c godoAppPlatformJobClient) CancelDeployment(ctx context.Context, appID, deploymentID string) (*godo.Deployment, *godo.Response, error) { + path := fmt.Sprintf("v2/apps/%s/deployments/%s/cancel", url.PathEscape(appID), url.PathEscape(deploymentID)) + req, err := c.client.NewRequest(ctx, "POST", path, nil) + if err != nil { + return nil, nil, err + } + var result struct { + Deployment *godo.Deployment `json:"deployment"` + } + response, err := c.client.Do(ctx, req, &result) + return result.Deployment, response, err +} + +func (p *DOProvider) RunJob(ctx context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + runner, err := p.appPlatformJobRunner() + if err != nil { + return nil, err + } + return runner.RunJob(ctx, spec) +} + +func (p *DOProvider) JobStatus(ctx context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + runner, err := p.appPlatformJobRunner() + if err != nil { + return nil, err + } + return runner.JobStatus(ctx, handle) +} + +func (p *DOProvider) JobLogs(ctx context.Context, handle interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + runner, err := p.appPlatformJobRunner() + if err != nil { + return err + } + return runner.JobLogs(ctx, handle, sink) +} + +func (p *DOProvider) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + runner, err := p.appPlatformJobRunner() + if err != nil { + return err + } + return runner.CancelJob(ctx, handle) +} + +func (s *doIaCServer) RunJob(ctx context.Context, spec interfaces.JobSpec) (*interfaces.JobHandle, error) { + return s.provider.RunJob(ctx, spec) +} + +func (s *doIaCServer) JobStatus(ctx context.Context, handle interfaces.JobHandle) (*interfaces.JobStatusReply, error) { + return s.provider.JobStatus(ctx, handle) +} + +func (s *doIaCServer) JobLogs(ctx context.Context, handle interfaces.JobHandle, sink interfaces.LogCaptureSink) error { + return s.provider.JobLogs(ctx, handle, sink) +} + +func (s *doIaCServer) CancelJob(ctx context.Context, handle interfaces.JobHandle) error { + return s.provider.CancelJob(ctx, handle) +} diff --git a/internal/iacserver_runner_test.go b/internal/iacserver_runner_test.go new file mode 100644 index 0000000..aacf5e2 --- /dev/null +++ b/internal/iacserver_runner_test.go @@ -0,0 +1,374 @@ +package internal + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/digitalocean/godo" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestAppPlatformRunnerNativeOwnershipRecovery(t *testing.T) { + if os.Getenv("WORKFLOW_IAC_HOST_CONFORMANCE") != "1" { + t.Skip("set WORKFLOW_IAC_HOST_CONFORMANCE=1 to launch the production plugin against loopback HTTP") + } + root := t.TempDir() + if evidence := os.Getenv("WORKFLOW_DO_TASK8_EVIDENCE"); evidence != "" { + var err error + root, err = os.MkdirTemp(evidence, "native-") + if err != nil { + t.Fatal(err) + } + } + repo := testRepoRoot(t) + name := readPluginName(t, filepath.Join(repo, "plugin.json")) + plugins := filepath.Join(root, "plugins") + plugin := filepath.Join(plugins, name) + if err := os.MkdirAll(plugin, 0o755); err != nil { + t.Fatal(err) + } + for _, file := range []string{"plugin.json", "plugin.contracts.json"} { + copyFile(t, filepath.Join(repo, file), filepath.Join(plugin, file)) + } + write := func(file string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, file), data, 0o600); err != nil { + t.Fatal(err) + } + } + write("http_dependency.go", []byte(databaseConsumerHTTPOverlay)) + overlay, err := json.Marshal(map[string]any{"Replace": map[string]string{filepath.Join(repo, "cmd/plugin/task8_http_dependency.go"): filepath.Join(root, "http_dependency.go")}}) + if err != nil { + t.Fatal(err) + } + write("overlay.json", overlay) + build := exec.Command("go", "build", "-overlay", filepath.Join(root, "overlay.json"), "-o", filepath.Join(plugin, name), "./cmd/plugin") + build.Dir = repo + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build production plugin with HTTP-only dependency overlay: %v\n%s", err, output) + } + t.Setenv("WORKFLOW_DO_TASK7_PIDS", filepath.Join(root, "owned-pids.txt")) + var cases []map[string]any + for _, outcome := range []string{"rejected", "deployment-failed", "invocation-succeeded", "interrupted-cancel", "cleanup-original-handle", "cleanup-known-id", "cleanup-unencrypted", "cleanup-live-drift", "cleanup-pending-owned"} { + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + durableCancel := strings.HasPrefix(outcome, "cleanup-") + if outcome == "rejected" { + api.app.InProgressDeployment = &godo.Deployment{ID: "deployment-uuid", Phase: godo.DeploymentPhase_Deploying, Spec: jobClone(api.app.Spec)} + api.updateErr = fmt.Errorf("launch rejected before write") + } else { + api.encryptSecretValues, api.loseUpdateResponse, api.failGetAt = true, true, 2 + } + if durableCancel { + api.noInvocation, api.loseCleanupReadback = true, true + if outcome == "cleanup-known-id" { + api.loseUpdateResponse, api.failGetAt = false, 0 + } + if outcome == "cleanup-unencrypted" { + api.encryptSecretValues = false + } + } + server := appJobHTTPFake(t, api) + t.Setenv("WORKFLOW_DO_TASK7_API", server.URL) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + var handle *pb.JobHandle + load := func(call func(*external.ExternalPluginAdapter)) { + t.Helper() + mgr := external.NewExternalPluginManager(plugins, nil) + defer mgr.Shutdown() + adapter, err := mgr.LoadPlugin(name) + if err != nil { + t.Fatal(err) + } + if err := providerclient.New(adapter.Conn(), map[string]bool{providerclient.IaCServiceResourceDriver: true}).Initialize(ctx, map[string]any{"token": "fixture-token"}); err != nil { + t.Fatal(err) + } + call(adapter) + } + load(func(adapter *external.ExternalPluginAdapter) { + spec := validAppJobSpec() + var err error + handle, err = pb.NewIaCProviderRunnerClient(adapter.Conn()).RunJob(ctx, &pb.JobSpec{Name: spec.Name, Target: &pb.ResourceRef{Name: spec.Target.Name, Type: spec.Target.Type, ProviderId: spec.Target.ProviderID}, Image: spec.Image, RunCommand: spec.RunCommand, TimeoutSeconds: int32(spec.TimeoutSeconds), EnvVarsSecret: spec.EnvVarsSecret}) + if err != nil { + t.Fatal(err) + } + }) + encoded, err := json.Marshal(handle) + if err != nil || strings.Contains(string(encoded), "${db.DATABASE_URL}") || strings.Contains(string(encoded), "EV[") { + t.Fatal("native handle encoding failed or persisted secret bytes") + } + write(outcome+"-handle.json", encoded) + if durableCancel { + var metadata appPlatformJobMetadata + if err := json.Unmarshal([]byte(handle.Metadata["app_platform_job"]), &metadata); err != nil || (metadata.DeploymentID != "") != (outcome == "cleanup-known-id") || metadata.ComponentAcknowledged != (outcome == "cleanup-known-id") { + t.Fatal("native fixture did not preserve original launch acknowledgement state") + } + } + handle = jobClone(handle) + api.loseUpdateResponse = false + switch outcome { + case "deployment-failed": + api.noInvocation, api.deploymentPhase = true, godo.DeploymentPhase_Error + case "invocation-succeeded": + api.phase = godo.JOBINVOCATIONPHASE_Succeeded + } + wantDeploymentCancels := 0 + if durableCancel { + cancelOriginal := func(wantError bool) { + t.Helper() + load(func(adapter *external.ExternalPluginAdapter) { + persisted, err := os.ReadFile(filepath.Join(root, outcome+"-handle.json")) + if err != nil { + t.Fatal(err) + } + var original pb.JobHandle + if err := json.Unmarshal(persisted, &original); err != nil { + t.Fatal(err) + } + _, err = pb.NewIaCProviderJobCancelerClient(adapter.Conn()).CancelJob(ctx, &original) + if (err != nil) != wantError { + t.Fatalf("original durable handle CancelRPC %s: error=%v want-error=%t", outcome, err, wantError) + } + }) + } + cancelOriginal(true) + if api.loseCleanupReadback || api.deploymentCancels != 1 || api.updates != 2 || len(api.app.Spec.Jobs) != 0 || api.app.InProgressDeployment.ID == api.deployment.ID { + t.Fatal("native fixture did not cancel/remove and advance deployment hint before losing cleanup readback") + } + parent := jobClone(api.app.Spec) + wantDeploymentCancels = 1 + switch outcome { + case "cleanup-live-drift": + api.app.Spec.Services[0].RunCommand = "/changed-server" + cancelOriginal(true) + api.app.Spec = parent + case "cleanup-pending-owned": + api.deploymentPhase, api.ignoreDeployCancel = godo.DeploymentPhase_Deploying, true + cancelOriginal(true) + wantDeploymentCancels++ + api.deploymentPhase, api.ignoreDeployCancel = godo.DeploymentPhase_Canceled, false + } + cancelOriginal(false) + cancelOriginal(false) + persisted, err := os.ReadFile(filepath.Join(root, outcome+"-handle.json")) + if err != nil || string(persisted) != string(encoded) { + t.Fatal("CancelRPC recovery changed the original persisted handle") + } + } else { + load(func(adapter *external.ExternalPluginAdapter) { + client := pb.NewIaCProviderRunnerClient(adapter.Conn()) + if outcome != "interrupted-cancel" { + reply, err := client.JobStatus(ctx, handle) + if err != nil || (outcome == "invocation-succeeded" && reply.GetState() != pb.JobState_JOB_STATE_SUCCEEDED) || (outcome != "invocation-succeeded" && reply.GetState() != pb.JobState_JOB_STATE_FAILED) { + t.Fatalf("native restarted recovery %s: %v", outcome, err) + } + handle = jobClone(reply.GetHandle()) + } + _, err := pb.NewIaCProviderJobCancelerClient(adapter.Conn()).CancelJob(ctx, handle) + if outcome != "rejected" && err != nil { + t.Fatalf("native restarted cancellation %s: %v", outcome, err) + } + }) + } + if api.deploymentCancels != wantDeploymentCancels || len(api.app.Spec.Jobs) != 0 || (outcome == "rejected" && (api.updates != 1 || api.cancels != 0)) || (outcome == "interrupted-cancel" && api.cancels != 1) || (durableCancel && (api.updates != 2 || api.cancels != 0)) { + t.Fatalf("native %s did not preserve ownership/cleanup: updates=%d cancels=%d deployment-cancels=%d jobs=%d", outcome, api.updates, api.cancels, api.deploymentCancels, len(api.app.Spec.Jobs)) + } + encoded, err = json.Marshal(handle) + if err != nil || strings.Contains(string(encoded), "${db.DATABASE_URL}") || strings.Contains(string(encoded), "EV[") { + t.Fatal("native recovered handle persisted secret bytes") + } + write(outcome+"-recovered-handle.json", encoded) + cases = append(cases, map[string]any{"case": outcome, "updates": api.updates, "invocation_cancels": api.cancels, "deployment_cancels": api.deploymentCancels, "remaining_jobs": len(api.app.Spec.Jobs), "original_durable_handle_reloaded_per_cancel_rpc": durableCancel}) + cancel() + server.Close() + if conn, err := net.DialTimeout("tcp", strings.TrimPrefix(server.URL, "http://"), time.Second); err == nil { + _ = conn.Close() + t.Fatal("native API fixture listener was not reaped") + } + } + pids, err := os.ReadFile(filepath.Join(root, "owned-pids.txt")) + if err != nil { + t.Fatal(err) + } + for _, raw := range strings.Fields(string(pids)) { + pid, err := strconv.Atoi(raw) + if err != nil { + t.Fatal(err) + } + process, err := os.FindProcess(pid) + if err != nil { + t.Fatal(err) + } + if err := process.Signal(syscall.Signal(0)); err == nil { + t.Fatalf("owned production plugin child %d retained", pid) + } + } + binary, err := os.ReadFile(filepath.Join(plugin, name)) + if err != nil { + t.Fatal(err) + } + proof, err := json.MarshalIndent(map[string]any{"result": "PASS", "cases": cases, "plugin_sha256": fmt.Sprintf("%x", sha256.Sum256(binary)), "host": "released Workflow v0.86.1 external plugin manager/SDK; actual cmd/plugin entrypoint; HTTP-only loopback dependency overlay", "owned_children": strings.Fields(string(pids)), "owned_children_and_listeners_reaped": true, "secret_bytes_in_handles": false}, "", " ") + if err != nil { + t.Fatal(err) + } + write("runner-proof.json", proof) + t.Logf("native ownership/recovery proof: %s", filepath.Join(root, "runner-proof.json")) +} + +func appJobHTTPFake(t *testing.T, fake *appJobAPIFake) *httptest.Server { + t.Helper() + var mu sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + ctx := r.Context() + var response any + var err error + switch { + case r.URL.Path == "/v2/apps/app-uuid" && r.Method == http.MethodGet: + app, _, readErr := fake.Get(ctx, "app-uuid") + err = readErr + response = struct { + App *godo.App `json:"app"` + }{app} + case r.URL.Path == "/v2/apps/app-uuid" && r.Method == http.MethodPut: + var req godo.AppUpdateRequest + if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil { + t.Errorf("decode fake update: %v", decodeErr) + w.WriteHeader(http.StatusBadRequest) + return + } + app, _, writeErr := fake.Update(ctx, "app-uuid", &req) + err = writeErr + response = struct { + App *godo.App `json:"app"` + }{app} + case r.URL.Path == "/v2/apps/app-uuid/job-invocations" && r.Method == http.MethodGet: + opts := &godo.ListJobInvocationsOptions{} + if names := r.URL.Query().Get("job_names"); names != "" { + opts.JobNames = strings.Split(names, ",") + } + jobs, _, readErr := fake.ListJobInvocations(ctx, "app-uuid", opts) + err = readErr + response = struct { + Jobs []*godo.JobInvocation `json:"job_invocations"` + }{jobs} + case r.URL.Path == "/v2/apps/app-uuid/deployments" && r.Method == http.MethodGet: + deployments, _, readErr := fake.ListDeployments(ctx, "app-uuid", &godo.ListOptions{Page: 1, PerPage: 100}) + err = readErr + response = struct { + Deployments []*godo.Deployment `json:"deployments"` + }{deployments} + case r.URL.Path == "/v2/apps/app-uuid/job-invocations/invocation-uuid/cancel" && r.Method == http.MethodPost: + job, _, cancelErr := fake.CancelJobInvocation(ctx, "app-uuid", "invocation-uuid", &godo.CancelJobInvocationOptions{JobName: r.URL.Query().Get("job_name")}) + err = cancelErr + response = struct { + Job *godo.JobInvocation `json:"job_invocation"` + }{job} + case r.URL.Path == "/v2/apps/app-uuid/job-invocations/invocation-uuid" && r.Method == http.MethodGet: + job, _, readErr := fake.GetJobInvocation(ctx, "app-uuid", "invocation-uuid", &godo.GetJobInvocationOptions{JobName: r.URL.Query().Get("job_name")}) + err = readErr + response = struct { + Job *godo.JobInvocation `json:"job_invocation"` + }{job} + case strings.HasPrefix(r.URL.Path, "/v2/apps/app-uuid/jobs/") && strings.HasSuffix(r.URL.Path, "/invocations/invocation-uuid/logs") && r.Method == http.MethodGet: + parts := strings.Split(r.URL.Path, "/") + if r.URL.Query().Get("tail_lines") != "200" || r.URL.Query().Get("type") != "JOB_INVOCATION" { + t.Error("unbounded or wrong log request") + } + logs, _, readErr := fake.GetJobInvocationLogs(ctx, "app-uuid", "invocation-uuid", &godo.GetJobInvocationLogsOptions{JobName: parts[5], TailLines: 200}) + err = readErr + if logs == nil { + logs = &godo.AppLogs{} + } + response = logs + case r.URL.Path == "/v2/apps/app-uuid/deployments/deployment-uuid" && r.Method == http.MethodGet: + deployment, _, readErr := fake.GetDeployment(ctx, "app-uuid", "deployment-uuid") + err = readErr + response = struct { + Deployment *godo.Deployment `json:"deployment"` + }{deployment} + case r.URL.Path == "/v2/apps/app-uuid/deployments/deployment-uuid/cancel" && r.Method == http.MethodPost: + deployment, _, cancelErr := fake.CancelDeployment(ctx, "app-uuid", "deployment-uuid") + err = cancelErr + response = struct { + Deployment *godo.Deployment `json:"deployment"` + }{deployment} + default: + t.Errorf("unexpected provider request %s %s", r.Method, r.URL.RequestURI()) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `{"id":"fixture_failure","message":"provider request failed"}`) + return + } + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Errorf("encode fake API response: %v", err) + } + })) + t.Cleanup(server.Close) + return server +} + +func TestAppPlatformRunnerGRPCLifecycle(t *testing.T) { + api := newAppJobAPIFake() + api.app.Spec.Databases = []*godo.AppDatabaseSpec{{Name: "db"}} + server := appJobHTTPFake(t, api) + provider := NewDOProvider() + provider.client = godo.NewClient(server.Client()) + provider.client.BaseURL, _ = url.Parse(server.URL + "/") + conn := sensitiveInputTestConn(t, provider) + spec := validAppJobSpec() + handle, err := pb.NewIaCProviderRunnerClient(conn).RunJob(t.Context(), &pb.JobSpec{Name: spec.Name, Target: &pb.ResourceRef{Name: spec.Target.Name, Type: spec.Target.Type, ProviderId: spec.Target.ProviderID}, Image: spec.Image, RunCommand: spec.RunCommand, TimeoutSeconds: int32(spec.TimeoutSeconds), EnvVarsSecret: spec.EnvVarsSecret}) + if err != nil { + t.Fatal(err) + } + if _, err := pb.NewIaCProviderJobCancelerClient(conn).CancelJob(t.Context(), handle); err != nil { + t.Fatal(err) + } + if api.cancels != 1 || len(api.app.Spec.Jobs) != 0 { + t.Fatal("native gRPC forwarding did not cancel and clean up") + } + stream, err := pb.NewIaCProviderRunnerClient(conn).JobLogs(t.Context(), handle) + if err != nil { + t.Fatal(err) + } + chunk, err := stream.Recv() + if err != nil || !chunk.GetEof() { + t.Fatalf("expected log EOF chunk, got %v %v", chunk, err) + } + if _, err := stream.Recv(); err != io.EOF { + t.Fatalf("expected completed log stream, got %v", err) + } +} + +func TestAppPlatformRunnerGRPCRequiresInitializedProvider(t *testing.T) { + conn := sensitiveInputTestConn(t, NewDOProvider()) + _, err := pb.NewIaCProviderJobCancelerClient(conn).CancelJob(context.Background(), &pb.JobHandle{}) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("uninitialized native canceler: %v", err) + } +} diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go index baeaa31..f600a96 100644 --- a/internal/iacserver_test.go +++ b/internal/iacserver_test.go @@ -106,8 +106,8 @@ func TestDOIaCProviderRequiredServer_AllRPCs(t *testing.T) { t.Fatalf("Capabilities RPC: %v", err) } got := capsResp.GetCapabilities() - if len(got) != 17 { - t.Errorf("Capabilities len = %d, want 17 (one per DO resource type)", len(got)) + if len(got) != 18 { + t.Errorf("Capabilities len = %d, want 18 (one per DO resource type)", len(got)) } hasContainerService := false for _, c := range got { diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 27a6f2f..95b8bae 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -14,6 +14,7 @@ package internal import ( "archive/tar" "compress/gzip" + "encoding/json" "errors" "fmt" "io" @@ -166,6 +167,51 @@ func TestPluginBinaryEmbedsManifest(t *testing.T) { } } +func TestRecoveryResourceConfigDiscovery(t *testing.T) { + type field struct { + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` + } + for _, name := range []string{"plugin.json", "cmd/plugin/plugin.json"} { + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(testRepoRoot(t), name)) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Capabilities struct { + IaCProvider struct { + ConfigSchema map[string]struct { + Fields map[string]field `json:"fields"` + } `json:"configSchema"` + } `json:"iacProvider"` + } `json:"capabilities"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + for _, want := range []struct { + resource string + name string + typeName string + required bool + }{ + {"digitalocean.database_user", "database_id", "string", true}, + {"digitalocean.database_user", "username", "string", true}, + {"digitalocean.database_user", "rotation_epoch", "string", false}, + {"infra.database", "admin_rotation_epoch", "string", false}, + {"infra.droplet", "max_monthly_usd", "number", false}, + } { + got, ok := manifest.Capabilities.IaCProvider.ConfigSchema[want.resource].Fields[want.name] + if !ok || got.Type != want.typeName || got.Required != want.required || got.Description == "" { + t.Errorf("%s.%s discovery = %+v (present %t), want %s required=%t and a description", want.resource, want.name, got, ok, want.typeName, want.required) + } + } + }) + } +} + type archiveFileSpec struct { Src string Dst string diff --git a/internal/provider.go b/internal/provider.go index 8241de4..419a8e5 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -103,23 +103,24 @@ func (p *DOProvider) Initialize(ctx context.Context, config map[string]any) erro p.client = godo.NewClient(oauthClient) p.drivers = map[string]interfaces.ResourceDriver{ - "infra.container_service": drivers.NewAppPlatformDriver(p.client, p.region), - "infra.app_domain": drivers.NewAppDomainDriver(p.client), - "infra.k8s_cluster": drivers.NewKubernetesDriver(p.client, p.region), - "infra.database": drivers.NewDatabaseDriver(p.client, p.region), - "infra.cache": drivers.NewCacheDriver(p.client, p.region), - "infra.load_balancer": drivers.NewLoadBalancerDriver(p.client, p.region), - "infra.vpc": drivers.NewVPCDriver(p.client, p.region), - "infra.firewall": drivers.NewFirewallDriver(p.client), - "infra.dns": drivers.NewDNSDriver(p.client), - "infra.storage": drivers.NewSpacesDriver(p.client, p.region, spacesAccessKey, spacesSecretKey), - "infra.spaces_key": drivers.NewSpacesKeyDriver(p.client), - "infra.registry": drivers.NewRegistryDriver(p.client), - "infra.certificate": drivers.NewCertificateDriver(p.client), - "infra.droplet": drivers.NewDropletDriver(p.client, p.region), - "infra.volume": drivers.NewVolumeDriver(p.client, p.region), - "infra.iam_role": drivers.NewIAMRoleDriver(), - "infra.api_gateway": drivers.NewAPIGatewayDriver(p.client, p.region), + "infra.container_service": drivers.NewAppPlatformDriver(p.client, p.region), + "infra.app_domain": drivers.NewAppDomainDriver(p.client), + "infra.k8s_cluster": drivers.NewKubernetesDriver(p.client, p.region), + "infra.database": drivers.NewDatabaseDriver(p.client, p.region), + "digitalocean.database_user": drivers.NewDatabaseUserDriver(p.client), + "infra.cache": drivers.NewCacheDriver(p.client, p.region), + "infra.load_balancer": drivers.NewLoadBalancerDriver(p.client, p.region), + "infra.vpc": drivers.NewVPCDriver(p.client, p.region), + "infra.firewall": drivers.NewFirewallDriver(p.client), + "infra.dns": drivers.NewDNSDriver(p.client), + "infra.storage": drivers.NewSpacesDriver(p.client, p.region, spacesAccessKey, spacesSecretKey), + "infra.spaces_key": drivers.NewSpacesKeyDriver(p.client), + "infra.registry": drivers.NewRegistryDriver(p.client), + "infra.certificate": drivers.NewCertificateDriver(p.client), + "infra.droplet": drivers.NewDropletDriver(p.client, p.region), + "infra.volume": drivers.NewVolumeDriver(p.client, p.region), + "infra.iam_role": drivers.NewIAMRoleDriver(), + "infra.api_gateway": drivers.NewAPIGatewayDriver(p.client, p.region), } return nil } @@ -133,6 +134,7 @@ func (p *DOProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { {ResourceType: "infra.app_domain", Tier: 2, Operations: noScale}, {ResourceType: "infra.k8s_cluster", Tier: 1, Operations: ops}, {ResourceType: "infra.database", Tier: 1, Operations: ops}, + {ResourceType: "digitalocean.database_user", Tier: 1, Operations: noScale}, {ResourceType: "infra.cache", Tier: 1, Operations: ops}, {ResourceType: "infra.load_balancer", Tier: 1, Operations: ops}, {ResourceType: "infra.vpc", Tier: 1, Operations: ops}, @@ -255,15 +257,26 @@ func (p *DOProvider) ResolveSizing(resourceType string, size interfaces.Size, hi // (including the ForceNew → replace promotion), emits creates/deletes in // dependency-correct order, and consults the diff cache. // -// The 2-statement form (call + pointer-bridge return) is mandated by the -// W-Refactor / iac-codemod analyzer (cmd/iac-codemod AssertPlanDelegatesToHelper); -// see workflow CHANGELOG entry referencing platform.ComputePlan as the -// canonical target for v2 IaC providers. +// Live pricing is checked outside ComputePlan so a cached Diff or a new +// resource cannot bypass the cap. Action classification stays delegated to +// the canonical helper; quotes are exposed by ValidatePlan diagnostics. // // addresses workflow-plugin-digitalocean#63: the prior hand-rolled body // duplicated ComputePlan's classification logic and silently dropped the // ForceNew → replace upgrade path (only NeedsReplace was honored). func (p *DOProvider) Plan(ctx context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + var sizes drivers.DropletSizesClient + if p.client != nil { + sizes = p.client.Sizes + } + for _, spec := range desired { + if spec.Type != "infra.droplet" { + continue + } + if _, err := drivers.QuoteDropletPrice(ctx, sizes, spec); err != nil { + return nil, fmt.Errorf("digitalocean plan: %w", err) + } + } plan, err := platform.ComputePlan(ctx, p, desired, current) return &plan, err } diff --git a/internal/released_host_test.go b/internal/released_host_test.go new file mode 100644 index 0000000..241a39d --- /dev/null +++ b/internal/released_host_test.go @@ -0,0 +1,531 @@ +package internal + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +const releasedHostURL = "https://github.com/GoCodeAlone/workflow/releases/download/v0.86.1/wfctl-linux-amd64" +const releasedHostSize int64 = 153448928 +const releasedHostSHA256 = "b2522ff83ea13719faa1f7d290505875693b807f048521bcd9661b7941b61503" + +func releasedHostMode(getenv func(string) string, goos, arch string) (required, enabled bool, err error) { + ci := strings.ToLower(getenv("CI")) + required = (ci != "" && ci != "false" && ci != "0") || getenv("GITHUB_ACTIONS") == "true" + if !required { + return false, getenv("WORKFLOW_DO_TASK7_WFCTL") != "", nil + } + // These getenv calls are Go-cache freshness inputs, never authority. + _ = getenv("GITHUB_RUN_ID") + _ = getenv("GITHUB_RUN_ATTEMPT") + for _, key := range []string{"WORKFLOW_DO_TASK7_WFCTL", "WORKFLOW_DO_TASK7_BUILD_ROOT", "WORKFLOW_DO_TASK7_EVIDENCE"} { + if getenv(key) != "" { + return true, true, errors.New("required released host rejects local overrides") + } + } + if goos != "linux" || arch != "amd64" { + return true, true, errors.New("required released host needs linux/amd64") + } + return true, true, nil +} + +func downloadReleasedHost(ctx context.Context, dir string, transport http.RoundTripper, size int64, digest string) (string, error) { + client := &http.Client{Transport: transport, Timeout: 90 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) > 3 || !releasedHostOrigin(req.URL) { + return errors.New("released host redirect denied") + } + req.Header = make(http.Header) + return nil + }} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasedHostURL, nil) + if err != nil { + return "", errors.New("released host request failed") + } + resp, err := client.Do(req) + if err != nil { + return "", errors.New("released host download failed") + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", errors.New("released host HTTP status rejected") + } + file, err := os.CreateTemp(dir, "wfctl-") + if err != nil { + return "", errors.New("released host temporary file failed") + } + verified := false + defer func() { + _ = file.Close() + if !verified { + _ = os.Remove(file.Name()) + } + }() + hash := sha256.New() + n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(resp.Body, size+1)) + if err != nil || ctx.Err() != nil || n != size || fmt.Sprintf("%x", hash.Sum(nil)) != digest { + return "", errors.New("released host integrity check failed") + } + if err := file.Close(); err != nil { + return "", errors.New("released host file close failed") + } + if err := os.Chmod(file.Name(), 0o700); err != nil { + return "", errors.New("released host executable permission failed") + } + verified = true + return file.Name(), nil +} + +func releasedHostOrigin(u *url.URL) bool { + return u != nil && u.Scheme == "https" && u.User == nil && u.Opaque == "" && u.Fragment == "" && (u.Host == "github.com" || u.Host == "release-assets.githubusercontent.com") +} + +func releasedHostTransport() *http.Transport { + return &http.Transport{DialContext: (&net.Dialer{Timeout: 15 * time.Second}).DialContext, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second, DisableKeepAlives: true} +} + +func releasedHostEnvironment(root string) []string { + return []string{"HOME=" + filepath.Join(root, "home"), "XDG_CONFIG_HOME=" + filepath.Join(root, "config"), "TMPDIR=" + filepath.Join(root, "tmp"), "PATH=/usr/bin:/bin", "LANG=C", "GOWORK=off", "GOENV=off", "GOTOOLCHAIN=go1.26.5", "GOFLAGS=-mod=readonly"} +} + +func releasedHostGoTool() (string, error) { + goTool, err := exec.LookPath("go") + if err != nil { + return "", errors.New("Go build tool unavailable") + } + return goTool, nil +} + +func releasedHostCloseAPI(api *httptest.Server, cancel context.CancelFunc) { + cancel() + api.CloseClientConnections() + api.Close() +} + +func TestReleasedHostFixtureCancellationClosesActiveHandler(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + entered := make(chan struct{}) + api := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() })) + api.Config.BaseContext = func(net.Listener) context.Context { return ctx } + api.Start() + defer api.Close() + transport := releasedHostTransport() + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: time.Second} + requestDone := make(chan struct{}) + go func() { + defer close(requestDone) + resp, err := client.Post(api.URL, "application/json", strings.NewReader(`{"fixture":true}`)) + if err == nil { + _ = resp.Body.Close() + } + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("owned fixture request did not start") + } + closed := make(chan struct{}) + go func() { releasedHostCloseAPI(api, cancel); close(closed) }() + select { + case <-closed: + case <-time.After(300 * time.Millisecond): + cancel() + <-closed + t.Error("fixture cleanup did not cancel an active unread request") + } + <-requestDone + if probe, err := net.DialTimeout("tcp", strings.TrimPrefix(api.URL, "http://"), time.Second); err == nil { + _ = probe.Close() + t.Fatal("owned listener survived cancellation cleanup") + } +} + +func releasedHostCommand(ctx context.Context, env []string, dir, executable string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, executable, args...) + cmd.Dir, cmd.Env = dir, env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return errors.New("owned process group cleanup failed") + } + return nil + } + cmd.WaitDelay = time.Second + return cmd +} + +func verifyReleasedHostFile(path string, size int64, digest string) error { + file, err := os.Open(path) + if err != nil { + return errors.New("local released host cannot be opened") + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() != size { + return errors.New("local released host size rejected") + } + hash := sha256.New() + if n, err := io.Copy(hash, io.LimitReader(file, size+1)); err != nil || n != size || fmt.Sprintf("%x", hash.Sum(nil)) != digest { + return errors.New("local released host digest rejected") + } + return nil +} + +func reapReleasedHostChildren(path string, kill bool) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) && kill { + return nil + } + if err != nil { + return errors.New("owned child ledger unavailable") + } + deadline := time.Now().Add(2 * time.Second) + for _, raw := range strings.Fields(string(data)) { + pid, err := strconv.Atoi(raw) + if err != nil || pid < 2 || pid == os.Getpid() { + return errors.New("owned child ledger invalid") + } + if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { + continue + } + if !kill { + return errors.New("owned native child still running") + } + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return errors.New("owned native child termination failed") + } + for !errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) { + if time.Now().After(deadline) { + return errors.New("owned native child not reaped") + } + time.Sleep(10 * time.Millisecond) + } + } + return nil +} + +type releasedHostFailingBody struct { + ctx context.Context + first bool + timeout bool + inspect func() +} + +func (b *releasedHostFailingBody) Read(p []byte) (int, error) { + if !b.first { + b.first = true + p[0] = 'x' + return 1, nil + } + b.inspect() + if b.timeout { + <-b.ctx.Done() + } + return 0, errors.New("sensitive-body-error") +} + +func (*releasedHostFailingBody) Close() error { return nil } + +func TestReleasedHostPartialDownloadNeverExecutable(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(fmt.Sprint(timeout), func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Millisecond) + defer cancel() + inspected := false + body := &releasedHostFailingBody{ctx: ctx, timeout: timeout, inspect: func() { + files, err := os.ReadDir(dir) + if err != nil || len(files) != 1 { + t.Fatal("partial download ownership missing") + } + info, err := files[0].Info() + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatal("unverified partial bytes became executable") + } + inspected = true + }} + transport := releasedHostRoundTrip(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: body, Request: r}, nil + }) + file, err := downloadReleasedHost(ctx, dir, transport, 2, strings.Repeat("0", 64)) + files, _ := os.ReadDir(dir) + if err == nil || file != "" || len(files) != 0 || !inspected || strings.Contains(err.Error(), "sensitive") { + t.Fatal("failed partial download was not safely cleaned") + } + }) + } +} + +func TestReleasedHostRedirectLimit(t *testing.T) { + calls := 0 + transport := releasedHostRoundTrip(func(r *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: http.StatusFound, Header: http.Header{"Location": {releasedHostURL}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil + }) + dir := t.TempDir() + file, err := downloadReleasedHost(t.Context(), dir, transport, 1, strings.Repeat("0", 64)) + files, _ := os.ReadDir(dir) + if err == nil || file != "" || len(files) != 0 || calls != 4 { + t.Fatal("download redirects are not bounded") + } +} + +func TestReleasedHostFailureCleanupReapsOwnedChild(t *testing.T) { + root := t.TempDir() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + cmd := releasedHostCommand(ctx, releasedHostEnvironment(root), "", "/bin/sleep", "0.5") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + defer func() { _ = cmd.Process.Kill() }() + path := filepath.Join(root, "owned-pids.txt") + if err := os.WriteFile(path, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600); err != nil { + t.Fatal(err) + } + if err := reapReleasedHostChildren(path, false); err == nil { + t.Fatal("consumer would report PASS with an owned child still running") + } + if err := reapReleasedHostChildren(path, true); err != nil { + t.Fatal(err) + } + if err := <-waited; err == nil { + t.Fatal("cleanup did not kill owned child") + } + if cmd.Process.Signal(syscall.Signal(0)) == nil { + t.Fatal("failed consumer left an owned process") + } +} + +func TestReleasedHostTransportHasNoAmbientCredentials(t *testing.T) { + transport := releasedHostTransport() + defer transport.CloseIdleConnections() + if transport.Proxy != nil || transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("download transport inherits proxy credentials or waives TLS") + } +} + +func TestReleasedHostChildEnvironment(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "credential-sentinel") + t.Setenv("DIGITALOCEAN_TOKEN", "credential-sentinel") + t.Setenv("HTTPS_PROXY", "credential-sentinel") + t.Setenv("CI", "true") + root := t.TempDir() + for _, entry := range releasedHostEnvironment(root) { + key, value, _ := strings.Cut(entry, "=") + if value == "credential-sentinel" || key == "CI" || key == "GITHUB_ACTIONS" { + t.Fatal("native child inherited CI/cloud credentials") + } + if (key == "HOME" || key == "TMPDIR" || key == "XDG_CONFIG_HOME") && !strings.HasPrefix(value, root+string(filepath.Separator)) { + t.Fatal("native child paths are not owned") + } + } +} + +func TestReleasedHostCommandTimeoutReapsChild(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + cmd := releasedHostCommand(ctx, releasedHostEnvironment(t.TempDir()), "", "/bin/sleep", "0.3") + start := time.Now() + if err := cmd.Run(); err == nil || time.Since(start) > 250*time.Millisecond { + t.Fatal("native command ignored its context deadline") + } + if cmd.Process == nil || cmd.Process.Signal(syscall.Signal(0)) == nil { + t.Fatal("owned command was not reaped") + } +} + +type releasedHostRoundTrip func(*http.Request) (*http.Response, error) + +func (f releasedHostRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestReleasedHostMode(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + goos, arch string + required, enabled, denied bool + }{ + {name: "ordinary local", goos: "darwin", arch: "arm64"}, + {name: "deliberate local", env: map[string]string{"WORKFLOW_DO_TASK7_WFCTL": "/local/verified-host"}, goos: "darwin", arch: "arm64", enabled: true}, + {name: "CI cannot skip", env: map[string]string{"CI": "true"}, goos: "linux", arch: "amd64", required: true, enabled: true}, + {name: "Actions cannot disable CI", env: map[string]string{"CI": "false", "GITHUB_ACTIONS": "true"}, goos: "linux", arch: "amd64", required: true, enabled: true}, + {name: "unsupported OS fails", env: map[string]string{"CI": "true"}, goos: "darwin", arch: "arm64", required: true, enabled: true, denied: true}, + {name: "unsupported arch fails", env: map[string]string{"CI": "true"}, goos: "linux", arch: "arm64", required: true, enabled: true, denied: true}, + } { + t.Run(tc.name, func(t *testing.T) { + required, enabled, err := releasedHostMode(func(k string) string { return tc.env[k] }, tc.goos, tc.arch) + if required != tc.required || enabled != tc.enabled || (err != nil) != tc.denied { + t.Fatalf("required=%t enabled=%t denied=%t; want %t/%t/%t", required, enabled, err != nil, tc.required, tc.enabled, tc.denied) + } + }) + } + for _, override := range []string{"WORKFLOW_DO_TASK7_WFCTL", "WORKFLOW_DO_TASK7_BUILD_ROOT", "WORKFLOW_DO_TASK7_EVIDENCE"} { + t.Run(override, func(t *testing.T) { + _, _, err := releasedHostMode(func(k string) string { + if k == "CI" { + return "true" + } + if k == override { + return "sensitive-override" + } + return "" + }, "linux", "amd64") + if err == nil || strings.Contains(err.Error(), "sensitive-override") { + t.Fatal("required CI accepted or exposed an arbitrary override") + } + }) + } +} + +func TestReleasedHostCacheInputs(t *testing.T) { + reads := map[string]bool{} + _, _, _ = releasedHostMode(func(k string) string { + reads[k] = true + if k == "CI" { + return "true" + } + return "" + }, "linux", "amd64") + if !reads["GITHUB_RUN_ID"] || !reads["GITHUB_RUN_ATTEMPT"] { + t.Fatal("required proof did not consume both Go-cache metadata inputs") + } + // Exercise real getenv instrumentation too; the command-level cache + // experiment uses this unit boundary as its cacheable control, not a + // replacement for the uncached native consumer proof. + _, _, _ = releasedHostMode(os.Getenv, runtime.GOOS, runtime.GOARCH) +} + +func TestReleasedHostDownloadIntegrityAndCleanup(t *testing.T) { + const payload = "verified-test-asset" + digest := fmt.Sprintf("%x", sha256.Sum256([]byte(payload))) + for _, tc := range []struct { + name, body string + status int + size int64 + digest string + ok bool + }{ + {"verified", payload, http.StatusOK, int64(len(payload)), digest, true}, + {"HTTP failure", "sensitive-error-body", http.StatusForbidden, int64(len(payload)), digest, false}, + {"short", payload[:3], http.StatusOK, int64(len(payload)), digest, false}, + {"oversize", payload + "x", http.StatusOK, int64(len(payload)), digest, false}, + {"digest mismatch", payload, http.StatusOK, int64(len(payload)), strings.Repeat("0", 64), false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + calls := 0 + transport := releasedHostRoundTrip(func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.String() != releasedHostURL || len(r.Header) != 0 { + t.Fatal("download changed pinned URL or supplied headers") + } + return &http.Response{StatusCode: tc.status, Body: io.NopCloser(strings.NewReader(tc.body)), Header: make(http.Header), Request: r}, nil + }) + file, err := downloadReleasedHost(t.Context(), dir, transport, tc.size, tc.digest) + if (err == nil) != tc.ok || calls != 1 { + t.Fatalf("verified=%t requests=%d; want %t/1", err == nil, calls, tc.ok) + } + entries, readErr := os.ReadDir(dir) + if readErr != nil { + t.Fatal(readErr) + } + if !tc.ok { + if file != "" || len(entries) != 0 || strings.Contains(err.Error(), "sensitive") || strings.Contains(err.Error(), "https:") { + t.Fatal("failure exposed download data or left partial executable") + } + return + } + data, err := os.ReadFile(file) + if err != nil || string(data) != payload || len(entries) != 1 || filepath.Dir(file) != dir { + t.Fatal("verified host bytes or ownership differ") + } + info, err := os.Stat(file) + if err != nil || info.Mode().Perm() != 0o700 { + t.Fatal("only verified bytes may become executable") + } + }) + } +} + +func TestReleasedHostDownloadRedirects(t *testing.T) { + const payload = "verified-test-asset" + digest := fmt.Sprintf("%x", sha256.Sum256([]byte(payload))) + for _, target := range []string{ + "https://release-assets.githubusercontent.com/asset?signed=sensitive", + "http://release-assets.githubusercontent.com/asset", + "https://user:password@release-assets.githubusercontent.com/asset", + "https://release-assets.githubusercontent.com:444/asset", + "https://unreviewed.example/asset", + "https://release-assets.githubusercontent.com.evil.example/asset", + } { + t.Run(strings.ReplaceAll(target, "/", "_"), func(t *testing.T) { + dir := t.TempDir() + calls := 0 + transport := releasedHostRoundTrip(func(r *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return &http.Response{StatusCode: http.StatusFound, Header: http.Header{"Location": {target}, "Set-Cookie": {"secret=cookie"}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil + } + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { + t.Fatal("redirect inherited credentials") + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(payload)), Request: r}, nil + }) + file, err := downloadReleasedHost(t.Context(), dir, transport, int64(len(payload)), digest) + approved := target == "https://release-assets.githubusercontent.com/asset?signed=sensitive" + if (err == nil) != approved { + t.Fatalf("approved=%t success=%t", approved, err == nil) + } + if !approved && (calls != 1 || file != "") { + t.Fatal("denied redirect reached another origin or left a host") + } + if err != nil && (strings.Contains(err.Error(), "sensitive") || strings.Contains(err.Error(), "https:") || strings.Contains(err.Error(), "password")) { + t.Fatal("raw redirect error leaked") + } + }) + } +} + +func TestReleasedHostDownloadTimeoutAndRawError(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(fmt.Sprint(timeout), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Millisecond) + defer cancel() + dir := t.TempDir() + transport := releasedHostRoundTrip(func(r *http.Request) (*http.Response, error) { + if timeout { + <-r.Context().Done() + } + return nil, errors.New("https://user:credential@unreviewed.example?signed=secret") + }) + file, err := downloadReleasedHost(ctx, dir, transport, 1, strings.Repeat("0", 64)) + entries, _ := os.ReadDir(dir) + if err == nil || file != "" || len(entries) != 0 || strings.Contains(err.Error(), "credential") || strings.Contains(err.Error(), "https:") { + t.Fatal("timeout/transport failure was not bounded, sanitized and cleaned") + } + }) + } +} diff --git a/internal/resourcedriver_sensitive_inputs_test.go b/internal/resourcedriver_sensitive_inputs_test.go new file mode 100644 index 0000000..acf6f11 --- /dev/null +++ b/internal/resourcedriver_sensitive_inputs_test.go @@ -0,0 +1,773 @@ +package internal + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + goplugin "github.com/GoCodeAlone/go-plugin" + "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" + pluginpkg "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "github.com/GoCodeAlone/workflow/secrets" + "github.com/hashicorp/go-hclog" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type sensitiveInputTestDriver struct { + interfaces.ResourceDriver + paths []string + err error + createCalls atomic.Int32 +} + +func (d *sensitiveInputTestDriver) SensitiveInputPaths(context.Context) ([]string, error) { + return append([]string(nil), d.paths...), d.err +} + +func (d *sensitiveInputTestDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.createCalls.Add(1) + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "fixture-id"}, nil +} + +type stateUpdateTestDriver struct { + sensitiveInputTestDriver + legacyCalls atomic.Int32 + stateCalls atomic.Int32 +} + +func (d *stateUpdateTestDriver) Update(_ context.Context, ref interfaces.ResourceRef, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.legacyCalls.Add(1) + return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil +} + +func (d *stateUpdateTestDriver) UpdateWithState(_ context.Context, ref interfaces.ResourceRef, _ interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) { + d.stateCalls.Add(1) + encoded, err := json.Marshal(prior) + if err != nil { + return nil, err + } + return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Outputs: map[string]any{"prior": string(encoded)}}, nil +} + +func TestDOResourceUpdateWithStateSDKBridge(t *testing.T) { + provider := NewDOProvider() + driver := &stateUpdateTestDriver{} + legacy := &stateUpdateTestDriver{} + // The legacy wrapper intentionally does not expose ResourceStateUpdater. + provider.drivers = map[string]interfaces.ResourceDriver{"fixture.state": driver, "fixture.legacy": struct{ interfaces.ResourceDriver }{legacy}} + adapter := providerclient.New(sensitiveInputTestConn(t, provider), map[string]bool{providerclient.IaCServiceResourceDriver: true}) + rd, err := adapter.ResourceDriver("fixture.state") + if err != nil { + t.Fatal(err) + } + ref := interfaces.ResourceRef{Name: "identity", Type: "fixture.state", ProviderID: "parent/user"} + spec := interfaces.ResourceSpec{Name: ref.Name, Type: ref.Type, Config: map[string]any{"rotation_epoch": "2"}} + prior := &interfaces.ResourceState{ID: "state-id", Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, + Provider: "digitalocean", ProviderRef: "provider-module", ConfigHash: "hash", AppliedConfigSource: "apply", + Outputs: map[string]any{"rotation_epoch": "2", "password": "secret://iac/identity/password"}, + AppliedConfig: map[string]any{"rotation_epoch": "2"}, Dependencies: []string{"parent"}, + CreatedAt: time.Unix(1700000000, 0).UTC(), UpdatedAt: time.Unix(1700000001, 0).UTC(), LastDriftCheck: time.Unix(1700000002, 0).UTC()} + updater := rd.(interfaces.ResourceStateUpdater) + out, err := updater.UpdateWithState(t.Context(), ref, spec, prior) + if err != nil { + t.Fatal(err) + } + var forwarded interfaces.ResourceState + if raw, ok := out.Outputs["prior"].(string); !ok { + t.Fatal("SDK bridge discarded authoritative prior state") + } else if err := json.Unmarshal([]byte(raw), &forwarded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(&forwarded, prior) || driver.stateCalls.Load() != 1 || driver.legacyCalls.Load() != 0 { + t.Fatal("SDK bridge did not forward exact public prior state") + } + if _, err := rd.Update(t.Context(), ref, spec); err != nil { + t.Fatal(err) + } + if driver.stateCalls.Load() != 2 { + t.Fatal("nil state did not reach native state updater") + } + legacyRD, err := adapter.ResourceDriver("fixture.legacy") + if err != nil { + t.Fatal(err) + } + ref.Type, spec.Type = "fixture.legacy", "fixture.legacy" + if _, err := legacyRD.Update(t.Context(), ref, spec); err != nil || legacy.legacyCalls.Load() != 1 { + t.Fatalf("legacy server fallback changed: %v", err) + } +} + +func TestDOResourceUpdateWithStateSDKRejectsUnboundPrior(t *testing.T) { + provider := NewDOProvider() + driver := &stateUpdateTestDriver{} + provider.drivers = map[string]interfaces.ResourceDriver{"fixture.state": driver} + client := pb.NewResourceDriverClient(sensitiveInputTestConn(t, provider)) + for _, invalid := range []string{"name", "type", "provider-id", "resource-type", "outputs-json"} { + t.Run(invalid, func(t *testing.T) { + req := &pb.ResourceUpdateRequest{ResourceType: "fixture.state", Ref: &pb.ResourceRef{Name: "identity", Type: "fixture.state", ProviderId: "id"}, + Spec: &pb.ResourceSpec{Name: "identity", Type: "fixture.state", ConfigJson: []byte(`{}`)}, + PriorState: &pb.ResourceState{Name: "identity", Type: "fixture.state", ProviderId: "id", OutputsJson: []byte(`{"rotation_epoch":"1"}`)}} + switch invalid { + case "name": + req.PriorState.Name = "other" + case "type": + req.PriorState.Type = "other" + case "provider-id": + req.PriorState.ProviderId = "other" + case "resource-type": + req.ResourceType = "other" + case "outputs-json": + req.PriorState.OutputsJson = []byte(`{"password":"known-literal-do-credential"`) + } + _, err := client.Update(t.Context(), req) + if status.Code(err) != codes.InvalidArgument || strings.Contains(err.Error(), "known-literal-do-credential") { + t.Fatalf("invalid prior wire state not safely rejected: %v", err) + } + if driver.stateCalls.Load()+driver.legacyCalls.Load() != 0 { + t.Fatal("invalid state reached driver") + } + }) + } +} + +// This init-only overlay substitutes HTTP, not the production plugin entrypoint, +// SDK, provider, drivers, state store, plan producer, or released wfctl host. +const databaseConsumerHTTPOverlay = `package main +import ( + "fmt" + "net/http" + "net/url" + "os" +) +type task7Transport struct { target *url.URL; base http.RoundTripper } +func (t task7Transport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != "api.digitalocean.com" || req.Header.Get("Authorization") != "Bearer fixture-token" { + return nil, fmt.Errorf("fixture denies non-DigitalOcean request or nonfixture credential") + } + r := req.Clone(req.Context()) + r.URL.Scheme, r.URL.Host, r.Host = t.target.Scheme, t.target.Host, "" + r.Header.Set("X-Task7-Fixture-Pid", fmt.Sprint(os.Getpid())) + return t.base.RoundTrip(r) +} +func init() { + target, err := url.Parse(os.Getenv("WORKFLOW_DO_TASK7_API")) + if err != nil || target.Scheme != "http" || target.Hostname() != "127.0.0.1" { panic("fixture requires loopback HTTP") } + http.DefaultClient = &http.Client{Transport: task7Transport{target, http.DefaultTransport}} + f, err := os.OpenFile(os.Getenv("WORKFLOW_DO_TASK7_PIDS"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { panic("fixture cannot record owned child") } + if _, err := fmt.Fprintln(f, os.Getpid()); err != nil { panic("fixture cannot record owned child") } + if err := f.Close(); err != nil { panic("fixture cannot close child ledger") } +} +` + +type releasedHostConsumerProof struct { + Result string `json:"result"` + WFCTLVersion string `json:"wfctl_version"` + WFCTLSHA256 string `json:"wfctl_sha256"` + PluginSHA256 string `json:"plugin_sha256"` + Dependency string `json:"dependency"` + SavedPlanProducer string `json:"saved_plan_producer"` + LatestEpoch string `json:"saved_plan_latest_epoch"` + RotatedEpoch string `json:"rotated_epoch"` + FailedResetEpoch string `json:"failed_reset_retained_epoch"` + RetryEpoch string `json:"retry_epoch"` + APICalls map[string]int `json:"api_calls"` + APIEvents []string `json:"api_events"` + OwnedChildren []string `json:"owned_children"` + ChildrenReaped bool `json:"owned_children_reaped"` + ListenerReaped bool `json:"owned_listener_reaped"` + Plaintext bool `json:"plaintext_in_state_plan_logs"` +} + +func TestDODatabaseRotationNativeReleasedWFCTL(t *testing.T) { + required, enabled, err := releasedHostMode(os.Getenv, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + if !enabled { + t.Skip("set WORKFLOW_DO_TASK7_WFCTL to the downloaded native released wfctl") + } + _ = os.Getenv("GITHUB_RUN_ID") + _ = os.Getenv("GITHUB_RUN_ATTEMPT") + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + t.Cleanup(cancel) + root := t.TempDir() + if evidence := os.Getenv("WORKFLOW_DO_TASK7_EVIDENCE"); evidence != "" { + var err error + root, err = os.MkdirTemp(evidence, "native-") + if err != nil { + t.Fatal(err) + } + } + for _, dir := range []string{"home", "config", "tmp"} { + if err := os.Mkdir(filepath.Join(root, dir), 0o700); err != nil { + t.Fatal("cannot create owned native environment") + } + } + wfctl := os.Getenv("WORKFLOW_DO_TASK7_WFCTL") + hostDigest := releasedHostSHA256 + if required { + transport := releasedHostTransport() + defer transport.CloseIdleConnections() + wfctl, err = downloadReleasedHost(ctx, root, transport, releasedHostSize, releasedHostSHA256) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Remove(wfctl); err != nil && !os.IsNotExist(err) { + t.Error("cannot remove owned released host") + } + }) + } else { + size := releasedHostSize + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + size, hostDigest = 112815442, "64bfdf1a3f99bd634dd2a0c8118a5145cc322149bf34eb1ea30a64836a3adf20" + } else if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Fatal("local released host platform unsupported") + } + if err := verifyReleasedHostFile(wfctl, size, hostDigest); err != nil { + t.Fatal(err) + } + } + repoRoot := testRepoRoot(t) + if buildRoot := os.Getenv("WORKFLOW_DO_TASK7_BUILD_ROOT"); buildRoot != "" { + repoRoot = buildRoot + } + pluginName := readPluginName(t, filepath.Join(repoRoot, "plugin.json")) + pluginsDir := filepath.Join(root, "plugins") + pluginDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"plugin.json", "plugin.contracts.json"} { + copyFile(t, filepath.Join(repoRoot, name), filepath.Join(pluginDir, name)) + } + write := func(name string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + write("http_dependency.go", []byte(databaseConsumerHTTPOverlay)) + overlay, err := json.Marshal(map[string]any{"Replace": map[string]string{ + filepath.Join(repoRoot, "cmd/plugin/task7_http_dependency.go"): filepath.Join(root, "http_dependency.go"), + }}) + if err != nil { + t.Fatal(err) + } + write("overlay.json", overlay) + childEnv := releasedHostEnvironment(root) + buildEnv := append([]string(nil), childEnv...) + home, err := os.UserHomeDir() + if err != nil { + t.Fatal("cannot resolve toolchain module directory") + } + moduleCache := os.Getenv("GOMODCACHE") + if moduleCache == "" { + moduleCache = filepath.Join(home, "go", "pkg", "mod") + } + buildCache := os.Getenv("GOCACHE") + if buildCache == "" { + buildCache = filepath.Join(root, "build-cache") + } + buildEnv = append(buildEnv, "GOMODCACHE="+moduleCache, "GOCACHE="+buildCache, "CGO_ENABLED=0") + buildCtx, buildCancel := context.WithTimeout(ctx, 2*time.Minute) + defer buildCancel() + goTool, err := releasedHostGoTool() + if err != nil { + t.Fatal(err) + } + build := releasedHostCommand(buildCtx, buildEnv, repoRoot, goTool, "build", "-overlay", filepath.Join(root, "overlay.json"), "-o", filepath.Join(pluginDir, pluginName), "./cmd/plugin") + if output, err := build.CombinedOutput(); err != nil { + _ = output + t.Fatal("production entrypoint build failed") + } + const parent = "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30" + const sentinel = "NATIVE_TASK7_KNOWN_SECRET:/?#@%+" + var mu sync.Mutex + calls := map[string]int{} + var events []string + failReset := false + apiCtx, apiCancel := context.WithCancel(ctx) + api := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + key := r.Method + " " + r.URL.EscapedPath() + calls[key]++ + events = append(events, r.Header.Get("X-Task7-Fixture-Pid")+" "+key) + w.Header().Set("Content-Type", "application/json") + base := "/v2/databases/" + parent + switch { + case r.Method == http.MethodGet && r.URL.Path == base: + _ = json.NewEncoder(w).Encode(map[string]any{"database": map[string]any{ + "id": parent, "name": "db", "engine": "pg", "region": "nyc3", "version": "16", "status": "online", "size": "db-s-1vcpu-1gb", "num_nodes": 1, + "connection": map[string]any{"host": "db.fixture.test", "port": 25060, "database": "defaultdb", "user": "doadmin", "password": sentinel, "uri": "DO_NOT_COPY_ADMIN_URI", "ssl": true}, + }}) + case r.Method == http.MethodPut && (r.URL.Path == base+"/resize" || r.URL.Path == base+"/firewall"): + w.WriteHeader(http.StatusNoContent) + case (r.Method == http.MethodGet && r.URL.Path == base+"/users/app-user") || (r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/reset_auth")): + if r.Method == http.MethodPost && failReset { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"message": sentinel}) + return + } + username := "app-user" + if strings.Contains(r.URL.Path, "/doadmin/") { + username = "doadmin" + } + _ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"name": username, "role": "normal", "password": sentinel}}) + default: + t.Errorf("unexpected external API fixture request %s", key) + w.WriteHeader(http.StatusInternalServerError) + } + })) + api.Config.BaseContext = func(net.Listener) context.Context { return apiCtx } + api.Start() + closeAPI := func() { releasedHostCloseAPI(api, apiCancel) } + t.Cleanup(closeAPI) + pidFile := filepath.Join(root, "owned-pids.txt") + childEnv = append(childEnv, "WORKFLOW_DO_TASK7_API="+api.URL, "WORKFLOW_DO_TASK7_PIDS="+pidFile) + t.Cleanup(func() { + if err := reapReleasedHostChildren(pidFile, true); err != nil { + t.Error(err) + } + closeAPI() + if probe, err := net.DialTimeout("tcp", strings.TrimPrefix(api.URL, "http://"), time.Second); err == nil { + _ = probe.Close() + t.Error("owned fixture listener survived cleanup") + } + }) + secretDir := filepath.Join(root, "secrets") + t.Cleanup(func() { _ = os.RemoveAll(secretDir) }) + if err := os.MkdirAll(secretDir, 0o700); err != nil { + t.Fatal(err) + } + configuration := fmt.Sprintf("secrets:\n provider: file\n config:\n path: %q\nmodules:\n - name: do-provider\n type: iac.provider\n config:\n provider: digitalocean\n token: fixture-token\n - name: state\n type: iac.state\n config:\n backend: filesystem\n directory: %q\n", secretDir, filepath.Join(root, "state")) + write("config.yaml", []byte(configuration)) + store, err := wfctlhelpers.ResolveStateStore(filepath.Join(root, "config.yaml"), "", pluginsDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + states := []interfaces.ResourceState{ + {ID: "app-user", Name: "app-user", Type: "digitalocean.database_user", Provider: "digitalocean", ProviderRef: "do-provider", ProviderID: parent + "/app-user", Outputs: map[string]any{"rotation_epoch": "1"}}, + {ID: "db", Name: "db", Type: "infra.database", Provider: "digitalocean", ProviderRef: "do-provider", ProviderID: parent, Outputs: map[string]any{"admin_rotation_epoch": "1", "engine": "pg", "version": "16", "region": "nyc3", "size": "db-s-1vcpu-1gb", "num_nodes": float64(1)}}, + } + secretStore := secrets.NewFileProvider(secretDir) + for i := range states { + for _, key := range []string{"password", "uri"} { + states[i].Outputs[key] = sensitive.Placeholder(states[i].Name, key) + if err := secretStore.Set(ctx, sensitive.SecretKey(states[i].Name, key), sentinel); err != nil { + t.Fatal(err) + } + } + if err := store.SaveResource(ctx, states[i]); err != nil { + t.Fatal(err) + } + } + producePlan := func(epoch, filename string) { + t.Helper() + rpcCtx, rpcCancel := context.WithTimeout(ctx, 30*time.Second) + defer rpcCancel() + manifest, err := pluginpkg.LoadManifest(filepath.Join(pluginDir, "plugin.json")) + if err != nil { + t.Fatal("cannot load real plugin manifest") + } + if err := manifest.Validate(); err != nil { + t.Fatal("real plugin manifest invalid") + } + pluginLog, err := os.CreateTemp(root, "plan-rpc-*.log") + if err != nil { + t.Fatal("cannot create owned Plan RPC log") + } + cmd := releasedHostCommand(rpcCtx, childEnv, pluginDir, filepath.Join(pluginDir, pluginName)) + client := goplugin.NewClient(&goplugin.ClientConfig{HandshakeConfig: external.Handshake, Plugins: goplugin.PluginSet{"plugin": &external.GRPCPlugin{}}, Cmd: cmd, SkipHostEnv: true, StartTimeout: 10 * time.Second, Logger: hclog.NewNullLogger(), Stderr: pluginLog, SyncStderr: pluginLog, SyncStdout: pluginLog, AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}}) + defer func() { + client.Kill() + _ = pluginLog.Close() + data, err := os.ReadFile(pluginLog.Name()) + if err != nil { + t.Error("owned Plan RPC log unavailable") + } + if strings.Contains(string(data), sentinel) { + _ = os.Remove(pluginLog.Name()) + t.Error("Plan RPC plugin logged plaintext") + } + }() + rpcClient, err := client.Client() + if err != nil { + t.Fatal("real plugin handshake failed") + } + raw, err := rpcClient.Dispense("plugin") + if err != nil { + t.Fatal("real public SDK plugin dispense failed") + } + pluginClient, ok := raw.(*external.PluginClient) + if !ok { + t.Fatal("real plugin did not expose public SDK client") + } + adapter, err := external.NewExternalPluginAdapter(pluginName, pluginClient, manifest) + if err != nil { + t.Fatal("real public SDK adapter initialization failed") + } + if !registryHasService(adapter.ContractRegistry(), pb.ResourceDriver_ServiceDesc.ServiceName) { + t.Fatal("actual built plugin omitted ResourceDriver service") + } + provider := providerclient.New(adapter.Conn(), map[string]bool{providerclient.IaCServiceResourceDriver: true, providerclient.IaCServiceSensitiveInputDeclarer: true}) + if err := provider.Initialize(rpcCtx, map[string]any{"token": "fixture-token"}); err != nil { + t.Fatal("real provider initialization failed") + } + current, err := store.ListResources(rpcCtx) + if err != nil { + t.Fatal(err) + } + desired := []interfaces.ResourceSpec{ + {Name: "app-user", Type: "digitalocean.database_user", Config: map[string]any{"provider": "do-provider", "database_id": parent, "username": "app-user", "rotation_epoch": epoch}}, + {Name: "db", Type: "infra.database", Config: map[string]any{"provider": "do-provider", "admin_rotation_epoch": epoch, "version": "16", "trusted_sources": []any{}}}, + } + plan, err := provider.Plan(rpcCtx, desired, current) + if err != nil { + t.Fatal("real provider Plan RPC failed") + } + if len(plan.Actions) != 2 || plan.Actions[0].Action != "update" || plan.Actions[1].Action != "update" { + t.Fatalf("real provider did not produce two update actions: %+v", plan.Actions) + } + // The config declares provider/state only; resource specs come from the + // real Plan RPC. Use the public saved-plan hash helper for that config. + plan.DesiredHash = wfctlhelpers.DesiredStateHash(nil, nil, nil, "") + encoded, err := json.MarshalIndent(plan, "", " ") + if err != nil { + t.Fatal(err) + } + write(filename, encoded) + } + var commandNumber int + run := func(expectSuccess bool, args ...string) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := releasedHostCommand(ctx, childEnv, root, wfctl, args...) + output, err := cmd.CombinedOutput() + commandNumber++ + if strings.Contains(string(output), sentinel) { + t.Fatal("released wfctl printed credential bytes") + } + write(fmt.Sprintf("wfctl-%02d.log", commandNumber), output) + if (err == nil) != expectSuccess { + t.Fatalf("released wfctl command %d returned unexpected status", commandNumber) + } + return output + } + version := run(true, "version") + if strings.TrimSpace(string(version)) != "v0.86.1" { + t.Fatal("proof did not run the required released wfctl") + } + producePlan("2", "stale-plan.json") + for i := range states { + key := "rotation_epoch" + if states[i].Type == "infra.database" { + key = "admin_rotation_epoch" + } + states[i].Outputs[key] = "2" + if err := store.SaveResource(ctx, states[i]); err != nil { + t.Fatal(err) + } + } + apply := func(plan string, ok bool) { + run(ok, "infra", "apply", "--config", filepath.Join(root, "config.yaml"), "--plan", filepath.Join(root, plan), "--plugin-dir", pluginsDir, "--auto-approve", "--skip-bootstrap") + } + resetCount := func() int { + mu.Lock() + defer mu.Unlock() + return calls["POST /v2/databases/"+parent+"/users/app-user/reset_auth"] + calls["POST /v2/databases/"+parent+"/users/doadmin/reset_auth"] + } + assertState := func(epoch string) { + t.Helper() + for _, name := range []string{"app-user", "db"} { + state, err := store.GetResource(ctx, name) + if err != nil || state == nil { + t.Fatalf("durable state missing after real apply: %v", err) + } + key := "rotation_epoch" + if name == "db" { + key = "admin_rotation_epoch" + } + if state.Outputs[key] != epoch || !sensitive.IsPlaceholder(state.Outputs["password"]) || !sensitive.IsPlaceholder(state.Outputs["uri"]) { + t.Fatal("released host did not preserve epoch and routed secret references") + } + encoded, err := json.MarshalIndent(state, "", " ") + if err != nil { + t.Fatal(err) + } + write(fmt.Sprintf("epoch-%s-%s.json", epoch, name), encoded) + } + list := run(true, "infra", "state", "list", "--config", filepath.Join(root, "config.yaml")) + if !strings.Contains(string(list), "digitalocean.database_user") || !strings.Contains(string(list), "infra.database") || !strings.Contains(string(list), "2 resource(s) tracked") { + t.Fatal("released wfctl did not list both durable identities") + } + } + apply("stale-plan.json", true) + if resetCount() != 0 { + t.Fatal("saved plan used stale generation instead of latest persisted outputs") + } + assertState("2") + producePlan("3", "rotation-plan.json") + apply("rotation-plan.json", true) + assertState("3") + apply("rotation-plan.json", true) + assertState("3") + if resetCount() != 2 { + t.Fatal("restart/saved-plan replay did not rotate each identity exactly once") + } + producePlan("4", "failure-plan.json") + mu.Lock() + failReset = true + mu.Unlock() + apply("failure-plan.json", false) + assertState("3") + mu.Lock() + failReset = false + mu.Unlock() + apply("failure-plan.json", true) + assertState("4") + if resetCount() != 6 { + t.Fatal("failed-reset/retry did not execute each identity exactly once per attempt") + } + for _, filename := range []string{"stale-plan.json", "rotation-plan.json", "failure-plan.json", "state/app-user.json", "state/db.json"} { + data, err := os.ReadFile(filepath.Join(root, filename)) + if err != nil || strings.Contains(string(data), sentinel) { + t.Fatalf("state/plan evidence missing or contains plaintext: %s", filename) + } + } + pidBytes, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + if len(strings.Fields(string(pidBytes))) == 0 { + t.Fatal("real consumer did not record native children") + } + if err := reapReleasedHostChildren(pidFile, false); err != nil { + t.Fatal(err) + } + closeAPI() + probe, err := net.DialTimeout("tcp", strings.TrimPrefix(api.URL, "http://"), time.Second) + if err == nil { + _ = probe.Close() + t.Fatal("owned HTTP fixture listener was not reaped") + } + pluginBytes, err := os.ReadFile(filepath.Join(pluginDir, pluginName)) + if err != nil { + t.Fatal(err) + } + mu.Lock() + report, err := json.MarshalIndent(releasedHostConsumerProof{Result: "PASS", WFCTLVersion: strings.TrimSpace(string(version)), + WFCTLSHA256: hostDigest, PluginSHA256: fmt.Sprintf("%x", sha256.Sum256(pluginBytes)), + Dependency: "loopback DigitalOcean HTTP fixture via init-only Go build overlay; all host/SDK/provider/driver logic real", + SavedPlanProducer: "actual plugin Plan RPC via public providerclient; public DesiredStateHash for provider/state-only config; actual downloaded wfctl owns apply and state list", + LatestEpoch: "2", RotatedEpoch: "3", FailedResetEpoch: "3", RetryEpoch: "4", APICalls: calls, APIEvents: events, OwnedChildren: strings.Fields(string(pidBytes)), ChildrenReaped: true, ListenerReaped: true}, "", " ") + mu.Unlock() + if err != nil { + t.Fatal(err) + } + if t.Failed() || ctx.Err() != nil { + t.Fatal("native consumer cannot report PASS after a failed assertion or deadline") + } + write("consumer-proof.json", report) + proofFiles, err := filepath.Glob(filepath.Join(root, "*consumer-proof*.json")) + if err != nil || len(proofFiles) != 1 { + t.Fatal("native consumer must produce exactly one genuine proof") + } + var proof releasedHostConsumerProof + if err := json.Unmarshal(report, &proof); err != nil || proof.Result != "PASS" || proof.WFCTLVersion != "v0.86.1" || proof.WFCTLSHA256 != hostDigest || !proof.ChildrenReaped || !proof.ListenerReaped || proof.Plaintext || proof.LatestEpoch != "2" || proof.RotatedEpoch != "3" || proof.FailedResetEpoch != "3" || proof.RetryEpoch != "4" { + t.Fatal("native consumer proof is incomplete") + } + t.Logf("real released-wfctl consumer proof: %s", filepath.Join(root, "consumer-proof.json")) +} + +func sensitiveInputTestConn(t *testing.T, provider *DOProvider) *grpc.ClientConn { + t.Helper() + listener := bufconn.Listen(iacServerTestBufSize) + t.Cleanup(func() { _ = listener.Close() }) + server := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(server, newDOIaCServer(provider)); err != nil { + t.Fatal(err) + } + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func TestDOResourceSensitiveInputsGRPC(t *testing.T) { + provider := NewDOProvider() + if err := provider.Initialize(t.Context(), map[string]any{"token": "fixture-token"}); err != nil { + t.Fatal(err) + } + paths := []string{"/password", "/connection/uri"} + provider.drivers["fixture.credential"] = &sensitiveInputTestDriver{paths: paths} + provider.drivers["fixture.denied"] = &sensitiveInputTestDriver{err: status.Error(codes.PermissionDenied, "discovery denied")} + client := pb.NewResourceSensitiveInputDeclarerClient(sensitiveInputTestConn(t, provider)) + ctx, cancel := context.WithTimeout(t.Context(), iacServerTestRPCDeadline) + defer cancel() + resp, err := client.SensitiveInputPaths(ctx, &pb.ResourceSensitiveInputPathsRequest{ResourceType: "fixture.credential"}) + if err != nil || !reflect.DeepEqual(resp.GetPaths(), paths) { + t.Fatalf("sensitive paths = %v, err = %v; want %v", resp.GetPaths(), err, paths) + } + for _, tc := range []struct { + resourceType string + code codes.Code + }{ + {"infra.spaces_key", codes.Unimplemented}, + {"infra.unknown", codes.NotFound}, + {"", codes.InvalidArgument}, + {"fixture.denied", codes.PermissionDenied}, + } { + t.Run(tc.resourceType, func(t *testing.T) { + _, err := client.SensitiveInputPaths(ctx, &pb.ResourceSensitiveInputPathsRequest{ResourceType: tc.resourceType}) + if status.Code(err) != tc.code { + t.Fatalf("got %v, want code %v", err, tc.code) + } + }) + } +} + +func TestDOSensitiveInputLiteralRejectedBeforeDispatch(t *testing.T) { + provider := NewDOProvider() + driver := &sensitiveInputTestDriver{paths: []string{"/password"}} + provider.drivers = map[string]interfaces.ResourceDriver{"fixture.credential": driver} + adapter := providerclient.New(sensitiveInputTestConn(t, provider), map[string]bool{ + providerclient.IaCServiceResourceDriver: true, + providerclient.IaCServiceSensitiveInputDeclarer: true, + }) + const credential = "known-literal-do-credential" + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{ + Action: "create", Resource: interfaces.ResourceSpec{ + Name: "fixture", Type: "fixture.credential", Config: map[string]any{"password": credential}, + }, + }}} + result, err := wfctlhelpers.ApplyPlanWithHooks(t.Context(), adapter, plan, wfctlhelpers.ApplyPlanHooks{}) + if err != nil || result == nil || len(result.Errors) != 1 || !strings.Contains(result.Errors[0].Error, interfaces.ErrValidation.Error()) { + t.Fatalf("literal credential error = %v, result = %+v; want one action validation error", err, result) + } + if calls := driver.createCalls.Load(); calls != 0 { + t.Fatalf("literal reached provider Create %d times", calls) + } + evidence, marshalErr := json.Marshal(result) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if strings.Contains(string(evidence), credential) { + t.Fatal("validation diagnostic exposed credential bytes") + } +} + +func TestDOProviderDatabaseUserRegistration(t *testing.T) { + provider := NewDOProvider() + if err := provider.Initialize(t.Context(), map[string]any{"token": "fixture-token"}); err != nil { + t.Fatal(err) + } + var declared bool + for _, capability := range provider.Capabilities() { + declared = declared || capability.ResourceType == "digitalocean.database_user" + } + if !declared { + t.Error("database_user capability absent") + } + driver, err := provider.ResourceDriver("digitalocean.database_user") + if err != nil { + t.Fatal(err) + } + if _, ok := driver.(interfaces.ResourceSensitiveInputDeclarer); !ok { + t.Fatal("database_user sensitive-input declarations absent") + } + for _, required := range []string{"uri", "password"} { + var present bool + for _, key := range driver.SensitiveKeys() { + present = present || key == required + } + if !present { + t.Errorf("sensitive output %s absent", required) + } + } +} + +func TestDODatabaseUserManifest(t *testing.T) { + root, err := os.ReadFile("../plugin.json") + if err != nil { + t.Fatal(err) + } + embedded, err := os.ReadFile("../cmd/plugin/plugin.json") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(root, embedded) { + t.Fatal("root and embedded plugin manifests differ") + } + var manifest struct { + MinEngineVersion string `json:"minEngineVersion"` + IaCServices []string `json:"iacServices"` + Capabilities struct { + IaCProvider struct { + ResourceTypes []string `json:"resourceTypes"` + } `json:"iacProvider"` + } `json:"capabilities"` + } + if err := json.Unmarshal(root, &manifest); err != nil { + t.Fatal(err) + } + if manifest.MinEngineVersion != "0.86.1" { + t.Errorf("min engine = %q; authoritative update state requires 0.86.1", manifest.MinEngineVersion) + } + var declaredService, declaredResource bool + for _, service := range manifest.IaCServices { + declaredService = declaredService || service == providerclient.IaCServiceSensitiveInputDeclarer + } + for _, resourceType := range manifest.Capabilities.IaCProvider.ResourceTypes { + declaredResource = declaredResource || resourceType == "digitalocean.database_user" + } + if !declaredService || !declaredResource { + t.Errorf("sensitive-input service declared=%t, database_user declared=%t", declaredService, declaredResource) + } + for _, name := range []string{pb.IaCProviderRunner_ServiceDesc.ServiceName, pb.IaCProviderJobCanceler_ServiceDesc.ServiceName} { + present := false + for _, service := range manifest.IaCServices { + present = present || service == name + } + if !present { + t.Errorf("targeted-job service %s absent from manifest", name) + } + } +} diff --git a/internal/resourcedriver_server.go b/internal/resourcedriver_server.go index 701fd61..6dd5ac0 100644 --- a/internal/resourcedriver_server.go +++ b/internal/resourcedriver_server.go @@ -42,6 +42,25 @@ import ( // A signature drift in iac.proto fails the build at this line rather // than at first RPC dispatch. var _ pb.ResourceDriverServer = (*doIaCServer)(nil) +var _ pb.ResourceSensitiveInputDeclarerServer = (*doIaCServer)(nil) +var _ interfaces.ResourceStateUpdater = (*doIaCServer)(nil) + +// The public SDK decodes the Update RPC and calls this native optional surface. +func (s *doIaCServer) UpdateWithState(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec, prior *interfaces.ResourceState) (*interfaces.ResourceOutput, error) { + if prior != nil { + if err := interfaces.ValidateUpdatePriorState(ref, spec, prior); err != nil { + return nil, err + } + } + driver, err := s.resolveResourceDriver(ref.Type) + if err != nil { + return nil, err + } + if updater, ok := driver.(interfaces.ResourceStateUpdater); ok { + return updater.UpdateWithState(ctx, ref, spec, prior) + } + return driver.Update(ctx, ref, spec) +} // resolveResourceDriver looks up the per-type driver registered on // the underlying *DOProvider. Returns a typed gRPC error with @@ -207,6 +226,22 @@ func (s *doIaCServer) SensitiveKeys(_ context.Context, req *pb.SensitiveKeysRequ return &pb.SensitiveKeysResponse{Keys: append([]string(nil), keys...)}, nil } +func (s *doIaCServer) SensitiveInputPaths(ctx context.Context, req *pb.ResourceSensitiveInputPathsRequest) (*pb.ResourceSensitiveInputPathsResponse, error) { + driver, err := s.resolveResourceDriver(req.GetResourceType()) + if err != nil { + return nil, err + } + declarer, ok := driver.(interfaces.ResourceSensitiveInputDeclarer) + if !ok { + return nil, status.Error(codes.Unimplemented, "driver does not declare sensitive inputs") + } + paths, err := declarer.SensitiveInputPaths(ctx) + if err != nil { + return nil, err + } + return &pb.ResourceSensitiveInputPathsResponse{Paths: append([]string(nil), paths...)}, nil +} + // Troubleshoot dispatches to interfaces.Troubleshooter.Troubleshoot // when the per-type driver implements that optional interface. // Drivers that don't satisfy Troubleshooter surface a typed gRPC @@ -293,4 +328,3 @@ func outputFromPB(o *pb.ResourceOutput) (*interfaces.ResourceOutput, error) { Status: o.GetStatus(), }, nil } - diff --git a/internal/testdata/app-platform-runner/main.go b/internal/testdata/app-platform-runner/main.go new file mode 100644 index 0000000..f845520 --- /dev/null +++ b/internal/testdata/app-platform-runner/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "golang.org/x/oauth2" +) + +type fixtureTransport struct{ target *url.URL } + +func (t fixtureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != "api.digitalocean.com" { + return nil, fmt.Errorf("unexpected fixture API host") + } + copy := req.Clone(req.Context()) + copy.URL.Scheme = t.target.Scheme + copy.URL.Host = t.target.Host + copy.Host = "" + return http.DefaultTransport.RoundTrip(copy) +} + +func main() { + target, err := url.Parse(os.Getenv("WORKFLOW_DO_JOB_TEST_API")) + if err != nil || target.Scheme != "http" || (target.Hostname() != "127.0.0.1" && target.Hostname() != "localhost" && target.Hostname() != "::1") { + panic("fixture requires a loopback API") + } + manifest, err := os.ReadFile(os.Getenv("WORKFLOW_DO_JOB_TEST_MANIFEST")) + if err != nil { + panic("fixture manifest could not be read") + } + server := internal.NewIaCServer() + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Transport: fixtureTransport{target: target}}) + if _, err := server.Initialize(ctx, &pb.InitializeRequest{ConfigJson: []byte(`{"token":"fixture-token"}`)}); err != nil { + panic("fixture provider initialization failed") + } + sdk.ServeIaCPlugin(server, sdk.IaCServeOptions{ManifestProvider: sdk.MustEmbedManifest(manifest), BuildVersion: sdk.ResolveBuildVersion(internal.Version)}) +} diff --git a/internal/validate_plan.go b/internal/validate_plan.go index c34649a..cb65d87 100644 --- a/internal/validate_plan.go +++ b/internal/validate_plan.go @@ -1,11 +1,14 @@ package internal import ( + "context" + "encoding/json" "fmt" "regexp" "sort" "strings" + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -52,8 +55,8 @@ func looksLikeResourceName(s string) bool { } // ValidatePlan implements interfaces.ProviderValidator (W-4): a read-only, -// no-remote-call cross-resource constraint check that runs at `wfctl infra -// align` time before any cloud API call. Diagnostics surface as +// cross-resource constraint check. Explicitly capped droplets additionally +// require a fresh read-only Sizes lookup. Diagnostics surface as // PlanDiagnosticError|Warning|Info with severity-driven exit-code mapping // (Error always fails align; Warning fails only under --strict; Info never // affects exit). @@ -84,6 +87,10 @@ func looksLikeResourceName(s string) bool { // load balancer zone matching against attached droplets, registry // regional restrictions. func (p *DOProvider) ValidatePlan(plan *interfaces.IaCPlan) []interfaces.PlanDiagnostic { + return p.validatePlanContext(context.Background(), plan) +} + +func (p *DOProvider) validatePlanContext(ctx context.Context, plan *interfaces.IaCPlan) []interfaces.PlanDiagnostic { if plan == nil || len(plan.Actions) == 0 { return nil } @@ -118,6 +125,26 @@ func (p *DOProvider) ValidatePlan(plan *interfaces.IaCPlan) []interfaces.PlanDia continue } region, _ := a.Resource.Config["region"].(string) + if a.Resource.Type == "infra.droplet" { + var sizes drivers.DropletSizesClient + if p.client != nil { + sizes = p.client.Sizes + } + quote, err := drivers.QuoteDropletPrice(ctx, sizes, a.Resource) + if err != nil { + diags = append(diags, interfaces.PlanDiagnostic{Severity: interfaces.PlanDiagnosticError, Resource: a.Resource.Name, Field: "max_monthly_usd", Message: err.Error()}) + continue + } + if quote != nil { + message, err := json.Marshal(quote) + if err != nil { + diags = append(diags, interfaces.PlanDiagnostic{Severity: interfaces.PlanDiagnosticError, Resource: a.Resource.Name, Field: "max_monthly_usd", Message: "cannot encode live droplet price quote"}) + } else { + diags = append(diags, interfaces.PlanDiagnostic{Severity: interfaces.PlanDiagnosticInfo, Resource: a.Resource.Name, Field: "price_quote", Message: string(message)}) + } + continue + } + } switch a.Resource.Type { case "infra.container_service": diags = appendAppPlatformDiagnostics(diags, a.Resource, region, byName) diff --git a/internal/validate_plan_pricing_test.go b/internal/validate_plan_pricing_test.go new file mode 100644 index 0000000..14828f5 --- /dev/null +++ b/internal/validate_plan_pricing_test.go @@ -0,0 +1,214 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers" + "github.com/GoCodeAlone/workflow/iac/diffcache" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/platform" + "golang.org/x/oauth2" +) + +func TestValidatePlan_DropletPricingFreshAndSanitized(t *testing.T) { + price := 24.0 + status, calls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != "/v2/sizes" { + t.Error("unexpected pricing request") + } + if status != 0 { + w.WriteHeader(status) + _, _ = fmt.Fprint(w, `{"message":"KNOWN_PRICE_SECRET"}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"sizes": []map[string]any{{"slug": "s-2vcpu-4gb", "available": true, "regions": []string{"nyc3"}, "price_monthly": price}}}) + })) + defer srv.Close() + // Reuse the provider's ctx-injected HTTP seam; redirect every SDK request + // to the local handler without changing production client construction. + ctx := contextWithPricingHTTPClient(t, srv) + p := NewDOProvider() + if err := p.Initialize(ctx, map[string]any{"token": "fake", "region": "nyc3"}); err != nil { + t.Fatal(err) + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Name: "host", Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3", "max_monthly_usd": 24}}}}} + diags := p.ValidatePlan(plan) + if len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticInfo { + t.Fatalf("live quote diagnostic missing: %v", diags) + } + var quote drivers.DropletPriceQuote + if err := json.Unmarshal([]byte(diags[0].Message), "e); err != nil || quote.MonthlyUSD != 24 || quote.CheckedAt.IsZero() { + t.Fatal("diagnostic is not a typed sanitized quote") + } + price = 25 + diags = p.ValidatePlan(plan) + if len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticError || calls != 2 { + t.Fatal("validation used a stale quote") + } + status = http.StatusForbidden + diags = p.ValidatePlan(plan) + if len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticError || strings.Contains(diags[0].Message, "KNOWN_PRICE_SECRET") { + t.Fatal("API failure accepted or exposed") + } + plan.Actions[0].Action = "delete" + if diags := p.ValidatePlan(plan); len(diags) != 0 || calls != 3 { + t.Fatal("cleanup delete performed pricing lookup") + } +} + +func TestValidatePlan_DropletPricingOptInAndUninitializedClient(t *testing.T) { + p := NewDOProvider() + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3"}}}}} + if diags := p.ValidatePlan(plan); len(diags) != 0 { + t.Fatal("uncapped legacy required live pricing") + } + plan.Actions[0].Resource.Config["max_monthly_usd"] = 24 + if diags := p.ValidatePlan(plan); len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticError { + t.Fatal("missing live client did not fail closed") + } +} + +func TestValidatePlan_DropletPricingLiveCatalogNotStaticRegions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v2/sizes" { + t.Error("unexpected pricing request") + } + _, _ = fmt.Fprint(w, `{"sizes":[{"slug":"s-2vcpu-4gb","available":true,"regions":["future1"],"price_monthly":24}]}`) + })) + defer srv.Close() + p := NewDOProvider() + if err := p.Initialize(contextWithPricingHTTPClient(t, srv), map[string]any{"token": "fake", "region": "nyc3"}); err != nil { + t.Fatal(err) + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "future1"}}}}} + if diags := p.ValidatePlan(plan); len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticWarning { + t.Fatal("fixture region unexpectedly present in static catalog") + } + plan.Actions[0].Resource.Config["max_monthly_usd"] = 24 + if diags := p.ValidatePlan(plan); len(diags) != 1 || diags[0].Severity != interfaces.PlanDiagnosticInfo { + t.Fatal("static region catalog overrode authoritative live availability") + } +} + +func TestDOProvider_Plan_DropletPricingCacheRefresh(t *testing.T) { + platform.SetDiffCacheForTest(t, diffcache.NewMemory()) + var price atomic.Uint64 + price.Store(math.Float64bits(24)) + var calls, httpStatus atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/v2/sizes" { + t.Error("unexpected pricing request") + } + if code := httpStatus.Load(); code != 0 { + w.WriteHeader(int(code)) + _, _ = fmt.Fprint(w, `{"message":"KNOWN_PRICE_SECRET"}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"sizes": []map[string]any{{"slug": "s-2vcpu-4gb", "available": true, "regions": []string{"nyc3"}, "price_monthly": math.Float64frombits(price.Load())}}}) + })) + defer srv.Close() + p := NewDOProvider() + if err := p.Initialize(contextWithPricingHTTPClient(t, srv), map[string]any{"token": "fake", "region": "nyc3"}); err != nil { + t.Fatal(err) + } + driver := &pricingCountingDriver{ResourceDriver: p.drivers["infra.droplet"]} + p.drivers["infra.droplet"] = driver + desired := []interfaces.ResourceSpec{{Name: "host", Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3", "max_monthly_usd": 24}}} + current := []interfaces.ResourceState{{Name: "host", Type: "infra.droplet", ProviderID: "100", Outputs: map[string]any{"size": "s-2vcpu-4gb"}, AppliedConfig: desired[0].Config}} + first, err := p.Plan(t.Context(), desired, current) + if err != nil || len(first.Actions) != 0 || driver.calls.Load() != 1 || calls.Load() == 0 { + t.Fatalf("initial live Diff fixture failed: %v", err) + } + before := calls.Load() + price.Store(math.Float64bits(23)) + second, err := p.Plan(t.Context(), desired, current) + if err != nil || len(second.Actions) != 0 || driver.calls.Load() != 1 { + t.Fatalf("second plan did not hit the real diff cache: %v", err) + } + if calls.Load() != before+1 { + t.Error("cache-hit plan did not fetch a fresh quote") + } + price.Store(math.Float64bits(25)) + if _, err := p.Plan(t.Context(), desired, current); err == nil || !errors.Is(err, interfaces.ErrValidation) { + t.Error("cache-hit plan authorized an over-cap live price") + } + httpStatus.Store(http.StatusForbidden) + if _, err := p.Plan(t.Context(), desired, current); err == nil || !errors.Is(err, interfaces.ErrForbidden) || strings.Contains(err.Error(), "KNOWN_PRICE_SECRET") { + t.Error("cache-hit plan accepted or exposed a failed lookup") + } + if driver.calls.Load() != 1 { + t.Fatal("fixture did not retain its cached Diff result") + } +} + +func TestDOProvider_Plan_DropletPricingNewResourceAndUncapped(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + _, _ = fmt.Fprint(w, `{"sizes":[{"slug":"s-2vcpu-4gb","available":true,"regions":["nyc3"],"price_monthly":25}]}`) + })) + defer srv.Close() + p := NewDOProvider() + if err := p.Initialize(contextWithPricingHTTPClient(t, srv), map[string]any{"token": "fake", "region": "nyc3"}); err != nil { + t.Fatal(err) + } + desired := []interfaces.ResourceSpec{{Name: "host", Type: "infra.droplet", Config: map[string]any{"size": "s-2vcpu-4gb", "region": "nyc3"}}} + if plan, err := p.Plan(t.Context(), desired, nil); err != nil || len(plan.Actions) != 1 || calls.Load() != 0 { + t.Fatal("uncapped new-resource plan performed pricing lookup or changed behavior") + } + desired[0].Config["max_monthly_usd"] = 24 + if _, err := p.Plan(t.Context(), desired, nil); err == nil || !errors.Is(err, interfaces.ErrValidation) || calls.Load() != 1 { + t.Fatal("new-resource plan bypassed live pricing") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := p.Plan(ctx, desired, nil); !errors.Is(err, context.Canceled) { + t.Fatal("canceled plan lost the pricing cancellation cause") + } + desired[0].Type = "infra.vpc" + before := calls.Load() + if _, err := p.Plan(t.Context(), desired, nil); err != nil || calls.Load() != before { + t.Fatal("droplet price cap became a cross-resource public cost contract") + } +} + +type pricingCountingDriver struct { + interfaces.ResourceDriver + calls atomic.Int32 +} + +func (d *pricingCountingDriver) Diff(ctx context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + d.calls.Add(1) + return d.ResourceDriver.Diff(ctx, desired, current) +} + +type pricingRedirectTransport struct { + target *http.Client + server *httptest.Server +} + +func (r pricingRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + u := *clone.URL + u.Scheme, u.Host = "http", r.server.Listener.Addr().String() + clone.URL = &u + clone.Host = u.Host + return r.target.Transport.RoundTrip(clone) +} + +func contextWithPricingHTTPClient(t *testing.T, srv *httptest.Server) context.Context { + t.Helper() + return context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: pricingRedirectTransport{target: srv.Client(), server: srv}}) +} diff --git a/plugin.json b/plugin.json index 7589567..9416236 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "external", "tier": "community", - "minEngineVersion": "0.80.1", + "minEngineVersion": "0.86.1", "required_secrets": [ { "name": "DIGITALOCEAN_TOKEN", @@ -24,11 +24,14 @@ "workflow.plugin.external.iac.IaCProviderValidator", "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", "workflow.plugin.external.iac.IaCProviderLogCapture", + "workflow.plugin.external.iac.IaCProviderRunner", + "workflow.plugin.external.iac.IaCProviderJobCanceler", "workflow.plugin.external.iac.IaCProviderRequirementMapper", "workflow.plugin.external.iac.IaCProviderRegionLister", "workflow.plugin.external.iac.IaCProviderOwnership", "workflow.plugin.external.iac.IaCProviderFinalizer", "workflow.plugin.external.iac.ResourceDriver", + "workflow.plugin.external.iac.ResourceSensitiveInputDeclarer", "workflow.plugin.external.iac.IaCStateBackend" ], "keywords": [ @@ -88,6 +91,7 @@ "infra.app_domain", "infra.k8s_cluster", "infra.database", + "digitalocean.database_user", "infra.cache", "infra.load_balancer", "infra.vpc", @@ -102,6 +106,56 @@ "infra.api_gateway" ], "configSchema": { + "digitalocean.database_user": { + "description": "Managed database user with provider-generated credentials and opt-in epoch-controlled password rotation.", + "fields": { + "database_id": { + "type": "string", + "required": true, + "description": "Parent managed database's canonical UUID." + }, + "username": { + "type": "string", + "required": true, + "description": "Nonempty database username. The provider escapes it in API paths and resource identifiers." + }, + "rotation_epoch": { + "type": "string", + "required": false, + "description": "Nonempty opaque epoch. Changing it requests password rotation; returned password and TLS URI are sensitive outputs, not desired inputs." + } + } + }, + "infra.database": { + "description": "Managed database. Administrator password rotation is opt-in and separate from ordinary configuration updates.", + "fields": { + "admin_rotation_epoch": { + "type": "string", + "required": false, + "description": "Nonempty opaque epoch. Changing it requests administrator password rotation; omission preserves legacy behavior." + } + } + }, + "infra.droplet": { + "description": "DigitalOcean Droplet with optional fresh catalog price validation before plan, create, or destructive replacement.", + "fields": { + "size": { + "type": "string", + "required": false, + "description": "Explicit size slug required when max_monthly_usd is present." + }, + "region": { + "type": "string", + "required": false, + "description": "Explicit region slug required when max_monthly_usd is present." + }, + "max_monthly_usd": { + "type": "number", + "required": false, + "description": "Positive finite monthly USD cap. A fresh Sizes API quote must confirm exact size and region availability and price at or below this cap; uncapped resources make no price lookup." + } + } + }, "infra.container_service": { "expose": { "type": "string", diff --git a/recovery_workflow_test.go b/recovery_workflow_test.go new file mode 100644 index 0000000..1ed7e80 --- /dev/null +++ b/recovery_workflow_test.go @@ -0,0 +1,90 @@ +package digitalocean_test + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// Entire CI, trust and executable inventory at immutable 5e5887a5. +const recoveryTrustedInventorySHA256 = "0d7179d840de07d0791ada93c2e1be1cc228a2eb37efbd93baba439ea669f250" + +func recoveryTrustedInventory(root string) (string, int, error) { + var paths []string + for _, dir := range []string{".github", "scripts"} { + if err := filepath.WalkDir(filepath.Join(root, dir), func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("CI inventory contains a symlink") + } + if !entry.IsDir() { + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + paths = append(paths, filepath.ToSlash(relative)) + } + return nil + }); err != nil { + return "", 0, err + } + } + sort.Strings(paths) + var inventory strings.Builder + for _, path := range paths { + data, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + return "", 0, err + } + fmt.Fprintf(&inventory, "%s\x00%x\n", path, sha256.Sum256(data)) + } + return fmt.Sprintf("%x", sha256.Sum256([]byte(inventory.String()))), len(paths), nil +} + +func TestRecoveryCIAndTrustBytesUnchanged(t *testing.T) { + digest, count, err := recoveryTrustedInventory(".") + if err != nil { + t.Fatal(err) + } + if count != 46 || digest != recoveryTrustedInventorySHA256 { + t.Fatalf("CI/trust/script bytes differ from immutable base: files=%d digest=%s", count, digest) + } +} + +func TestRecoveryTrustedInventoryDetectsByteAndPresenceChanges(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{".github", "scripts"} { + if err := os.Mkdir(filepath.Join(root, dir), 0o700); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(root, ".github", "workflow.yml") + if err := os.WriteFile(path, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + before, count, err := recoveryTrustedInventory(root) + if err != nil || count != 1 { + t.Fatal("cannot inspect fixture inventory") + } + if err := os.WriteFile(path, []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + after, _, err := recoveryTrustedInventory(root) + if err != nil || after == before { + t.Fatal("byte change escaped regression") + } + if err := os.WriteFile(filepath.Join(root, "scripts", "new.sh"), nil, 0o600); err != nil { + t.Fatal(err) + } + after, count, err = recoveryTrustedInventory(root) + if err != nil || count != 2 || after == before { + t.Fatal("new executable escaped regression") + } +} From 5d5e99735c4bedde25a1695ebec6cea3f47b2863 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 4 Oct 2026 03:09:35 -0400 Subject: [PATCH 2/2] test: isolate runtime toolchain selection Built hosts can query ambient Go during startup. Keep the fixed compiler pin only in build children so runtime queries cannot extract a read-only toolchain into the isolated test home. Preserve ordinary cleanup and verify the built Go version. --- internal/released_host_test.go | 49 ++++++++++++++++++- .../resourcedriver_sensitive_inputs_test.go | 7 ++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/released_host_test.go b/internal/released_host_test.go index 241a39d..fa8fd52 100644 --- a/internal/released_host_test.go +++ b/internal/released_host_test.go @@ -100,7 +100,7 @@ func releasedHostTransport() *http.Transport { } func releasedHostEnvironment(root string) []string { - return []string{"HOME=" + filepath.Join(root, "home"), "XDG_CONFIG_HOME=" + filepath.Join(root, "config"), "TMPDIR=" + filepath.Join(root, "tmp"), "PATH=/usr/bin:/bin", "LANG=C", "GOWORK=off", "GOENV=off", "GOTOOLCHAIN=go1.26.5", "GOFLAGS=-mod=readonly"} + return []string{"HOME=" + filepath.Join(root, "home"), "XDG_CONFIG_HOME=" + filepath.Join(root, "config"), "TMPDIR=" + filepath.Join(root, "tmp"), "PATH=/usr/bin:/bin", "LANG=C", "GOWORK=off", "GOENV=off", "GOTOOLCHAIN=local", "GOFLAGS=-mod=readonly"} } func releasedHostGoTool() (string, error) { @@ -344,6 +344,53 @@ func TestReleasedHostChildEnvironment(t *testing.T) { } } +func TestReleasedHostRuntimeEnvironmentPreventsToolchainSelection(t *testing.T) { + const child = "released-host-runtime-environment-child" + if os.Args[len(os.Args)-1] == child { + if os.Getenv("GOTOOLCHAIN") != "local" { + t.Fatal("runtime environment permits automatic toolchain selection") + } + if os.Getenv("PATH") != "/usr/bin:/bin" || os.Getenv("GOMODCACHE") != "" || os.Getenv("GOPATH") != "" { + t.Fatal("runtime environment changed its fixed PATH or redirects compiler caches") + } + home := os.Getenv("HOME") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal("cannot create owned runtime home") + } + if err := os.WriteFile(filepath.Join(home, "runtime-child"), nil, 0o600); err != nil { + t.Fatal("cannot write owned runtime marker") + } + return + } + root := t.TempDir() + executable, err := os.Executable() + if err != nil { + t.Fatal("cannot resolve compiled runtime probe") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + cmd := releasedHostCommand(ctx, releasedHostEnvironment(root), root, executable, + "-test.run=^TestReleasedHostRuntimeEnvironmentPreventsToolchainSelection$", "--", child) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("compiled runtime environment probe failed: %s", output) + } + if cmd.Process == nil || cmd.Process.Signal(syscall.Signal(0)) == nil { + t.Fatal("runtime environment probe left an owned child") + } + if _, err := os.Stat(filepath.Join(root, "home", "runtime-child")); err != nil { + t.Fatal("compiled runtime environment probe did not execute") + } + if _, err := os.Stat(filepath.Join(root, "home", "go", "pkg", "mod")); !os.IsNotExist(err) { + t.Fatal("runtime environment probe produced a compiler cache") + } + if err := os.RemoveAll(root); err != nil { + t.Fatal("ordinary owned runtime cleanup failed") + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatal("owned runtime root survived ordinary cleanup") + } +} + func TestReleasedHostCommandTimeoutReapsChild(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) defer cancel() diff --git a/internal/resourcedriver_sensitive_inputs_test.go b/internal/resourcedriver_sensitive_inputs_test.go index acf6f11..a8c849d 100644 --- a/internal/resourcedriver_sensitive_inputs_test.go +++ b/internal/resourcedriver_sensitive_inputs_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "debug/buildinfo" "encoding/json" "fmt" "net" @@ -294,7 +295,7 @@ func TestDODatabaseRotationNativeReleasedWFCTL(t *testing.T) { if buildCache == "" { buildCache = filepath.Join(root, "build-cache") } - buildEnv = append(buildEnv, "GOMODCACHE="+moduleCache, "GOCACHE="+buildCache, "CGO_ENABLED=0") + buildEnv = append(buildEnv, "GOTOOLCHAIN=go1.26.5", "GOMODCACHE="+moduleCache, "GOCACHE="+buildCache, "CGO_ENABLED=0") buildCtx, buildCancel := context.WithTimeout(ctx, 2*time.Minute) defer buildCancel() goTool, err := releasedHostGoTool() @@ -306,6 +307,10 @@ func TestDODatabaseRotationNativeReleasedWFCTL(t *testing.T) { _ = output t.Fatal("production entrypoint build failed") } + buildInfo, err := buildinfo.ReadFile(filepath.Join(pluginDir, pluginName)) + if err != nil || buildInfo.GoVersion != "go1.26.5" { + t.Fatal("production entrypoint was not built with Go1.26.5") + } const parent = "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30" const sentinel = "NATIVE_TASK7_KNOWN_SECRET:/?#@%+" var mu sync.Mutex