diff --git a/cmd/schema-gen/main.go b/cmd/schema-gen/main.go index 377aae342..a34e3bfc6 100644 --- a/cmd/schema-gen/main.go +++ b/cmd/schema-gen/main.go @@ -16,6 +16,12 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + // Import cloud provider packages directly to access their CloudExtras types + "github.com/simple-container-com/sc-plugin-aws/api" + _ "github.com/simple-container-com/sc-plugin-gcp/api" + _ "github.com/simple-container-com/sc-plugin-mongodb/api" + "github.com/simple-container-com/sc-plugin-yandex/api" + // Core API package for registration maps and configuration structs "github.com/simple-container-com/api/pkg/api" // Import all cloud provider packages to trigger init() functions @@ -23,17 +29,15 @@ import ( _ "github.com/simple-container-com/api/pkg/clouds/discord" _ "github.com/simple-container-com/api/pkg/clouds/docker" _ "github.com/simple-container-com/api/pkg/clouds/fs" - _ "github.com/simple-container-com/api/pkg/clouds/gcloud" + // pkg/clouds/gcloud blank-import replaced in phase 5 of the GCP + // plugin extraction with sc-plugin-gcp/api below (same shape as + // sc-plugin-{aws,mongodb,yandex}/api imports a few lines down). _ "github.com/simple-container-com/api/pkg/clouds/github" _ "github.com/simple-container-com/api/pkg/clouds/k3s" "github.com/simple-container-com/api/pkg/clouds/k8s" _ "github.com/simple-container-com/api/pkg/clouds/slack" _ "github.com/simple-container-com/api/pkg/clouds/telegram" _ "github.com/simple-container-com/api/pkg/clouds/vm" - // Import cloud provider packages directly to access their CloudExtras types - "github.com/simple-container-com/sc-plugin-aws/api" - _ "github.com/simple-container-com/sc-plugin-mongodb/api" - "github.com/simple-container-com/sc-plugin-yandex/api" ) // ResourceDefinition holds metadata about a resource struct diff --git a/docs/docs/reference/supported-resources.md b/docs/docs/reference/supported-resources.md index c5ff01432..6d2e922ca 100644 --- a/docs/docs/reference/supported-resources.md +++ b/docs/docs/reference/supported-resources.md @@ -553,9 +553,9 @@ stacks: Deploys containerized applications on Google Cloud Run. -**Golang Struct Reference:** `pkg/clouds/gcloud/cloudrun.go:CloudRunInput` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/cloudrun.go:CloudRunTemplate` -**JSON Schema:** [CloudRunInput Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/cloudruninput.json) +**JSON Schema:** [CloudRunTemplate Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/cloudruntemplate.json) ```yaml # server.yaml - Parent Stack (DevOps managed) @@ -566,13 +566,34 @@ templates: # GCP credentials and project (from TemplateConfig struct) projectId: "${auth:gcp-main.projectId}" credentials: "${auth:gcp-main}" + # Cloud Run is a regional resource — required. + region: us-central1 + # Images from the client stack's docker-compose are built and + # pushed to this parent-stack gcp-artifact-registry resource + # before the Cloud Run service references them. Required. + artifactRegistryResource: my-registry resources: resources: production: template: my-cloudrun-template + resources: + my-registry: + type: gcp-artifact-registry + config: + credentials: "${auth:gcp-main}" + projectId: "${auth:gcp-main.projectId}" + location: us-central1 + docker: + immutableTags: false ``` +The Cloud Run v2 Service auto-enables `run.googleapis.com` on first +provision and is created with `deletion_protection: false` so `sc +destroy` tears it down cleanly. A public `allUsers → roles/run.invoker` +IAM binding is set by default; a future `access:` knob narrows this to +authenticated-only or specific service accounts. + **How Client Stacks Use This Template:** ```yaml # client.yaml - Client Stack (Developer managed) @@ -597,7 +618,7 @@ stacks: Template for deploying applications to GKE Autopilot clusters. References GKE cluster and Artifact Registry resources. -**Golang Struct Reference:** `pkg/clouds/gcloud/gke_autopilot.go:GkeAutopilotTemplate` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/gke_autopilot.go:GkeAutopilotTemplate` ```yaml # server.yaml - Parent Stack (DevOps managed) @@ -645,7 +666,7 @@ resources: Hosts static websites on Google Cloud Storage with Cloud CDN. -**Golang Struct Reference:** `pkg/clouds/gcloud/static_website.go:StaticSiteInput` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/static_website.go:StaticSiteInput` ```yaml # server.yaml @@ -665,7 +686,7 @@ stacks: Creates and manages Google Kubernetes Engine Autopilot clusters as a resource. -**Golang Struct Reference:** `pkg/clouds/gcloud/gke_autopilot.go:GkeAutopilotResource` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/gke_autopilot.go:GkeAutopilotResource` **JSON Schema:** [GkeAutopilotResource Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/gkeautopilotresource.json) @@ -755,7 +776,7 @@ resources: Template for deploying applications to GKE Autopilot clusters. References GKE cluster and Artifact Registry resources. -**Golang Struct Reference:** `pkg/clouds/gcloud/gke_autopilot.go:GkeAutopilotTemplate` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/gke_autopilot.go:GkeAutopilotTemplate` ```yaml # server.yaml - Template Definition @@ -838,7 +859,7 @@ resources: Creates and manages Google Cloud Storage buckets with S3-compatible access via HMAC keys. -**Golang Struct Reference:** `pkg/clouds/gcloud/bucket.go:GcpBucket` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/bucket.go:GcpBucket` **JSON Schema:** [GcpBucket Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/gcpbucket.json) @@ -870,7 +891,7 @@ When this resource is used in a client stack via the `uses` section, Simple Cont Creates and manages Google Artifact Registry repositories. -**Golang Struct Reference:** `pkg/clouds/gcloud/artifactregistry.go:ArtifactRegistryConfig` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/artifactregistry.go:ArtifactRegistryConfig` ```yaml # server.yaml - Parent Stack @@ -934,7 +955,7 @@ An unrecognised key in `server.yaml` is silently ignored rather than rejected, s Creates and manages Google Cloud SQL PostgreSQL databases. -**Golang Struct Reference:** `pkg/clouds/gcloud/postgres.go:PostgresGcpCloudsqlConfig` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/postgres.go:PostgresGcpCloudsqlConfig` **JSON Schema:** [PostgresGcpCloudsqlConfig Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/postgresgcpcloudsqlconfig.json) @@ -991,7 +1012,7 @@ Your container connects to Cloud SQL over `localhost` — the injected `*.host`/ Creates and manages Google Cloud Memorystore Redis instances. -**Golang Struct Reference:** `pkg/clouds/gcloud/redis.go:RedisConfig` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/redis.go:RedisConfig` **JSON Schema:** [RedisConfig Schema](https://github.com/simple-container-com/api/tree/main/docs/schemas/gcp/redisconfig.json) @@ -1031,7 +1052,7 @@ When this resource is used in a client stack via the `uses` section, Simple Cont Creates and manages Google Cloud Pub/Sub topics and subscriptions. -**Golang Struct Reference:** `pkg/clouds/gcloud/pubsub.go:PubSubConfig` +**Golang Struct Reference:** `github.com/simple-container-com/sc-plugin-gcp/api/pubsub.go:PubSubConfig` ```yaml # server.yaml - Parent Stack diff --git a/go.mod b/go.mod index e5d6a674e..6e9115ce7 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,7 @@ go 1.26.10 require ( cloud.google.com/go/kms v1.26.0 - cloud.google.com/go/storage v1.62.3 filippo.io/edwards25519 v1.2.0 - github.com/MShekow/directory-checksum v1.4.18 github.com/Masterminds/semver/v3 v3.4.0 github.com/anthonycorbacho/slack-webhook v1.0.1 github.com/antonmedv/expr v1.12.6 @@ -46,7 +44,6 @@ require ( github.com/pkg/errors v0.9.1 github.com/pulumi/pulumi-command/sdk v1.2.1 github.com/pulumi/pulumi-docker/sdk/v4 v4.11.2 - github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1 github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.32.0 github.com/pulumi/pulumi-random/sdk/v4 v4.21.0 github.com/pulumi/pulumi/pkg/v3 v3.245.0 @@ -54,9 +51,9 @@ require ( github.com/samber/lo v1.53.0 github.com/simple-container-com/sc-plugin-aws/api v0.0.0-20261102081521-e5fd1e8fa7a8 github.com/simple-container-com/sc-plugin-cloudflare/api v0.0.0-20261031083455-cdbcf98c7bfb + github.com/simple-container-com/sc-plugin-gcp/api v0.0.0-20261108045141-58ce4b6cfa4a github.com/simple-container-com/sc-plugin-mongodb/api v0.0.0-20261029130510-ff1589792eaa github.com/simple-container-com/sc-plugin-yandex/api v0.0.0-20261101233310-67602079742f - github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tmc/langchaingo v0.1.14 @@ -65,11 +62,9 @@ require ( go.uber.org/atomic v1.11.0 gocloud.dev v0.46.0 golang.org/x/crypto v0.58.0 - golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.24.0 golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 - google.golang.org/api v0.284.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v2 v2.4.0 @@ -91,6 +86,7 @@ require ( cloud.google.com/go/logging v1.13.2 // indirect cloud.google.com/go/longrunning v0.9.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.62.3 // indirect dario.cat/mergo v1.0.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect @@ -213,7 +209,6 @@ require ( github.com/git-pkgs/vers v0.2.4 // indirect github.com/go-critic/go-critic v0.12.0 // indirect github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 // indirect - github.com/go-errors/errors v1.5.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-jose/go-jose/v4 v4.1.6 // indirect github.com/go-logr/logr v1.4.4 // indirect @@ -400,6 +395,7 @@ require ( github.com/sonatard/noctx v0.1.0 // indirect github.com/sosodev/duration v1.4.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect @@ -462,11 +458,13 @@ require ( golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.62.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.51.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.284.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect diff --git a/go.sum b/go.sum index 624a9bc19..5332f9bee 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA= -github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -361,8 +359,6 @@ github.com/go-delve/delve v1.27.0 h1:i66Einw/sQhm0hlbjLNUNxrwCmKdTcIqpyHVqSWAbd0 github.com/go-delve/delve v1.27.0/go.mod h1:l6Xb1ype6VEKoKZCaOg7mGN5B8Zi3Sn+K7qs5Pqqc8k= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62 h1:IGtvsNyIuRjl04XAOFGACozgUD7A82UffYxZt4DWbvA= github.com/go-delve/liner v1.2.3-0.20231231155935-4726ab1d7f62/go.mod h1:biJCRbqp51wS+I92HMqn5H8/A0PAhxn2vyOT+JqhiGI= -github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= -github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= @@ -803,8 +799,6 @@ github.com/pulumi/pulumi-command/sdk v1.2.1 h1:mAziZ91a/9U+5IjZH5Skcar80OSmpBSYl github.com/pulumi/pulumi-command/sdk v1.2.1/go.mod h1:hQxv9DXg6bFjcd9BEiNdMImQ/V1rnC9D115q5VXYNps= github.com/pulumi/pulumi-docker/sdk/v4 v4.11.2 h1:mc9IbrRi1pWSg9HWEFT6EcQ274WnZ97nyP2pmUEvSp4= github.com/pulumi/pulumi-docker/sdk/v4 v4.11.2/go.mod h1:gPNbbz+ywFqnRRQMDxQQbMp4nXJIk43QBUj0N+xWXbw= -github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1 h1:w6OnO3d4j5yVf2vpm8OzXFC/xHOEGqt+9FjWCUBCq6U= -github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1/go.mod h1:UyZyv7hz4knpFx6/Sh+SkZe6hT6sJHtDvw9A0TbvEsk= github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.32.0 h1:QwJzM3PseaorfASUlDXxsyntVGac7ZSYjqjxa038uoU= github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.32.0/go.mod h1:MtckkCd6mzQQ3AYw+qjdt6ROfdBB3dr8dI/dE5/jxrM= github.com/pulumi/pulumi-random/sdk/v4 v4.21.0 h1:j6LtoXue77y16trYOR40iqCUxXxv8TnxvOCOP/a0zVI= @@ -874,6 +868,12 @@ github.com/simple-container-com/sc-plugin-aws/api v0.0.0-20261102081521-e5fd1e8f github.com/simple-container-com/sc-plugin-aws/api v0.0.0-20261102081521-e5fd1e8fa7a8/go.mod h1:xrWxpDoBjIeQhguBwoIsVj/JxTLpZHxyxqaGtbjUCJs= github.com/simple-container-com/sc-plugin-cloudflare/api v0.0.0-20261031083455-cdbcf98c7bfb h1:whdI/2NLI+LOis05ojruccP+v1UqydKFMJVH6WdDlz8= github.com/simple-container-com/sc-plugin-cloudflare/api v0.0.0-20261031083455-cdbcf98c7bfb/go.mod h1:grjigb/0Y7wQ5hT9eWnsIghVoumT2YWeo8x5B2hetSo= +github.com/simple-container-com/sc-plugin-gcp v0.0.0-20261108045141-58ce4b6cfa4a h1:1BPNHNyBU3PV/TDqkOs1fd6r3yvV+s+P66MDwRUGPlM= +github.com/simple-container-com/sc-plugin-gcp v0.0.0-20261108045141-58ce4b6cfa4a/go.mod h1:vFStCvcXkTyVIo6MN9BvudXRmAm4drwwN6d908KqXlw= +github.com/simple-container-com/sc-plugin-gcp/api v0.0.0-20261107212225-8bf6d5d6f638 h1:h564wJ/YQs3wKyHdDjcp/aj1VzvpvjqkWLm/ebtWUiM= +github.com/simple-container-com/sc-plugin-gcp/api v0.0.0-20261107212225-8bf6d5d6f638/go.mod h1:ebRWOMTQAIyTtzDzB27cx5FidTdjDF57fSpCcVBWn3c= +github.com/simple-container-com/sc-plugin-gcp/api v0.0.0-20261108045141-58ce4b6cfa4a h1:XrF9fCS3a76uqO+fLs7vRb636oDtQJZoKpF2XMCvbA0= +github.com/simple-container-com/sc-plugin-gcp/api v0.0.0-20261108045141-58ce4b6cfa4a/go.mod h1:ebRWOMTQAIyTtzDzB27cx5FidTdjDF57fSpCcVBWn3c= github.com/simple-container-com/sc-plugin-mongodb/api v0.0.0-20261029130510-ff1589792eaa h1:y+iTXrgLbZdP5WCSdbkdFOcDzANk+uBqszuVtJue+VE= github.com/simple-container-com/sc-plugin-mongodb/api v0.0.0-20261029130510-ff1589792eaa/go.mod h1:hcPcAX/B6qNRSqkYYE5IDeQviydAmjQ39S9sKmJnmPw= github.com/simple-container-com/sc-plugin-yandex/api v0.0.0-20261101233310-67602079742f h1:4CHL1KWcyF979Y2qW4OKeHf3QMKl3Ih6CgBkoaDzBvU= diff --git a/pkg/api/git/testdata/repo/stacks/common/server.yaml b/pkg/api/git/testdata/repo/stacks/common/server.yaml index be7f17ccf..5c3f9ef80 100644 --- a/pkg/api/git/testdata/repo/stacks/common/server.yaml +++ b/pkg/api/git/testdata/repo/stacks/common/server.yaml @@ -36,6 +36,11 @@ cicd: config: auth-token: "${secret:GITHUB_TOKEN}" plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da cloudflare: source: type: github-release diff --git a/pkg/api/plugin_bootstrap.go b/pkg/api/plugin_bootstrap.go index 96c27b6cf..3f5c0b904 100644 --- a/pkg/api/plugin_bootstrap.go +++ b/pkg/api/plugin_bootstrap.go @@ -111,6 +111,43 @@ func init() { Repository: "simple-container-com/sc-plugin-mongodb", }, }) + RegisterPluginRequirement(PluginRequirement{ + PluginName: "gcp", + // Full token list sc-plugin-gcp owns after the phase 5 extraction + // of pkg/clouds/gcloud + pkg/clouds/pulumi/gcp out of api core. + // Must stay in sync with sc-plugin-gcp/api/init.go's OwnedTypes. + // Same shape as the aws/yandex/cloudflare/cloudru/mongodb entries + // above — one DefaultSource; operators who paste any of these + // tokens into server.yaml without a `plugins: gcp:` block get + // auto-inject via ValidatePluginRequirements. + OwnedTypes: []string{ + // provider + "gcp", + // auth + "gcp-service-account", + // secrets + "gcp-secrets-manager", + // state store + "gcp-bucket", + // secrets provider (Pulumi stack-secrets) + "gcp-kms", + // resources + "gcp-pubsub", + "gcp-redis", + "gcp-cloudsql-postgres", + "gcp-artifact-registry", + "gcp-docker-image-push", + "gcp-gke-autopilot-cluster", + // templates + "cloudrun", + "gcp-static-website", + "gcp-gke-autopilot", + }, + DefaultSource: PluginSource{ + Type: "github-release", + Repository: "simple-container-com/sc-plugin-gcp", + }, + }) // ALSO populate providerConfigMapping (via RegisterPluginOwnedType) // for every bootstrap token, so DetectResourcesType / DetectAuthType / @@ -170,6 +207,26 @@ func init() { "mongodb": { PluginOwnedKindResource: {"mongodb-atlas"}, }, + "gcp": { + // Provider + resources + templates all seed providerConfigMapping + // identically (RegisterPluginOwnedType's kind field is read only + // by the state-store / secrets-provider / auth post-processing). + PluginOwnedKindResource: { + "gcp", + "gcp-pubsub", + "gcp-redis", + "gcp-cloudsql-postgres", + "gcp-artifact-registry", + "gcp-docker-image-push", + "gcp-gke-autopilot-cluster", + "cloudrun", + "gcp-static-website", + "gcp-gke-autopilot", + }, + PluginOwnedKindStateStore: {"gcp-bucket"}, + PluginOwnedKindSecretsProvider: {"gcp-kms", "gcp-secrets-manager"}, + PluginOwnedKindAuth: {"gcp-service-account"}, + }, } { for kind, tokens := range kinds { // Strip the "/auth" sentinel — the kind for auth is diff --git a/pkg/api/secrets/testdata/repo/stacks/common/server.yaml b/pkg/api/secrets/testdata/repo/stacks/common/server.yaml index 24386902d..39d0d71fc 100644 --- a/pkg/api/secrets/testdata/repo/stacks/common/server.yaml +++ b/pkg/api/secrets/testdata/repo/stacks/common/server.yaml @@ -34,6 +34,11 @@ cicd: config: auth-token: "${secret:GITHUB_TOKEN}" plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da cloudflare: source: type: github-release diff --git a/pkg/api/tests/refapp.go b/pkg/api/tests/refapp.go index 103e30a63..45a2eaf90 100644 --- a/pkg/api/tests/refapp.go +++ b/pkg/api/tests/refapp.go @@ -6,18 +6,19 @@ package tests import ( "fmt" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" + "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" "github.com/simple-container-com/api/pkg/clouds/github" "github.com/simple-container-com/api/pkg/clouds/k8s" "github.com/simple-container-com/api/pkg/clouds/pulumi" ) -var CommonGcpCredentials = gcloud.Credentials{ +var CommonGcpCredentials = gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, } @@ -99,14 +100,14 @@ var CommonServerDescriptor = &api.ServerDescriptor{ Config: api.Config{Config: &pulumi.ProvisionerConfig{ StateStorage: pulumi.StateStorageConfig{ Type: pulumi.StateStorageTypeGcpBucket, - Config: api.Config{Config: &gcloud.StateStorageConfig{ + Config: api.Config{Config: &gcpApi.StateStorageConfig{ Credentials: CommonGcpCredentials, Provision: true, }}, }, SecretsProvider: pulumi.SecretsProviderConfig{ Type: pulumi.SecretsProviderTypeGcpKms, - Config: api.Config{Config: &gcloud.SecretsProviderConfig{ + Config: api.Config{Config: &gcpApi.SecretsProviderConfig{ Credentials: CommonGcpCredentials, Provision: true, KeyName: "mypulumi-base-kms-key", @@ -167,13 +168,13 @@ var CommonServerDescriptor = &api.ServerDescriptor{ }}, }, Secrets: api.SecretsConfigDescriptor{ - Type: gcloud.SecretsTypeGCPSecretsManager, - Config: api.Config{Config: &gcloud.SecretsProviderConfig{ - Credentials: gcloud.Credentials{ + Type: gcpApi.SecretsTypeGCPSecretsManager, + Config: api.Config{Config: &gcpApi.SecretsProviderConfig{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, }, @@ -185,13 +186,13 @@ var CommonServerDescriptor = &api.ServerDescriptor{ Config: api.Config{Config: &awsEcsFargateTemplateConfig}, }, "stack-per-app": { - Type: gcloud.TemplateTypeGcpCloudrun, - Config: api.Config{Config: &gcloud.TemplateConfig{ - Credentials: gcloud.Credentials{ + Type: gcpApi.TemplateTypeGcpCloudrun, + Config: api.Config{Config: &gcpApi.TemplateConfig{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, }, @@ -226,11 +227,11 @@ var CommonServerDescriptor = &api.ServerDescriptor{ }, } -var ResolvedCommonGcpCredentials = gcloud.Credentials{ +var ResolvedCommonGcpCredentials = gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "test-gcp-project", }, } @@ -242,14 +243,14 @@ var ResolvedCommonServerDescriptor = &api.ServerDescriptor{ Config: api.Config{Config: &pulumi.ProvisionerConfig{ StateStorage: pulumi.StateStorageConfig{ Type: pulumi.StateStorageTypeGcpBucket, - Config: api.Config{Config: &gcloud.StateStorageConfig{ + Config: api.Config{Config: &gcpApi.StateStorageConfig{ Credentials: ResolvedCommonGcpCredentials, Provision: true, }}, }, SecretsProvider: pulumi.SecretsProviderConfig{ Type: pulumi.SecretsProviderTypeGcpKms, - Config: api.Config{Config: &gcloud.SecretsProviderConfig{ + Config: api.Config{Config: &gcpApi.SecretsProviderConfig{ Credentials: ResolvedCommonGcpCredentials, KeyName: "mypulumi-base-kms-key", KeyLocation: "global", @@ -313,8 +314,8 @@ var ResolvedCommonServerDescriptor = &api.ServerDescriptor{ }}, }, Secrets: api.SecretsConfigDescriptor{ - Type: gcloud.SecretsTypeGCPSecretsManager, - Config: api.Config{Config: &gcloud.SecretsProviderConfig{ + Type: gcpApi.SecretsTypeGCPSecretsManager, + Config: api.Config{Config: &gcpApi.SecretsProviderConfig{ Credentials: ResolvedCommonGcpCredentials, }}, }, @@ -324,8 +325,8 @@ var ResolvedCommonServerDescriptor = &api.ServerDescriptor{ Config: api.Config{Config: &resolvedAwsEcsFargateTemplateConfig}, }, "stack-per-app": { - Type: gcloud.TemplateTypeGcpCloudrun, - Config: api.Config{Config: &gcloud.TemplateConfig{ + Type: gcpApi.TemplateTypeGcpCloudrun, + Config: api.Config{Config: &gcpApi.TemplateConfig{ Credentials: ResolvedCommonGcpCredentials, }}, }, @@ -438,15 +439,15 @@ var RefappServerDescriptor = &api.ServerDescriptor{ Resources: map[string]api.ResourceDescriptor{ "mongodb": mongodbAtlasResourceConfig, "postgres": { - Type: gcloud.ResourceTypePostgresGcpCloudsql, - Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ + Type: gcpApi.ResourceTypePostgresGcpCloudsql, + Config: api.Config{Config: &gcpApi.PostgresGcpCloudsqlConfig{ Version: "14.5", Project: "${stack:name}", - Credentials: gcloud.Credentials{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, }, @@ -460,14 +461,14 @@ var RefappServerDescriptor = &api.ServerDescriptor{ "mongodb": mongodbAtlasResourceConfig, "postgres": { Type: "gcp-cloudsql-postgres", - Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ + Config: api.Config{Config: &gcpApi.PostgresGcpCloudsqlConfig{ Version: "14.5", Project: "${stack:name}", - Credentials: gcloud.Credentials{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, }, @@ -554,15 +555,15 @@ var ResolvedRefappServerDescriptor = &api.ServerDescriptor{ Resources: map[string]api.ResourceDescriptor{ "mongodb": resolvedMongodbAtlasResource, "postgres": { - Type: gcloud.ResourceTypePostgresGcpCloudsql, - Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ + Type: gcpApi.ResourceTypePostgresGcpCloudsql, + Config: api.Config{Config: &gcpApi.PostgresGcpCloudsqlConfig{ Version: "14.5", Project: "refapp", - Credentials: gcloud.Credentials{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "test-gcp-project", }, }, @@ -575,15 +576,15 @@ var ResolvedRefappServerDescriptor = &api.ServerDescriptor{ Resources: map[string]api.ResourceDescriptor{ "mongodb": resolvedMongodbAtlasResource, "postgres": { - Type: gcloud.ResourceTypePostgresGcpCloudsql, - Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ + Type: gcpApi.ResourceTypePostgresGcpCloudsql, + Config: api.Config{Config: &gcpApi.PostgresGcpCloudsqlConfig{ Version: "14.5", Project: "refapp", - Credentials: gcloud.Credentials{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "test-gcp-project", }, }, @@ -599,12 +600,12 @@ var CommonSecretsDescriptor = &api.SecretsDescriptor{ SchemaVersion: api.SecretsSchemaVersion, Auth: map[string]api.AuthDescriptor{ "gcloud": { - Type: gcloud.AuthTypeGCPServiceAccount, - Config: api.Config{Config: &gcloud.Credentials{ + Type: gcpApi.AuthTypeGCPServiceAccount, + Config: api.Config{Config: &gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "test-gcp-project", }, }}, diff --git a/pkg/api/tests/refapp_gke_autopilot.go b/pkg/api/tests/refapp_gke_autopilot.go index 5410c2e76..54bb33110 100644 --- a/pkg/api/tests/refapp_gke_autopilot.go +++ b/pkg/api/tests/refapp_gke_autopilot.go @@ -4,15 +4,16 @@ package tests import ( + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" + "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" ) var RefappGkeAutopilotServerResources = map[string]api.ResourceDescriptor{ "gke-autopilot-res": { Type: "gcp-gke-autopilot-cluster", Config: api.Config{ - Config: &gcloud.GkeAutopilotResource{ + Config: &gcpApi.GkeAutopilotResource{ Credentials: CommonGcpCredentials, GkeMinVersion: "1.33.4-gke.1245000", Location: "europe-west3", @@ -23,7 +24,7 @@ var RefappGkeAutopilotServerResources = map[string]api.ResourceDescriptor{ "artifact-registry-res": { Type: "gcp-artifact-registry", Config: api.Config{ - Config: &gcloud.ArtifactRegistryConfig{ + Config: &gcpApi.ArtifactRegistryConfig{ Credentials: CommonGcpCredentials, Location: "europe-west3", }, @@ -47,7 +48,7 @@ var RefappGkeAutopilotServerDescriptor = &api.ServerDescriptor{ "stack-per-app-gke": { Type: "gcp-gke-autopilot", Config: api.Config{ - Config: &gcloud.GkeAutopilotTemplate{ + Config: &gcpApi.GkeAutopilotTemplate{ Credentials: CommonGcpCredentials, GkeClusterResource: "gke-autopilot-res", ArtifactRegistryResource: "artifact-registry-res", @@ -77,7 +78,7 @@ var ResolvedRefappGkeAutopilotServerResources = map[string]api.ResourceDescripto "gke-autopilot-res": { Type: "gcp-gke-autopilot-cluster", Config: api.Config{ - Config: &gcloud.GkeAutopilotResource{ + Config: &gcpApi.GkeAutopilotResource{ Credentials: ResolvedCommonGcpCredentials, GkeMinVersion: "1.33.4-gke.1245000", Location: "europe-west3", @@ -88,7 +89,7 @@ var ResolvedRefappGkeAutopilotServerResources = map[string]api.ResourceDescripto "artifact-registry-res": { Type: "gcp-artifact-registry", Config: api.Config{ - Config: &gcloud.ArtifactRegistryConfig{ + Config: &gcpApi.ArtifactRegistryConfig{ Credentials: ResolvedCommonGcpCredentials, Location: "europe-west3", }, @@ -106,7 +107,7 @@ var ResolvedRefappGkeAutopilotServerDescriptor = &api.ServerDescriptor{ "stack-per-app-gke": { Type: "gcp-gke-autopilot", Config: api.Config{ - Config: &gcloud.GkeAutopilotTemplate{ + Config: &gcpApi.GkeAutopilotTemplate{ Credentials: ResolvedCommonGcpCredentials, ArtifactRegistryResource: "artifact-registry-res", GkeClusterResource: "gke-autopilot-res", diff --git a/pkg/api/tests/refapp_static_gcp.go b/pkg/api/tests/refapp_static_gcp.go index b040bfe41..5ea535bc7 100644 --- a/pkg/api/tests/refapp_static_gcp.go +++ b/pkg/api/tests/refapp_static_gcp.go @@ -4,8 +4,9 @@ package tests import ( + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" + "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" ) var RefappStaticGCPServerDescriptor = &api.ServerDescriptor{ @@ -21,13 +22,13 @@ var RefappStaticGCPServerDescriptor = &api.ServerDescriptor{ }, Templates: map[string]api.StackDescriptor{ "static-website": { - Type: gcloud.TemplateTypeStaticWebsite, - Config: api.Config{Config: &gcloud.TemplateConfig{ - Credentials: gcloud.Credentials{ + Type: gcpApi.TemplateTypeStaticWebsite, + Config: api.Config{Config: &gcpApi.TemplateConfig{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "${auth:gcloud}", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "${auth:gcloud.projectId}", }, }, @@ -57,13 +58,13 @@ var ResolvedRefappStaticGCPServerDescriptor = &api.ServerDescriptor{ CiCd: ResolvedCommonServerDescriptor.CiCd, Templates: map[string]api.StackDescriptor{ "static-website": { - Type: gcloud.TemplateTypeStaticWebsite, - Config: api.Config{Config: &gcloud.TemplateConfig{ - Credentials: gcloud.Credentials{ + Type: gcpApi.TemplateTypeStaticWebsite, + Config: api.Config{Config: &gcpApi.TemplateConfig{ + Credentials: gcpApi.Credentials{ Credentials: api.Credentials{ Credentials: "", }, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ + ServiceAccountConfig: gcpApi.ServiceAccountConfig{ ProjectId: "test-gcp-project", }, }, diff --git a/pkg/api/tests/testdata/stacks/common/server.yaml b/pkg/api/tests/testdata/stacks/common/server.yaml index 5865ca9f3..c74f80e29 100644 --- a/pkg/api/tests/testdata/stacks/common/server.yaml +++ b/pkg/api/tests/testdata/stacks/common/server.yaml @@ -67,6 +67,11 @@ plugins: type: github-release repository: simple-container-com/sc-plugin-cloudflare version: v2026.10.26-5eddde3 + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da resources: registrar: type: cloudflare diff --git a/pkg/api/tests/testdata/stacks/refapp-ansible-k3s/server.yaml b/pkg/api/tests/testdata/stacks/refapp-ansible-k3s/server.yaml index 934d74bff..93936f199 100644 --- a/pkg/api/tests/testdata/stacks/refapp-ansible-k3s/server.yaml +++ b/pkg/api/tests/testdata/stacks/refapp-ansible-k3s/server.yaml @@ -91,6 +91,11 @@ templates: stack-per-app-aws: type: fargate plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da cloudflare: source: type: github-release diff --git a/pkg/api/tests/testdata/stacks/refapp-gke-autopilot/server.yaml b/pkg/api/tests/testdata/stacks/refapp-gke-autopilot/server.yaml index 84cc4ebeb..347e7fb90 100644 --- a/pkg/api/tests/testdata/stacks/refapp-gke-autopilot/server.yaml +++ b/pkg/api/tests/testdata/stacks/refapp-gke-autopilot/server.yaml @@ -1,3 +1,9 @@ +plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da schemaVersion: 1.0 provisioner: inherit: common diff --git a/pkg/api/tests/testdata/stacks/refapp-static-gcp/server.yaml b/pkg/api/tests/testdata/stacks/refapp-static-gcp/server.yaml index f8d019913..46fd4643c 100644 --- a/pkg/api/tests/testdata/stacks/refapp-static-gcp/server.yaml +++ b/pkg/api/tests/testdata/stacks/refapp-static-gcp/server.yaml @@ -1,3 +1,9 @@ +plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da schemaVersion: 1.0 provisioner: inherit: common diff --git a/pkg/api/tests/testdata/stacks/refapp/server.yaml b/pkg/api/tests/testdata/stacks/refapp/server.yaml index 7f8455cf7..c8e19b07d 100644 --- a/pkg/api/tests/testdata/stacks/refapp/server.yaml +++ b/pkg/api/tests/testdata/stacks/refapp/server.yaml @@ -22,6 +22,11 @@ variables: type: string value: M10 plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da aws: source: type: github-release diff --git a/pkg/clouds/gcloud/artifactregistry.go b/pkg/clouds/gcloud/artifactregistry.go deleted file mode 100644 index a250f3fb1..000000000 --- a/pkg/clouds/gcloud/artifactregistry.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import "github.com/simple-container-com/api/pkg/api" - -const ( - ResourceTypeArtifactRegistry = "gcp-artifact-registry" - ResourceTypeRemoteDockerImagePush = "gcp-docker-image-push" -) - -type ArtifactRegistryConfig struct { - Credentials `json:",inline" yaml:",inline"` - Location string `json:"location" yaml:"location"` - Public *bool `json:"public,omitempty" yaml:"public,omitempty"` - Docker *DockerConfig `json:"docker,omitempty" yaml:"docker,omitempty"` - Domain *string `json:"domain" yaml:"domain"` - BasicAuth *RegistryBasicAuth `json:"basicAuth,omitempty" yaml:"basicAuth,omitempty"` - - // CleanupPolicies declares image retention for the repository. - // - // The provider treats cleanupPolicies as authoritative (unlike labels, whose - // schema documents itself as non-authoritative), so a repository resource - // that does not declare it will CLEAR any policy set by another tool on the - // next provision. Leaving this empty therefore means "SC does not manage - // retention", and SC preserves whatever is configured out of band rather - // than deleting it. See ManagesCleanupPolicies. - // A POINTER so that "absent" and "declared empty" stay distinguishable. - // The placeholder resolver deep-copies configs by reflection and calls - // reflect.MakeSlice for every slice kind, so a nil SLICE arrives as an empty - // one and the difference is destroyed; its pointer branch returns early on - // nil, so a nil POINTER survives. Without that, `cleanupPolicies: []` could - // not mean "managed, and I want none", and removing policies from config - // would silently leave the live ones in place forever. - CleanupPolicies *[]ArtifactRegistryCleanupPolicy `json:"cleanupPolicies,omitempty" yaml:"cleanupPolicies,omitempty"` - - // CleanupPolicyDryRun evaluates the policies and reports what they would - // delete without deleting it. Only meaningful alongside CleanupPolicies. - CleanupPolicyDryRun *bool `json:"cleanupPolicyDryRun,omitempty" yaml:"cleanupPolicyDryRun,omitempty"` -} - -// ManagesCleanupPolicies reports whether retention is declared here. When it is -// not, the caller must tell Pulumi to ignore the field rather than send an empty -// value, which is the difference between "not managed" and "delete the policy". -func (c *ArtifactRegistryConfig) ManagesCleanupPolicies() bool { - return c.CleanupPolicies != nil -} - -// DeclaredCleanupPolicies returns the declared retention, or nil when SC does -// not manage it. An explicitly empty list is "managed, and empty", which is how -// retention is removed. -func (c *ArtifactRegistryConfig) DeclaredCleanupPolicies() []ArtifactRegistryCleanupPolicy { - if c.CleanupPolicies == nil { - return nil - } - return *c.CleanupPolicies -} - -// ArtifactRegistryCleanupPolicy mirrors a single Artifact Registry cleanup -// policy. Durations are the API's second-suffixed form, e.g. "2592000s". -type ArtifactRegistryCleanupPolicy struct { - // Name identifies the policy within the repository. - Name string `json:"name" yaml:"name"` - // Action is DELETE or KEEP (case-insensitive). - Action string `json:"action" yaml:"action"` - Condition *ArtifactRegistryCleanupPolicyCondition `json:"condition,omitempty" yaml:"condition,omitempty"` - // MostRecentVersions retains a minimum number of versions. KEEP only. - MostRecentVersions *ArtifactRegistryCleanupMostRecentVersions `json:"mostRecentVersions,omitempty" yaml:"mostRecentVersions,omitempty"` -} - -type ArtifactRegistryCleanupPolicyCondition struct { - // TagState is TAGGED, UNTAGGED or ANY. - TagState string `json:"tagState,omitempty" yaml:"tagState,omitempty"` - // OlderThan / NewerThan are durations, e.g. "2592000s" for 30 days. - OlderThan string `json:"olderThan,omitempty" yaml:"olderThan,omitempty"` - NewerThan string `json:"newerThan,omitempty" yaml:"newerThan,omitempty"` - TagPrefixes []string `json:"tagPrefixes,omitempty" yaml:"tagPrefixes,omitempty"` - PackageNamePrefixes []string `json:"packageNamePrefixes,omitempty" yaml:"packageNamePrefixes,omitempty"` - VersionNamePrefixes []string `json:"versionNamePrefixes,omitempty" yaml:"versionNamePrefixes,omitempty"` -} - -type ArtifactRegistryCleanupMostRecentVersions struct { - KeepCount *int `json:"keepCount,omitempty" yaml:"keepCount,omitempty"` - PackageNamePrefixes []string `json:"packageNamePrefixes,omitempty" yaml:"packageNamePrefixes,omitempty"` -} - -type RegistryBasicAuth struct { - Username string `json:"username" yaml:"username"` - Password string `json:"password" yaml:"password"` -} - -type DockerConfig struct { - ImmutableTags *bool `json:"immutableTags" yaml:"immutableTags"` -} - -type RemoteImagePush struct { - Credentials `json:",inline" yaml:",inline"` - RemoteImage string `json:"remoteImage" yaml:"remoteImage"` - Name string `json:"name" yaml:"name"` - Tag string `json:"tag" yaml:"tag"` - ArtifactRegistryResource string `json:"artifactRegistryResource" yaml:"artifactRegistryResource"` - RegistryCredentials *string `json:"registryCredentials" yaml:"registryCredentials"` // TODO: support other registries' creds - Platform api.ImagePlatform `json:"platform" yaml:"platform"` -} - -func (i *RemoteImagePush) DependsOnResources() []api.ParentResourceDependency { - return []api.ParentResourceDependency{ - {Name: i.ArtifactRegistryResource}, - } -} - -func ArtifactRegistryConfigReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &ArtifactRegistryConfig{}) -} - -func DockerRemoteImagePushReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &RemoteImagePush{}) -} diff --git a/pkg/clouds/gcloud/auth.go b/pkg/clouds/gcloud/auth.go deleted file mode 100644 index 9bfde2de6..000000000 --- a/pkg/clouds/gcloud/auth.go +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/pkg/errors" - - "github.com/simple-container-com/api/pkg/api" -) - -const ( - AuthTypeGCPServiceAccount = "gcp-service-account" - SecretsTypeGCPSecretsManager = "gcp-secrets-manager" - - StateStorageTypeGcpBucket = "gcp-bucket" - SecretsProviderTypeGcpKms = "gcp-kms" -) - -const ( - // DefaultKeyRotationPeriod is applied when keyRotationPeriod is unset. - // - // Cloud KMS bills every ACTIVE key version (ENABLED, DISABLED and - // DESTROY_SCHEDULED all count; only DESTROYED is free) and rotation never - // re-encrypts existing ciphertext, so every version a key mints stays - // load-bearing and billed for the lifetime of the key. That makes the - // rotation period a direct, compounding cost multiplier: one provisioned - // key per stack rotating daily adds a billed version per stack per day, - // forever. - DefaultKeyRotationPeriod = "7776000s" // 90 days - - // MinKeyRotationPeriodSeconds is the lower bound accepted for an explicit - // keyRotationPeriod. GCP's own floor is 86400s (1 day), which is far too - // low to be a sane default for a per-stack provisioned key: a period of a - // few hours or days passes GCP validation and silently accrues versions. - // Rejecting anything under 30 days turns that class of typo into a - // config-parse error instead of an unattributed bill months later. - MinKeyRotationPeriodSeconds = 2592000 // 30 days - - // GCP's own documented bounds for rotationPeriod: at least 24h, at most - // 876,000h. These are NOT waivable by AllowShortKeyRotation — a value - // outside them is rejected by the KMS API, and by then the KeyRing has - // already been created and can never be deleted. Failing here keeps that - // class of error away from any side effect. - GcpMinKeyRotationPeriodSeconds = 86400 // 24h - GcpMaxKeyRotationPeriodSeconds = 3153600000 // 876,000h -) - -type ServiceAccountConfig struct { - ProjectId string `json:"projectId" yaml:"projectId"` - // ServiceAccount is the email of the identity the ambient credentials act as. - // Only read when credentials are empty (see UsesAmbientCredentials), where no - // key JSON exists to take the email from; resources that grant the deploying - // identity access to themselves (a static website's write binding) need it. - ServiceAccount string `json:"serviceAccount,omitempty" yaml:"serviceAccount,omitempty"` -} - -type Credentials struct { - api.Credentials `json:",inline" yaml:",inline"` - ServiceAccountConfig `json:",inline" yaml:",inline"` -} - -type CredentialsParsed struct { - Type string `json:"type"` - ClientEmail string `json:"client_email"` -} - -type StateStorageConfig struct { - Credentials `json:",inline" yaml:",inline"` - BucketName string `json:"bucketName" yaml:"bucketName"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Location *string `json:"location" yaml:"location"` - Provision bool `json:"provision" yaml:"provision"` - - // NoncurrentVersionRetentionDays bounds how long overwritten state - // generations are kept when the bucket has object versioning enabled. - // - // It matters beyond storage cost. Pulumi's cloud secrets manager re-wraps - // each stack's data key on every state write, and a KMS symmetric encrypt - // always uses the key's current primary version, so each retained - // generation depends on whichever key version was primary when it was - // written. With versioning on and no lifecycle rule, that set of - // load-bearing key versions grows without limit and no key version can - // ever be proven unreferenced. - // - // Zero disables the rule. Nil means DefaultNoncurrentVersionRetentionDays. - NoncurrentVersionRetentionDays *int `json:"noncurrentVersionRetentionDays,omitempty" yaml:"noncurrentVersionRetentionDays,omitempty"` -} - -// DefaultNoncurrentVersionRetentionDays is the rollback horizon applied when -// noncurrentVersionRetentionDays is unset. Long enough to recover from a bad -// deployment, short enough that the dependency set stays bounded. -const DefaultNoncurrentVersionRetentionDays = 30 - -// EffectiveNoncurrentVersionRetentionDays resolves the configured retention, -// falling back to the default when unset. A configured zero disables the rule. -func (s *StateStorageConfig) EffectiveNoncurrentVersionRetentionDays() int { - if s.NoncurrentVersionRetentionDays == nil { - return DefaultNoncurrentVersionRetentionDays - } - return *s.NoncurrentVersionRetentionDays -} - -// GetBucketName returns the bucket name, supporting both "name" and "bucketName" fields -// Falls back to "name" if "bucketName" is empty, or "bucketName" if "name" is empty -func (s *StateStorageConfig) GetBucketName() string { - if s.BucketName != "" { - return s.BucketName - } - return s.Name -} - -type SecretsProviderConfig struct { - Credentials `json:",inline" yaml:",inline"` - // format: - // "gcpkms://projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s" - KeyName string `json:"keyName" yaml:"keyName"` - - // only applicable when provision=true - KeyLocation string `json:"keyLocation" yaml:"keyLocation"` - // only applicable when provision=true - KeyRotationPeriod string `json:"keyRotationPeriod,omitempty" yaml:"keyRotationPeriod,omitempty"` - // AllowShortKeyRotation opts out of the MinKeyRotationPeriodSeconds floor. - // Rotating faster than 30 days is a legitimate compliance choice; it is - // gated only because every rotation mints a permanently billed key version, - // so the common case of a mistyped period should fail loudly. Setting this - // makes the short period a deliberate, reviewable decision. - AllowShortKeyRotation bool `json:"allowShortKeyRotation,omitempty" yaml:"allowShortKeyRotation,omitempty"` - - // whether to provision key - Provision bool `json:"provision" yaml:"provision"` -} - -func (sa *StateStorageConfig) StorageUrl() string { - return fmt.Sprintf("gs://%s", sa.GetBucketName()) -} - -func (sa *StateStorageConfig) IsProvisionEnabled() bool { - return sa.Provision -} - -func (r *SecretsProviderConfig) IsProvisionEnabled() bool { - return r.Provision -} - -func (r *SecretsProviderConfig) KeyUrl() string { - return r.KeyName -} - -// EffectiveKeyRotationPeriod returns the configured rotation period, or -// DefaultKeyRotationPeriod when unset. -func (r *SecretsProviderConfig) EffectiveKeyRotationPeriod() string { - if r.KeyRotationPeriod == "" { - return DefaultKeyRotationPeriod - } - return r.KeyRotationPeriod -} - -// Validate checks the secrets-provider config. Follows the same shape as the -// other GCP configs in this package (PostgresGcpCloudsqlConfig.Validate, -// ExternalEgressIpConfig.Validate) so there is one convention to learn. -// -// keyRotationPeriod only applies when the key is provisioned here; a BYO key -// referenced by keyName ignores it, so a stale value must not block those -// consumers. An empty value is valid and means DefaultKeyRotationPeriod. -func (r *SecretsProviderConfig) Validate() error { - if !r.Provision || r.KeyRotationPeriod == "" { - return nil - } - raw := r.KeyRotationPeriod - if !strings.HasSuffix(raw, "s") { - return errors.Errorf("keyRotationPeriod %q must be a duration in seconds with an 's' suffix, e.g. %q", raw, DefaultKeyRotationPeriod) - } - secs, err := strconv.Atoi(strings.TrimSuffix(raw, "s")) - if err != nil { - return errors.Errorf("keyRotationPeriod %q must be a whole number of seconds with an 's' suffix, e.g. %q", raw, DefaultKeyRotationPeriod) - } - // GCP's own bounds first, and never waivable: outside them the KMS API - // rejects the key AFTER the KeyRing exists. - if secs < GcpMinKeyRotationPeriodSeconds { - return errors.Errorf("keyRotationPeriod %q is %d seconds; GCP requires at least %d (24h)", - raw, secs, GcpMinKeyRotationPeriodSeconds) - } - if secs > GcpMaxKeyRotationPeriodSeconds { - return errors.Errorf("keyRotationPeriod %q is %d seconds; GCP allows at most %d (876,000h)", - raw, secs, GcpMaxKeyRotationPeriodSeconds) - } - if secs < MinKeyRotationPeriodSeconds && !r.AllowShortKeyRotation { - return errors.Errorf("keyRotationPeriod %q (%ds) is below the %ds (30 day) minimum; set allowShortKeyRotation: true to override", - raw, secs, MinKeyRotationPeriodSeconds) - } - return nil -} - -func (r *Credentials) ProviderType() string { - return ProviderType -} - -func (r *Credentials) ProjectIdValue() string { - return r.ProjectId -} - -func (r *Credentials) CredentialsValue() string { - return r.Credentials.Credentials // just return serialized gcp account json -} - -// UsesAmbientCredentials reports whether no service-account key is configured, in -// which case every GCP client uses Application Default Credentials: whatever the -// environment provides, such as a GitHub OIDC token exchanged through Workload -// Identity Federation. An empty value used to fail at the first API call, so no -// working configuration changes meaning. -func (r *Credentials) UsesAmbientCredentials() bool { - return UsesAmbientCredentials(r.CredentialsValue()) -} - -// UsesAmbientCredentials reports whether a credentials value means "use ADC". -func UsesAmbientCredentials(credentials string) bool { - return strings.TrimSpace(credentials) == "" -} - -func (r *Credentials) CredentialsParsed() (*CredentialsParsed, error) { - if r.UsesAmbientCredentials() { - if r.ServiceAccount == "" { - return nil, errors.New("credentials are empty (ambient mode), so the deploying identity cannot be read from a key; set serviceAccount to its email") - } - return &CredentialsParsed{Type: "service_account", ClientEmail: r.ServiceAccount}, nil - } - var key CredentialsParsed - if err := json.Unmarshal([]byte(r.CredentialsValue()), &key); err != nil { - return nil, err - } - return &key, nil -} - -func ReadAuthServiceAccountConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &Credentials{}) -} - -func ReadStateStorageConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &StateStorageConfig{}) -} - -func ReadSecretsProviderConfig(config *api.Config) (api.Config, error) { - out, err := api.ConvertConfig(config, &SecretsProviderConfig{}) - if err != nil { - return out, err - } - // Validate here rather than only in the provisioner: the secrets-provider - // stack is Up'd only when its URL export is absent, so a provisioner-only - // check never runs for an already-provisioned stack and a bad value would - // sit unnoticed until a DR rebuild. - if sp, ok := out.Config.(*SecretsProviderConfig); ok { - if err := sp.Validate(); err != nil { - return out, err - } - } - return out, nil -} diff --git a/pkg/clouds/gcloud/auth_test.go b/pkg/clouds/gcloud/auth_test.go deleted file mode 100644 index 161d0db2c..000000000 --- a/pkg/clouds/gcloud/auth_test.go +++ /dev/null @@ -1,370 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "encoding/json" - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" - - "github.com/simple-container-com/api/pkg/api" -) - -// ---- Credentials getters ------------------------------------------------- - -func TestCredentials_ProviderType(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{} - Expect(c.ProviderType()).To(Equal(ProviderType)) - Expect(c.ProviderType()).To(Equal("gcp")) -} - -func TestCredentials_ProjectIdValue(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - projectId string - want string - }{ - {name: "populated project id", projectId: "my-gcp-project", want: "my-gcp-project"}, - {name: "empty project id", projectId: "", want: ""}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{ - ServiceAccountConfig: ServiceAccountConfig{ProjectId: tc.projectId}, - } - Expect(c.ProjectIdValue()).To(Equal(tc.want)) - }) - } -} - -func TestCredentials_CredentialsValue(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - creds string - want string - }{ - {name: "serialized gcp account json", creds: `{"type":"service_account","client_email":"sa@p.iam.gserviceaccount.com"}`, want: `{"type":"service_account","client_email":"sa@p.iam.gserviceaccount.com"}`}, - {name: "empty credentials", creds: "", want: ""}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{ - Credentials: api.Credentials{Credentials: tc.creds}, - } - Expect(c.CredentialsValue()).To(Equal(tc.want)) - }) - } -} - -func TestCredentials_CredentialsParsed(t *testing.T) { - RegisterTestingT(t) - - t.Run("valid service account json parses type and client_email", func(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{ - Credentials: api.Credentials{Credentials: `{"type":"service_account","client_email":"sa@proj.iam.gserviceaccount.com","extra":"ignored"}`}, - } - parsed, err := c.CredentialsParsed() - Expect(err).ToNot(HaveOccurred()) - Expect(parsed).ToNot(BeNil()) - Expect(parsed.Type).To(Equal("service_account")) - Expect(parsed.ClientEmail).To(Equal("sa@proj.iam.gserviceaccount.com")) - }) - - t.Run("invalid json returns an error", func(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{ - Credentials: api.Credentials{Credentials: "this-is-not-json"}, - } - parsed, err := c.CredentialsParsed() - Expect(err).To(HaveOccurred()) - Expect(parsed).To(BeNil()) - }) - - t.Run("empty credentials string returns an error", func(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{Credentials: api.Credentials{Credentials: ""}} - _, err := c.CredentialsParsed() - Expect(err).To(HaveOccurred()) - }) - - // Interface conformance: Credentials must satisfy api.AuthConfig. - var _ api.AuthConfig = &Credentials{} -} - -// ---- StateStorageConfig getters ----------------------------------------- - -func TestStateStorageConfig_GetBucketName(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - bucketName string - nameField string - want string - }{ - {name: "bucketName takes precedence", bucketName: "bucket-a", nameField: "name-b", want: "bucket-a"}, - {name: "falls back to name when bucketName empty", bucketName: "", nameField: "name-b", want: "name-b"}, - {name: "both empty yields empty", bucketName: "", nameField: "", want: ""}, - {name: "only bucketName set", bucketName: "bucket-a", nameField: "", want: "bucket-a"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - s := &StateStorageConfig{BucketName: tc.bucketName, Name: tc.nameField} - Expect(s.GetBucketName()).To(Equal(tc.want)) - }) - } -} - -func TestStateStorageConfig_StorageUrl(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - bucketName string - nameField string - want string - }{ - {name: "uses bucketName", bucketName: "my-state", nameField: "", want: "gs://my-state"}, - {name: "uses name fallback", bucketName: "", nameField: "fallback-state", want: "gs://fallback-state"}, - {name: "empty bucket", bucketName: "", nameField: "", want: "gs://"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - s := &StateStorageConfig{BucketName: tc.bucketName, Name: tc.nameField} - Expect(s.StorageUrl()).To(Equal(tc.want)) - }) - } -} - -func TestStateStorageConfig_IsProvisionEnabled(t *testing.T) { - RegisterTestingT(t) - Expect((&StateStorageConfig{Provision: true}).IsProvisionEnabled()).To(BeTrue()) - Expect((&StateStorageConfig{Provision: false}).IsProvisionEnabled()).To(BeFalse()) -} - -// ---- SecretsProviderConfig getters -------------------------------------- - -func TestSecretsProviderConfig_IsProvisionEnabled(t *testing.T) { - RegisterTestingT(t) - Expect((&SecretsProviderConfig{Provision: true}).IsProvisionEnabled()).To(BeTrue()) - Expect((&SecretsProviderConfig{Provision: false}).IsProvisionEnabled()).To(BeFalse()) -} - -func TestSecretsProviderConfig_KeyUrl(t *testing.T) { - RegisterTestingT(t) - key := "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k" - Expect((&SecretsProviderConfig{KeyName: key}).KeyUrl()).To(Equal(key)) - Expect((&SecretsProviderConfig{}).KeyUrl()).To(Equal("")) -} - -// ---- Read* config readers ----------------------------------------------- - -func TestReadAuthServiceAccountConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps projectId and credentials", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "credentials": `{"type":"service_account"}`, - }} - out, err := ReadAuthServiceAccountConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - c, ok := out.Config.(*Credentials) - Expect(ok).To(BeTrue()) - Expect(c.ProjectId).To(Equal("my-gcp-project")) - Expect(c.CredentialsValue()).To(Equal(`{"type":"service_account"}`)) - - var _ api.AuthConfig = c - }) - - t.Run("error path: wrong-typed field surfaces conversion error", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": []string{"not", "a", "string"}, - }} - _, err := ReadAuthServiceAccountConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -func TestReadStateStorageConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "bucketName": "sc-state", - "location": "EU", - "provision": true, - }} - out, err := ReadStateStorageConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - ss, ok := out.Config.(*StateStorageConfig) - Expect(ok).To(BeTrue()) - Expect(ss.GetBucketName()).To(Equal("sc-state")) - Expect(ss.IsProvisionEnabled()).To(BeTrue()) - Expect(ss.StorageUrl()).To(Equal("gs://sc-state")) - Expect(ss.Location).ToNot(BeNil()) - Expect(*ss.Location).To(Equal("EU")) - - var _ api.StateStorageConfig = ss - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "provision": "not-a-bool-but-a-map", - "bucketName": map[string]any{ - "nested": "value", - }, - }} - _, err := ReadStateStorageConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -func TestReadSecretsProviderConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - key := "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k" - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "keyName": key, - "keyLocation": "global", - "keyRotationPeriod": "7776000s", - "provision": false, - }} - out, err := ReadSecretsProviderConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - sp, ok := out.Config.(*SecretsProviderConfig) - Expect(ok).To(BeTrue()) - Expect(sp.KeyUrl()).To(Equal(key)) - Expect(sp.KeyLocation).To(Equal("global")) - Expect(sp.KeyRotationPeriod).To(Equal("7776000s")) - Expect(sp.IsProvisionEnabled()).To(BeFalse()) - - var _ api.SecretsProviderConfig = sp - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "keyName": []int{1, 2, 3}, - }} - _, err := ReadSecretsProviderConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// CredentialsValue round-trips through json (used by api.ConvertAuth). -func TestCredentials_RoundTripJSON(t *testing.T) { - RegisterTestingT(t) - raw := `{"type":"service_account","client_email":"sa@p.iam.gserviceaccount.com"}` - c := &Credentials{Credentials: api.Credentials{Credentials: raw}} - var parsed CredentialsParsed - Expect(json.Unmarshal([]byte(c.CredentialsValue()), &parsed)).To(Succeed()) - Expect(parsed.Type).To(Equal("service_account")) -} - -// The read path validates, not just the provisioner: an already-provisioned -// secrets-provider stack is skipped by the provisioner (its URL export exists), -// so a bad rotation period would otherwise surface only at a DR rebuild. -func TestReadSecretsProviderConfig_RejectsBadRotationPeriodAtReadTime(t *testing.T) { - RegisterTestingT(t) - - for _, tc := range []struct { - name string - period string - errSubstr string - }{ - {name: "missing s suffix", period: "90d", errSubstr: "must be a duration in seconds"}, - {name: "not a number", period: "ninetys", errSubstr: "whole number of seconds"}, - {name: "below GCP floor", period: "3600s", errSubstr: "GCP requires at least"}, - {name: "below 30 day minimum", period: "604800s", errSubstr: "allowShortKeyRotation"}, - } { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "keyName": "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k", - "keyLocation": "global", - "keyRotationPeriod": tc.period, - "provision": true, - }} - _, err := ReadSecretsProviderConfig(cfg) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) - }) - } - - // provision:false means the period is a BYO-key leftover and must not block. - t.Run("ignored when not provisioning", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "keyName": "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k", - "keyRotationPeriod": "90d", - "provision": false, - }} - _, err := ReadSecretsProviderConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - }) -} - -// Nil means "apply the default", zero means "disable the rule" — the two are -// not interchangeable, and collapsing them would silently drop the rollback -// horizon on every state bucket that never set the field. -func TestStateStorageConfig_EffectiveNoncurrentVersionRetentionDays(t *testing.T) { - RegisterTestingT(t) - - Expect((&StateStorageConfig{}).EffectiveNoncurrentVersionRetentionDays()). - To(Equal(DefaultNoncurrentVersionRetentionDays)) - Expect((&StateStorageConfig{NoncurrentVersionRetentionDays: lo.ToPtr(0)}).EffectiveNoncurrentVersionRetentionDays()). - To(Equal(0), "an explicit zero disables the rule and must not fall back to the default") - Expect((&StateStorageConfig{NoncurrentVersionRetentionDays: lo.ToPtr(7)}).EffectiveNoncurrentVersionRetentionDays()). - To(Equal(7)) -} - -// ---- ambient credentials ------------------------------------------------- - -func TestCredentials_UsesAmbientCredentials(t *testing.T) { - RegisterTestingT(t) - Expect((&Credentials{}).UsesAmbientCredentials()).To(BeTrue()) - Expect((&Credentials{Credentials: api.Credentials{Credentials: " \n"}}).UsesAmbientCredentials()).To(BeTrue()) - Expect((&Credentials{Credentials: api.Credentials{Credentials: `{"type":"service_account"}`}}).UsesAmbientCredentials()).To(BeFalse()) -} - -func TestCredentials_CredentialsParsed_Ambient(t *testing.T) { - RegisterTestingT(t) - _, err := (&Credentials{}).CredentialsParsed() - Expect(err).To(MatchError(ContainSubstring("set serviceAccount"))) - - c := &Credentials{ServiceAccountConfig: ServiceAccountConfig{ServiceAccount: "deployer@p.iam.gserviceaccount.com"}} - parsed, err := c.CredentialsParsed() - Expect(err).NotTo(HaveOccurred()) - Expect(parsed.Type).To(Equal("service_account")) - Expect(parsed.ClientEmail).To(Equal("deployer@p.iam.gserviceaccount.com")) -} - -func TestCredentials_CredentialsParsed_KeyWinsOverServiceAccount(t *testing.T) { - RegisterTestingT(t) - c := &Credentials{ - Credentials: api.Credentials{Credentials: `{"type":"service_account","client_email":"key@p.iam.gserviceaccount.com"}`}, - ServiceAccountConfig: ServiceAccountConfig{ServiceAccount: "other@p.iam.gserviceaccount.com"}, - } - parsed, err := c.CredentialsParsed() - Expect(err).NotTo(HaveOccurred()) - Expect(parsed.ClientEmail).To(Equal("key@p.iam.gserviceaccount.com")) -} diff --git a/pkg/clouds/gcloud/bucket.go b/pkg/clouds/gcloud/bucket.go deleted file mode 100644 index efef5f06b..000000000 --- a/pkg/clouds/gcloud/bucket.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import "github.com/simple-container-com/api/pkg/api" - -const ResourceTypeBucket = "gcp-bucket" - -type GcpBucket struct { - Credentials `json:",inline" yaml:",inline"` - Name string `json:"name,omitempty" yaml:"name"` - Location string `json:"location" yaml:"location"` - // Resource adoption fields - Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` - BucketName string `json:"bucketName,omitempty" yaml:"bucketName,omitempty"` -} - -// GetBucketName returns the bucket name, supporting both "name" and "bucketName" fields -// Falls back to "name" if "bucketName" is empty, or "bucketName" if "name" is empty -func (b *GcpBucket) GetBucketName() string { - if b.BucketName != "" { - return b.BucketName - } - return b.Name -} - -func GcpBucketReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &GcpBucket{}) -} diff --git a/pkg/clouds/gcloud/cloudrun.go b/pkg/clouds/gcloud/cloudrun.go deleted file mode 100644 index f7e699f92..000000000 --- a/pkg/clouds/gcloud/cloudrun.go +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "github.com/pkg/errors" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" - "github.com/simple-container-com/api/pkg/clouds/k8s" -) - -const ( - TemplateTypeGcpCloudrun = "cloudrun" -) - -type AlertsConfig struct { - MaxErrors MaxErrorConfig - Discord DiscordCfg - Telegram TelegramCfg -} - -type TelegramCfg struct { - DefaultChatId string -} - -type DiscordCfg struct { - WebhookId string -} - -type MaxErrorConfig struct { - ErrorLogMessageRegexp string - MaxErrorCount int -} - -type CloudRunInput struct { - TemplateConfig `json:"templateConfig" yaml:"templateConfig"` - Deployment k8s.DeploymentConfig `json:"deployment" yaml:"deployment"` -} - -func (i *CloudRunInput) Uses() []string { - return i.Deployment.StackConfig.Uses -} - -func (i *CloudRunInput) OverriddenBaseZone() string { - return i.Deployment.StackConfig.BaseDnsZone -} - -func ToCloudRunConfig(tpl any, composeCfg compose.Config, stackCfg *api.StackConfigCompose) (any, error) { - templateCfg, ok := tpl.(*TemplateConfig) - if !ok { - return nil, errors.Errorf("template config is not of type *gcloud.TemplateConfig") - } - if templateCfg == nil { - return nil, errors.Errorf("template config is nil") - } - - res := &CloudRunInput{ - TemplateConfig: *templateCfg, - Deployment: k8s.DeploymentConfig{ - StackConfig: stackCfg, - }, - } - containers, err := k8s.ConvertComposeToContainers(composeCfg, stackCfg) - if err != nil { - return nil, err - } - res.Deployment.Containers = containers - - iContainer, err := k8s.FindIngressContainer(composeCfg, containers) - if err != nil { - return nil, errors.Wrapf(err, "failed to detect ingress container") - } - res.Deployment.IngressContainer = iContainer - - return res, nil -} diff --git a/pkg/clouds/gcloud/cloudrun_test.go b/pkg/clouds/gcloud/cloudrun_test.go deleted file mode 100644 index 4bc356939..000000000 --- a/pkg/clouds/gcloud/cloudrun_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "context" - "testing" - - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" -) - -func TestToCloudRunConfig(t *testing.T) { - RegisterTestingT(t) - - tests := []struct { - name string - tpl any - stackCfg *api.StackConfigCompose - check func(t *testing.T, got any, err error) - }{ - { - name: "happy path", - tpl: &TemplateConfig{}, - stackCfg: &api.StackConfigCompose{}, - check: func(t *testing.T, got any, err error) { - Expect(err).To(BeNil()) - _, ok := got.(*CloudRunInput) - Expect(ok).To(BeTrue()) - _, ok = got.(api.AuthConfig) - Expect(ok).To(BeTrue()) - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - cfg, err := compose.ReadDockerCompose(ctx, "", "testdata/stacks/refapp/docker-compose.yaml") - Expect(err).To(BeNil()) - Expect(cfg.Project).NotTo(BeNil()) - - got, err := ToCloudRunConfig(tt.tpl, cfg, tt.stackCfg) - - if tt.check != nil { - tt.check(t, got, err) - } - }) - } -} diff --git a/pkg/clouds/gcloud/config_validation_test.go b/pkg/clouds/gcloud/config_validation_test.go deleted file mode 100644 index 35369e186..000000000 --- a/pkg/clouds/gcloud/config_validation_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "strconv" - "strings" - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" -) - -// Cloud NAT port tuning is validated against the EFFECTIVE values (defaults -// applied) and GCP's real bounds, so misconfigurations fail here instead of late -// at pulumi up. -func TestExternalEgressIpConfig_NatTuningValidate(t *testing.T) { - tests := []struct { - name string - cfg ExternalEgressIpConfig - errSubstr string - }{ - {name: "valid dynamic port allocation", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024), MaxPortsPerVm: lo.ToPtr(8192), DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false)}}, - {name: "valid raised min without dpa", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024)}}, - {name: "min below floor", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(16)}, errSubstr: "minPortsPerVm must be between"}, - {name: "min above ceiling", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(70000)}, errSubstr: "minPortsPerVm must be between"}, - {name: "max below floor", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(16)}, errSubstr: "maxPortsPerVm must be between"}, - {name: "max above ceiling", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(70000)}, errSubstr: "maxPortsPerVm must be between"}, - {name: "lone max below default min", cfg: ExternalEgressIpConfig{Enabled: true, MaxPortsPerVm: lo.ToPtr(48)}, errSubstr: "must be >= minPortsPerVm"}, - {name: "explicit max below min", cfg: ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(4096), MaxPortsPerVm: lo.ToPtr(1024)}, errSubstr: "must be >= minPortsPerVm"}, - {name: "dpa requires eim off when unset", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true)}, errSubstr: "endpointIndependentMapping: false"}, - {name: "dpa requires eim off when explicitly true", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(true)}, errSubstr: "endpointIndependentMapping: false"}, - {name: "dpa min not power of two", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MinPortsPerVm: lo.ToPtr(1000)}, errSubstr: "minPortsPerVm must be a power of two"}, - {name: "dpa max not power of two", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MaxPortsPerVm: lo.ToPtr(3000)}, errSubstr: "maxPortsPerVm must be a power of two"}, - {name: "dpa max equals min", cfg: ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true), EndpointIndependentMapping: lo.ToPtr(false), MinPortsPerVm: lo.ToPtr(1024), MaxPortsPerVm: lo.ToPtr(1024)}, errSubstr: "must be > minPortsPerVm"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - err := tc.cfg.Validate() - if tc.errSubstr == "" { - Expect(err).ToNot(HaveOccurred()) - } else { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) - } - }) - } -} - -func TestPostgresGcpCloudsqlConfig_Validate(t *testing.T) { - tests := []struct { - name string - cfg PostgresGcpCloudsqlConfig - errSubstr string - }{ - {name: "empty is valid", cfg: PostgresGcpCloudsqlConfig{}}, - {name: "availabilityType invalid", cfg: PostgresGcpCloudsqlConfig{AvailabilityType: lo.ToPtr("HA")}, errSubstr: "availabilityType must be"}, - {name: "availabilityType regional ok", cfg: PostgresGcpCloudsqlConfig{AvailabilityType: lo.ToPtr("REGIONAL")}}, - {name: "privateNetwork bad format", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("my-vpc")}, errSubstr: "privateNetwork must be a full"}, - {name: "privateNetwork ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}}, - {name: "public off without network", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false)}, errSubstr: "requires privateNetwork"}, - {name: "public off with empty network", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false), PrivateNetwork: lo.ToPtr("")}, errSubstr: "requires privateNetwork"}, - {name: "public off with network ok", cfg: PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false), PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}}, - {name: "proxy private without network", cfg: PostgresGcpCloudsqlConfig{ProxyPrivateIp: lo.ToPtr(true)}, errSubstr: "proxyPrivateIp: true requires privateNetwork"}, - {name: "proxy private with network ok", cfg: PostgresGcpCloudsqlConfig{ProxyPrivateIp: lo.ToPtr(true), PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}}, - {name: "proxy public with public off", cfg: PostgresGcpCloudsqlConfig{ProxyPrivateIp: lo.ToPtr(false), PublicIpEnabled: lo.ToPtr(false), PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}, errSubstr: "contradicts publicIpEnabled: false"}, - {name: "proxy public explicit ok", cfg: PostgresGcpCloudsqlConfig{ProxyPrivateIp: lo.ToPtr(false)}}, - {name: "psa range without network", cfg: PostgresGcpCloudsqlConfig{PrivateServicesAccessRange: lo.ToPtr("10.30.0.0/20")}, errSubstr: "requires privateNetwork"}, - {name: "psa range not a CIDR", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.30.0.0")}, errSubstr: "must be a CIDR"}, - {name: "psa range public", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("8.8.8.0/24")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range ipv6", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("fd00::/48")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range host bits", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.30.0.1/20")}, errSubstr: "use \"10.30.0.0/20\""}, - {name: "psa range too small", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.30.0.0/25")}, errSubstr: "/24 or larger"}, - {name: "psa range /24 ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("192.168.10.0/24")}}, - {name: "psa range /20 ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.30.0.0/20")}}, - {name: "psa range empty is unset", cfg: PostgresGcpCloudsqlConfig{PrivateServicesAccessRange: lo.ToPtr("")}}, - {name: "psa range spans 10/8 and 11/8", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.0.0.0/7")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range wider than 172.16/12", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("172.16.0.0/11")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range wider than 192.168/16", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("192.168.0.0/15")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range ipv4-mapped ipv6", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("::ffff:10.0.0.0/104")}, errSubstr: "inside 10.0.0.0/8"}, - {name: "psa range whole 10/8 ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.0.0.0/8")}}, - {name: "psa range whole 172.16/12 ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("172.16.0.0/12")}}, - {name: "psa range surrounding whitespace ok", cfg: PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr(" 10.30.0.0/20 ")}}, - {name: "psa range with adopt", cfg: PostgresGcpCloudsqlConfig{Adopt: true, PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc"), PrivateServicesAccessRange: lo.ToPtr("10.30.0.0/20")}, errSubstr: "not supported with adopt"}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - err := tc.cfg.Validate() - if tc.errSubstr == "" { - Expect(err).ToNot(HaveOccurred()) - } else { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) - } - }) - } -} - -func TestPostgresGcpCloudsqlConfig_ProxyAndNetworkHelpers(t *testing.T) { - RegisterTestingT(t) - Expect((&PostgresGcpCloudsqlConfig{}).HasPrivateNetwork()).To(BeFalse()) - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("")}).HasPrivateNetwork()).To(BeFalse()) - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}).HasPrivateNetwork()).To(BeTrue()) - - // Proxy stays on the public endpoint until the public IP is disabled. - Expect((&PostgresGcpCloudsqlConfig{}).UsesPrivateIpProxy()).To(BeFalse()) - Expect((&PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(true)}).UsesPrivateIpProxy()).To(BeFalse()) - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("projects/p/global/networks/vpc")}).UsesPrivateIpProxy()).To(BeFalse()) - Expect((&PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(false)}).UsesPrivateIpProxy()).To(BeTrue()) - - // proxyPrivateIp moves the proxy to the private IP while the public IP stays on. - net := lo.ToPtr("projects/p/global/networks/vpc") - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: net, ProxyPrivateIp: lo.ToPtr(true)}).UsesPrivateIpProxy()).To(BeTrue()) - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: net, ProxyPrivateIp: lo.ToPtr(true), PublicIpEnabled: lo.ToPtr(true)}).UsesPrivateIpProxy()).To(BeTrue()) - Expect((&PostgresGcpCloudsqlConfig{PrivateNetwork: net, ProxyPrivateIp: lo.ToPtr(false)}).UsesPrivateIpProxy()).To(BeFalse()) -} - -// Cloud KMS bills every ACTIVE key version (ENABLED, DISABLED and -// DESTROY_SCHEDULED; only DESTROYED is free) and rotation never re-encrypts -// existing ciphertext, so every version a key mints stays billed for the life -// of the key. With one provisioned key per stack, the rotation period is a -// compounding cost multiplier rather than a cosmetic setting. -func TestSecretsProviderConfig_EffectiveKeyRotationPeriod(t *testing.T) { - RegisterTestingT(t) - - Expect((&SecretsProviderConfig{}).EffectiveKeyRotationPeriod()).To(Equal(DefaultKeyRotationPeriod)) - Expect((&SecretsProviderConfig{KeyRotationPeriod: "31536000s"}).EffectiveKeyRotationPeriod()). - To(Equal("31536000s"), "an explicit value must win over the default") -} - -func TestDefaultKeyRotationPeriodIsSane(t *testing.T) { - RegisterTestingT(t) - - // Pin the value, not just its properties: asserting only "effective == - // Default" passes for any constant, including the 100000s (27.8h) typo - // this default replaced. - Expect(DefaultKeyRotationPeriod).To(Equal("7776000s"), "90 days") - - secs, err := strconv.Atoi(strings.TrimSuffix(DefaultKeyRotationPeriod, "s")) - Expect(err).To(BeNil()) - Expect(secs).To(BeNumerically(">=", MinKeyRotationPeriodSeconds)) - Expect(secs).To(BeNumerically(">", GcpMinKeyRotationPeriodSeconds), - "must be well clear of GCP's 24h minimum") - Expect(secs).To(BeNumerically("<=", GcpMaxKeyRotationPeriodSeconds)) -} - -func TestSecretsProviderConfig_Validate(t *testing.T) { - tests := []struct { - name string - cfg SecretsProviderConfig - errSubstr string - }{ - {name: "unset means default", cfg: SecretsProviderConfig{Provision: true}}, - {name: "90 days", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "7776000s"}}, - {name: "exactly the 30-day floor", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "2592000s"}}, - // keyRotationPeriod is meaningless for a BYO key referenced by keyName, - // so a stale value must not block those consumers. - {name: "provision disabled ignores the period", cfg: SecretsProviderConfig{Provision: false, KeyRotationPeriod: "100000s"}}, - {name: "below policy floor", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "100000s"}, errSubstr: "30 day"}, - {name: "one second under the floor", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "2591999s"}, errSubstr: "30 day"}, - {name: "policy floor waivable", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "604800s", AllowShortKeyRotation: true}}, - // The opt-out waives the POLICY floor only. GCP's own bounds stay hard: - // breaching them fails at the KMS API after the KeyRing already exists, - // and a KeyRing can never be deleted. - {name: "gcp floor NOT waivable", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "3600s", AllowShortKeyRotation: true}, errSubstr: "at least 86400"}, - {name: "zero NOT waivable", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "0s", AllowShortKeyRotation: true}, errSubstr: "at least 86400"}, - {name: "negative NOT waivable", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "-100s", AllowShortKeyRotation: true}, errSubstr: "at least 86400"}, - {name: "above gcp maximum", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "3153600001s", AllowShortKeyRotation: true}, errSubstr: "at most"}, - {name: "missing seconds suffix", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "7776000"}, errSubstr: "'s' suffix"}, - {name: "not a number but ends in s", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "ninetydays"}, errSubstr: "whole number of seconds"}, - {name: "duration shorthand", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "90d"}, errSubstr: "'s' suffix"}, - {name: "malformed still fails under the opt-out", cfg: SecretsProviderConfig{Provision: true, KeyRotationPeriod: "90d", AllowShortKeyRotation: true}, errSubstr: "'s' suffix"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - err := tt.cfg.Validate() - if tt.errSubstr == "" { - Expect(err).To(BeNil()) - return - } - Expect(err).NotTo(BeNil(), "expected %q to be rejected", tt.cfg.KeyRotationPeriod) - Expect(err.Error()).To(ContainSubstring(tt.errSubstr)) - }) - } -} diff --git a/pkg/clouds/gcloud/converters_test.go b/pkg/clouds/gcloud/converters_test.go deleted file mode 100644 index 4ba7a5f81..000000000 --- a/pkg/clouds/gcloud/converters_test.go +++ /dev/null @@ -1,354 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "testing" - - "github.com/compose-spec/compose-go/types" - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" - "github.com/simple-container-com/api/pkg/clouds/k8s" -) - -// ---- CloudRunInput getters ---------------------------------------------- - -func TestCloudRunInput_Uses(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - uses []string - }{ - {name: "non-empty uses", uses: []string{"postgres", "redis"}}, - {name: "empty uses", uses: nil}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - in := &CloudRunInput{ - Deployment: k8s.DeploymentConfig{ - StackConfig: &api.StackConfigCompose{Uses: tc.uses}, - }, - } - Expect(in.Uses()).To(Equal(tc.uses)) - }) - } -} - -func TestCloudRunInput_OverriddenBaseZone(t *testing.T) { - RegisterTestingT(t) - in := &CloudRunInput{ - Deployment: k8s.DeploymentConfig{ - StackConfig: &api.StackConfigCompose{BaseDnsZone: "example.com"}, - }, - } - Expect(in.OverriddenBaseZone()).To(Equal("example.com")) -} - -func TestToCloudRunConfig_Errors(t *testing.T) { - RegisterTestingT(t) - - t.Run("wrong template type returns error", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToCloudRunConfig("not-a-template", compose.Config{Project: &types.Project{}}, &api.StackConfigCompose{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not of type *gcloud.TemplateConfig")) - }) - - t.Run("nil template pointer returns error", func(t *testing.T) { - RegisterTestingT(t) - var tpl *TemplateConfig - _, err := ToCloudRunConfig(tpl, compose.Config{Project: &types.Project{}}, &api.StackConfigCompose{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("template config is nil")) - }) - - t.Run("ConvertComposeToContainers error surfaces (unknown service in runs)", func(t *testing.T) { - RegisterTestingT(t) - stackCfg := &api.StackConfigCompose{Runs: []string{"does-not-exist"}} - _, err := ToCloudRunConfig(&TemplateConfig{}, compose.Config{Project: &types.Project{}}, stackCfg) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found in docker-compose")) - }) - - t.Run("FindIngressContainer error surfaces (multiple ingress containers)", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToCloudRunConfig(&TemplateConfig{}, twoIngressComposeConfig(), &api.StackConfigCompose{Runs: []string{"web", "api"}}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to detect ingress container")) - }) -} - -// twoIngressComposeConfig builds a compose project with two services both -// labeled as ingress containers, which makes FindIngressContainer error out. -func twoIngressComposeConfig() compose.Config { - ingressLabel := types.Labels{api.ComposeLabelIngressContainer: "true"} - return compose.Config{Project: &types.Project{ - Services: types.Services{ - {Name: "web", Labels: ingressLabel}, - {Name: "api", Labels: ingressLabel}, - }, - }} -} - -// ---- GkeAutopilotInput getters ------------------------------------------ - -func TestGkeAutopilotInput_Uses(t *testing.T) { - RegisterTestingT(t) - in := &GkeAutopilotInput{ - Deployment: k8s.DeploymentConfig{ - StackConfig: &api.StackConfigCompose{Uses: []string{"postgres"}}, - }, - } - Expect(in.Uses()).To(ConsistOf("postgres")) -} - -func TestGkeAutopilotInput_OverriddenBaseZone(t *testing.T) { - RegisterTestingT(t) - in := &GkeAutopilotInput{ - Deployment: k8s.DeploymentConfig{ - StackConfig: &api.StackConfigCompose{BaseDnsZone: "zone.example.com"}, - }, - } - Expect(in.OverriddenBaseZone()).To(Equal("zone.example.com")) -} - -func TestGkeAutopilotInput_DependsOnResources(t *testing.T) { - RegisterTestingT(t) - deps := []api.StackConfigDependencyResource{ - {Name: "db", Owner: "other-stack", Resource: "postgres"}, - } - in := &GkeAutopilotInput{ - Deployment: k8s.DeploymentConfig{ - StackConfig: &api.StackConfigCompose{Dependencies: deps}, - }, - } - got := in.DependsOnResources() - Expect(got).To(HaveLen(1)) - Expect(got[0].Name).To(Equal("db")) - Expect(got[0].Owner).To(Equal("other-stack")) - Expect(got[0].Resource).To(Equal("postgres")) -} - -func TestToGkeAutopilotConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("wrong template type returns error", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToGkeAutopilotConfig("not-a-template", compose.Config{Project: &types.Project{}}, &api.StackConfigCompose{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not of type *gcloud.GkeAutopilotTemplate")) - }) - - t.Run("nil template pointer returns error", func(t *testing.T) { - RegisterTestingT(t) - var tpl *GkeAutopilotTemplate - _, err := ToGkeAutopilotConfig(tpl, compose.Config{Project: &types.Project{}}, &api.StackConfigCompose{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("template config is nil")) - }) - - t.Run("happy path with no cloudExtras", func(t *testing.T) { - RegisterTestingT(t) - stackCfg := &api.StackConfigCompose{Runs: []string{}} - res, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{GkeClusterResource: "c"}, compose.Config{Project: &types.Project{}}, stackCfg) - Expect(err).ToNot(HaveOccurred()) - in, ok := res.(*GkeAutopilotInput) - Expect(ok).To(BeTrue()) - Expect(in.GkeClusterResource).To(Equal("c")) - Expect(in.Deployment.StackConfig).To(Equal(stackCfg)) - }) - - t.Run("cloudExtras affinity nodePool adds workload-group selector and toleration", func(t *testing.T) { - RegisterTestingT(t) - cloudExtras := any(map[string]any{ - "affinity": map[string]any{ - "nodePool": "high-mem", - "computeClass": "Balanced", - }, - }) - stackCfg := &api.StackConfigCompose{ - Runs: []string{}, - CloudExtras: &cloudExtras, - } - res, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, compose.Config{Project: &types.Project{}}, stackCfg) - Expect(err).ToNot(HaveOccurred()) - in := res.(*GkeAutopilotInput) - Expect(in.Deployment.NodeSelector).To(HaveKeyWithValue("workload-group", "high-mem")) - Expect(in.Deployment.NodeSelector).To(HaveKeyWithValue("cloud.google.com/compute-class", "Balanced")) - Expect(in.Deployment.Tolerations).To(HaveLen(1)) - Expect(in.Deployment.Tolerations[0].Key).To(Equal("workload-group")) - Expect(in.Deployment.Tolerations[0].Value).To(Equal("high-mem")) - Expect(in.Deployment.Tolerations[0].Operator).To(Equal("Equal")) - Expect(in.Deployment.Tolerations[0].Effect).To(Equal("NoSchedule")) - }) - - t.Run("ConvertComposeToContainers error surfaces (unknown service in runs)", func(t *testing.T) { - RegisterTestingT(t) - stackCfg := &api.StackConfigCompose{Runs: []string{"does-not-exist"}} - _, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, compose.Config{Project: &types.Project{}}, stackCfg) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found in docker-compose")) - }) - - t.Run("FindIngressContainer error surfaces (multiple ingress containers)", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, twoIngressComposeConfig(), &api.StackConfigCompose{Runs: []string{"web", "api"}}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to detect ingress container")) - }) - - t.Run("invalid cloudExtras yields conversion error", func(t *testing.T) { - RegisterTestingT(t) - // cloudExtras whose affinity is not a mapping cannot be decoded into k8s.CloudExtras. - cloudExtras := any(map[string]any{ - "affinity": "this-should-be-a-mapping-not-a-string", - }) - stackCfg := &api.StackConfigCompose{ - Runs: []string{}, - CloudExtras: &cloudExtras, - } - _, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, compose.Config{Project: &types.Project{}}, stackCfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- ExternalEgressIpConfig.Validate ------------------------------------ - -func TestExternalEgressIpConfig_Validate(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - cfg ExternalEgressIpConfig - expectErr bool - errSubstr string - }{ - { - name: "disabled requires no validation", - cfg: ExternalEgressIpConfig{Enabled: false, Existing: "garbage"}, - }, - { - name: "enabled with no existing is valid", - cfg: ExternalEgressIpConfig{Enabled: true}, - }, - { - name: "enabled with valid full path", - cfg: ExternalEgressIpConfig{Enabled: true, Existing: "projects/p/regions/eu/addresses/egress"}, - }, - { - name: "enabled with non projects/ prefix", - cfg: ExternalEgressIpConfig{Enabled: true, Existing: "regions/eu/addresses/egress"}, - expectErr: true, - errSubstr: "must be a full GCP resource path", - }, - { - name: "enabled with wrong segment count", - cfg: ExternalEgressIpConfig{Enabled: true, Existing: "projects/p/regions/eu/addresses"}, - expectErr: true, - errSubstr: "invalid 'existing' format", - }, - { - name: "enabled with wrong segment labels", - cfg: ExternalEgressIpConfig{Enabled: true, Existing: "projects/p/zones/eu/networks/egress"}, - expectErr: true, - errSubstr: "invalid 'existing' format", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - err := tc.cfg.Validate() - if tc.expectErr { - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(tc.errSubstr)) - } else { - Expect(err).ToNot(HaveOccurred()) - } - }) - } -} - -// ---- StaticSiteInput / ToStaticSiteConfig ------------------------------- - -func TestStaticSiteInput_OverriddenBaseZone(t *testing.T) { - RegisterTestingT(t) - in := &StaticSiteInput{BaseDnsZone: "static.example.com"} - Expect(in.OverriddenBaseZone()).To(Equal("static.example.com")) -} - -func TestToStaticSiteConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps stackDir/stackName/location/baseDnsZone", func(t *testing.T) { - RegisterTestingT(t) - stackCfg := &api.StackConfigStatic{ - Location: "EU", - Site: api.StaticSiteConfig{BaseDnsZone: "static.example.com"}, - } - res, err := ToStaticSiteConfig(&TemplateConfig{}, "/stacks/web", "web", stackCfg) - Expect(err).ToNot(HaveOccurred()) - in, ok := res.(*StaticSiteInput) - Expect(ok).To(BeTrue()) - Expect(in.StackDir).To(Equal("/stacks/web")) - Expect(in.StackName).To(Equal("web")) - Expect(in.Location).To(Equal("EU")) - Expect(in.BaseDnsZone).To(Equal("static.example.com")) - Expect(in.OverriddenBaseZone()).To(Equal("static.example.com")) - Expect(in.StackConfigStatic).To(Equal(stackCfg)) - }) - - t.Run("wrong template type returns error", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToStaticSiteConfig("not-a-template", "/d", "n", &api.StackConfigStatic{Location: "EU"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("template config is not of type")) - }) - - t.Run("nil template pointer returns error", func(t *testing.T) { - RegisterTestingT(t) - var tpl *TemplateConfig - _, err := ToStaticSiteConfig(tpl, "/d", "n", &api.StackConfigStatic{Location: "EU"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("template config is nil")) - }) - - t.Run("missing location returns error", func(t *testing.T) { - RegisterTestingT(t) - _, err := ToStaticSiteConfig(&TemplateConfig{}, "/d", "n", &api.StackConfigStatic{Location: ""}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("location is required")) - }) -} - -// ---- init() registration ------------------------------------------------ - -// init() runs at package load and registers all GCP provider/field readers -// into the api global maps. Assert the GCP resource types resolve. -func TestInitRegistration(t *testing.T) { - RegisterTestingT(t) - - providers := api.GetRegisteredProviderConfigs() - for _, key := range []string{ - SecretsTypeGCPSecretsManager, - TemplateTypeGcpCloudrun, - TemplateTypeStaticWebsite, - AuthTypeGCPServiceAccount, - TemplateTypeGkeAutopilot, - ResourceTypeGkeAutopilot, - ResourceTypePostgresGcpCloudsql, - ResourceTypeRedis, - ResourceTypeBucket, - ResourceTypePubSub, - ResourceTypeArtifactRegistry, - ResourceTypeRemoteDockerImagePush, - } { - Expect(providers).To(HaveKey(key), "provider config %q must be registered by init()", key) - } - - fields := api.GetRegisteredProvisionerFieldConfigs() - Expect(fields).To(HaveKey(StateStorageTypeGcpBucket)) - Expect(fields).To(HaveKey(SecretsProviderTypeGcpKms)) -} diff --git a/pkg/clouds/gcloud/gcloud.go b/pkg/clouds/gcloud/gcloud.go deleted file mode 100644 index 5bc0bc657..000000000 --- a/pkg/clouds/gcloud/gcloud.go +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import "github.com/simple-container-com/api/pkg/api" - -type TemplateConfig struct { - Credentials `json:",inline" yaml:",inline"` -} - -func ReadTemplateConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &TemplateConfig{}) -} diff --git a/pkg/clouds/gcloud/gke_autopilot.go b/pkg/clouds/gcloud/gke_autopilot.go deleted file mode 100644 index 7b1f1b1e4..000000000 --- a/pkg/clouds/gcloud/gke_autopilot.go +++ /dev/null @@ -1,402 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "net" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" - "github.com/simple-container-com/api/pkg/clouds/k8s" -) - -const ( - ResourceTypeGkeAutopilot = "gcp-gke-autopilot-cluster" - TemplateTypeGkeAutopilot = "gcp-gke-autopilot" -) - -// Cloud NAT port-allocation defaults and bounds. Defaults preserve prior -// behaviour; the floor/ceiling match GCP's accepted range for ports per VM. -const ( - DefaultMinPortsPerVm = 64 - DefaultMaxPortsPerVm = 65536 - PortsPerVmFloor = 32 - PortsPerVmCeiling = 65536 -) - -type GkeAutopilotResource struct { - Credentials `json:",inline" yaml:",inline"` - GkeMinVersion string `json:"gkeMinVersion" yaml:"gkeMinVersion"` - Location string `json:"location" yaml:"location"` - Zone string `json:"zone" yaml:"zone"` - Timeouts *Timeouts `json:"timeouts,omitempty" yaml:"timeouts,omitempty"` - Caddy *k8s.CaddyConfig `json:"caddy,omitempty" yaml:"caddy,omitempty"` - - // External Egress IP Configuration - ExternalEgressIp *ExternalEgressIpConfig `json:"externalEgressIp,omitempty" yaml:"externalEgressIp,omitempty"` - - // Private VPC - creates dedicated VPC for the cluster (avoids CloudNAT conflicts) - PrivateVpc bool `json:"privateVpc,omitempty" yaml:"privateVpc,omitempty"` - - // ControlPlaneAccess restricts who may reach the Kubernetes API server. - // Left unset the control plane keeps GKE's default, an IP endpoint that - // accepts connections from any address on the internet. - ControlPlaneAccess *ControlPlaneAccessConfig `json:"controlPlaneAccess,omitempty" yaml:"controlPlaneAccess,omitempty"` - - // Resource adoption fields - Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` - ClusterName string `json:"clusterName,omitempty" yaml:"clusterName,omitempty"` -} - -type Timeouts struct { - Create string `json:"create" yaml:"create"` - Update string `json:"update" yaml:"update"` - Delete string `json:"delete" yaml:"delete"` -} - -// ExternalEgressIpConfig provides simple configuration for static egress IP -type ExternalEgressIpConfig struct { - Enabled bool `json:"enabled" yaml:"enabled"` - Existing string `json:"existing,omitempty" yaml:"existing,omitempty"` - - // Cloud NAT port/mapping tuning. All optional; when unset the previous - // defaults are preserved (64 min ports, endpoint-independent mapping on, - // dynamic port allocation off), so existing clusters are unaffected until - // they opt in. Raising the port budget and enabling dynamic port - // allocation avoids source-port exhaustion for pods that open many - // concurrent outbound connections (dropped SYNs surface downstream as - // dial i/o timeouts). - MinPortsPerVm *int `json:"minPortsPerVm,omitempty" yaml:"minPortsPerVm,omitempty"` - MaxPortsPerVm *int `json:"maxPortsPerVm,omitempty" yaml:"maxPortsPerVm,omitempty"` - // DynamicPortAllocation lets a VM scale its NAT ports between min and max - // on demand. GCP requires endpoint-independent mapping to be off when it is - // enabled, and both port bounds to be powers of two. - DynamicPortAllocation *bool `json:"dynamicPortAllocation,omitempty" yaml:"dynamicPortAllocation,omitempty"` - // EndpointIndependentMapping toggles NAT EIM (default true). Must be false - // to use dynamic port allocation. - EndpointIndependentMapping *bool `json:"endpointIndependentMapping,omitempty" yaml:"endpointIndependentMapping,omitempty"` -} - -type GkeAutopilotTemplate struct { - Credentials `json:",inline" yaml:",inline"` - GkeClusterResource string `json:"gkeClusterResource" yaml:"gkeClusterResource"` - ArtifactRegistryResource string `json:"artifactRegistryResource" yaml:"artifactRegistryResource"` -} - -type GkeAutopilotInput struct { - GkeAutopilotTemplate `json:"templateConfig" yaml:"templateConfig"` - Deployment k8s.DeploymentConfig `json:"deployment" yaml:"deployment"` -} - -func (i *GkeAutopilotInput) Uses() []string { - return i.Deployment.StackConfig.Uses -} - -func (i *GkeAutopilotInput) OverriddenBaseZone() string { - return i.Deployment.StackConfig.BaseDnsZone -} - -func (i *GkeAutopilotInput) DependsOnResources() []api.StackConfigDependencyResource { - return i.Deployment.StackConfig.Dependencies -} - -func ReadGkeAutopilotTemplateConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &GkeAutopilotTemplate{}) -} - -func ReadGkeAutopilotResourceConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &GkeAutopilotResource{}) -} - -func ToGkeAutopilotConfig(tpl any, composeCfg compose.Config, stackCfg *api.StackConfigCompose) (any, error) { - templateCfg, ok := tpl.(*GkeAutopilotTemplate) - if !ok { - return nil, errors.Errorf("template config is not of type *gcloud.GkeAutopilotTemplate") - } - if templateCfg == nil { - return nil, errors.Errorf("template config is nil") - } - deployCfg := k8s.DeploymentConfig{ - StackConfig: stackCfg, - Scale: k8s.ToScale(stackCfg), - } - - // Process CloudExtras for affinity rules, node selector, etc. - if stackCfg.CloudExtras != nil { - k8sCloudExtras := &k8s.CloudExtras{} - var err error - k8sCloudExtras, err = api.ConvertDescriptor(stackCfg.CloudExtras, k8sCloudExtras) - if err != nil { - return nil, errors.Wrapf(err, "failed to convert cloudExtras field to Kubernetes Cloud extras format") - } - - deployCfg.RollingUpdate = k8sCloudExtras.RollingUpdate - deployCfg.DisruptionBudget = k8sCloudExtras.DisruptionBudget - deployCfg.NodeSelector = k8sCloudExtras.NodeSelector - deployCfg.Tolerations = k8sCloudExtras.Tolerations - deployCfg.VPA = k8sCloudExtras.VPA // Extract VPA configuration from CloudExtras - deployCfg.ReadinessProbe = k8sCloudExtras.ReadinessProbe // Extract global readiness probe configuration - deployCfg.LivenessProbe = k8sCloudExtras.LivenessProbe // Extract global liveness probe configuration - deployCfg.StartupProbe = k8sCloudExtras.StartupProbe // Extract global startup probe configuration - deployCfg.PriorityClassName = k8sCloudExtras.PriorityClassName // Extract PriorityClass for pod scheduling and preemption - deployCfg.ServiceType = k8sCloudExtras.ServiceType // Extract Service type override (e.g. LoadBalancer for UDP) - deployCfg.ExternalTrafficPolicy = k8sCloudExtras.ExternalTrafficPolicy // e.g. Local, required for WebRTC return media - deployCfg.TopologySpreadConstraints = k8sCloudExtras.TopologySpreadConstraints - - if err := k8sCloudExtras.SecurityContext.Validate(); err != nil { - return nil, errors.Wrapf(err, "invalid cloudExtras.securityContext") - } - deployCfg.SecurityContext = k8sCloudExtras.SecurityContext - - // Process affinity rules and merge with existing NodeSelector if needed - if k8sCloudExtras.Affinity != nil { - // Store the full affinity configuration for advanced usage - deployCfg.Affinity = k8sCloudExtras.Affinity - - // Merge Space Pay style affinity rules with existing NodeSelector - if deployCfg.NodeSelector == nil { - deployCfg.NodeSelector = make(map[string]string) - } - - // GKE Autopilot supports custom nodeSelector labels for workload separation! - // When you specify a custom nodeSelector + toleration, GKE automatically creates - // separate nodes with those labels and taints. - - // Handle nodePool as a custom workload separation label - if k8sCloudExtras.Affinity.NodePool != nil { - nodePoolValue := *k8sCloudExtras.Affinity.NodePool - // Use custom label for workload separation (not system labels) - deployCfg.NodeSelector["workload-group"] = nodePoolValue - - // Automatically add corresponding toleration for GKE Autopilot workload separation - workloadToleration := k8s.Toleration{ - Key: "workload-group", - Operator: "Equal", - Value: nodePoolValue, - Effect: "NoSchedule", - } - deployCfg.Tolerations = append(deployCfg.Tolerations, workloadToleration) - } - - // Handle computeClass - require exact GKE Autopilot values - // Valid values: Accelerator, Balanced, Performance, Scale-Out, autopilot, autopilot-spot - if k8sCloudExtras.Affinity.ComputeClass != nil { - deployCfg.NodeSelector["cloud.google.com/compute-class"] = *k8sCloudExtras.Affinity.ComputeClass - } - - // For exclusive node pool, anti-affinity rules are handled in simple_container.go - } - } - - res := &GkeAutopilotInput{ - GkeAutopilotTemplate: *templateCfg, - Deployment: deployCfg, - } - - containers, err := k8s.ConvertComposeToContainers(composeCfg, stackCfg) - if err != nil { - return nil, err - } - iContainer, err := k8s.FindIngressContainer(composeCfg, containers) - if err != nil { - return nil, errors.Wrapf(err, "failed to detect ingress container") - } - res.Deployment.Containers = containers - res.Deployment.IngressContainer = iContainer - res.Deployment.Headers = lo.ToPtr(k8s.ToHeaders(stackCfg.Headers)) - res.Deployment.TextVolumes = k8s.ToSimpleTextVolumes(stackCfg) - - return res, nil -} - -// Validate validates the ExternalEgressIpConfig -func (c *ExternalEgressIpConfig) Validate() error { - if !c.Enabled { - return nil // No validation needed if disabled - } - - // Validate existing IP format if specified - if c.Existing != "" { - if !strings.HasPrefix(c.Existing, "projects/") { - return errors.New("'existing' must be a full GCP resource path like 'projects/{project}/regions/{region}/addresses/{name}'") - } - parts := strings.Split(c.Existing, "/") - if len(parts) != 6 || parts[2] != "regions" || parts[4] != "addresses" { - return errors.New("invalid 'existing' format, expected 'projects/{project}/regions/{region}/addresses/{name}'") - } - } - - // Validate the effective port bounds (applying the same defaults as - // resolveNatPortSettings) so a lone maxPortsPerVm below the default minimum - // is caught here rather than failing late at the GCP API. - minPorts, maxPorts := DefaultMinPortsPerVm, DefaultMaxPortsPerVm - if c.MinPortsPerVm != nil { - minPorts = *c.MinPortsPerVm - if minPorts < PortsPerVmFloor || minPorts > PortsPerVmCeiling { - return errors.Errorf("minPortsPerVm must be between %d and %d, got %d", PortsPerVmFloor, PortsPerVmCeiling, minPorts) - } - } - if c.MaxPortsPerVm != nil { - maxPorts = *c.MaxPortsPerVm - if maxPorts < PortsPerVmFloor || maxPorts > PortsPerVmCeiling { - return errors.Errorf("maxPortsPerVm must be between %d and %d, got %d", PortsPerVmFloor, PortsPerVmCeiling, maxPorts) - } - } - if maxPorts < minPorts { - return errors.Errorf("effective maxPortsPerVm (%d) must be >= minPortsPerVm (%d)", maxPorts, minPorts) - } - - if c.DynamicPortAllocation != nil && *c.DynamicPortAllocation { - // GCP rejects dynamic port allocation together with endpoint-independent - // mapping, needs both bounds to be powers of two, and needs a real range - // (max strictly greater than min). - if c.EndpointIndependentMapping == nil || *c.EndpointIndependentMapping { - return errors.New("dynamicPortAllocation requires endpointIndependentMapping: false") - } - if !isPowerOfTwo(minPorts) { - return errors.Errorf("minPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", minPorts) - } - if !isPowerOfTwo(maxPorts) { - return errors.Errorf("maxPortsPerVm must be a power of two when dynamicPortAllocation is enabled, got %d", maxPorts) - } - if maxPorts <= minPorts { - return errors.Errorf("effective maxPortsPerVm (%d) must be > minPortsPerVm (%d) when dynamicPortAllocation is enabled", maxPorts, minPorts) - } - } - - return nil -} - -func isPowerOfTwo(n int) bool { - return n > 0 && n&(n-1) == 0 -} - -// ControlPlaneAccessConfig configures reachability of the cluster control -// plane. The two endpoints are independent: the IP endpoint is authorised by -// source address, the DNS endpoint by IAM. Enabling the DNS endpoint is what -// makes locking the IP endpoint practical for callers with no stable address, -// such as hosted CI runners. -type ControlPlaneAccessConfig struct { - // DnsEndpoint opens the DNS-based control plane endpoint to callers outside - // the cluster VPC. The endpoint itself always exists; this is the flag that - // makes it usable, and it stays authorised by IAM either way. - DnsEndpoint *bool `json:"dnsEndpoint,omitempty" yaml:"dnsEndpoint,omitempty"` - - // IpEndpoint toggles the IP-based endpoint. Defaults to enabled. - IpEndpoint *bool `json:"ipEndpoint,omitempty" yaml:"ipEndpoint,omitempty"` - - // AuthorizedNetworks lists the CIDRs allowed to reach the IP endpoint. - // Setting this field is what turns the allow list on: with the list on and - // no entries, only Google internal traffic reaches the IP endpoint. - AuthorizedNetworks []AuthorizedNetwork `json:"authorizedNetworks,omitempty" yaml:"authorizedNetworks,omitempty"` - - // AllowGcpPublicCidrs keeps every Google Cloud public address authorised. - // GKE defaults this to true, which admits any Google Cloud tenant, so it - // defaults to false here whenever an allow list is configured. - AllowGcpPublicCidrs *bool `json:"allowGcpPublicCidrs,omitempty" yaml:"allowGcpPublicCidrs,omitempty"` -} - -// AuthorizedNetwork is one CIDR allowed to reach the control plane IP endpoint. -type AuthorizedNetwork struct { - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Cidr string `json:"cidr" yaml:"cidr"` -} - -// DnsEndpointEnabled reports whether the DNS endpoint accepts external callers. -func (c *ControlPlaneAccessConfig) DnsEndpointEnabled() bool { - return c != nil && c.DnsEndpoint != nil && *c.DnsEndpoint -} - -// IpEndpointEnabled reports whether the IP endpoint is switched on. It is on -// unless explicitly disabled, matching GKE. -func (c *ControlPlaneAccessConfig) IpEndpointEnabled() bool { - if c == nil || c.IpEndpoint == nil { - return true - } - return *c.IpEndpoint -} - -// AuthorizedNetworksEnabled reports whether the IP endpoint allow list applies. -// -// An explicitly empty list is a request to authorise nobody, so it counts as -// enabled. Only an absent list means "no opinion": read the other way round, -// `authorizedNetworks: []` would drop the whole block and leave the control -// plane on GKE's default, which is reachable from anywhere. That is a config -// that reads as a lock-down and silently is not one, so nil is the only value -// that turns the allow list off. -func (c *ControlPlaneAccessConfig) AuthorizedNetworksEnabled() bool { - return c != nil && (c.AuthorizedNetworks != nil || c.AllowGcpPublicCidrs != nil) -} - -// GcpPublicCidrsAllowed reports the effective value of the Google Cloud public -// range exemption. -func (c *ControlPlaneAccessConfig) GcpPublicCidrsAllowed() bool { - return c != nil && c.AllowGcpPublicCidrs != nil && *c.AllowGcpPublicCidrs -} - -func (c *ControlPlaneAccessConfig) Validate() error { - if c == nil { - return nil - } - - // GKE caps the list. Past the cap the cluster is rejected by the API after - // the update has already started. - const maxAuthorizedNetworks = 50 - if len(c.AuthorizedNetworks) > maxAuthorizedNetworks { - return errors.Errorf("controlPlaneAccess: %d authorized networks exceeds GKE's limit of %d", - len(c.AuthorizedNetworks), maxAuthorizedNetworks) - } - - seen := make(map[string]int, len(c.AuthorizedNetworks)) - for i, n := range c.AuthorizedNetworks { - n.Cidr = strings.TrimSpace(n.Cidr) - if n.Cidr == "" { - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: cidr is required", i) - } - ip, ipNet, err := net.ParseCIDR(n.Cidr) - if err != nil { - if net.ParseIP(n.Cidr) != nil { - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: %q is a single address, it needs an explicit /32 or /128", i, n.Cidr) - } - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: %q is not a CIDR", i, n.Cidr) - } - if ones, _ := ipNet.Mask.Size(); ones == 0 { - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: %q allows every address, which is what the allow list exists to prevent", i, n.Cidr) - } - if !ip.Equal(ipNet.IP) { - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: %q has host bits set, use %s", i, n.Cidr, ipNet.String()) - } - if first, dup := seen[ipNet.String()]; dup { - return errors.Errorf("controlPlaneAccess.authorizedNetworks[%d]: %q duplicates entry %d", i, n.Cidr, first) - } - seen[ipNet.String()] = i - } - - // Both endpoints off leaves no way to reach the API server, and GKE accepts - // it, so it is caught here rather than after the cluster is unreachable. - if !c.IpEndpointEnabled() && !c.DnsEndpointEnabled() { - return errors.New("controlPlaneAccess: ipEndpoint is disabled and dnsEndpoint is not enabled, which leaves no way to reach the control plane") - } - - // An empty allow list on the IP endpoint is the same lockout unless the DNS - // endpoint is there to take over. allowGcpPublicCidrs does not count as a - // way in: it authorises Google Cloud's public address space, which is every - // other tenant and nobody in this organisation. - if c.IpEndpointEnabled() && c.AuthorizedNetworksEnabled() && - len(c.AuthorizedNetworks) == 0 && !c.DnsEndpointEnabled() { - return errors.New("controlPlaneAccess: the authorized network list is empty and dnsEndpoint is not enabled, which leaves no way to reach the control plane") - } - - // An allow list on a switched-off endpoint governs nothing, and reads as - // though it does. - if !c.IpEndpointEnabled() && len(c.AuthorizedNetworks) > 0 { - return errors.New("controlPlaneAccess: authorizedNetworks has no effect while ipEndpoint is disabled") - } - - return nil -} diff --git a/pkg/clouds/gcloud/gke_control_plane_access_test.go b/pkg/clouds/gcloud/gke_control_plane_access_test.go deleted file mode 100644 index 81230143a..000000000 --- a/pkg/clouds/gcloud/gke_control_plane_access_test.go +++ /dev/null @@ -1,235 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "fmt" - "testing" - - . "github.com/onsi/gomega" -) - -func boolPtr(v bool) *bool { return &v } - -func TestControlPlaneAccessConfig_Validate(t *testing.T) { - for _, tt := range []struct { - name string - cfg *ControlPlaneAccessConfig - wantErr string - }{ - { - name: "nil config is valid", - cfg: nil, - }, - { - name: "authorized networks with a dns fallback", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{{Name: "office", Cidr: "203.0.113.0/24"}}, - }, - }, - { - name: "single address needs a prefix length", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "203.0.113.7"}}, - }, - wantErr: "is a single address, it needs an explicit /32 or /128", - }, - { - name: "empty cidr is rejected", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Name: "office"}}, - }, - wantErr: "cidr is required", - }, - { - name: "a default route defeats the allow list", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "0.0.0.0/0"}}, - }, - wantErr: "allows every address", - }, - { - name: "host bits set is rejected with the corrected form", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "203.0.113.7/24"}}, - }, - wantErr: "use 203.0.113.0/24", - }, - { - name: "ipv6 passes local validation", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "2001:db8::/32"}}, - }, - }, - { - name: "both endpoints off locks everyone out", - cfg: &ControlPlaneAccessConfig{ - IpEndpoint: boolPtr(false), - DnsEndpoint: boolPtr(false), - }, - wantErr: "leaves no way to reach the control plane", - }, - { - name: "ip endpoint off with dns on is valid", - cfg: &ControlPlaneAccessConfig{ - IpEndpoint: boolPtr(false), - DnsEndpoint: boolPtr(true), - }, - }, - { - name: "empty allow list without a dns fallback locks everyone out", - cfg: &ControlPlaneAccessConfig{ - AllowGcpPublicCidrs: boolPtr(false), - }, - wantErr: "authorized network list is empty", - }, - { - // allowGcpPublicCidrs authorises Google Cloud address space, which - // is every other tenant and nobody in this organisation, so it is - // not a way in and must not satisfy the lockout check. - name: "gcp public cidrs alone is not an access path", - cfg: &ControlPlaneAccessConfig{ - AllowGcpPublicCidrs: boolPtr(true), - }, - wantErr: "authorized network list is empty", - }, - { - name: "an allow list on a disabled ip endpoint governs nothing", - cfg: &ControlPlaneAccessConfig{ - IpEndpoint: boolPtr(false), - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "203.0.113.0/24"}}, - }, - wantErr: "no effect while ipEndpoint is disabled", - }, - { - // The index has to be real, otherwise a loop that stops after the - // first entry looks identical to one that checks them all. - name: "the reported index names the offending entry", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{ - {Cidr: "203.0.113.0/24"}, - {Cidr: "10.0.0.1/8"}, - }, - }, - wantErr: "authorizedNetworks[1]", - }, - { - name: "duplicate networks are rejected and name the first one", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{ - {Cidr: "203.0.113.0/24"}, - {Name: "same range, different name", Cidr: "203.0.113.0/24"}, - }, - }, - wantErr: "duplicates entry 0", - }, - { - name: "surrounding whitespace is tolerated", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: " 203.0.113.0/24 "}}, - }, - }, - { - name: "garbage is not reported as a missing prefix length", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "nonsense"}}, - }, - wantErr: "is not a CIDR", - }, - { - name: "a correct single address is accepted", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "203.0.113.7/32"}}, - }, - }, - { - name: "the ipv6 default route is rejected too", - cfg: &ControlPlaneAccessConfig{ - AuthorizedNetworks: []AuthorizedNetwork{{Cidr: "::/0"}}, - }, - wantErr: "allows every address", - }, - { - name: "empty allow list is fine once dns is open", - cfg: &ControlPlaneAccessConfig{ - DnsEndpoint: boolPtr(true), - AllowGcpPublicCidrs: boolPtr(false), - }, - }, - } { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - err := tt.cfg.Validate() - if tt.wantErr == "" { - Expect(err).To(BeNil()) - return - } - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring(tt.wantErr)) - }) - } -} - -func TestControlPlaneAccessConfig_Defaults(t *testing.T) { - RegisterTestingT(t) - - var nilCfg *ControlPlaneAccessConfig - // An absent block must read as "GKE default", which is an IP endpoint that - // is reachable and no allow list, so nothing gets written to the cluster. - Expect(nilCfg.IpEndpointEnabled()).To(BeTrue()) - Expect(nilCfg.DnsEndpointEnabled()).To(BeFalse()) - Expect(nilCfg.AuthorizedNetworksEnabled()).To(BeFalse()) - Expect(nilCfg.GcpPublicCidrsAllowed()).To(BeFalse()) - - empty := &ControlPlaneAccessConfig{} - Expect(empty.IpEndpointEnabled()).To(BeTrue()) - Expect(empty.AuthorizedNetworksEnabled()).To(BeFalse()) - - // Asking only to drop the Google Cloud exemption still turns the allow list - // on, otherwise the request would be silently ignored. - onlyGcpCidrs := &ControlPlaneAccessConfig{AllowGcpPublicCidrs: boolPtr(false)} - Expect(onlyGcpCidrs.AuthorizedNetworksEnabled()).To(BeTrue()) - Expect(onlyGcpCidrs.GcpPublicCidrsAllowed()).To(BeFalse()) -} - -// An explicitly empty list is a request to authorise nobody, and it must not be -// read as "no opinion". Read as no opinion, the whole block is dropped and the -// control plane keeps GKE's default, which is reachable from anywhere. -func TestControlPlaneAccessConfig_ExplicitlyEmptyListIsNotAbsent(t *testing.T) { - RegisterTestingT(t) - - explicit := &ControlPlaneAccessConfig{AuthorizedNetworks: []AuthorizedNetwork{}} - Expect(explicit.AuthorizedNetworksEnabled()).To(BeTrue()) - Expect(explicit.Validate()).ToNot(BeNil()) - - absent := &ControlPlaneAccessConfig{} - Expect(absent.AuthorizedNetworksEnabled()).To(BeFalse()) - Expect(absent.Validate()).To(BeNil()) -} - -func TestControlPlaneAccessConfig_TooManyNetworks(t *testing.T) { - RegisterTestingT(t) - - nets := make([]AuthorizedNetwork, 0, 51) - for i := 0; i < 51; i++ { - nets = append(nets, AuthorizedNetwork{Cidr: fmt.Sprintf("10.%d.0.0/16", i)}) - } - cfg := &ControlPlaneAccessConfig{DnsEndpoint: boolPtr(true), AuthorizedNetworks: nets} - - // Past GKE's cap the cluster is rejected by the API after the update has - // already started, so it is caught here instead. - err := cfg.Validate() - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("exceeds GKE's limit of 50")) - - Expect((&ControlPlaneAccessConfig{DnsEndpoint: boolPtr(true), AuthorizedNetworks: nets[:50]}).Validate()).To(BeNil()) -} diff --git a/pkg/clouds/gcloud/init.go b/pkg/clouds/gcloud/init.go deleted file mode 100644 index d64f13c1a..000000000 --- a/pkg/clouds/gcloud/init.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "github.com/simple-container-com/api/pkg/api" -) - -const ProviderType = "gcp" - -func init() { - api.RegisterProviderConfig(api.ConfigRegisterMap{ - // gcloud - SecretsTypeGCPSecretsManager: ReadSecretsProviderConfig, - TemplateTypeGcpCloudrun: ReadTemplateConfig, - TemplateTypeStaticWebsite: ReadTemplateConfig, - AuthTypeGCPServiceAccount: ReadAuthServiceAccountConfig, - TemplateTypeGkeAutopilot: ReadGkeAutopilotTemplateConfig, - ResourceTypeGkeAutopilot: ReadGkeAutopilotResourceConfig, - - // postgres - ResourceTypePostgresGcpCloudsql: PostgresqlGcpCloudsqlReadConfig, - - // redis - ResourceTypeRedis: RedisReadConfig, - - // bucket - ResourceTypeBucket: GcpBucketReadConfig, - - // pubsub - ResourceTypePubSub: GcpPubSubTopicsReadConfig, - - // artifact-registry - ResourceTypeArtifactRegistry: ArtifactRegistryConfigReadConfig, - - // docker images to re-push to artifact registry - ResourceTypeRemoteDockerImagePush: DockerRemoteImagePushReadConfig, - }) - - api.RegisterProvisionerFieldConfig(api.ProvisionerFieldConfigRegister{ - StateStorageTypeGcpBucket: ReadStateStorageConfig, - SecretsProviderTypeGcpKms: ReadSecretsProviderConfig, - }) - - api.RegisterCloudComposeConverter(api.CloudComposeConfigRegister{ - TemplateTypeGcpCloudrun: ToCloudRunConfig, - TemplateTypeGkeAutopilot: ToGkeAutopilotConfig, - }) - - api.RegisterCloudStaticSiteConverter(api.CloudStaticSiteConfigRegister{ - TemplateTypeStaticWebsite: ToStaticSiteConfig, - }) -} diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go deleted file mode 100644 index 9e1e3731f..000000000 --- a/pkg/clouds/gcloud/postgres.go +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "net" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/simple-container-com/api/pkg/api" -) - -const ResourceTypePostgresGcpCloudsql = "gcp-cloudsql-postgres" - -type PostgresGcpCloudsqlConfig struct { - Credentials `json:",inline" yaml:",inline"` - Version string `json:"version" yaml:"version"` - Project string `json:"project" yaml:"project"` - Tier *string `json:"tier" yaml:"tier"` - Region *string `json:"region" yaml:"region"` - // Deprecated: prefer DatabaseFlags["max_connections"]; kept for - // backward compatibility. - MaxConnections *int `json:"maxConnections" yaml:"maxConnections"` - // DatabaseFlags sets arbitrary Cloud SQL database flags by name - // (e.g. cloudsql.iam_authentication: "on"). An explicit max_connections - // entry here takes precedence over MaxConnections. - DatabaseFlags map[string]string `json:"databaseFlags,omitempty" yaml:"databaseFlags,omitempty"` - DeletionProtection *bool `json:"deletionProtection" yaml:"deletionProtection"` - QueryInsightsEnabled *bool `json:"queryInsightsEnabled" yaml:"queryInsightsEnabled"` - QueryStringLength *int `json:"queryStringLength" yaml:"queryStringLength"` - UsersProvisionRuntime *ProvisionRuntimeConfig `json:"usersProvisionRuntime" yaml:"usersProvisionRuntime"` - // Backup configuration - BackupEnabled *bool `json:"backupEnabled,omitempty" yaml:"backupEnabled,omitempty"` - BackupStartTime *string `json:"backupStartTime,omitempty" yaml:"backupStartTime,omitempty"` - PointInTimeRecoveryEnabled *bool `json:"pointInTimeRecoveryEnabled,omitempty" yaml:"pointInTimeRecoveryEnabled,omitempty"` - TransactionLogRetentionDays *int `json:"transactionLogRetentionDays,omitempty" yaml:"transactionLogRetentionDays,omitempty"` - RetainedBackups *int `json:"retainedBackups,omitempty" yaml:"retainedBackups,omitempty"` - // High availability - AvailabilityType *string `json:"availabilityType,omitempty" yaml:"availabilityType,omitempty"` // ZONAL or REGIONAL - // SSL - RequireSsl *bool `json:"requireSsl,omitempty" yaml:"requireSsl,omitempty"` - // PrivateNetwork: when set to a VPC network resource path - // (projects/{project}/global/networks/{vpc}) the instance is given a - // private IP on that network. Requires Private Services Access (a - // servicenetworking peering range) on the VPC: set privateServicesAccessRange - // for SC to create it, or create it outside SC. Adding it - // while the public IP stays on is a no-cutover step: the instance gains a - // private IP but the in-cluster proxy keeps using the public endpoint until - // proxyPrivateIp is set or publicIpEnabled is set false (see - // UsesPrivateIpProxy). - PrivateNetwork *string `json:"privateNetwork,omitempty" yaml:"privateNetwork,omitempty"` - // PublicIpEnabled toggles the instance's public IPv4 address (default true - // to preserve existing authorized networks). Setting it false requires - // privateNetwork and switches the in-cluster cloud-sql-proxy to dial the - // private IP (--private-ip). Do this only once the private path is verified - // reachable, since the proxy has no public fallback. - PublicIpEnabled *bool `json:"publicIpEnabled,omitempty" yaml:"publicIpEnabled,omitempty"` - // ProxyPrivateIp switches the in-cluster cloud-sql-proxy to the private IP - // (--private-ip) while the public IP stays on. Requires privateNetwork. Each - // client stack picks it up on its next deploy and every proxy not yet - // redeployed keeps the public path, so moving a fleet needs no cutover. - // Disabling the public IP (publicIpEnabled: false) implies it. - ProxyPrivateIp *bool `json:"proxyPrivateIp,omitempty" yaml:"proxyPrivateIp,omitempty"` - // PrivateServicesAccessRange is a CIDR (e.g. 10.30.0.0/20) that SC reserves - // on privateNetwork and peers to servicenetworking.googleapis.com before - // creating the instance. Leave it unset when Private Services Access already - // exists on the VPC. The peering is one per VPC, so set it on at most one - // resource per network. Treat it as immutable: the range and peering are - // retained when the field is removed or the stack destroyed, and changing it - // does not move an existing peering. - PrivateServicesAccessRange *string `json:"privateServicesAccessRange,omitempty" yaml:"privateServicesAccessRange,omitempty"` - // Resource adoption fields - Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` - InstanceName string `json:"instanceName,omitempty" yaml:"instanceName,omitempty"` - ConnectionName string `json:"connectionName,omitempty" yaml:"connectionName,omitempty"` - RootPassword string `json:"rootPassword,omitempty" yaml:"rootPassword,omitempty"` -} - -type ProvisionRuntimeConfig struct { - Type string `json:"type" yaml:"type"` // type of provisioning runtime - ResourceName string `json:"resourceName" yaml:"resourceName"` // allows to run init db users jobs on kube jobs (must reference resource name where we can obtain kubeconfig from, e.g. gke-autopilot-cluster) -} - -// HasPrivateNetwork reports whether a non-empty private VPC network is configured. -func (c *PostgresGcpCloudsqlConfig) HasPrivateNetwork() bool { - return c.PrivateNetwork != nil && *c.PrivateNetwork != "" -} - -// HasPrivateServicesAccessRange reports whether SC should create Private -// Services Access on privateNetwork. -func (c *PostgresGcpCloudsqlConfig) HasPrivateServicesAccessRange() bool { - return c.PrivateServicesAccessRange != nil && *c.PrivateServicesAccessRange != "" -} - -// UsesPrivateIpProxy reports whether the in-cluster cloud-sql-proxy should dial -// the instance's private IP: when proxyPrivateIp is set, or when the public IP is -// disabled and private is the only path. Adding privateNetwork alone does not -// touch the proxy. -func (c *PostgresGcpCloudsqlConfig) UsesPrivateIpProxy() bool { - return lo.FromPtr(c.ProxyPrivateIp) || (c.PublicIpEnabled != nil && !*c.PublicIpEnabled) -} - -// Validate checks Cloud SQL config invariants that would otherwise surface as -// opaque failures at the GCP API. -func (c *PostgresGcpCloudsqlConfig) Validate() error { - if c.AvailabilityType != nil && *c.AvailabilityType != "ZONAL" && *c.AvailabilityType != "REGIONAL" { - return errors.Errorf("availabilityType must be ZONAL or REGIONAL, got %q", *c.AvailabilityType) - } - if c.PrivateNetwork != nil && *c.PrivateNetwork != "" && !strings.HasPrefix(*c.PrivateNetwork, "projects/") { - return errors.Errorf("privateNetwork must be a full VPC network path like 'projects/{project}/global/networks/{vpc}', got %q", *c.PrivateNetwork) - } - // Disabling the public IP without a private network would leave the instance - // unreachable by the cloud-sql-proxy. - if c.PublicIpEnabled != nil && !*c.PublicIpEnabled && !c.HasPrivateNetwork() { - return errors.New("publicIpEnabled: false requires privateNetwork to be set") - } - if lo.FromPtr(c.ProxyPrivateIp) && !c.HasPrivateNetwork() { - return errors.New("proxyPrivateIp: true requires privateNetwork to be set") - } - // The proxy has no public fallback, so with the public IP off it must dial - // the private IP. - if c.ProxyPrivateIp != nil && !*c.ProxyPrivateIp && c.PublicIpEnabled != nil && !*c.PublicIpEnabled { - return errors.New("proxyPrivateIp: false contradicts publicIpEnabled: false; the proxy would have no address to dial") - } - if c.HasPrivateServicesAccessRange() { - // Adoption returns before any provisioning, so the range would be - // silently ignored. - if c.Adopt { - return errors.New("privateServicesAccessRange is not supported with adopt: true; create Private Services Access outside SC") - } - if !c.HasPrivateNetwork() { - return errors.New("privateServicesAccessRange requires privateNetwork to be set") - } - if _, err := c.PrivateServicesAccessPrefix(); err != nil { - return err - } - } - return nil -} - -// PrivateServicesAccessPrefix parses PrivateServicesAccessRange. The range must -// be an RFC 1918 IPv4 network address no smaller than /24, which is the least -// Cloud SQL needs per region. -func (c *PostgresGcpCloudsqlConfig) PrivateServicesAccessPrefix() (*net.IPNet, error) { - raw := strings.TrimSpace(lo.FromPtr(c.PrivateServicesAccessRange)) - ip, ipNet, err := net.ParseCIDR(raw) - if err != nil { - return nil, errors.Errorf("privateServicesAccessRange must be a CIDR like '10.30.0.0/20', got %q", raw) - } - if ip.To4() == nil || len(ipNet.Mask) != net.IPv4len || !withinRFC1918(ipNet) { - return nil, errors.Errorf("privateServicesAccessRange must be an IPv4 range inside 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16, got %q", raw) - } - if !ip.Equal(ipNet.IP) { - return nil, errors.Errorf("privateServicesAccessRange %q has host bits set; use %q", raw, ipNet.String()) - } - if ones, _ := ipNet.Mask.Size(); ones > 24 { - return nil, errors.Errorf("privateServicesAccessRange must be /24 or larger, got /%d", ones) - } - return ipNet, nil -} - -var rfc1918Blocks = []*net.IPNet{ - {IP: net.IPv4(10, 0, 0, 0).To4(), Mask: net.CIDRMask(8, 32)}, - {IP: net.IPv4(172, 16, 0, 0).To4(), Mask: net.CIDRMask(12, 32)}, - {IP: net.IPv4(192, 168, 0, 0).To4(), Mask: net.CIDRMask(16, 32)}, -} - -// withinRFC1918 reports whether the whole range, not just its first address, -// lies inside one RFC 1918 block. -func withinRFC1918(ipNet *net.IPNet) bool { - ones, _ := ipNet.Mask.Size() - for _, block := range rfc1918Blocks { - blockOnes, _ := block.Mask.Size() - if block.Contains(ipNet.IP) && ones >= blockOnes { - return true - } - } - return false -} - -func PostgresqlGcpCloudsqlReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &PostgresGcpCloudsqlConfig{}) -} diff --git a/pkg/clouds/gcloud/pubsub.go b/pkg/clouds/gcloud/pubsub.go deleted file mode 100644 index baca69fa6..000000000 --- a/pkg/clouds/gcloud/pubsub.go +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "github.com/simple-container-com/api/pkg/api" -) - -const ResourceTypePubSub = "gcp-pubsub" - -type PubSubConfig struct { - Credentials `json:",inline" yaml:",inline"` - Topics []PubSubTopic `json:"topics" yaml:"topics"` - Subscriptions []PubSubSubscription `json:"subscriptions" yaml:"subscriptions"` - Labels PlainLabels `json:"labels" yaml:"labels"` -} - -type PlainLabels map[string]string - -type SubscriptionDeadLetterPolicyArgs struct { - DeadLetterTopic *string `json:"deadLetterTopic,omitempty" yaml:"deadLetterTopic,omitempty"` - MaxDeliveryAttempts *int `json:"maxDeliveryAttempts,omitempty" yaml:"maxDeliveryAttempts,omitempty"` -} - -type PubSubTopic struct { - Name string `json:"name" yaml:"name"` - Labels PlainLabels `json:"labels" yaml:"labels"` - MessageRetentionDuration string `json:"messageRetentionDuration" yaml:"messageRetentionDuration"` -} - -type PubSubSubscription struct { - Name string `json:"name" yaml:"name"` - Topic string `json:"topic" yaml:"topic"` - Labels PlainLabels `json:"labels" yaml:"labels"` - DeadLetterPolicy *SubscriptionDeadLetterPolicyArgs `json:"deadLetterPolicy" yaml:"deadLetterPolicy"` - ExactlyOnceDelivery bool `json:"exactlyOnceDelivery" yaml:"exactlyOnceDelivery"` - AckDeadlineSec int `json:"ackDeadlineSec" yaml:"ackDeadlineSec"` - MessageRetentionDuration string `json:"messageRetentionDuration" yaml:"messageRetentionDuration"` -} - -func GcpPubSubTopicsReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &PubSubConfig{}) -} diff --git a/pkg/clouds/gcloud/redis.go b/pkg/clouds/gcloud/redis.go deleted file mode 100644 index 0d5d897d7..000000000 --- a/pkg/clouds/gcloud/redis.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import "github.com/simple-container-com/api/pkg/api" - -const ResourceTypeRedis = "gcp-redis" - -type RedisConfig struct { - Credentials `json:",inline" yaml:",inline"` - Version string `json:"version" yaml:"version"` - Project string `json:"project" yaml:"project"` - MemorySizeGb int `json:"memorySizeGb" yaml:"memorySizeGb"` - RedisConfig map[string]string `json:"redisConfig" yaml:"redisConfig"` - Region *string `json:"region" yaml:"region"` - - // VPC Network Configuration - AuthorizedNetwork *string `json:"authorizedNetwork,omitempty" yaml:"authorizedNetwork,omitempty"` // VPC network for Redis connectivity - - // Resource adoption fields - Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` - InstanceId string `json:"instanceId,omitempty" yaml:"instanceId,omitempty"` -} - -func RedisReadConfig(config *api.Config) (api.Config, error) { - return api.ConvertConfig(config, &RedisConfig{}) -} diff --git a/pkg/clouds/gcloud/resources_test.go b/pkg/clouds/gcloud/resources_test.go deleted file mode 100644 index 23a6e665d..000000000 --- a/pkg/clouds/gcloud/resources_test.go +++ /dev/null @@ -1,561 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "testing" - - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/api" -) - -// ---- ArtifactRegistry ---------------------------------------------------- - -func TestArtifactRegistryConfigReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps location/public/docker/basicAuth", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "location": "europe-west1", - "public": true, - "domain": "registry.example.com", - "docker": map[string]any{ - "immutableTags": true, - }, - "basicAuth": map[string]any{ - "username": "user", - "password": "pass", - }, - }} - out, err := ArtifactRegistryConfigReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - ar, ok := out.Config.(*ArtifactRegistryConfig) - Expect(ok).To(BeTrue()) - Expect(ar.Location).To(Equal("europe-west1")) - Expect(ar.Public).ToNot(BeNil()) - Expect(*ar.Public).To(BeTrue()) - Expect(ar.Domain).ToNot(BeNil()) - Expect(*ar.Domain).To(Equal("registry.example.com")) - Expect(ar.Docker).ToNot(BeNil()) - Expect(ar.Docker.ImmutableTags).ToNot(BeNil()) - Expect(*ar.Docker.ImmutableTags).To(BeTrue()) - Expect(ar.BasicAuth).ToNot(BeNil()) - Expect(ar.BasicAuth.Username).To(Equal("user")) - Expect(ar.BasicAuth.Password).To(Equal("pass")) - }) - - t.Run("optional pointers stay nil when absent", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "location": "us-central1", - }} - out, err := ArtifactRegistryConfigReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - ar := out.Config.(*ArtifactRegistryConfig) - Expect(ar.Public).To(BeNil()) - Expect(ar.Docker).To(BeNil()) - Expect(ar.BasicAuth).To(BeNil()) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "location": []int{1, 2}, - }} - _, err := ArtifactRegistryConfigReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -func TestDockerRemoteImagePushReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "remoteImage": "docker.io/library/nginx:latest", - "name": "nginx", - "tag": "v1", - "artifactRegistryResource": "my-registry", - }} - out, err := DockerRemoteImagePushReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - rip, ok := out.Config.(*RemoteImagePush) - Expect(ok).To(BeTrue()) - Expect(rip.RemoteImage).To(Equal("docker.io/library/nginx:latest")) - Expect(rip.Name).To(Equal("nginx")) - Expect(rip.Tag).To(Equal("v1")) - Expect(rip.ArtifactRegistryResource).To(Equal("my-registry")) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "name": map[string]any{"bad": "type"}, - }} - _, err := DockerRemoteImagePushReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -func TestRemoteImagePush_DependsOnResources(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - resource string - }{ - {name: "named registry resource", resource: "my-registry"}, - {name: "empty resource", resource: ""}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - rip := &RemoteImagePush{ArtifactRegistryResource: tc.resource} - deps := rip.DependsOnResources() - Expect(deps).To(HaveLen(1)) - Expect(deps[0].Name).To(Equal(tc.resource)) - }) - } -} - -// ---- Bucket -------------------------------------------------------------- - -func TestGcpBucket_GetBucketName(t *testing.T) { - RegisterTestingT(t) - tests := []struct { - name string - bucketName string - nameField string - want string - }{ - {name: "bucketName takes precedence", bucketName: "bucket-a", nameField: "name-b", want: "bucket-a"}, - {name: "falls back to name", bucketName: "", nameField: "name-b", want: "name-b"}, - {name: "both empty", bucketName: "", nameField: "", want: ""}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - b := &GcpBucket{BucketName: tc.bucketName, Name: tc.nameField} - Expect(b.GetBucketName()).To(Equal(tc.want)) - }) - } -} - -func TestGcpBucketReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "name": "my-bucket", - "location": "EU", - "adopt": true, - "bucketName": "explicit-bucket", - }} - out, err := GcpBucketReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - b, ok := out.Config.(*GcpBucket) - Expect(ok).To(BeTrue()) - Expect(b.Name).To(Equal("my-bucket")) - Expect(b.Location).To(Equal("EU")) - Expect(b.Adopt).To(BeTrue()) - Expect(b.BucketName).To(Equal("explicit-bucket")) - Expect(b.GetBucketName()).To(Equal("explicit-bucket")) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "adopt": "not-a-bool", - }} - _, err := GcpBucketReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- Postgres ------------------------------------------------------------ - -func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps scalars and pointers", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "version": "POSTGRES_16", - "project": "data-project", - "tier": "db-custom-2-7680", - "region": "europe-west1", - "maxConnections": 200, - "deletionProtection": true, - "availabilityType": "REGIONAL", - "requireSsl": true, - "databaseFlags": map[string]any{ - "cloudsql.iam_authentication": "on", - // Unquoted YAML int — users write flag values bare; - // yaml.v3 coerces scalars into the string map. - "log_min_duration_statement": 500, - }, - "usersProvisionRuntime": map[string]any{ - "type": "kube-job", - "resourceName": "gke-cluster", - }, - }} - out, err := PostgresqlGcpCloudsqlReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - pg, ok := out.Config.(*PostgresGcpCloudsqlConfig) - Expect(ok).To(BeTrue()) - Expect(pg.Version).To(Equal("POSTGRES_16")) - Expect(pg.Project).To(Equal("data-project")) - Expect(pg.Tier).ToNot(BeNil()) - Expect(*pg.Tier).To(Equal("db-custom-2-7680")) - Expect(pg.Region).ToNot(BeNil()) - Expect(*pg.Region).To(Equal("europe-west1")) - Expect(pg.MaxConnections).ToNot(BeNil()) - Expect(*pg.MaxConnections).To(Equal(200)) - Expect(pg.DatabaseFlags).To(Equal(map[string]string{ - "cloudsql.iam_authentication": "on", - "log_min_duration_statement": "500", - })) - Expect(pg.DeletionProtection).ToNot(BeNil()) - Expect(*pg.DeletionProtection).To(BeTrue()) - Expect(pg.AvailabilityType).ToNot(BeNil()) - Expect(*pg.AvailabilityType).To(Equal("REGIONAL")) - Expect(pg.RequireSsl).ToNot(BeNil()) - Expect(*pg.RequireSsl).To(BeTrue()) - Expect(pg.UsersProvisionRuntime).ToNot(BeNil()) - Expect(pg.UsersProvisionRuntime.Type).To(Equal("kube-job")) - Expect(pg.UsersProvisionRuntime.ResourceName).To(Equal("gke-cluster")) - }) - - t.Run("optional pointers stay nil when absent", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "version": "POSTGRES_15", - }} - out, err := PostgresqlGcpCloudsqlReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - pg := out.Config.(*PostgresGcpCloudsqlConfig) - Expect(pg.Tier).To(BeNil()) - Expect(pg.Region).To(BeNil()) - Expect(pg.MaxConnections).To(BeNil()) - Expect(pg.DatabaseFlags).To(BeNil()) - Expect(pg.UsersProvisionRuntime).To(BeNil()) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "maxConnections": "not-an-int", - }} - _, err := PostgresqlGcpCloudsqlReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- PubSub -------------------------------------------------------------- - -func TestGcpPubSubTopicsReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps topics, subscriptions and labels", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "labels": map[string]any{ - "env": "prod", - }, - "topics": []any{ - map[string]any{ - "name": "events", - "messageRetentionDuration": "604800s", - "labels": map[string]any{"team": "core"}, - }, - }, - "subscriptions": []any{ - map[string]any{ - "name": "events-sub", - "topic": "events", - "exactlyOnceDelivery": true, - "ackDeadlineSec": 30, - "deadLetterPolicy": map[string]any{ - "deadLetterTopic": "events-dlq", - "maxDeliveryAttempts": 5, - }, - }, - }, - }} - out, err := GcpPubSubTopicsReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - ps, ok := out.Config.(*PubSubConfig) - Expect(ok).To(BeTrue()) - Expect(ps.Labels).To(HaveKeyWithValue("env", "prod")) - Expect(ps.Topics).To(HaveLen(1)) - Expect(ps.Topics[0].Name).To(Equal("events")) - Expect(ps.Topics[0].MessageRetentionDuration).To(Equal("604800s")) - Expect(ps.Topics[0].Labels).To(HaveKeyWithValue("team", "core")) - Expect(ps.Subscriptions).To(HaveLen(1)) - sub := ps.Subscriptions[0] - Expect(sub.Name).To(Equal("events-sub")) - Expect(sub.Topic).To(Equal("events")) - Expect(sub.ExactlyOnceDelivery).To(BeTrue()) - Expect(sub.AckDeadlineSec).To(Equal(30)) - Expect(sub.DeadLetterPolicy).ToNot(BeNil()) - Expect(sub.DeadLetterPolicy.DeadLetterTopic).ToNot(BeNil()) - Expect(*sub.DeadLetterPolicy.DeadLetterTopic).To(Equal("events-dlq")) - Expect(sub.DeadLetterPolicy.MaxDeliveryAttempts).ToNot(BeNil()) - Expect(*sub.DeadLetterPolicy.MaxDeliveryAttempts).To(Equal(5)) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "topics": "not-a-list", - }} - _, err := GcpPubSubTopicsReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- Redis --------------------------------------------------------------- - -func TestRedisReadConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "version": "REDIS_7_0", - "project": "data-project", - "memorySizeGb": 4, - "region": "europe-west1", - "authorizedNetwork": "projects/p/global/networks/default", - "redisConfig": map[string]any{ - "maxmemory-policy": "allkeys-lru", - }, - }} - out, err := RedisReadConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - r, ok := out.Config.(*RedisConfig) - Expect(ok).To(BeTrue()) - Expect(r.Version).To(Equal("REDIS_7_0")) - Expect(r.Project).To(Equal("data-project")) - Expect(r.MemorySizeGb).To(Equal(4)) - Expect(r.Region).ToNot(BeNil()) - Expect(*r.Region).To(Equal("europe-west1")) - Expect(r.AuthorizedNetwork).ToNot(BeNil()) - Expect(*r.AuthorizedNetwork).To(Equal("projects/p/global/networks/default")) - Expect(r.RedisConfig).To(HaveKeyWithValue("maxmemory-policy", "allkeys-lru")) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "memorySizeGb": "not-an-int", - }} - _, err := RedisReadConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- TemplateConfig (gcloud.go) ----------------------------------------- - -func TestReadTemplateConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "credentials": `{"type":"service_account"}`, - }} - out, err := ReadTemplateConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - tc, ok := out.Config.(*TemplateConfig) - Expect(ok).To(BeTrue()) - Expect(tc.ProjectId).To(Equal("my-gcp-project")) - Expect(tc.CredentialsValue()).To(Equal(`{"type":"service_account"}`)) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "credentials": []int{1, 2, 3}, - }} - _, err := ReadTemplateConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// ---- GKE Autopilot readers ---------------------------------------------- - -func TestReadGkeAutopilotTemplateConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "gkeClusterResource": "my-cluster", - "artifactRegistryResource": "my-registry", - }} - out, err := ReadGkeAutopilotTemplateConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - tpl, ok := out.Config.(*GkeAutopilotTemplate) - Expect(ok).To(BeTrue()) - Expect(tpl.GkeClusterResource).To(Equal("my-cluster")) - Expect(tpl.ArtifactRegistryResource).To(Equal("my-registry")) - Expect(tpl.ProjectId).To(Equal("my-gcp-project")) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "gkeClusterResource": map[string]any{"bad": "type"}, - }} - _, err := ReadGkeAutopilotTemplateConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -func TestReadGkeAutopilotResourceConfig(t *testing.T) { - RegisterTestingT(t) - - t.Run("happy path maps nested timeouts, caddy, egress", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "projectId": "my-gcp-project", - "gkeMinVersion": "1.29", - "location": "europe-west1", - "zone": "europe-west1-b", - "privateVpc": true, - "adopt": true, - "clusterName": "adopted-cluster", - "timeouts": map[string]any{ - "create": "30m", - "update": "20m", - "delete": "15m", - }, - "externalEgressIp": map[string]any{ - "enabled": true, - "existing": "projects/p/regions/europe-west1/addresses/egress", - }, - }} - out, err := ReadGkeAutopilotResourceConfig(cfg) - Expect(err).ToNot(HaveOccurred()) - r, ok := out.Config.(*GkeAutopilotResource) - Expect(ok).To(BeTrue()) - Expect(r.GkeMinVersion).To(Equal("1.29")) - Expect(r.Location).To(Equal("europe-west1")) - Expect(r.Zone).To(Equal("europe-west1-b")) - Expect(r.PrivateVpc).To(BeTrue()) - Expect(r.Adopt).To(BeTrue()) - Expect(r.ClusterName).To(Equal("adopted-cluster")) - Expect(r.Timeouts).ToNot(BeNil()) - Expect(r.Timeouts.Create).To(Equal("30m")) - Expect(r.Timeouts.Update).To(Equal("20m")) - Expect(r.Timeouts.Delete).To(Equal("15m")) - Expect(r.ExternalEgressIp).ToNot(BeNil()) - Expect(r.ExternalEgressIp.Enabled).To(BeTrue()) - Expect(r.ExternalEgressIp.Existing).To(Equal("projects/p/regions/europe-west1/addresses/egress")) - }) - - t.Run("error path", func(t *testing.T) { - RegisterTestingT(t) - cfg := &api.Config{Config: map[string]any{ - "privateVpc": "not-a-bool", - }} - _, err := ReadGkeAutopilotResourceConfig(cfg) - Expect(err).To(HaveOccurred()) - }) -} - -// The cleanup-policy fields are only ever reachable from YAML, and every other -// test for them constructs Go structs directly. A wrong struct tag would ship -// green while the declared retention silently became "not managed" — which is -// the failure this whole feature exists to prevent. -func TestArtifactRegistryConfigReadsCleanupPoliciesFromYAML(t *testing.T) { - RegisterTestingT(t) - - cfg := &api.Config{Config: map[string]any{ - "projectId": "p", - "location": "europe-west3", - "cleanupPolicies": []any{ - map[string]any{ - "name": "delete-old-feature-tags", - "action": "DELETE", - "condition": map[string]any{ - "tagState": "TAGGED", "olderThan": "2592000s", "newerThan": "3600s", - "tagPrefixes": []any{"feature-"}, - "packageNamePrefixes": []any{"svc/"}, - "versionNamePrefixes": []any{"sha256:"}, - }, - }, - map[string]any{ - "name": "keep-most-recent-20", - "action": "KEEP", - "mostRecentVersions": map[string]any{"keepCount": 20, "packageNamePrefixes": []any{"api/"}}, - }, - }, - "cleanupPolicyDryRun": true, - }} - out, err := ArtifactRegistryConfigReadConfig(cfg) - Expect(err).To(BeNil()) - ar, ok := out.Config.(*ArtifactRegistryConfig) - Expect(ok).To(BeTrue()) - - Expect(ar.ManagesCleanupPolicies()).To(BeTrue()) - got := ar.DeclaredCleanupPolicies() - Expect(got).To(HaveLen(2)) - Expect(got[0].Name).To(Equal("delete-old-feature-tags")) - Expect(got[0].Action).To(Equal("DELETE")) - Expect(got[0].Condition).NotTo(BeNil()) - // Every key, because a dead struct tag on any prefix field silently drops - // the narrowing and widens the DELETE to the whole repository, with no error. - Expect(got[0].Condition.TagState).To(Equal("TAGGED")) - Expect(got[0].Condition.OlderThan).To(Equal("2592000s")) - Expect(got[0].Condition.NewerThan).To(Equal("3600s")) - Expect(got[0].Condition.TagPrefixes).To(ConsistOf("feature-")) - Expect(got[0].Condition.PackageNamePrefixes).To(ConsistOf("svc/")) - Expect(got[0].Condition.VersionNamePrefixes).To(ConsistOf("sha256:")) - Expect(got[1].MostRecentVersions).NotTo(BeNil()) - Expect(*got[1].MostRecentVersions.KeepCount).To(Equal(20)) - Expect(got[1].MostRecentVersions.PackageNamePrefixes).To(ConsistOf("api/")) - Expect(ar.CleanupPolicyDryRun).NotTo(BeNil()) - Expect(*ar.CleanupPolicyDryRun).To(BeTrue()) -} - -// Absent must stay absent through the decode: it is what makes SC preserve -// out-of-band retention instead of deleting it. -func TestArtifactRegistryConfigAbsentCleanupPoliciesStaysUnmanaged(t *testing.T) { - RegisterTestingT(t) - - out, err := ArtifactRegistryConfigReadConfig(&api.Config{Config: map[string]any{ - "projectId": "p", "location": "europe-west3", - }}) - Expect(err).To(BeNil()) - ar := out.Config.(*ArtifactRegistryConfig) - Expect(ar.CleanupPolicies).To(BeNil()) - Expect(ar.ManagesCleanupPolicies()).To(BeFalse()) -} - -// An explicitly empty YAML list must survive as managed-and-empty. -func TestArtifactRegistryConfigEmptyListIsManaged(t *testing.T) { - RegisterTestingT(t) - - out, err := ArtifactRegistryConfigReadConfig(&api.Config{Config: map[string]any{ - "projectId": "p", "location": "europe-west3", "cleanupPolicies": []any{}, - }}) - Expect(err).To(BeNil()) - ar := out.Config.(*ArtifactRegistryConfig) - Expect(ar.CleanupPolicies).NotTo(BeNil(), "an explicit empty list is how retention is removed") - Expect(ar.ManagesCleanupPolicies()).To(BeTrue()) - Expect(ar.DeclaredCleanupPolicies()).To(BeEmpty()) -} diff --git a/pkg/clouds/gcloud/security_context_test.go b/pkg/clouds/gcloud/security_context_test.go deleted file mode 100644 index 45652d8fd..000000000 --- a/pkg/clouds/gcloud/security_context_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "testing" - - "github.com/compose-spec/compose-go/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" -) - -func gkeInput(t *testing.T, cloudExtras map[string]any) (*GkeAutopilotInput, error) { - t.Helper() - extras := any(cloudExtras) - stackCfg := &api.StackConfigCompose{Runs: []string{}, CloudExtras: &extras} - res, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, compose.Config{Project: &types.Project{}}, stackCfg) - if err != nil { - return nil, err - } - input, ok := res.(*GkeAutopilotInput) - require.True(t, ok) - return input, nil -} - -func TestSecurityContextThreadedByToGkeAutopilotConfig(t *testing.T) { - input, err := gkeInput(t, map[string]any{ - "securityContext": map[string]any{ - "fsGroup": 10001, - "fsGroupChangePolicy": "OnRootMismatch", - "runAsNonRoot": true, - }, - }) - require.NoError(t, err) - sc := input.Deployment.SecurityContext - require.NotNil(t, sc) - assert.Equal(t, 10001, *sc.FSGroup) - assert.Equal(t, "OnRootMismatch", *sc.FSGroupChangePolicy) - assert.True(t, *sc.RunAsNonRoot) - assert.Nil(t, sc.RunAsUser) -} - -func TestSecurityContextAbsentStaysNil(t *testing.T) { - input, err := gkeInput(t, map[string]any{"priorityClassName": "p"}) - require.NoError(t, err) - assert.Nil(t, input.Deployment.SecurityContext) -} - -func TestSecurityContextInvalidRejectedByToGkeAutopilotConfig(t *testing.T) { - _, err := gkeInput(t, map[string]any{ - "securityContext": map[string]any{"fsGroupChangePolicy": "Sometimes"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "cloudExtras.securityContext") -} diff --git a/pkg/clouds/gcloud/static_website.go b/pkg/clouds/gcloud/static_website.go deleted file mode 100644 index 4eb76e842..000000000 --- a/pkg/clouds/gcloud/static_website.go +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "github.com/pkg/errors" - - "github.com/simple-container-com/api/pkg/api" -) - -const TemplateTypeStaticWebsite = "gcp-static-website" - -type StaticSiteInput struct { - TemplateConfig `json:"templateConfig" yaml:"templateConfig"` - *api.StackConfigStatic `json:",inline" yaml:",inline"` - StackDir string `json:"stackDir" yaml:"stackDir"` - StackName string `json:"stackName" yaml:"stackName"` - Location string `json:"location" yaml:"location"` - BaseDnsZone string `json:"baseDnsZone" yaml:"baseDnsZone"` -} - -func (i *StaticSiteInput) OverriddenBaseZone() string { - return i.BaseDnsZone -} - -func ToStaticSiteConfig(tpl any, stackDir, stackName string, stackCfg *api.StackConfigStatic) (any, error) { - templateCfg, ok := tpl.(*TemplateConfig) - if !ok { - return nil, errors.Errorf("template config is not of type aws.TemplateConfig") - } - - if templateCfg == nil { - return nil, errors.Errorf("template config is nil") - } - - res := &StaticSiteInput{ - TemplateConfig: *templateCfg, - StackConfigStatic: stackCfg, - StackDir: stackDir, - StackName: stackName, - BaseDnsZone: stackCfg.Site.BaseDnsZone, - Location: stackCfg.Location, - } - - if stackCfg.Location == "" { - return nil, errors.Errorf("location is required for gcp bucket") - } - - return res, nil -} diff --git a/pkg/clouds/gcloud/testdata b/pkg/clouds/gcloud/testdata deleted file mode 120000 index 071d24fe2..000000000 --- a/pkg/clouds/gcloud/testdata +++ /dev/null @@ -1 +0,0 @@ -../../api/tests/testdata/ \ No newline at end of file diff --git a/pkg/clouds/gcloud/topology_spread_test.go b/pkg/clouds/gcloud/topology_spread_test.go deleted file mode 100644 index 2c03219d3..000000000 --- a/pkg/clouds/gcloud/topology_spread_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcloud - -import ( - "testing" - - "github.com/compose-spec/compose-go/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/compose" -) - -func TestTopologySpreadThreadedByToGkeAutopilotConfig(t *testing.T) { - cloudExtras := any(map[string]any{ - "topologySpreadConstraints": []map[string]any{ - {"topologyKey": "kubernetes.io/hostname", "minDomains": 2}, - }, - }) - stackCfg := &api.StackConfigCompose{ - Runs: []string{}, - CloudExtras: &cloudExtras, - } - - res, err := ToGkeAutopilotConfig(&GkeAutopilotTemplate{}, compose.Config{Project: &types.Project{}}, stackCfg) - require.NoError(t, err) - input, ok := res.(*GkeAutopilotInput) - require.True(t, ok) - require.Len(t, input.Deployment.TopologySpreadConstraints, 1) - assert.Equal(t, "kubernetes.io/hostname", input.Deployment.TopologySpreadConstraints[0].TopologyKey) -} diff --git a/pkg/clouds/pulumi/e2e_compose_test.go b/pkg/clouds/pulumi/e2e_compose_test.go index 42a894f81..5ea7c4db7 100644 --- a/pkg/clouds/pulumi/e2e_compose_test.go +++ b/pkg/clouds/pulumi/e2e_compose_test.go @@ -10,13 +10,13 @@ import ( "testing" . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - secretTestutil "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" "github.com/simple-container-com/sc-plugin-aws/api" "github.com/simple-container-com/sc-plugin-cloudflare/api" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/simple-container-com/sc-plugin-mongodb/api" + + "github.com/simple-container-com/api/pkg/api" + secretTestutil "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" ) const ( @@ -38,8 +38,8 @@ func Test_CreateComposeStackGCPWithBucket(t *testing.T) { kmsKeyName: tmpResName(e2eKmsTestKeyName), templates: map[string]api.StackDescriptor{ "stack-per-app": { - Type: gcloud.TemplateTypeGcpCloudrun, - Config: api.Config{Config: &gcloud.TemplateConfig{ + Type: gcpApi.TemplateTypeGcpCloudrun, + Config: api.Config{Config: &gcpApi.TemplateConfig{ Credentials: *cfg.GcpCredentials, }}, }, @@ -55,9 +55,9 @@ func Test_CreateComposeStackGCPWithBucket(t *testing.T) { Template: "stack-per-app", Resources: map[string]api.ResourceDescriptor{ "test-bucket": { - Type: gcloud.ResourceTypeBucket, + Type: gcpApi.ResourceTypeBucket, Config: api.Config{ - Config: &gcloud.GcpBucket{ + Config: &gcpApi.GcpBucket{ Credentials: *cfg.GcpCredentials, Name: tmpResName("e2e-create--test-bucket"), }, @@ -107,8 +107,8 @@ func Test_CreateComposeStackGCPWithMongo(t *testing.T) { kmsKeyName: tmpResName(e2eKmsTestKeyName), templates: map[string]api.StackDescriptor{ "stack-per-app": { - Type: gcloud.TemplateTypeGcpCloudrun, - Config: api.Config{Config: &gcloud.TemplateConfig{ + Type: gcpApi.TemplateTypeGcpCloudrun, + Config: api.Config{Config: &gcpApi.TemplateConfig{ Credentials: *cfg.GcpCredentials, }}, }, diff --git a/pkg/clouds/pulumi/e2e_helpers_test.go b/pkg/clouds/pulumi/e2e_helpers_test.go index a3f3dc8cf..3ad79d540 100644 --- a/pkg/clouds/pulumi/e2e_helpers_test.go +++ b/pkg/clouds/pulumi/e2e_helpers_test.go @@ -12,12 +12,12 @@ import ( "path/filepath" . "github.com/onsi/gomega" + "github.com/simple-container-com/sc-plugin-aws/api" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/simple-container-com/api/pkg/api" "github.com/simple-container-com/api/pkg/clouds/fs" - "github.com/simple-container-com/api/pkg/clouds/gcloud" secretTestutil "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" - "github.com/simple-container-com/sc-plugin-aws/api" ) const ( @@ -29,7 +29,7 @@ type e2eConfig struct { templates map[string]api.StackDescriptor resources map[string]api.PerEnvResourcesDescriptor registrar api.RegistrarDescriptor - gcpCreds gcloud.Credentials + gcpCreds gcpApi.Credentials awsCreds aws.AccountConfig pulumiCreds TokenAuthDescriptor } @@ -102,7 +102,7 @@ func e2eServerDescriptorForGcp(config e2eConfig) api.ServerDescriptor { Organization: "organization", StateStorage: StateStorageConfig{ Type: StateStorageTypeGcpBucket, - Config: api.Config{Config: &gcloud.StateStorageConfig{ + Config: api.Config{Config: &gcpApi.StateStorageConfig{ Provision: false, BucketName: e2eBucketName, Credentials: config.gcpCreds, @@ -110,7 +110,7 @@ func e2eServerDescriptorForGcp(config e2eConfig) api.ServerDescriptor { }, SecretsProvider: SecretsProviderConfig{ Type: SecretsProviderTypeGcpKms, - Config: api.Config{Config: &gcloud.SecretsProviderConfig{ + Config: api.Config{Config: &gcpApi.SecretsProviderConfig{ KeyName: config.kmsKeyName, KeyLocation: "global", Provision: true, diff --git a/pkg/clouds/pulumi/e2e_pulumi_static_website_test.go b/pkg/clouds/pulumi/e2e_pulumi_static_website_test.go index 004367db8..0bc894e32 100644 --- a/pkg/clouds/pulumi/e2e_pulumi_static_website_test.go +++ b/pkg/clouds/pulumi/e2e_pulumi_static_website_test.go @@ -9,14 +9,12 @@ import ( "fmt" "testing" - "github.com/simple-container-com/sc-plugin-cloudflare/api" - "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" - . "github.com/onsi/gomega" + "github.com/simple-container-com/sc-plugin-cloudflare/api" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/simple-container-com/api/pkg/api" + "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" ) const ( @@ -40,8 +38,8 @@ func Test_CreatePulumiParentStack(t *testing.T) { pulumiCreds: *pulumiCreds, templates: map[string]api.StackDescriptor{ "static-website": { - Type: gcloud.TemplateTypeStaticWebsite, - Config: api.Config{Config: &gcloud.TemplateConfig{ + Type: gcpApi.TemplateTypeStaticWebsite, + Config: api.Config{Config: &gcpApi.TemplateConfig{ Credentials: *cfg.GcpCredentials, }}, }, diff --git a/pkg/clouds/pulumi/e2e_static_website_test.go b/pkg/clouds/pulumi/e2e_static_website_test.go index cadb6bdec..ff6ded8b4 100644 --- a/pkg/clouds/pulumi/e2e_static_website_test.go +++ b/pkg/clouds/pulumi/e2e_static_website_test.go @@ -10,12 +10,12 @@ import ( "testing" . "github.com/onsi/gomega" + "github.com/simple-container-com/sc-plugin-aws/api" + "github.com/simple-container-com/sc-plugin-cloudflare/api" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" "github.com/simple-container-com/api/pkg/clouds/pulumi/testutil" - "github.com/simple-container-com/sc-plugin-aws/api" - "github.com/simple-container-com/sc-plugin-cloudflare/api" ) const ( @@ -39,8 +39,8 @@ func Test_CreateStaticStackGCP(t *testing.T) { kmsKeyName: tmpResName(e2eStaticKmsTestKeyName), templates: map[string]api.StackDescriptor{ "static-website": { - Type: gcloud.TemplateTypeStaticWebsite, - Config: api.Config{Config: &gcloud.TemplateConfig{ + Type: gcpApi.TemplateTypeStaticWebsite, + Config: api.Config{Config: &gcpApi.TemplateConfig{ Credentials: *cfg.GcpCredentials, }}, }, diff --git a/pkg/clouds/pulumi/gcp/adopt_bucket.go b/pkg/clouds/pulumi/gcp/adopt_bucket.go deleted file mode 100644 index da1148ed3..000000000 --- a/pkg/clouds/pulumi/gcp/adopt_bucket.go +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// AdoptPrivateBucket imports an existing GCS bucket into Pulumi state and creates HMAC keys for S3 compatibility -func AdoptPrivateBucket(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeBucket { - return nil, errors.Errorf("unsupported bucket type %q", input.Descriptor.Type) - } - - bucketCfg, ok := input.Descriptor.Config.Config.(*gcloud.GcpBucket) - if !ok { - return nil, errors.Errorf("failed to convert bucket config for %q", input.Descriptor.Type) - } - - if !bucketCfg.Adopt { - return nil, errors.Errorf("adopt flag not set for resource %q", input.Descriptor.Name) - } - - if bucketCfg.GetBucketName() == "" { - return nil, errors.Errorf("bucketName or name is required when adopt=true for resource %q", input.Descriptor.Name) - } - - // Use identical naming functions as provisioning to ensure export compatibility - bucketName := input.ToResName(lo.If(bucketCfg.GetBucketName() == "", input.Descriptor.Name).Else(bucketCfg.GetBucketName())) - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - - params.Log.Info(ctx.Context(), "adopting existing GCS bucket %q and creating S3 interoperability", bucketCfg.GetBucketName()) - - // Import existing bucket into Pulumi state - bucket, err := storage.NewBucket(ctx, bucketName, &storage.BucketArgs{ - Name: sdk.String(bucketCfg.GetBucketName()), - Location: sdk.String(bucketCfg.Location), - // Note: We don't need to specify all the bucket configuration since we're importing - // Pulumi will read the current state from GCP - }, append(opts, sdk.Import(sdk.ID(bucketCfg.GetBucketName())))...) - if err != nil { - return nil, errors.Wrapf(err, "failed to import GCS bucket %q", bucketCfg.GetBucketName()) - } - - // Export bucket name and location (same as provisioning) - ctx.Export(toBucketNameExport(bucketName), bucket.Name) - ctx.Export(toBucketLocationExport(bucketName), bucket.Location) - - params.Log.Info(ctx.Context(), "creating service account for adopted bucket %q", bucketCfg.GetBucketName()) - - // Create a service account for S3-compatible access (same as provisioning) - serviceAccountName := fmt.Sprintf("%s-bucket-sa", bucketName) - sa, err := serviceaccount.NewAccount(ctx, serviceAccountName, &serviceaccount.AccountArgs{ - AccountId: sdk.String(serviceAccountName), - DisplayName: sdk.String(fmt.Sprintf("Service account for bucket %s", bucketCfg.GetBucketName())), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create service account for bucket %q", bucketCfg.GetBucketName()) - } - - // Grant the service account access to the bucket (same as provisioning) - _, err = storage.NewBucketIAMMember(ctx, fmt.Sprintf("%s-bucket-iam", bucketName), &storage.BucketIAMMemberArgs{ - Bucket: bucket.Name, - Role: sdk.String("roles/storage.objectAdmin"), - Member: sdk.Sprintf("serviceAccount:%s", sa.Email), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to grant bucket access to service account for %q", bucketCfg.GetBucketName()) - } - - params.Log.Info(ctx.Context(), "creating HMAC key for adopted bucket %q", bucketCfg.GetBucketName()) - - // Create HMAC key for S3-compatible access (same as provisioning) - hmacKeyName := fmt.Sprintf("%s-hmac-key", bucketName) - hmacKey, err := storage.NewHmacKey(ctx, hmacKeyName, &storage.HmacKeyArgs{ - ServiceAccountEmail: sa.Email, - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create HMAC key for bucket %q", bucketCfg.GetBucketName()) - } - - // Export HMAC credentials (Access Key ID and Secret Key) - same as provisioning - ctx.Export(toBucketHmacAccessKeyIdExport(bucketName), hmacKey.AccessId) - ctx.Export(toBucketHmacSecretKeyExport(bucketName), sdk.ToSecret(hmacKey.Secret)) - - params.Log.Info(ctx.Context(), "successfully adopted GCS bucket %q with S3 interoperability", bucketCfg.GetBucketName()) - - return &api.ResourceOutput{Ref: bucket}, nil -} diff --git a/pkg/clouds/pulumi/gcp/adopt_gke_autopilot.go b/pkg/clouds/pulumi/gcp/adopt_gke_autopilot.go deleted file mode 100644 index f5261f45a..000000000 --- a/pkg/clouds/pulumi/gcp/adopt_gke_autopilot.go +++ /dev/null @@ -1,244 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "encoding/json" - "fmt" - - "github.com/pkg/errors" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container" - k8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - pulumiKubernetes "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" -) - -// AdoptGkeAutopilot imports an existing GKE Autopilot cluster into Pulumi state without modifying it -func AdoptGkeAutopilot(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeGkeAutopilot { - return nil, errors.Errorf("unsupported resource type %q", input.Descriptor.Type) - } - - gkeInput, ok := input.Descriptor.Config.Config.(*gcloud.GkeAutopilotResource) - if !ok { - return nil, errors.Errorf("failed to convert gke autopilot config for %q", input.Descriptor.Type) - } - - if !gkeInput.Adopt { - return nil, errors.Errorf("adopt flag not set for resource %q", input.Descriptor.Name) - } - - if gkeInput.ClusterName == "" { - return nil, errors.Errorf("clusterName is required when adopt=true for resource %q", input.Descriptor.Name) - } - - if gkeInput.Location == "" { - return nil, errors.Errorf("location is required when adopt=true for resource %q", input.Descriptor.Name) - } - - // Use identical naming functions as provisioning to ensure export compatibility - clusterName := pulumiKubernetes.ToClusterName(input, input.Descriptor.Name) - - // CRITICAL SAFETY WARNING for production environments - // Use flexible environment detection instead of hardcoded names - pApi.LogAdoptionWarnings(ctx, input, params, "GKE Autopilot cluster", gkeInput.ClusterName) - - params.Log.Info(ctx.Context(), "adopting existing GKE Autopilot cluster %q in location %q", gkeInput.ClusterName, gkeInput.Location) - - // First, lookup the existing cluster to get its current configuration - params.Log.Info(ctx.Context(), "fetching existing GKE cluster details for %q", gkeInput.ClusterName) - existingCluster, err := container.LookupCluster(ctx, &container.LookupClusterArgs{ - Name: gkeInput.ClusterName, - Location: &gkeInput.Location, - Project: &gkeInput.ProjectId, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to lookup existing GKE Autopilot cluster %q", gkeInput.ClusterName) - } - - // Validate that this is actually an Autopilot cluster - if !existingCluster.EnableAutopilot { - params.Log.Warn(ctx.Context(), "cluster %q is not an Autopilot cluster (EnableAutopilot: %v)", gkeInput.ClusterName, existingCluster.EnableAutopilot) - } - - // Use the existing cluster's configuration for the import, but allow overrides from config - enableAutopilot := existingCluster.EnableAutopilot - - minMasterVersion := existingCluster.MinMasterVersion - if gkeInput.GkeMinVersion != "" { - minMasterVersion = gkeInput.GkeMinVersion - params.Log.Info(ctx.Context(), "overriding GKE min version with config value: %q", minMasterVersion) - } - - location := existingCluster.Location - if gkeInput.Location != "" && (location == nil || gkeInput.Location != *location) { - location = &gkeInput.Location - params.Log.Info(ctx.Context(), "overriding location with config value: %q", gkeInput.Location) - } - - locationStr := "unknown" - if location != nil { - locationStr = *location - } - params.Log.Info(ctx.Context(), "found existing GKE cluster with autopilot=%v, version=%q, location=%q", - enableAutopilot, minMasterVersion, locationStr) - - // Import existing GKE cluster into Pulumi state - // The cluster resource ID in GCP is: projects/{project}/locations/{location}/clusters/{cluster} - clusterResourceId := fmt.Sprintf("projects/%s/locations/%s/clusters/%s", - gkeInput.ProjectId, - gkeInput.Location, - gkeInput.ClusterName) - - opts := []sdk.ResourceOption{ - // Import the existing cluster without creating or modifying it - sdk.Import(sdk.ID(clusterResourceId)), - } - if params.Provider != nil { - opts = append(opts, sdk.Provider(params.Provider)) - } - - // For GKE Autopilot adoption, use minimal configuration to avoid conflicts - // Only specify essential fields that are compatible with Autopilot mode - clusterArgs := &container.ClusterArgs{ - Name: sdk.String(gkeInput.ClusterName), - Location: sdk.StringPtrFromPtr(location), - EnableAutopilot: sdk.BoolPtr(enableAutopilot), - } - - // Only set MinMasterVersion if it's provided and different from existing - if gkeInput.GkeMinVersion != "" && gkeInput.GkeMinVersion != minMasterVersion { - clusterArgs.MinMasterVersion = sdk.StringPtr(gkeInput.GkeMinVersion) - params.Log.Info(ctx.Context(), "setting MinMasterVersion to %q for adoption", gkeInput.GkeMinVersion) - } - - // Use standardized adoption protection options - adoptionProtectionOpts := pApi.AdoptionProtectionOptions([]string{ - // Ignore node pool related changes since Autopilot manages these automatically - "nodePools", - "nodePool", - "defaultMaxPodsPerNode", - "ipAllocationPolicy", - "networkPolicy", - "enableIntranodeVisibility", - "loggingService", - "monitoringService", - "verticalPodAutoscaling", - "clusterTelemetry", - "nodeConfig", - }) - - // Add resource options to ignore changes that might cause conflicts during adoption - adoptionOpts := append(opts, adoptionProtectionOpts...) - - cluster, err := container.NewCluster(ctx, clusterName, clusterArgs, adoptionOpts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to import GKE Autopilot cluster %q", gkeInput.ClusterName) - } - - out := &GkeAutopilotOut{ - Cluster: cluster, - Caddy: nil, // Will be set below if Caddy is configured - } - - // Export the same keys as the provisioning function to ensure compute processor compatibility - kubeconfig := generateKubeconfig(cluster, gkeInput) - ctx.Export(toKubeconfigExport(clusterName), kubeconfig) - - // Export Caddy configuration if specified (for child stack compatibility) - // Note: For adopted clusters, we don't deploy Caddy but still need to export config - if gkeInput.Caddy != nil { - params.Log.Info(ctx.Context(), "exporting Caddy config for adopted GKE Autopilot cluster %q (no deployment)", gkeInput.ClusterName) - - // Export a minimal Caddy config that child stacks can read - // This matches the structure that DeployCaddyService would export - caddyConfigJson, err := json.Marshal(gkeInput.Caddy) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal caddy config for adopted cluster %q", clusterName) - } - - // Export the Caddy config using the same export key as regular provisioning - ctx.Export(pulumiKubernetes.ToCaddyConfigExport(clusterName), sdk.String(string(caddyConfigJson))) - - params.Log.Info(ctx.Context(), "✅ Caddy config exported for child stack compatibility") - - // Detect and export existing load balancer IP for DNS record creation - if err := exportExistingLoadBalancerIP(ctx, cluster, gkeInput, clusterName, params); err != nil { - params.Log.Warn(ctx.Context(), "⚠️ Failed to detect existing load balancer IP: %v", err) - // Don't fail the adoption, just warn - DNS records might need manual setup - } - } - - params.Log.Info(ctx.Context(), "successfully adopted GKE Autopilot cluster %q", gkeInput.ClusterName) - - return &api.ResourceOutput{Ref: out}, nil -} - -// exportExistingLoadBalancerIP detects the existing Caddy load balancer service in the adopted cluster -// and exports its IP address for DNS record creation -func exportExistingLoadBalancerIP(ctx *sdk.Context, cluster *container.Cluster, gkeInput *gcloud.GkeAutopilotResource, clusterName string, params pApi.ProvisionParams) error { - // Create a Kubernetes provider for the adopted cluster - kubeconfig := generateKubeconfig(cluster, gkeInput) - kubeProvider, err := k8s.NewProvider(ctx, fmt.Sprintf("%s-adoption-kube-provider", clusterName), &k8s.ProviderArgs{ - Kubeconfig: pulumiKubernetes.SecretKubeconfigOutput(kubeconfig), - }) - if err != nil { - return errors.Wrapf(err, "failed to create Kubernetes provider for adopted cluster %q", gkeInput.ClusterName) - } - - // Determine the service name and namespace based on Caddy configuration - serviceName := "caddy" - namespace := "caddy" - - if gkeInput.Caddy.DeploymentName != nil { - serviceName = *gkeInput.Caddy.DeploymentName - } - if gkeInput.Caddy.Namespace != nil { - namespace = *gkeInput.Caddy.Namespace - } - - params.Log.Info(ctx.Context(), "🔍 Looking for existing Caddy service %q in namespace %q", serviceName, namespace) - - // Look up the existing Caddy service to get its load balancer IP - // Use GetService with the proper resource ID format: namespace/serviceName - serviceId := fmt.Sprintf("%s/%s", namespace, serviceName) - service, err := corev1.GetService(ctx, fmt.Sprintf("%s-adoption-service-lookup", clusterName), sdk.ID(serviceId), nil, sdk.Provider(kubeProvider)) - if err != nil { - return errors.Wrapf(err, "failed to lookup existing Caddy service %q in namespace %q", serviceName, namespace) - } - - // Extract the load balancer IP and export it - loadBalancerIP := service.Status.ApplyT(func(status *corev1.ServiceStatus) string { - if status.LoadBalancer == nil || len(status.LoadBalancer.Ingress) == 0 { - params.Log.Warn(ctx.Context(), "⚠️ No load balancer ingress found for service %q in namespace %q", serviceName, namespace) - return "" - } - - ingress := status.LoadBalancer.Ingress[0] - ip := "" - if ingress.Ip != nil { - ip = *ingress.Ip - } else if ingress.Hostname != nil { - // Some load balancers provide hostname instead of IP - ip = *ingress.Hostname - } - - if ip != "" { - params.Log.Info(ctx.Context(), "✅ Found existing load balancer IP: %s", ip) - } - return ip - }).(sdk.StringOutput) - - // Export the IP using the same key that child stacks expect - ctx.Export(pulumiKubernetes.ToIngressIpExport(clusterName), loadBalancerIP) - params.Log.Info(ctx.Context(), "✅ Exported existing load balancer IP for DNS record creation") - - return nil -} diff --git a/pkg/clouds/pulumi/gcp/adopt_postgres.go b/pkg/clouds/pulumi/gcp/adopt_postgres.go deleted file mode 100644 index 624baf416..000000000 --- a/pkg/clouds/pulumi/gcp/adopt_postgres.go +++ /dev/null @@ -1,307 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// AdoptPostgres imports an existing Cloud SQL Postgres instance into Pulumi state without modifying it -func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypePostgresGcpCloudsql { - return nil, errors.Errorf("unsupported postgres type %q", input.Descriptor.Type) - } - - pgCfg, ok := input.Descriptor.Config.Config.(*gcloud.PostgresGcpCloudsqlConfig) - if !ok { - return nil, errors.Errorf("failed to convert postgresql config for %q", input.Descriptor.Type) - } - - if !pgCfg.Adopt { - return nil, errors.Errorf("adopt flag not set for resource %q", input.Descriptor.Name) - } - - if pgCfg.InstanceName == "" { - return nil, errors.Errorf("instanceName is required when adopt=true for resource %q", input.Descriptor.Name) - } - - if pgCfg.ConnectionName == "" { - return nil, errors.Errorf("connectionName is required when adopt=true for resource %q", input.Descriptor.Name) - } - - // Use identical naming functions as provisioning to ensure export compatibility - postgresName := toPostgresName(input, input.Descriptor.Name) - - // CRITICAL SAFETY WARNING for production environments - // Use flexible environment detection instead of hardcoded names - pApi.LogAdoptionWarnings(ctx, input, params, "PostgreSQL instance", pgCfg.InstanceName) - - params.Log.Info(ctx.Context(), "adopting existing Cloud SQL Postgres instance %q", pgCfg.InstanceName) - - // First, lookup the existing instance to get its current configuration - params.Log.Info(ctx.Context(), "fetching existing Cloud SQL instance details for %q", pgCfg.InstanceName) - existingInstance, err := sql.LookupDatabaseInstance(ctx, &sql.LookupDatabaseInstanceArgs{ - Name: pgCfg.InstanceName, - Project: &pgCfg.ProjectId, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to lookup existing Cloud SQL Postgres instance %q", pgCfg.InstanceName) - } - - // Use the existing instance's configuration for the import, but allow overrides from config - databaseVersion := existingInstance.DatabaseVersion - if pgCfg.Version != "" { - databaseVersion = pgCfg.Version - params.Log.Info(ctx.Context(), "overriding database version with config value: %q", databaseVersion) - } - - region := existingInstance.Region - if pgCfg.Region != nil && *pgCfg.Region != "" { - region = *pgCfg.Region - params.Log.Info(ctx.Context(), "overriding region with config value: %q", region) - } - - // Extract all settings from existing instance to preserve them - var existingSettings *sql.GetDatabaseInstanceSetting - if len(existingInstance.Settings) > 0 { - existingSettings = &existingInstance.Settings[0] - } else { - return nil, errors.Errorf("existing instance %q has no settings - cannot adopt", pgCfg.InstanceName) - } - - // Use existing tier unless overridden in config - tier := existingSettings.Tier - if pgCfg.Tier != nil && *pgCfg.Tier != "" { - tier = *pgCfg.Tier - params.Log.Info(ctx.Context(), "overriding tier with config value: %q", tier) - } - - params.Log.Info(ctx.Context(), "found existing instance with version %q, region %q, tier %q", - databaseVersion, region, tier) - params.Log.Info(ctx.Context(), "preserving existing settings: %d database flags, insights enabled: %t, maintenance windows: %d", - len(existingSettings.DatabaseFlags), len(existingSettings.InsightsConfigs) > 0 && existingSettings.InsightsConfigs[0].QueryInsightsEnabled, len(existingSettings.MaintenanceWindows)) - - // Import existing Cloud SQL instance into Pulumi state - // The instance resource ID in GCP is: projects/{project}/instances/{instance} - instanceResourceId := fmt.Sprintf("projects/%s/instances/%s", pgCfg.ProjectId, pgCfg.InstanceName) - - // Preserve existing database flags, overridden by any flags set in config - // (MaxConnections and the generic DatabaseFlags map). - configuredFlags := configuredDatabaseFlags(pgCfg) - databaseFlags := mergeDatabaseFlags(existingSettings.DatabaseFlags, configuredFlags) - if len(pgCfg.DatabaseFlags) > 0 { - // Names only — flag values could carry substituted secrets. - params.Log.Warn(ctx.Context(), - "applying database flags %v to adopted instance %q: static flags restart the instance; "+ - "on first-time adoption values must match the live instance or the import fails", - sortedFlagNames(configuredFlags), pgCfg.InstanceName) - } - - adoptionOpts := pApi.AdoptionProtectionOptions(adoptIgnoreChanges(pgCfg.DatabaseFlags)) - - opts := append([]sdk.ResourceOption{ - sdk.Provider(params.Provider), - // Import the existing instance without creating or modifying it - sdk.Import(sdk.ID(instanceResourceId)), - }, adoptionOpts...) - - pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ - Name: sdk.String(pgCfg.InstanceName), - Region: sdk.StringPtr(region), - DatabaseVersion: sdk.String(databaseVersion), - // Use the existing instance's configuration for import, preserving all settings - Settings: buildSettingsFromExisting(ctx, existingSettings, tier, databaseFlags, pgCfg, params), - DeletionProtection: sdk.BoolPtrFromPtr(pgCfg.DeletionProtection), - // Note: Using actual instance configuration from GCP - // This ensures the import matches the existing instance exactly - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to import Cloud SQL Postgres instance %q", pgCfg.InstanceName) - } - - // For adopted instances, we need to export the root password from configuration - // instead of generating a new one like in provisioning - rootPasswordExport := toPostgresRootPasswordExport(postgresName) - - // Read root password from configuration (this will be provided in secrets.yaml) - // The compute processor will use this to create database users - if pgCfg.RootPassword != "" { - ctx.Export(rootPasswordExport, sdk.ToSecret(sdk.String(pgCfg.RootPassword))) - } else { - // For backward compatibility, we can also try to read from secrets - params.Log.Warn(ctx.Context(), "rootPassword not provided in config for adopted instance %q, compute processor will need to handle this", pgCfg.InstanceName) - ctx.Export(rootPasswordExport, sdk.ToSecret(sdk.String(""))) // Empty, compute processor will handle - } - - params.Log.Info(ctx.Context(), "successfully adopted Cloud SQL Postgres instance %q", pgCfg.InstanceName) - - return &api.ResourceOutput{Ref: pgInstance}, nil -} - -// buildSettingsFromExisting creates database instance settings that preserve all existing configuration -// while allowing selective overrides from the Simple Container configuration -func buildSettingsFromExisting(ctx *sdk.Context, existingSettings *sql.GetDatabaseInstanceSetting, tier string, databaseFlags sql.DatabaseInstanceSettingsDatabaseFlagArray, pgCfg *gcloud.PostgresGcpCloudsqlConfig, params pApi.ProvisionParams) *sql.DatabaseInstanceSettingsArgs { - // Build insights config - preserve existing unless explicitly overridden - var insightsConfig *sql.DatabaseInstanceSettingsInsightsConfigArgs - if len(existingSettings.InsightsConfigs) > 0 { - existing := existingSettings.InsightsConfigs[0] - insightsConfig = &sql.DatabaseInstanceSettingsInsightsConfigArgs{ - QueryInsightsEnabled: sdk.Bool(existing.QueryInsightsEnabled), - QueryStringLength: sdk.Int(existing.QueryStringLength), - RecordApplicationTags: sdk.Bool(existing.RecordApplicationTags), - RecordClientAddress: sdk.Bool(existing.RecordClientAddress), - } - - // Override with config values if specified - if pgCfg.QueryInsightsEnabled != nil { - insightsConfig.QueryInsightsEnabled = sdk.Bool(*pgCfg.QueryInsightsEnabled) - } - if pgCfg.QueryStringLength != nil { - insightsConfig.QueryStringLength = sdk.Int(*pgCfg.QueryStringLength) - } - } - - // Build backup configuration - preserve existing (use first one if multiple) - var backupConfig *sql.DatabaseInstanceSettingsBackupConfigurationArgs - if len(existingSettings.BackupConfigurations) > 0 { - backup := existingSettings.BackupConfigurations[0] - backupConfig = &sql.DatabaseInstanceSettingsBackupConfigurationArgs{ - Enabled: sdk.Bool(backup.Enabled), - StartTime: sdk.String(backup.StartTime), - Location: sdk.String(backup.Location), - BinaryLogEnabled: sdk.Bool(backup.BinaryLogEnabled), - PointInTimeRecoveryEnabled: sdk.Bool(backup.PointInTimeRecoveryEnabled), - TransactionLogRetentionDays: sdk.Int(backup.TransactionLogRetentionDays), - BackupRetentionSettings: func() *sql.DatabaseInstanceSettingsBackupConfigurationBackupRetentionSettingsArgs { - if len(backup.BackupRetentionSettings) > 0 { - retention := backup.BackupRetentionSettings[0] - return &sql.DatabaseInstanceSettingsBackupConfigurationBackupRetentionSettingsArgs{ - RetainedBackups: sdk.Int(retention.RetainedBackups), - RetentionUnit: sdk.String(retention.RetentionUnit), - } - } - return nil - }(), - } - } - - // Build IP configuration - preserve existing (use first one if multiple) - var ipConfig *sql.DatabaseInstanceSettingsIpConfigurationArgs - if len(existingSettings.IpConfigurations) > 0 { - ip := existingSettings.IpConfigurations[0] - var authorizedNetworks sql.DatabaseInstanceSettingsIpConfigurationAuthorizedNetworkArray - for _, network := range ip.AuthorizedNetworks { - authorizedNetworks = append(authorizedNetworks, sql.DatabaseInstanceSettingsIpConfigurationAuthorizedNetworkArgs{ - Name: sdk.String(network.Name), - Value: sdk.String(network.Value), - ExpirationTime: sdk.String(network.ExpirationTime), - }) - } - - ipConfig = &sql.DatabaseInstanceSettingsIpConfigurationArgs{ - Ipv4Enabled: sdk.Bool(ip.Ipv4Enabled), - PrivateNetwork: sdk.String(ip.PrivateNetwork), - AllocatedIpRange: sdk.String(ip.AllocatedIpRange), - AuthorizedNetworks: authorizedNetworks, - EnablePrivatePathForGoogleCloudServices: sdk.Bool(ip.EnablePrivatePathForGoogleCloudServices), - } - } - - // Build maintenance window - preserve existing (use first one if multiple) - var maintenanceWindow *sql.DatabaseInstanceSettingsMaintenanceWindowArgs - if len(existingSettings.MaintenanceWindows) > 0 { - window := existingSettings.MaintenanceWindows[0] - // Only create maintenance window if day is valid (1-7). Day 0 means no maintenance window configured. - if window.Day >= 1 && window.Day <= 7 { - maintenanceWindow = &sql.DatabaseInstanceSettingsMaintenanceWindowArgs{ - Day: sdk.Int(window.Day), - Hour: sdk.Int(window.Hour), - UpdateTrack: sdk.String(window.UpdateTrack), - } - } else { - params.Log.Info(ctx.Context(), "skipping maintenance window with invalid day value: %d (expected 1-7)", window.Day) - } - } - - // Build location preference - preserve existing (use first one if multiple) - var locationPref *sql.DatabaseInstanceSettingsLocationPreferenceArgs - if len(existingSettings.LocationPreferences) > 0 { - pref := existingSettings.LocationPreferences[0] - locationPref = &sql.DatabaseInstanceSettingsLocationPreferenceArgs{ - FollowGaeApplication: sdk.String(pref.FollowGaeApplication), - Zone: sdk.String(pref.Zone), - SecondaryZone: sdk.String(pref.SecondaryZone), - } - } - - return &sql.DatabaseInstanceSettingsArgs{ - Tier: sdk.String(tier), - DatabaseFlags: databaseFlags, - InsightsConfig: insightsConfig, - BackupConfiguration: backupConfig, - IpConfiguration: ipConfig, - MaintenanceWindow: maintenanceWindow, - LocationPreference: locationPref, - ActivationPolicy: sdk.String(existingSettings.ActivationPolicy), - AvailabilityType: sdk.String(existingSettings.AvailabilityType), - Collation: sdk.String(existingSettings.Collation), - ConnectorEnforcement: sdk.String(existingSettings.ConnectorEnforcement), - DeletionProtectionEnabled: sdk.Bool(existingSettings.DeletionProtectionEnabled), - DiskAutoresize: sdk.Bool(existingSettings.DiskAutoresize), - DiskAutoresizeLimit: sdk.Int(existingSettings.DiskAutoresizeLimit), - DiskSize: sdk.Int(existingSettings.DiskSize), - DiskType: sdk.String(existingSettings.DiskType), - Edition: sdk.String(existingSettings.Edition), - EnableDataplexIntegration: sdk.Bool(existingSettings.EnableDataplexIntegration), - EnableGoogleMlIntegration: sdk.Bool(existingSettings.EnableGoogleMlIntegration), - PricingPlan: sdk.String(existingSettings.PricingPlan), - TimeZone: sdk.String(existingSettings.TimeZone), - UserLabels: sdk.ToStringMap(existingSettings.UserLabels), - // Note: Version is automatically managed by GCP and cannot be explicitly set - // Other auto-managed fields are excluded to prevent configuration errors - } -} - -// mergeDatabaseFlags overlays configured flags onto an adopted instance's -// existing flags and renders the union fully sorted — a mixed -// existing-order/sorted-tail array would churn on every update. -func mergeDatabaseFlags(existing []sql.GetDatabaseInstanceSettingDatabaseFlag, configured map[string]string) sql.DatabaseInstanceSettingsDatabaseFlagArray { - merged := map[string]string{} - for _, flag := range existing { - merged[flag.Name] = flag.Value - } - for name, value := range configured { - merged[name] = value - } - return toDatabaseFlagArray(merged) -} - -// adoptIgnoreChanges returns the adoption protection ignore list. -// settings.databaseFlags stays ignored unless the DatabaseFlags field is -// explicitly set: legacy adopted stacks carrying only maxConnections keep the -// historical no-op instead of suddenly applying a restart-requiring flag. -func adoptIgnoreChanges(databaseFlags map[string]string) []string { - ignoreChanges := []string{ - // Instance configuration that might drift - "settings.insightsConfig", "settings.maintenanceWindow", - "settings.backupConfiguration", "settings.ipConfiguration", - // Version and upgrade settings - "masterInstanceName", "replicaConfiguration", "restoreBackupContext", - // Advanced settings that might be managed outside of Pulumi - "settings.userLabels", "settings.availabilityType", "settings.diskAutoresize", - } - if len(databaseFlags) == 0 { - ignoreChanges = append(ignoreChanges, "settings.databaseFlags") - } - return ignoreChanges -} diff --git a/pkg/clouds/pulumi/gcp/adopt_redis.go b/pkg/clouds/pulumi/gcp/adopt_redis.go deleted file mode 100644 index 2e149d029..000000000 --- a/pkg/clouds/pulumi/gcp/adopt_redis.go +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/redis" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// AdoptRedis imports an existing Redis Memorystore instance into Pulumi state without modifying it -func AdoptRedis(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeRedis { - return nil, errors.Errorf("unsupported redis type %q", input.Descriptor.Type) - } - - redisCfg, ok := input.Descriptor.Config.Config.(*gcloud.RedisConfig) - if !ok { - return nil, errors.Errorf("failed to convert redis config for %q", input.Descriptor.Type) - } - - if !redisCfg.Adopt { - return nil, errors.Errorf("adopt flag not set for resource %q", input.Descriptor.Name) - } - - if redisCfg.InstanceId == "" { - return nil, errors.Errorf("instanceId is required when adopt=true for resource %q", input.Descriptor.Name) - } - - // Use identical naming functions as provisioning to ensure export compatibility - redisName := toRedisName(input, input.Descriptor.Name) - - // CRITICAL SAFETY WARNING for production environments - // Use flexible environment detection instead of hardcoded names - pApi.LogAdoptionWarnings(ctx, input, params, "Redis instance", redisCfg.InstanceId) - - params.Log.Info(ctx.Context(), "adopting existing Redis Memorystore instance %q", redisCfg.InstanceId) - - // First, lookup the existing Redis instance to get its current configuration - params.Log.Info(ctx.Context(), "fetching existing Redis instance details for %q", redisCfg.InstanceId) - existingInstance, err := redis.LookupInstance(ctx, &redis.LookupInstanceArgs{ - Name: redisCfg.InstanceId, - Project: &redisCfg.ProjectId, - Region: redisCfg.Region, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to lookup existing Redis Memorystore instance %q", redisCfg.InstanceId) - } - - // Use the existing instance's configuration for the import, but allow overrides from config - memorySizeGb := existingInstance.MemorySizeGb - if redisCfg.MemorySizeGb != 0 { - memorySizeGb = redisCfg.MemorySizeGb - params.Log.Info(ctx.Context(), "overriding memory size with config value: %dGB", memorySizeGb) - } - - redisVersion := existingInstance.RedisVersion - if redisCfg.Version != "" { - redisVersion = redisCfg.Version - params.Log.Info(ctx.Context(), "overriding Redis version with config value: %q", redisVersion) - } - - region := existingInstance.Region - if redisCfg.Region != nil && *redisCfg.Region != "" { - region = redisCfg.Region - params.Log.Info(ctx.Context(), "overriding region with config value: %q", *region) - } - - regionStr := "" - if region != nil { - regionStr = *region - } - params.Log.Info(ctx.Context(), "found existing Redis instance with memory size %dGB, version %q, region %q", - memorySizeGb, redisVersion, regionStr) - - // Import existing Redis instance into Pulumi state - // The instance resource ID in GCP is: projects/{project}/locations/{location}/instances/{instance} - // For Redis, we need to construct the full resource path - instanceResourceId := fmt.Sprintf("projects/%s/locations/%s/instances/%s", - redisCfg.ProjectId, - *redisCfg.Region, // Redis requires region - redisCfg.InstanceId) - - // Use standardized adoption protection options - adoptionOpts := pApi.AdoptionProtectionOptions([]string{ - // Instance configuration that might drift - "authorizedNetwork", "connectMode", "displayName", - "labels", "redisConfigs", "reservedIpRange", - // Advanced settings that might be managed outside of Pulumi - "locationId", "alternativeLocationId", "tier", - }) - - opts := append([]sdk.ResourceOption{ - sdk.Provider(params.Provider), - // Import the existing instance without creating or modifying it - sdk.Import(sdk.ID(instanceResourceId)), - }, adoptionOpts...) - - redisInstance, err := redis.NewInstance(ctx, redisName, &redis.InstanceArgs{ - Name: sdk.String(redisCfg.InstanceId), - // Use the existing instance's configuration for import - MemorySizeGb: sdk.Int(memorySizeGb), - RedisVersion: sdk.StringPtr(redisVersion), - Region: sdk.StringPtrFromPtr(region), - // Note: Using actual instance configuration from GCP - // This ensures the import matches the existing instance exactly - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to import Redis Memorystore instance %q", redisCfg.InstanceId) - } - - // Export the same keys as the provisioning function to ensure compute processor compatibility - ctx.Export(toRedisHostExport(redisName), redisInstance.Host) - ctx.Export(toRedisPortExport(redisName), sdk.Sprintf("%d", redisInstance.Port)) - - params.Log.Info(ctx.Context(), "successfully adopted Redis Memorystore instance %q", redisCfg.InstanceId) - - return &api.ResourceOutput{Ref: redisInstance}, nil -} diff --git a/pkg/clouds/pulumi/gcp/alerts.go b/pkg/clouds/pulumi/gcp/alerts.go deleted file mode 100644 index dcad9dcf8..000000000 --- a/pkg/clouds/pulumi/gcp/alerts.go +++ /dev/null @@ -1,256 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/monitoring" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" -) - -type AlertsConfig struct { - MaxErrCount *float64 `json:"maxErrCount,omitempty"` - MaxStdoutLinesCount *float64 `json:"maxStdoutLinesCount,omitempty"` -} - -type AlertsConfigWithProvider struct { - AlertsConfig - Provider *gcp.Provider `json:"provider"` -} - -type AlertCfg struct { - Message string - ChatId *string - Provider *gcp.Provider -} - -type StdErrAlertCfg struct { - AlertCfg - ContainerName string - Threshold *float64 - AutoClose *string -} - -type TelegramCfg struct { - DefaultChatId string `json:"defaultChatId"` -} - -type DiscordCfg struct { - WebhookId string `json:"webhookId"` -} - -type GCPAlertIncident struct { - IncidentID string `json:"incident_id,omitempty"` - URL string `json:"url,omitempty"` - PolicyName string `json:"policy_name,omitempty"` - State string `json:"state,omitempty"` -} - -type GCPAlertPayload struct { - Incident *GCPAlertIncident `json:"incident,omitempty"` - Summary string `json:"summary,omitempty"` -} - -type CreatedAlert struct { - PolicyName sdk.Output - ChannelNames []sdk.Output -} - -// nolint: unused -func createMaxStdErrAlertPolicy(ctx *sdk.Context, name string, config StdErrAlertCfg, ntfChannels []sdk.IDOutput) (*monitoring.AlertPolicy, error) { - filter := fmt.Sprintf(`resource.type = "k8s_container" - AND resource.labels.container_name = "%s" - AND metric.type = "logging.googleapis.com/log_entry_count" - AND metric.labels.log = "stderr"`, config.ContainerName) - - policy, err := monitoring.NewAlertPolicy(ctx, fmt.Sprintf("stderr-%s", name), &monitoring.AlertPolicyArgs{ - Conditions: monitoring.AlertPolicyConditionArray{ - &monitoring.AlertPolicyConditionArgs{ - ConditionThreshold: &monitoring.AlertPolicyConditionConditionThresholdArgs{ - Aggregations: monitoring.AlertPolicyConditionConditionThresholdAggregationArray{ - &monitoring.AlertPolicyConditionConditionThresholdAggregationArgs{ - AlignmentPeriod: sdk.String("60s"), - CrossSeriesReducer: sdk.String("REDUCE_SUM"), - GroupByFields: sdk.StringArray{sdk.String("metric.label.severity")}, - PerSeriesAligner: sdk.String("ALIGN_MEAN"), - }, - }, - Comparison: sdk.String("COMPARISON_GT"), - Duration: sdk.String("0s"), - Filter: sdk.String(filter), - ThresholdValue: sdk.Float64Ptr(lo.FromPtr(config.Threshold)), - Trigger: &monitoring.AlertPolicyConditionConditionThresholdTriggerArgs{ - Count: sdk.Int(1), - }, - }, - DisplayName: sdk.String("stderr higher than threshold"), - }, - }, - Documentation: &monitoring.AlertPolicyDocumentationArgs{ - Content: sdk.String(config.Message), - MimeType: sdk.String("text/markdown"), - }, - AlertStrategy: &monitoring.AlertPolicyAlertStrategyArgs{ - AutoClose: sdk.String(config.AutoCloseOr("86400s")), - }, - Combiner: sdk.String("AND_WITH_MATCHING_RESOURCE"), - Enabled: sdk.Bool(true), - NotificationChannels: sdk.StringArray(lo.Map(ntfChannels, func(item sdk.IDOutput, _ int) sdk.StringInput { - return item.ToStringOutput() - })), - DisplayName: sdk.String(name), - }, sdk.Provider(config.Provider)) - - return policy, err -} - -// nolint: unused -func createMaxStdLinesAlertPolicy(ctx *sdk.Context, name string, config StdErrAlertCfg, ntfChannels []sdk.IDOutput) (*monitoring.AlertPolicy, error) { - filter := fmt.Sprintf(`resource.type = "k8s_container" - AND resource.labels.container_name = "%s" - AND metric.type = "logging.googleapis.com/log_entry_count" - AND metric.labels.log = "stdout"`, config.ContainerName) - - policy, err := monitoring.NewAlertPolicy(ctx, fmt.Sprintf("stdout-%s", name), &monitoring.AlertPolicyArgs{ - Conditions: monitoring.AlertPolicyConditionArray{ - &monitoring.AlertPolicyConditionArgs{ - ConditionThreshold: &monitoring.AlertPolicyConditionConditionThresholdArgs{ - Aggregations: monitoring.AlertPolicyConditionConditionThresholdAggregationArray{ - &monitoring.AlertPolicyConditionConditionThresholdAggregationArgs{ - AlignmentPeriod: sdk.String("60s"), - CrossSeriesReducer: sdk.String("REDUCE_SUM"), - GroupByFields: sdk.StringArray{sdk.String("metric.label.severity")}, - PerSeriesAligner: sdk.String("ALIGN_MEAN"), - }, - }, - Comparison: sdk.String("COMPARISON_GT"), - Duration: sdk.String("0s"), - Filter: sdk.String(filter), - ThresholdValue: sdk.Float64Ptr(lo.FromPtr(config.Threshold)), - Trigger: &monitoring.AlertPolicyConditionConditionThresholdTriggerArgs{ - Count: sdk.Int(1), - }, - }, - DisplayName: sdk.String("stdout higher than threshold"), - }, - }, - Documentation: &monitoring.AlertPolicyDocumentationArgs{ - Content: sdk.String(config.Message), - MimeType: sdk.String("text/markdown"), - }, - AlertStrategy: &monitoring.AlertPolicyAlertStrategyArgs{ - AutoClose: sdk.String(config.AutoCloseOr("86400s")), - }, - Combiner: sdk.String("AND_WITH_MATCHING_RESOURCE"), - Enabled: sdk.Bool(true), - NotificationChannels: sdk.StringArray(lo.Map(ntfChannels, func(item sdk.IDOutput, _ int) sdk.StringInput { - return item.ToStringOutput() - })), - DisplayName: sdk.String(name), - }, sdk.Provider(config.Provider)) - - return policy, err -} - -// nolint: unused -func (config *StdErrAlertCfg) AutoCloseOr(defaultValue string) string { - if config.AutoClose != nil { - return *config.AutoClose - } - return defaultValue -} - -// nolint: unused -func alertText(incident *GCPAlertIncident, message string) string { - if incident.State == "open" { - return fmt.Sprintf("⚠️ triggered: %s", message) - } - return fmt.Sprintf("✅ resolved: %s", message) -} - -// nolint: unused -var supportedCFLocations = []string{ - "asia-east1", "asia-east2", "asia-northeast1", "asia-northeast2", - "europe-north1", "europe-west1", "europe-west2", "europe-west4", - "us-central1", "us-east1", "us-east4", "us-west1", -} - -// nolint: unused -const cfFallbackLocation = "europe-west1" - -// nolint: unused -func createMaxStderrCountAlert(ctx *sdk.Context, serviceName string, config AlertsConfigWithProvider) (*CreatedAlert, error) { - alertCfg := StdErrAlertCfg{ - AlertCfg: AlertCfg{ - Message: fmt.Sprintf("number of errors for '%s' > %.2f per 1 minute", serviceName, *config.MaxErrCount), - Provider: config.Provider, - }, - ContainerName: serviceName, - Threshold: config.MaxErrCount, - } - - channels := []sdk.Output{ - // cloudfunctionChannel(ctx, fmt.Sprintf("max-stderr-%s", serviceName), alertCfg), - } - - policy, err := createMaxStdErrAlertPolicy(ctx, fmt.Sprintf("%s-max-stderr", serviceName), alertCfg, []sdk.IDOutput{}) - if err != nil { - return nil, err - } - - return &CreatedAlert{ - PolicyName: policy.Name, - ChannelNames: channels, - }, nil -} - -// nolint: unused -func createMaxStdoutLinesAlert(ctx *sdk.Context, serviceName string, config AlertsConfigWithProvider) (*CreatedAlert, error) { - alertCfg := StdErrAlertCfg{ - AlertCfg: AlertCfg{ - Message: fmt.Sprintf("number of log lines for '%s' > %.2f per 1 minute", serviceName, *config.MaxStdoutLinesCount), - Provider: config.Provider, - }, - ContainerName: serviceName, - Threshold: config.MaxStdoutLinesCount, - } - - channels := []sdk.Output{ - // cloudfunctionChannel(ctx, fmt.Sprintf("max-stdout-%s", serviceName), alertCfg), - } - - policy, err := createMaxStdLinesAlertPolicy(ctx, fmt.Sprintf("%s-max-stdout", serviceName), alertCfg, []sdk.IDOutput{}) - if err != nil { - return nil, err - } - - return &CreatedAlert{ - PolicyName: policy.Name, - ChannelNames: channels, - }, nil -} - -// nolint: unused -func createAlertsForService(ctx *sdk.Context, serviceName string, config AlertsConfigWithProvider) ([]CreatedAlert, error) { - var alerts []CreatedAlert - if config.MaxErrCount != nil { - alert, err := createMaxStderrCountAlert(ctx, serviceName, config) - if err != nil { - return nil, err - } - alerts = append(alerts, *alert) - } - if config.MaxStdoutLinesCount != nil { - alert, err := createMaxStdoutLinesAlert(ctx, serviceName, config) - if err != nil { - return nil, err - } - alerts = append(alerts, *alert) - } - return alerts, nil -} diff --git a/pkg/clouds/pulumi/gcp/ambient_gcloud_test.go b/pkg/clouds/pulumi/gcp/ambient_gcloud_test.go deleted file mode 100644 index b9d7d0171..000000000 --- a/pkg/clouds/pulumi/gcp/ambient_gcloud_test.go +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "reflect" - "testing" - - "github.com/pkg/errors" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -type gcloudCall struct { - args []string - configDir string // CLOUDSDK_CONFIG when gcloud ran - keyFile string // contents of --key-file when gcloud ran -} - -func fakeGcloud(t *testing.T, err error) *[]gcloudCall { - t.Helper() - var calls []gcloudCall - prev := runGcloud - runGcloud = func(args ...string) ([]byte, error) { - c := gcloudCall{args: args, configDir: os.Getenv("CLOUDSDK_CONFIG")} - for i, a := range args { - if a == "--key-file" && i+1 < len(args) { - data, _ := os.ReadFile(args[i+1]) - c.keyFile = string(data) - } - } - calls = append(calls, c) - return []byte("gcloud said no"), err - } - t.Cleanup(func() { runGcloud = prev }) - return &calls -} - -func ambientEnv(t *testing.T) string { - t.Helper() - credFile := filepath.Join(t.TempDir(), "gha-creds-1.json") - if err := os.WriteFile(credFile, []byte(`{"type":"external_account"}`), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credFile) - t.Setenv("GOOGLE_CREDENTIALS", "") - t.Setenv("GITHUB_ACTIONS", "true") - t.Setenv("CLOUDSDK_CONFIG", "/home/runner/shared-gcloud") - t.Setenv("TMPDIR", t.TempDir()) - return credFile -} - -func initAmbient(t *testing.T) { - t.Helper() - if err := InitStateStore(context.Background(), &gcloud.StateStorageConfig{}, logger.New()); err != nil { - t.Fatalf("InitStateStore: %v", err) - } -} - -// A keyless deploy reaches its cluster through gke-gcloud-auth-plugin, which asks -// gcloud for a token; gcloud must be signed in with the federated credentials, in -// a configuration of its own so the account later steps share is not switched. -func TestInitStateStoreSignsGcloudInWithAmbientCredentials(t *testing.T) { - calls := fakeGcloud(t, nil) - credFile := ambientEnv(t) - - initAmbient(t) - - if len(*calls) != 1 || !reflect.DeepEqual((*calls)[0].args, []string{"auth", "login", "--cred-file=" + credFile, "--quiet"}) { - t.Fatalf("gcloud calls = %+v; want one auth login with the credentials file", *calls) - } - dir := (*calls)[0].configDir - if dir == "" || dir == "/home/runner/shared-gcloud" { - t.Fatalf("gcloud signed in under CLOUDSDK_CONFIG=%q; want a private directory", dir) - } - if st, err := os.Stat(dir); err != nil || st.Mode().Perm() != 0o700 { - t.Errorf("private gcloud config: %v; want an existing 0700 directory", err) - } - if os.Getenv("CLOUDSDK_CONFIG") != dir { - t.Error("the processes this run starts (gke-gcloud-auth-plugin) would not see the signed-in configuration") - } - if os.Getenv("GOOGLE_CREDENTIALS") != "" { - t.Error("ambient mode must not set GOOGLE_CREDENTIALS") - } -} - -// When the sign-in fails, or gcloud is not installed, the run keeps going with -// the configuration it had and leaves no private directory behind. -func TestInitStateStoreAmbientGcloudFailureRestoresConfig(t *testing.T) { - for _, c := range []struct { - name string - err error - }{ - {"gcloud fails", errors.New("exit status 1")}, - {"gcloud missing", &exec.Error{Name: "gcloud", Err: exec.ErrNotFound}}, - } { - t.Run(c.name, func(t *testing.T) { - calls := fakeGcloud(t, c.err) - ambientEnv(t) - - initAmbient(t) - - if len(*calls) != 1 { - t.Fatalf("gcloud calls = %+v", *calls) - } - if got := os.Getenv("CLOUDSDK_CONFIG"); got != "/home/runner/shared-gcloud" { - t.Errorf("CLOUDSDK_CONFIG = %q; want the previous value back", got) - } - if _, err := os.Stat((*calls)[0].configDir); !os.IsNotExist(err) { - t.Errorf("private gcloud config left behind: %v", err) - } - }) - } -} - -func TestInitStateStoreAmbientSkipsSignIn(t *testing.T) { - for _, c := range []struct { - name string - set func(t *testing.T) - }{ - {"outside GitHub Actions", func(t *testing.T) { t.Setenv("GITHUB_ACTIONS", "") }}, - {"no credentials variable", func(t *testing.T) { t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") }}, - {"credentials file absent", func(t *testing.T) { - t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(t.TempDir(), "absent.json")) - }}, - {"credentials path is a directory", func(t *testing.T) { t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", t.TempDir()) }}, - } { - t.Run(c.name, func(t *testing.T) { - calls := fakeGcloud(t, nil) - ambientEnv(t) - c.set(t) - - initAmbient(t) - - if len(*calls) != 0 { - t.Errorf("gcloud called: %+v", *calls) - } - if got := os.Getenv("CLOUDSDK_CONFIG"); got != "/home/runner/shared-gcloud" { - t.Errorf("CLOUDSDK_CONFIG changed to %q", got) - } - }) - } -} - -// Key mode activates the configured key and removes the temporary key file; it -// never signs in with ambient credentials. -func TestInitStateStoreKeyModeActivatesServiceAccount(t *testing.T) { - for _, c := range []struct { - name string - err error - }{ - {"activates", nil}, - {"activation fails", errors.New("exit status 1")}, - {"gcloud missing", &exec.Error{Name: "gcloud", Err: exec.ErrNotFound}}, - } { - t.Run(c.name, func(t *testing.T) { - calls := fakeGcloud(t, c.err) - ambientEnv(t) - key := `{"type":"service_account","client_email":"ci@p.iam.gserviceaccount.com"}` - cfg := &gcloud.StateStorageConfig{Credentials: gcloud.Credentials{Credentials: api.Credentials{Credentials: key}}} - - if err := InitStateStore(context.Background(), cfg, logger.New()); err != nil { - t.Fatal(err) - } - - if len(*calls) != 1 || (*calls)[0].args[0] != "auth" || (*calls)[0].args[1] != "activate-service-account" { - t.Fatalf("gcloud calls = %+v; want one activate-service-account", *calls) - } - if (*calls)[0].keyFile != key { - t.Errorf("gcloud read key file %q; want the configured key", (*calls)[0].keyFile) - } - keyPath := (*calls)[0].args[len((*calls)[0].args)-1] - if _, err := os.Stat(keyPath); !os.IsNotExist(err) { - t.Errorf("temporary key file %s left behind: %v", keyPath, err) - } - if os.Getenv("GOOGLE_CREDENTIALS") != key { - t.Error("key mode must set GOOGLE_CREDENTIALS for the Pulumi backend") - } - }) - } -} diff --git a/pkg/clouds/pulumi/gcp/artifactregistry.go b/pkg/clouds/pulumi/gcp/artifactregistry.go deleted file mode 100644 index a3035c932..000000000 --- a/pkg/clouds/pulumi/gcp/artifactregistry.go +++ /dev/null @@ -1,514 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/artifactregistry" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - taggingUtil "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/util" -) - -type ArtifactRegistryOut struct { - Repository *artifactregistry.Repository - URL sdk.StringOutput -} - -func ArtifactRegistry(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeArtifactRegistry { - return nil, errors.Errorf("unsupported artifact-registry type %q", input.Descriptor.Type) - } - - arCfg, ok := input.Descriptor.Config.Config.(*gcloud.ArtifactRegistryConfig) - if !ok { - return nil, errors.Errorf("failed to convert artifact-registry config for %q", input.Descriptor.Type) - } - - iamServiceName := fmt.Sprintf("projects/%s/services/iam.googleapis.com", arCfg.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, iamServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", iamServiceName) - } - gcpServiceName := fmt.Sprintf("projects/%s/services/artifactregistry.googleapis.com", arCfg.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, gcpServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", gcpServiceName) - } - - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - - artifactRegistryName := toArtifactRegistryName(input, input.Descriptor.Name) - location := arCfg.Location - - if location == "" { - return nil, errors.Errorf("`location` must be specified for artifact registry %q in %q", artifactRegistryName, input.StackParams.Environment) - } - - out := &ArtifactRegistryOut{} - - // Build unified labels using the tagging utility - var stackParams api.StackParams - if input.StackParams != nil { - stackParams = *input.StackParams - } - labels := taggingUtil.BuildTagsFromStackParams(stackParams).ToGCPLabels() - - // Create a new Artifact Registry repository for Docker images - repoArgs := artifactregistry.RepositoryArgs{ - RepositoryId: sdk.String(artifactRegistryName), - Location: sdk.String(location), - Project: sdk.StringPtr(arCfg.ProjectId), - Labels: sdk.ToStringMap(labels), - } - if arCfg.Docker != nil { - repoArgs.Format = sdk.String("DOCKER") - repoArgs.DockerConfig = &artifactregistry.RepositoryDockerConfigArgs{ - ImmutableTags: sdk.Bool(lo.FromPtr(arCfg.Docker.ImmutableTags)), - } - } else { - return nil, errors.Errorf("registry format is not supported") - } - - // cleanupPolicies is authoritative in the provider: a Repository resource - // that omits it sends an update clearing whatever is there. So either - // declare it, or tell the engine to leave it alone. Sending nothing is the - // one option that silently deletes another tool's retention policy. - // Scoped to this resource only. opts is shared with the IAM policy, both - // service accounts and their keys, none of which has a cleanupPolicies - // property, so appending in place attaches a meaningless option to eight - // resources and persists it in their state. - repoOpts := opts - if arCfg.ManagesCleanupPolicies() { - declared := arCfg.DeclaredCleanupPolicies() - policies, err := cleanupPolicyArgs(declared) - if err != nil { - return nil, errors.Wrapf(err, "invalid cleanup policies for artifact registry %q in %q", - artifactRegistryName, input.StackParams.Environment) - } - // Unset means dry run. The failure modes are not symmetric: dry-run when - // you wanted enforcement costs storage and is undone by flipping one - // boolean, while enforcement when you wanted dry run destroys image - // layers that no provision can restore. Enforcing is therefore an - // explicit, reviewable act. - dryRun := true - if arCfg.CleanupPolicyDryRun != nil { - dryRun = *arCfg.CleanupPolicyDryRun - } - repoArgs.CleanupPolicies = policies - repoArgs.CleanupPolicyDryRun = sdk.Bool(dryRun) - if len(declared) == 0 { - // dryRun gates deletion of VERSIONS by policies, not removal of the - // policies themselves, so it offers no protection on this path. - params.Log.Warn(ctx.Context(), "artifact registry %q: cleanupPolicies is declared empty, so ALL retention "+ - "policies on this repository will be removed, including any set outside SC; "+ - "remove the cleanupPolicies block instead if you meant to leave retention alone", - artifactRegistryName) - } else if dryRun { - params.Log.Info(ctx.Context(), "artifact registry %q: SC manages %d cleanup policies in DRY RUN; "+ - "policies set outside SC will be replaced, nothing is deleted until cleanupPolicyDryRun is false", - artifactRegistryName, len(declared)) - } else { - params.Log.Warn(ctx.Context(), "artifact registry %q: cleanup policies are ENFORCING (%d policies); "+ - "versions matching a DELETE policy will be permanently removed", - artifactRegistryName, len(declared)) - } - } else { - if arCfg.CleanupPolicyDryRun != nil { - return nil, errors.Errorf("artifact registry %q in %q: cleanupPolicyDryRun is set but cleanupPolicies is not declared, "+ - "so it would have no effect; declare cleanupPolicies or remove cleanupPolicyDryRun", - artifactRegistryName, input.StackParams.Environment) - } - params.Log.Info(ctx.Context(), "artifact registry %q: cleanup policies not declared, leaving any out-of-band retention untouched", - artifactRegistryName) - repoOpts = append(append([]sdk.ResourceOption{}, opts...), sdk.IgnoreChanges(cleanupPolicyFields)) - } - - params.Log.Info(ctx.Context(), "configure artifact registry repository %q", artifactRegistryName) - repo, err := artifactregistry.NewRepository(ctx, artifactRegistryName, &repoArgs, repoOpts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create artifact registry") - } - - if lo.FromPtr(arCfg.Public) { - params.Log.Info(ctx.Context(), "configure repository IAM policy for public access for %q...", artifactRegistryName) - _, err = artifactregistry.NewRepositoryIamPolicy(ctx, fmt.Sprintf("%s-iam", artifactRegistryName), &artifactregistry.RepositoryIamPolicyArgs{ - Project: sdk.String(arCfg.ProjectId), - Location: sdk.String(location), - Repository: repo.RepositoryId, - PolicyData: sdk.String(`{ - "bindings": [ - { - "role": "roles/artifactregistry.reader", - "members": ["allUsers"] - } - ] - }`), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to apply IAM policy on artifact registry %q in %q", artifactRegistryName, input.StackParams.Environment) - } - } - - urlPrefix := strings.ToLower(location) - urlSuffix := "" - if arCfg.Docker != nil { - urlSuffix = "-docker" - } - targetDomain := fmt.Sprintf("%s%s.pkg.dev", urlPrefix, urlSuffix) - registryURL := sdk.Sprintf("%s/%s/%s", targetDomain, repo.Project, repo.RepositoryId) - ctx.Export(toRegistryUrlExport(artifactRegistryName), registryURL) - out.Repository = repo - out.URL = registryURL - - // Create a GCP service account - params.Log.Info(ctx.Context(), "configure service account for admin access to %q...", artifactRegistryName) - _, err = createArtifactRegistryServiceAccount(ctx, arServiceAccountArgs{ - arCfg: arCfg, - registryName: artifactRegistryName, - saType: "admin", - saRole: "roles/artifactregistry.repoAdmin", - input: input, - params: params, - opts: opts, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision admin service account for artifact registry %q", artifactRegistryName) - } - params.Log.Info(ctx.Context(), "configure service account for read access to %q...", artifactRegistryName) - _, err = createArtifactRegistryServiceAccount(ctx, arServiceAccountArgs{ - arCfg: arCfg, - registryName: artifactRegistryName, - saType: "reader", - saRole: "roles/artifactregistry.reader", - input: input, - params: params, - opts: opts, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision reader service account for artifact registry %q", artifactRegistryName) - } - - if arCfg.Domain != nil { - sdk.All(repo.Project, repo.RepositoryId).ApplyT(func(outs []any) any { - project, repoId := outs[0].(string), outs[1].(string) - dnsRecord, err := params.Registrar.NewRecord(ctx, api.DnsRecord{ - Name: *arCfg.Domain, - ValueOut: sdk.String(targetDomain).ToStringOutput(), - Type: "CNAME", - Proxied: true, - }) - if err != nil { - return errors.Wrapf(err, "failed to create new DNS record for artifact registry") - } - overrideHeaderRule := pApi.OverrideHeaderRule{ - Name: strings.ReplaceAll(*arCfg.Domain, ".", "-"), - FromHost: *arCfg.Domain, - ToHost: sdk.String(targetDomain), - PathPrefix: fmt.Sprintf("/%s/%s", project, repoId), - } - if arCfg.BasicAuth != nil { // - overrideHeaderRule.BasicAuth = &pApi.BasicAuth{ - Username: arCfg.BasicAuth.Username, - Password: arCfg.BasicAuth.Password, - Realm: fmt.Sprintf("%s / %s / %s", input.StackParams.StackName, input.StackParams.Environment, input.Descriptor.Name), - } - } - _, err = params.Registrar.NewOverrideHeaderRule(ctx, stack, overrideHeaderRule) - if err != nil { - return errors.Wrapf(err, "failed to create override host rule from %q to %q", *arCfg.Domain, targetDomain) - } - return dnsRecord - }) - } - - return &api.ResourceOutput{Ref: out}, nil -} - -type arServiceAccountArgs struct { - arCfg *gcloud.ArtifactRegistryConfig - registryName string - saType string - saRole string - input api.ResourceInput - params pApi.ProvisionParams - opts []sdk.ResourceOption -} - -func createArtifactRegistryServiceAccount(ctx *sdk.Context, args arServiceAccountArgs) (*serviceaccount.Account, error) { - input, arCfg, params, registryName, opts := args.input, args.arCfg, args.params, args.registryName, args.opts - // Create a GCP service account - params.Log.Info(ctx.Context(), "configure service account for %s access to %q...", args.saType, registryName) - - // need to generate SA name that matches GCP rules - // GCP service account IDs must match: ^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])$ - // Replace underscores with hyphens to comply with GCP naming requirements - sanitizedRegistryName := strings.ReplaceAll(strings.ReplaceAll(registryName, "_", "-"), "-", "") - saName := fmt.Sprintf("%s-%s-sa", args.saType, sanitizedRegistryName) - saName = strings.ReplaceAll(util.TrimStringMiddle(saName, 28, "-"), "--", "-") - - sa, err := serviceaccount.NewAccount(ctx, saName, &serviceaccount.AccountArgs{ - Description: sdk.String(fmt.Sprintf("Service account to %s images at in %s", args.saType, registryName)), - AccountId: sdk.String(saName), - DisplayName: sdk.String(fmt.Sprintf("%s-%s-service-account", registryName, args.saType)), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision service account for artifact registry %q", registryName) - } - - // Grant the service account access to the repository - params.Log.Info(ctx.Context(), "grant service account %s access to %q...", args.saRole, registryName) - _, err = artifactregistry.NewRepositoryIamMember(ctx, fmt.Sprintf("%s-%s-sa-iam-binding", registryName, args.saType), &artifactregistry.RepositoryIamMemberArgs{ - Repository: sdk.String(registryName), - Project: sdk.String(arCfg.ProjectId), - Location: sdk.String(arCfg.Location), - Role: sdk.String(args.saRole), - Member: sdk.Sprintf("serviceAccount:%s", sa.Email), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision IAM membership for %s service account for registry %q", args.saType, registryName) - } - opts = append(opts, sdk.Parent(sa)) - serviceAccountKey, err := serviceaccount.NewKey(ctx, fmt.Sprintf("%s-key", saName), &serviceaccount.KeyArgs{ - ServiceAccountId: sa.AccountId, - }, opts...) - if err != nil { - return nil, err - } - - ctx.Export(toRegistryServiceAccountKeyExport(input, args.saType, registryName), serviceAccountKey.PrivateKey) - ctx.Export(toRegistryServiceAccountEmailExport(input, args.saType, registryName), sa.Email) - - return sa, err -} - -func toArtifactRegistryName(input api.ResourceInput, name string) string { - return input.ToResName(name) -} - -func toRegistryUrlExport(registryName string) string { - return fmt.Sprintf("%s-url", registryName) -} - -func toRegistryServiceAccountKeyExport(input api.ResourceInput, saType string, registryName string) string { - return input.ToResName(fmt.Sprintf("%s-%s-sa-key", saType, registryName)) -} - -func toRegistryServiceAccountEmailExport(input api.ResourceInput, saType string, registryName string) string { - return input.ToResName(fmt.Sprintf("%s-%s-sa", saType, registryName)) -} - -// cleanupPolicyFields are the property paths SC declines to manage when no -// retention is configured. Both are needed: leaving cleanupPolicyDryRun out -// would let a provision flip an out-of-band dry-run repository into enforcing. -var cleanupPolicyFields = []string{"cleanupPolicies", "cleanupPolicyDryRun"} - -// maxCleanupPolicies is the Artifact Registry limit. Exceeding it fails at -// apply, after other resources in the stack have already been mutated. -const maxCleanupPolicies = 10 - -// maxCleanupPolicyNameLen mirrors the provider's documented limit on the policy -// id; over it the apply fails. -const maxCleanupPolicyNameLen = 128 - -// cleanupDurationRe accepts the duration forms the provider accepts. Its own -// acceptance tests use the day form ("30d", "7d"), while the REST API reports -// seconds ("2592000s"); DurationDiffSuppress treats them as equivalent, so -// rejecting either would reject valid configuration. -// The quantity must be an INTEGER: the provider expands the m/h/d forms with -// strconv.Atoi before converting to seconds, so a fractional value such as -// "1.5h" passes any regex that allows it and then fails at registration. -var cleanupDurationRe = regexp.MustCompile(`^([0-9]+)(s|m|h|d)$`) - -// validateCleanupDuration rejects shapes the provider would reject at apply, -// and zero, which is not a syntax error but a semantic one: "olderThan: 0s" on -// a DELETE policy matches every version in the repository. -func validateCleanupDuration(policy, field, v string) error { - m := cleanupDurationRe.FindStringSubmatch(v) - if m == nil { - return errors.Errorf("cleanup policy %q: %s must be a positive duration such as %q or %q, got %q", - policy, field, "30d", "2592000s", v) - } - // m[1] is [0-9]+ by construction, so only the range error is reachable. - n, err := strconv.Atoi(m[1]) - if err != nil { - return errors.Errorf("cleanup policy %q: %s is out of range, got %q", policy, field, v) - } - if n <= 0 { - return errors.Errorf("cleanup policy %q: %s must be greater than zero, got %q", policy, field, v) - } - return nil -} - -// hasEmptyPrefix reports whether a prefix list contains an empty entry, which -// matches everything. -// -// Reachable without anyone typing "": an ${env:VAR} whose variable is unset -// resolves to the empty string. (An UNRESOLVED placeholder stays literal -// "${...}" instead, so that is not the path.) -func hasEmptyPrefix(vals []string) bool { - return lo.Contains(vals, "") -} - -// cleanupPolicyArgs converts the declared retention into provider inputs. -// -// Validation is deliberate rather than passing strings through. Two classes of -// mistake matter here and neither is caught by the provider in time to help: -// shapes the API rejects fail at APPLY, halfway through a provision; and -// shapes the API ACCEPTS but which match far more than the author intended -// delete images that are still deployed. The guards below are ordered so the -// second class is impossible to express, not merely discouraged. -func cleanupPolicyArgs(policies []gcloud.ArtifactRegistryCleanupPolicy) (artifactregistry.RepositoryCleanupPolicyArray, error) { - if len(policies) > maxCleanupPolicies { - return nil, errors.Errorf("artifact registry accepts at most %d cleanup policies, got %d", maxCleanupPolicies, len(policies)) - } - out := make(artifactregistry.RepositoryCleanupPolicyArray, 0, len(policies)) - seen := make(map[string]bool, len(policies)) - for i, p := range policies { - if p.Name == "" { - return nil, errors.Errorf("cleanup policy #%d is missing a name", i+1) - } - if len(p.Name) >= maxCleanupPolicyNameLen { - return nil, errors.Errorf("cleanup policy %q: name must be under %d characters", p.Name, maxCleanupPolicyNameLen) - } - if seen[p.Name] { - return nil, errors.Errorf("duplicate cleanup policy name %q", p.Name) - } - seen[p.Name] = true - - action := strings.ToUpper(p.Action) - if action != "DELETE" && action != "KEEP" { - return nil, errors.Errorf("cleanup policy %q: action must be DELETE or KEEP, got %q", p.Name, p.Action) - } - if p.MostRecentVersions != nil && action != "KEEP" { - return nil, errors.Errorf("cleanup policy %q: mostRecentVersions is only valid with a KEEP action", p.Name) - } - // condition and mostRecentVersions are a union field in the API. - if p.Condition != nil && p.MostRecentVersions != nil { - return nil, errors.Errorf("cleanup policy %q: condition and mostRecentVersions are mutually exclusive", p.Name) - } - if p.Condition == nil && p.MostRecentVersions == nil { - return nil, errors.Errorf("cleanup policy %q: needs a condition or mostRecentVersions", p.Name) - } - - args := &artifactregistry.RepositoryCleanupPolicyArgs{ - Id: sdk.String(p.Name), - Action: sdk.String(action), - } - if c := p.Condition; c != nil { - tagState := strings.ToUpper(c.TagState) - switch tagState { - case "", "TAGGED", "UNTAGGED", "ANY": - default: - return nil, errors.Errorf("cleanup policy %q: tagState must be TAGGED, UNTAGGED or ANY, got %q", p.Name, c.TagState) - } - // A condition whose every field is empty is not a narrow policy, it - // is every version in the repository: tagState defaults to ANY - // server-side and nothing else constrains it. Config decoding is - // non-strict, so a mistyped key ("olderThen") produces exactly this. - if tagState == "" && c.OlderThan == "" && c.NewerThan == "" && - len(c.TagPrefixes)+len(c.PackageNamePrefixes)+len(c.VersionNamePrefixes) == 0 { - return nil, errors.Errorf("cleanup policy %q: condition has no criteria and would match every version; "+ - "note that an unrecognised key is silently ignored, so check for a typo", p.Name) - } - for _, d := range []struct{ field, value string }{ - {"olderThan", c.OlderThan}, - {"newerThan", c.NewerThan}, - } { - if d.value == "" { - continue - } - if err := validateCleanupDuration(p.Name, d.field, d.value); err != nil { - return nil, err - } - } - // Tag prefixes only mean anything against tagged versions; the API - // rejects the combination rather than ignoring it. - if len(c.TagPrefixes) > 0 && tagState != "TAGGED" { - got := c.TagState - if got == "" { - got = `"" (defaults to ANY)` - } else { - got = strconv.Quote(got) - } - return nil, errors.Errorf("cleanup policy %q: tagPrefixes requires tagState TAGGED, got %s", p.Name, got) - } - for _, pl := range []struct { - field string - vals []string - }{ - {"condition.tagPrefixes", c.TagPrefixes}, - {"condition.packageNamePrefixes", c.PackageNamePrefixes}, - {"condition.versionNamePrefixes", c.VersionNamePrefixes}, - } { - if hasEmptyPrefix(pl.vals) { - return nil, errors.Errorf("cleanup policy %q: %s contains an empty prefix, which matches everything", p.Name, pl.field) - } - } - // Only AGE, or restricting to untagged versions, separates "old" from - // "still running". Prefixes select which PACKAGES a policy covers, - // not which ages, so `DELETE` + packageNamePrefixes deletes every - // version of that package including the deployed digest, and for a - // Docker repository versionNamePrefixes is the digest itself. - // An earlier revision of this guard accepted prefixes as narrowing - // and named them in the error, which steered anyone blocked on the - // safe shape towards the destructive one. - narrowed := c.OlderThan != "" || tagState == "UNTAGGED" - if action == "DELETE" && !narrowed { - return nil, errors.Errorf("cleanup policy %q: a DELETE condition must set olderThan, or target tagState UNTAGGED; "+ - "prefixes select which packages are covered, not which ages, so a prefix alone deletes the running version", p.Name) - } - cond := &artifactregistry.RepositoryCleanupPolicyConditionArgs{} - if len(c.TagPrefixes) > 0 { - cond.TagPrefixes = sdk.ToStringArray(c.TagPrefixes) - } - if len(c.PackageNamePrefixes) > 0 { - cond.PackageNamePrefixes = sdk.ToStringArray(c.PackageNamePrefixes) - } - if len(c.VersionNamePrefixes) > 0 { - cond.VersionNamePrefixes = sdk.ToStringArray(c.VersionNamePrefixes) - } - if tagState != "" { - cond.TagState = sdk.StringPtr(tagState) - } - if c.OlderThan != "" { - cond.OlderThan = sdk.StringPtr(c.OlderThan) - } - if c.NewerThan != "" { - cond.NewerThan = sdk.StringPtr(c.NewerThan) - } - args.Condition = cond - } - if m := p.MostRecentVersions; m != nil { - // keepCount nil or 0 sends most_recent_versions {} with no count, - // which GCP treats as keeping nothing. Since KEEP is what outranks a - // companion DELETE, that reads as a safety net while being none. - if m.KeepCount == nil || *m.KeepCount < 1 { - return nil, errors.Errorf("cleanup policy %q: mostRecentVersions requires keepCount >= 1", p.Name) - } - if hasEmptyPrefix(m.PackageNamePrefixes) { - return nil, errors.Errorf("cleanup policy %q: mostRecentVersions.packageNamePrefixes contains an empty prefix, which matches everything", p.Name) - } - mrv := &artifactregistry.RepositoryCleanupPolicyMostRecentVersionsArgs{ - KeepCount: sdk.IntPtr(*m.KeepCount), - } - if len(m.PackageNamePrefixes) > 0 { - mrv.PackageNamePrefixes = sdk.ToStringArray(m.PackageNamePrefixes) - } - args.MostRecentVersions = mrv - } - out = append(out, args) - } - return out, nil -} diff --git a/pkg/clouds/pulumi/gcp/artifactregistry_cleanup_test.go b/pkg/clouds/pulumi/gcp/artifactregistry_cleanup_test.go deleted file mode 100644 index 4f2e01721..000000000 --- a/pkg/clouds/pulumi/gcp/artifactregistry_cleanup_test.go +++ /dev/null @@ -1,495 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "strings" - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" - - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - gcpsdk "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// arCapture records what ArtifactRegistry() actually asks Pulumi to register. -// -// Testing the helpers in isolation is what let the original change ship with -// its own fix untested: deleting the IgnoreChanges branch entirely — reverting -// to the behaviour that silently deleted retention policies — left the suite -// green, because nothing observed the ResourceOption that IS the fix. -// MockResourceArgs.RegisterRPC exposes both the inputs and the ignore list, so -// the property can be asserted where it lives. -type arCapture struct { - inputs resource.PropertyMap - ignoreChanges []string - seen bool -} - -func (c *arCapture) NewResource(a sdk.MockResourceArgs) (string, resource.PropertyMap, error) { - if a.TypeToken == "gcp:artifactregistry/repository:Repository" { - c.seen, c.inputs = true, a.Inputs - if a.RegisterRPC != nil { - c.ignoreChanges = a.RegisterRPC.IgnoreChanges - } - } - out := a.Inputs.Mappable() - out["email"] = "sa@test-project.iam.gserviceaccount.com" - out["privateKey"] = "x" - return a.Name + "-id", resource.NewPropertyMapFromMap(out), nil -} - -func (c *arCapture) Call(sdk.MockCallArgs) (resource.PropertyMap, error) { - return resource.PropertyMap{}, nil -} - -// renderARRepo runs the real provisioning function under mocks and returns what -// reached the Repository resource. err is the provisioning error, if any. -func renderARRepo(cfg *gcloud.ArtifactRegistryConfig) (*arCapture, error) { - setGlobalServicesAPIClient(newMockServicesAPIClient()) - defer resetGlobalServicesAPIClient() - - cfg.Docker = &gcloud.DockerConfig{ImmutableTags: lo.ToPtr(false)} - cfg.Location = "europe-west3" - cfg.ProjectId = "test-project" - - capture := &arCapture{} - err := sdk.RunErr(func(ctx *sdk.Context) error { - prov, err := gcpsdk.NewProvider(ctx, "test-provider", &gcpsdk.ProviderArgs{ - Project: sdk.String("test-project"), - }) - if err != nil { - return err - } - _, err = ArtifactRegistry(ctx, api.Stack{}, api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: "registry", - Type: gcloud.ResourceTypeArtifactRegistry, - Config: api.Config{Config: cfg}, - }, - StackParams: &api.StackParams{Environment: "test", StackName: "s"}, - }, pApi.ProvisionParams{Log: logger.New(), Provider: prov}) - return err - }, sdk.WithMocks("test", "test", capture)) - return capture, err -} - -// The fix itself: with no retention declared, SC must ask the engine to leave -// the field alone and must not send an empty value, which is what deleted the -// policies. Removing the IgnoreChanges branch fails this test. -func TestUndeclaredRetentionIsIgnoredNotEmptied(t *testing.T) { - RegisterTestingT(t) - - c, err := renderARRepo(&gcloud.ArtifactRegistryConfig{}) - Expect(err).To(BeNil()) - Expect(c.seen).To(BeTrue()) - - Expect(c.ignoreChanges).To(ConsistOf("cleanupPolicies", "cleanupPolicyDryRun"), - "undeclared retention must be ignored, including dryRun, or a provision can flip an "+ - "out-of-band dry-run repository into enforcing") - Expect(c.inputs).NotTo(HaveKey(resource.PropertyKey("cleanupPolicies")), - "sending an empty value is what deleted the policies") - Expect(c.inputs).NotTo(HaveKey(resource.PropertyKey("cleanupPolicyDryRun"))) -} - -// The ignore list must not leak onto the IAM policy, service accounts or keys, -// none of which has a cleanupPolicies property. -func TestIgnoreChangesIsScopedToTheRepository(t *testing.T) { - RegisterTestingT(t) - - var others []string - leakCheck := &arLeakCapture{other: &others} - setGlobalServicesAPIClient(newMockServicesAPIClient()) - defer resetGlobalServicesAPIClient() - cfg := &gcloud.ArtifactRegistryConfig{ - Docker: &gcloud.DockerConfig{ImmutableTags: lo.ToPtr(false)}, Location: "europe-west3", - } - cfg.ProjectId = "test-project" - Expect(sdk.RunErr(func(ctx *sdk.Context) error { - prov, err := gcpsdk.NewProvider(ctx, "p", &gcpsdk.ProviderArgs{Project: sdk.String("test-project")}) - if err != nil { - return err - } - _, err = ArtifactRegistry(ctx, api.Stack{}, api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{Name: "registry", Type: gcloud.ResourceTypeArtifactRegistry, Config: api.Config{Config: cfg}}, - StackParams: &api.StackParams{Environment: "test", StackName: "s"}, - }, pApi.ProvisionParams{Log: logger.New(), Provider: prov}) - return err - }, sdk.WithMocks("test", "test", leakCheck))).To(BeNil()) - - Expect(others).To(BeEmpty(), "cleanup ignore list leaked onto non-repository resources: %v", others) -} - -type arLeakCapture struct{ other *[]string } - -func (c *arLeakCapture) NewResource(a sdk.MockResourceArgs) (string, resource.PropertyMap, error) { - if a.TypeToken != "gcp:artifactregistry/repository:Repository" && a.RegisterRPC != nil { - for _, ic := range a.RegisterRPC.IgnoreChanges { - if ic == "cleanupPolicies" || ic == "cleanupPolicyDryRun" { - *c.other = append(*c.other, a.TypeToken+":"+ic) - } - } - } - out := a.Inputs.Mappable() - out["email"] = "sa@test-project.iam.gserviceaccount.com" - out["privateKey"] = "x" - return a.Name + "-id", resource.NewPropertyMapFromMap(out), nil -} -func (c *arLeakCapture) Call(sdk.MockCallArgs) (resource.PropertyMap, error) { - return resource.PropertyMap{}, nil -} - -// Declared retention must reach the resource intact. Every field asserted here -// survives a mutant that drops or hardcodes it. -func TestDeclaredRetentionReachesRepoArgs(t *testing.T) { - RegisterTestingT(t) - - c, err := renderARRepo(&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{ - { - Name: "delete-untagged-older-30d", Action: "delete", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "untagged", OlderThan: "2592000s"}, - }, - { - Name: "keep-most-recent-20", Action: "KEEP", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(20)}, - }, - { - Name: "delete-old-feature-tags", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{ - TagState: "TAGGED", OlderThan: "30d", NewerThan: "1s", - TagPrefixes: []string{"feature-"}, - PackageNamePrefixes: []string{"svc/"}, - VersionNamePrefixes: []string{"sha256:"}, - }, - }, - { - Name: "keep-api-20", Action: "KEEP", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{ - KeepCount: lo.ToPtr(20), PackageNamePrefixes: []string{"api/"}, - }, - }, - }, - CleanupPolicyDryRun: lo.ToPtr(true), - }) - Expect(err).To(BeNil()) - Expect(c.ignoreChanges).To(BeEmpty(), "declared retention is managed, so nothing may be ignored") - Expect(c.inputs["cleanupPolicyDryRun"].BoolValue()).To(BeTrue()) - - arr := c.inputs["cleanupPolicies"].ArrayValue() - Expect(arr).To(HaveLen(4)) - - p0 := arr[0].ObjectValue() - Expect(p0["id"].StringValue()).To(Equal("delete-untagged-older-30d")) - Expect(p0["action"].StringValue()).To(Equal("DELETE"), "action must be upper-cased for the API") - c0 := p0["condition"].ObjectValue() - Expect(c0["tagState"].StringValue()).To(Equal("UNTAGGED")) - Expect(c0["olderThan"].StringValue()).To(Equal("2592000s")) - // The absence side is the whole reason the builder uses len()>0 guards: - // sending an empty list is not the same as not constraining on it. - for _, k := range []resource.PropertyKey{"tagPrefixes", "packageNamePrefixes", "versionNamePrefixes", "newerThan"} { - Expect(c0).NotTo(HaveKey(k), "undeclared condition field %q must not be sent", k) - } - - p1 := arr[1].ObjectValue() - Expect(p1["action"].StringValue()).To(Equal("KEEP")) - Expect(p1["mostRecentVersions"].ObjectValue()["keepCount"].NumberValue()).To(BeEquivalentTo(20)) - Expect(p1["mostRecentVersions"].ObjectValue()).NotTo(HaveKey(resource.PropertyKey("packageNamePrefixes"))) - - // Every prefix and newerThan path must survive the trip. Losing a prefix - // silently widens a DELETE from one package to the whole repository. - c2 := arr[2].ObjectValue()["condition"].ObjectValue() - Expect(c2["tagPrefixes"].ArrayValue()[0].StringValue()).To(Equal("feature-")) - Expect(c2["packageNamePrefixes"].ArrayValue()[0].StringValue()).To(Equal("svc/")) - Expect(c2["versionNamePrefixes"].ArrayValue()[0].StringValue()).To(Equal("sha256:")) - Expect(c2["newerThan"].StringValue()).To(Equal("1s")) - Expect(c2["olderThan"].StringValue()).To(Equal("30d")) - - mrv3 := arr[3].ObjectValue()["mostRecentVersions"].ObjectValue() - Expect(mrv3["packageNamePrefixes"].ArrayValue()[0].StringValue()).To(Equal("api/")) -} - -// Unset dryRun must mean dry run. Enforcement destroys image layers no -// provision can restore; dry-run costs storage and is undone by one boolean. -func TestUnsetDryRunDefaultsToDryRun(t *testing.T) { - RegisterTestingT(t) - - c, err := renderARRepo(&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{{ - Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "UNTAGGED", OlderThan: "30d"}, - }}, - }) - Expect(err).To(BeNil()) - Expect(c.inputs["cleanupPolicyDryRun"].BoolValue()).To(BeTrue(), - "omitting cleanupPolicyDryRun must not enforce deletion on the first provision") -} - -func TestEnforcingRequiresAnExplicitFalse(t *testing.T) { - RegisterTestingT(t) - - c, err := renderARRepo(&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{{ - Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "UNTAGGED", OlderThan: "30d"}, - }}, - CleanupPolicyDryRun: lo.ToPtr(false), - }) - Expect(err).To(BeNil()) - Expect(c.inputs["cleanupPolicyDryRun"].BoolValue()).To(BeFalse()) -} - -// An explicitly empty list is "managed, and I want none" — the only way to -// remove retention through config. -func TestEmptyListIsManagedAndEmpty(t *testing.T) { - RegisterTestingT(t) - - c, err := renderARRepo(&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{}, - }) - Expect(err).To(BeNil()) - Expect(c.ignoreChanges).To(BeEmpty(), - "an explicit empty list means SC owns the field; ignoring it would make retention impossible to remove") - Expect(c.inputs).To(HaveKey(resource.PropertyKey("cleanupPolicies"))) - Expect(c.inputs["cleanupPolicies"].ArrayValue()).To(BeEmpty()) -} - -func TestDryRunWithoutPoliciesIsRejected(t *testing.T) { - RegisterTestingT(t) - - _, err := renderARRepo(&gcloud.ArtifactRegistryConfig{CleanupPolicyDryRun: lo.ToPtr(true)}) - Expect(err).NotTo(BeNil(), "config that silently does nothing must fail loudly") - Expect(err.Error()).To(ContainSubstring(`artifact registry "registry--test" in "test": cleanupPolicyDryRun is set`), - "the error must identify which registry and environment") -} - -func TestArtifactRegistryRejectsInvalidCleanupPolicy(t *testing.T) { - RegisterTestingT(t) - - _, err := renderARRepo(&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{{Name: "p", Action: "PURGE"}}, - }) - Expect(err).NotTo(BeNil()) - Expect(err.Error()).To(ContainSubstring(`invalid cleanup policies for artifact registry "registry--test" in "test"`), - "the error must name the registry and environment like every other failure in this function") -} - -func TestManagesCleanupPolicies(t *testing.T) { - RegisterTestingT(t) - - Expect((&gcloud.ArtifactRegistryConfig{}).ManagesCleanupPolicies()).To(BeFalse(), - "absent means not managed, so out-of-band retention is preserved rather than deleted") - Expect((&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{}, - }).ManagesCleanupPolicies()).To(BeTrue(), - "an explicit empty list is managed-and-empty, which is how retention is removed") - Expect((&gcloud.ArtifactRegistryConfig{ - CleanupPolicies: &[]gcloud.ArtifactRegistryCleanupPolicy{{Name: "x"}}, - }).ManagesCleanupPolicies()).To(BeTrue()) -} - -func TestCleanupPolicyArgsRejectsBadInput(t *testing.T) { - RegisterTestingT(t) - - ok := func(p gcloud.ArtifactRegistryCleanupPolicy) []gcloud.ArtifactRegistryCleanupPolicy { - return []gcloud.ArtifactRegistryCleanupPolicy{p} - } - for _, tc := range []struct { - name string - in []gcloud.ArtifactRegistryCleanupPolicy - wantErr string - why string - }{ - { - name: "empty condition matches every version", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{}}), - wantErr: "condition has no criteria", - why: "config decoding is non-strict, so a mistyped key produces exactly this and would delete the whole repository", - }, - { - name: "newerThan alone targets running images", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{NewerThan: "7d"}}), - wantErr: "must set olderThan, or target tagState UNTAGGED", - why: "deleting the most recently pushed images is the shortest path to CannotPull", - }, - { - name: "condition and mostRecentVersions together", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "KEEP", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "30d"}, - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(2)}}), - wantErr: "mutually exclusive", - why: "they are a union field; the API rejects it mid-provision", - }, - { - name: "tagPrefixes without TAGGED", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "UNTAGGED", TagPrefixes: []string{"v"}}}), - wantErr: "requires tagState TAGGED", - }, - { - name: "empty prefix matches everything", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "TAGGED", TagPrefixes: []string{""}}}), - wantErr: "empty prefix", - why: "an unresolved template placeholder collapses to an empty string", - }, - { - name: "keepCount unset protects nothing", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "KEEP", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{}}), - wantErr: "keepCount >= 1", - why: "GCP treats a missing count as zero, so the KEEP reads as protective while protecting nothing", - }, - { - name: "keepCount zero", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "KEEP", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(0)}}), - wantErr: "keepCount >= 1", - }, - { - name: "olderThan zero matches every version", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "0s"}}), - wantErr: "greater than zero", - }, - { - name: "garbage duration", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "30days"}}), - wantErr: "positive duration", - }, - { - name: "negative duration", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "-5s"}}), - wantErr: "positive duration", - }, - { - name: "newerThan is validated too", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{NewerThan: "abcs", TagPrefixes: []string{"v"}, TagState: "TAGGED"}}), - wantErr: "positive duration", - why: "newerThan must be validated too, not just olderThan", - }, - { - name: "unknown tagState", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{TagState: "RELEASED", OlderThan: "30d"}}), - wantErr: "tagState must be", - why: "a typo'd tagState defaults to ANY server-side, widening the delete set", - }, - { - name: "keep with an empty package prefix", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "KEEP", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{ - KeepCount: lo.ToPtr(1), PackageNamePrefixes: []string{""}}}), - wantErr: "mostRecentVersions.packageNamePrefixes contains an empty prefix", - why: "a KEEP matching every package silently changes what the companion DELETE spares", - }, - { - name: "prefix alone does not narrow a DELETE by age", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{PackageNamePrefixes: []string{"api"}}}), - wantErr: "must set olderThan, or target tagState UNTAGGED", - why: "this deletes every version of that package including the deployed digest", - }, - { - name: "digest prefix alone covers the whole docker repository", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{VersionNamePrefixes: []string{"sha256:"}}}), - wantErr: "must set olderThan, or target tagState UNTAGGED", - why: "for a Docker repo the version name IS the digest", - }, - { - name: "policy name over the provider limit", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: strings.Repeat("x", 128), Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "30d"}}), - wantErr: "must be under 128 characters", - }, - { - name: "unknown action", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "PURGE"}), - wantErr: "action must be DELETE or KEEP", - }, - { - name: "mostRecentVersions under DELETE", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE", - MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(5)}}), - wantErr: "only valid with a KEEP action", - }, - { - name: "neither condition nor mostRecentVersions", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Name: "p", Action: "DELETE"}), - wantErr: "needs a condition or mostRecentVersions", - }, - { - name: "missing name", - in: ok(gcloud.ArtifactRegistryCleanupPolicy{Action: "KEEP", MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(1)}}), - wantErr: "missing a name", - }, - { - name: "duplicate names", - in: []gcloud.ArtifactRegistryCleanupPolicy{ - {Name: "dup", Action: "DELETE", Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "30d"}}, - {Name: "dup", Action: "KEEP", MostRecentVersions: &gcloud.ArtifactRegistryCleanupMostRecentVersions{KeepCount: lo.ToPtr(1)}}, - }, - wantErr: "duplicate cleanup policy name", - why: "Artifact Registry keys policies by name, so a duplicate silently drops one", - }, - } { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - _, err := cleanupPolicyArgs(tc.in) - Expect(err).NotTo(BeNil(), tc.why) - Expect(err.Error()).To(ContainSubstring(tc.wantErr)) - }) - } -} - -func TestCleanupPolicyArgsRejectsTooManyPolicies(t *testing.T) { - RegisterTestingT(t) - - many := make([]gcloud.ArtifactRegistryCleanupPolicy, 0, maxCleanupPolicies+1) - for i := 0; i <= maxCleanupPolicies; i++ { - many = append(many, gcloud.ArtifactRegistryCleanupPolicy{ - Name: string(rune('a' + i)), Action: "DELETE", - Condition: &gcloud.ArtifactRegistryCleanupPolicyCondition{OlderThan: "30d"}, - }) - } - _, err := cleanupPolicyArgs(many) - Expect(err).NotTo(BeNil()) - Expect(err.Error()).To(ContainSubstring("at most 10 cleanup policies")) -} - -// Accepted forms are pinned to evidence, not to the regex under test. -// -// "30d"/"7d" appear verbatim in terraform-provider-google's generated -// acceptance test for google_artifact_registry_repository (older_than = "30d", -// newer_than = "7d"), and the REST API reports the same field in seconds. So -// both must be accepted. The quantity must be an integer, because the provider -// expands the m/h/d forms with strconv.Atoi: "1.5h" would pass validation here -// and then fail at resource registration. -func TestCleanupDurationAcceptsTheFormsTheProviderAccepts(t *testing.T) { - RegisterTestingT(t) - - for _, d := range []string{"30d", "7d", "2592000s", "720h", "45m", "1s"} { - Expect(validateCleanupDuration("p", "olderThan", d)).To(BeNil(), "%q is used by the provider and must be accepted", d) - } - for _, d := range []string{ - "1.5h", "0.5d", // fractional: the provider expands with strconv.Atoi - "30days", "abcs", "s", "-5s", "30 s", "", // malformed - "0s", "0d", // zero matches every version - } { - Expect(validateCleanupDuration("p", "olderThan", d)).NotTo(BeNil(), "%q must be rejected", d) - } -} diff --git a/pkg/clouds/pulumi/gcp/bucket.go b/pkg/clouds/pulumi/gcp/bucket.go deleted file mode 100644 index d6c3e3397..000000000 --- a/pkg/clouds/pulumi/gcp/bucket.go +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/util" -) - -func PrivateBucket(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeBucket { - return nil, errors.Errorf("unsupported bucket type %q", input.Descriptor.Type) - } - - bucketCfg, ok := input.Descriptor.Config.Config.(*gcloud.GcpBucket) - if !ok { - return nil, errors.Errorf("failed to convert bucket config for %q", input.Descriptor.Type) - } - - // Handle resource adoption - exit early if adopting - if bucketCfg.Adopt { - return AdoptPrivateBucket(ctx, stack, input, params) - } - - bucketName := input.ToResName(lo.If(bucketCfg.GetBucketName() == "", input.Descriptor.Name).Else(bucketCfg.GetBucketName())) - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - - params.Log.Info(ctx.Context(), "creating GCS bucket %q with S3 interoperability", bucketName) - - // Create the bucket - bucket, err := storage.NewBucket(ctx, bucketName, &storage.BucketArgs{ - Name: sdk.String(bucketName), - Location: sdk.String(bucketCfg.Location), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision bucket %q", bucketName) - } - - // Export bucket name and location - ctx.Export(toBucketNameExport(bucketName), bucket.Name) - ctx.Export(toBucketLocationExport(bucketName), bucket.Location) - - params.Log.Info(ctx.Context(), "creating service account for bucket %q", bucketName) - - // Create a service account for S3-compatible access - saName := fmt.Sprintf("%s-sa", bucketName) - // Use the same sanitization logic as the service_account.go helper - sanitizedName := strings.ReplaceAll(saName, "_", "-") - saAccountId := strings.ReplaceAll(util.TrimStringMiddle(sanitizedName, 28, "-"), "--", "-") - - sa, err := serviceaccount.NewAccount(ctx, saName, &serviceaccount.AccountArgs{ - AccountId: sdk.String(saAccountId), - DisplayName: sdk.String(fmt.Sprintf("Service Account for %s", bucketName)), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create service account for bucket %q", bucketName) - } - - params.Log.Info(ctx.Context(), "granting storage permissions to service account for bucket %q", bucketName) - - // Grant storage.objectAdmin role to the service account on the bucket - _, err = storage.NewBucketIAMMember(ctx, fmt.Sprintf("%s-iam-object", bucketName), &storage.BucketIAMMemberArgs{ - Bucket: bucket.Name, - Role: sdk.String("roles/storage.objectAdmin"), - Member: sa.Email.ApplyT(func(email string) string { - return fmt.Sprintf("serviceAccount:%s", email) - }).(sdk.StringOutput), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to grant object permissions to service account for bucket %q", bucketName) - } - - // Grant storage.legacyBucketWriter role for S3-compatible bucket operations (includes read and write) - _, err = storage.NewBucketIAMMember(ctx, fmt.Sprintf("%s-iam-bucket", bucketName), &storage.BucketIAMMemberArgs{ - Bucket: bucket.Name, - Role: sdk.String("roles/storage.legacyBucketWriter"), - Member: sa.Email.ApplyT(func(email string) string { - return fmt.Sprintf("serviceAccount:%s", email) - }).(sdk.StringOutput), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to grant bucket permissions to service account for bucket %q", bucketName) - } - - params.Log.Info(ctx.Context(), "generating HMAC keys for S3-compatible access to bucket %q", bucketName) - - // Generate HMAC keys for S3-compatible access - hmacKey, err := storage.NewHmacKey(ctx, fmt.Sprintf("%s-hmac", bucketName), &storage.HmacKeyArgs{ - ServiceAccountEmail: sa.Email, - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create HMAC key for bucket %q", bucketName) - } - - // Export HMAC credentials (Access Key ID and Secret Key) - ctx.Export(toBucketHmacAccessKeyIdExport(bucketName), hmacKey.AccessId) - ctx.Export(toBucketHmacSecretKeyExport(bucketName), sdk.ToSecret(hmacKey.Secret)) - - params.Log.Info(ctx.Context(), "successfully provisioned GCS bucket %q with S3 interoperability", bucketName) - - return &api.ResourceOutput{Ref: bucket}, nil -} - -func BucketComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, collector pApi.ComputeContextCollector, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if params.ParentStack == nil { - return nil, errors.Errorf("parent stack must not be nil for compute processor for %q", stack.Name) - } - parentStackName := params.ParentStack.StackName - - bucketCfg, ok := input.Descriptor.Config.Config.(*gcloud.GcpBucket) - if !ok { - return nil, errors.Errorf("failed to convert bucket config for %q", input.Descriptor.Type) - } - - bucketName := input.ToResName(lo.If(bucketCfg.Name == "", input.Descriptor.Name).Else(bucketCfg.Name)) - - // Create a StackReference to the parent stack - suffix := lo.If(params.ParentStack.DependsOnResource != nil, "--"+lo.FromPtr(params.ParentStack.DependsOnResource).Name).Else("") - params.Log.Info(ctx.Context(), "getting parent's (%q) outputs for gcp bucket %q (%q)", params.ParentStack.FullReference, bucketName, suffix) - parentRef, err := sdk.NewStackReference(ctx, fmt.Sprintf("%s--%s--%s%s--gcs-bucket-ref", stack.Name, params.ParentStack.StackName, input.Descriptor.Name, suffix), &sdk.StackReferenceArgs{ - Name: sdk.String(params.ParentStack.FullReference).ToStringOutput(), - }) - if err != nil { - return nil, err - } - - // Get bucket name from parent stack - bucketNameExport := toBucketNameExport(bucketName) - resBucketName, err := pApi.GetParentOutput(parentRef, bucketNameExport, params.ParentStack.FullReference, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get bucket name from parent stack for %q (%q)", stack.Name, bucketNameExport) - } else if resBucketName == "" { - return nil, errors.Errorf("bucket name is empty for %q (%q)", stack.Name, bucketNameExport) - } - - // Get HMAC access key ID from parent stack - accessKeyIdExport := toBucketHmacAccessKeyIdExport(bucketName) - resAccessKeyId, err := pApi.GetParentOutput(parentRef, accessKeyIdExport, params.ParentStack.FullReference, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get HMAC access key ID from parent stack for %q (%q)", stack.Name, accessKeyIdExport) - } else if resAccessKeyId == "" { - return nil, errors.Errorf("HMAC access key ID is empty for %q (%q)", stack.Name, accessKeyIdExport) - } - - // Get HMAC secret key from parent stack - secretKeyExport := toBucketHmacSecretKeyExport(bucketName) - resSecretKey, err := pApi.GetParentOutput(parentRef, secretKeyExport, params.ParentStack.FullReference, true) - if err != nil { - return nil, errors.Wrapf(err, "failed to get HMAC secret key from parent stack for %q (%q)", stack.Name, secretKeyExport) - } else if resSecretKey == "" { - return nil, errors.Errorf("HMAC secret key is empty for %q (%q)", stack.Name, secretKeyExport) - } - - // Get bucket location from parent stack - locationExport := toBucketLocationExport(bucketName) - resBucketLocation, err := pApi.GetParentOutput(parentRef, locationExport, params.ParentStack.FullReference, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get bucket location from parent stack for %q (%q)", stack.Name, locationExport) - } else if resBucketLocation == "" { - return nil, errors.Errorf("bucket location is empty for %q (%q)", stack.Name, locationExport) - } - - collector.AddOutput(ctx, parentRef.Name.ApplyT(func(refName any) any { - // S3-compatible endpoint for GCS - use the correct S3 interoperability endpoint - // GCS S3-compatible API uses storage.googleapis.com with path-style access - s3Endpoint := "https://storage.googleapis.com" - - // Add bucket-specific environment variables - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("GCS_%s_BUCKET", bucketName)), resBucketName, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("GCS_%s_LOCATION", bucketName)), resBucketLocation, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("GCS_%s_ACCESS_KEY", bucketName)), resAccessKeyId, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("GCS_%s_SECRET_KEY", bucketName)), resSecretKey, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("GCS_%s_ENDPOINT", bucketName)), s3Endpoint, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - - // Add S3-compatible environment variables for applications expecting S3 - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("S3_%s_BUCKET", bucketName)), resBucketName, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("S3_%s_REGION", bucketName)), resBucketLocation, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("S3_%s_ACCESS_KEY", bucketName)), resAccessKeyId, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("S3_%s_SECRET_KEY", bucketName)), resSecretKey, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("S3_%s_ENDPOINT", bucketName)), s3Endpoint, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - - // Add generic environment variables (without bucket name prefix) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("GCS_BUCKET"), resBucketName, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("GCS_LOCATION"), resBucketLocation, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("GCS_ACCESS_KEY"), resAccessKeyId, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("GCS_SECRET_KEY"), resSecretKey, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("GCS_ENDPOINT"), s3Endpoint, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - - // Add AWS SDK compatible environment variables - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("AWS_ACCESS_KEY_ID"), resAccessKeyId, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("AWS_SECRET_ACCESS_KEY"), resSecretKey, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("S3_ENDPOINT"), s3Endpoint, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("S3_BUCKET"), resBucketName, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("S3_REGION"), resBucketLocation, - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - - // Add AWS CLI specific configuration for GCS compatibility - // Use "auto" region for GCS S3-compatible API signature calculation - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_DEFAULT_REGION"), "auto", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - // Force AWS CLI to use signature version 4 for GCS compatibility - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_S3_SIGNATURE_VERSION"), "s3v4", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - // Force path-style URLs for GCS compatibility (required for some bucket names) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_S3_ADDRESSING_STYLE"), "path", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - // Disable payload signing for GCS compatibility (GCS uses x-goog-content-sha256 instead of x-amz-content-sha256) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_S3_PAYLOAD_SIGNING_ENABLED"), "false", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - // Fix AWS CLI checksum behavior for GCS compatibility (AWS changed defaults in 2024/2025) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_REQUEST_CHECKSUM_CALCULATION"), "when_required", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("AWS_RESPONSE_CHECKSUM_VALIDATION"), "when_required", - input.Descriptor.Type, input.Descriptor.Name, parentStackName) - - // Add resource template extension for programmatic access - collector.AddResourceTplExtension(input.Descriptor.Name, map[string]string{ - "bucket": resBucketName, - "location": resBucketLocation, - "access-key": resAccessKeyId, - "secret-key": resSecretKey, - "endpoint": s3Endpoint, - }) - - return nil - })) - - return &api.ResourceOutput{ - Ref: parentStackName, - }, nil -} - -// Helper functions for export names -func toBucketNameExport(bucketName string) string { - return fmt.Sprintf("%s-bucket-name", bucketName) -} - -func toBucketLocationExport(bucketName string) string { - return fmt.Sprintf("%s-bucket-location", bucketName) -} - -func toBucketHmacAccessKeyIdExport(bucketName string) string { - return fmt.Sprintf("%s-hmac-access-key-id", bucketName) -} - -func toBucketHmacSecretKeyExport(bucketName string) string { - return fmt.Sprintf("%s-hmac-secret-key", bucketName) -} diff --git a/pkg/clouds/pulumi/gcp/bucket_uploader.go b/pkg/clouds/pulumi/gcp/bucket_uploader.go deleted file mode 100644 index 4d5702e75..000000000 --- a/pkg/clouds/pulumi/gcp/bucket_uploader.go +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "crypto/md5" - "encoding/hex" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path" - "path/filepath" - "strings" - - gcpStorage "cloud.google.com/go/storage" - "go.uber.org/atomic" - - "github.com/MShekow/directory-checksum/directory_checksum" - "github.com/pkg/errors" - "github.com/spf13/afero" - - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api/logger/color" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -type GcpBucketUploader struct { - sdk.ResourceState -} - -type BucketUploaderArgs struct { - bucketName sdk.StringInput - syncDir string - gcpCreds string - params pApi.ProvisionParams -} - -func NewGcpBucketUploader(ctx *sdk.Context, name string, args BucketUploaderArgs, opts ...sdk.ResourceOption) (*GcpBucketUploader, error) { - resource := &GcpBucketUploader{} - err := ctx.RegisterComponentResource("simple-container.com:module:GcpBucketUploader", name, resource, opts...) - if err != nil { - return nil, err - } - - syncOutput := args.bucketName.ToStringOutput().ApplyT(func(bucketName string) (any, error) { - var checksum string - if dir, err := directory_checksum.ScanDirectory(args.syncDir, afero.NewOsFs()); err != nil { - return nil, errors.Wrapf(err, "failed to scan directory %q", args.syncDir) - } else if checksums, err := dir.ComputeDirectoryChecksums(); err != nil { - return nil, errors.Wrapf(err, "failed to calculate directory checksums") - } else { - sum := md5.Sum([]byte(checksums)) - checksum = hex.EncodeToString(sum[:]) - } - - if ctx.DryRun() { - return checksum, nil - } - _, err := copyAllFilesToBucket(ctx.Context(), bucketName, args.syncDir, args.gcpCreds, args.params) - if err != nil { - return nil, errors.Wrapf(err, "failed to sync files to GCP bucket %q", args.bucketName) - } - return checksum, nil - }) - - // Complete the component resource creation - err = ctx.RegisterResourceOutputs(resource, sdk.Map{ - "dirChecksum": syncOutput, - }) - if err != nil { - return nil, err - } - return resource, nil -} - -func copyAllFilesToBucket(ctx context.Context, bucketName string, syncDir, gcpCreds string, params pApi.ProvisionParams) (int64, error) { - // WithCredentialsJSON is SA1019-deprecated by Google as a "potential - // security risk" but provides no equivalent in-memory replacement — - // the recommended alternatives all require writing credentials to disk - // or to a SecretManager round-trip, neither of which fits this flow. - client, err := gcpStorage.NewClient(ctx, clientOptions(gcpCreds)...) - if err != nil { - return 0, errors.Wrapf(err, "failed to initialize gcp client") - } - defer func(client *gcpStorage.Client) { - _ = client.Close() - }(client) - bucketRef := client.Bucket(bucketName) - totalBytes := atomic.NewInt64(0) - params.Log.Info(ctx, "scanning directory %s...", syncDir) - err = filepath.Walk(syncDir, func(filePath string, info fs.FileInfo, walkErr error) error { - if info == nil || info.IsDir() { - return nil - } - if walkErr != nil { - params.Log.Error(ctx, "%s", color.RedFmt("failed to walk through path %q: %v", filePath, walkErr)) - return nil - } - copyPath, err := filepath.Rel(syncDir, filePath) - if err != nil { - return err - } - params.Log.Info(ctx, "%s", color.YellowFmt("uploading file %q to gs://%s/%s...", filePath, bucketName, copyPath)) - f, err := os.Open(path.Join(syncDir, copyPath)) - if err != nil { - params.Log.Error(ctx, "%s", color.RedFmt("Error uploading %s: %v", filePath, err)) - return fmt.Errorf("os.Open: %w", err) - } - defer func(f *os.File) { - _ = f.Close() - }(f) - object := bucketRef.Object(copyPath) - wc := object.NewWriter(ctx) - bytesCopied, err := io.Copy(wc, f) - if err != nil { - params.Log.Error(ctx, "%s", color.RedFmt("Error uploading %s: %v", filePath, err)) - return fmt.Errorf("io.Copy: %w", err) - } - totalBytes.Add(bytesCopied) - if err := wc.Close(); err != nil { - params.Log.Error(ctx, "%s", color.RedFmt("Error closing bucket object %s: %v", filePath, err)) - return fmt.Errorf("Writer.Close: %w", err) - } - var contentType string - if attrs, err := updateContentType(ctx, object, filePath); err != nil { - return errors.Wrapf(err, "failed to update content type for %s", filePath) - } else { - contentType = attrs.ContentType - } - params.Log.Info(ctx, "%s", color.GreenFmt("DONE gs://%s/%s (MIME: %s) (%d bytes)", bucketName, copyPath, contentType, bytesCopied)) - return nil - }) - return totalBytes.Load(), err -} - -func updateContentType(ctx context.Context, object *gcpStorage.ObjectHandle, filePath string) (*gcpStorage.ObjectAttrs, error) { - contentType := "" - ext := filepath.Ext(strings.ToLower(filePath)) - if strings.HasSuffix(filepath.Base(filePath), ".min.css.map") { - ext = ".css" - } else if strings.HasSuffix(filepath.Base(filePath), ".min.js") { - ext = ".js" - } - switch ext { - case ".css": - contentType = "text/css" - case ".min.js": - case ".js": - contentType = "text/javascript" - case ".png": - contentType = "image/png" - case ".apng": - contentType = "image/apng" - case ".avif": - contentType = "image/avig" - case ".gif": - contentType = "image/gif" - case ".jpeg": - case ".jpg": - contentType = "image/jpeg" - case ".svg": - contentType = "image/svg+xml" - case ".webp": - contentType = "image/webp" - } - if contentType == "" { - if filebytes, err := os.ReadFile(filePath); err != nil { - return nil, errors.Wrapf(err, "failed to read file %s", filePath) - } else { - contentType = http.DetectContentType(filebytes) - } - } - if attrs, err := object.Update(ctx, gcpStorage.ObjectAttrsToUpdate{ - ContentType: contentType, - }); err != nil { - return nil, errors.Wrapf(err, "failed to update content type for %s", filePath) - } else { - return attrs, nil - } -} diff --git a/pkg/clouds/pulumi/gcp/cloudrun.go b/pkg/clouds/pulumi/gcp/cloudrun.go deleted file mode 100644 index 241c0eda3..000000000 --- a/pkg/clouds/pulumi/gcp/cloudrun.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func Cloudrun(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.TemplateTypeGcpCloudrun { - return nil, errors.Errorf("unsupported template type %q", input.Descriptor.Type) - } - - for _, dep := range params.ComputeContext.Dependencies() { - dep.URN().ApplyT(func(urn string) (any, error) { - fmt.Println(urn) - return nil, nil - }) - } - cloudrunInput, ok := input.Descriptor.Config.Config.(*gcloud.CloudRunInput) - if !ok { - return nil, errors.Errorf("failed to convert cloudrun config for %q", input.Descriptor.Type) - } - params.Log.Debug(ctx.Context(), "configure cloud run for %+v", cloudrunInput) - - params.Log.Error(ctx.Context(), "not implemented for %q", input.Descriptor.Type) - return &api.ResourceOutput{Ref: nil}, nil -} diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go deleted file mode 100644 index 066db227c..000000000 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go +++ /dev/null @@ -1,257 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/util" -) - -type PostgresDBInstanceArgs struct { - Project string - InstanceName string - Region string -} - -type CloudSQLAccount struct { - ServiceAccount *serviceaccount.Account - ServiceAccountKey *serviceaccount.Key - CredentialsSecrets sdk.StringMap -} - -func NewCloudSQLAccount(ctx *sdk.Context, name string, dbInstance PostgresDBInstanceArgs, provider *gcp.Provider, opts ...sdk.ResourceOption) (*CloudSQLAccount, error) { - accountName := util.SanitizeGCPServiceAccountName(name) - - opts = append(opts, sdk.Provider(provider)) - serviceAccount, err := serviceaccount.NewAccount(ctx, accountName, &serviceaccount.AccountArgs{ - AccountId: sdk.String(accountName), - Project: sdk.String(dbInstance.Project), - Description: sdk.String(fmt.Sprintf("Service account to access database %s", dbInstance.InstanceName)), - DisplayName: sdk.String(fmt.Sprintf("%s-service-account", name)), - }, opts...) - if err != nil { - return nil, err - } - - opts = append(opts, sdk.Parent(serviceAccount)) - serviceAccountKey, err := serviceaccount.NewKey(ctx, fmt.Sprintf("%s-key", accountName), &serviceaccount.KeyArgs{ - ServiceAccountId: serviceAccount.AccountId, - }, opts...) - if err != nil { - return nil, err - } - - _, err = projects.NewIAMMember(ctx, fmt.Sprintf("%s-iam", accountName), &projects.IAMMemberArgs{ - Project: sdk.String(dbInstance.Project), - Member: serviceAccount.Member, - Role: sdk.String("roles/cloudsql.client"), - }, opts...) - if err != nil { - return nil, err - } - - credentialsSecrets := sdk.StringMap{ - "credentials.json": serviceAccountKey.PrivateKey, - } - - return &CloudSQLAccount{ - ServiceAccount: serviceAccount, - ServiceAccountKey: serviceAccountKey, - CredentialsSecrets: credentialsSecrets, - }, nil -} - -type CloudSQLProxyArgs struct { - Name string - DBInstance PostgresDBInstanceArgs - GcpProvider *gcp.Provider - KubeProvider *sdkK8s.Provider - Metadata *metav1.ObjectMetaArgs - // PrivateIp makes the proxy dial the instance's private IP (--private-ip) - // instead of the public endpoint. - PrivateIp bool -} - -type CloudSQLProxy struct { - ProxyContainer sdk.Output - Account *CloudSQLAccount - Name string - SqlProxySecret *v1.Secret -} - -func NewCloudsqlProxy(ctx *sdk.Context, args CloudSQLProxyArgs, opts ...sdk.ResourceOption) (*CloudSQLProxy, error) { - account, err := NewCloudSQLAccount(ctx, args.Name, args.DBInstance, args.GcpProvider, opts...) - if err != nil { - return nil, err - } - - opts = append(opts, sdk.Provider(args.KubeProvider)) - // args.Metadata.Namespace is the live Namespace.Metadata.Name() Output, threaded - // through by compute_proc.go (preprocessor uses kubeArgs.NamespaceNameOutput, - // postprocessor uses sc.Namespace). That means this Secret automatically lands - // in the same k8s namespace as the consuming pod under both fresh deploys (isolated - // name) and migrated stacks where #255's IgnoreChanges("metadata.name") keeps the - // Namespace parent-shared — no IgnoreChanges needed here. - secretName := util.SanitizeK8sResourceName(args.Name + "-creds") - sqlProxySecret, err := v1.NewSecret(ctx, secretName, &v1.SecretArgs{ - Metadata: args.Metadata, - Data: account.CredentialsSecrets, - }, opts...) - if err != nil { - return nil, err - } - - proxyContainer := cloudsqlProxyContainer(sqlProxySecret, args.DBInstance, args.PrivateIp) - - return &CloudSQLProxy{ - ProxyContainer: proxyContainer, - Account: account, - Name: args.Name, - SqlProxySecret: sqlProxySecret, - }, nil -} - -// cloudSQLProxyHealthPort is the port the proxy's built-in health-check HTTP server -// listens on (--health-check). Its /startup endpoint backs the startup probe that -// gates the app containers. -const cloudSQLProxyHealthPort = 9090 - -func cloudsqlProxyContainer(credsSecret *v1.Secret, dbInstance PostgresDBInstanceArgs, privateIp bool) sdk.Output { - return sdk.All(credsSecret.Metadata.Name(), dbInstance.Project, dbInstance.Region, dbInstance.InstanceName).ApplyT(func(all []interface{}) v1.ContainerArgs { - secretName := all[0].(*string) - project := all[1].(string) - region := all[2].(string) - instanceName := all[3].(string) - return cloudsqlProxyContainerArgs(lo.FromPtr(secretName), project, region, instanceName, privateIp) - }).(v1.ContainerOutput) -} - -// cloudsqlProxyCommandArgs returns the proxy entrypoint, with its health server enabled -// for the native-sidecar probes. -func cloudsqlProxyCommandArgs(project, region, instanceName string, privateIp bool) (string, []string) { - args := []string{ - "--address", - "0.0.0.0", - "--structured-logs", - } - if privateIp { - args = append(args, "--private-ip") - } - args = append(args, - "--credentials-file=/var/run/secrets/cloudsql/credentials.json", - fmt.Sprintf("%s:%s:%s", project, region, instanceName), - // The kubelet SIGTERMs a native sidecar once the main containers exit; - // exit 0 keeps a finished db-user-init Job from showing a failed container. - "--exit-zero-on-sigterm", - "--http-address=0.0.0.0", - fmt.Sprintf("--http-port=%d", cloudSQLProxyHealthPort), - "--health-check", - ) - return "/cloud-sql-proxy", args -} - -// cloudsqlProxyContainerArgs builds the proxy as a native sidecar (RestartPolicy: Always -// + startup probe), so the containers that use it don't start before it is listening. The -// same container serves the app pods and the db-user-init Job: a native sidecar does not -// keep a Job from completing, the kubelet stops it once the main container exits. -func cloudsqlProxyContainerArgs(secretName, project, region, instanceName string, privateIp bool) v1.ContainerArgs { - command, args := cloudsqlProxyCommandArgs(project, region, instanceName, privateIp) - - container := v1.ContainerArgs{ - Name: sdk.String("cloudsql-proxy"), - Image: sdk.String("gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.8.1-alpine"), - Command: sdk.StringArray{sdk.String(command)}, - Args: sdk.ToStringArray(args), - SecurityContext: &v1.SecurityContextArgs{ - RunAsNonRoot: sdk.Bool(true), - }, - Resources: &v1.ResourceRequirementsArgs{ - Limits: sdk.StringMap{ - "memory": sdk.String("300Mi"), - "cpu": sdk.String("300m"), - }, - Requests: sdk.StringMap{ - "memory": sdk.String("200Mi"), - // 100m (was 50m): at 50m the proxy's health server starves under - // node CPU pressure and readiness probes time out. - "cpu": sdk.String("100m"), - }, - }, - VolumeMounts: v1.VolumeMountArray{ - &v1.VolumeMountArgs{ - Name: sdk.String(secretName), - MountPath: sdk.String("/var/run/secrets/cloudsql"), - ReadOnly: sdk.Bool(true), - }, - }, - } - - container.RestartPolicy = sdk.String("Always") - container.Ports = v1.ContainerPortArray{ - &v1.ContainerPortArgs{ - Name: sdk.String("csql-hc"), - ContainerPort: sdk.Int(cloudSQLProxyHealthPort), - }, - } - container.StartupProbe = &v1.ProbeArgs{ - HttpGet: v1.HTTPGetActionArgs{ - Path: sdk.String("/startup"), - Port: sdk.String("csql-hc"), - }, - // Startup keeps the tight 3s timeout deliberately: nothing is connected - // yet, and the 30-failure budget already absorbs slow cold starts. - PeriodSeconds: sdk.IntPtr(2), - TimeoutSeconds: sdk.IntPtr(3), - FailureThreshold: sdk.IntPtr(30), - } - // Sidecar readiness GATES POD READINESS (KEP-753): three consecutive - // failures drop the whole pod from Service endpoints. timeout 10s (was 3s): - // with a 50m-CPU-request sidecar the health server starves under node - // pressure (observed at the previous 50m request) and 3s timeouts flap - // pods out of rotation; combined with - // Autopilot node consolidation this can leave a Service with zero ready - // pods. - // /readiness stays (unlike /liveness it verifies instance connectivity - // and connection capacity; /liveness returns 200 whenever the health - // server responds at all). - container.ReadinessProbe = &v1.ProbeArgs{ - HttpGet: v1.HTTPGetActionArgs{ - Path: sdk.String("/readiness"), - Port: sdk.String("csql-hc"), - }, - PeriodSeconds: sdk.IntPtr(10), - TimeoutSeconds: sdk.IntPtr(10), - FailureThreshold: sdk.IntPtr(3), - } - // Liveness recovers a proxy that passed startup and then hung (deadlock / - // pool exhaustion / partial-OOM), where the process stays alive but app DB - // calls to localhost:5432 fail. /liveness is already served by --health-check. - // kubelet defers liveness until the startup probe succeeds, so no InitialDelay is needed. - container.LivenessProbe = &v1.ProbeArgs{ - HttpGet: v1.HTTPGetActionArgs{ - Path: sdk.String("/liveness"), - Port: sdk.String("csql-hc"), - }, - PeriodSeconds: sdk.IntPtr(10), - // Same 10s budget as readiness: /liveness is served by the same health - // server, and under the exact starvation the readiness change tolerates, - // a 3s liveness timeout would RESTART the sidecar (dropping every live - // DB connection) — strictly worse than an endpoint drop. A genuinely - // hung process still fails a 10s timeout, so deadlock recovery holds. - TimeoutSeconds: sdk.IntPtr(10), - FailureThreshold: sdk.IntPtr(3), - } - return container -} diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go deleted file mode 100644 index 3289c6b17..000000000 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" -) - -// The proxy must expose its built-in health server so a startup probe can gate the -// containers that use it -- otherwise the app dials localhost:5432 before the -// proxy is listening and logs connection-refused on every pod (re)start. -func TestCloudsqlProxyCommandArgs_EnablesHealthCheck(t *testing.T) { - cmd, args := cloudsqlProxyCommandArgs("proj", "europe-north1", "inst", false) - - assert.Equal(t, "/cloud-sql-proxy", cmd, "the proxy runs the binary directly (no shell wrapper)") - assert.Contains(t, args, "--health-check", "the proxy must expose its health server for the startup probe") - assert.Contains(t, args, "--http-address=0.0.0.0", "health server must bind 0.0.0.0 so kubelet probes can reach it") - assert.Contains(t, args, "--http-port=9090") - assert.Contains(t, args, "proj:europe-north1:inst", "instance connection name must be preserved") - assert.NotContains(t, args, "-c", "the proxy must not be wrapped in a self-killing shell") - // Base flags moved from the old inline builder must survive the refactor. --credentials-file - // is the only flag that authenticates the proxy; it must equal the VolumeMount path + - // /credentials.json, so a future mount-path change forces this flag to move in lockstep. - assert.Contains(t, args, "--address") - assert.Contains(t, args, "0.0.0.0") - assert.Contains(t, args, "--credentials-file=/var/run/secrets/cloudsql/credentials.json", - "credentials flag must match the mounted secret path") - assert.Contains(t, args, "--exit-zero-on-sigterm", - "the kubelet SIGTERMs the sidecar when a Job's main container exits; it must not report a failure") -} - -// --private-ip must be emitted only when requested, so a private-IP-only instance is reachable while the default (public) -// path is unchanged. -func TestCloudsqlProxyCommandArgs_PrivateIp(t *testing.T) { - _, pub := cloudsqlProxyCommandArgs("proj", "reg", "inst", false) - assert.NotContains(t, pub, "--private-ip", "default (public) path must not pass --private-ip") - - _, priv := cloudsqlProxyCommandArgs("proj", "reg", "inst", true) - assert.Contains(t, priv, "--private-ip", "private path must pass --private-ip") - assert.Contains(t, priv, "proj:reg:inst", "instance connection name must be preserved") -} - -// The container-args builder must thread privateIp into the rendered Args — a bug -// dropping it in the delegation to cloudsqlProxyCommandArgs would pass the -// command-args test above but ship a public-dialing sidecar. -func TestCloudsqlProxyContainerArgs_PrivateIpThreaded(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", true) - args, ok := c.Args.(sdk.StringArray) - require.True(t, ok, "Args should be a pulumi.StringArray") - assert.Contains(t, args, sdk.StringInput(sdk.String("--private-ip")), "container args must carry --private-ip when privateIp=true") - - cNoPriv := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false) - argsNoPriv := cNoPriv.Args.(sdk.StringArray) - assert.NotContains(t, argsNoPriv, sdk.StringInput(sdk.String("--private-ip"))) -} - -// Runtime proxy => native sidecar: an init container with RestartPolicy: Always plus -// startup/readiness/liveness probes. This is what eliminates the startup race and lets a -// hung-but-alive proxy self-heal. -func TestCloudsqlProxyContainerArgs_RuntimeIsNativeSidecar(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false) - - assert.Equal(t, sdk.String("Always"), c.RestartPolicy, - "runtime proxy must be a native sidecar (init container, RestartPolicy: Always)") - assert.NotNil(t, c.StartupProbe, "startup probe must gate app containers until the proxy is listening") - assert.NotNil(t, c.ReadinessProbe, "readiness probe keeps the pod out of rotation until the DB path is up") - assert.NotNil(t, c.LivenessProbe, "liveness probe restarts a proxy that passed startup then hung") - assert.NotNil(t, c.Ports, "health-check port must be declared for the probes to target") -} - -// Pin the exact probe<->port wiring. A NotNil check would pass even with a port-name typo -// or a wrong probe path -- a probe the kubelet can never satisfy, which loops the pod in -// Init then restarts it. The agreement assertions (probe Port == declared port Name) are -// what actually defend the named-port linkage the whole sidecar depends on. -func TestCloudsqlProxyContainerArgs_RuntimeProbeWiring(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false) - - ports := c.Ports.(v1.ContainerPortArray) - require.Len(t, ports, 1) - p0 := ports[0].(*v1.ContainerPortArgs) - assert.Equal(t, sdk.Int(9090), p0.ContainerPort) - assert.Equal(t, sdk.String("csql-hc"), p0.Name) - - sp := c.StartupProbe.(*v1.ProbeArgs) - spHG := sp.HttpGet.(v1.HTTPGetActionArgs) - assert.Equal(t, sdk.String("/startup"), spHG.Path) - assert.Equal(t, sdk.IntPtr(30), sp.FailureThreshold, "startup budget ~= period(2s) x 30 = 60s") - assert.Equal(t, p0.Name, spHG.Port, "startup probe must target the declared health port by name") - - rp := c.ReadinessProbe.(*v1.ProbeArgs) - rpHG := rp.HttpGet.(v1.HTTPGetActionArgs) - assert.Equal(t, sdk.String("/readiness"), rpHG.Path) - assert.Equal(t, p0.Name, rpHG.Port, "readiness probe must target the declared health port by name") - - lp := c.LivenessProbe.(*v1.ProbeArgs) - lpHG := lp.HttpGet.(v1.HTTPGetActionArgs) - assert.Equal(t, sdk.String("/liveness"), lpHG.Path) - assert.Equal(t, p0.Name, lpHG.Port, "liveness probe must target the declared health port by name") -} - -// The proxy mounts its credential Secret at a fixed path; the mount Name must equal the -// secret name (the credential Volume that compute_proc.go appends derives from the same -// Metadata.Name(), so a drift breaks `--credentials-file` auth). -func TestCloudsqlProxyContainerArgs_MountsCredentialSecret(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false) - - mounts := c.VolumeMounts.(v1.VolumeMountArray) - require.Len(t, mounts, 1) - m := mounts[0].(*v1.VolumeMountArgs) - assert.Equal(t, sdk.String("creds"), m.Name, "mount name must equal the secret name") - assert.Equal(t, sdk.String("/var/run/secrets/cloudsql"), m.MountPath) - assert.Equal(t, sdk.Bool(true), m.ReadOnly) -} - -// The load-bearing wiring: the runtime proxy MUST be attached as a native sidecar (init -// container), never a regular container. RestartPolicy: Always on a regular container is -// rejected by the API server, and only the init-container placement gives the startup-probe -// ordering that removes the connection-refused race. Guards against a future refactor -// silently appending to SidecarOutputs. -func TestAttachCloudsqlProxyAsNativeSidecar_LandsInInitContainers(t *testing.T) { - kubeArgs := &kubernetes.SimpleContainerArgs{} - var proxy v1.ContainerOutput - var vol v1.VolumeOutput - - attachCloudsqlProxyAsNativeSidecar(kubeArgs, proxy, vol) - - assert.Len(t, kubeArgs.InitContainerOutputs, 1, "proxy must be a native sidecar (init container)") - assert.Empty(t, kubeArgs.SidecarOutputs, "proxy must NOT land in regular containers") - assert.Len(t, kubeArgs.VolumeOutputs, 1, "credential secret volume must ride along") -} - -// 3s readiness timeouts on a 50m-CPU sidecar can flap pods out of Service -// endpoints during Autopilot node consolidation (sidecar readiness -// gates pod readiness, KEP-753). Generous timeout + bigger CPU request keep -// transient starvation from dropping the whole pod out of rotation. -func TestCloudsqlProxyContainerArgs_ProbesTolerantToStarvation(t *testing.T) { - c := cloudsqlProxyContainerArgs("creds", "proj", "reg", "inst", false) - - rp := c.ReadinessProbe.(*v1.ProbeArgs) - assert.Equal(t, sdk.IntPtr(10), rp.TimeoutSeconds) - assert.Equal(t, sdk.IntPtr(10), rp.PeriodSeconds) - assert.Equal(t, sdk.IntPtr(3), rp.FailureThreshold) - - lp := c.LivenessProbe.(*v1.ProbeArgs) - assert.Equal(t, sdk.IntPtr(10), lp.TimeoutSeconds, "liveness shares the starved health server; 3s would restart the sidecar under the exact pressure readiness now tolerates") - assert.Equal(t, sdk.IntPtr(10), lp.PeriodSeconds) - assert.Equal(t, sdk.IntPtr(3), lp.FailureThreshold) - - sp := c.StartupProbe.(*v1.ProbeArgs) - assert.Equal(t, sdk.IntPtr(2), sp.PeriodSeconds) - assert.Equal(t, sdk.IntPtr(3), sp.TimeoutSeconds, "startup stays tight deliberately: nothing is connected yet") - assert.Equal(t, sdk.IntPtr(30), sp.FailureThreshold) - - res := c.Resources.(*v1.ResourceRequirementsArgs) - req := res.Requests.(sdk.StringMap) - assert.Equal(t, sdk.String("100m"), req["cpu"]) -} diff --git a/pkg/clouds/pulumi/gcp/compute_proc.go b/pkg/clouds/pulumi/gcp/compute_proc.go deleted file mode 100644 index 625eeda95..000000000 --- a/pkg/clouds/pulumi/gcp/compute_proc.go +++ /dev/null @@ -1,426 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" - "github.com/pulumi/pulumi-random/sdk/v4/go/random" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" - "github.com/simple-container-com/api/pkg/util" -) - -func PostgresComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, collector pApi.ComputeContextCollector, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if params.ParentStack == nil { - return nil, errors.Errorf("parent stack must not be nil for compute processor for %q", stack.Name) - } - - pgCfg, ok := input.Descriptor.Config.Config.(*gcloud.PostgresGcpCloudsqlConfig) - if !ok { - return nil, errors.Errorf("failed to convert postgresql config for %q", input.Descriptor.Type) - } - // Clients read the parent's config straight from server.yaml, so an invalid - // one (proxyPrivateIp without privateNetwork) must fail here too, not only on - // the parent's provision. - if err := pgCfg.Validate(); err != nil { - return nil, err - } - - // For export lookups, always use the configuration-based name (what parent stack exports) - configBasedName := toPostgresName(input, input.Descriptor.Name) - - // For actual database operations, use InstanceName when adopting existing resources - var actualInstanceName string - if pgCfg.Adopt && pgCfg.InstanceName != "" { - actualInstanceName = pgCfg.InstanceName - params.Log.Info(ctx.Context(), "Using actual instance name %q for adopted Postgres resource (config name: %q)", actualInstanceName, configBasedName) - } else { - actualInstanceName = configBasedName - params.Log.Debug(ctx.Context(), "Using convention-based name %q for Postgres resource", actualInstanceName) - } - - fullParentReference := params.ParentStack.FullReference - suffix := lo.If(params.ParentStack.DependsOnResource != nil, "--"+lo.FromPtr(params.ParentStack.DependsOnResource).Name).Else("") - - var rootPassword string - var err error - - // For adopted resources, try to get root password from configuration first - if pgCfg.Adopt && pgCfg.RootPassword != "" { - params.Log.Info(ctx.Context(), "Using root password from configuration for adopted Postgres instance %q", actualInstanceName) - rootPassword = pgCfg.RootPassword - } else { - // For provisioned resources or adopted resources without config password, get from parent stack - params.Log.Info(ctx.Context(), "Getting postgres root password for %q from parent stack %q (%q)", stack.Name, fullParentReference, suffix) - rootPasswordExport := toPostgresRootPasswordExport(configBasedName) // Use config-based name for export lookup - rootPassword, err = pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-cproc-rootpass", configBasedName, suffix), fullParentReference, rootPasswordExport, true) - if err != nil { - return nil, errors.Wrapf(err, "failed to get root password from parent stack for %q", configBasedName) - } - } - - if rootPassword == "" { - return nil, errors.Errorf("failed to get root password (empty) for %q - check parent stack exports or configuration", actualInstanceName) - } - - // TODO: move to provider init - cloudresourcemanagerServiceName := fmt.Sprintf("projects/%s/services/cloudresourcemanager.googleapis.com", pgCfg.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, cloudresourcemanagerServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", cloudresourcemanagerServiceName) - } - - stackName := input.StackParams.StackName - if pgCfg.UsersProvisionRuntime == nil { - return nil, errors.Errorf("`usersProvisionRuntime` is not configured for %q in %q, so %q cannot consume it", - input.Descriptor.Name, input.StackParams.Environment, stackName) - } - - var kubeProvider *sdkK8s.Provider - if pgCfg.UsersProvisionRuntime.Type == "gke" { - clusterName := input.ToResName(pgCfg.UsersProvisionRuntime.ResourceName) - outputName := toKubeconfigExport(clusterName) - params.Log.Info(ctx.Context(), "Getting kubeconfig for %q from parent stack %q (outputName=%q, suffix=%q)", clusterName, fullParentReference, outputName, suffix) - kubeConfig, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-cproc-kubeconfig", configBasedName, suffix), fullParentReference, outputName, true) - if err != nil { - return nil, errors.Wrapf(err, "failed to get kubeconfig from parent stack's resources") - } - if kubeConfig == "" { - return nil, errors.Errorf("failed to get kubeconfig from parent stack's resources: empty") - } - kubeProviderName := fmt.Sprintf("%s-%s-computeproc-kubeconfig", input.ToResName(input.Descriptor.Name), clusterName) - kubeProvider, err = sdkK8s.NewProvider(ctx, kubeProviderName, &sdkK8s.ProviderArgs{ - Kubeconfig: kubernetes.SecretKubeconfig(kubeConfig), - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision kubeconfig provider for %q/%q in %q", - stackName, input.Descriptor.Name, input.StackParams.Environment) - } - } else { - return nil, errors.Errorf("unsupported users provision runtime %q for %q/%q in %q", - pgCfg.UsersProvisionRuntime.Type, stackName, input.Descriptor.Name, input.StackParams.Environment) - } - - gcpProvider, ok := params.Provider.(*gcp.Provider) - if !ok { - return nil, errors.Errorf("failed to convert provider to *gcp.Provider when processing compute context for %q", actualInstanceName) - } - appendContextParams := appendParams{ - config: pgCfg, - stack: stack, - collector: collector, - input: input, - rootPassword: rootPassword, - postgresName: actualInstanceName, // Use actual instance name for database operations - provisionParams: params, - kubeProvider: kubeProvider, - gcpProvider: gcpProvider, - suffix: suffix, - } - if params.ParentStack.UsesResource { - if err := appendUsesPostgresResourceContext(ctx, appendContextParams); err != nil { - return nil, errors.Wrapf(err, "failed to append consumes resource context") - } - } else if params.ParentStack.DependsOnResource != nil { - appendContextParams.dependency = *params.ParentStack.DependsOnResource - if err := appendDependsOnPostgresResourceContext(ctx, appendContextParams); err != nil { - return nil, err - } - } else { - return nil, errors.Errorf("postgres %q only supports `uses` or `dependency`, but it wasn't explicitly declared as being used", actualInstanceName) - } - - return &api.ResourceOutput{ - Ref: nil, - }, nil -} - -type appendParams struct { - stack api.Stack - collector pApi.ComputeContextCollector - input api.ResourceInput - provisionParams pApi.ProvisionParams - dependency api.StackConfigDependencyResource - rootPassword string - postgresName string - gcpProvider *gcp.Provider - kubeProvider *sdkK8s.Provider - config *gcloud.PostgresGcpCloudsqlConfig - suffix any -} - -func appendUsesPostgresResourceContext(ctx *sdk.Context, params appendParams) error { - // set both dbname and username to stack name - dbName := params.stack.Name - userName := params.stack.Name - - database, err := sql.NewDatabase(ctx, dbName, &sql.DatabaseArgs{ - Project: sdk.String(params.config.Project), - Instance: sdk.String(params.postgresName), - Name: sdk.String(dbName), - }, sdk.Provider(params.gcpProvider)) - if err != nil { - return errors.Wrapf(err, "failed to create database %q for stack %q", dbName, params.stack.Name) - } - - password, err := createUserForDatabase(ctx, userName, dbName, params) - if err != nil { - return errors.Wrapf(err, "failed to init user %q for database %q", userName, dbName) - } - - addCloudsqlProxySidecarPreProcessor(ctx, params) - - params.collector.AddOutput(ctx, sdk.All(password.Result, database.Name).ApplyT(func(args []any) (any, error) { - userPassword := args[0].(string) - - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("POSTGRES_USERNAME"), userName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("POSTGRES_DATABASE"), dbName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("POSTGRES_HOST"), "localhost", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("POSTGRES_PORT"), "5432", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("POSTGRES_PASSWORD"), userPassword, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("PGHOST"), "localhost", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("PGPORT"), "5432", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("PGDATABASE"), dbName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("PGUSER"), userName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName("PGPASSWORD"), userPassword, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - - params.collector.AddResourceTplExtension(params.input.Descriptor.Name, map[string]string{ - "password": userPassword, - "user": userName, - "database": dbName, - "host": "localhost", - "port": "5432", - }) - - return nil, nil - })) - - return nil -} - -func addCloudsqlProxySidecarPreProcessor(ctx *sdk.Context, params appendParams) { - params.collector.AddPreProcessor(&kubernetes.SimpleContainerArgs{}, func(arg any) error { - kubeArgs, ok := arg.(*kubernetes.SimpleContainerArgs) - if !ok { - return errors.Errorf("arg is not *kubernetes.Args") - } - // Live Namespace name from the just-created Namespace resource. Using this - // Output (instead of recomputing via kubernetes.GenerateNamespaceName) keeps - // the CSQL credential Secret in lock-step with the Namespace under both - // fresh deploys (Output resolves to the isolated name) and migrated stacks - // where #255's IgnoreChanges("metadata.name") keeps the Namespace's k8s - // name parent-shared. - cloudsqlProxy, err := createCloudsqlProxy(ctx, params, kubeArgs.NamespaceNameOutput) - if err != nil { - return errors.Wrapf(err, "failed to create cloudsql proxy for %q in stack %q", params.postgresName, params.stack.Name) - } - proxyContainer := cloudsqlProxy.ProxyContainer.ApplyT(func(arg any) corev1.ContainerArgs { - return arg.(corev1.ContainerArgs) - }).(corev1.ContainerOutput) - credsVolume := cloudsqlProxy.SqlProxySecret.Metadata.Name().ApplyT(func(arg any) corev1.VolumeArgs { - return corev1.VolumeArgs{ - Name: sdk.String(lo.FromPtr(arg.(*string))), - Secret: &corev1.SecretVolumeSourceArgs{ - SecretName: sdk.StringPtrFromPtr(arg.(*string)), - }, - } - }).(corev1.VolumeOutput) - attachCloudsqlProxyAsNativeSidecar(kubeArgs, proxyContainer, credsVolume) - return nil - }) -} - -// attachCloudsqlProxyAsNativeSidecar wires the runtime proxy into kubeArgs as a native -// sidecar: its container goes in InitContainerOutputs (NOT SidecarOutputs) and its -// credential Secret volume rides along in VolumeOutputs. The init-container target is -// load-bearing — the container carries RestartPolicy: Always (set in cloudsqlProxyContainer), -// which the API server rejects on a regular container, and only the init-container placement -// gives the startup-probe ordering that stops the app from dialing localhost:5432 before the -// proxy is up. Kept as a tiny pure helper so that contract is unit-testable without the GCP -// provisioning path. The db-user-init Job puts the same container in its pod's -// InitContainers directly (init_pg_user_job.go). -func attachCloudsqlProxyAsNativeSidecar(kubeArgs *kubernetes.SimpleContainerArgs, proxyContainer corev1.ContainerOutput, credsVolume corev1.VolumeOutput) { - kubeArgs.InitContainerOutputs = append(kubeArgs.InitContainerOutputs, proxyContainer) - kubeArgs.VolumeOutputs = append(kubeArgs.VolumeOutputs, credsVolume) -} - -func createCloudsqlProxy(ctx *sdk.Context, params appendParams, namespaceOutput sdk.StringInput) (*CloudSQLProxy, error) { - // Fix for custom stacks: ensure input.StackParams.ParentEnv is set correctly for proper resource naming - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - // This is a custom stack - set ParentEnv so ToResName uses parent environment for resource naming - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - // Use environment-aware resource naming to avoid conflicts between custom stacks - // This ensures service accounts are unique per environment (e.g., telegram-bot--on-sidecarcsql--production vs telegram-bot--on-sidecarcsql--staging) - baseProxyName := fmt.Sprintf("%s-%s-sidecarcsql", params.stack.Name, params.postgresName) - cloudsqlProxyName := kubernetes.SanitizeK8sName(params.input.ToResName(baseProxyName)) - cloudsqlProxy, err := NewCloudsqlProxy(ctx, CloudSQLProxyArgs{ - Name: cloudsqlProxyName, - DBInstance: PostgresDBInstanceArgs{ - Project: params.config.ProjectId, - InstanceName: params.postgresName, - Region: lo.FromPtr(params.config.Region), - }, - GcpProvider: params.gcpProvider, - KubeProvider: params.kubeProvider, - Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), - PrivateIp: params.config.UsesPrivateIpProxy(), - }) - if err != nil { - return nil, err - } - params.collector.AddDependency(cloudsqlProxy.Account.ServiceAccount) - params.collector.AddDependency(cloudsqlProxy.Account.ServiceAccountKey) - params.collector.AddDependency(cloudsqlProxy.SqlProxySecret) - params.collector.AddOutput(ctx, cloudsqlProxy.ProxyContainer) - return cloudsqlProxy, nil -} - -func appendDependsOnPostgresResourceContext(ctx *sdk.Context, params appendParams) error { - ownerStackName := pApi.CollapseStackReference(params.dependency.Owner) - userName := fmt.Sprintf("%s--%s", params.stack.Name, params.dependency.Name) - dbName := pApi.StackNameInEnv(ownerStackName, params.input.StackParams.Environment) - - password, err := createUserForDatabase(ctx, userName, dbName, params) - if err != nil { - return errors.Wrapf(err, "failed to init user %q for database %q", userName, dbName) - } - - addCloudsqlProxySidecarPreProcessor(ctx, params) - - params.collector.AddOutput(ctx, sdk.All(password.Result).ApplyT(func(args []any) (any, error) { - userPassword := args[0].(string) - - params.collector.AddSecretEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("POSTGRES_DEP_%s_PASSWORD", ownerStackName)), userPassword, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("POSTGRES_DEP_%s_USERNAME", ownerStackName)), userName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("POSTGRES_DEP_%s_DATABASE", dbName)), dbName, - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("POSTGRES_DEP_%s_HOST", "localhost")), "localhost", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - params.collector.AddEnvVariableIfNotExist(util.ToEnvVariableName(fmt.Sprintf("POSTGRES_DEP_%s_PORT", "5432")), "5432", - params.input.Descriptor.Type, params.input.Descriptor.Name, params.provisionParams.ParentStack.StackName) - - params.collector.AddDependencyTplExtension(params.dependency.Name, params.input.Descriptor.Name, map[string]string{ - "password": userPassword, - "user": userName, - "database": dbName, - "host": "localhost", - "port": "5432", - }) - - return nil, nil - })) - - return nil -} - -func createUserForDatabase(ctx *sdk.Context, userName, dbName string, params appendParams) (*random.RandomPassword, error) { - ctx.Export(fmt.Sprintf("%s-%s%s-username", userName, params.postgresName, params.suffix), sdk.String(userName)) - passwordName := fmt.Sprintf("%s-%s%s-password", userName, params.postgresName, params.suffix) - password, err := random.NewRandomPassword(ctx, passwordName, &random.RandomPasswordArgs{ - Length: sdk.Int(20), - Special: sdk.Bool(false), - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to generate random password for postgres for user %q", userName) - } - ctx.Export(passwordName, password.Result) - - _, err = sql.NewUser(ctx, fmt.Sprintf("%s-%s-user", userName, params.postgresName), &sql.UserArgs{ - Password: password.Result, - Project: sdk.String(params.config.Project), - Instance: sdk.String(params.postgresName), - Name: sdk.String(userName), - }, sdk.Provider(params.gcpProvider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to create database user %q for database %q in stack %q", userName, dbName, params.stack.Name) - } - - params.collector.AddPostProcessor(&kubernetes.SimpleContainer{}, func(c any) error { - sc := c.(*kubernetes.SimpleContainer) - dbInstanceArgs := PostgresDBInstanceArgs{ - Project: params.config.ProjectId, - InstanceName: params.postgresName, - Region: lo.FromPtr(params.config.Region), - } - // Sanitize names to comply with Kubernetes RFC 1123 requirements (no underscores) - cloudsqlProxyName := kubernetes.SanitizeK8sName(util.TrimStringMiddle(fmt.Sprintf("%s-%s-initcsql", userName, params.postgresName), 60, "-")) - // Init job + ad-hoc CloudSQL proxy must live in the same namespace as the consuming - // pod so the proxy's credential Secret is mountable. Use the live Namespace.Metadata.Name() - // Output from the just-deployed SimpleContainer (instead of recomputing via - // GenerateNamespaceName), so we follow #255's IgnoreChanges("metadata.name") on the - // Namespace: migrated stacks stay parent-shared, fresh stacks get the isolated name. - namespaceOutput := sc.Namespace - cloudsqlProxy, err := NewCloudsqlProxy(ctx, CloudSQLProxyArgs{ - Name: cloudsqlProxyName, - DBInstance: dbInstanceArgs, - GcpProvider: params.gcpProvider, - KubeProvider: params.kubeProvider, - Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), - PrivateIp: params.config.UsesPrivateIpProxy(), - }, sdk.DependsOn([]sdk.Resource{sc})) - if err != nil { - return errors.Wrapf(err, "failed to init cloudsql proxy") - } - - _, err = NewInitDbUserJob(ctx, userName, InitDbUserJobArgs{ - Namespace: namespaceOutput, - User: CloudsqlDbUser{ - Database: dbName, - Username: userName, - }, - RootPassword: params.rootPassword, - DBInstance: dbInstanceArgs, - CloudSQLProxy: cloudsqlProxy, - KubeProvider: params.kubeProvider, - DBInstanceType: PostgreSQL, - Opts: []sdk.ResourceOption{sdk.DependsOn([]sdk.Resource{cloudsqlProxy.SqlProxySecret, sc})}, - }) - if err != nil { - return errors.Wrapf(err, "failed to init user %q for database %q", userName, dbName) - } - return nil - }) - - return password, nil -} - -func cloudsqlProxyMetaFromOutput(namespace sdk.StringInput, cloudsqlProxyName string, params appendParams) *v1.ObjectMetaArgs { - return &v1.ObjectMetaArgs{ - Namespace: namespace, - Name: sdk.String(cloudsqlProxyName), - Labels: sdk.StringMap{ - kubernetes.LabelAppType: sdk.String(kubernetes.AppTypeSimpleContainer), - kubernetes.LabelScEnv: sdk.String(params.input.StackParams.Environment), - kubernetes.LabelAppName: sdk.String(params.input.StackParams.StackName), - }, - } -} diff --git a/pkg/clouds/pulumi/gcp/compute_proc_private_ip_test.go b/pkg/clouds/pulumi/gcp/compute_proc_private_ip_test.go deleted file mode 100644 index f0da6d61a..000000000 --- a/pkg/clouds/pulumi/gcp/compute_proc_private_ip_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "sync" - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" - - gcpsdk "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -type proxyMocks struct{} - -func (proxyMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { - return args.Name + "_id", args.Inputs, nil -} - -func (proxyMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { - return args.Args, nil -} - -// renderedProxyArgs runs the client-side sidecar path (createCloudsqlProxy) for a -// parent postgres config and returns the args of the proxy container it renders. -func renderedProxyArgs(t *testing.T, pgCfg *gcloud.PostgresGcpCloudsqlConfig) []string { - t.Helper() - var ( - mu sync.Mutex - args []string - ) - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - gcpProv, err := gcpsdk.NewProvider(ctx, "gcp", &gcpsdk.ProviderArgs{Project: pulumi.String("test-project")}) - if err != nil { - return err - } - kubeProv, err := sdkK8s.NewProvider(ctx, "k8s", &sdkK8s.ProviderArgs{}) - if err != nil { - return err - } - pgCfg.ProjectId = "test-project" - pgCfg.Region = lo.ToPtr("europe-north1") - input := createTestResourceInput("production", "telegram-bot", nil) - input.Descriptor.Config.Config = pgCfg - params := createTestAppendParams(input, nil) - params.config = pgCfg - params.gcpProvider = gcpProv - params.kubeProvider = kubeProv - params.collector = pApi.NewComputeContextCollector(context.Background(), logger.New(), "telegram-bot", "production") - - proxy, err := createCloudsqlProxy(ctx, params, pulumi.String("telegram-bot")) - if err != nil { - return err - } - // The output is typed Container but carries the ContainerArgs that - // cloudsqlProxyContainer built, so take it untyped. - proxy.ProxyContainer.ApplyT(func(v interface{}) error { - c, ok := v.(corev1.ContainerArgs) - if !ok { - t.Errorf("unexpected proxy container value %T", v) - return nil - } - mu.Lock() - defer mu.Unlock() - for _, a := range c.Args.(pulumi.StringArray) { - args = append(args, string(a.(pulumi.String))) - } - return nil - }) - return nil - }, pulumi.WithMocks("project", "stack", proxyMocks{})) - Expect(err).ToNot(HaveOccurred()) - mu.Lock() - defer mu.Unlock() - Expect(args).ToNot(BeEmpty(), "the proxy container was never rendered") - return args -} - -func TestCreateCloudsqlProxy_PrivateIpFollowsParentConfig(t *testing.T) { - RegisterTestingT(t) - network := lo.ToPtr("projects/p/global/networks/vpc") - - Expect(renderedProxyArgs(t, &gcloud.PostgresGcpCloudsqlConfig{})). - ToNot(ContainElement("--private-ip"), "default stays on the public IP") - Expect(renderedProxyArgs(t, &gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: network})). - ToNot(ContainElement("--private-ip"), "privateNetwork alone must not move the proxy") - Expect(renderedProxyArgs(t, &gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: network, ProxyPrivateIp: lo.ToPtr(true)})). - To(ContainElement("--private-ip"), "proxyPrivateIp must reach the client sidecar") - Expect(renderedProxyArgs(t, &gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: network, PublicIpEnabled: lo.ToPtr(false)})). - To(ContainElement("--private-ip"), "with the public IP off the sidecar must dial private") -} diff --git a/pkg/clouds/pulumi/gcp/compute_proc_test.go b/pkg/clouds/pulumi/gcp/compute_proc_test.go deleted file mode 100644 index 67b3744fb..000000000 --- a/pkg/clouds/pulumi/gcp/compute_proc_test.go +++ /dev/null @@ -1,395 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" -) - -// Test utilities for compute processor testing - -// createTestResourceInput creates a ResourceInput for testing -func createTestResourceInput(environment, stackName string, parentEnv *string) api.ResourceInput { - stackParams := &api.StackParams{ - Environment: environment, - StackName: stackName, - } - if parentEnv != nil { - stackParams.ParentEnv = *parentEnv - } - - return api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: "test-postgres", - Type: "gcp-cloudsql-postgres", - Config: api.Config{ - Config: &gcloud.PostgresGcpCloudsqlConfig{ - Credentials: gcloud.Credentials{ - ServiceAccountConfig: gcloud.ServiceAccountConfig{ - ProjectId: "test-project", - }, - }, - }, - }, - }, - StackParams: stackParams, - } -} - -// createTestAppendParams creates appendParams for testing -func createTestAppendParams(input api.ResourceInput, parentStack *pApi.ParentInfo) appendParams { - provisionParams := pApi.ProvisionParams{ - Log: logger.New(), - } - if parentStack != nil { - provisionParams.ParentStack = parentStack - } - - return appendParams{ - stack: api.Stack{ - Name: "telegram-bot", - }, - input: input, - postgresName: "main-db", - provisionParams: provisionParams, - } -} - -// Test cases for service account naming with custom stacks - -func TestCreateCloudsqlProxy_StandardStack_NamingConvention(t *testing.T) { - RegisterTestingT(t) - - // Test standard stack (no custom stack features) - input := createTestResourceInput("production", "telegram-bot", nil) - _ = createTestAppendParams(input, nil) - - // Test the naming logic without actually creating resources - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // For standard stack, ToResName should add environment suffix (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-production")) -} - -func TestCreateCloudsqlProxy_CustomStack_WithoutParentEnvInitialization(t *testing.T) { - RegisterTestingT(t) - - // Test custom stack without ParentEnv initialization (should behave like standard stack) - input := createTestResourceInput("preview", "telegram-bot", nil) // ParentEnv not set - parentStack := &pApi.ParentInfo{ - ParentEnv: "production", // This exists but input.StackParams.ParentEnv is not set - } - _ = createTestAppendParams(input, parentStack) - - // Test the naming logic - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // Without ParentEnv initialization, should use custom stack's environment (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-preview")) -} - -func TestCreateCloudsqlProxy_CustomStack_WithParentEnvInitialization(t *testing.T) { - RegisterTestingT(t) - - // Test custom stack with ParentEnv initialization (simulates the fix) - input := createTestResourceInput("preview", "telegram-bot", nil) - parentStack := &pApi.ParentInfo{ - ParentEnv: "production", - } - params := createTestAppendParams(input, parentStack) - - // Simulate the ParentEnv initialization logic from our fix - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - // Test the naming logic - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // With ParentEnv initialization, should use parent environment (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-production")) -} - -func TestCreateCloudsqlProxy_CustomStack_SameEnvironmentAsParent(t *testing.T) { - RegisterTestingT(t) - - // Test custom stack where custom environment matches parent environment - input := createTestResourceInput("production", "telegram-bot", nil) - parentStack := &pApi.ParentInfo{ - ParentEnv: "production", // Same as custom stack environment - } - params := createTestAppendParams(input, parentStack) - - // Simulate the ParentEnv initialization logic - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - // Test the naming logic - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // Should not initialize ParentEnv since environments match, use standard naming (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-production")) - Expect(input.StackParams.ParentEnv).To(Equal("")) // ParentEnv should not be set -} - -func TestCreateCloudsqlProxy_CustomStack_MultipleEnvironments(t *testing.T) { - RegisterTestingT(t) - - // Test multiple custom stacks with different environments but same parent - testCases := []struct { - name string - customEnv string - parentEnv string - expectedSuffix string - shouldSetParent bool - }{ - { - name: "Preview environment with production parent", - customEnv: "preview", - parentEnv: "production", - expectedSuffix: "-production", - shouldSetParent: true, - }, - { - name: "Staging environment with production parent", - customEnv: "staging-preview", - parentEnv: "production", - expectedSuffix: "-production", - shouldSetParent: true, - }, - { - name: "Development environment with staging parent", - customEnv: "dev", - parentEnv: "staging", - expectedSuffix: "-staging", - shouldSetParent: true, - }, - { - name: "Production custom stack with production parent", - customEnv: "production", - parentEnv: "production", - expectedSuffix: "-production", - shouldSetParent: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - - input := createTestResourceInput(tc.customEnv, "telegram-bot", nil) - parentStack := &pApi.ParentInfo{ - ParentEnv: tc.parentEnv, - } - params := createTestAppendParams(input, parentStack) - - // Simulate the ParentEnv initialization logic - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - // Test the naming logic - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - expectedFullName := "telegram-bot-main-db-sidecarcsql" + tc.expectedSuffix - - Expect(expectedName).To(Equal(expectedFullName)) - - // Verify ParentEnv is set correctly - if tc.shouldSetParent { - Expect(input.StackParams.ParentEnv).To(Equal(tc.parentEnv)) - } else { - Expect(input.StackParams.ParentEnv).To(Equal("")) - } - }) - } -} - -func TestCreateCloudsqlProxy_ServiceAccountNamingConflictPrevention(t *testing.T) { - RegisterTestingT(t) - - // Test that demonstrates the conflict prevention - // This simulates the original issue where multiple custom stacks would create - // service accounts with the same name - - // Scenario: Two custom stacks deploying to different environments - // but both using the same parent environment for resource naming - - // Custom stack 1: preview environment - input1 := createTestResourceInput("preview", "telegram-bot", nil) - parentStack1 := &pApi.ParentInfo{ParentEnv: "production"} - params1 := createTestAppendParams(input1, parentStack1) - - // Custom stack 2: staging-preview environment - input2 := createTestResourceInput("staging-preview", "telegram-bot", nil) - parentStack2 := &pApi.ParentInfo{ParentEnv: "production"} - params2 := createTestAppendParams(input2, parentStack2) - - // Apply ParentEnv initialization to both - if params1.provisionParams.ParentStack != nil && params1.provisionParams.ParentStack.ParentEnv != "" && - params1.provisionParams.ParentStack.ParentEnv != params1.input.StackParams.Environment { - params1.input.StackParams.ParentEnv = params1.provisionParams.ParentStack.ParentEnv - } - - if params2.provisionParams.ParentStack != nil && params2.provisionParams.ParentStack.ParentEnv != "" && - params2.provisionParams.ParentStack.ParentEnv != params2.input.StackParams.Environment { - params2.input.StackParams.ParentEnv = params2.provisionParams.ParentStack.ParentEnv - } - - // Generate service account names - baseProxyName := "telegram-bot-main-db-sidecarcsql" - name1 := kubernetes.SanitizeK8sName(input1.ToResName(baseProxyName)) - name2 := kubernetes.SanitizeK8sName(input2.ToResName(baseProxyName)) - - // Both should use the parent environment for naming (sanitized to remove double hyphens) - expectedName := "telegram-bot-main-db-sidecarcsql-production" - Expect(name1).To(Equal(expectedName)) - Expect(name2).To(Equal(expectedName)) - - // This demonstrates that both custom stacks will create service accounts - // with the same name, which is the desired behavior for shared parent resources - // The service account should be shared between custom stacks that use the same parent -} - -func TestCreateCloudsqlProxy_EdgeCases(t *testing.T) { - RegisterTestingT(t) - - t.Run("Empty parent environment", func(t *testing.T) { - RegisterTestingT(t) - - input := createTestResourceInput("preview", "telegram-bot", nil) - parentStack := &pApi.ParentInfo{ParentEnv: ""} // Empty parent env - params := createTestAppendParams(input, parentStack) - - // Apply initialization logic - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // Should use custom stack environment since parent env is empty (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-preview")) - Expect(input.StackParams.ParentEnv).To(Equal("")) - }) - - t.Run("Nil parent stack", func(t *testing.T) { - RegisterTestingT(t) - - input := createTestResourceInput("preview", "telegram-bot", nil) - params := createTestAppendParams(input, nil) // No parent stack - - // Apply initialization logic - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // Should use custom stack environment (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-preview")) - Expect(input.StackParams.ParentEnv).To(Equal("")) - }) - - t.Run("ParentEnv already set in input", func(t *testing.T) { - RegisterTestingT(t) - - // Test case where ParentEnv is already set in the input - parentEnv := "staging" - input := createTestResourceInput("preview", "telegram-bot", &parentEnv) - parentStack := &pApi.ParentInfo{ParentEnv: "production"} - params := createTestAppendParams(input, parentStack) - - // Apply initialization logic - if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && - params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { - params.input.StackParams.ParentEnv = params.provisionParams.ParentStack.ParentEnv - } - - baseProxyName := "telegram-bot-main-db-sidecarcsql" - expectedName := kubernetes.SanitizeK8sName(input.ToResName(baseProxyName)) - - // Should override the existing ParentEnv with the one from ParentStack (sanitized to remove double hyphens) - Expect(expectedName).To(Equal("telegram-bot-main-db-sidecarcsql-production")) - Expect(input.StackParams.ParentEnv).To(Equal("production")) - }) -} - -// Test the ToResName method behavior directly -func TestToResName_CustomStackBehavior(t *testing.T) { - RegisterTestingT(t) - - testCases := []struct { - name string - environment string - parentEnv *string - resName string - expected string - }{ - { - name: "Standard stack with environment", - environment: "production", - parentEnv: nil, - resName: "service-account", - expected: "service-account--production", - }, - { - name: "Custom stack with parent environment", - environment: "preview", - parentEnv: stringPtr("production"), - resName: "service-account", - expected: "service-account--production", - }, - { - name: "Custom stack with empty parent environment", - environment: "preview", - parentEnv: stringPtr(""), - resName: "service-account", - expected: "service-account--preview", - }, - { - name: "No environment", - environment: "", - parentEnv: nil, - resName: "service-account", - expected: "service-account", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - - input := createTestResourceInput(tc.environment, "test-stack", tc.parentEnv) - result := input.ToResName(tc.resName) - - Expect(result).To(Equal(tc.expected)) - }) - } -} - -// Helper function to create string pointer -func stringPtr(s string) *string { - return &s -} diff --git a/pkg/clouds/pulumi/gcp/credentials.go b/pkg/clouds/pulumi/gcp/credentials.go deleted file mode 100644 index 46773d0f6..000000000 --- a/pkg/clouds/pulumi/gcp/credentials.go +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - - "golang.org/x/oauth2" - auth "golang.org/x/oauth2/google" - gcpOptions "google.golang.org/api/option" - - "github.com/pkg/errors" - - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" - -// secretCredentials wraps the service-account document so Pulumi treats it as a -// secret. -// -// Pulumi prints the properties of every resource it creates or changes in its -// preview and update summaries, SC forwards that summary to stdout, and GitHub -// Actions archives it — so a provider credential passed as a plain property -// value is published by the first deploy of the stack. That is not theoretical: -// the same shape leaked a Yandex service-account private key into a -// world-readable log on 2026-09-30 (forge-atrium run 36687152934). See -// yandex/credentials.go and forge's -// docs/roadmap/items/yc-provider-key-in-deploy-log.md. -// -// pulumi-gcp's generated NewProvider marks only `accessToken` secret; the -// `credentials` field — which is where SC puts the whole service-account JSON, -// `private_key` included — is left to the caller. Marking it here also encrypts -// the value in the Pulumi checkpoint, where it was otherwise stored in -// plaintext. -func secretCredentials(value string) sdk.StringPtrOutput { - return sdk.ToSecret(sdk.String(value)).(sdk.StringOutput).ToStringPtrOutput() -} - -// clientOptions authenticates a Google API client with the configured key, or with -// Application Default Credentials when none is configured. -func clientOptions(credentials string) []gcpOptions.ClientOption { - if gcloud.UsesAmbientCredentials(credentials) { - return nil - } - return []gcpOptions.ClientOption{gcpOptions.WithCredentialsJSON([]byte(credentials))} //nolint:staticcheck // SA1019: no in-memory replacement available -} - -// tokenSource returns an OAuth2 token source for the configured key, or for -// Application Default Credentials when none is configured. A configured key must -// still be a service-account key: pinning the type keeps an unexpected credential -// shape from being accepted from config. -func tokenSource(ctx context.Context, credentials string) (oauth2.TokenSource, error) { - if gcloud.UsesAmbientCredentials(credentials) { - creds, err := auth.FindDefaultCredentials(ctx, cloudPlatformScope) - if err != nil { - return nil, errors.Wrap(err, "credentials are empty (ambient mode) and no Application Default Credentials were found") - } - return creds.TokenSource, nil - } - creds, err := auth.CredentialsFromJSONWithTypeAndParams(ctx, []byte(credentials), auth.ServiceAccount, auth.CredentialsParams{ - Scopes: []string{cloudPlatformScope}, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse GCP service account credentials") - } - return creds.TokenSource, nil -} diff --git a/pkg/clouds/pulumi/gcp/credentials_test.go b/pkg/clouds/pulumi/gcp/credentials_test.go deleted file mode 100644 index 11a2eb64c..000000000 --- a/pkg/clouds/pulumi/gcp/credentials_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "os" - "path/filepath" - "testing" - - . "github.com/onsi/gomega" - - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi/internals" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -const fakeServiceAccountKey = `{"type":"service_account","project_id":"p","private_key_id":"k","private_key":"-----BEGIN PRIVATE KEY-----\nMA==\n-----END PRIVATE KEY-----\n","client_email":"deployer@p.iam.gserviceaccount.com","token_uri":"https://oauth2.googleapis.com/token"}` - -// What google-github-actions/auth writes for Workload Identity Federation. -const fakeExternalAccount = `{"type":"external_account","audience":"//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/gh","subject_token_type":"urn:ietf:params:oauth:token-type:jwt","token_url":"https://sts.googleapis.com/v1/token","credential_source":{"file":"/dev/null"},"service_account_impersonation_url":"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/deployer@p.iam.gserviceaccount.com:generateAccessToken"}` - -func writeADC(t *testing.T, content string) { - path := filepath.Join(t.TempDir(), "adc.json") - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) -} - -func providerInputs(t *testing.T, credentials string) map[string]any { - return providerProps(t, credentials).Mappable() -} - -func providerProps(t *testing.T, credentials string) resource.PropertyMap { - mocks := newKmsMocks() - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - _, err := Provider(ctx, api.Stack{}, api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{Name: "gcp", Config: api.Config{Config: &gcloud.Credentials{ - Credentials: api.Credentials{Credentials: credentials}, - ServiceAccountConfig: gcloud.ServiceAccountConfig{ProjectId: "p"}, - }}}, - StackParams: &api.StackParams{Environment: "test"}, - }, createBasicProvisionParams()) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - if err != nil { - t.Fatal(err) - } - for _, a := range mocks.created { - if a.TypeToken == "pulumi:providers:gcp" { - return a.Inputs - } - } - t.Fatal("no gcp provider was created") - return nil -} - -func TestProvider_AmbientCredentialsAreNotPassed(t *testing.T) { - RegisterTestingT(t) - // Passing an empty string would override Application Default Credentials with - // "no credentials" instead of falling back to them. - Expect(providerInputs(t, "")).NotTo(HaveKey("credentials")) -} - -// The one property that must never be printable. pulumi-gcp's generated -// NewProvider marks only `accessToken`, so `credentials` — the whole -// service-account document, `private_key` included — is left to the caller, and -// an unmarked provider credential is how a Yandex service-account private key -// reached a world-readable CI log on 2026-09-30. -func TestProvider_ConfiguredKeyIsSecretAndStillPassed(t *testing.T) { - RegisterTestingT(t) - creds := providerProps(t, fakeServiceAccountKey)["credentials"] - Expect(creds.IsSecret()).To(BeTrue(), - "credentials reached the engine unmarked — the service-account document will be "+ - "printed verbatim in the preview and update summaries and archived in CI logs") - // ...and arrive intact. Masking is the engine's job, so a secret - // PropertyValue still carries the plaintext document as its element — that - // is not the leak. The leak is it reaching the engine unmarked, above. - Expect(creds.SecretValue().Element.StringValue()).To(Equal(fakeServiceAccountKey)) -} - -// secretCredentials' own contract, asserted directly so a refactor of the helper -// cannot pass the call-site test by accident. -func TestSecretCredentialsMarksSecret(t *testing.T) { - RegisterTestingT(t) - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - got, err := internals.UnsafeAwaitOutput(ctx.Context(), secretCredentials("a-credential")) - if err != nil { - return err - } - Expect(got.Secret).To(BeTrue()) - Expect(*got.Value.(*string)).To(Equal("a-credential")) - return nil - }, pulumi.WithMocks("project", "stack", newKmsMocks())) - Expect(err).To(BeNil()) -} - -func TestClientOptions(t *testing.T) { - RegisterTestingT(t) - Expect(clientOptions("")).To(BeEmpty()) - Expect(clientOptions(" \n")).To(BeEmpty()) - Expect(clientOptions(fakeServiceAccountKey)).To(HaveLen(1)) -} - -func TestTokenSource_AmbientAcceptsWorkloadIdentityFederation(t *testing.T) { - RegisterTestingT(t) - writeADC(t, fakeExternalAccount) - ts, err := tokenSource(context.Background(), "") - Expect(err).NotTo(HaveOccurred()) - Expect(ts).NotTo(BeNil()) -} - -func TestTokenSource_AmbientWithoutADCFails(t *testing.T) { - RegisterTestingT(t) - t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(t.TempDir(), "missing.json")) - _, err := tokenSource(context.Background(), "") - Expect(err).To(MatchError(ContainSubstring("ambient mode"))) -} - -func TestTokenSource_ConfiguredKeyMustBeAServiceAccount(t *testing.T) { - RegisterTestingT(t) - // A configured value keeps the old pin: only a service-account key is accepted - // from config. An external-account file is only honoured through ADC. - _, err := tokenSource(context.Background(), fakeExternalAccount) - Expect(err).To(HaveOccurred()) - ts, err := tokenSource(context.Background(), fakeServiceAccountKey) - Expect(err).NotTo(HaveOccurred()) - Expect(ts).NotTo(BeNil()) -} diff --git a/pkg/clouds/pulumi/gcp/docker_image.go b/pkg/clouds/pulumi/gcp/docker_image.go deleted file mode 100644 index 17a8e1d49..000000000 --- a/pkg/clouds/pulumi/gcp/docker_image.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-docker/sdk/v4/go/docker" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - pDocker "github.com/simple-container-com/api/pkg/clouds/pulumi/docker" -) - -type DockerImageOutput struct { - Image *RemoteImage -} - -func RemoteImagePush(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeRemoteDockerImagePush { - return nil, errors.Errorf("unsupported docker image type %q", input.Descriptor.Type) - } - - dockerImage, ok := input.Descriptor.Config.Config.(*gcloud.RemoteImagePush) - if !ok { - return nil, errors.Errorf("failed to convert docker image config for %q", input.Descriptor.Type) - } - stackName := input.StackParams.StackName - environment := input.StackParams.Environment - registryResource := dockerImage.ArtifactRegistryResource - - if registryResource == "" { - return nil, errors.Errorf("`artifactRegistryResource` must be specified for docker image for %q/%q in %q", stackName, input.Descriptor.Name, environment) - } - - out := &DockerImageOutput{} - - if params.ResourceOutputs == nil || params.ResourceOutputs[registryResource] == nil { - return nil, errors.Errorf("expected registry resource %q to be present in outputs for %q in %q", registryResource, dockerImage.Name, environment) - } - - registryOut, ok := params.ResourceOutputs[registryResource].Ref.(*ArtifactRegistryOut) - if !ok { - return nil, errors.Errorf("resource output for %q could not be casted to *artifactregistry.Repository for %q in %q", registryResource, dockerImage.Name, environment) - } - - opts := []sdk.ResourceOption{ - sdk.DependsOn([]sdk.Resource{registryOut.Repository}), - } - if input.StackParams.Timeouts.DeployTimeout != "" { - opts = append(opts, sdk.Timeouts(&sdk.CustomTimeouts{ - Create: input.StackParams.Timeouts.DeployTimeout, - Update: input.StackParams.Timeouts.DeployTimeout, - Delete: input.StackParams.Timeouts.DeployTimeout, - })) - } - - image, err := PushRemoteImageToRegistry(ctx, RemoteImageArgs{ - Image: dockerImage, - RegistryURL: registryOut.URL, - Stack: stack, - Input: input, - Params: params, - Opts: opts, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to build and push docker images for stack %q in %q", stackName, input.StackParams.Environment) - } - out.Image = image - - return &api.ResourceOutput{Ref: out}, nil -} - -type RemoteImageArgs struct { - RegistryURL sdk.StringOutput - Params pApi.ProvisionParams - Image *gcloud.RemoteImagePush - Stack api.Stack - Input api.ResourceInput - Opts []sdk.ResourceOption -} - -type RemoteImage struct { - Image *docker.Image -} - -func PushRemoteImageToRegistry(ctx *sdk.Context, args RemoteImageArgs) (*RemoteImage, error) { - remoteImageName := fmt.Sprintf("%s-%s-%s-remote", args.Stack.Name, args.Input.StackParams.Environment, args.Image.RemoteImage) - pushImageName := fmt.Sprintf("%s-%s-%s-push", args.Stack.Name, args.Input.StackParams.Environment, args.Image.Name) - if args.Image.RemoteImage == "" { - return nil, errors.Errorf("`remoteImage` must be specified for image %q in %q", args.Image.Name, args.Stack.Name) - } - if args.Image.Name == "" { - return nil, errors.Errorf("`name` must be specified for image %q in %q", args.Image.Name, args.Stack.Name) - } - - opts := args.Opts - - remoteImageUrl, err := url.Parse(fmt.Sprintf("https://%s", args.Image.RemoteImage)) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse remote image url") - } - stackName := args.Input.StackParams.StackName - pushRegistryURL := args.RegistryURL - - // TODO: we only support images in the same project or images that are accessible with the same service account - remoteRegistryHost := remoteImageUrl.Host - - args.Params.Log.Info(ctx.Context(), "Authenticating against registry %q for stack %q", remoteRegistryHost, stackName) - gcpCreds, err := getDockerCredentialsWithAuthToken(ctx, args.Input) - if err != nil { - return nil, errors.Wrapf(err, "failed to obtain access token for registry %q for stack %q", remoteRegistryHost, stackName) - } - - version := lo.If(args.Image.Tag == "", "latest").Else(args.Image.Tag) - platform := lo.If(args.Image.Platform == "", api.ImagePlatformLinuxAmd64).Else(args.Image.Platform) - - remoteImage, err := pDocker.NewDockerPull(ctx, remoteImageName, &pDocker.PullArgs{ - RemoteImage: sdk.String(args.Image.RemoteImage), - AuthHeader: sdk.String(gcpCreds.AuthHeader), - Platform: sdk.String(platform), - Log: args.Params.Log, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to pull remote image %q", args.Image.Name) - } - - buildArgs, err := dockerImageWrapper(args.Image.RemoteImage, version, platform) - if err != nil { - return nil, errors.Wrapf(err, "failed to prepare docker image wrapper with Dockerfile") - } - - opts = append(opts, sdk.DependsOn([]sdk.Resource{remoteImage})) - - image, err := docker.NewImage(ctx, pushImageName, &docker.ImageArgs{ - Build: buildArgs, - SkipPush: sdk.Bool(ctx.DryRun()), - ImageName: sdk.Sprintf("%s/%s:%s", pushRegistryURL, args.Image.Name, version), - Registry: pDocker.SecretRegistry(docker.RegistryArgs{ - Server: pushRegistryURL, - Password: sdk.String(gcpCreds.Password), - Username: sdk.String(gcpCreds.Username), - }), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to push image %q in stack %q env %q", args.Image.Name, args.Stack.Name, args.Input.StackParams.Environment) - } - return &RemoteImage{ - Image: image, - }, nil -} - -func dockerImageWrapper(imageName string, version string, platform api.ImagePlatform) (*docker.DockerBuildArgs, error) { - // hack taken from here https://github.com/pulumi/pulumi-docker/issues/54#issuecomment-772250411 - var dockerFilePath string - if depDir, err := os.MkdirTemp(os.TempDir(), strings.ReplaceAll(imageName, string(filepath.Separator), "-")); err != nil { - return nil, errors.Wrapf(err, "failed to create tempDir") - } else if err = os.WriteFile(filepath.Join(depDir, "Dockerfile"), []byte("ARG SOURCE_IMAGE\n\nFROM ${SOURCE_IMAGE}\nARG VERSION\nLABEL VERSION=${VERSION}"), os.ModePerm); err != nil { - return nil, errors.Wrapf(err, "failed to write temporary Dockerfile") - } else { - dockerFilePath = filepath.Join(depDir, "Dockerfile") - } - - return &docker.DockerBuildArgs{ - Context: sdk.String("."), - Dockerfile: sdk.String(dockerFilePath), - Platform: sdk.String(platform), - Args: sdk.StringMap{ - "SOURCE_IMAGE": sdk.String(imageName), - "VERSION": sdk.String(version), - }, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/egress_nat_test.go b/pkg/clouds/pulumi/gcp/egress_nat_test.go deleted file mode 100644 index e5f2dcb2b..000000000 --- a/pkg/clouds/pulumi/gcp/egress_nat_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -// A nil or empty egress config must reproduce the historical Cloud NAT defaults -// exactly, so existing clusters are unchanged until they opt in. -func TestResolveNatPortSettings_Defaults(t *testing.T) { - for _, cfg := range []*gcloud.ExternalEgressIpConfig{nil, {Enabled: true}} { - s := resolveNatPortSettings(cfg) - assert.Equal(t, gcloud.DefaultMinPortsPerVm, s.minPortsPerVm) - assert.Equal(t, gcloud.DefaultMaxPortsPerVm, s.maxPortsPerVm) - assert.True(t, s.endpointIndependentMapping) - assert.False(t, s.dynamicPortAllocation) - } -} - -func TestResolveNatPortSettings_Overrides(t *testing.T) { - s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{ - Enabled: true, - MinPortsPerVm: lo.ToPtr(1024), - MaxPortsPerVm: lo.ToPtr(4096), - DynamicPortAllocation: lo.ToPtr(true), - EndpointIndependentMapping: lo.ToPtr(false), - }) - assert.Equal(t, 1024, s.minPortsPerVm) - assert.Equal(t, 4096, s.maxPortsPerVm) - assert.False(t, s.endpointIndependentMapping) - assert.True(t, s.dynamicPortAllocation) -} - -// A single-field override must not drop the other field's default. -func TestResolveNatPortSettings_PartialOverride(t *testing.T) { - s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{Enabled: true, MinPortsPerVm: lo.ToPtr(1024)}) - assert.Equal(t, 1024, s.minPortsPerVm) - assert.Equal(t, gcloud.DefaultMaxPortsPerVm, s.maxPortsPerVm) -} - -// Enabling dynamic port allocation must force endpoint-independent mapping off in -// the resolved settings even if the config left EIM at its default, so the NAT -// args stay acceptable to GCP regardless of validation order. -func TestResolveNatPortSettings_DpaForcesEimOff(t *testing.T) { - s := resolveNatPortSettings(&gcloud.ExternalEgressIpConfig{Enabled: true, DynamicPortAllocation: lo.ToPtr(true)}) - assert.True(t, s.dynamicPortAllocation) - assert.False(t, s.endpointIndependentMapping) -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot.go b/pkg/clouds/pulumi/gcp/gke_autopilot.go deleted file mode 100644 index 67b8a3628..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot.go +++ /dev/null @@ -1,862 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "encoding/base64" - "fmt" - "strings" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - "github.com/simple-container-com/api/pkg/clouds/k8s" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - taggingUtil "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - pulumiKubernetes "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" - "github.com/simple-container-com/api/pkg/provisioner/placeholders" -) - -type GkeAutopilotOut struct { - Cluster *container.Cluster - Caddy *pulumiKubernetes.SimpleContainer - - // Cloud NAT resources (optional) - StaticIp *compute.Address - Router *compute.Router - Nat *compute.RouterNat -} - -func GkeAutopilot(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeGkeAutopilot { - return nil, errors.Errorf("unsupported resource type %q", input.Descriptor.Type) - } - - gkeInput, ok := input.Descriptor.Config.Config.(*gcloud.GkeAutopilotResource) - if !ok { - return nil, errors.Errorf("failed to convert gke autopilot config for %q", input.Descriptor.Type) - } - - // Handle resource adoption - exit early if adopting - if gkeInput.Adopt { - return AdoptGkeAutopilot(ctx, stack, input, params) - } - - containerServiceName := fmt.Sprintf("projects/%s/services/container.googleapis.com", gkeInput.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, containerServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", containerServiceName) - } - var opts []sdk.ResourceOption - if params.Provider != nil { - opts = append(opts, sdk.Provider(params.Provider)) - } - - location := gkeInput.Location - - if location == "" { - return nil, errors.Errorf("`location` must be specified for GKE cluster %q in %q", input.Descriptor.Name, input.StackParams.Environment) - } - - clusterName := pulumiKubernetes.ToClusterName(input, input.Descriptor.Name) - params.Log.Info(ctx.Context(), "Configuring GKE Autopilot cluster %q in %q", clusterName, input.StackParams.Environment) - timeouts := sdk.CustomTimeouts{ - Create: "10m", - Update: "5m", - Delete: "10m", - } - if gkeInput.Timeouts != nil { - timeouts.Delete = lo.If(gkeInput.Timeouts.Delete != "", gkeInput.Timeouts.Delete).Else(timeouts.Delete) - timeouts.Update = lo.If(gkeInput.Timeouts.Update != "", gkeInput.Timeouts.Update).Else(timeouts.Update) - timeouts.Create = lo.If(gkeInput.Timeouts.Create != "", gkeInput.Timeouts.Create).Else(timeouts.Create) - } - out := GkeAutopilotOut{} - - // Create private VPC if requested - var network, subnetwork sdk.StringInput - if gkeInput.PrivateVpc { - params.Log.Info(ctx.Context(), "🏗️ Creating private VPC for cluster %q", clusterName) - - vpcName := fmt.Sprintf("%s-vpc", clusterName) - subnetName := fmt.Sprintf("%s-subnet", clusterName) - - // Create VPC - vpc, err := compute.NewNetwork(ctx, vpcName, &compute.NetworkArgs{ - Name: sdk.String(vpcName), - AutoCreateSubnetworks: sdk.Bool(false), // We'll create subnet manually - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create VPC %q", vpcName) - } - - // Create subnet with auto-generated CIDR based on environment - // Use different CIDR ranges to avoid conflicts: prod=10.1.x.x, staging=10.2.x.x, etc. - var subnetCidr string - switch input.StackParams.Environment { - case "production": - subnetCidr = "10.1.0.0/16" - case "staging": - subnetCidr = "10.2.0.0/16" - default: - // Use hash of environment name to generate unique CIDR - envHash := len(input.StackParams.Environment)%200 + 10 // Range 10-209 - subnetCidr = fmt.Sprintf("10.%d.0.0/16", envHash) - } - - subnet, err := compute.NewSubnetwork(ctx, subnetName, &compute.SubnetworkArgs{ - Name: sdk.String(subnetName), - IpCidrRange: sdk.String(subnetCidr), - Network: vpc.ID(), - Region: sdk.String(location), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create subnet %q", subnetName) - } - - network = vpc.Name - subnetwork = subnet.Name - - params.Log.Info(ctx.Context(), " ✅ VPC created: %s (CIDR: %s)", vpcName, subnetCidr) - - // Create VPC peering to default VPC for shared resources (Redis, CloudSQL, etc.) - params.Log.Info(ctx.Context(), "🔗 Creating VPC peering to default VPC for shared resources") - - // Peering from private VPC to default VPC - peeringName := fmt.Sprintf("%s-to-default", vpcName) - _, err = compute.NewNetworkPeering(ctx, peeringName, &compute.NetworkPeeringArgs{ - Name: sdk.String(peeringName), - Network: vpc.SelfLink, - PeerNetwork: sdk.Sprintf("projects/%s/global/networks/default", gkeInput.ProjectId), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create VPC peering %q", peeringName) - } - - // Peering from default VPC to private VPC (bidirectional) - reversePeeringName := fmt.Sprintf("default-to-%s", clusterName) - _, err = compute.NewNetworkPeering(ctx, reversePeeringName, &compute.NetworkPeeringArgs{ - Name: sdk.String(reversePeeringName), - Network: sdk.Sprintf("projects/%s/global/networks/default", gkeInput.ProjectId), - PeerNetwork: vpc.SelfLink, - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create reverse VPC peering %q", reversePeeringName) - } - - params.Log.Info(ctx.Context(), " ✅ VPC peering configured: production ↔ default (for shared resources)") - } - - // Configure cluster args - clusterArgs := &container.ClusterArgs{ - EnableAutopilot: sdk.Bool(true), - Location: sdk.String(location), - Name: sdk.String(clusterName), - MinMasterVersion: sdk.String(gkeInput.GkeMinVersion), - ReleaseChannel: &container.ClusterReleaseChannelArgs{ - Channel: sdk.String("STABLE"), - }, - IpAllocationPolicy: &container.ClusterIpAllocationPolicyArgs{}, - ResourceLabels: sdk.ToStringMap(taggingUtil.BuildTagsFromStackParams(lo.FromPtr(input.StackParams)).ToGCPLabels()), - // because we are using autopilot verticalPodAutoscaling is handled by the GCP - } - - // Set VPC and subnet if private VPC is enabled - if gkeInput.PrivateVpc { - clusterArgs.Network = network - clusterArgs.Subnetwork = subnetwork - } - - // Enable private nodes when Cloud NAT is enabled (required for Cloud NAT to work) - // NOTE: privateClusterConfig is immutable and cannot be changed after cluster creation - ignoreChanges := []string{"verticalPodAutoscaling"} - if gkeInput.ExternalEgressIp != nil && gkeInput.ExternalEgressIp.Enabled { - params.Log.Info(ctx.Context(), "🔒 Enabling private nodes (required for Cloud NAT)") - clusterArgs.PrivateClusterConfig = &container.ClusterPrivateClusterConfigArgs{ - EnablePrivateNodes: sdk.Bool(true), // Nodes will not have external IPs - EnablePrivateEndpoint: sdk.Bool(false), // Keep control plane public for easy access - } - params.Log.Info(ctx.Context(), " ✅ Private nodes enabled (nodes will use Cloud NAT for egress)") - params.Log.Info(ctx.Context(), " ✅ Control plane remains public (kubectl works from anywhere)") - - // NOTE: privateClusterConfig is immutable in GKE. Adding it to existing clusters will trigger replacement. - // For production safety, you may want to add "privateClusterConfig" to ignoreChanges for existing clusters. - } - - if err := gkeInput.ControlPlaneAccess.Validate(); err != nil { - return nil, errors.Wrapf(err, "invalid control plane access configuration for cluster %q", clusterName) - } - ignoreChanges = append(ignoreChanges, applyControlPlaneAccess(clusterArgs, gkeInput.ControlPlaneAccess)...) - - cluster, err := container.NewCluster(ctx, clusterName, clusterArgs, append(opts, sdk.IgnoreChanges(ignoreChanges), sdk.Timeouts(&timeouts))...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create cluster %q in %q", clusterName, input.StackParams.Environment) - } - out.Cluster = cluster - kubeconfig := generateKubeconfig(cluster, gkeInput) - ctx.Export(toKubeconfigExport(clusterName), kubeconfig) - - // Setup Cloud NAT if external egress IP is enabled - if gkeInput.ExternalEgressIp != nil && gkeInput.ExternalEgressIp.Enabled { - if err := gkeInput.ExternalEgressIp.Validate(); err != nil { - return nil, errors.Wrapf(err, "invalid external egress IP configuration") - } - - if err := setupCloudNAT(ctx, gkeInput, clusterName, location, cluster, subnetwork, &out, opts, params); err != nil { - return nil, errors.Wrapf(err, "failed to setup Cloud NAT for cluster %q", clusterName) - } - - // Note: Cloud NAT is now configured to handle both primary and secondary IP ranges - // No additional Egress NAT Policy needed - GKE's default policies work correctly - // with the proper Cloud NAT subnet configuration (LIST_OF_SUBNETWORKS + ALL_IP_RANGES) - } - - if gkeInput.Caddy != nil { - // Provision GCS bucket and service account for Caddy ACME certificate storage - bucket, credentialsJSON, err := provisionCaddyACMEStorage(ctx, clusterName, gkeInput.ProjectId, location, input.Descriptor.Config.Config, opts, params) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision ACME storage for Caddy in cluster %q", clusterName) - } - - // Validate trusted proxies early (before Pulumi apply) - trustedProxiesBlock, err := pulumiKubernetes.BuildTrustedProxiesBlock(lo.FromPtrOr(gkeInput.Caddy, k8s.CaddyConfig{})) - if err != nil { - return nil, errors.Wrapf(err, "invalid caddy trusted proxies for cluster %q", clusterName) - } - - // Build Caddyfile prefix with GCS storage configuration and trusted proxies - caddyfilePrefix := bucket.Name.ApplyT(func(bucketName string) string { - storageBlock := fmt.Sprintf(" storage gcs {\n bucket-name %s\n }", bucketName) - userPrefix := lo.FromPtrOr(gkeInput.Caddy.CaddyfilePrefix, "") - return pulumiKubernetes.BuildCaddyfileGlobalOptions(storageBlock, trustedProxiesBlock, userPrefix) - }).(sdk.StringOutput) - - // Prepare GCP credentials as a secret volume output (Pulumi output) - // SimpleContainer will create the Kubernetes Secret automatically - // Note: With SubPath mounting, the file is mounted directly at MountPath (not MountPath/Name) - credentialsMountPath := "/etc/gcp-credentials/credentials.json" - gcpCredentialsVolume := credentialsJSON.ApplyT(func(creds string) interface{} { - return k8s.SimpleTextVolume{ - TextVolume: api.TextVolume{ - Name: "credentials.json", // Filename within the Kubernetes secret - Content: creds, // GCP service account JSON - MountPath: credentialsMountPath, // File will be mounted directly here (SubPath behavior) - }, - } - }) - - params.Log.Info(ctx.Context(), "🔐 Preparing GCP credentials secret volume for Caddy ACME storage at %s", credentialsMountPath) - - // Build Caddy deployment configuration with GCS storage - caddyConfig := pulumiKubernetes.CaddyDeployment{ - CaddyConfig: gkeInput.Caddy, - ClusterName: clusterName, - ClusterResource: cluster, - CaddyfilePrefixOut: caddyfilePrefix, // Caddyfile with GCS storage config - // Pass GCP credentials as secret volume output (SimpleContainer will create the K8s Secret) - SecretVolumeOutputs: []any{gcpCredentialsVolume}, - // Set environment variable pointing to mounted credentials - SecretEnvs: map[string]string{ - "GOOGLE_APPLICATION_CREDENTIALS": credentialsMountPath, - }, - } - - caddy, err := pulumiKubernetes.DeployCaddyService(ctx, caddyConfig, input, params, kubeconfig) - if err != nil { - return nil, errors.Wrapf(err, "failed to create caddy deployment for cluster %q in %q", clusterName, input.StackParams.Environment) - } - out.Caddy = caddy - } - - return &api.ResourceOutput{Ref: out}, nil -} - -// convertClusterLocationToBucketLocation converts GKE cluster location to GCS bucket location -// GKE locations can be zones (us-central1-a) or regions (us-central1) -// GCS buckets should use regions for single-region buckets -func convertClusterLocationToBucketLocation(clusterLocation string) string { - // Check if location is a zone (has zone suffix like -a, -b, -c) - // Zones follow pattern: {region}-{zone} (e.g., us-central1-a) - // Regions follow pattern: {continent}-{area}{number} (e.g., us-central1, europe-west1) - - // Count dashes to determine if it's a zone or region - // Region: 2 dashes (us-central1, europe-west1) - // Zone: 3 dashes (us-central1-a, europe-west1-b) - lastDashIndex := -1 - dashCount := 0 - for i := len(clusterLocation) - 1; i >= 0; i-- { - if clusterLocation[i] == '-' { - dashCount++ - if lastDashIndex == -1 { - lastDashIndex = i - } - } - } - - // If it's a zone (has zone suffix), extract the region part - if dashCount >= 2 && lastDashIndex > 0 && lastDashIndex < len(clusterLocation)-1 { - // Check if the part after the last dash is a single letter (zone indicator) - zonePart := clusterLocation[lastDashIndex+1:] - if len(zonePart) == 1 && zonePart >= "a" && zonePart <= "z" { - // It's a zone, return the region part (everything before the last dash) - return clusterLocation[:lastDashIndex] - } - } - - // It's already a region or a multi-region location, return as-is - return clusterLocation -} - -// provisionCaddyACMEStorage provisions a GCS bucket and service account for Caddy ACME certificate storage -func provisionCaddyACMEStorage(ctx *sdk.Context, clusterName, projectID, clusterLocation string, authConfig any, opts []sdk.ResourceOption, params pApi.ProvisionParams) (*storage.Bucket, sdk.StringOutput, error) { - bucketName := fmt.Sprintf("%s-caddy-acme", clusterName) - - // Convert cluster location to GCS bucket location - // GKE location can be a zone (e.g., "us-central1-a") or region (e.g., "us-central1") - // GCS buckets need a region or multi-region location - bucketLocation := convertClusterLocationToBucketLocation(clusterLocation) - - params.Log.Info(ctx.Context(), "📦 Provisioning GCS bucket %q in location %q for Caddy ACME certificate storage", bucketName, bucketLocation) - - // Enable Cloud Resource Manager API before creating IAM bindings - // This is required for creating project-level IAM members for the service account - cloudresourcemanagerServiceName := fmt.Sprintf("projects/%s/services/cloudresourcemanager.googleapis.com", projectID) - if err := enableServicesAPI(ctx.Context(), authConfig, cloudresourcemanagerServiceName); err != nil { - return nil, sdk.StringOutput{}, errors.Wrapf(err, "failed to enable %s", cloudresourcemanagerServiceName) - } - - // Provision GCS bucket for ACME data - bucket, err := storage.NewBucket(ctx, bucketName, &storage.BucketArgs{ - Name: sdk.String(bucketName), - Location: sdk.String(bucketLocation), - UniformBucketLevelAccess: sdk.Bool(true), - LifecycleRules: storage.BucketLifecycleRuleArray{ - &storage.BucketLifecycleRuleArgs{ - Action: &storage.BucketLifecycleRuleActionArgs{ - Type: sdk.String("Delete"), - }, - Condition: &storage.BucketLifecycleRuleConditionArgs{ - Age: sdk.Int(90), // Delete old certificate data after 90 days - }, - }, - }, - }, opts...) - if err != nil { - return nil, sdk.StringOutput{}, errors.Wrapf(err, "failed to provision GCS bucket for Caddy ACME storage") - } - - params.Log.Info(ctx.Context(), "🔐 Creating service account for Caddy GCS bucket access") - - // Create service account for Caddy to access GCS bucket - saName := fmt.Sprintf("%s-caddy-sa", clusterName) - sa, err := NewServiceAccount(ctx, saName, ServiceAccountArgs{ - Project: projectID, - Description: "Service account for Caddy to access GCS bucket for ACME certificate storage", - Roles: []string{ - "roles/storage.objectAdmin", // Full access to bucket objects - }, - }, opts...) - if err != nil { - return nil, sdk.StringOutput{}, errors.Wrapf(err, "failed to create service account for Caddy") - } - - params.Log.Info(ctx.Context(), "✅ GCS bucket and service account provisioned successfully") - - // Decode base64-encoded service account key to get actual JSON credentials - // GCP's ServiceAccountKey.PrivateKey is base64-encoded, but we need the raw JSON - credentialsJSON := sa.ServiceAccountKey.PrivateKey.ApplyT(func(base64Key string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(base64Key) - if err != nil { - return "", errors.Wrapf(err, "failed to decode service account key") - } - return string(decoded), nil - }).(sdk.StringOutput) - - return bucket, credentialsJSON, nil -} - -// toKubeconfigExport is kept as a package-local alias of the shared helper rather -// than inlined at its four call sites: the name is now a contract between this -// package and every other cluster implementation (k3s, next), and the shared -// definition is the only copy allowed to know the format. -func toKubeconfigExport(clusterName string) string { - return pulumiKubernetes.ToKubeconfigExport(clusterName) -} - -func generateKubeconfig(cluster *container.Cluster, gkeInput *gcloud.GkeAutopilotResource) sdk.StringOutput { - return sdk.All(cluster.Project, cluster.Name, cluster.Endpoint, cluster.MasterAuth, - cluster.ControlPlaneEndpointsConfig).ApplyT(func(args []any) (string, error) { - project := args[0].(string) - name := args[1].(string) - endpoint := args[2].(string) - masterAuth := args[3].(container.ClusterMasterAuth) - endpoints, _ := args[4].(container.ClusterControlPlaneEndpointsConfig) - - endpoint, err := kubeconfigEndpoint(name, endpoint, endpoints, gkeInput.ControlPlaneAccess) - if err != nil { - return "", err - } - - context := fmt.Sprintf("%s_%s_%s", project, gkeInput.Zone, name) - - kubeconfig := ` -apiVersion: v1 -clusters: -- cluster: - certificate-authority-data: ${clusterCaCertificate} - server: https://${endpoint} - name: ${context} -contexts: -- context: - cluster: ${context} - user: ${context} - name: ${context} -current-context: ${context} -kind: Config -preferences: {} -users: -- name: ${context} - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - command: gke-gcloud-auth-plugin - installHint: Install gke-gcloud-auth-plugin for use with kubectl by following - https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke - provideClusterInfo: true -` - - if err := placeholders.New().Apply(&kubeconfig, placeholders.WithData((placeholders.MapData{ - "clusterCaCertificate": lo.FromPtr(masterAuth.ClusterCaCertificate), - "endpoint": endpoint, - "context": context, - }))); err != nil { - return "", errors.Wrapf(err, "failed to apply placeholders on kubeconfig template") - } - - return kubeconfig, nil - }).(sdk.StringOutput) -} - -// setupCloudNAT creates Cloud NAT resources for static egress IP -func setupCloudNAT( - ctx *sdk.Context, - gkeInput *gcloud.GkeAutopilotResource, - clusterName string, - location string, - cluster *container.Cluster, - subnetwork sdk.StringInput, // Optional: specific subnet for private VPC - out *GkeAutopilotOut, - opts []sdk.ResourceOption, - params pApi.ProvisionParams, -) error { - // Extract region from location (handle both regional and zonal locations) - region := extractRegionFromLocation(location) - - params.Log.Info(ctx.Context(), "🌐 Setting up Cloud NAT for static egress IP in region %s", region) - params.Log.Info(ctx.Context(), " 💡 Note: Private nodes were enabled for this cluster to allow Cloud NAT usage") - - // Validate that cluster and NAT will be in the same region - cluster.Location.ApplyT(func(clusterLocation string) error { - clusterRegion := extractRegionFromLocation(clusterLocation) - if clusterRegion != region { - params.Log.Warn(ctx.Context(), "⚠️ Region mismatch: cluster in %s, NAT in %s", clusterRegion, region) - } else { - params.Log.Info(ctx.Context(), "✅ Cluster and NAT both in region %s", region) - } - return nil - }) - - // Step 1: Create or reference static IP - staticIp, err := createOrReferenceStaticIp(ctx, gkeInput.ExternalEgressIp, clusterName, region, opts, params) - if err != nil { - return errors.Wrap(err, "failed to create or reference static IP") - } - out.StaticIp = staticIp - - // Step 2: Create Cloud Router (using cluster's VPC network) - router, err := createCloudRouter(ctx, clusterName, region, cluster, opts, params) - if err != nil { - return errors.Wrap(err, "failed to create Cloud Router") - } - out.Router = router - - // Step 3: Create Cloud NAT (configured for cluster's specific subnet) - nat, err := createCloudNat(ctx, clusterName, router, staticIp, region, cluster, subnetwork, gkeInput.ExternalEgressIp, opts, params) - if err != nil { - return errors.Wrap(err, "failed to create Cloud NAT") - } - out.Nat = nat - - // Export static IP address for external reference - ctx.Export(fmt.Sprintf("%s-egress-ip-address", clusterName), staticIp.Address) - ctx.Export(fmt.Sprintf("%s-egress-ip-name", clusterName), staticIp.Name) - - params.Log.Info(ctx.Context(), "✅ Cloud NAT configured successfully with static egress IP") - - return nil -} - -// createOrReferenceStaticIp creates a new static IP or references an existing one -func createOrReferenceStaticIp( - ctx *sdk.Context, - config *gcloud.ExternalEgressIpConfig, - clusterName string, - region string, - opts []sdk.ResourceOption, - params pApi.ProvisionParams, -) (*compute.Address, error) { - if config.Existing != "" { - // Use existing static IP - params.Log.Info(ctx.Context(), "🔗 Using existing static IP: %s", config.Existing) - - // Parse the existing IP reference to extract name - // Format: projects/{project}/regions/{region}/addresses/{name} - parts := strings.Split(config.Existing, "/") - if len(parts) != 6 { - return nil, errors.Errorf("invalid existing static IP reference format: %s", config.Existing) - } - addressName := parts[5] - - return compute.GetAddress(ctx, addressName, sdk.ID(config.Existing), nil, opts...) - } else { - // Create new static IP automatically - staticIpName := fmt.Sprintf("%s-egress-ip", clusterName) - params.Log.Info(ctx.Context(), "📍 Creating static IP address: %s", staticIpName) - - return compute.NewAddress(ctx, staticIpName, &compute.AddressArgs{ - Name: sdk.String(staticIpName), - Region: sdk.String(region), - AddressType: sdk.String("EXTERNAL"), - Description: sdk.String(fmt.Sprintf("Static egress IP for GKE cluster %s", clusterName)), - }, opts...) - } -} - -// createCloudRouter creates a Cloud Router for NAT -func createCloudRouter( - ctx *sdk.Context, - clusterName string, - region string, - cluster *container.Cluster, - opts []sdk.ResourceOption, - params pApi.ProvisionParams, -) (*compute.Router, error) { - routerName := fmt.Sprintf("%s-router", clusterName) - params.Log.Info(ctx.Context(), "🔀 Creating Cloud Router: %s", routerName) - - return compute.NewRouter(ctx, routerName, &compute.RouterArgs{ - Name: sdk.String(routerName), - Region: sdk.String(region), - Network: cluster.Network.ToStringPtrOutput().Elem(), // Use cluster's actual VPC network - Bgp: &compute.RouterBgpArgs{ - Asn: sdk.Int(64512), // Private ASN - }, - Description: sdk.String(fmt.Sprintf("Cloud Router for GKE cluster %s NAT", clusterName)), - }, opts...) -} - -// natPortSettings holds the resolved Cloud NAT port/mapping configuration. -type natPortSettings struct { - minPortsPerVm int - maxPortsPerVm int - endpointIndependentMapping bool - dynamicPortAllocation bool -} - -// resolveNatPortSettings applies the egress config over the historical defaults -// (64 min ports, endpoint-independent mapping on, dynamic port allocation off), -// so an unset or nil config reproduces the prior behaviour exactly. -func resolveNatPortSettings(cfg *gcloud.ExternalEgressIpConfig) natPortSettings { - s := natPortSettings{ - minPortsPerVm: gcloud.DefaultMinPortsPerVm, - maxPortsPerVm: gcloud.DefaultMaxPortsPerVm, - endpointIndependentMapping: true, - dynamicPortAllocation: false, - } - if cfg == nil { - return s - } - if cfg.MinPortsPerVm != nil { - s.minPortsPerVm = *cfg.MinPortsPerVm - } - if cfg.MaxPortsPerVm != nil { - s.maxPortsPerVm = *cfg.MaxPortsPerVm - } - if cfg.EndpointIndependentMapping != nil { - s.endpointIndependentMapping = *cfg.EndpointIndependentMapping - } - if cfg.DynamicPortAllocation != nil { - s.dynamicPortAllocation = *cfg.DynamicPortAllocation - } - // GCP rejects dynamic port allocation with endpoint-independent mapping on; - // keep the resolved settings self-consistent regardless of validation order. - if s.dynamicPortAllocation { - s.endpointIndependentMapping = false - } - return s -} - -// createCloudNat creates a Cloud NAT gateway -func createCloudNat( - ctx *sdk.Context, - clusterName string, - router *compute.Router, - staticIp *compute.Address, - region string, - cluster *container.Cluster, - subnetwork sdk.StringInput, // Optional: specific subnet for private VPC - egressCfg *gcloud.ExternalEgressIpConfig, - opts []sdk.ResourceOption, - params pApi.ProvisionParams, -) (*compute.RouterNat, error) { - natName := fmt.Sprintf("%s-nat", clusterName) - params.Log.Info(ctx.Context(), "🌐 Creating Cloud NAT gateway: %s", natName) - - // Create array of static IP references for NAT - natIps := sdk.StringArray{staticIp.SelfLink} - - ports := resolveNatPortSettings(egressCfg) - - // Configure NAT for specific GKE cluster subnet instead of all subnets - natArgs := &compute.RouterNatArgs{ - Name: sdk.String(natName), - Router: router.Name, - Region: sdk.String(region), - - // NAT configuration - use our specific static IP (not random GCP-assigned IPs) - NatIpAllocateOption: sdk.String("MANUAL_ONLY"), // Use only the IPs we specify in NatIps - NatIps: natIps, // Our static IP address - - // Port allocation - defaults preserve prior behaviour; tunable via egress config - MinPortsPerVm: sdk.Int(ports.minPortsPerVm), - MaxPortsPerVm: sdk.Int(ports.maxPortsPerVm), - - // Logging configuration - errors only for cost optimization - LogConfig: &compute.RouterNatLogConfigArgs{ - Enable: sdk.Bool(true), - Filter: sdk.String("ERRORS_ONLY"), - }, - - EnableEndpointIndependentMapping: sdk.Bool(ports.endpointIndependentMapping), - } - - // Only send EnableDynamicPortAllocation when enabling it: the historical - // default path left the field unset, so keep it absent to avoid a spurious - // resource update on existing NATs. - if ports.dynamicPortAllocation { - natArgs.EnableDynamicPortAllocation = sdk.Bool(true) - } - - // Configure NAT to target ALL IP ranges (primary + secondary) for GKE pods - params.Log.Info(ctx.Context(), "🎯 Configuring NAT for ALL IP ranges (primary + secondary)") - - // Use LIST_OF_SUBNETWORKS with ALL_IP_RANGES to include both primary and secondary ranges - // This is required for GKE Autopilot where pods use secondary IP ranges - natArgs.SourceSubnetworkIpRangesToNat = sdk.String("LIST_OF_SUBNETWORKS") - - // Configure subnet to use ALL_IP_RANGES (primary + secondary) - // Use specific subnet if provided (private VPC), otherwise use default - var subnetName sdk.StringInput - if subnetwork != nil { - subnetName = subnetwork - params.Log.Info(ctx.Context(), "🎯 Using private VPC subnet for NAT") - } else { - subnetName = sdk.String("default") - params.Log.Info(ctx.Context(), "🎯 Using default subnet for NAT") - } - - natArgs.Subnetworks = compute.RouterNatSubnetworkArray{ - &compute.RouterNatSubnetworkArgs{ - Name: subnetName, - SourceIpRangesToNats: sdk.StringArray{ - sdk.String("ALL_IP_RANGES"), // Include both primary and secondary ranges - }, - }, - } - - params.Log.Info(ctx.Context(), "🔧 NAT configured for ALL IP ranges (primary + secondary for GKE pods)") - - // Log cluster subnetwork for debugging purposes - cluster.Subnetwork.ApplyT(func(subnetwork string) error { - if subnetwork != "" { - params.Log.Info(ctx.Context(), "📍 GKE cluster using subnetwork: %s", subnetwork) - } else { - params.Log.Info(ctx.Context(), "📍 GKE cluster using default subnetwork") - } - return nil - }) - - // Add additional logging to help debug NAT configuration - params.Log.Info(ctx.Context(), "🔍 NAT Configuration Details:") - params.Log.Info(ctx.Context(), " - IP Allocation: MANUAL_ONLY (using static IP %v)", staticIp.Name.ToStringOutput()) - params.Log.Info(ctx.Context(), " - Source Ranges: LIST_OF_SUBNETWORKS with ALL_IP_RANGES") - params.Log.Info(ctx.Context(), " - Subnet: default (includes primary + secondary ranges)") - params.Log.Info(ctx.Context(), " - Port Range: %d-%d per VM (dynamic=%t, endpointIndependentMapping=%t)", ports.minPortsPerVm, ports.maxPortsPerVm, ports.dynamicPortAllocation, ports.endpointIndependentMapping) - params.Log.Info(ctx.Context(), "") - params.Log.Info(ctx.Context(), "🔍 Troubleshooting Steps if egress IP is still wrong:") - params.Log.Info(ctx.Context(), " 1. Check GCP Console → VPC Network → Cloud NAT") - params.Log.Info(ctx.Context(), " 2. Verify NAT gateway is 'Active' and using your static IP") - params.Log.Info(ctx.Context(), " 3. Check if there are multiple NAT gateways in the same region") - params.Log.Info(ctx.Context(), " 4. Restart pods to pick up new NAT configuration") - params.Log.Info(ctx.Context(), " 5. Run: kubectl delete pods --all -n ") - - // Export the static IP for easy reference - staticIp.Address.ApplyT(func(addr string) error { - params.Log.Info(ctx.Context(), "📍 Expected egress IP: %s", addr) - return nil - }) - - return compute.NewRouterNat(ctx, natName, natArgs, opts...) -} - -// extractRegionFromLocation extracts region from GKE location (handles both regional and zonal) -func extractRegionFromLocation(location string) string { - // Handle both regional (us-central1) and zonal (us-central1-a) locations - - // Special handling for complex region names that don't follow simple pattern - complexRegions := map[string]string{ - "asia-southeast1-a": "asia-southeast1", - "asia-southeast1-b": "asia-southeast1", - "asia-southeast1-c": "asia-southeast1", - "asia-southeast2-a": "asia-southeast2", - "asia-southeast2-b": "asia-southeast2", - "asia-southeast2-c": "asia-southeast2", - "asia-northeast1-a": "asia-northeast1", - "asia-northeast1-b": "asia-northeast1", - "asia-northeast1-c": "asia-northeast1", - "asia-northeast2-a": "asia-northeast2", - "asia-northeast2-b": "asia-northeast2", - "asia-northeast2-c": "asia-northeast2", - "asia-northeast3-a": "asia-northeast3", - "asia-northeast3-b": "asia-northeast3", - "asia-northeast3-c": "asia-northeast3", - "australia-southeast1-a": "australia-southeast1", - "australia-southeast1-b": "australia-southeast1", - "australia-southeast1-c": "australia-southeast1", - "australia-southeast2-a": "australia-southeast2", - "australia-southeast2-b": "australia-southeast2", - "australia-southeast2-c": "australia-southeast2", - "europe-southwest1-a": "europe-southwest1", - "europe-southwest1-b": "europe-southwest1", - "europe-southwest1-c": "europe-southwest1", - "northamerica-northeast1-a": "northamerica-northeast1", - "northamerica-northeast1-b": "northamerica-northeast1", - "northamerica-northeast1-c": "northamerica-northeast1", - "northamerica-northeast2-a": "northamerica-northeast2", - "northamerica-northeast2-b": "northamerica-northeast2", - "northamerica-northeast2-c": "northamerica-northeast2", - "southamerica-east1-a": "southamerica-east1", - "southamerica-east1-b": "southamerica-east1", - "southamerica-east1-c": "southamerica-east1", - "southamerica-west1-a": "southamerica-west1", - "southamerica-west1-b": "southamerica-west1", - "southamerica-west1-c": "southamerica-west1", - } - - // Check if it's a known complex region zone - if region, exists := complexRegions[location]; exists { - return region - } - - // For zones like us-central1-a, extract us-central1 - if strings.Count(location, "-") >= 2 { - lastDash := strings.LastIndex(location, "-") - if lastDash > 0 { - zonePart := location[lastDash+1:] - // Check if the part after the last dash is a single letter (zone indicator) - if len(zonePart) == 1 && zonePart >= "a" && zonePart <= "z" { - return location[:lastDash] - } - } - } - - // Return as-is for regional clusters (already in correct format) - return location -} - -// applyControlPlaneAccess translates the controlPlaneAccess block onto the -// cluster arguments. A nil config writes nothing, so clusters that do not ask -// for it keep whatever GKE gave them and Pulumi does not start managing the -// fields. -func applyControlPlaneAccess(args *container.ClusterArgs, cfg *gcloud.ControlPlaneAccessConfig) []string { - // Writing nothing is not the same as not managing the field. Both blocks are - // optional and not computed, so once either is in state, a program that - // stops sending it plans its REMOVAL: deleting a controlPlaneAccess block as - // tidy-up would drop the allow list and put the control plane back on the - // open internet, with a diff that reads as "removed some config". Ignoring - // them while nothing asks for them is what makes the opt-in claim true. - if cfg == nil { - return []string{"masterAuthorizedNetworksConfig", "controlPlaneEndpointsConfig"} - } - - if cfg.AuthorizedNetworksEnabled() { - blocks := make(container.ClusterMasterAuthorizedNetworksConfigCidrBlockArray, 0, len(cfg.AuthorizedNetworks)) - for _, n := range cfg.AuthorizedNetworks { - block := container.ClusterMasterAuthorizedNetworksConfigCidrBlockArgs{ - CidrBlock: sdk.String(n.Cidr), - } - if n.Name != "" { - block.DisplayName = sdk.String(n.Name) - } - blocks = append(blocks, block) - } - // GcpPublicCidrsAccessEnabled is sent explicitly because GKE defaults it - // to true, which would keep every Google Cloud public address authorised - // alongside the list. - args.MasterAuthorizedNetworksConfig = &container.ClusterMasterAuthorizedNetworksConfigArgs{ - CidrBlocks: blocks, - GcpPublicCidrsAccessEnabled: sdk.Bool(cfg.GcpPublicCidrsAllowed()), - } - } - - if cfg.DnsEndpoint == nil && cfg.IpEndpoint == nil { - return nil - } - endpoints := &container.ClusterControlPlaneEndpointsConfigArgs{} - if cfg.DnsEndpoint != nil { - endpoints.DnsEndpointConfig = &container.ClusterControlPlaneEndpointsConfigDnsEndpointConfigArgs{ - AllowExternalTraffic: sdk.Bool(cfg.DnsEndpointEnabled()), - } - } - if cfg.IpEndpoint != nil { - endpoints.IpEndpointsConfig = &container.ClusterControlPlaneEndpointsConfigIpEndpointsConfigArgs{ - Enabled: sdk.Bool(cfg.IpEndpointEnabled()), - } - } - args.ControlPlaneEndpointsConfig = endpoints - return nil -} - -// kubeconfigEndpoint picks the host a generated kubeconfig should talk to. -// -// cluster.Endpoint is the IP endpoint. With that endpoint turned off it names -// something nothing can dial, and every stack consuming the kubeconfig fails at -// connect time rather than at configuration time, so the DNS endpoint is used -// instead. The IP endpoint is still the default: an allow list narrows who can -// reach it, and deciding here whether the caller is inside that list is not -// something this code can know. -func kubeconfigEndpoint( - clusterName, ipEndpoint string, - endpoints container.ClusterControlPlaneEndpointsConfig, - cfg *gcloud.ControlPlaneAccessConfig, -) (string, error) { - if cfg.IpEndpointEnabled() { - return ipEndpoint, nil - } - dnsEndpoint := "" - if endpoints.DnsEndpointConfig != nil { - dnsEndpoint = lo.FromPtr(endpoints.DnsEndpointConfig.Endpoint) - } - if dnsEndpoint == "" { - return "", errors.Errorf( - "cluster %q has ipEndpoint disabled but reported no DNS endpoint, so no kubeconfig can be generated", - clusterName) - } - return dnsEndpoint, nil -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot_advanced_test.go b/pkg/clouds/pulumi/gcp/gke_autopilot_advanced_test.go deleted file mode 100644 index c3e5aa1c7..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot_advanced_test.go +++ /dev/null @@ -1,475 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -// Advanced and edge case tests for GKE Autopilot - -func TestGkeAutopilot_CompleteConfiguration(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with all features enabled - gkeInput := createBasicGkeInput() - gkeInput.Zone = "us-central1-a" // Add zone - gkeInput.Timeouts = &gcloud.Timeouts{ - Create: "20m", - Update: "15m", - Delete: "25m", - } - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - // Note: Caddy deployment requires complex Kubernetes setup, testing separately - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle complete configuration") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create static IP") - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(1), "Should create Cloud Router") - Expect(mocks.GetResourceCount("gcp:compute/routerNat:RouterNat")).To(Equal(1), "Should create Cloud NAT") -} - -func TestGkeAutopilot_ZonalLocation(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with zonal location - gkeInput := createBasicGkeInput() - gkeInput.Location = "europe-west1-b" // Zonal location - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle zonal location") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create GKE cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create static IP in correct region") - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(1), "Should create Cloud Router in correct region") -} - -func TestGkeAutopilot_MinimalConfiguration(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with absolute minimum configuration - gkeInput := &gcloud.GkeAutopilotResource{ - Credentials: gcloud.Credentials{ - ServiceAccountConfig: gcloud.ServiceAccountConfig{ - ProjectId: "minimal-project", - }, - }, - Location: "us-west1", - GkeMinVersion: "1.27", // Minimum supported version - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle minimal configuration") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate only essential resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(0), "Should not create static IP without external egress config") - Expect(mocks.GetResourceCount("gcp:storage/bucket:Bucket")).To(Equal(0), "Should not create GCS bucket without Caddy config") -} - -func TestGkeAutopilot_ResourceCreationFailure(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - // Note: Failure simulation is complex in Pulumi mocks, testing basic functionality instead - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare basic test inputs - gkeInput := createBasicGkeInput() - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate basic functionality works - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error for basic test") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource was created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster") -} - -func TestGkeAutopilot_CloudNatResourceFailure(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - // Note: Failure simulation is complex in Pulumi mocks, testing basic functionality instead - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with external egress IP - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate basic functionality works - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error for Cloud NAT test") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create static IP") - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(1), "Should create Cloud Router") -} - -func TestGkeAutopilot_CaddyResourceFailure(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - // Simulate GCS bucket creation failure - mocks.SimulateFailure("gcp:storage/bucket:Bucket", true) - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Skip Caddy test due to Kubernetes deployment complexity - // This test would require proper Kubernetes provider and compute context setup - gkeInput := createBasicGkeInput() - // Note: Caddy testing requires complex Kubernetes mock setup - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no error since we're not testing Caddy - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should succeed without Caddy") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate cluster was created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster successfully") -} - -func TestGkeAutopilot_MultipleExternalEgressIpConfigurations(t *testing.T) { - RegisterTestingT(t) - - testCases := []struct { - name string - config *gcloud.ExternalEgressIpConfig - expectedStaticIPs int - expectedRouters int - expectedNats int - }{ - { - name: "disabled_external_egress", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: false, - }, - expectedStaticIPs: 0, - expectedRouters: 0, - expectedNats: 0, - }, - { - name: "enabled_auto_create", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - }, - expectedStaticIPs: 1, - expectedRouters: 1, - expectedNats: 1, - }, - { - name: "enabled_existing_ip", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "projects/test-project/regions/us-central1/addresses/shared-ip", - }, - expectedStaticIPs: 0, // No new IP created - expectedRouters: 1, - expectedNats: 1, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = tc.config - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle %s configuration", tc.name) - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result for %s", tc.name) - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error for %s", tc.name) - - // Validate resource counts (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should always create one cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(tc.expectedStaticIPs), "Static IP count mismatch for %s", tc.name) - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(tc.expectedRouters), "Router count mismatch for %s", tc.name) - Expect(mocks.GetResourceCount("gcp:compute/routerNat:RouterNat")).To(Equal(tc.expectedNats), "NAT count mismatch for %s", tc.name) - }) - } -} - -func TestGkeAutopilot_DifferentRegions(t *testing.T) { - RegisterTestingT(t) - - regions := []string{ - "us-central1", - "us-west1", - "europe-west1", - "asia-southeast1", - "australia-southeast1", - } - - for _, region := range regions { - t.Run(region, func(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs for specific region - gkeInput := createBasicGkeInput() - gkeInput.Location = region - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle %s region", region) - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result for %s", region) - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error for %s", region) - - // Validate resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster in %s", region) - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create static IP in %s", region) - }) - } -} - -func TestGkeAutopilot_LongResourceNames(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with very long resource name - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - // Use a very long resource name to test name handling - resourceInput.Descriptor.Name = "very-long-cluster-name-that-might-exceed-gcp-limits-for-resource-naming" - - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors (name should be handled properly) - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle long resource names") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster with long name") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create static IP with long name") -} - -func TestGkeAutopilot_SpecialCharactersInNames(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with special characters (should be sanitized) - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - // Use name with special characters - resourceInput.Descriptor.Name = "test_cluster.with-special@chars!" - - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors (names should be sanitized) - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should handle special characters in names") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resources were created (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create cluster with sanitized name") -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot_compute_proc.go b/pkg/clouds/pulumi/gcp/gke_autopilot_compute_proc.go deleted file mode 100644 index b741e308b..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot_compute_proc.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func GkeAutopilotComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, collector pApi.ComputeContextCollector, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - params.Log.Error(ctx.Context(), "not implemented for gke autopilot") - - return &api.ResourceOutput{ - Ref: nil, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot_mocks_test.go b/pkg/clouds/pulumi/gcp/gke_autopilot_mocks_test.go deleted file mode 100644 index 598c3db42..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot_mocks_test.go +++ /dev/null @@ -1,269 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "sync" - - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" -) - -// gkeAutopilotMocks implements pulumi.MockResourceMonitor for testing GKE Autopilot -type gkeAutopilotMocks struct { - mu sync.Mutex - // Track resource creation counts by type - resourceCounts map[string]int - // Store created resources for validation - createdResources map[string]resource.PropertyMap - // Control whether to simulate failures - simulateFailures map[string]bool -} - -// newGkeAutopilotMocks creates a new mock instance -func newGkeAutopilotMocks() *gkeAutopilotMocks { - return &gkeAutopilotMocks{ - resourceCounts: make(map[string]int), - createdResources: make(map[string]resource.PropertyMap), - simulateFailures: make(map[string]bool), - } -} - -// NewResource mocks the creation of GCP resources -func (m *gkeAutopilotMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { - m.mu.Lock() - defer m.mu.Unlock() - - // Check for simulated failures - if m.simulateFailures[args.TypeToken] { - return "", nil, fmt.Errorf("simulated failure for %s", args.TypeToken) - } - - // Check if this is a resource lookup (has ID) vs creation (no ID) - isLookup := args.ID != "" - - // Only track resource creation for actual new resources, not lookups - if !isLookup { - m.resourceCounts[args.TypeToken]++ - } - - // Generate unique resource ID (will be used in switch cases) - _ = fmt.Sprintf("%s-%d", args.Name, m.resourceCounts[args.TypeToken]) - - // Get input properties - outputs := args.Inputs.Mappable() - - // Add mock outputs based on resource type - switch args.TypeToken { - case "gcp:container/cluster:Cluster": - return m.mockGkeCluster(args, outputs) - case "gcp:container:Cluster": // Alternative format - return m.mockGkeCluster(args, outputs) - case "gcp:container/Cluster": // Another alternative format - return m.mockGkeCluster(args, outputs) - case "gcp:compute/address:Address": - return m.mockStaticIP(args, outputs) - case "gcp:compute/router:Router": - return m.mockCloudRouter(args, outputs) - case "gcp:compute/routerNat:RouterNat": - return m.mockCloudNat(args, outputs) - case "gcp:storage/bucket:Bucket": - return m.mockGcsBucket(args, outputs) - case "gcp:serviceaccount/account:Account": - return m.mockServiceAccount(args, outputs) - case "gcp:serviceaccount/key:Key": - return m.mockServiceAccountKey(args, outputs) - case "kubernetes:core/v1:Namespace": - return m.mockKubernetesNamespace(args, outputs) - case "kubernetes:apps/v1:Deployment": - return m.mockKubernetesDeployment(args, outputs) - case "kubernetes:core/v1:Service": - return m.mockKubernetesService(args, outputs) - default: - // Generic mock for unknown resource types - return m.mockGenericResource(args, outputs) - } -} - -// Call mocks function calls (handles LookupCluster for adoption tests) -func (m *gkeAutopilotMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { - switch args.Token { - case "gcp:container/getCluster:getCluster": - // Mock the LookupCluster call for adoption tests - return resource.NewPropertyMapFromMap(map[string]interface{}{ - "name": "existing-cluster", - "location": "us-central1", - "minMasterVersion": "1.28", - "enableAutopilot": false, // Simulate non-autopilot cluster - "project": "test-project", - }), nil - default: - return resource.NewPropertyMapFromMap(map[string]interface{}{}), nil - } -} - -// Mock implementations for specific resource types - -func (m *gkeAutopilotMocks) mockGkeCluster(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add GKE cluster-specific outputs - outputs["endpoint"] = "https://203.0.113.10" - outputs["masterAuth"] = map[string]interface{}{ - "clusterCaCertificate": "LS0tLS1CRUdJTi0tLS0t", // Base64 mock cert - } - outputs["project"] = "test-project" - outputs["location"] = "us-central1" // Default mock location - outputs["status"] = "RUNNING" - - // Store for validation - resourceID := fmt.Sprintf("%s-cluster-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockStaticIP(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add static IP-specific outputs - outputs["address"] = "203.0.113.42" - outputs["selfLink"] = fmt.Sprintf("projects/test-project/regions/us-central1/addresses/%s", args.Name) - outputs["addressType"] = "EXTERNAL" - outputs["status"] = "RESERVED" - - resourceID := fmt.Sprintf("%s-ip-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockCloudRouter(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Cloud Router-specific outputs - outputs["selfLink"] = fmt.Sprintf("projects/test-project/regions/us-central1/routers/%s", args.Name) - outputs["creationTimestamp"] = "2023-01-01T00:00:00.000-00:00" - - resourceID := fmt.Sprintf("%s-router-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockCloudNat(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Cloud NAT-specific outputs - outputs["selfLink"] = fmt.Sprintf("projects/test-project/regions/us-central1/routers/test-router/nats/%s", args.Name) - outputs["natIpAllocateOption"] = "MANUAL_ONLY" - - resourceID := fmt.Sprintf("%s-nat-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockGcsBucket(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add GCS bucket-specific outputs - outputs["url"] = fmt.Sprintf("gs://%s", args.Name) - outputs["selfLink"] = fmt.Sprintf("https://www.googleapis.com/storage/v1/b/%s", args.Name) - - resourceID := fmt.Sprintf("%s-bucket-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockServiceAccount(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Service Account-specific outputs - outputs["email"] = fmt.Sprintf("%s@test-project.iam.gserviceaccount.com", args.Name) - outputs["uniqueId"] = "123456789012345678901" - - resourceID := fmt.Sprintf("%s-sa-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockServiceAccountKey(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Service Account Key-specific outputs (base64 encoded JSON) - _ = `{"type":"service_account","project_id":"test-project","private_key_id":"key123"}` // mockKey not used - outputs["privateKey"] = "eyJ0eXBlIjoic2VydmljZV9hY2NvdW50In0=" // base64 of mock JSON - - resourceID := fmt.Sprintf("%s-key-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockKubernetesNamespace(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Kubernetes namespace-specific outputs - outputs["status"] = map[string]interface{}{ - "phase": "Active", - } - - resourceID := fmt.Sprintf("%s-ns-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockKubernetesDeployment(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Kubernetes deployment-specific outputs - outputs["status"] = map[string]interface{}{ - "readyReplicas": 1, - "availableReplicas": 1, - "replicas": 1, - } - - resourceID := fmt.Sprintf("%s-deploy-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockKubernetesService(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Add Kubernetes service-specific outputs - outputs["status"] = map[string]interface{}{ - "loadBalancer": map[string]interface{}{ - "ingress": []interface{}{ - map[string]interface{}{ - "ip": "203.0.113.100", - }, - }, - }, - } - - resourceID := fmt.Sprintf("%s-svc-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -func (m *gkeAutopilotMocks) mockGenericResource(args pulumi.MockResourceArgs, outputs map[string]interface{}) (string, resource.PropertyMap, error) { - // Generic mock for unknown resource types - resourceID := fmt.Sprintf("%s-generic-id", args.Name) - m.createdResources[resourceID] = resource.NewPropertyMapFromMap(outputs) - - return resourceID, resource.NewPropertyMapFromMap(outputs), nil -} - -// Helper methods for test validation - -// GetResourceCount returns the number of resources created of a specific type -func (m *gkeAutopilotMocks) GetResourceCount(resourceType string) int { - return m.resourceCounts[resourceType] -} - -// GetCreatedResource returns the properties of a created resource by ID -func (m *gkeAutopilotMocks) GetCreatedResource(resourceID string) (resource.PropertyMap, bool) { - props, exists := m.createdResources[resourceID] - return props, exists -} - -// SimulateFailure configures the mock to simulate failures for a specific resource type -func (m *gkeAutopilotMocks) SimulateFailure(resourceType string, shouldFail bool) { - m.simulateFailures[resourceType] = shouldFail -} - -// Reset clears all tracked data (useful between tests) -func (m *gkeAutopilotMocks) Reset() { - m.resourceCounts = make(map[string]int) - m.createdResources = make(map[string]resource.PropertyMap) - m.simulateFailures = make(map[string]bool) -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot_stack.go b/pkg/clouds/pulumi/gcp/gke_autopilot_stack.go deleted file mode 100644 index e54f5f7cf..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot_stack.go +++ /dev/null @@ -1,327 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - "github.com/simple-container-com/api/pkg/clouds/k8s" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/clouds/pulumi/docker" - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" -) - -type GkeAutopilotOutput struct { - Provider *sdkK8s.Provider - Images []*kubernetes.ContainerImage - SimpleContainer *kubernetes.SimpleContainer -} - -func GkeAutopilotStack(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.TemplateTypeGkeAutopilot { - return nil, errors.Errorf("unsupported template type %q", input.Descriptor.Type) - } - - gkeAutopilotInput, ok := input.Descriptor.Config.Config.(*gcloud.GkeAutopilotInput) - if !ok { - return nil, errors.Errorf("failed to convert gke autopilot config for %q", input.Descriptor.Type) - } - - clusterResource := gkeAutopilotInput.GkeAutopilotTemplate.GkeClusterResource - registryResource := gkeAutopilotInput.GkeAutopilotTemplate.ArtifactRegistryResource - - // Fix for custom stacks: ensure input.StackParams.ParentEnv is set correctly - environment := input.StackParams.Environment - if params.ParentStack != nil && params.ParentStack.ParentEnv != "" && params.ParentStack.ParentEnv != environment { - // This is a custom stack - set ParentEnv so ToResName uses parent environment for resource naming - input.StackParams.ParentEnv = params.ParentStack.ParentEnv - params.Log.Info(ctx.Context(), "🔧 Custom stack detected: set input.StackParams.ParentEnv to %q for resource naming", params.ParentStack.ParentEnv) - } - - clusterName := kubernetes.ToClusterName(input, clusterResource) - registryName := toArtifactRegistryName(input, registryResource) - stackName := input.StackParams.StackName - fullParentReference := params.ParentStack.FullReference - - // Debug logging for custom stacks - params.Log.Info(ctx.Context(), "🔍 DEBUG: GKE Autopilot stack deployment") - params.Log.Info(ctx.Context(), "🔍 DEBUG: environment=%q, stackName=%q", environment, stackName) - params.Log.Info(ctx.Context(), "🔍 DEBUG: input.StackParams.ParentEnv=%q", input.StackParams.ParentEnv) - params.Log.Info(ctx.Context(), "🔍 DEBUG: params.ParentStack.ParentEnv=%q", params.ParentStack.ParentEnv) - params.Log.Info(ctx.Context(), "🔍 DEBUG: clusterResource=%q, clusterName=%q", clusterResource, clusterName) - params.Log.Info(ctx.Context(), "🔍 DEBUG: registryResource=%q, registryName=%q", registryResource, registryName) - - if clusterResource == "" { - return nil, errors.Errorf("`clusterResource` must be specified for gke autopilot config for %q/%q in %q", stackName, input.Descriptor.Name, environment) - } - - if registryResource == "" { - return nil, errors.Errorf("`artifactRegistryResource` must be specified for gke autopilot config for %q/%q in %q", stackName, input.Descriptor.Name, environment) - } - - suffix := lo.If(params.ParentStack.DependsOnResource != nil, "--"+lo.FromPtr(params.ParentStack.DependsOnResource).Name).Else("") - params.Log.Info(ctx.Context(), "Getting kubeconfig for %q from parent stack %q (%q)", clusterName, fullParentReference, suffix) - kubeConfig, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-stack-kubeconfig", clusterName, suffix), fullParentReference, toKubeconfigExport(clusterName), true) - if err != nil { - return nil, errors.Wrapf(err, "failed to get kubeconfig from parent stack's resources") - } - out := &GkeAutopilotOutput{} - - kubeProvider, err := sdkK8s.NewProvider(ctx, input.ToResName(stackName), &sdkK8s.ProviderArgs{ - Kubeconfig: kubernetes.SecretKubeconfig(kubeConfig), - EnableServerSideApply: sdk.BoolPtr(true), // Required for DeploymentPatch resources - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision kubeconfig provider for %q/%q in %q", stackName, input.Descriptor.Name, environment) - } - - out.Provider = kubeProvider - - params.Log.Info(ctx.Context(), "Getting registry url for %q from parent stack %q (%q)", registryResource, fullParentReference, suffix) - registryURL, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-stack-registryurl", clusterName, suffix), fullParentReference, toRegistryUrlExport(registryName), false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get registry url from parent stack's %q resources for resource %q", fullParentReference, registryResource) - } - if registryURL == "" { - return nil, errors.Errorf("parent stack's registry url is empty for stack %q", stackName) - } - - params.Log.Info(ctx.Context(), "Authenticating against registry %q for stack %q", registryURL, stackName) - gcpCreds, err := getDockerCredentialsWithAuthToken(ctx, input) - if err != nil { - return nil, errors.Wrapf(err, "failed to obtain access token for registry %q for stack %q", registryURL, stackName) - } - - params.Log.Info(ctx.Context(), "Building and pushing images to registry %q for stack %q in %q", registryURL, stackName, environment) - images, err := kubernetes.BuildAndPushImages(ctx, kubernetes.BuildArgs{ - RegistryURL: registryURL, - RegistryUsername: lo.ToPtr(gcpCreds.Username), - RegistryPassword: lo.ToPtr(gcpCreds.Password), - Stack: stack, - Input: input, - Params: params, - Deployment: gkeAutopilotInput.Deployment, - Opts: []sdk.ResourceOption{}, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to build and push docker images for stack %q in %q", stackName, input.StackParams.Environment) - } - out.Images = images - - params.Log.Info(ctx.Context(), "Configure simple container deployment for stack %q in %q", stackName, environment) - - // Safely extract domain and ephemeral size from StackConfig with nil checking - domain := "" - var ephemeralSize string - if gkeAutopilotInput.Deployment.StackConfig != nil { - domain = gkeAutopilotInput.Deployment.StackConfig.Domain - if gkeAutopilotInput.Deployment.StackConfig.Size != nil { - ephemeralSize = gkeAutopilotInput.Deployment.StackConfig.Size.Ephemeral - } - } - - // Debug logging for affinity rules - params.Log.Info(ctx.Context(), "🔍 DEBUG: gkeAutopilotInput.Deployment.Affinity: %+v", gkeAutopilotInput.Deployment.Affinity) - if gkeAutopilotInput.Deployment.Affinity != nil { - params.Log.Info(ctx.Context(), "🔍 DEBUG: GKE Affinity details - NodePool: %v, ComputeClass: %v, ExclusiveNodePool: %v", - gkeAutopilotInput.Deployment.Affinity.NodePool, - gkeAutopilotInput.Deployment.Affinity.ComputeClass, - gkeAutopilotInput.Deployment.Affinity.ExclusiveNodePool) - } else { - params.Log.Info(ctx.Context(), "🔍 DEBUG: gkeAutopilotInput.Deployment.Affinity is nil") - } - - // UseSSL gates two SC outputs in the kubernetes.Args contract: - // 1. `import hsts` in the per-stack Caddyfile entry → the parent - // Caddy's `(hsts)` snippet then sets Strict-Transport-Security - // site-level AND redirects HTTP→HTTPS on X-Forwarded-Proto: http. - // 2. The `ingress.kubernetes.io/ssl-redirect: false` annotation on - // the Ingress (Caddy owns the redirect, so the ingress shouldn't). - // - // We derive it from `lbConfig.https` (a per-stack yaml field that the - // consumer already sets when their stack is HTTPS-fronted) rather than - // introducing a new template-level knob — same signal, fewer concepts. - // Net behaviour: stacks declaring `lbConfig.https: true` opt into the - // HSTS snippet; everything else stays plain HTTP and the snippet stays - // inert (its `redir @httpReq` would otherwise loop on origins without - // a TLS cert). - // - // Pre-this-fix, the field was unset on the kubernetes.Args struct, so - // it landed at the bool zero value and `import hsts` was never added — - // silently dropping HSTS on every GKE Autopilot deploy regardless of - // the consumer's lbConfig.https setting. - var useSSL bool - if sc := gkeAutopilotInput.Deployment.StackConfig; sc != nil { - useSSL = lo.FromPtr(sc.LBConfig).Https - } - - kubeArgs := kubernetes.Args{ - Input: input, - Deployment: gkeAutopilotInput.Deployment, - Images: images, - Params: params, - KubeProvider: kubeProvider, - ComputeContext: params.ComputeContext, - UseSSL: useSSL, - GenerateCaddyfileEntry: domain != "", - Annotations: map[string]string{ - "pulumi.com/patchForce": "true", - }, - NodeSelector: gkeAutopilotInput.Deployment.NodeSelector, - Affinity: gkeAutopilotInput.Deployment.Affinity, - Tolerations: gkeAutopilotInput.Deployment.Tolerations, - VPA: gkeAutopilotInput.Deployment.VPA, // Pass VPA configuration to Kubernetes deployment - ReadinessProbe: gkeAutopilotInput.Deployment.ReadinessProbe, // Pass global readiness probe configuration - LivenessProbe: gkeAutopilotInput.Deployment.LivenessProbe, // Pass global liveness probe configuration - StartupProbe: gkeAutopilotInput.Deployment.StartupProbe, // Pass global startup probe configuration - ServiceType: gkeAutopilotInput.Deployment.ServiceType, // Pass Service type override (e.g. LoadBalancer for UDP) - ExternalTrafficPolicy: gkeAutopilotInput.Deployment.ExternalTrafficPolicy, // e.g. Local, required for WebRTC return media on Autopilot - EphemeralSize: ephemeralSize, - } - - params.Log.Info(ctx.Context(), "🔍 DEBUG: kubeArgs.Affinity passed to DeploySimpleContainer: %+v", kubeArgs.Affinity) - - sc, err := kubernetes.DeploySimpleContainer(ctx, kubeArgs) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision simple container for stack %q in %q", stackName, input.StackParams.Environment) - } - out.SimpleContainer = sc - - if domain != "" { - if params.Registrar == nil { - return nil, errors.Errorf("cannot provision domain %q for stack %q in %q: registrar is not configured", domain, stackName, input.StackParams.Environment) - } - clusterIPAddress, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s-%s%s-ip", stackName, input.StackParams.Environment, suffix), fullParentReference, kubernetes.ToIngressIpExport(clusterName), false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get cluster IP address from parent stack's resources") - } - - // Determine if domain should be proxied - defaults to true if not explicitly set to false - domainProxied := true - if gkeAutopilotInput.Deployment.StackConfig != nil && gkeAutopilotInput.Deployment.StackConfig.DomainProxied != nil { - domainProxied = *gkeAutopilotInput.Deployment.StackConfig.DomainProxied - } - - _, err = params.Registrar.NewRecord(ctx, api.DnsRecord{ - Name: domain, - Type: "A", - Value: clusterIPAddress, - Proxied: domainProxied, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision domain %q for stack %q in %q", domain, stackName, environment) - } - - caddyCfgExport := kubernetes.ToCaddyConfigExport(clusterName) - params.Log.Info(ctx.Context(), "Getting caddy config from parent stack %q (%s)", fullParentReference, caddyCfgExport) - params.Log.Debug(ctx.Context(), "🔧 DEBUG: clusterName=%q, suffix=%q", clusterName, suffix) - caddyConfigJson, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s-%s%s-caddy-cfg", stackName, input.StackParams.Environment, suffix), fullParentReference, caddyCfgExport, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get caddy config from parent stack's resources") - } - // DEBUG: Log caddy config JSON content and length for debugging - params.Log.Debug(ctx.Context(), "🔧 DEBUG [GKE]: Caddy config JSON content: %q", caddyConfigJson) - params.Log.Debug(ctx.Context(), "🔧 DEBUG [GKE]: Caddy config JSON length: %d", len(caddyConfigJson)) - params.Log.Debug(ctx.Context(), "🔧 DEBUG [GKE]: Caddy config JSON as bytes: %v", []byte(caddyConfigJson)) - - var caddyCfg k8s.CaddyConfig - err = json.Unmarshal([]byte(caddyConfigJson), &caddyCfg) - if err != nil { - return nil, errors.Wrapf(err, "failed to unmarshal caddy config from parent stack: JSON was %q", caddyConfigJson) - } - - // Attempt to patch caddy deployment annotations (non-critical - skip if it fails). - // Use deployment name override if specified, otherwise resolve via the parent-aware helper - // so sub-env client stacks (parentEnv != stackEnv) target the parent's caddy- - // deployment instead of a non-existent caddy-. - // input.StackParams.ParentEnv is reliably populated for this code path by the - // custom-stack fix-up at the top of this function. - defaultDeploymentName := kubernetes.CaddyDeploymentNameForChild(input.StackParams.Environment, input.StackParams.ParentEnv) - deploymentName := lo.If(caddyCfg.DeploymentName != nil, lo.FromPtr(caddyCfg.DeploymentName)).Else(defaultDeploymentName) - namespace := lo.If(caddyCfg.Namespace != nil, lo.FromPtr(caddyCfg.Namespace)).Else("caddy") - - kubeConfigOutput := sdk.String(kubeConfig).ToStringOutput() - patchResult, patchErr := kubernetes.PatchDeployment(ctx, &kubernetes.DeploymentPatchArgs{ - PatchName: input.ToResName(stackName), - ServiceName: deploymentName, - Namespace: namespace, - KubeProvider: kubeProvider, - Kubeconfig: &kubeConfigOutput, - // caddy-update-hash goes into spec.template.metadata so Caddy pods roll only when - // the Caddyfile actually changes. Content-hash, not wall-clock time, prevents - // spurious restarts (and Cloudflare 521s) on every pulumi up. - Annotations: map[string]sdk.StringOutput{ - "simple-container.com/caddy-update-hash": sdk.All(sc.CaddyfileEntry).ApplyT(func(entry []any) string { - sum := md5.Sum([]byte(entry[0].(string))) - return hex.EncodeToString(sum[:]) - }).(sdk.StringOutput), - }, - // Informational annotations live on deployment metadata only — no pod restarts. - DeploymentAnnotations: map[string]sdk.StringOutput{ - "simple-container.com/caddy-updated-by": sdk.String(stackName).ToStringOutput(), - "simple-container.com/caddy-updated-at": sdk.All(sc.CaddyfileEntry).ApplyT(func(entry []any) string { - sum := md5.Sum([]byte(entry[0].(string))) - return hex.EncodeToString(sum[:])[:8] - }).(sdk.StringOutput), - }, - Opts: []sdk.ResourceOption{sdk.DependsOn([]sdk.Resource{sc.Service})}, - }) - if patchErr != nil { - // Log warning but continue - caddy annotation patch is not critical for deployment - params.Log.Warn(ctx.Context(), " Failed to patch caddy deployment annotations (non-critical): %v", patchErr) - } else if patchResult != nil { - patchResult.ApplyT(func(msg string) string { - params.Log.Info(ctx.Context(), "✅ Caddy deployment patched: %s", msg) - return msg - }) - } - } - - return &api.ResourceOutput{Ref: out}, nil -} - -type AccessTokenCreds struct { - Username string - Password string - AuthHeader string -} - -func getDockerCredentialsWithAuthToken(ctx *sdk.Context, input api.ResourceInput) (*AccessTokenCreds, error) { - authCfg, ok := input.Descriptor.Config.Config.(api.AuthConfig) - if !ok { - return nil, errors.Errorf("failed to cast resource descriptor to api.AuthConfig") - } - ts, err := tokenSource(ctx.Context(), authCfg.CredentialsValue()) - if err != nil { - return nil, err - } - token, err := ts.Token() - if err != nil { - return nil, errors.Wrapf(err, "failed to get GCP token from credentials") - } - - username := "oauth2accesstoken" - password := token.AccessToken - authHeader, err := docker.EncodeDockerAuthHeader(username, password) - if err != nil { - return nil, errors.Wrapf(err, "failed to generate docker auth header") - } - - return &AccessTokenCreds{ - Username: username, - Password: password, - AuthHeader: authHeader, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/gke_autopilot_test.go b/pkg/clouds/pulumi/gcp/gke_autopilot_test.go deleted file mode 100644 index 8b91773a9..000000000 --- a/pkg/clouds/pulumi/gcp/gke_autopilot_test.go +++ /dev/null @@ -1,599 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// Test utilities and helpers - -// createBasicGkeInput creates a minimal GkeAutopilotResource for testing -func createBasicGkeInput() *gcloud.GkeAutopilotResource { - return &gcloud.GkeAutopilotResource{ - Credentials: gcloud.Credentials{ - ServiceAccountConfig: gcloud.ServiceAccountConfig{ - ProjectId: "test-project", - }, - }, - Location: "us-central1", - GkeMinVersion: "1.28", - } -} - -// createBasicResourceInput creates a minimal ResourceInput for testing -func createBasicResourceInput(gkeInput *gcloud.GkeAutopilotResource) api.ResourceInput { - return api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: "test-cluster", - Type: gcloud.ResourceTypeGkeAutopilot, - Config: api.Config{ - Config: gkeInput, - }, - }, - StackParams: &api.StackParams{ - Environment: "test", - }, - } -} - -// createBasicProvisionParams creates minimal ProvisionParams for testing -func createBasicProvisionParams() pApi.ProvisionParams { - return pApi.ProvisionParams{ - Log: logger.New(), - // Provider will be nil in tests - handled gracefully by resource creation functions - } -} - -// Basic GKE Autopilot cluster creation tests - -func TestGkeAutopilot_BasicClusterCreation(t *testing.T) { - RegisterTestingT(t) - - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs - gkeInput := createBasicGkeInput() - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} // Empty stack for basic test - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error for valid input") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - Expect(result.Ref).ToNot(BeNil(), "GkeAutopilot result should have Ref") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") - - // Validate services API was called - expectedServiceName := "projects/test-project/services/container.googleapis.com" - Expect(mockServicesClient.isServiceEnabled(expectedServiceName)).To(BeTrue(), "Container service should be enabled") -} - -func TestGkeAutopilot_WithExternalEgressIp(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with external egress IP - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error with external egress IP") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(1), "Should create exactly one static IP") - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(1), "Should create exactly one Cloud Router") - Expect(mocks.GetResourceCount("gcp:compute/routerNat:RouterNat")).To(Equal(1), "Should create exactly one Cloud NAT") -} - -func TestGkeAutopilot_WithExistingStaticIp(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with existing static IP - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "projects/test-project/regions/us-central1/addresses/shared-ip", - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error with existing static IP") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") - Expect(mocks.GetResourceCount("gcp:compute/address:Address")).To(Equal(0), "Should not create new static IP when using existing") - Expect(mocks.GetResourceCount("gcp:compute/router:Router")).To(Equal(1), "Should create exactly one Cloud Router") - Expect(mocks.GetResourceCount("gcp:compute/routerNat:RouterNat")).To(Equal(1), "Should create exactly one Cloud NAT") -} - -func TestGkeAutopilot_WithCaddy(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Skip Caddy configuration due to Kubernetes deployment complexity - gkeInput := createBasicGkeInput() - // Note: Caddy testing requires complex Kubernetes mock setup - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error without Caddy") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") -} - -func TestGkeAutopilot_WithCustomTimeouts(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with custom timeouts - gkeInput := createBasicGkeInput() - gkeInput.Timeouts = &gcloud.Timeouts{ - Create: "15m", - Update: "10m", - Delete: "20m", - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error with custom timeouts") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate resource creation (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create exactly one GKE cluster") -} - -// Error condition tests - -func TestGkeAutopilot_InvalidResourceType(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with wrong resource type - gkeInput := createBasicGkeInput() - resourceInput := createBasicResourceInput(gkeInput) - resourceInput.Descriptor.Type = "invalid-resource-type" - - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate error is returned - Expect(err).To(HaveOccurred(), "GkeAutopilot should return error for invalid resource type") - Expect(result).To(BeNil(), "GkeAutopilot should return nil result for invalid resource type") - - // Validate no resources were created - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(0), "Should not create any resources for invalid type") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - // Note: We expect the function to return an error, so the Pulumi test itself should succeed - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") -} - -func TestGkeAutopilot_InvalidConfigType(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with wrong config type - resourceInput := api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: "test-cluster", - Type: gcloud.ResourceTypeGkeAutopilot, - Config: api.Config{ - Config: "invalid-config-type", // Should be *gcloud.GkeAutopilotResource - }, - }, - StackParams: &api.StackParams{ - Environment: "test", - }, - } - - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate error is returned - Expect(err).To(HaveOccurred(), "GkeAutopilot should return error for invalid config type") - Expect(result).To(BeNil(), "GkeAutopilot should return nil result for invalid config type") - - // Validate no resources were created - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(0), "Should not create any resources for invalid config") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") -} - -func TestGkeAutopilot_MissingLocation(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs without location - gkeInput := createBasicGkeInput() - gkeInput.Location = "" // Missing required location - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate error is returned - Expect(err).To(HaveOccurred(), "GkeAutopilot should return error for missing location") - Expect(result).To(BeNil(), "GkeAutopilot should return nil result for missing location") - - // Validate no resources were created - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(0), "Should not create any resources without location") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") -} - -func TestGkeAutopilot_InvalidExternalEgressIpConfig(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with invalid external egress IP config - gkeInput := createBasicGkeInput() - gkeInput.ExternalEgressIp = &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "invalid-ip-format", // Invalid format - } - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate error is returned - Expect(err).To(HaveOccurred(), "GkeAutopilot should return error for invalid external egress IP config") - Expect(result).To(BeNil(), "GkeAutopilot should return nil result for invalid config") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") - - // Validate cluster was created but Cloud NAT setup failed (check after Pulumi completes) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(1), "Should create GKE cluster before validation fails") -} - -// Adoption tests - -func TestGkeAutopilot_AdoptionMode(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare test inputs with adoption enabled - gkeInput := createBasicGkeInput() - gkeInput.Adopt = true - gkeInput.ClusterName = "existing-cluster" - - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate no errors (adoption should work) - Expect(err).ToNot(HaveOccurred(), "GkeAutopilot should not return error in adoption mode") - Expect(result).ToNot(BeNil(), "GkeAutopilot should return result in adoption mode") - - // Validate no new resources were created (adoption mode) - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(0), "Should not create new cluster in adoption mode") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") -} - -func TestGkeAutopilot_ServicesAPIFailure(t *testing.T) { - RegisterTestingT(t) - - // Setup mock services API client with failure - mockServicesClient := newMockServicesAPIClient() - setGlobalServicesAPIClient(mockServicesClient) - defer resetGlobalServicesAPIClient() - - // Configure the mock to fail for container service - expectedServiceName := "projects/test-project/services/container.googleapis.com" - mockServicesClient.simulateFailure(expectedServiceName, true) - - mocks := newGkeAutopilotMocks() - - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - // Prepare basic test inputs - gkeInput := createBasicGkeInput() - resourceInput := createBasicResourceInput(gkeInput) - params := createBasicProvisionParams() - stack := api.Stack{} - - // Call function under test - result, err := GkeAutopilot(ctx, stack, resourceInput, params) - - // Validate error is returned due to services API failure - Expect(err).To(HaveOccurred(), "GkeAutopilot should return error when services API fails") - Expect(result).To(BeNil(), "GkeAutopilot should return nil result when services API fails") - - // Validate no resources were created due to early failure - Expect(mocks.GetResourceCount("gcp:container/cluster:Cluster")).To(Equal(0), "Should not create any resources when services API fails") - - return nil - }, pulumi.WithMocks("test", "test", mocks)) - - Expect(err).ToNot(HaveOccurred(), "Pulumi test should complete without error") -} - -// Region extraction tests - -func TestExtractRegionFromLocation(t *testing.T) { - RegisterTestingT(t) - - testCases := []struct { - name string - location string - expected string - }{ - { - name: "regional_location", - location: "us-central1", - expected: "us-central1", - }, - { - name: "zonal_location", - location: "us-central1-a", - expected: "us-central1", - }, - { - name: "europe_regional", - location: "europe-west1", - expected: "europe-west1", - }, - { - name: "europe_zonal", - location: "europe-west1-b", - expected: "europe-west1", - }, - { - name: "asia_regional", - location: "asia-southeast1", - expected: "asia-southeast1", - }, - { - name: "single_part", - location: "invalid", - expected: "invalid", // Should return as-is for invalid format - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - result := extractRegionFromLocation(tc.location) - Expect(result).To(Equal(tc.expected), "extractRegionFromLocation should correctly extract region from %s", tc.location) - }) - } -} - -// External Egress IP validation tests - -func TestExternalEgressIpConfig_Validation(t *testing.T) { - RegisterTestingT(t) - - testCases := []struct { - name string - config *gcloud.ExternalEgressIpConfig - expectError bool - errorMsg string - }{ - { - name: "disabled_config", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: false, - }, - expectError: false, - }, - { - name: "enabled_without_existing", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - }, - expectError: false, - }, - { - name: "valid_existing_ip", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "projects/test-project/regions/us-central1/addresses/shared-ip", - }, - expectError: false, - }, - { - name: "invalid_existing_ip_format", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "invalid-format", - }, - expectError: true, - errorMsg: "must be a full GCP resource path", - }, - { - name: "invalid_existing_ip_structure", - config: &gcloud.ExternalEgressIpConfig{ - Enabled: true, - Existing: "projects/test-project/invalid/structure", - }, - expectError: true, - errorMsg: "invalid 'existing' format", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - err := tc.config.Validate() - - if tc.expectError { - Expect(err).To(HaveOccurred(), "Validation should fail for %s", tc.name) - Expect(err.Error()).To(ContainSubstring(tc.errorMsg), "Error message should contain expected text") - } else { - Expect(err).ToNot(HaveOccurred(), "Validation should pass for %s", tc.name) - } - }) - } -} diff --git a/pkg/clouds/pulumi/gcp/gke_control_plane_access_test.go b/pkg/clouds/pulumi/gcp/gke_control_plane_access_test.go deleted file mode 100644 index 2730576e3..000000000 --- a/pkg/clouds/pulumi/gcp/gke_control_plane_access_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -func cpaBool(v bool) *bool { return &v } - -func TestApplyControlPlaneAccess_NilLeavesArgsUntouched(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - ignore := applyControlPlaneAccess(args, nil) - - Expect(args.MasterAuthorizedNetworksConfig).To(BeNil()) - Expect(args.ControlPlaneEndpointsConfig).To(BeNil()) - // Writing nothing is not enough. Both blocks are optional and not computed, - // so once either is in state a program that stops sending it plans its - // removal, which would silently re-open the control plane. - Expect(ignore).To(ConsistOf("masterAuthorizedNetworksConfig", "controlPlaneEndpointsConfig")) -} - -func TestApplyControlPlaneAccess_AuthorizedNetworks(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - _ = applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - DnsEndpoint: cpaBool(true), - AuthorizedNetworks: []gcloud.AuthorizedNetwork{ - {Name: "office", Cidr: "203.0.113.0/24"}, - {Cidr: "198.51.100.8/29"}, - }, - }) - - Expect(args.MasterAuthorizedNetworksConfig).ToNot(BeNil()) - man, ok := args.MasterAuthorizedNetworksConfig.(*container.ClusterMasterAuthorizedNetworksConfigArgs) - Expect(ok).To(BeTrue()) - - blocks, ok := man.CidrBlocks.(container.ClusterMasterAuthorizedNetworksConfigCidrBlockArray) - Expect(ok).To(BeTrue()) - Expect(blocks).To(HaveLen(2)) - - first, ok := blocks[0].(container.ClusterMasterAuthorizedNetworksConfigCidrBlockArgs) - Expect(ok).To(BeTrue()) - Expect(first.CidrBlock).To(Equal(sdk.String("203.0.113.0/24"))) - Expect(first.DisplayName).To(Equal(sdk.String("office"))) - - second, ok := blocks[1].(container.ClusterMasterAuthorizedNetworksConfigCidrBlockArgs) - Expect(ok).To(BeTrue()) - Expect(second.CidrBlock).To(Equal(sdk.String("198.51.100.8/29"))) - // An unnamed entry must stay unset rather than become an empty string. - Expect(second.DisplayName).To(BeNil()) - - // GKE defaults this to true, which would admit every Google Cloud tenant - // alongside the list, so an unset field must still be sent as false. - Expect(man.GcpPublicCidrsAccessEnabled).To(Equal(sdk.Bool(false))) -} - -func TestApplyControlPlaneAccess_GcpPublicCidrsOptIn(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - _ = applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - DnsEndpoint: cpaBool(true), - AllowGcpPublicCidrs: cpaBool(true), - AuthorizedNetworks: []gcloud.AuthorizedNetwork{{Cidr: "203.0.113.0/24"}}, - }) - - man, ok := args.MasterAuthorizedNetworksConfig.(*container.ClusterMasterAuthorizedNetworksConfigArgs) - Expect(ok).To(BeTrue()) - Expect(man.GcpPublicCidrsAccessEnabled).To(Equal(sdk.Bool(true))) -} - -func TestApplyControlPlaneAccess_Endpoints(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - _ = applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - DnsEndpoint: cpaBool(true), - IpEndpoint: cpaBool(false), - }) - - endpoints, ok := args.ControlPlaneEndpointsConfig.(*container.ClusterControlPlaneEndpointsConfigArgs) - Expect(ok).To(BeTrue()) - - dns, ok := endpoints.DnsEndpointConfig.(*container.ClusterControlPlaneEndpointsConfigDnsEndpointConfigArgs) - Expect(ok).To(BeTrue()) - Expect(dns.AllowExternalTraffic).To(Equal(sdk.Bool(true))) - - ip, ok := endpoints.IpEndpointsConfig.(*container.ClusterControlPlaneEndpointsConfigIpEndpointsConfigArgs) - Expect(ok).To(BeTrue()) - Expect(ip.Enabled).To(Equal(sdk.Bool(false))) -} - -func TestApplyControlPlaneAccess_OnlyAllowListLeavesEndpointsAlone(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - _ = applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - AuthorizedNetworks: []gcloud.AuthorizedNetwork{{Cidr: "203.0.113.0/24"}}, - }) - - Expect(args.MasterAuthorizedNetworksConfig).ToNot(BeNil()) - // Neither endpoint flag was named, so neither is taken over. - Expect(args.ControlPlaneEndpointsConfig).To(BeNil()) -} - -func TestApplyControlPlaneAccess_ExplicitlyEmptyListAuthorisesNobody(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - _ = applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - DnsEndpoint: cpaBool(true), - AuthorizedNetworks: []gcloud.AuthorizedNetwork{}, - }) - - // The block must be written, with no entries. Omitting it would leave GKE's - // default in place, which authorises every address. - man, ok := args.MasterAuthorizedNetworksConfig.(*container.ClusterMasterAuthorizedNetworksConfigArgs) - Expect(ok).To(BeTrue()) - blocks, ok := man.CidrBlocks.(container.ClusterMasterAuthorizedNetworksConfigCidrBlockArray) - Expect(ok).To(BeTrue()) - Expect(blocks).To(HaveLen(0)) - Expect(man.GcpPublicCidrsAccessEnabled).To(Equal(sdk.Bool(false))) -} - -func TestKubeconfigEndpoint(t *testing.T) { - dns := "gke-abc123.europe-north1.gke.goog" - withDNS := container.ClusterControlPlaneEndpointsConfig{ - DnsEndpointConfig: &container.ClusterControlPlaneEndpointsConfigDnsEndpointConfig{ - Endpoint: &dns, - }, - } - - for _, tt := range []struct { - name string - cfg *gcloud.ControlPlaneAccessConfig - endpoints container.ClusterControlPlaneEndpointsConfig - want string - wantErr string - }{ - { - name: "no control plane access block keeps the ip endpoint", - cfg: nil, - want: "10.0.0.1", - }, - { - name: "allow list alone keeps the ip endpoint", - cfg: &gcloud.ControlPlaneAccessConfig{ - AuthorizedNetworks: []gcloud.AuthorizedNetwork{{Cidr: "203.0.113.0/24"}}, - }, - want: "10.0.0.1", - }, - { - name: "ip endpoint disabled switches to the dns endpoint", - cfg: &gcloud.ControlPlaneAccessConfig{IpEndpoint: cpaBool(false), DnsEndpoint: cpaBool(true)}, - endpoints: withDNS, - want: dns, - }, - { - // Without this the kubeconfig would name the disabled IP endpoint and - // every consumer would fail at connect time instead of here. - name: "ip endpoint disabled with no dns endpoint reported is an error", - cfg: &gcloud.ControlPlaneAccessConfig{IpEndpoint: cpaBool(false), DnsEndpoint: cpaBool(true)}, - wantErr: "reported no DNS endpoint", - }, - } { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - got, err := kubeconfigEndpoint("test-cluster", "10.0.0.1", tt.endpoints, tt.cfg) - if tt.wantErr != "" { - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring(tt.wantErr)) - return - } - Expect(err).To(BeNil()) - Expect(got).To(Equal(tt.want)) - }) - } -} - -func TestApplyControlPlaneAccess_EndpointsOnlyLeavesTheAllowListAlone(t *testing.T) { - RegisterTestingT(t) - - args := &container.ClusterArgs{} - ignore := applyControlPlaneAccess(args, &gcloud.ControlPlaneAccessConfig{ - DnsEndpoint: cpaBool(true), - IpEndpoint: cpaBool(false), - }) - - // Writing an allow list here would produce master authorized networks with - // no entries, which authorises nobody, for a config that only asked to move - // between endpoints. - Expect(args.MasterAuthorizedNetworksConfig).To(BeNil()) - Expect(ignore).To(BeEmpty()) -} - -func TestApplyControlPlaneAccess_OnlyTheNamedEndpointIsTakenOver(t *testing.T) { - RegisterTestingT(t) - - dnsOnly := &container.ClusterArgs{} - _ = applyControlPlaneAccess(dnsOnly, &gcloud.ControlPlaneAccessConfig{DnsEndpoint: cpaBool(true)}) - endpoints, ok := dnsOnly.ControlPlaneEndpointsConfig.(*container.ClusterControlPlaneEndpointsConfigArgs) - Expect(ok).To(BeTrue()) - Expect(endpoints.DnsEndpointConfig).ToNot(BeNil()) - // Naming only the DNS endpoint must not close the IP endpoint the caller - // never mentioned. - Expect(endpoints.IpEndpointsConfig).To(BeNil()) - - ipOnly := &container.ClusterArgs{} - _ = applyControlPlaneAccess(ipOnly, &gcloud.ControlPlaneAccessConfig{IpEndpoint: cpaBool(false)}) - endpoints, ok = ipOnly.ControlPlaneEndpointsConfig.(*container.ClusterControlPlaneEndpointsConfigArgs) - Expect(ok).To(BeTrue()) - Expect(endpoints.IpEndpointsConfig).ToNot(BeNil()) - Expect(endpoints.DnsEndpointConfig).To(BeNil()) -} diff --git a/pkg/clouds/pulumi/gcp/init.go b/pkg/clouds/pulumi/gcp/init.go deleted file mode 100644 index c28c69ae5..000000000 --- a/pkg/clouds/pulumi/gcp/init.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "github.com/simple-container-com/api/pkg/clouds/gcloud" - "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func init() { - api.RegisterInitStateStore(gcloud.ProviderType, InitStateStore) - api.RegisterProvider(gcloud.ProviderType, Provider) - api.RegisterResources(map[string]api.ProvisionFunc{ - gcloud.ResourceTypeBucket: PrivateBucket, - gcloud.ResourceTypeRemoteDockerImagePush: RemoteImagePush, - gcloud.ResourceTypePubSub: PubSubTopics, - gcloud.ResourceTypePostgresGcpCloudsql: Postgres, - gcloud.ResourceTypeRedis: Redis, - gcloud.ResourceTypeGkeAutopilot: GkeAutopilot, - gcloud.ResourceTypeArtifactRegistry: ArtifactRegistry, - gcloud.SecretsProviderTypeGcpKms: KmsKeySecretsProvider, - gcloud.TemplateTypeGcpCloudrun: Cloudrun, - gcloud.TemplateTypeStaticWebsite: StaticWebsite, - gcloud.TemplateTypeGkeAutopilot: GkeAutopilotStack, - }) - api.RegisterComputeProcessor(map[string]api.ComputeProcessorFunc{ - gcloud.ResourceTypeBucket: BucketComputeProcessor, - gcloud.ResourceTypeGkeAutopilot: GkeAutopilotComputeProcessor, - gcloud.ResourceTypePostgresGcpCloudsql: PostgresComputeProcessor, - gcloud.ResourceTypeRedis: RedisComputeProcessor, - gcloud.ResourceTypePubSub: PubSubTopicsProcessor, - }) -} diff --git a/pkg/clouds/pulumi/gcp/init_pg_user_job.go b/pkg/clouds/pulumi/gcp/init_pg_user_job.go deleted file mode 100644 index 3f5499b25..000000000 --- a/pkg/clouds/pulumi/gcp/init_pg_user_job.go +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - batchv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/batch/v1" - corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" - v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" - "github.com/simple-container-com/api/pkg/util" -) - -type CloudsqlInstanceType string - -const ( - PostgreSQL CloudsqlInstanceType = "POSTGRES" - MySQL CloudsqlInstanceType = "MYSQL" -) - -// initDbUserJobDeadlineSec bounds the whole Job. A proxy that never becomes -// ready (bad credentials, missing role, no route to the private IP) leaves the -// pod in Init:CrashLoopBackOff, which never counts against BackoffLimit. -const initDbUserJobDeadlineSec = 300 - -type CloudsqlDbUser struct { - Database string - Username string -} - -type InitDbUserJobArgs struct { - User CloudsqlDbUser - RootPassword string - DBInstance PostgresDBInstanceArgs - CloudSQLProxy *CloudSQLProxy - KubeProvider *sdkK8s.Provider - DBInstanceType CloudsqlInstanceType - // Namespace is the live Namespace name Output, threaded from - // SimpleContainerArgs.NamespaceNameOutput / SimpleContainer.Namespace via - // compute_proc.go. Do not re-derive via kubernetes.GenerateNamespaceName — - // that drifts from the actual k8s name on migrated stacks where #255's - // IgnoreChanges("metadata.name") keeps the Namespace parent-shared. - Namespace sdk.StringInput - Opts []sdk.ResourceOption -} - -type InitUserJob struct { - Job sdk.Output -} - -func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs) (*InitUserJob, error) { - if args.DBInstanceType != PostgreSQL { - return nil, errors.Errorf("db-user-init Job supports %s only, got %q", PostgreSQL, args.DBInstanceType) - } - // Sanitize stack name to comply with Kubernetes RFC 1123 requirements (no underscores) - sanitizedStackName := kubernetes.SanitizeK8sName(stackName) - jobName := util.TrimStringMiddle(fmt.Sprintf("%s-db-user-init", sanitizedStackName), 60, "-") - jobCredsName := util.TrimStringMiddle(fmt.Sprintf("%s-creds", jobName), 60, "-") - - opts := args.Opts - opts = append(opts, sdk.Provider(args.KubeProvider)) - - // Secret creation - jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ - Metadata: &v1.ObjectMetaArgs{ - Namespace: args.Namespace, - Name: sdk.String(jobCredsName), - }, - StringData: sdk.StringMap{ - "PGPASSWORD": sdk.String(args.RootPassword), - "MYSQL_PWD": sdk.String(args.RootPassword), - }, - }, opts...) - if err != nil { - return nil, err - } - // Job Container creation - jobContainer := sdk.All(args.User.Database, args.User.Username).ApplyT(func(all []interface{}) corev1.ContainerArgs { - database := all[0].(string) - username := all[1].(string) - - initScript := fmt.Sprintf(` -set -e; -apk add --no-cache postgresql-client; -psql -h localhost -U postgres -d %s -c 'GRANT pg_read_all_data TO "%s";'; -psql -h localhost -U postgres -d %s -c 'GRANT pg_write_all_data TO "%s";'; - `, database, username, database, username) - - return corev1.ContainerArgs{ - Command: sdk.StringArray{ - sdk.String("sh"), - sdk.String("-c"), - sdk.String(initScript), - }, - EnvFrom: corev1.EnvFromSourceArray{ - &corev1.EnvFromSourceArgs{ - SecretRef: &corev1.SecretEnvSourceArgs{ - Name: jobCredsSecret.Metadata.Name().Elem(), - }, - }, - }, - Image: sdk.String("alpine:latest"), - Name: sdk.String("job"), - } - }) - - cloudsqlProxy := args.CloudSQLProxy - kubeProvider := args.KubeProvider - namespace := args.Namespace - - jobOut := sdk.All(cloudsqlProxy.SqlProxySecret.Metadata.Name(), jobContainer, cloudsqlProxy.ProxyContainer).ApplyT(func(args []any) (*batchv1.Job, error) { - secretName := args[0].(*string) - jobContainerArgs := args[1].(corev1.ContainerArgs) - proxyContainerArgs := args[2].(corev1.ContainerArgs) - - // Job creation - job, err := batchv1.NewJob(ctx, jobName, &batchv1.JobArgs{ - Metadata: &v1.ObjectMetaArgs{ - Name: sdk.String(jobName), - Namespace: namespace, - Annotations: sdk.StringMap{ - "pulumi.com/patchForce": sdk.String("true"), - }, - }, - Spec: &batchv1.JobSpecArgs{ - BackoffLimit: sdk.Int(5), - ActiveDeadlineSeconds: sdk.Int(initDbUserJobDeadlineSec), - Template: &corev1.PodTemplateSpecArgs{ - Spec: &corev1.PodSpecArgs{ - InitContainers: corev1.ContainerArray{proxyContainerArgs}, - Containers: corev1.ContainerArray{jobContainerArgs}, - RestartPolicy: sdk.String("Never"), - Volumes: corev1.VolumeArray{ - &corev1.VolumeArgs{ - Name: sdk.String(lo.FromPtr(secretName)), - Secret: &corev1.SecretVolumeSourceArgs{ - SecretName: sdk.StringPtrFromPtr(secretName), - }, - }, - }, - }, - }, - }, - }, append(opts, sdk.Provider(kubeProvider))...) - if err != nil { - return nil, err - } - return job, nil - }) - - if err != nil { - return nil, err - } - - // Proxy Container creation - return &InitUserJob{ - Job: jobOut, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/init_pg_user_job_test.go b/pkg/clouds/pulumi/gcp/init_pg_user_job_test.go deleted file mode 100644 index 4019b8f5e..000000000 --- a/pkg/clouds/pulumi/gcp/init_pg_user_job_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "sync" - "testing" - - . "github.com/onsi/gomega" - - gcpsdk "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - sdkK8s "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" -) - -type jobMocks struct { - mu sync.Mutex - jobs []resource.PropertyMap -} - -func (m *jobMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { - if args.TypeToken == "kubernetes:batch/v1:Job" { - m.mu.Lock() - m.jobs = append(m.jobs, args.Inputs) - m.mu.Unlock() - } - return args.Name + "_id", args.Inputs, nil -} - -func (m *jobMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { - return args.Args, nil -} - -func renderInitDbUserJob(t *testing.T) resource.PropertyMap { - t.Helper() - mocks := &jobMocks{} - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - gcpProv, err := gcpsdk.NewProvider(ctx, "gcp", &gcpsdk.ProviderArgs{Project: pulumi.String("p")}) - if err != nil { - return err - } - kubeProv, err := sdkK8s.NewProvider(ctx, "k8s", &sdkK8s.ProviderArgs{}) - if err != nil { - return err - } - db := PostgresDBInstanceArgs{Project: "p", InstanceName: "postgres--production", Region: "europe-north1"} - proxy, err := NewCloudsqlProxy(ctx, CloudSQLProxyArgs{ - Name: "app-initcsql", - DBInstance: db, - GcpProvider: gcpProv, - KubeProvider: kubeProv, - Metadata: &metav1.ObjectMetaArgs{Namespace: pulumi.String("app"), Name: pulumi.String("app-initcsql")}, - PrivateIp: true, - }) - if err != nil { - return err - } - _, err = NewInitDbUserJob(ctx, "app", InitDbUserJobArgs{ - Namespace: pulumi.String("app"), - User: CloudsqlDbUser{Database: "app", Username: "app"}, - RootPassword: "pw", - DBInstance: db, - CloudSQLProxy: proxy, - KubeProvider: kubeProv, - DBInstanceType: PostgreSQL, - }) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - Expect(err).ToNot(HaveOccurred()) - mocks.mu.Lock() - defer mocks.mu.Unlock() - Expect(mocks.jobs).To(HaveLen(1), "exactly one db-user-init Job") - return mocks.jobs[0] -} - -// The Job's psql must not race the proxy: the proxy is a native sidecar whose startup -// probe holds the job container until it listens, and the kubelet stops it once psql -// exits. Fixed sleeps on either side failed whenever the proxy started late (cold -// Autopilot node) or the job container was slow (apk add). -func TestInitDbUserJob_ProxyIsANativeSidecar(t *testing.T) { - RegisterTestingT(t) - job := renderInitDbUserJob(t) - jobSpec := job["spec"].ObjectValue() - Expect(jobSpec["activeDeadlineSeconds"].NumberValue()).To(BeEquivalentTo(initDbUserJobDeadlineSec), - "a proxy that never gets ready crash-loops in Init, which BackoffLimit never counts") - spec := jobSpec["template"].ObjectValue()["spec"].ObjectValue() - - Expect(spec["restartPolicy"].StringValue()).To(Equal("Never")) - Expect(spec).To(HaveKey(resource.PropertyKey("initContainers")), "the proxy must be an init container") - - inits := spec["initContainers"].ArrayValue() - Expect(inits).To(HaveLen(1)) - proxy := inits[0].ObjectValue() - Expect(proxy["name"].StringValue()).To(Equal("cloudsql-proxy")) - Expect(proxy["restartPolicy"].StringValue()).To(Equal("Always")) - Expect(proxy).To(HaveKey(resource.PropertyKey("startupProbe"))) - Expect(proxy["command"].ArrayValue()[0].StringValue()).To(Equal("/cloud-sql-proxy"), "no self-killing shell wrapper") - var args []string - for _, a := range proxy["args"].ArrayValue() { - args = append(args, a.StringValue()) - } - Expect(args).To(ContainElements("--private-ip", "--health-check", "--exit-zero-on-sigterm", "p:europe-north1:postgres--production")) - mount := proxy["volumeMounts"].ArrayValue()[0].ObjectValue()["name"].StringValue() - vols := spec["volumes"].ArrayValue() - Expect(vols).To(HaveLen(1)) - Expect(vols[0].ObjectValue()["name"].StringValue()).To(Equal(mount), "the credentials volume must be the one the proxy mounts") - - containers := spec["containers"].ArrayValue() - Expect(containers).To(HaveLen(1), "the proxy must not also run as a regular container") - main := containers[0].ObjectValue() - Expect(main["name"].StringValue()).To(Equal("job")) - script := main["command"].ArrayValue()[2].StringValue() - Expect(script).To(ContainSubstring(`GRANT pg_read_all_data TO "app"`)) - Expect(script).ToNot(ContainSubstring("sleep"), "psql waits on the startup probe, not a timer") -} - -func TestInitDbUserJob_RejectsNonPostgres(t *testing.T) { - RegisterTestingT(t) - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - _, err := NewInitDbUserJob(ctx, "app", InitDbUserJobArgs{DBInstanceType: MySQL}) - return err - }, pulumi.WithMocks("project", "stack", &jobMocks{})) - Expect(err).To(MatchError(ContainSubstring("supports POSTGRES only"))) -} diff --git a/pkg/clouds/pulumi/gcp/kms_key.go b/pkg/clouds/pulumi/gcp/kms_key.go deleted file mode 100644 index c24c75440..000000000 --- a/pkg/clouds/pulumi/gcp/kms_key.go +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "fmt" - "os" - - "google.golang.org/api/serviceusage/v1" - - "github.com/pkg/errors" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/kms" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger/color" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func KmsKeySecretsProvider(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - kmsInput, ok := input.Descriptor.Config.Config.(*gcloud.SecretsProviderConfig) - if !ok { - return nil, errors.Errorf("failed to convert KmsKeyInput for %q", input.Descriptor.Type) - } - - // Validate before any side effect. Enabling service APIs mutates the - // project, and a KeyRing can never be deleted in GCP (destroying the Pulumi - // resource only drops it from state), so failing later would leave a - // permanent, un-recreatable-by-name KeyRing behind for a mere typo. - if err := kmsInput.Validate(); err != nil { - return nil, err - } - - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, - fmt.Sprintf("projects/%s/services/serviceusage.googleapis.com", kmsInput.ProjectId)); err != nil { - _, _ = os.Stderr.WriteString(color.RedFmt("service usage API seems to be disabled on project %q, "+ - "please enable it manually with command or in the GCP Console ", kmsInput.ProjectId)) - _, _ = os.Stderr.WriteString(color.YellowFmt("`gcloud services enable serviceusage.googleapis.com --project %s`", kmsInput.ProjectId)) - return nil, errors.Wrapf(err, "serviceusage API is not enabled on project %q", kmsInput.ProjectId) - } - kmsServiceName := fmt.Sprintf("projects/%s/services/cloudkms.googleapis.com", kmsInput.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, kmsServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", kmsServiceName) - } - - projectId := kmsInput.ProjectIdValue() - keyRingName := input.Descriptor.Name - - // Create a new KeyRing for stack - keyRing, err := kms.NewKeyRing(ctx, keyRingName, &kms.KeyRingArgs{ - Name: sdk.String(keyRingName), - Location: sdk.String(kmsInput.KeyLocation), - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, err - } - - // Create a new CryptoKey associated with the KeyRing. - rotationPeriod := kmsInput.EffectiveKeyRotationPeriod() - - key, err := kms.NewCryptoKey(ctx, input.ToResName(input.Descriptor.Name), &kms.CryptoKeyArgs{ - Name: sdk.String(input.Descriptor.Name), - KeyRing: keyRing.ID(), // Reference the ID of the KeyRing created above. - RotationPeriod: sdk.String(rotationPeriod), // Define key rotation period in seconds. - VersionTemplate: &kms.CryptoKeyVersionTemplateArgs{ - Algorithm: sdk.String("GOOGLE_SYMMETRIC_ENCRYPTION"), - ProtectionLevel: sdk.String("SOFTWARE"), - }, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, err - } - - ctx.Export(input.Descriptor.Name, sdk.All(keyRing.Name, key.Name).ApplyT(func(args []any) (string, error) { - keyRingName, keyName := args[0].(string), args[1].(string) - return fmt.Sprintf("gcpkms://projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s", - projectId, kmsInput.KeyLocation, keyRingName, keyName), nil - })) - - return &api.ResourceOutput{Ref: key}, nil -} - -func initServicesAPIClient(ctx context.Context, resourceConfig any) (*serviceusage.Service, error) { - authCfg, ok := resourceConfig.(api.AuthConfig) - if !ok { - return nil, errors.Errorf("failed to convert config to api.AuthConfig") - } - svc, err := serviceusage.NewService(ctx, clientOptions(authCfg.CredentialsValue())...) - if err != nil { - return nil, errors.Wrapf(err, "failed to init google API services client") - } - return svc, nil -} diff --git a/pkg/clouds/pulumi/gcp/kms_key_test.go b/pkg/clouds/pulumi/gcp/kms_key_test.go deleted file mode 100644 index c1c10863b..000000000 --- a/pkg/clouds/pulumi/gcp/kms_key_test.go +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "sync" - "testing" - - . "github.com/onsi/gomega" - - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - gcpsdk "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// createBasicProvisionParams leaves Provider nil, which panics inside -// kms.NewKeyRing. Build a real provider under the mocks instead. -func kmsProvisionParams(ctx *pulumi.Context) (pApi.ProvisionParams, error) { - params := createBasicProvisionParams() - prov, err := gcpsdk.NewProvider(ctx, "test-gcp", &gcpsdk.ProviderArgs{ - Project: pulumi.String("test-project"), - }) - if err != nil { - return params, err - } - params.Provider = prov - return params, nil -} - -// kmsMocks records what the provisioner actually asked Pulumi to create, so the -// tests can assert on the value that reaches the CryptoKey rather than on the -// helper that computes it. Without this, reverting the default in -// KmsKeySecretsProvider leaves every config-level test green. -type kmsMocks struct { - mu sync.Mutex - created []pulumi.MockResourceArgs -} - -func newKmsMocks() *kmsMocks { return &kmsMocks{} } - -func (m *kmsMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.created = append(m.created, args) - return args.Name + "-id", args.Inputs, nil -} - -func (m *kmsMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { - return args.Args, nil -} - -func (m *kmsMocks) countOf(typeToken string) int { - m.mu.Lock() - defer m.mu.Unlock() - n := 0 - for _, a := range m.created { - if a.TypeToken == typeToken { - n++ - } - } - return n -} - -func (m *kmsMocks) rotationPeriod() string { - m.mu.Lock() - defer m.mu.Unlock() - for _, a := range m.created { - if a.TypeToken == "gcp:kms/cryptoKey:CryptoKey" { - if v, ok := a.Inputs["rotationPeriod"]; ok && v.IsString() { - return v.StringValue() - } - } - } - return "" -} - -func kmsResourceInput(cfg *gcloud.SecretsProviderConfig) api.ResourceInput { - return api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Type: gcloud.SecretsProviderTypeGcpKms, - Name: "test-stack-sc", - Config: api.Config{Config: cfg}, - }, - StackParams: &api.StackParams{Environment: "test"}, - } -} - -func baseKmsConfig() *gcloud.SecretsProviderConfig { - return &gcloud.SecretsProviderConfig{ - Provision: true, - KeyName: "test-key", - KeyLocation: "global", - Credentials: gcloud.Credentials{ - ServiceAccountConfig: gcloud.ServiceAccountConfig{ProjectId: "test-project"}, - }, - } -} - -// The default is the whole point of the change: an unset keyRotationPeriod must -// reach the CryptoKey as 90 days, not the previous 100000s (27.8h). -func TestKmsKeySecretsProvider_AppliesDefaultRotationPeriod(t *testing.T) { - RegisterTestingT(t) - - setGlobalServicesAPIClient(newMockServicesAPIClient()) - defer resetGlobalServicesAPIClient() - - mocks := newKmsMocks() - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - params, err := kmsProvisionParams(ctx) - if err != nil { - return err - } - _, err = KmsKeySecretsProvider(ctx, api.Stack{}, kmsResourceInput(baseKmsConfig()), params) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - - Expect(err).To(BeNil()) - Expect(mocks.rotationPeriod()).To(Equal(gcloud.DefaultKeyRotationPeriod), - "unset keyRotationPeriod must provision the 90-day default") - Expect(mocks.rotationPeriod()).NotTo(Equal("100000s"), - "the 27.8h default is what caused the key-version cost blowup") -} - -func TestKmsKeySecretsProvider_AppliesExplicitRotationPeriod(t *testing.T) { - RegisterTestingT(t) - - setGlobalServicesAPIClient(newMockServicesAPIClient()) - defer resetGlobalServicesAPIClient() - - cfg := baseKmsConfig() - cfg.KeyRotationPeriod = "31536000s" - - mocks := newKmsMocks() - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - params, err := kmsProvisionParams(ctx) - if err != nil { - return err - } - _, err = KmsKeySecretsProvider(ctx, api.Stack{}, kmsResourceInput(cfg), params) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - - Expect(err).To(BeNil()) - Expect(mocks.rotationPeriod()).To(Equal("31536000s")) -} - -// Ordering matters more than the message: a GCP KeyRing can never be deleted -// (destroying the Pulumi resource only drops it from state), so a rejected -// config must not leave one behind. Asserting zero created resources pins that, -// where asserting only the error would pass even if validation ran last. -func TestKmsKeySecretsProvider_RejectsBadPeriodBeforeCreatingAnything(t *testing.T) { - RegisterTestingT(t) - - setGlobalServicesAPIClient(newMockServicesAPIClient()) - defer resetGlobalServicesAPIClient() - - cfg := baseKmsConfig() - cfg.KeyRotationPeriod = "100000s" // the old default: below the 30-day floor - - mocks := newKmsMocks() - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - params, err := kmsProvisionParams(ctx) - if err != nil { - return err - } - _, err = KmsKeySecretsProvider(ctx, api.Stack{}, kmsResourceInput(cfg), params) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - - Expect(err).NotTo(BeNil()) - Expect(err.Error()).To(ContainSubstring("30 day")) - Expect(mocks.countOf("gcp:kms/keyRing:KeyRing")).To(Equal(0), - "a KeyRing can never be deleted; validation must run before it is created") - Expect(mocks.countOf("gcp:kms/cryptoKey:CryptoKey")).To(Equal(0)) -} diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go deleted file mode 100644 index 3955b4d33..000000000 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "sort" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" - "github.com/pulumi/pulumi-random/sdk/v4/go/random" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func Postgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypePostgresGcpCloudsql { - return nil, errors.Errorf("unsupported postgres type %q", input.Descriptor.Type) - } - - pgCfg, ok := input.Descriptor.Config.Config.(*gcloud.PostgresGcpCloudsqlConfig) - if !ok { - return nil, errors.Errorf("failed to convert postgresql config for %q", input.Descriptor.Type) - } - - if err := pgCfg.Validate(); err != nil { - return nil, err - } - - // Handle resource adoption - exit early if adopting - if pgCfg.Adopt { - return AdoptPostgres(ctx, stack, input, params) - } - - containerServiceName := fmt.Sprintf("projects/%s/services/sqladmin.googleapis.com", pgCfg.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, containerServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", containerServiceName) - } - - postgresName := toPostgresName(input, input.Descriptor.Name) - rootPasswordExport := toPostgresRootPasswordExport(postgresName) - rootPassword, err := random.NewRandomPassword(ctx, rootPasswordExport, &random.RandomPasswordArgs{ - Length: sdk.Int(16), - OverrideSpecial: sdk.String("-_"), - Special: sdk.Bool(true), - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to generate root postgres password") - } - ctx.Export(rootPasswordExport, rootPassword.Result) - - databaseFlags := toDatabaseFlagArray(configuredDatabaseFlags(pgCfg)) - - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - if pgCfg.HasPrivateServicesAccessRange() { - psa, err := privateServicesAccess(ctx, pgCfg, postgresName, params) - if err != nil { - return nil, err - } - opts = append(opts, sdk.DependsOn([]sdk.Resource{psa})) - } - - pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ - Name: sdk.String(postgresName), - Region: sdk.StringPtrFromPtr(lo.If(pgCfg.Region != nil, pgCfg.Region).Else(nil)), - DatabaseVersion: sdk.String(pgCfg.Version), - RootPassword: rootPassword.Result, - Settings: &sql.DatabaseInstanceSettingsArgs{ - Tier: sdk.String(lo.If(pgCfg.Tier != nil, lo.FromPtr(pgCfg.Tier)).Else("db-f1-micro")), - DatabaseFlags: databaseFlags, - InsightsConfig: &sql.DatabaseInstanceSettingsInsightsConfigArgs{ - QueryInsightsEnabled: sdk.Bool( - lo.If(pgCfg.QueryInsightsEnabled != nil, lo.FromPtr(pgCfg.QueryInsightsEnabled)).Else(true), - ), - QueryStringLength: sdk.Int( - lo.If(pgCfg.QueryStringLength != nil, lo.FromPtr(pgCfg.QueryStringLength)).Else(2048), - ), - }, - BackupConfiguration: backupConfiguration(pgCfg), - AvailabilityType: sdk.StringPtrFromPtr(pgCfg.AvailabilityType), - IpConfiguration: ipConfiguration(pgCfg), - }, - DeletionProtection: sdk.Bool(pgCfg.DeletionProtection != nil && *pgCfg.DeletionProtection), - }, opts...) - if err != nil { - if pgCfg.HasPrivateNetwork() { - return nil, errors.Wrapf(err, "failed to provision postgres instance %q (privateNetwork requires Private Services Access on the VPC: set privateServicesAccessRange or create it outside SC)", postgresName) - } - return nil, errors.Wrapf(err, "failed to provision postgres instance %q", postgresName) - } - - return &api.ResourceOutput{Ref: pgInstance}, nil -} - -func backupConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInstanceSettingsBackupConfigurationArgs { - if pgCfg.BackupEnabled == nil || !*pgCfg.BackupEnabled { - return nil - } - args := &sql.DatabaseInstanceSettingsBackupConfigurationArgs{ - Enabled: sdk.Bool(true), - PointInTimeRecoveryEnabled: sdk.Bool(pgCfg.PointInTimeRecoveryEnabled != nil && *pgCfg.PointInTimeRecoveryEnabled), - } - if pgCfg.BackupStartTime != nil { - args.StartTime = sdk.StringPtr(*pgCfg.BackupStartTime) - } - if pgCfg.TransactionLogRetentionDays != nil { - args.TransactionLogRetentionDays = sdk.Int(*pgCfg.TransactionLogRetentionDays) - } - if pgCfg.RetainedBackups != nil { - args.BackupRetentionSettings = &sql.DatabaseInstanceSettingsBackupConfigurationBackupRetentionSettingsArgs{ - RetainedBackups: sdk.Int(*pgCfg.RetainedBackups), - RetentionUnit: sdk.String("COUNT"), - } - } - return args -} - -// ipConfiguration returns IP settings only when they must actually be managed: -// requireSsl set, a private network set, or the public IP explicitly disabled. -// When none apply it returns nil so Pulumi leaves existing IP configuration -// (and any out-of-band authorized networks) untouched — in particular -// publicIpEnabled:true stays a no-op since public IPv4 is already the default. -// Uses SslMode (Pulumi GCP SDK v8) instead of deprecated RequireSsl. -func ipConfiguration(pgCfg *gcloud.PostgresGcpCloudsqlConfig) *sql.DatabaseInstanceSettingsIpConfigurationArgs { - publicEnabled := pgCfg.PublicIpEnabled == nil || *pgCfg.PublicIpEnabled - if pgCfg.RequireSsl == nil && !pgCfg.HasPrivateNetwork() && publicEnabled { - return nil - } - args := &sql.DatabaseInstanceSettingsIpConfigurationArgs{ - Ipv4Enabled: sdk.Bool(publicEnabled), - } - if pgCfg.RequireSsl != nil { - sslMode := "ALLOW_UNENCRYPTED_AND_ENCRYPTED" - if *pgCfg.RequireSsl { - sslMode = "ENCRYPTED_ONLY" - } - args.SslMode = sdk.String(sslMode) - } - if pgCfg.HasPrivateNetwork() { - args.PrivateNetwork = sdk.String(*pgCfg.PrivateNetwork) - } - return args -} - -func toPostgresRootPasswordExport(resName string) string { - return fmt.Sprintf("%s-root-password", resName) -} - -func toPostgresName(input api.ResourceInput, resName string) string { - return input.ToResName(resName) -} - -// configuredDatabaseFlags merges the legacy MaxConnections field with the -// generic DatabaseFlags map. An explicit max_connections entry in -// DatabaseFlags takes precedence over MaxConnections. -func configuredDatabaseFlags(pgCfg *gcloud.PostgresGcpCloudsqlConfig) map[string]string { - flags := map[string]string{} - //nolint:staticcheck // the compat shim is the one legitimate reader of the deprecated field - if pgCfg.MaxConnections != nil { - flags["max_connections"] = fmt.Sprintf("%d", *pgCfg.MaxConnections) - } - for name, value := range pgCfg.DatabaseFlags { - flags[name] = value - } - return flags -} - -// sortedFlagNames returns flag names in stable order for rendering and logs. -func sortedFlagNames(flags map[string]string) []string { - names := lo.Keys(flags) - sort.Strings(names) - return names -} - -// toDatabaseFlagArray renders flags sorted by name — map iteration order is -// random and would produce phantom diffs on every update. -func toDatabaseFlagArray(flags map[string]string) sql.DatabaseInstanceSettingsDatabaseFlagArray { - var res sql.DatabaseInstanceSettingsDatabaseFlagArray - for _, name := range sortedFlagNames(flags) { - res = append(res, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ - Name: sdk.String(name), - Value: sdk.String(flags[name]), - }) - } - return res -} diff --git a/pkg/clouds/pulumi/gcp/postgres_flags_test.go b/pkg/clouds/pulumi/gcp/postgres_flags_test.go deleted file mode 100644 index d16705138..000000000 --- a/pkg/clouds/pulumi/gcp/postgres_flags_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -func renderedFlags(arr sql.DatabaseInstanceSettingsDatabaseFlagArray) []string { - var got []string - for _, f := range arr { - args := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) - got = append(got, string(args.Name.(sdk.String))+"="+string(args.Value.(sdk.String))) - } - return got -} - -func TestConfiguredDatabaseFlags(t *testing.T) { - RegisterTestingT(t) - - t.Run("empty config yields no flags", func(t *testing.T) { - RegisterTestingT(t) - flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{}) - Expect(flags).To(BeEmpty()) - Expect(toDatabaseFlagArray(flags)).To(BeNil()) - }) - - t.Run("maxConnections maps to max_connections", func(t *testing.T) { - RegisterTestingT(t) - flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ - MaxConnections: lo.ToPtr(200), - }) - Expect(flags).To(Equal(map[string]string{"max_connections": "200"})) - }) - - t.Run("databaseFlags merge with maxConnections", func(t *testing.T) { - RegisterTestingT(t) - flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ - MaxConnections: lo.ToPtr(200), - DatabaseFlags: map[string]string{ - "cloudsql.iam_authentication": "on", - }, - }) - Expect(flags).To(Equal(map[string]string{ - "max_connections": "200", - "cloudsql.iam_authentication": "on", - })) - }) - - t.Run("explicit max_connections in databaseFlags wins", func(t *testing.T) { - RegisterTestingT(t) - flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ - MaxConnections: lo.ToPtr(200), - DatabaseFlags: map[string]string{"max_connections": "500"}, - }) - Expect(flags).To(Equal(map[string]string{"max_connections": "500"})) - }) -} - -func TestToDatabaseFlagArray(t *testing.T) { - RegisterTestingT(t) - - t.Run("sorted by flag name for deterministic diffs", func(t *testing.T) { - RegisterTestingT(t) - got := renderedFlags(toDatabaseFlagArray(map[string]string{ - "max_connections": "200", - "cloudsql.iam_authentication": "on", - "log_min_duration_statement": "500", - })) - Expect(got).To(Equal([]string{ - "cloudsql.iam_authentication=on", - "log_min_duration_statement=500", - "max_connections=200", - })) - }) -} - -func TestMergeDatabaseFlags(t *testing.T) { - RegisterTestingT(t) - - t.Run("preserves unmanaged flags, overrides configured, sorts all", func(t *testing.T) { - RegisterTestingT(t) - existing := []sql.GetDatabaseInstanceSettingDatabaseFlag{ - {Name: "max_connections", Value: "100"}, - {Name: "log_connections", Value: "on"}, - } - got := renderedFlags(mergeDatabaseFlags(existing, map[string]string{ - "max_connections": "200", - "cloudsql.iam_authentication": "on", - })) - Expect(got).To(Equal([]string{ - "cloudsql.iam_authentication=on", - "log_connections=on", - "max_connections=200", - })) - }) -} - -func TestAdoptIgnoreChanges(t *testing.T) { - RegisterTestingT(t) - - t.Run("no databaseFlags keeps the legacy no-op on settings.databaseFlags", func(t *testing.T) { - RegisterTestingT(t) - Expect(adoptIgnoreChanges(nil)).To(ContainElement("settings.databaseFlags")) - }) - - t.Run("explicit databaseFlags un-ignores settings.databaseFlags", func(t *testing.T) { - RegisterTestingT(t) - got := adoptIgnoreChanges(map[string]string{"cloudsql.iam_authentication": "on"}) - Expect(got).ToNot(ContainElement("settings.databaseFlags")) - // The rest of the protection list must stay intact. - Expect(got).To(ContainElement("settings.backupConfiguration")) - }) -} diff --git a/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go b/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go deleted file mode 100644 index bb89c056d..000000000 --- a/pkg/clouds/pulumi/gcp/postgres_ipconfig_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -// With nothing that must be managed set, ipConfiguration returns nil so Pulumi -// leaves the instance's IP configuration (and authorized networks) untouched. -func TestIpConfiguration_NilWhenUnset(t *testing.T) { - assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{})) -} - -// publicIpEnabled:true is the existing default, so it must stay a no-op and not -// start managing (and thereby wiping) the IP configuration. -func TestIpConfiguration_PublicEnabledTrueIsNoop(t *testing.T) { - assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PublicIpEnabled: lo.ToPtr(true)})) -} - -// An empty privateNetwork string is treated as unset. -func TestIpConfiguration_EmptyPrivateNetworkIsNoop(t *testing.T) { - assert.Nil(t, ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr("")})) -} - -// requireSsl-only must stay byte-identical to the prior behaviour. -func TestIpConfiguration_RequireSslTrueUnchanged(t *testing.T) { - args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: lo.ToPtr(true)}) - require.NotNil(t, args) - assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) - assert.Equal(t, sdk.String("ENCRYPTED_ONLY"), args.SslMode) - assert.Nil(t, args.PrivateNetwork) -} - -func TestIpConfiguration_RequireSslFalse(t *testing.T) { - args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{RequireSsl: lo.ToPtr(false)}) - require.NotNil(t, args) - assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) - assert.Equal(t, sdk.String("ALLOW_UNENCRYPTED_AND_ENCRYPTED"), args.SslMode) -} - -// A private network keeps the public IP on by default (safe migration) and wires -// the private network path. -func TestIpConfiguration_PrivateNetworkKeepsPublicByDefault(t *testing.T) { - net := "projects/p/global/networks/vpc" - args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{PrivateNetwork: lo.ToPtr(net)}) - require.NotNil(t, args) - assert.Equal(t, sdk.Bool(true), args.Ipv4Enabled) - assert.Equal(t, sdk.String(net), args.PrivateNetwork) -} - -// Explicitly disabling the public IP (only valid alongside a private network) -// sets Ipv4Enabled false. -func TestIpConfiguration_PublicDisabled(t *testing.T) { - net := "projects/p/global/networks/vpc" - args := ipConfiguration(&gcloud.PostgresGcpCloudsqlConfig{ - PrivateNetwork: lo.ToPtr(net), - PublicIpEnabled: lo.ToPtr(false), - }) - require.NotNil(t, args) - assert.Equal(t, sdk.Bool(false), args.Ipv4Enabled) - assert.Equal(t, sdk.String(net), args.PrivateNetwork) -} diff --git a/pkg/clouds/pulumi/gcp/private_services_access.go b/pkg/clouds/pulumi/gcp/private_services_access.go deleted file mode 100644 index 899105687..000000000 --- a/pkg/clouds/pulumi/gcp/private_services_access.go +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/servicenetworking" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -const ( - serviceNetworkingService = "servicenetworking.googleapis.com" - maxGlobalAddressName = 63 -) - -// privateServicesAccess reserves the configured range on the private network and -// peers it to Service Networking, which Cloud SQL needs before an instance can -// take a private IP on that network. -func privateServicesAccess(ctx *sdk.Context, pgCfg *gcloud.PostgresGcpCloudsqlConfig, postgresName string, params pApi.ProvisionParams) (*servicenetworking.Connection, error) { - prefix, err := pgCfg.PrivateServicesAccessPrefix() - if err != nil { - return nil, err - } - rangeName := fmt.Sprintf("%s-psa", postgresName) - if len(rangeName) > maxGlobalAddressName { - return nil, errors.Errorf("Private Services Access range name %q exceeds %d characters; shorten the postgres resource name", rangeName, maxGlobalAddressName) - } - apiName := fmt.Sprintf("projects/%s/services/%s", pgCfg.ProjectId, serviceNetworkingService) - if err := enableServicesAPI(ctx.Context(), pgCfg, apiName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", apiName) - } - - ones, _ := prefix.Mask.Size() - reserved, err := compute.NewGlobalAddress(ctx, rangeName, &compute.GlobalAddressArgs{ - Name: sdk.String(rangeName), - Purpose: sdk.String("VPC_PEERING"), - AddressType: sdk.String("INTERNAL"), - Address: sdk.String(prefix.IP.String()), - PrefixLength: sdk.Int(ones), - Network: sdk.String(lo.FromPtr(pgCfg.PrivateNetwork)), - // Kept with the abandoned connection below: deleting a range the - // peering still references either fails or frees it for reuse. - }, sdk.Provider(params.Provider), sdk.RetainOnDelete(true)) - if err != nil { - return nil, errors.Wrapf(err, "failed to reserve Private Services Access range %q", rangeName) - } - - conn, err := servicenetworking.NewConnection(ctx, fmt.Sprintf("%s-psa-connection", postgresName), &servicenetworking.ConnectionArgs{ - Network: sdk.String(lo.FromPtr(pgCfg.PrivateNetwork)), - Service: sdk.String(serviceNetworkingService), - ReservedPeeringRanges: sdk.StringArray{reserved.Name}, - // Cloud SQL holds the peering for days after its last instance is - // deleted, so removing the connection on destroy fails; abandon it. - DeletionPolicy: sdk.String("ABANDON"), - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to create Private Services Access connection for %q", postgresName) - } - return conn, nil -} diff --git a/pkg/clouds/pulumi/gcp/private_services_access_test.go b/pkg/clouds/pulumi/gcp/private_services_access_test.go deleted file mode 100644 index 7a6b2fb1f..000000000 --- a/pkg/clouds/pulumi/gcp/private_services_access_test.go +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "strings" - "sync" - "testing" - - . "github.com/onsi/gomega" - "github.com/samber/lo" - - gcpsdk "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -const ( - psaTestNetwork = "projects/p/global/networks/vpc" - psaTestConnection = "gcp:servicenetworking/connection:Connection" - psaTestAddress = "gcp:compute/globalAddress:GlobalAddress" - psaTestInstance = "gcp:sql/databaseInstance:DatabaseInstance" -) - -type psaMocks struct { - mu sync.Mutex - resources map[string][]pulumi.MockResourceArgs -} - -func (m *psaMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.resources[args.TypeToken] = append(m.resources[args.TypeToken], args) - return args.Name + "_id", args.Inputs, nil -} - -func (m *psaMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) { - return args.Args, nil -} - -func (m *psaMocks) only(t string) pulumi.MockResourceArgs { - Expect(m.resources[t]).To(HaveLen(1), "exactly one %s", t) - return m.resources[t][0] -} - -func runPostgresWithMocks(t *testing.T, pgCfg *gcloud.PostgresGcpCloudsqlConfig) (*psaMocks, *mockServicesAPIClient, error) { - t.Helper() - services := newMockServicesAPIClient() - setGlobalServicesAPIClient(services) - t.Cleanup(resetGlobalServicesAPIClient) - - mocks := &psaMocks{resources: map[string][]pulumi.MockResourceArgs{}} - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - prov, err := gcpsdk.NewProvider(ctx, "gcp", &gcpsdk.ProviderArgs{Project: pulumi.String("p")}) - if err != nil { - return err - } - params := createBasicProvisionParams() - params.Provider = prov - _, err = Postgres(ctx, api.Stack{}, api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: "postgres", - Type: gcloud.ResourceTypePostgresGcpCloudsql, - Config: api.Config{Config: pgCfg}, - }, - StackParams: &api.StackParams{Environment: "production"}, - }, params) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - delete(mocks.resources, "pulumi:providers:gcp") - return mocks, services, err -} - -func psaTestConfig(psaRange *string) *gcloud.PostgresGcpCloudsqlConfig { - cfg := &gcloud.PostgresGcpCloudsqlConfig{ - Version: "POSTGRES_14", - PrivateNetwork: lo.ToPtr(psaTestNetwork), - PrivateServicesAccessRange: psaRange, - } - cfg.ProjectId = "p" - return cfg -} - -func TestPostgres_PrivateServicesAccessCreatedBeforeInstance(t *testing.T) { - RegisterTestingT(t) - mocks, services, err := runPostgresWithMocks(t, psaTestConfig(lo.ToPtr("10.30.0.0/20"))) - Expect(err).ToNot(HaveOccurred()) - - Expect(services.isServiceEnabled("projects/p/services/servicenetworking.googleapis.com")).To(BeTrue()) - - addr := mocks.only(psaTestAddress).Inputs - Expect(addr["purpose"].StringValue()).To(Equal("VPC_PEERING")) - Expect(addr["addressType"].StringValue()).To(Equal("INTERNAL")) - Expect(addr["address"].StringValue()).To(Equal("10.30.0.0")) - Expect(addr["prefixLength"].NumberValue()).To(BeEquivalentTo(20)) - Expect(addr["network"].StringValue()).To(Equal(psaTestNetwork)) - rangeName := addr["name"].StringValue() - Expect(rangeName).To(Equal("postgres--production-psa")) - addrArgs := mocks.only(psaTestAddress) - Expect(addrArgs.RegisterRPC.GetRetainOnDelete()).To(BeTrue(), "the range must outlive the abandoned peering") - - conn := mocks.only(psaTestConnection) - Expect(conn.Inputs["service"].StringValue()).To(Equal("servicenetworking.googleapis.com")) - Expect(conn.Inputs["network"].StringValue()).To(Equal(psaTestNetwork)) - Expect(conn.Inputs["deletionPolicy"].StringValue()).To(Equal("ABANDON")) - Expect(conn.RegisterRPC.GetDependencies()).To(ContainElement(ContainSubstring(psaTestAddress+"::"+addrArgs.Name)), - "the connection must reference the reserved range resource, not a literal name") - ranges := conn.Inputs["reservedPeeringRanges"].ArrayValue() - Expect(ranges).To(HaveLen(1)) - Expect(ranges[0].StringValue()).To(Equal(rangeName)) - - instance := mocks.only(psaTestInstance) - Expect(instance.RegisterRPC).ToNot(BeNil()) - dependsOnConnection := lo.ContainsBy(instance.RegisterRPC.GetDependencies(), func(urn string) bool { - return strings.Contains(urn, psaTestConnection+"::"+conn.Name) - }) - Expect(dependsOnConnection).To(BeTrue(), "instance must wait for the peering, got %v", instance.RegisterRPC.GetDependencies()) - ip := instance.Inputs["settings"].ObjectValue()["ipConfiguration"].ObjectValue() - Expect(ip["privateNetwork"].StringValue()).To(Equal(psaTestNetwork)) - Expect(ip["ipv4Enabled"].BoolValue()).To(BeTrue()) -} - -// Without the range the instance must not touch Service Networking, so stacks -// whose VPC already has Private Services Access are unchanged. -func TestPostgres_NoPrivateServicesAccessWithoutRange(t *testing.T) { - RegisterTestingT(t) - for _, psaRange := range []*string{nil, lo.ToPtr("")} { - mocks, services, err := runPostgresWithMocks(t, psaTestConfig(psaRange)) - Expect(err).ToNot(HaveOccurred()) - Expect(mocks.resources[psaTestAddress]).To(BeEmpty()) - Expect(mocks.resources[psaTestConnection]).To(BeEmpty()) - Expect(services.isServiceEnabled("projects/p/services/servicenetworking.googleapis.com")).To(BeFalse()) - Expect(mocks.only(psaTestInstance).RegisterRPC.GetDependencies()).ToNot(ContainElement(ContainSubstring(psaTestConnection))) - } -} - -func TestPostgres_InvalidPrivateServicesAccessRangeFailsBeforeAnyResource(t *testing.T) { - RegisterTestingT(t) - mocks, services, err := runPostgresWithMocks(t, psaTestConfig(lo.ToPtr("10.30.0.0/25"))) - Expect(err).To(MatchError(ContainSubstring("/24 or larger"))) - Expect(mocks.resources).To(BeEmpty()) - Expect(services.isServiceEnabled("projects/p/services/servicenetworking.googleapis.com")).To(BeFalse()) -} - -func TestPostgres_PrivateServicesAccessRangeNameTooLong(t *testing.T) { - RegisterTestingT(t) - services := newMockServicesAPIClient() - setGlobalServicesAPIClient(services) - t.Cleanup(resetGlobalServicesAPIClient) - - mocks := &psaMocks{resources: map[string][]pulumi.MockResourceArgs{}} - err := pulumi.RunErr(func(ctx *pulumi.Context) error { - prov, err := gcpsdk.NewProvider(ctx, "gcp", &gcpsdk.ProviderArgs{Project: pulumi.String("p")}) - if err != nil { - return err - } - params := createBasicProvisionParams() - params.Provider = prov - _, err = Postgres(ctx, api.Stack{}, api.ResourceInput{ - Descriptor: &api.ResourceDescriptor{ - Name: strings.Repeat("x", 50), - Type: gcloud.ResourceTypePostgresGcpCloudsql, - Config: api.Config{Config: psaTestConfig(lo.ToPtr("10.30.0.0/20"))}, - }, - StackParams: &api.StackParams{Environment: "production"}, - }, params) - return err - }, pulumi.WithMocks("project", "stack", mocks)) - Expect(err).To(MatchError(ContainSubstring("exceeds 63 characters"))) - Expect(mocks.resources[psaTestAddress]).To(BeEmpty()) - Expect(services.isServiceEnabled("projects/p/services/servicenetworking.googleapis.com")).To(BeFalse()) -} diff --git a/pkg/clouds/pulumi/gcp/provider.go b/pkg/clouds/pulumi/gcp/provider.go deleted file mode 100644 index cf8da1b6d..000000000 --- a/pkg/clouds/pulumi/gcp/provider.go +++ /dev/null @@ -1,421 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "fmt" - "net/http" - "os" - "os/exec" - "strings" - - gcpStorage "cloud.google.com/go/storage" - "google.golang.org/api/googleapi" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -func InitStateStore(ctx context.Context, stateStoreCfg api.StateStorageConfig, log logger.Logger) error { - authCfg, ok := stateStoreCfg.(api.AuthConfig) - if !ok { - return errors.Errorf("failed to convert gcloud state storage config to api.AuthConfig") - } - log.Info(ctx, "Initializing gcp statestore...") - // SECURITY: Never log actual credential values - log.Debug(ctx, "🔍 GCP Auth Debug - Credentials length: %d", len(authCfg.CredentialsValue())) - - credValue := authCfg.CredentialsValue() - ambient := gcloud.UsesAmbientCredentials(credValue) - if ambient { - log.Info(ctx, "GCP credentials are empty: using Application Default Credentials (ambient mode)") - } else if credValue[0] == '$' { - log.Debug(ctx, "❌ GCP credentials contain unresolved placeholder (starts with '$')") - } else if credValue[0] == '{' { - log.Debug(ctx, "✅ GCP credentials appear to be valid JSON") - } else { - log.Debug(ctx, "⚠️ GCP credentials format unknown (doesn't start with '{' or '$')") - } - - // hackily set google creds env variable, so that bucket can access it (see github.com/pulumi/pulumi/pkg/v3/authhelpers/gcpauth.go:28) - // In ambient mode GOOGLE_CREDENTIALS is not set: the Pulumi backend falls back to - // Application Default Credentials. gcloud does not, so it is signed in below. - if ambient { - // nothing to activate - } else if err := os.Setenv("GOOGLE_CREDENTIALS", credValue); err != nil { - fmt.Println("Failed to set GOOGLE_CREDENTIALS env variable: ", err.Error()) - } - - if ambient { - loginGcloudWithAmbientCredentials(ctx, log) - } else { - activateGcloudServiceAccount(ctx, log, credValue) - } - - if !stateStoreCfg.IsProvisionEnabled() { - return nil - } - - // provision bucket - gcpStateCfg, ok := authCfg.(*gcloud.StateStorageConfig) - if !ok { - return errors.Errorf("failed to convert auth config to *gcloud.Credentials") - } - client, err := gcpStorage.NewClient(ctx, clientOptions(credValue)...) - if err != nil { - return errors.Wrapf(err, "failed to initialize gcp client") - } - defer func(client *gcpStorage.Client) { - _ = client.Close() - }(client) - bucketRef := client.Bucket(gcpStateCfg.GetBucketName()) - retentionDays := gcpStateCfg.EffectiveNoncurrentVersionRetentionDays() - - attrs, err := bucketRef.Attrs(ctx) - if err != nil { - switch stateBucketReadOutcome(err) { - case stateBucketForbidden: - log.Warn(ctx, "cannot read state bucket %q metadata (permission denied); it exists, so it is not created and its lifecycle is not checked", gcpStateCfg.GetBucketName()) - return nil - case stateBucketReadFailed: - return errors.Wrapf(err, "failed to read state bucket %q", gcpStateCfg.GetBucketName()) - } - return bucketRef.Create(ctx, gcpStateCfg.ProjectId, &gcpStorage.BucketAttrs{ - Location: lo.FromPtr(gcpStateCfg.Location), - Lifecycle: StateHistoryLifecycle(retentionDays), - }) - } - - if ShouldApplyStateHistoryLifecycle(attrs, retentionDays) { - log.Info(ctx, "state bucket %q has object versioning enabled and no lifecycle policy of its own; "+ - "bounding superseded state generations and %s to %d days", - gcpStateCfg.GetBucketName(), PulumiBackupsPrefix, retentionDays) - if _, err := bucketRef.Update(ctx, gcpStorage.BucketAttrsToUpdate{ - Lifecycle: lo.ToPtr(StateHistoryLifecycle(retentionDays)), - }); err != nil { - return errors.Wrapf(err, "failed to bound state history on bucket %q", gcpStateCfg.GetBucketName()) - } - } - return nil -} - -// PulumiBackupsPrefix is where the Pulumi gs backend writes a per-update copy of -// each stack's state. Every rule that can delete a LIVE object must be scoped to -// this prefix; a prefix-less age rule would delete the current state file. -const PulumiBackupsPrefix = ".pulumi/backups/" - -// NoncurrentVersionLifecycle deletes state generations once they have been -// superseded for retentionDays. It targets Archived objects only, so the live -// state file is never a candidate. A non-positive retention yields no rules. -func NoncurrentVersionLifecycle(retentionDays int) gcpStorage.Lifecycle { - if retentionDays <= 0 { - return gcpStorage.Lifecycle{} - } - return gcpStorage.Lifecycle{ - Rules: []gcpStorage.LifecycleRule{ - { - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - Liveness: gcpStorage.Archived, - DaysSinceNoncurrentTime: int64(retentionDays), - }, - }, - }, - } -} - -// StateHistoryLifecycle bounds BOTH halves of a state bucket's history. -// -// The noncurrent rule alone does not do that, and the gap is not academic. -// Pulumi writes each per-update backup under .pulumi/backups as its own LIVE -// object, so it never becomes noncurrent and a DaysSinceNoncurrentTime rule -// never reaches it. Measured on one real bucket 2026-08-20: 11,947 such objects -// going back to the day the bucket was created, none of them ever eligible for -// deletion. -// -// That matters beyond storage, which is trivial for state files. Each backup -// carries the stack's data key wrapped under whichever KMS key version was -// primary when it was written, so an unbounded backups prefix keeps nearly every -// key version alive and billable. On the bucket above that was ~7,000 versions, -// around $420/mo, none of it reclaimable while the prefix grows without limit. -// -// The second rule is therefore age-based and LIVE-matching, which is only safe -// because it is scoped to the backups prefix. It is emitted only when that -// prefix is non-empty, so a refactor that loses the scope produces no rule -// rather than a rule that deletes live state. -func StateHistoryLifecycle(retentionDays int) gcpStorage.Lifecycle { - lc := NoncurrentVersionLifecycle(retentionDays) - if retentionDays <= 0 || PulumiBackupsPrefix == "" { - return lc - } - lc.Rules = append(lc.Rules, gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - AgeInDays: int64(retentionDays), - MatchesPrefix: []string{PulumiBackupsPrefix}, - }, - }) - return lc -} - -// isManagedStateHistoryRule reports whether a rule is one this package writes. -// -// Used to tell "a policy Simple Container put here" apart from "a policy the -// operator wrote", so the former can be upgraded when this package learns a new -// rule while the latter is still never touched. The match is on exact shape, not -// on the day count, because the day count comes from the operator's config and -// may legitimately have changed since it was written. -func isManagedStateHistoryRule(rule gcpStorage.LifecycleRule) bool { - if rule.Action.Type != gcpStorage.DeleteAction { - return false - } - c := rule.Condition - noExtras := c.CreatedBefore.IsZero() && c.CustomTimeBefore.IsZero() && - c.DaysSinceCustomTime == 0 && c.NumNewerVersions == 0 && - len(c.MatchesStorageClasses) == 0 && len(c.MatchesSuffix) == 0 - if !noExtras { - return false - } - // The noncurrent rule. - if c.Liveness == gcpStorage.Archived && c.DaysSinceNoncurrentTime > 0 && - c.AgeInDays == 0 && len(c.MatchesPrefix) == 0 { - return true - } - // The backups rule. - if c.AgeInDays > 0 && c.DaysSinceNoncurrentTime == 0 && - len(c.MatchesPrefix) == 1 && c.MatchesPrefix[0] == PulumiBackupsPrefix { - return true - } - return false -} - -// hasBackupsPruningRule reports whether anything already bounds the backups -// prefix, however it was written. An operator who bounds it their own way must -// not be overridden. -func hasBackupsPruningRule(attrs *gcpStorage.BucketAttrs) bool { - if attrs == nil { - return false - } - for _, rule := range attrs.Lifecycle.Rules { - for _, prefix := range rule.Condition.MatchesPrefix { - if strings.HasPrefix(PulumiBackupsPrefix, prefix) || strings.HasPrefix(prefix, PulumiBackupsPrefix) { - return true - } - } - } - return false -} - -// ShouldApplyStateHistoryLifecycle decides whether to write the full two-rule -// policy onto a bucket that already exists. -// -// It extends ShouldApplyNoncurrentVersionLifecycle with one case: a bucket whose -// rules were all written by this package. Without that, the guard below excludes -// every bucket Simple Container has already touched, so the backups rule would -// only ever reach newly created buckets and the buckets that actually have the -// problem would never be fixed. -// -// The property the original guard exists for is kept intact: a bucket carrying -// any rule this package did not write is left completely alone, and so is a -// bucket where the backups prefix is already bounded by some other means. -func ShouldApplyStateHistoryLifecycle(attrs *gcpStorage.BucketAttrs, retentionDays int) bool { - if attrs == nil || retentionDays <= 0 { - return false - } - if !attrs.VersioningEnabled { - return false - } - if len(attrs.Lifecycle.Rules) == 0 { - return true - } - if hasBackupsPruningRule(attrs) { - return false - } - for _, rule := range attrs.Lifecycle.Rules { - if !isManagedStateHistoryRule(rule) { - return false - } - } - return true -} - -// ShouldApplyNoncurrentVersionLifecycle decides whether to modify a state -// bucket that already exists. -// -// Deprecated: use ShouldApplyStateHistoryLifecycle. This one is retained for -// compatibility but is too narrow to be useful on its own: it excludes every -// bucket that already carries a rule, including the buckets whose only rule was -// written by this package, which are exactly the ones still needing the backups -// prefix bounded. -// -// Adding a rule to somebody's existing bucket is -// the kind of thing that should be narrow and predictable, so all three -// conditions must hold: -// -// - retention is enabled; -// - object versioning is on, so noncurrent generations actually accumulate -// and the rule is not merely inert; -// - the bucket has no lifecycle rules at all, so an operator's own policy is -// never edited, replaced or merged with. -// -// A bucket that already carries any rule is left alone even if that rule does -// not bound noncurrent versions. Guessing at intent there would be worse than -// leaving it to the operator. -func ShouldApplyNoncurrentVersionLifecycle(attrs *gcpStorage.BucketAttrs, retentionDays int) bool { - if attrs == nil || retentionDays <= 0 { - return false - } - if !attrs.VersioningEnabled { - return false - } - return len(attrs.Lifecycle.Rules) == 0 -} - -func Provider(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - pcfg, ok := input.Descriptor.Config.Config.(api.AuthConfig) - if !ok { - return nil, errors.Errorf("failed to cast config to api.AuthConfig") - } - - creds := pcfg.CredentialsValue() - projectId := pcfg.ProjectIdValue() - - args := &gcp.ProviderArgs{Project: sdk.String(projectId)} - if !gcloud.UsesAmbientCredentials(creds) { - // Without Credentials the provider uses Application Default Credentials. - args.Credentials = secretCredentials(creds) - } - provider, err := gcp.NewProvider(ctx, input.ToResName(input.Descriptor.Name), args) - return &api.ResourceOutput{ - Ref: provider, - }, err -} - -type stateBucketRead int - -const ( - stateBucketMissing stateBucketRead = iota - stateBucketForbidden - stateBucketReadFailed -) - -// stateBucketReadOutcome classifies a failed read of the state bucket's metadata. -// Only a bucket that does not exist is created. A denied read proves the bucket -// exists (Cloud Storage answers 404 for a missing one), so an identity that may -// write its own state objects but not read bucket metadata still deploys. Any -// other error stops the run instead of being taken for a missing bucket. -func stateBucketReadOutcome(err error) stateBucketRead { - if errors.Is(err, gcpStorage.ErrBucketNotExist) { - return stateBucketMissing - } - var gerr *googleapi.Error - if errors.As(err, &gerr) && gerr.Code == http.StatusForbidden { - return stateBucketForbidden - } - if status.Code(err) == codes.PermissionDenied { - return stateBucketForbidden - } - return stateBucketReadFailed -} - -// runGcloud runs the gcloud CLI with this process's environment; tests replace it. -var runGcloud = func(args ...string) ([]byte, error) { - gcloudPath, err := exec.LookPath("gcloud") - if err != nil { - return nil, err - } - return exec.Command(gcloudPath, args...).CombinedOutput() -} - -// activateGcloudServiceAccount activates the configured service account key in -// gcloud, for kubeconfigs that authenticate through gke-gcloud-auth-plugin. gcloud -// keeps its own copy of the key, so the temporary file is removed afterwards. -func activateGcloudServiceAccount(ctx context.Context, log logger.Logger, key string) { - f, err := os.CreateTemp("", "google-creds-*.json") - if err != nil { - log.Warn(ctx, "failed to create a temp file for the gcloud key: %v", err) - return - } - defer func() { _ = os.Remove(f.Name()) }() - if _, err := f.WriteString(key); err != nil { - _ = f.Close() - log.Warn(ctx, "failed to write the gcloud key: %v", err) - return - } - if err := f.Close(); err != nil { - log.Warn(ctx, "failed to write the gcloud key: %v", err) - return - } - if out, err := runGcloud("auth", "activate-service-account", "--key-file", f.Name()); err != nil { - if errors.Is(err, exec.ErrNotFound) { - log.Debug(ctx, "gcloud is not installed; skipping service account activation") - return - } - log.Warn(ctx, "failed to activate the gcloud service account: %v: %s", err, strings.TrimSpace(string(out))) - } -} - -// loginGcloudWithAmbientCredentials signs gcloud in with the credentials file -// Application Default Credentials name. Kubeconfigs authenticate through -// gke-gcloud-auth-plugin, which asks gcloud for a token, and gcloud does not use -// a Workload Identity Federation config on its own: with no account signed in it -// reports none selected. Key mode activates the service account key for the same -// reason. -// -// It runs only in GitHub Actions, where nothing else signs gcloud in; on a -// workstation gcloud already carries the user's own account. The sign-in goes to -// a gcloud configuration directory private to this process (CLOUDSDK_CONFIG, -// inherited by the processes it starts), so the account later steps of the job -// share in their home directory is never switched. -func loginGcloudWithAmbientCredentials(ctx context.Context, log logger.Logger) { - credFile := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") - if credFile == "" || os.Getenv("GITHUB_ACTIONS") != "true" { - return - } - if st, err := os.Stat(credFile); err != nil || !st.Mode().IsRegular() { - log.Warn(ctx, "GOOGLE_APPLICATION_CREDENTIALS names no readable file; gcloud is not signed in, and GKE kubeconfigs will not authenticate") - return - } - configDir, err := os.MkdirTemp("", "sc-gcloud-") - if err != nil { - log.Warn(ctx, "failed to create a private gcloud configuration: %v; GKE kubeconfigs will not authenticate", err) - return - } - prev, hadPrev := os.LookupEnv("CLOUDSDK_CONFIG") - restore := func() { - if hadPrev { - _ = os.Setenv("CLOUDSDK_CONFIG", prev) - } else { - _ = os.Unsetenv("CLOUDSDK_CONFIG") - } - _ = os.RemoveAll(configDir) - } - if err := os.Setenv("CLOUDSDK_CONFIG", configDir); err != nil { - restore() - log.Warn(ctx, "failed to point gcloud at a private configuration: %v", err) - return - } - out, err := runGcloud("auth", "login", "--cred-file="+credFile, "--quiet") - if err != nil { - restore() - if errors.Is(err, exec.ErrNotFound) { - log.Debug(ctx, "gcloud is not installed; skipping its sign-in") - return - } - log.Warn(ctx, "failed to sign gcloud in with the ambient credentials: %v: %s; GKE kubeconfigs will not authenticate", err, strings.TrimSpace(string(out))) - return - } - log.Info(ctx, "gcloud signed in with the ambient credentials (private configuration)") -} diff --git a/pkg/clouds/pulumi/gcp/pubsub.go b/pkg/clouds/pulumi/gcp/pubsub.go deleted file mode 100644 index e651de04e..000000000 --- a/pkg/clouds/pulumi/gcp/pubsub.go +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "encoding/base64" - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/pubsub" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - "github.com/simple-container-com/api/pkg/clouds/k8s" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" -) - -func PubSubTopics(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypePubSub { - return nil, errors.Errorf("unsupported pubsub topics type %q", input.Descriptor.Type) - } - - pubsubCfg, ok := input.Descriptor.Config.Config.(*gcloud.PubSubConfig) - if !ok { - return nil, errors.Errorf("failed to convert pubsub topics config for %q", input.Descriptor.Type) - } - - out, err := createPubSubResources(ctx, pubsubCfg, input, params) - if err != nil { - return nil, errors.Wrapf(err, "failed to create pubsub topics and subscriptions for stack %q in %q", - input.StackParams.StackName, input.StackParams.Environment) - } - - return &api.ResourceOutput{Ref: out}, nil -} - -type PubSubResourcesOutput struct { - Topics map[string]*pubsub.Topic - Subscriptions map[string]*pubsub.Subscription -} - -func appendInputLabels(baseLabels, additionalLabels gcloud.PlainLabels) gcloud.PlainLabels { - for key, value := range additionalLabels { - baseLabels[key] = value - } - return baseLabels -} - -func createPubSubResources(ctx *sdk.Context, cfg *gcloud.PubSubConfig, input api.ResourceInput, params pApi.ProvisionParams) (*PubSubResourcesOutput, error) { - commonLabels := appendInputLabels(cfg.Labels, gcloud.PlainLabels{ - "stack-name": input.StackParams.StackName, - "stack-env": input.StackParams.Environment, - }) - - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - - topics := make(map[string]*pubsub.Topic) - for _, topic := range cfg.Topics { - topicLabels := appendInputLabels(commonLabels, topic.Labels) - psTopic, err := pubsub.NewTopic(ctx, topic.Name, &pubsub.TopicArgs{ - Name: sdk.String(topic.Name), - Labels: sdk.ToStringMap(topicLabels), - MessageRetentionDuration: sdk.String(topic.MessageRetentionDuration), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create pubsub topic %q for stack %q in %q", - topic.Name, input.StackParams.StackName, input.StackParams.Environment) - } - topics[topic.Name] = psTopic - } - - subscriptions := make(map[string]*pubsub.Subscription) - for _, subscription := range cfg.Subscriptions { - subscriptionLabels := appendInputLabels(commonLabels, subscription.Labels) - - var deadLetterPolicyArgs *pubsub.SubscriptionDeadLetterPolicyArgs - if subscription.DeadLetterPolicy != nil { - deadLetterPolicyArgs = &pubsub.SubscriptionDeadLetterPolicyArgs{ - DeadLetterTopic: sdk.StringPtrFromPtr(lo.If(subscription.DeadLetterPolicy.DeadLetterTopic != nil, subscription.DeadLetterPolicy.DeadLetterTopic).Else(nil)), - MaxDeliveryAttempts: sdk.IntPtrFromPtr(lo.If(subscription.DeadLetterPolicy.MaxDeliveryAttempts != nil, subscription.DeadLetterPolicy.MaxDeliveryAttempts).Else(nil)), - } - } - psSubscription, err := pubsub.NewSubscription(ctx, subscription.Name, &pubsub.SubscriptionArgs{ - Name: sdk.String(subscription.Name), - Labels: sdk.ToStringMap(subscriptionLabels), - DeadLetterPolicy: deadLetterPolicyArgs, - Topic: topics[subscription.Topic].ID(), - EnableExactlyOnceDelivery: sdk.Bool(subscription.ExactlyOnceDelivery), - AckDeadlineSeconds: sdk.Int(subscription.AckDeadlineSec), - MessageRetentionDuration: sdk.String(subscription.MessageRetentionDuration), - }, opts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to create pubsub subscription %q for stack %q in %q", - subscription.Name, input.StackParams.StackName, input.StackParams.Environment) - } - subscriptions[subscription.Name] = psSubscription - } - - ctx.Export(toPubsubProjectIdExport(input), sdk.String(cfg.ProjectId)) - - return &PubSubResourcesOutput{ - Topics: topics, - Subscriptions: subscriptions, - }, nil -} - -func toPubsubProjectIdExport(input api.ResourceInput) string { - return input.ToResName(input.Descriptor.Name) -} - -func PubSubTopicsProcessor(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, collector pApi.ComputeContextCollector, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - fullParentReference := params.ParentStack.FullReference - suffix := lo.If(params.ParentStack.DependsOnResource != nil, "--"+lo.FromPtr(params.ParentStack.DependsOnResource).Name).Else("") - projectId, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-pubsub-projectId", input.Descriptor.Type, suffix), fullParentReference, toPubsubProjectIdExport(input), false) - if err != nil { - return nil, errors.Wrapf(err, "failed to retrieve pubsub projectId from parent stack") - } - opts := []sdk.ResourceOption{sdk.Provider(params.Provider)} - - collector.AddEnvVariableIfNotExist("PUBSUB_PROJECT_ID", projectId, - input.Descriptor.Type, input.Descriptor.Name, params.ParentStack.StackName) - collector.AddEnvVariableIfNotExist("GOOGLE_CLOUD_PROJECT", projectId, - input.Descriptor.Type, input.Descriptor.Name, params.ParentStack.StackName) - collector.AddEnvVariableIfNotExist("GOOGLE_APPLICATION_CREDENTIALS", "/gcp-credentials.json", - input.Descriptor.Type, input.Descriptor.Name, params.ParentStack.StackName) - - collector.AddPreProcessor(&kubernetes.SimpleContainerArgs{}, func(arg any) error { - // TODO: figure out how to support multiple roles and single service account - serviceAccount, err := NewServiceAccount(ctx, fmt.Sprintf("%s-%s-%s-sa", input.Descriptor.Name, input.StackParams.StackName, input.StackParams.Environment), - ServiceAccountArgs{ - Project: projectId, - Description: fmt.Sprintf("Service account for %s to access pub/sub in %s", input.StackParams.StackName, input.StackParams.Environment), - Roles: []string{"roles/pubsub.editor"}, - }, opts...) - if err != nil { - return errors.Wrapf(err, "failed to create service account to access pub/sub for %q in %q", input.StackParams.StackName, input.StackParams.Environment) - } - kubeArgs, ok := arg.(*kubernetes.SimpleContainerArgs) - if !ok { - return errors.Errorf("arg is not *kubernetes.Args") - } - - kubeArgs.SecretVolumeOutputs = append(kubeArgs.SecretVolumeOutputs, serviceAccount.ServiceAccountKey.PrivateKey.ApplyT(func(pkArg any) (k8s.SimpleTextVolume, error) { - // need to decode private key - privateKeyDecoded, err := base64.StdEncoding.DecodeString(pkArg.(string)) - if err != nil { - return k8s.SimpleTextVolume{}, err - } - return k8s.SimpleTextVolume{ - TextVolume: api.TextVolume{ - Content: string(privateKeyDecoded), - Name: "gcp-credentials", - MountPath: "/gcp-credentials.json", - }, - }, nil - })) - return nil - }) - - return &api.ResourceOutput{ - Ref: nil, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/redis.go b/pkg/clouds/pulumi/gcp/redis.go deleted file mode 100644 index 421bdbcec..000000000 --- a/pkg/clouds/pulumi/gcp/redis.go +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pkg/errors" - "github.com/samber/lo" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/redis" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - "github.com/simple-container-com/api/pkg/util" -) - -func Redis(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.ResourceTypeRedis { - return nil, errors.Errorf("unsupported redis type %q", input.Descriptor.Type) - } - - redisCfg, ok := input.Descriptor.Config.Config.(*gcloud.RedisConfig) - if !ok { - return nil, errors.Errorf("failed to convert redis config for %q", input.Descriptor.Type) - } - - // Handle resource adoption - exit early if adopting - if redisCfg.Adopt { - return AdoptRedis(ctx, stack, input, params) - } - - redisServiceName := fmt.Sprintf("projects/%s/services/redis.googleapis.com", redisCfg.ProjectId) - if err := enableServicesAPI(ctx.Context(), input.Descriptor.Config.Config, redisServiceName); err != nil { - return nil, errors.Wrapf(err, "failed to enable %s", redisServiceName) - } - - redisName := toRedisName(input, input.Descriptor.Name) - - // Configure Redis instance arguments - redisArgs := &redis.InstanceArgs{ - MemorySizeGb: sdk.Int(redisCfg.MemorySizeGb), - RedisConfigs: sdk.ToStringMap(redisCfg.RedisConfig), - Region: sdk.StringPtrFromPtr(lo.If(redisCfg.Region != nil, redisCfg.Region).Else(nil)), - } - - // Set VPC network if specified (for custom VPC connectivity) - if redisCfg.AuthorizedNetwork != nil && *redisCfg.AuthorizedNetwork != "" { - redisArgs.AuthorizedNetwork = sdk.String(*redisCfg.AuthorizedNetwork) - params.Log.Info(ctx.Context(), "🔗 Configuring Redis %q to use VPC network: %s", redisName, *redisCfg.AuthorizedNetwork) - } else { - params.Log.Info(ctx.Context(), "📡 Redis %q will use default VPC network", redisName) - } - - redisInstance, err := redis.NewInstance(ctx, redisName, redisArgs, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to provision redis instance %q", redisName) - } - - ctx.Export(toRedisPortExport(redisName), sdk.Sprintf("%d", redisInstance.Port)) - ctx.Export(toRedisHostExport(redisName), redisInstance.Host) - - return &api.ResourceOutput{Ref: redisInstance}, nil -} - -func RedisComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, collector pApi.ComputeContextCollector, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if params.ParentStack == nil { - return nil, errors.Errorf("parent stack must not be nil for compute processor for %q", stack.Name) - } - - redisName := toRedisName(input, input.Descriptor.Name) - fullParentReference := params.ParentStack.FullReference - redisHostExport := toRedisHostExport(redisName) - suffix := lo.If(params.ParentStack.DependsOnResource != nil, "--"+lo.FromPtr(params.ParentStack.DependsOnResource).Name).Else("") - params.Log.Info(ctx.Context(), "Getting redis host for stack %q from parent stack %q (suffix %q)", stack.Name, fullParentReference, suffix) - redisHost, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-cproc-host", redisName, suffix), fullParentReference, redisHostExport, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get redis host from parent stack for %q", redisName) - } - if redisHost == "" { - return nil, errors.Errorf("failed to get redis host from parent stack for %q: empty", redisName) - } - redisPortExport := toRedisPortExport(redisName) - params.Log.Info(ctx.Context(), "Getting redis port from %q for %q from parent stack %q", redisPortExport, stack.Name, fullParentReference) - redisPort, err := pApi.GetValueFromStack[string](ctx, fmt.Sprintf("%s%s-cproc-port", redisName, suffix), fullParentReference, redisPortExport, false) - if err != nil { - return nil, errors.Wrapf(err, "failed to get redis port from parent stack for %q", redisName) - } - if redisPort == "" { - redisPort = "6379" - params.Log.Warn(ctx.Context(), "redis's port %q wasn't found in the outputs, fallback to default port 6379", redisName) - } - - if !params.ParentStack.UsesResource { - params.Log.Warn(ctx.Context(), "redis %q only supports `uses`, but it wasn't explicitly declared as being used", redisName) - return nil, errors.Errorf("redis %q only supports `uses`, but it wasn't explicitly declared as being used", redisName) - } - - params.Log.Info(ctx.Context(), "Adding REDIS_HOST env variable for stack %q from %q", stack.Name, fullParentReference) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("REDIS_HOST"), redisHost, - input.Descriptor.Type, input.Descriptor.Name, params.ParentStack.StackName) - params.Log.Info(ctx.Context(), "Adding REDIS_PORT env variable for stack %q from %q", stack.Name, fullParentReference) - collector.AddEnvVariableIfNotExist(util.ToEnvVariableName("REDIS_PORT"), redisPort, - input.Descriptor.Type, input.Descriptor.Name, params.ParentStack.StackName) - - collector.AddResourceTplExtension(input.Descriptor.Name, map[string]string{ - "host": redisHost, - "port": redisPort, - }) - - return &api.ResourceOutput{ - Ref: nil, - }, nil -} - -func toRedisPortExport(resName string) string { - return fmt.Sprintf("%s-port", resName) -} - -func toRedisHostExport(resName string) string { - return fmt.Sprintf("%s-host", resName) -} - -func toRedisName(input api.ResourceInput, resName string) string { - return input.ToResName(resName) -} diff --git a/pkg/clouds/pulumi/gcp/service_account.go b/pkg/clouds/pulumi/gcp/service_account.go deleted file mode 100644 index 5e977c08c..000000000 --- a/pkg/clouds/pulumi/gcp/service_account.go +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/projects" - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/util" -) - -type ServiceAccount struct { - ServiceAccount *serviceaccount.Account - ServiceAccountKey *serviceaccount.Key - CredentialsSecrets sdk.StringMap -} - -type ServiceAccountArgs struct { - Project string - Description string - Roles []string -} - -func NewServiceAccount(ctx *sdk.Context, name string, args ServiceAccountArgs, opts ...sdk.ResourceOption) (*CloudSQLAccount, error) { - accountName := util.SanitizeGCPServiceAccountName(name) - - serviceAccount, err := serviceaccount.NewAccount(ctx, accountName, &serviceaccount.AccountArgs{ - AccountId: sdk.String(accountName), - Project: sdk.String(args.Project), - Description: sdk.String(args.Description), - DisplayName: sdk.String(fmt.Sprintf("%s-service-account", name)), - }, opts...) - if err != nil { - return nil, err - } - - opts = append(opts, sdk.Parent(serviceAccount)) - serviceAccountKey, err := serviceaccount.NewKey(ctx, fmt.Sprintf("%s-key", accountName), &serviceaccount.KeyArgs{ - ServiceAccountId: serviceAccount.AccountId, - }, opts...) - if err != nil { - return nil, err - } - for _, role := range args.Roles { - _, err = projects.NewIAMMember(ctx, fmt.Sprintf("%s-%s-iam", accountName, role), &projects.IAMMemberArgs{ - Project: sdk.String(args.Project), - Member: serviceAccount.Member, - Role: sdk.String(role), - }, opts...) - if err != nil { - return nil, err - } - } - - credentialsSecrets := sdk.StringMap{ - "credentials.json": serviceAccountKey.PrivateKey, - } - - return &CloudSQLAccount{ - ServiceAccount: serviceAccount, - ServiceAccountKey: serviceAccountKey, - CredentialsSecrets: credentialsSecrets, - }, nil -} diff --git a/pkg/clouds/pulumi/gcp/service_account_naming_test.go b/pkg/clouds/pulumi/gcp/service_account_naming_test.go deleted file mode 100644 index fa8f7ef33..000000000 --- a/pkg/clouds/pulumi/gcp/service_account_naming_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/util" -) - -func TestServiceAccountNaming_LongNames(t *testing.T) { - RegisterTestingT(t) - - testCases := []struct { - name string - input string - expectedLength int - shouldContain string - shouldNotContain string - }{ - { - name: "Short name unchanged", - input: "telegram-bot-db", - expectedLength: 15, - shouldContain: "telegram-bot-db", - }, - { - name: "Long name truncated with hash", - input: "telegram-bot-connection-initcsql-sidecarcsql--production", - expectedLength: 28, - shouldContain: "telegram-bot-connection-", - shouldNotContain: "--ction-initcsql", // Should not have malformed middle truncation - }, - { - name: "Very long name with environment suffix", - input: "very-long-service-name-that-exceeds-gcp-limits-production", - expectedLength: 28, - shouldContain: "very-long-service-name", - }, - { - name: "Name with underscores", - input: "telegram_bot_connection_initcsql_sidecarcsql", - expectedLength: 28, - shouldContain: "telegram-bot-connection-", - shouldNotContain: "_", // Underscores should be replaced - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - RegisterTestingT(t) - - // Use the utility function - accountName := util.SanitizeGCPServiceAccountName(tc.input) - - // Verify length constraint - Expect(len(accountName)).To(BeNumerically("<=", tc.expectedLength)) - - // Verify it contains expected prefix - if tc.shouldContain != "" { - Expect(accountName).To(ContainSubstring(tc.shouldContain)) - } - - // Verify it doesn't contain problematic patterns - if tc.shouldNotContain != "" { - Expect(accountName).ToNot(ContainSubstring(tc.shouldNotContain)) - } - - // Verify GCP naming requirements - Expect(accountName).To(MatchRegexp("^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])?$")) - - // Verify no double hyphens - Expect(accountName).ToNot(ContainSubstring("--")) - }) - } -} - -func TestServiceAccountNaming_RealWorldExample(t *testing.T) { - RegisterTestingT(t) - - // Test the exact case from the error message - problematicName := "telegram-bot-connection-initcsql-sidecarcsql--production" - - // Use the utility function - accountName := util.SanitizeGCPServiceAccountName(problematicName) - - // Verify the fix - Expect(len(accountName)).To(BeNumerically("<=", 28)) - Expect(accountName).ToNot(ContainSubstring("--ction-initcsql")) // Should not have the malformed pattern - Expect(accountName).To(ContainSubstring("telegram-bot-connection")) // Should preserve meaningful prefix - Expect(accountName).To(MatchRegexp("^[a-z](?:[-a-z0-9]{4,28}[a-z0-9])?$")) // Should match GCP requirements - Expect(accountName).ToNot(ContainSubstring("--")) // Should not have double hyphens -} diff --git a/pkg/clouds/pulumi/gcp/services_api.go b/pkg/clouds/pulumi/gcp/services_api.go deleted file mode 100644 index b67fe8977..000000000 --- a/pkg/clouds/pulumi/gcp/services_api.go +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "context" - "fmt" - "strings" - "time" - - "google.golang.org/api/serviceusage/v1" - - "github.com/pkg/errors" -) - -// servicesAPIClient interface for enabling GCP services -type servicesAPIClient interface { - EnableService(ctx context.Context, authConfig any, apiName string) error -} - -// defaultServicesAPIClient implements servicesAPIClient using real GCP APIs -type defaultServicesAPIClient struct{} - -// EnableService enables a GCP service API -func (c *defaultServicesAPIClient) EnableService(ctx context.Context, authConfig any, apiName string) error { - return enableServicesAPIImpl(ctx, authConfig, apiName) -} - -// mockServicesAPIClient implements servicesAPIClient for testing -type mockServicesAPIClient struct { - // enabledServices tracks which services have been "enabled" - enabledServices map[string]bool - // shouldFail controls whether to simulate failures - shouldFail map[string]bool -} - -// newMockServicesAPIClient creates a new mock client -func newMockServicesAPIClient() *mockServicesAPIClient { - return &mockServicesAPIClient{ - enabledServices: make(map[string]bool), - shouldFail: make(map[string]bool), - } -} - -// EnableService mocks enabling a GCP service API -func (c *mockServicesAPIClient) EnableService(ctx context.Context, authConfig any, apiName string) error { - if c.shouldFail[apiName] { - return fmt.Errorf("mock failure for service %s", apiName) - } - c.enabledServices[apiName] = true - return nil -} - -// simulateFailure configures the mock to fail for a specific service -func (c *mockServicesAPIClient) simulateFailure(apiName string, shouldFail bool) { - c.shouldFail[apiName] = shouldFail -} - -// isServiceEnabled checks if a service was enabled -func (c *mockServicesAPIClient) isServiceEnabled(apiName string) bool { - return c.enabledServices[apiName] -} - -// Context key for services API client -type servicesAPIClientKey struct{} - -// Global variable for dependency injection (used when context doesn't have client) -var globalServicesAPIClient servicesAPIClient = &defaultServicesAPIClient{} - -// enableServicesAPI enables a GCP service API using the client from context, global, or default -func enableServicesAPI(ctx context.Context, authConfig any, apiName string) error { - client := getServicesAPIClient(ctx) - return client.EnableService(ctx, authConfig, apiName) -} - -// getServicesAPIClient gets the services API client from context, global, or returns default -func getServicesAPIClient(ctx context.Context) servicesAPIClient { - // First try to get from context - if client, ok := ctx.Value(servicesAPIClientKey{}).(servicesAPIClient); ok { - return client - } - // Fall back to global variable - if globalServicesAPIClient != nil { - return globalServicesAPIClient - } - // Final fallback to default - return &defaultServicesAPIClient{} -} - -// setGlobalServicesAPIClient sets the global services API client (for testing) -func setGlobalServicesAPIClient(client servicesAPIClient) { - globalServicesAPIClient = client -} - -// resetGlobalServicesAPIClient resets the global services API client to default -func resetGlobalServicesAPIClient() { - globalServicesAPIClient = &defaultServicesAPIClient{} -} - -// enableServicesAPIImpl is the original implementation moved here -func enableServicesAPIImpl(ctx context.Context, authConfig any, apiName string) error { - svc, err := initServicesAPIClient(ctx, authConfig) - if err != nil { - return errors.Wrapf(err, "failed to init services API client") - } - - if info, err := svc.Services.Get(apiName).Do(); err == nil { - if info.State == "ENABLED" { - // already enabled - return nil - } - } - op, err := svc.Services.Enable(apiName, &serviceusage.EnableServiceRequest{}).Do() - if err != nil { - return errors.Wrapf(err, "failed to enable %s", apiName) - } - - // Handle immediate completion or operations that don't need polling - if op.Done { - // Operation completed immediately - if op.Error != nil { - return errors.Errorf("failed to enable API %q: %s", apiName, op.Error.Message) - } - return nil - } - - // Poll for operation completion with improved error handling - maxRetries := 60 // Maximum 60 seconds - for i := 0; i < maxRetries; i++ { - // Add context cancellation check - select { - case <-ctx.Done(): - return errors.Wrapf(ctx.Err(), "context cancelled while enabling API %q", apiName) - default: - } - - op, err = svc.Operations.Get(op.Name).Do() - if err != nil { - // Handle specific "DONE_OPERATION" error - if strings.Contains(err.Error(), "DONE_OPERATION") { - // Operation is already done, check final state - if info, checkErr := svc.Services.Get(apiName).Do(); checkErr == nil { - if info.State == "ENABLED" { - return nil - } - } - return errors.Wrapf(err, "API enablement operation completed with error for %q", apiName) - } - return errors.Wrapf(err, "failed to check operation status for API %q", apiName) - } - - if op.Done { - if op.Error != nil { - return errors.Errorf("failed to enable API %q: %s", apiName, op.Error.Message) - } - return nil - } - - time.Sleep(1 * time.Second) - } - - return errors.Errorf("timeout waiting for API %q to be enabled after %d seconds", apiName, maxRetries) -} diff --git a/pkg/clouds/pulumi/gcp/state_bucket_backups_test.go b/pkg/clouds/pulumi/gcp/state_bucket_backups_test.go deleted file mode 100644 index 1ff2d6412..000000000 --- a/pkg/clouds/pulumi/gcp/state_bucket_backups_test.go +++ /dev/null @@ -1,282 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - gcpStorage "cloud.google.com/go/storage" - . "github.com/onsi/gomega" -) - -func managedNoncurrentRule(days int64) gcpStorage.LifecycleRule { - return gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - Liveness: gcpStorage.Archived, - DaysSinceNoncurrentTime: days, - }, - } -} - -func managedBackupsRule(days int64) gcpStorage.LifecycleRule { - return gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - AgeInDays: days, - MatchesPrefix: []string{PulumiBackupsPrefix}, - }, - } -} - -// The single most dangerous thing in this file. The backups rule matches LIVE -// objects by age, so it is only safe while it is scoped to the backups prefix. -// Unscoped, it deletes the current state file of every stack in the bucket. -func TestBackupsRuleIsAlwaysScopedToThePrefix(t *testing.T) { - RegisterTestingT(t) - - lc := StateHistoryLifecycle(30) - Expect(lc.Rules).To(HaveLen(2)) - - for i, rule := range lc.Rules { - if rule.Condition.AgeInDays == 0 { - continue - } - Expect(rule.Condition.MatchesPrefix).To(Equal([]string{PulumiBackupsPrefix}), - "rule %d matches live objects by age and MUST be scoped to the backups prefix; "+ - "unscoped it deletes the live state file", i) - } -} - -// Restating the invariant independently of rule ordering or count, so a future -// rule cannot slip in unscoped. -func TestNoAgeBasedRuleIsUnscoped(t *testing.T) { - RegisterTestingT(t) - - for _, days := range []int{1, 7, 30, 365} { - for _, rule := range StateHistoryLifecycle(days).Rules { - if rule.Condition.AgeInDays > 0 { - Expect(rule.Condition.MatchesPrefix).ToNot(BeEmpty(), - "an AgeInDays rule with no prefix expires live state") - } - } - } -} - -func TestStateHistoryLifecycleKeepsTheNoncurrentRule(t *testing.T) { - RegisterTestingT(t) - - lc := StateHistoryLifecycle(30) - Expect(lc.Rules).To(ContainElement(managedNoncurrentRule(30)), - "the backups rule is additional to the noncurrent rule, not a replacement") - Expect(lc.Rules).To(ContainElement(managedBackupsRule(30))) -} - -func TestStateHistoryLifecycleDisabled(t *testing.T) { - RegisterTestingT(t) - - Expect(StateHistoryLifecycle(0).Rules).To(BeEmpty(), "zero is a deliberate opt-out") - Expect(StateHistoryLifecycle(-1).Rules).To(BeEmpty()) -} - -func TestShouldApplyStateHistoryLifecycle(t *testing.T) { - RegisterTestingT(t) - - versioned := func(rules ...gcpStorage.LifecycleRule) *gcpStorage.BucketAttrs { - return &gcpStorage.BucketAttrs{ - VersioningEnabled: true, - Lifecycle: gcpStorage.Lifecycle{Rules: rules}, - } - } - operatorRule := gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{AgeInDays: 365}, - } - operatorBackupsRule := gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - AgeInDays: 90, - MatchesPrefix: []string{".pulumi/backups/"}, - }, - } - broaderOperatorPrefix := gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - AgeInDays: 90, - MatchesPrefix: []string{".pulumi/"}, - }, - } - - for _, tc := range []struct { - name string - attrs *gcpStorage.BucketAttrs - days int - want bool - why string - }{ - { - name: "empty policy is the original case", - attrs: versioned(), - days: 30, - want: true, - }, - { - name: "a policy this package wrote is upgradable", - attrs: versioned(managedNoncurrentRule(30)), - days: 30, - want: true, - why: "without this the guard excludes every bucket SC already touched, " + - "so the buckets that have the problem could never be fixed", - }, - { - name: "recognised even when the day count has since changed in config", - attrs: versioned(managedNoncurrentRule(7)), - days: 30, - want: true, - why: "the count comes from operator config and may legitimately differ", - }, - { - name: "already fully managed, so nothing to add", - attrs: versioned(managedNoncurrentRule(30), managedBackupsRule(30)), - days: 30, - want: false, - why: "the backups prefix is already bounded; rewriting it is a pointless write", - }, - { - name: "an operator's own rule is still never touched", - attrs: versioned(operatorRule), - days: 30, - want: false, - why: "this is the property the original guard exists for", - }, - { - name: "a managed rule alongside an operator rule is left alone", - attrs: versioned(managedNoncurrentRule(30), operatorRule), - days: 30, - want: false, - why: "any unrecognised rule means the policy is not ours to rewrite", - }, - { - name: "an operator bounding backups their own way wins", - attrs: versioned(operatorBackupsRule), - days: 30, - want: false, - why: "their retention choice for the prefix must not be overridden", - }, - { - name: "an operator rule covering a parent of the prefix also wins", - attrs: versioned(broaderOperatorPrefix), - days: 30, - want: false, - why: ".pulumi/ already covers .pulumi/backups/", - }, - { - name: "versioning off means the noncurrent half would be inert", - attrs: &gcpStorage.BucketAttrs{VersioningEnabled: false}, - days: 30, - want: false, - }, - { - name: "retention explicitly disabled", - attrs: versioned(), - days: 0, - want: false, - }, - { - name: "nil attrs never provokes a write", - attrs: nil, - days: 30, - want: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - Expect(ShouldApplyStateHistoryLifecycle(tc.attrs, tc.days)).To(Equal(tc.want), tc.why) - }) - } -} - -// The shape match is what separates "ours" from "theirs". If it were loose -// enough to accept an operator's rule, the guard above would start rewriting -// policies it must never touch. -func TestIsManagedStateHistoryRuleRejectsLookalikes(t *testing.T) { - RegisterTestingT(t) - - for _, tc := range []struct { - name string - rule gcpStorage.LifecycleRule - want bool - }{ - {"our noncurrent rule", managedNoncurrentRule(30), true}, - {"our backups rule", managedBackupsRule(30), true}, - { - name: "not a delete action", - rule: gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.SetStorageClassAction, StorageClass: "NEARLINE"}, - Condition: gcpStorage.LifecycleCondition{Liveness: gcpStorage.Archived, DaysSinceNoncurrentTime: 30}, - }, - }, - { - name: "age rule with a different prefix", - rule: gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - AgeInDays: 30, - MatchesPrefix: []string{"other/"}, - }, - }, - }, - { - name: "unscoped age rule is emphatically not ours", - rule: gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{AgeInDays: 30}, - }, - }, - { - name: "carries an extra condition we never set", - rule: gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - Liveness: gcpStorage.Archived, - DaysSinceNoncurrentTime: 30, - NumNewerVersions: 3, - }, - }, - }, - { - name: "matches a storage class we never filter on", - rule: gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{ - Liveness: gcpStorage.Archived, - DaysSinceNoncurrentTime: 30, - MatchesStorageClasses: []string{"STANDARD"}, - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - Expect(isManagedStateHistoryRule(tc.rule)).To(Equal(tc.want)) - }) - } -} - -// A freshly created bucket must get both rules, not just the noncurrent one. -// Getting this wrong is how the original fix ended up only half-applied. -func TestNewBucketGetsBothRules(t *testing.T) { - RegisterTestingT(t) - - lc := StateHistoryLifecycle(30) - var archived, backups int - for _, r := range lc.Rules { - if r.Condition.Liveness == gcpStorage.Archived && r.Condition.DaysSinceNoncurrentTime > 0 { - archived++ - } - if r.Condition.AgeInDays > 0 && len(r.Condition.MatchesPrefix) == 1 { - backups++ - } - } - Expect(archived).To(Equal(1), "superseded generations must be bounded") - Expect(backups).To(Equal(1), "the backups prefix must be bounded too, or KMS versions stay pinned") -} diff --git a/pkg/clouds/pulumi/gcp/state_bucket_lifecycle_test.go b/pkg/clouds/pulumi/gcp/state_bucket_lifecycle_test.go deleted file mode 100644 index 4a95f8e58..000000000 --- a/pkg/clouds/pulumi/gcp/state_bucket_lifecycle_test.go +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "testing" - - gcpStorage "cloud.google.com/go/storage" - . "github.com/onsi/gomega" - - "github.com/simple-container-com/api/pkg/clouds/gcloud" -) - -// InitStateStore now touches buckets it did not create. These tests pin the -// guards on that, because the failure mode is editing an operator's lifecycle -// policy on a bucket holding all of their Pulumi state. -func TestShouldApplyNoncurrentVersionLifecycle(t *testing.T) { - RegisterTestingT(t) - - versioned := func(rules ...gcpStorage.LifecycleRule) *gcpStorage.BucketAttrs { - return &gcpStorage.BucketAttrs{ - VersioningEnabled: true, - Lifecycle: gcpStorage.Lifecycle{Rules: rules}, - } - } - someRule := gcpStorage.LifecycleRule{ - Action: gcpStorage.LifecycleAction{Type: gcpStorage.DeleteAction}, - Condition: gcpStorage.LifecycleCondition{AgeInDays: 365}, - } - - for _, tc := range []struct { - name string - attrs *gcpStorage.BucketAttrs - days int - want bool - why string - }{ - { - name: "versioned bucket with no rules is the case this exists for", - attrs: versioned(), - days: 30, - want: true, - why: "unbounded history keeps every KMS key version load-bearing", - }, - { - name: "an existing rule is left alone even if it does not bound noncurrent versions", - attrs: versioned(someRule), - days: 30, - want: false, - why: "the operator owns their lifecycle policy; guessing at intent is worse than doing nothing", - }, - { - name: "versioning off means noncurrent generations never accumulate", - attrs: &gcpStorage.BucketAttrs{VersioningEnabled: false}, - days: 30, - want: false, - why: "the rule would be inert, so do not touch the bucket at all", - }, - { - name: "retention explicitly disabled", - attrs: versioned(), - days: 0, - want: false, - why: "zero is a deliberate opt-out", - }, - { - name: "negative retention is not treated as enabled", - attrs: versioned(), - days: -1, - want: false, - }, - { - name: "nil attrs never provokes a write", - attrs: nil, - days: 30, - want: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - Expect(ShouldApplyNoncurrentVersionLifecycle(tc.attrs, tc.days)).To(Equal(tc.want), tc.why) - }) - } -} - -// The rule must target archived objects only. A rule that matched live objects -// would delete the current state file, which is the worst outcome available -// here, so assert the shape rather than trusting the constant. -func TestNoncurrentVersionLifecycleTargetsArchivedOnly(t *testing.T) { - RegisterTestingT(t) - - lc := NoncurrentVersionLifecycle(30) - Expect(lc.Rules).To(HaveLen(1)) - - rule := lc.Rules[0] - Expect(rule.Action.Type).To(Equal(gcpStorage.DeleteAction)) - Expect(rule.Condition.Liveness).To(Equal(gcpStorage.Archived), - "a Live or LiveAndArchived rule would delete the current state file") - Expect(rule.Condition.DaysSinceNoncurrentTime).To(Equal(int64(30))) - Expect(rule.Condition.AgeInDays).To(BeZero(), - "AgeInDays counts from creation, not from being superseded, and would expire live state") -} - -func TestNoncurrentVersionLifecycleDisabled(t *testing.T) { - RegisterTestingT(t) - - Expect(NoncurrentVersionLifecycle(0).Rules).To(BeEmpty()) - Expect(NoncurrentVersionLifecycle(-5).Rules).To(BeEmpty()) -} - -func TestEffectiveNoncurrentVersionRetentionDays(t *testing.T) { - RegisterTestingT(t) - - cfg := &gcloud.StateStorageConfig{} - Expect(cfg.EffectiveNoncurrentVersionRetentionDays()).To(Equal(gcloud.DefaultNoncurrentVersionRetentionDays), - "unset must not mean unbounded, which is the state that caused this") - - zero := 0 - cfg.NoncurrentVersionRetentionDays = &zero - Expect(cfg.EffectiveNoncurrentVersionRetentionDays()).To(BeZero(), - "an explicit zero is an opt-out and must be distinguishable from unset") - - seven := 7 - cfg.NoncurrentVersionRetentionDays = &seven - Expect(cfg.EffectiveNoncurrentVersionRetentionDays()).To(Equal(7)) -} diff --git a/pkg/clouds/pulumi/gcp/state_bucket_test.go b/pkg/clouds/pulumi/gcp/state_bucket_test.go deleted file mode 100644 index 489eb6fa0..000000000 --- a/pkg/clouds/pulumi/gcp/state_bucket_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "net/http" - "testing" - - gcpStorage "cloud.google.com/go/storage" - "google.golang.org/api/googleapi" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestStateBucketReadOutcome(t *testing.T) { - cases := []struct { - name string - err error - want stateBucketRead - }{ - {"not found", gcpStorage.ErrBucketNotExist, stateBucketMissing}, - {"not found, wrapped", fmt.Errorf("attrs: %w", gcpStorage.ErrBucketNotExist), stateBucketMissing}, - {"permission denied (JSON API)", &googleapi.Error{Code: http.StatusForbidden, Message: "no storage.buckets.get"}, stateBucketForbidden}, - {"permission denied, wrapped", fmt.Errorf("attrs: %w", &googleapi.Error{Code: http.StatusForbidden}), stateBucketForbidden}, - {"permission denied (gRPC)", status.Error(codes.PermissionDenied, "denied"), stateBucketForbidden}, - {"transient", &googleapi.Error{Code: http.StatusServiceUnavailable}, stateBucketReadFailed}, - {"unauthenticated", &googleapi.Error{Code: http.StatusUnauthorized}, stateBucketReadFailed}, - {"other", fmt.Errorf("dial tcp: timeout"), stateBucketReadFailed}, - } - for _, c := range cases { - if got := stateBucketReadOutcome(c.err); got != c.want { - t.Errorf("%s: stateBucketReadOutcome = %v; want %v", c.name, got, c.want) - } - } -} diff --git a/pkg/clouds/pulumi/gcp/static_website.go b/pkg/clouds/pulumi/gcp/static_website.go deleted file mode 100644 index f7427aac0..000000000 --- a/pkg/clouds/pulumi/gcp/static_website.go +++ /dev/null @@ -1,172 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) Simple Container - -package gcp - -import ( - "fmt" - "path" - - "github.com/pkg/errors" - - "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/storage" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" - - "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" -) - -// exportableRef turns a registrar's ResourceOutput.Ref into something -// ctx.Export accepts. Most registrars hand back an Output (cloudru returns -// cmd.ID()); the plugin registrar's synchronous DNS path has a resolved URN -// string in hand. Both are legitimate, and an unchecked -// `ref.(sdk.Output)` turned the second one into a panic raised from inside -// deployStackProgram's ApplyT continuation — where Pulumi reports it under an -// opaque "waiting for RPCs", naming neither this file nor the assertion. Be -// permissive here rather than re-learn that. -func exportableRef(ref any) sdk.Input { - switch v := ref.(type) { - case sdk.Input: - return v - case string: - return sdk.String(v) - default: - return sdk.String(fmt.Sprintf("%v", v)) - } -} - -type StaticSiteOutput struct { - Bucket *storage.Bucket - IamReadBinding *storage.BucketIAMBinding - DnsRecord *api.ResourceOutput - OverrideHeaderRule *api.ResourceOutput - IamWriteBinding *storage.BucketIAMBinding -} - -func StaticWebsite(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi.ProvisionParams) (*api.ResourceOutput, error) { - if input.Descriptor.Type != gcloud.TemplateTypeStaticWebsite { - return nil, errors.Errorf("unsupported bucket type %q", input.Descriptor.Type) - } - - in, ok := input.Descriptor.Config.Config.(*gcloud.StaticSiteInput) - if !ok { - return nil, errors.Errorf("failed to convert static site input to *aws.StaticSiteInput for %q", stack.Name) - } - - gcpCreds := in.CredentialsValue() - - out := &StaticSiteOutput{} - - bucketName := fmt.Sprintf("%s--%s", ctx.Project(), stack.Name) - domain := in.Site.Domain - - if in.BucketName != "" { - // override bucket name - bucketName = in.BucketName - } - - bucketLocation := in.Location - if bucketLocation == "" { - return nil, errors.Errorf("location is required for gcp bucket, but wasn't set for %q in %q", stack.Name, input.StackParams.Environment) - } - - // Create a GCP storage bucket for the static website. - bucket, err := storage.NewBucket(ctx, bucketName, &storage.BucketArgs{ - Name: sdk.String(bucketName), - Location: sdk.String(bucketLocation), - ForceDestroy: sdk.BoolPtr(true), - UniformBucketLevelAccess: sdk.BoolPtr(true), - Website: &storage.BucketWebsiteArgs{ - MainPageSuffix: sdk.String("index.html"), - NotFoundPage: sdk.String("404.html"), - }, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to create storage bucket for stack %q in %q", stack.Name, input.StackParams.Environment) - } - ctx.Export(fmt.Sprintf("%s-bucket-name", stack.Name), bucket.Name) - ctx.Export(fmt.Sprintf("%s-url", stack.Name), bucket.Url) - out.Bucket = bucket - - // Set the public access on the bucket. - iamReadBinding, err := storage.NewBucketIAMBinding(ctx, fmt.Sprintf("%s-read-iam", bucketName), &storage.BucketIAMBindingArgs{ - Bucket: bucket.Name, - Role: sdk.String("roles/storage.objectViewer"), - Members: sdk.StringArray{ - sdk.String("allUsers"), - }, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to create read iam binding for bucket %q for stack %q in %q", bucketName, stack.Name, input.StackParams.Environment) - } - ctx.Export(fmt.Sprintf("%s-iam-read-id", stack.Name), iamReadBinding.ID()) - out.IamReadBinding = iamReadBinding - - gcpCredsParsed, err := in.CredentialsParsed() - if err != nil { - return nil, errors.Wrapf(err, "failed to detect client email from provided service account for stack %q", stack.Name) - } - accountType := "user" - if gcpCredsParsed.Type == "service_account" { - accountType = "serviceAccount" - } - iamWriteBinding, err := storage.NewBucketIAMBinding(ctx, fmt.Sprintf("%s-write-iam", bucketName), &storage.BucketIAMBindingArgs{ - Bucket: bucket.Name, - Role: sdk.String("roles/storage.objectAdmin"), - Members: sdk.StringArray{ - sdk.String(fmt.Sprintf("%s:%s", accountType, gcpCredsParsed.ClientEmail)), - }, - }, sdk.Provider(params.Provider)) - if err != nil { - return nil, errors.Wrapf(err, "failed to create write iam binding for bucket %q for stack %q in %q", bucketName, stack.Name, input.StackParams.Environment) - } - ctx.Export(fmt.Sprintf("%s-iam-write-id", stack.Name), iamWriteBinding.ID()) - out.IamWriteBinding = iamWriteBinding - - params.Log.Info(ctx.Context(), "copying all files from %q to gs://%s for %q in %q...", in.BundleDir, bucketName, stack.Name, input.StackParams.Environment) - syncDir := in.BundleDir - if !path.IsAbs(syncDir) { - syncDir = path.Join(in.StackDir, in.BundleDir) - } - _, err = NewGcpBucketUploader(ctx, bucketName, BucketUploaderArgs{ - bucketName: bucket.Name, - syncDir: syncDir, - gcpCreds: gcpCreds, - params: params, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to sync bucket") - } - - params.Log.Info(ctx.Context(), "configure CNAME DNS record %q for %q in %q...", bucketName, stack.Name, input.StackParams.Environment) - bucketDomain := fmt.Sprintf("%s.storage.googleapis.com", bucketName) - dnsRecord, err := params.Registrar.NewRecord(ctx, api.DnsRecord{ - Name: domain, - Value: bucketDomain, - Type: "CNAME", - Proxied: true, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to create CNAME DNS record %q -> %q for stack %q in %q", domain, bucketDomain, stack.Name, input.StackParams.Environment) - } - ctx.Export(fmt.Sprintf("%s-dns-record-id", stack.Name), exportableRef(dnsRecord.Ref)) - out.DnsRecord = dnsRecord - - params.Log.Info(ctx.Context(), "configure override header rule from %q to %q for %q in %q...", domain, bucketDomain, stack.Name, input.StackParams.Environment) - overrideHeaderRule, err := params.Registrar.NewOverrideHeaderRule(ctx, stack, pApi.OverrideHeaderRule{ - FromHost: domain, - ToHost: sdk.String(bucketDomain), - OverridePages: &pApi.OverridePagesRule{ - IndexPage: in.Site.IndexDocument, - NotFoundPage: in.Site.ErrorDocument, - }, - }) - if err != nil { - return nil, errors.Wrapf(err, "failed to create override host rule from %q to %q", domain, bucketDomain) - } - ctx.Export(fmt.Sprintf("%s-override-header-rule-id", stack.Name), exportableRef(overrideHeaderRule.Ref)) - out.OverrideHeaderRule = overrideHeaderRule - - return &api.ResourceOutput{Ref: out}, nil -} diff --git a/pkg/clouds/pulumi/init.go b/pkg/clouds/pulumi/init.go index 42d0e8851..241c786ec 100644 --- a/pkg/clouds/pulumi/init.go +++ b/pkg/clouds/pulumi/init.go @@ -13,8 +13,12 @@ import ( "github.com/simple-container-com/api/pkg/api/logger" "github.com/simple-container-com/api/pkg/clouds/fs" pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" - // Register all the providers - _ "github.com/simple-container-com/api/pkg/clouds/pulumi/gcp" + // Register all the providers. Blank imports only; registration + // happens in each package's init(). + // The GCP in-tree package was deleted in phase 5 of the GCP plugin + // extraction — sc-plugin-gcp now owns every GCP type-token. + // plugin_bootstrap.go's canonical table carries the auto-inject row + // for operators who paste an unknown-but-canonical token. _ "github.com/simple-container-com/api/pkg/clouds/pulumi/k3s" _ "github.com/simple-container-com/api/pkg/clouds/pulumi/vm" ) diff --git a/pkg/clouds/pulumi/kubernetes/caddy_naming_test.go b/pkg/clouds/pulumi/kubernetes/caddy_naming_test.go index 02aa2982f..058904d9d 100644 --- a/pkg/clouds/pulumi/kubernetes/caddy_naming_test.go +++ b/pkg/clouds/pulumi/kubernetes/caddy_naming_test.go @@ -11,17 +11,25 @@ import ( "testing" . "github.com/onsi/gomega" - "github.com/pulumi/pulumi/sdk/v3/go/common/resource" sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/simple-container-com/api/pkg/api" "github.com/simple-container-com/api/pkg/api/logger" - "github.com/simple-container-com/api/pkg/clouds/gcloud" "github.com/simple-container-com/api/pkg/clouds/k8s" pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" ) +// testGkeAutopilotResource mirrors sc-plugin-gcp/api's GkeAutopilotResource +// for the two fields `deploysEmbeddedCaddy` actually probes +// (`Caddy != nil && !Adopt`). Kept local to this test file to keep the +// shared `kubernetes` package from depending on sc-plugin-gcp's /api. +// Must stay compatible with `gkeAutopilotProbe` in `naming.go`. +type testGkeAutopilotResource struct { + Caddy *k8s.CaddyConfig `json:"caddy,omitempty" yaml:"caddy,omitempty"` + Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` +} + type namingMocks struct { *simpleContainerMocks mu sync.Mutex @@ -45,8 +53,8 @@ func (m *namingMocks) NewResource(args sdk.MockResourceArgs) (string, resource.P func autopilotWithCaddy(adopt bool) api.ResourceDescriptor { return api.ResourceDescriptor{ - Type: gcloud.ResourceTypeGkeAutopilot, - Config: api.Config{Config: &gcloud.GkeAutopilotResource{ + Type: resourceTypeGkeAutopilot, + Config: api.Config{Config: &testGkeAutopilotResource{ Caddy: &k8s.CaddyConfig{Namespace: strPtr("caddy"), DeploymentName: strPtr("caddy")}, Adopt: adopt, }}, @@ -222,7 +230,7 @@ func TestKubernetesCaddyDeploymentName(t *testing.T) { // Config maps the reader did not type yet are read the same way. name, err = KubernetesCaddyDeploymentName("caddy-std", "likeclaw-us", true, oneEnv("likeclaw-us", map[string]api.ResourceDescriptor{ - "gke": {Type: gcloud.ResourceTypeGkeAutopilot, Config: api.Config{Config: map[string]any{ + "gke": {Type: resourceTypeGkeAutopilot, Config: api.Config{Config: map[string]any{ "caddy": map[string]any{"enable": true}, }}}, "other": {Type: k8s.ResourceTypeCaddy, Config: api.Config{Config: map[string]any{ diff --git a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go index e87965a9e..7307e6c4a 100644 --- a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go +++ b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go @@ -69,14 +69,11 @@ func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { reason string } + // GCP's CSQL sidecar/init proxy consumer invariant (createCloudsqlProxy, + // createUserForDatabase) moved to sc-plugin-gcp in phase 5 of the GCP + // plugin extraction. The equivalent regression guard belongs on + // sc-plugin-gcp's test suite now; this test no longer covers that path. sites := []site{ - { - file: filepath.Join("..", "gcp", "compute_proc.go"), - forbidden: []string{"createCloudsqlProxy", "createUserForDatabase"}, - reason: "GCP CSQL sidecar/init proxy consumers must consume the namespace Output threaded " + - "via kubernetes.SimpleContainerArgs.NamespaceNameOutput (preprocessor path) or " + - "SimpleContainer.Namespace (postprocessor path), not recompute it.", - }, { file: "compute_proc_postgres.go", forbidden: []string{"createPostgresUserForDatabase"}, diff --git a/pkg/clouds/pulumi/kubernetes/naming.go b/pkg/clouds/pulumi/kubernetes/naming.go index 6c29f4aea..8db19d502 100644 --- a/pkg/clouds/pulumi/kubernetes/naming.go +++ b/pkg/clouds/pulumi/kubernetes/naming.go @@ -10,10 +10,28 @@ import ( "github.com/pkg/errors" "github.com/simple-container-com/api/pkg/api" - "github.com/simple-container-com/api/pkg/clouds/gcloud" "github.com/simple-container-com/api/pkg/clouds/k8s" ) +// resourceTypeGkeAutopilot is the type-token for sc-plugin-gcp's GKE Autopilot +// cluster resource. Hardcoded here to break the previous import on +// `pkg/clouds/gcloud` (phase 5 of the GCP plugin extraction). Must match the +// value sc-plugin-gcp/api/gke_autopilot.go exports; the plugin owns the token +// authoritatively. +const resourceTypeGkeAutopilot = "gcp-gke-autopilot-cluster" + +// gkeAutopilotProbe mirrors just the two fields `envCaddyObjectsName` reads +// off sc-plugin-gcp's GkeAutopilotResource — `Caddy != nil && !Adopt`. +// Carried here as a local struct to keep `pkg/clouds/pulumi/kubernetes/` +// (a shared helper) from depending on the plugin's /api package. If +// sc-plugin-gcp ever grows a different cue for embedded Caddy deployment, +// update the fields alongside the plugin-side change — the token + these +// two fields are the full contract. +type gkeAutopilotProbe struct { + Caddy any `json:"caddy,omitempty" yaml:"caddy,omitempty"` + Adopt bool `json:"adopt,omitempty" yaml:"adopt,omitempty"` +} + // generateResourceName creates resource names with environment-specific suffixes for custom stacks // When parentEnv differs from stackEnv, it adds the stackEnv as a suffix to ensure uniqueness func generateResourceName(serviceName, stackEnv, parentEnv string, resourceType string) string { @@ -231,7 +249,7 @@ func envCaddyObjectsName(resName, env string, res api.ResourceDescriptor) (strin } } return caddyObjectsName(resName, env, cfg.UseResourceName), true - case gcloud.ResourceTypeGkeAutopilot: + case resourceTypeGkeAutopilot: if deploysEmbeddedCaddy(res) { return GenerateCaddyDeploymentName(env), true } @@ -241,15 +259,14 @@ func envCaddyObjectsName(resName, env string, res api.ResourceDescriptor) (strin // deploysEmbeddedCaddy mirrors the condition under which GkeAutopilot calls // DeployCaddyService: an adopted cluster creates no Caddy objects of its own. +// Lifts the typed-struct-cast to an opaque probe struct (see gkeAutopilotProbe) +// so this shared helper has no dependency on sc-plugin-gcp's /api package. func deploysEmbeddedCaddy(res api.ResourceDescriptor) bool { - cfg, ok := res.Config.Config.(*gcloud.GkeAutopilotResource) - if !ok { - var err error - if cfg, err = api.ConvertDescriptor(res.Config.Config, &gcloud.GkeAutopilotResource{}); err != nil { - return false - } + probe := &gkeAutopilotProbe{} + if _, err := api.ConvertDescriptor(res.Config.Config, probe); err != nil { + return false } - return cfg.Caddy != nil && !cfg.Adopt + return probe.Caddy != nil && !probe.Adopt } // ChildCaddyTarget returns the Caddy deployment and namespace a child stack patches, diff --git a/pkg/clouds/pulumi/pulumi.go b/pkg/clouds/pulumi/pulumi.go index 874d83b7b..b2e493f1d 100644 --- a/pkg/clouds/pulumi/pulumi.go +++ b/pkg/clouds/pulumi/pulumi.go @@ -5,11 +5,16 @@ package pulumi import ( "github.com/simple-container-com/api/pkg/clouds/fs" - "github.com/simple-container-com/api/pkg/clouds/gcloud" ) const ( - StateStorageTypeGcpBucket = gcloud.StateStorageTypeGcpBucket + // Literal string instead of importing sc-plugin-gcp/api — SC core + // stops Go-link-time importing the plugin's /api module in phase 5 of + // the GCP extraction (mirrors the comment on StateStorageTypeS3Bucket + // a few lines down — same reasoning: the token is a wire-level string + // both sides have agreed on, a const in the plugin's /api is just one + // place the spelling lives). + StateStorageTypeGcpBucket = "gcp-bucket" // Literal string instead of the sc-plugin-aws/api constant so SC // core doesn't need to Go-link-time-import the plugin's /api // module. The token is a wire-level string that both sides have diff --git a/pkg/clouds/pulumi/testutil/secrets_test_util.go b/pkg/clouds/pulumi/testutil/secrets_test_util.go index a27e77e52..6f3d8f1b1 100644 --- a/pkg/clouds/pulumi/testutil/secrets_test_util.go +++ b/pkg/clouds/pulumi/testutil/secrets_test_util.go @@ -10,14 +10,14 @@ import ( "gopkg.in/yaml.v3" . "github.com/onsi/gomega" + awsApi "github.com/simple-container-com/sc-plugin-aws/api" + "github.com/simple-container-com/sc-plugin-cloudflare/api" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" + "github.com/simple-container-com/sc-plugin-mongodb/api" "github.com/simple-container-com/api/pkg/api" "github.com/simple-container-com/api/pkg/api/secrets" "github.com/simple-container-com/api/pkg/clouds/docker" - "github.com/simple-container-com/api/pkg/clouds/gcloud" - awsApi "github.com/simple-container-com/sc-plugin-aws/api" - "github.com/simple-container-com/sc-plugin-cloudflare/api" - "github.com/simple-container-com/sc-plugin-mongodb/api" ) const ( @@ -30,7 +30,7 @@ const ( ) type E2ETestConfig struct { - GcpCredentials *gcloud.Credentials + GcpCredentials *gcpApi.Credentials AwsCredentials *awsApi.AccountConfig MongoConfig *mongodb.AtlasConfig ConfigFile *api.ConfigFile @@ -64,7 +64,7 @@ func PrepareE2Etest() E2ETestConfig { cfg, cryptor := ReadIntegrationTestConfig() Expect(cryptor.GetSecretFiles().Registry.Files).NotTo(BeEmpty()) fmt.Println(cryptor.GetSecretFiles().Registry.Files) // for debugging purposes - gcpCreds := ReadTestSecretConfig(cryptor, testGCPConfigFile, &gcloud.Credentials{}) + gcpCreds := ReadTestSecretConfig(cryptor, testGCPConfigFile, &gcpApi.Credentials{}) awsCreds := ReadTestSecretConfig(cryptor, testAwsConfigFile, &awsApi.AccountConfig{}) cfCreds := ReadTestSecretConfig(cryptor, testCfConfigFile, &cloudflare.RegistrarConfig{}) mongoCreds := ReadTestSecretConfig(cryptor, testMongoConfigFile, &mongodb.AtlasConfig{}) diff --git a/pkg/cmd/cmd_secrets/cmd_scope_hardening_test.go b/pkg/cmd/cmd_secrets/cmd_scope_hardening_test.go index 36c1486ae..2fa6dae37 100644 --- a/pkg/cmd/cmd_secrets/cmd_scope_hardening_test.go +++ b/pkg/cmd/cmd_secrets/cmd_scope_hardening_test.go @@ -22,6 +22,16 @@ import ( "github.com/simple-container-com/api/pkg/cmd/root_cmd" "github.com/simple-container-com/api/pkg/provisioner" "github.com/simple-container-com/api/pkg/provisioner/placeholders" + + // TestScopeCmd_LintChecksAuthEntriesWithEnvScopeKeysOnly puts a + // `type: gcp-service-account` into a scope file and expects lint to + // catch a malformed config via the typed decoder. Post-phase-5 the + // bootstrap registers only the opaque decoder for gcp tokens, which + // accepts any map shape. Blank-importing sc-plugin-gcp/api fires its + // init() → RegisterProviderConfig → typed ReadAuthServiceAccountConfig + // overrides the opaque one, restoring the parse-time strictness the + // test relies on. + _ "github.com/simple-container-com/sc-plugin-gcp/api" ) // newScopeRepo is a repository with stack "app" and a scope "pr" sealed to admin. @@ -29,6 +39,20 @@ func newScopeRepo(t *testing.T) (workdir, adminPub, adminPEM string) { t.Helper() workdir = t.TempDir() mkStackDirs(t, workdir, ".sc/stacks", "app") + // Declare sc-plugin-gcp so `type: gcp-service-account` resolves during + // lint. After phase 5 of the GCP extraction the in-tree registrations + // are gone and the only path to GCP tokens is through the plugin + // bootstrap or an explicit `plugins:` block. + if err := os.WriteFile(filepath.Join(workdir, ".sc", "stacks", "app", "server.yaml"), []byte(`schemaVersion: 1.0 +plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da +`), 0o644); err != nil { + t.Fatal(err) + } adminPub, adminPEM = testRecipient(t) if out, err := execScope(t, workdir, "", "allow", "--scope", "pr", adminPub); err != nil { t.Fatalf("allow: %v\n%s", err, out) diff --git a/pkg/e2e/scopedstore/scoped_e2e_test.go b/pkg/e2e/scopedstore/scoped_e2e_test.go index 261a90e45..e963c348a 100644 --- a/pkg/e2e/scopedstore/scoped_e2e_test.go +++ b/pkg/e2e/scopedstore/scoped_e2e_test.go @@ -704,6 +704,12 @@ func serverYAML(stagingResources string) string { stagingResources = " {}" } return `schemaVersion: 1.0 +plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da provisioner: type: pulumi config: diff --git a/pkg/provisioner/init.go b/pkg/provisioner/init.go index fb2210d4d..3bafbfded 100644 --- a/pkg/provisioner/init.go +++ b/pkg/provisioner/init.go @@ -6,7 +6,9 @@ package provisioner import ( // sc-plugin-mongodb/api blank-import stripped as part of A3 // smoke; see pkg/clouds/pulumi/init.go for the rationale. - _ "github.com/simple-container-com/api/pkg/clouds/gcloud" + // pkg/clouds/gcloud blank-import stripped in phase 5 of the GCP + // plugin extraction — sc-plugin-gcp now owns every GCP type-token + // authoritatively via its own /api init + plugin_bootstrap.go. _ "github.com/simple-container-com/api/pkg/clouds/github" _ "github.com/simple-container-com/api/pkg/clouds/pulumi" ) diff --git a/pkg/provisioner/placeholders/tests/placeholders_test.go b/pkg/provisioner/placeholders/tests/placeholders_test.go index 8086f727c..bf866273e 100644 --- a/pkg/provisioner/placeholders/tests/placeholders_test.go +++ b/pkg/provisioner/placeholders/tests/placeholders_test.go @@ -8,12 +8,12 @@ import ( "testing" . "github.com/onsi/gomega" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/simple-container-com/api/pkg/api" git_mocks "github.com/simple-container-com/api/pkg/api/git/mocks" tests "github.com/simple-container-com/api/pkg/api/tests" testutils "github.com/simple-container-com/api/pkg/api/tests/testutil" - "github.com/simple-container-com/api/pkg/clouds/gcloud" "github.com/simple-container-com/api/pkg/clouds/github" "github.com/simple-container-com/api/pkg/clouds/pulumi" "github.com/simple-container-com/api/pkg/provisioner/placeholders" @@ -38,8 +38,8 @@ func Test_placeholders_ProcessStacks(t *testing.T) { // auth Expect(stacks["common"].Secrets.Auth["gcloud"]).NotTo(BeNil()) authConfigGeneric := stacks["common"].Secrets.Auth["gcloud"].Config.Config - Expect(authConfigGeneric).To(BeAssignableToTypeOf(&gcloud.Credentials{})) - authConfig := authConfigGeneric.(*gcloud.Credentials) + Expect(authConfigGeneric).To(BeAssignableToTypeOf(&gcpApi.Credentials{})) + authConfig := authConfigGeneric.(*gcpApi.Credentials) Expect(authConfig.ProjectId).To(Equal("test-gcp-project")) Expect(authConfig.Credentials.Credentials).To(Equal("")) @@ -49,10 +49,10 @@ func Test_placeholders_ProcessStacks(t *testing.T) { pConfig := srvConfig.(*pulumi.ProvisionerConfig) // state storage - Expect(pConfig.StateStorage.Config.Config).To(BeAssignableToTypeOf(&gcloud.StateStorageConfig{})) - stateStorageCfg := pConfig.StateStorage.Config.Config.(*gcloud.StateStorageConfig) - Expect(pConfig.SecretsProvider.Config.Config).To(BeAssignableToTypeOf(&gcloud.SecretsProviderConfig{})) - secretsProviderCfg := pConfig.SecretsProvider.Config.Config.(*gcloud.SecretsProviderConfig) + Expect(pConfig.StateStorage.Config.Config).To(BeAssignableToTypeOf(&gcpApi.StateStorageConfig{})) + stateStorageCfg := pConfig.StateStorage.Config.Config.(*gcpApi.StateStorageConfig) + Expect(pConfig.SecretsProvider.Config.Config).To(BeAssignableToTypeOf(&gcpApi.SecretsProviderConfig{})) + secretsProviderCfg := pConfig.SecretsProvider.Config.Config.(*gcpApi.SecretsProviderConfig) Expect(stateStorageCfg.ProjectId).To(Equal("test-gcp-project")) Expect(stateStorageCfg.Credentials.Credentials.Credentials).To(Equal("")) Expect(secretsProviderCfg.ProjectId).To(Equal("test-gcp-project")) @@ -83,8 +83,8 @@ func Test_placeholders_ProcessStacks(t *testing.T) { check: func(t *testing.T, stacks api.StacksMap) { Expect(stacks["refapp"]).NotTo(BeNil()) resPgCfg := stacks["refapp"].Server.Resources.Resources["staging"].Resources["postgres"].Config.Config - Expect(resPgCfg).To(BeAssignableToTypeOf(&gcloud.PostgresGcpCloudsqlConfig{})) - pgConfig := resPgCfg.(*gcloud.PostgresGcpCloudsqlConfig) + Expect(resPgCfg).To(BeAssignableToTypeOf(&gcpApi.PostgresGcpCloudsqlConfig{})) + pgConfig := resPgCfg.(*gcpApi.PostgresGcpCloudsqlConfig) Expect(pgConfig.CredentialsValue()).To(Equal("")) Expect(pgConfig.Project).To(Equal("refapp")) diff --git a/pkg/provisioner/scoped_deploy_test.go b/pkg/provisioner/scoped_deploy_test.go index 3552a5ba2..c88fa0d8a 100644 --- a/pkg/provisioner/scoped_deploy_test.go +++ b/pkg/provisioner/scoped_deploy_test.go @@ -10,16 +10,17 @@ import ( "strings" "testing" + "golang.org/x/crypto/ssh" + . "github.com/onsi/gomega" "github.com/pkg/errors" + gcpApi "github.com/simple-container-com/sc-plugin-gcp/api" "github.com/stretchr/testify/mock" - "golang.org/x/crypto/ssh" "github.com/simple-container-com/api/pkg/api" git_mocks "github.com/simple-container-com/api/pkg/api/git/mocks" "github.com/simple-container-com/api/pkg/api/secrets/ciphers" "github.com/simple-container-com/api/pkg/api/secrets/scoped" - "github.com/simple-container-com/api/pkg/clouds/gcloud" pulumi_mocks "github.com/simple-container-com/api/pkg/clouds/pulumi/mocks" "github.com/simple-container-com/api/pkg/provisioner/placeholders" ) @@ -29,6 +30,12 @@ import ( // secrets.yaml could not be revealed, and the job holds only its own scope key. const scopedParentServer = `schemaVersion: 1.0 +plugins: + gcp: + source: + type: github-release + repository: simple-container-com/sc-plugin-gcp + version: v2026.11.08-d14f4da provisioner: type: pulumi config: @@ -199,7 +206,7 @@ func Test_Deploy_ScopedOnly_AmbientAuth(t *testing.T) { Expect(appEnv(got, "staging")).To(HaveKeyWithValue("APP_KEY", "s3cr3t")) auth, ok := got.Secrets.Auth["gcloud"] Expect(ok).To(BeTrue()) - creds, ok := auth.Config.Config.(*gcloud.Credentials) + creds, ok := auth.Config.Config.(*gcpApi.Credentials) Expect(ok).To(BeTrue(), "auth config is %T", auth.Config.Config) Expect(creds.UsesAmbientCredentials()).To(BeTrue()) Expect(creds.ProjectId).To(Equal("acme-staging")) @@ -286,7 +293,7 @@ func Test_Deploy_ScopedAuthCannotOverrideLegacy(t *testing.T) { log := &captureLogger{} got, err := w.deploy(t, log, "staging") Expect(err).NotTo(HaveOccurred()) - creds := got.Secrets.Auth["gcloud"].Config.Config.(*gcloud.Credentials) + creds := got.Secrets.Auth["gcloud"].Config.Config.(*gcpApi.Credentials) Expect(creds.ProjectId).To(Equal("legacy-project")) Expect(strings.Join(log.warns, "\n")).To(ContainSubstring(`scoped auth "gcloud" is shadowed`)) }