diff --git a/docs/research/azure-bearer-token-auth-support.md b/docs/research/azure-bearer-token-auth-support.md new file mode 100644 index 00000000..6111b3e6 --- /dev/null +++ b/docs/research/azure-bearer-token-auth-support.md @@ -0,0 +1,134 @@ +# Research: Azure Bearer Access Token Auth Support + +**Question:** Can an Azure Bearer access token (from `az account get-access-token`) be used +directly as authentication in ESO Azure Key Vault SecretStore, ESO PushSecret, and the +Terraform `azurerm` provider? + +**Date:** 2026-07-24 +**Sources:** Official ESO docs, ESO Go types (source), azurerm provider source, Terraform docs. + +--- + +## ESO Version in this codebase + +`firestartr-bootstrap/operator.go` installs ESO with: + +```go +helm upgrade --install external-secrets external-secrets/external-secrets \ + -n external-secrets --create-namespace +``` + +**No `--version` flag is set.** ESO is always installed at the latest available Helm chart +version. The `v1beta1` API types analysed below are current as of ESO v0.9+. + +--- + +## Scenario 1: ESO SecretStore — Azure Key Vault (read path) + +### Sources +- Docs: https://external-secrets.io/latest/provider/azure-key-vault/ +- Source: `apis/externalsecrets/v1beta1/secretstore_azurekv_types.go` (ESO main branch) + +### Supported `authType` values (enum, exhaustive) + +| Value | Description | +|---|---| +| `ServicePrincipal` (default) | Needs `tenantId` + `clientId` + (`clientSecret` OR `clientCertificate`) | +| `ManagedIdentity` | AAD Pod Identity (deprecated upstream). No secret ref needed. | +| `WorkloadIdentity` | OIDC-based federated identity. Uses a Kubernetes `ServiceAccount`. | + +### `AzureKVAuth` struct fields (the `authSecretRef` object) + +```go +type AzureKVAuth struct { + ClientID *smmeta.SecretKeySelector `json:"clientId,omitempty"` + TenantID *smmeta.SecretKeySelector `json:"tenantId,omitempty"` + ClientSecret *smmeta.SecretKeySelector `json:"clientSecret,omitempty"` + ClientCertificate *smmeta.SecretKeySelector `json:"clientCertificate,omitempty"` +} +``` + +### Does ESO support a raw Bearer access token? + +**No.** There is no `accessToken` field in `AzureKVAuth` and no `AccessToken` auth type in the +`AzureAuthType` enum. The struct is exhaustive — only `clientSecret` and `clientCertificate` are +accepted alongside `clientId`. + +A token from `az account get-access-token` is a short-lived ARM Bearer token (valid ~1 hour, +audience `https://vault.azure.net/`). ESO has no mechanism to inject a pre-obtained Bearer token +directly into Key Vault HTTP requests. + +**Workaround:** The closest option without a permanent SP is `WorkloadIdentity`, which uses OIDC +federation and requires cluster-level setup (`azure.workload.identity/client-id` annotation on a +`ServiceAccount`). + +--- + +## Scenario 2: ESO PushSecret — Azure Key Vault (write path) + +### Does PushSecret support different auth methods? + +**No difference from the read path.** A `PushSecret` references a `SecretStore` (or +`ClusterSecretStore`) object via `spec.secretStoreRefs`. Authentication is entirely delegated to +the store — the `PushSecret` resource itself has no auth fields. + +The same `AzureKVProvider` / `AzureKVAuth` types govern both read (`ExternalSecret`) and write +(`PushSecret`) operations. A raw Bearer access token is equally unsupported on the write path. + +The only write-specific requirement is an elevated RBAC role: +- For secrets: `Key Vault Secrets Officer` (or Access Policy `Set`/`Delete`) +- For keys: `Key Vault Crypto Officer` +- For certificates: `Key Vault Certificates Officer` + +--- + +## Scenario 3: Terraform `azurerm` provider + +### Sources +- Docs (source): `website/docs/index.html.markdown` (hashicorp/terraform-provider-azurerm main) +- Provider schema: `internal/provider/provider.go` (hashicorp/terraform-provider-azurerm main) + +### Supported authentication methods (exhaustive from provider schema) + +| Method | Key fields / env vars | +|---|---| +| Azure CLI | `use_cli` / `ARM_USE_CLI` (default `true`) | +| Service Principal + Client Secret | `client_secret` / `ARM_CLIENT_SECRET` | +| Service Principal + Client Certificate | `client_certificate` / `ARM_CLIENT_CERTIFICATE` | +| Managed Service Identity | `use_msi` / `ARM_USE_MSI` | +| AKS Workload Identity | `use_aks_workload_identity` / `ARM_USE_AKS_WORKLOAD_IDENTITY` | +| OIDC | `use_oidc` / `ARM_USE_OIDC` + `oidc_token` / `ARM_OIDC_TOKEN` | + +### Does `azurerm` support `access_token` or `ARM_ACCESS_TOKEN`? + +**No.** Neither `access_token` nor `ARM_ACCESS_TOKEN` appears anywhere in the provider schema or +documentation. + +The `oidc_token` / `ARM_OIDC_TOKEN` field looks superficially similar but is fundamentally +different: it accepts an **OIDC ID token** (a JWT issued by an OIDC provider to be exchanged for +an Azure access token), not a pre-obtained ARM Bearer access token from +`az account get-access-token`. + +### `use_cli = true` is the implicit path + +When `ARM_USE_CLI` is not overridden (default `true`), the provider delegates to the Azure CLI +credential chain, which calls `az account get-access-token` internally. This is the only way a +CLI-obtained token is consumed — indirectly, with the provider managing token refresh. You cannot +supply the raw token string yourself. + +--- + +## Summary table + +| Scenario | Bearer token supported? | Config field/env var | Notes | +|---|---|---|---| +| ESO SecretStore (Azure KV, read) | **No** | — | Only `ServicePrincipal`, `ManagedIdentity`, `WorkloadIdentity` auth types exist in the API | +| ESO PushSecret (Azure KV, write) | **No** | — | Delegates to same SecretStore; no separate auth path | +| Terraform `azurerm` provider | **No** | — | No `access_token`/`ARM_ACCESS_TOKEN` field; CLI token used implicitly via `use_cli=true` | + +### Token characteristics (for context) + +- `az account get-access-token` returns a token valid for **~1 hour**. +- The default audience is `https://management.azure.com/` (ARM). For Key Vault operations you + need `--resource https://vault.azure.net/` — a different token. +- Neither ESO nor `azurerm` expose a field to inject either token directly. diff --git a/firestartr-bootstrap/README.md b/firestartr-bootstrap/README.md index c547f2dc..bca7b261 100644 --- a/firestartr-bootstrap/README.md +++ b/firestartr-bootstrap/README.md @@ -49,13 +49,24 @@ The following AWS Parameter Store parameters are required: ### 2. Bootstrap File +There are two deployment modes, selected via `deploymentMode`: + +| Field | SaaS (default) | Dedicated (Azure) | +|---|---|---| +| `deploymentMode` | `saas` (or omit) | `dedicated` | +| `env` | **required** — `"pre"` or `"pro"` | **omit** — no environment concept | +| `domain` | not used | **required** — base domain (e.g. `"azure-pre.firestartr.dev"`) | + +#### 2.1 SaaS Bootstrap File (AWS) + ```yaml -# BootstrapFile.yaml +# BootstrapFile.yaml (SaaS / AWS) --- +deploymentMode: saas # optional — saas is the default org: # github org name -customer: # customer name used for Firestartr internally, for example to find secrets within the parameter store -env: # set either "pre" for firestartr-pre or "pro" for firestartr-pro -defaultOrgPermissions: # default permissions for the -all group, can be none, view or contribute +customer: # customer name used for Firestartr internally +env: # required for SaaS: "pre" (firestartr-pre) or "pro" (firestartr-pro) +defaultOrgPermissions: # default permissions for the -all group, can be none, view or contribute defaultBranch: main defaultBranchStrategy: none defaultDomainName: # ask customer for a default domain name to be used in the claims, for example "myproduct" @@ -65,8 +76,8 @@ defaultFirestartrGroup: firestartr # default group for firestartr users and rela firestartr: # Check latest available release at github.com/prefapp/gitops-k8s - operator: # Ex. v1.56.1 - cli: # Ex. v1.56.1 + operator: # Ex. v1.56.1 + cli: # Ex. v1.56.1 pushFiles: claims: @@ -147,10 +158,55 @@ components: version: latest # Check available versions at github.com/prefapp/features ``` +#### 2.2 Dedicated Bootstrap File (Azure) + +For dedicated (non-SaaS) deployments, replace `env` with `deploymentMode: dedicated` and `domain`: + +```yaml +# BootstrapFile.yaml (dedicated / Azure) +--- +deploymentMode: dedicated +domain: "azure-pre.firestartr.dev" # fully-qualified base domain; no env suffix needed +org: +customer: +# NOTE: 'env' is absent — dedicated deployments have no environment concept +defaultOrgPermissions: +defaultBranch: main +defaultBranchStrategy: none +defaultDomainName: +defaultSystemName: +defaultGroup: +defaultFirestartrGroup: firestartr + +firestartr: + operator: + cli: + +pushFiles: + claims: + push: true + repo: "claims" + dotFirestartr: + push: true + repo: ".firestartr" + crs: + providers: + github: + push: true + repo: "state-github" + +components: + # state-sys-services and state-argocd are auto-injected; no need to declare them here + - name: "dot-firestartr" + ... +``` + All the parameters must be filled. When copy pasting this file, `` must be replaced, but any other values can be treated as defaults and changed if needed: - `org`: name of the GitHub organization where Firestartr will be installed. -- `env`: environment where the deployment and ArgoCD application will be created. Can be either `pre` or `pro`, and will result in commits being created to the necessary repositories in the `firestartr-` organization. +- `deploymentMode`: deployment topology. `saas` (default, can be omitted) targets the shared `firestartr-` org on AWS. `dedicated` targets the customer's own GitHub org on Azure. +- `env`: **SaaS only.** Environment where the deployment and ArgoCD application will be created. Can be either `pre` or `pro`, resulting in commits to the `firestartr-` organization. **Omit for dedicated deployments.** +- `domain`: **Dedicated only.** Fully-qualified base domain for the deployment (e.g. `azure-pre.firestartr.dev`). The webhook URL will be `https://.events.`. **Omit for SaaS deployments.** - `customer`: name used for the org internally, to compose the parameter store paths (e.g. `/firestartr/fs--admin/app-id`). Must be set even if it matches the org name. - `defaultBranch`: default branch name to set in the `defaults` config file, `claims_defaults.yaml`. Usually `main` or `master`. - `defaultSystemName`: the name of the system that will be created by the bootstrapping process and set in the `claims_defaults.yaml` configuration file. Though any name can be used, it's recommended the bootstrap operator asks the client which system name they want to use as default. @@ -162,10 +218,10 @@ All the parameters must be filled. When copy pasting this file, `` - `firestartr.operator`: Firestartr version to be used by the operator. Must be the name of an image tag, without the flavor (i.e., `v1.53.0` instead of `v1.53.0_full-aws` or `v1.53.0_slim`). You can check the latest available image version [here](https://github.com/prefapp/gitops-k8s/pkgs/container/gitops-k8s). - `firestartr.cli`: Firestartr CLI version to be used in the importation process. You can check the latest available CLI version [here](https://github.com/prefapp/gitops-k8s/blob/main/.release-please-manifest.json#L2). Note that this CLI version **won't** be the version set as the `FIRESTARTR_CLI_VERSION` organization variable, which is set from the parameter store instead (`/firestartr//firestartr-cli-version`). - `pushFiles`: whether or not to push the files create to their respective repositories once the bootstrap process finishes. Each section has two parameters: `push`, which if `true` will push those files to `repo`, whose value should be the name of the repository where those files will be pushed to. -- `components`: list of repositories to create during the bootstrap process. The values of each component will be explained in section 2.1. For a default bootstrap installation, it's recommended to leave them as is and update only the `` placeholders. This section should only be updated on special cases (e.g., the client already has a `claims` repository created). +- `components`: list of repositories to create during the bootstrap process. The values of each component will be explained in section 2.3. For a default bootstrap installation, it's recommended to leave them as is and update only the `` placeholders. This section should only be updated on special cases (e.g., the client already has a `claims` repository created). **For dedicated deployments, `state-sys-services` and `state-argocd` are auto-injected and do not need to be listed here.** -#### 2.1 Components +#### 2.3 Components Each component represents a repository that will be created in the organization. All fields are mandatory. The parameters are: @@ -212,28 +268,83 @@ The rest of the parameters of the `cloudProvider` section are the AWS S3 bucket - `github.prefappBotPat`: Personal Access Token for the Prefapp Bot user, used to download the features from the features repository. - `github.operatorPat`: Personal Access Token for the Operator user, used to commit the deployment and ArgoCD application PRs to the `firestartr-` organization. -#### 3.2 Azure terraform backend provider configuration (currently not supported) +#### 3.2 Azure dedicated deployment configuration + +For dedicated (non-SaaS) deployments on Azure, set `deploymentMode: dedicated` in your `Bootstrapfile.yaml` and use the following `Credentialsfile.yaml` format: ```yaml # Credentialsfile.yaml --- cloudProvider: - providerConfigName: backend-provider-config-name - name: azurerm + name: azure config: - use_azuread_auth: true - tenant_id: "00000000-0000-0000-0000-000000000000" - client_id: "00000000-0000-0000-0000-000000000000" - client_secret: "************************************" - storage_account_name: "abcd1234" + tenant_id: "" + subscription_id: "" + # Runtime identity: firestartr-mi User-Assigned Managed Identity. + # Used in the deployed AKS cluster via Workload Identity (no client secret). + client_id: "" + # Bootstrap identity: dedicated App Registration (Service Principal). + # Used only during bootstrap in the local kind cluster by ESO and Terraform. + # Delete the entire App Registration after bootstrap completes. + bootstrap_client_id: "" + bootstrap_client_secret: "" + storage_account_name: "tfstate" # Azure Storage Account name for Terraform state container_name: "tfstate" - source: hashicorp/aws - type: aws - version: ~> 4.0 + resource_group_name: "rg-firestartr" # Also used for DNS zone resource group + key_vault_name: "firestartr-kv" # Azure Key Vault name (no slashes in secret names) + aks_cluster_name: "" # Name of the target AKS cluster + location: "" # Azure region, e.g. "westeurope" — must match resource group + source: hashicorp/azurerm + type: azurerm + version: "~> 3.0" github: - providerConfigName: github-app-provider-config-name + prefappBotPat: "" + operatorPat: "" +``` + +And your `Bootstrapfile.yaml` must include `deploymentMode` and `domain`: + +```yaml +# Bootstrapfile.yaml (dedicated Azure example) +--- +deploymentMode: dedicated +domain: "azure-pre.firestartr.dev" # Fully-qualified base domain; encodes env if needed +org: "" +customer: "" +# (no 'env' field for dedicated deployments) +... ``` +**Pre-flight requirements for dedicated Azure deployments:** + +1. A `firestartr-mi` User-Assigned Managed Identity with OIDC federation configured on the AKS cluster. +2. A bootstrap App Registration (Service Principal) in Entra ID with: + - `Key Vault Secrets Officer` on the Azure Key Vault + - `Storage Blob Data Contributor` on the Terraform state storage account + - A client secret (populate `bootstrap_client_id` and `bootstrap_client_secret` in the credentials file) + - **Delete the entire App Registration after bootstrap completes.** +3. Azure Key Vault populated with all required secrets using the `[a-zA-Z0-9-]` naming convention: + - `fs-pem`, `fs-app-id`, `fs--installation-id` + - `fs-admin-pem`, `fs-admin-app-id`, `fs-admin--installation-id` + - `fs-argocd-pem`, `fs-argocd-app-id`, `fs-argocd--installation-id` + - `fs-state-pem`, `fs-state-app-id`, `fs-state--installation-id` + - `fs-checks-pem`, `fs-checks-app-id`, `fs-checks--installation-id` + - `fs-import-pem`, `fs-import-app-id`, `fs-import--installation-id` + - `github-webhook-secret`, `prefapp-bot-pat`, `firestartr-cli-version` +4. Delegated DNS zone matching `domain` in the Azure resource group. + +**What the dedicated bootstrap does differently from SaaS:** + +| Concern | SaaS (AWS) | Dedicated (Azure) | +|---|---|---| +| Repo targeting | `firestartr-/` | `/` | +| Cluster services | ESO only (pre-provisioned) | Installs ESO + nginx + cert-manager + external-dns + ArgoCD | +| Webhook URL | `.events[.].firestartr.dev` | `.events.` | +| Secret refs | AWS Parameter Store paths | Azure Key Vault dash-delimited names | +| Deployment values | `values.tmpl` (AWS/ALB/IAM) | `azure_values.tmpl` (Azure/nginx/MI) | + +> **Important:** The `bootstrap_client_id` and `bootstrap_client_secret` belong to a dedicated App Registration created solely for this bootstrap run. The deployed state uses `firestartr-mi` via Workload Identity and never needs a client secret. **Delete the entire App Registration after bootstrap completes** — not just the secret. + ### 4. How to launch the bootstrap ``: Replace with the port that kind is using to expose the Kubernetes API server (noted in step 1.1). @@ -406,8 +517,25 @@ dagger --bootstrap-file="./Bootstrapfile.yaml" \ call cmd-push-deployment ``` -Creates a deployment PR in `firestartr-/app-firestartr`. +- **SaaS:** Creates a deployment PR in `firestartr-/app-firestartr`. +- **Dedicated:** Creates a deployment PR in `/state-sys-services` with all sys-service release descriptors and values. + +Apply sys-services with values (**dedicated only**): + +```shell +dagger --bootstrap-file="./Bootstrapfile.yaml" \ + --credentials-secret="file:./Credentialsfile.yaml" \ + call cmd-apply-sys-services \ + --docker-socket=/var/run/docker.sock \ + --kind-svc=tcp://localhost: \ + --kind-cluster-name= +``` + +This step is **only needed for dedicated deployments**. It renders the Azure-specific Helm values and applies them directly to the AKS cluster via `helm upgrade --install --values`, ensuring all cluster services (nginx, cert-manager, external-dns, ArgoCD, argo-events, argo-workflows) are correctly configured from the start. + +The AKS cluster name is read from `aks_cluster_name` in the credentials file. If the external-dns Managed Identity client ID cannot be resolved automatically, the command prompts for the client ID on the terminal. +> Merge the `state-sys-services` PR **before** running this step so the desired state is recorded in git first. Create ArgoCD application PR: @@ -417,7 +545,8 @@ dagger --bootstrap-file="./Bootstrapfile.yaml" \ call cmd-push-argo ``` -Creates an application PR in `firestartr-/state-argocd`. +- **SaaS:** Creates an application PR in `firestartr-/state-argocd`. +- **Dedicated:** Creates an application PR in `/state-argocd` and patches `/state-sys-services` with the ArgoCD secrets entry. ## 7. Troubleshooting diff --git a/firestartr-bootstrap/argocd.go b/firestartr-bootstrap/argocd.go index bce79cf5..3163c6b2 100644 --- a/firestartr-bootstrap/argocd.go +++ b/firestartr-bootstrap/argocd.go @@ -23,9 +23,18 @@ func (m *FirestartrBootstrap) CreateArgCDApplications( m.Creds.GithubApp.OperatorPat, ) + // For dedicated deployments, target the customer's own org; for SaaS, + // target the shared firestartr- org. + var argoOrg string + if m.isDedicatedDeployment() { + argoOrg = m.Bootstrap.Org + } else { + argoOrg = fmt.Sprintf("firestartr-%s", m.Bootstrap.Env) + } + argoCDRepo, err := m.CloneRepo( ctx, - fmt.Sprintf("firestartr-%s", m.Bootstrap.Env), + argoOrg, "state-argocd", tokenSecret, ) @@ -34,27 +43,30 @@ func (m *FirestartrBootstrap) CreateArgCDApplications( return nil, fmt.Errorf("cloning ArgoCD repo: %w", err) } - projectDir, err := addProjectDestination( - ctx, - argoCDRepo.Directory("/repo"), - "apps/firestartr/argo-firestartr.Project.yaml", - fmt.Sprintf("%s-firestartr-%s", m.Bootstrap.Customer, m.Bootstrap.Env), - "https://kubernetes.default.svc", - ) + if !m.isDedicatedDeployment() { + // SaaS path: patch the ArgoCD project to add the new namespace as a destination + projectDir, err := addProjectDestination( + ctx, + argoCDRepo.Directory("/repo"), + "apps/firestartr/argo-firestartr.Project.yaml", + fmt.Sprintf("%s-firestartr-%s", m.Bootstrap.Customer, m.Bootstrap.Env), + "https://kubernetes.default.svc", + ) + + if err != nil { + return nil, fmt.Errorf("adding project destination to ArgoCD: %w", err) + } - if err != nil { - return nil, fmt.Errorf("adding project destination to ArgoCD: %w", err) + argoCDRenderedDir = argoCDRenderedDir.WithFile( + "apps/firestartr/argo-firestartr.Project.yaml", + projectDir.File("apps/firestartr/argo-firestartr.Project.yaml"), + ) } - argoCDRenderedDir = argoCDRenderedDir.WithFile( - "apps/firestartr/argo-firestartr.Project.yaml", - projectDir.File("apps/firestartr/argo-firestartr.Project.yaml"), - ) - err = m.CreatePR( ctx, "state-argocd", - fmt.Sprintf("firestartr-%s", m.Bootstrap.Env), + argoOrg, argoCDRenderedDir, fmt.Sprintf("automated-create-applications-%s", m.Bootstrap.Org), fmt.Sprintf("ci: add applications for %s [automated]", m.Bootstrap.Org), @@ -73,6 +85,15 @@ func (m *FirestartrBootstrap) RenderArgoCDApplications( ctx context.Context, ) (*dagger.Directory, error) { + // For dedicated deployments ArgoCD runs in the "argocd" namespace; + // for SaaS it uses a customer+env-scoped namespace. + var namespace string + if m.isDedicatedDeployment() { + namespace = "argocd" + } else { + namespace = fmt.Sprintf("%s-firestartr-%s", m.Bootstrap.Customer, m.Bootstrap.Env) + } + argoCDData := ArgoCDConfig{ Name: fmt.Sprintf( @@ -89,10 +110,7 @@ func (m *FirestartrBootstrap) RenderArgoCDApplications( m.Bootstrap.Org, ), - Namespace: fmt.Sprintf("%s-firestartr-%s", - m.Bootstrap.Customer, - m.Bootstrap.Env, - ), + Namespace: namespace, } argoCDDataInfra := ArgoCDConfig{ @@ -109,10 +127,7 @@ func (m *FirestartrBootstrap) RenderArgoCDApplications( m.Bootstrap.Org, ), - Namespace: fmt.Sprintf("%s-firestartr-%s", - m.Bootstrap.Customer, - m.Bootstrap.Env, - ), + Namespace: namespace, } argoCDDataSecrets := ArgoCDConfig{ @@ -129,10 +144,7 @@ func (m *FirestartrBootstrap) RenderArgoCDApplications( m.Bootstrap.Org, ), - Namespace: fmt.Sprintf("%s-firestartr-%s", - m.Bootstrap.Customer, - m.Bootstrap.Env, - ), + Namespace: namespace, } applicationStateGithub, errGithub := renderArgoCDApplication( diff --git a/firestartr-bootstrap/argocd_config_secrets.go b/firestartr-bootstrap/argocd_config_secrets.go index a90a7441..c9d60e48 100644 --- a/firestartr-bootstrap/argocd_config_secrets.go +++ b/firestartr-bootstrap/argocd_config_secrets.go @@ -18,9 +18,42 @@ func (m *FirestartrBootstrap) AddArgoCDSecrets( m.Creds.GithubApp.OperatorPat, ) + // For dedicated deployments, target the customer's own state-sys-services repo. + var sysSvcsOrg, argoSecretsFilePath string + var appIdSecretRef, installationIdSecretRef, pemSecretRef string + + if m.isDedicatedDeployment() { + sysSvcsOrg = m.Bootstrap.Org + argoSecretsFilePath = fmt.Sprintf("kubernetes-sys-services/%s/argo-configuration-secrets/values.yaml", DeploymentPlatformAKS) + // Azure Key Vault dash-delimited names + appIdSecretRef = "fs-argocd-app-id" + installationIdSecretRef = fmt.Sprintf("fs-argocd-%s-installation-id", m.GhOrgLowerCase) + pemSecretRef = "fs-argocd-pem" + } else { + sysSvcsOrg = fmt.Sprintf("firestartr-%s", m.Bootstrap.Env) + argoSecretsFilePath = fmt.Sprintf("kubernetes-sys-services/firestartr-%s/argo-configuration-secrets/values.yaml", m.Bootstrap.Env) + // AWS Parameter Store hierarchical paths + appIdSecretRef = fmt.Sprintf( + "/firestartr/%s/fs-%s-argocd/app-id", + m.Bootstrap.Customer, + m.Bootstrap.Customer, + ) + installationIdSecretRef = fmt.Sprintf( + "/firestartr/%s/fs-%s-argocd/%s/app-installation-id", + m.Bootstrap.Customer, + m.Bootstrap.Customer, + m.Bootstrap.Org, + ) + pemSecretRef = fmt.Sprintf( + "/firestartr/%s/fs-%s-argocd/pem", + m.Bootstrap.Customer, + m.Bootstrap.Customer, + ) + } + argoCDRepo, err := m.CloneRepo( ctx, - fmt.Sprintf("firestartr-%s", m.Bootstrap.Env), + sysSvcsOrg, "state-sys-services", tokenSecret, ) @@ -30,23 +63,6 @@ func (m *FirestartrBootstrap) AddArgoCDSecrets( return nil, fmt.Errorf("cloning state-sys-services repo: %w", err) } - appIdSecretRef := fmt.Sprintf( - "/firestartr/%s/fs-%s-argocd/app-id", - m.Bootstrap.Customer, - m.Bootstrap.Customer, - ) - installationIdSecretRef := fmt.Sprintf( - "/firestartr/%s/fs-%s-argocd/%s/app-installation-id", - m.Bootstrap.Customer, - m.Bootstrap.Customer, - m.Bootstrap.Org, - ) - pemSecretRef := fmt.Sprintf( - "/firestartr/%s/fs-%s-argocd/pem", - m.Bootstrap.Customer, - m.Bootstrap.Customer, - ) - clientAccess := ClientAccess{ GithubAppId: PrivateKeyReference{ RemoteRef: appIdSecretRef, @@ -62,7 +78,7 @@ func (m *FirestartrBootstrap) AddArgoCDSecrets( patchedDir, err := safelyPatchYamlConfig( ctx, argoCDRepo.Directory("/repo"), - fmt.Sprintf("kubernetes-sys-services/firestartr-%s/argo-configuration-secrets/values.yaml", m.Bootstrap.Env), + argoSecretsFilePath, m.GhOrgLowerCase, clientAccess, ) @@ -75,7 +91,7 @@ func (m *FirestartrBootstrap) AddArgoCDSecrets( err = m.CreatePR( ctx, "state-sys-services", - fmt.Sprintf("firestartr-%s", m.Bootstrap.Env), + sysSvcsOrg, patchedDir, fmt.Sprintf("automated-add-argocd-secrets-for-%s", m.Bootstrap.Org), fmt.Sprintf("ci: add argocd secrets for %s [automated]", m.Bootstrap.Org), diff --git a/firestartr-bootstrap/azure.go b/firestartr-bootstrap/azure.go new file mode 100644 index 00000000..2cf29487 --- /dev/null +++ b/firestartr-bootstrap/azure.go @@ -0,0 +1,412 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "bufio" + "dagger/firestartr-bootstrap/internal/dagger" +) + +// azureTokenResponse is the JSON payload returned by the Azure AD token endpoint. +type azureTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// kvSecretItem is a single entry in the Key Vault secrets list response. +type kvSecretItem struct { + ID string `json:"id"` +} + +// kvSecretList is a page of results from the Key Vault secrets list endpoint. +type kvSecretList struct { + Value []kvSecretItem `json:"value"` + NextLink string `json:"nextLink"` +} + +// loginAzureKV authenticates the bootstrap App Registration (Service Principal) +// and returns a Bearer token scoped to Azure Key Vault. +// Equivalent to loginAWS for the dedicated deployment path. +func loginAzureKV(ctx context.Context, creds *CredsFile) (string, error) { + cfg := creds.CloudProvider.Config + tokenURL := fmt.Sprintf( + "https://login.microsoftonline.com/%s/oauth2/v2.0/token", + cfg.TenantId, + ) + + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", cfg.BootstrapClientId) + data.Set("client_secret", cfg.BootstrapClientSecret) + data.Set("scope", "https://vault.azure.net/.default") + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode()), + ) + if err != nil { + return "", fmt.Errorf("building Azure AD token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("calling Azure AD token endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading Azure AD token response: %w", err) + } + + var tokenResp azureTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("parsing Azure AD token response: %w", err) + } + + if tokenResp.Error != "" { + return "", fmt.Errorf( + "Azure AD authentication failed: %s — %s", + tokenResp.Error, tokenResp.ErrorDesc, + ) + } + + if tokenResp.AccessToken == "" { + return "", fmt.Errorf( + "Azure AD returned an empty access token (HTTP %d)", resp.StatusCode, + ) + } + + return tokenResp.AccessToken, nil +} + +// ValidateAzureSPCredentials validates that the bootstrap App Registration credentials +// (bootstrap_client_id + bootstrap_client_secret) can successfully authenticate to +// Azure AD and obtain a Key Vault scoped token. +// Equivalent to ValidateSTSCredentials for the dedicated deployment path. +func (m *FirestartrBootstrap) ValidateAzureSPCredentials(ctx context.Context) error { + log.Println("Attempting to validate Azure bootstrap SP credentials...") + + _, err := loginAzureKV(ctx, m.Creds) + if err != nil { + return fmt.Errorf("bootstrap SP credentials are invalid: %w", err) + } + + log.Printf("✅ Azure bootstrap SP credentials validated successfully.") + return nil +} + +// expectedAzureKVSecrets returns the Key Vault secret names that must exist before +// bootstrap can run. These are the secrets that ESO will pull into the kind cluster +// via the azure_bootstrap_secrets and azure_operator_secrets ExternalSecret CRs. +func (m *FirestartrBootstrap) expectedAzureKVSecrets() []string { + org := m.GhOrgLowerCase + return []string{ + "fs-admin-pem", + "fs-admin-app-id", + fmt.Sprintf("fs-admin-%s-installation-id", org), + "fs-pem", + "fs-app-id", + fmt.Sprintf("fs-%s-installation-id", org), + } +} + +// listKVSecretNames retrieves all secret names from an Azure Key Vault, following +// pagination via nextLink. Secret values are never fetched — only names. +func listKVSecretNames(ctx context.Context, vaultURL, token string) ([]string, error) { + names := []string{} + nextURL := fmt.Sprintf("%s/secrets?api-version=7.4", vaultURL) + + for nextURL != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, nil) + if err != nil { + return nil, fmt.Errorf("building Key Vault list request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("calling Key Vault list endpoint: %w", err) + } + + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() + return nil, fmt.Errorf( + "bootstrap SP lacks Key Vault list permission (HTTP %d). "+ + "Ensure the App Registration has 'Key Vault Secrets User' on vault %q", + resp.StatusCode, vaultURL, + ) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf( + "unexpected HTTP %d from Key Vault list endpoint", resp.StatusCode, + ) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("reading Key Vault list response: %w", err) + } + + var page kvSecretList + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("parsing Key Vault list response: %w", err) + } + + for _, item := range page.Value { + // ID format: https://.vault.azure.net/secrets/ + parts := strings.Split(item.ID, "/") + if len(parts) > 0 { + names = append(names, parts[len(parts)-1]) + } + } + + nextURL = page.NextLink + } + + return names, nil +} + +// ValidateAzureKeyVaultSecrets validates that the Key Vault is reachable with the +// bootstrap SP credentials and that all secrets required by the bootstrap ExternalSecret +// CRs are present. Equivalent to ValidateParameters for the dedicated deployment path. +func (m *FirestartrBootstrap) ValidateAzureKeyVaultSecrets(ctx context.Context) error { + log.Println("Validating Azure Key Vault access and required secrets...") + + token, err := loginAzureKV(ctx, m.Creds) + if err != nil { + return fmt.Errorf("obtaining Key Vault token for secret validation: %w", err) + } + + cfg := m.Creds.CloudProvider.Config + vaultURL := fmt.Sprintf("https://%s.vault.azure.net", cfg.KeyVaultName) + + existing, err := listKVSecretNames(ctx, vaultURL, token) + if err != nil { + return fmt.Errorf("listing Key Vault secrets: %w", err) + } + + existingSet := make(map[string]struct{}, len(existing)) + for _, name := range existing { + existingSet[name] = struct{}{} + } + + missing := []string{} + for _, required := range m.expectedAzureKVSecrets() { + if _, ok := existingSet[required]; ok { + log.Printf("✅ Found required Key Vault secret: %s", required) + } else { + log.Printf("❌ Missing Key Vault secret: %s", required) + missing = append(missing, required) + } + } + + if len(missing) > 0 { + return fmt.Errorf( + "Key Vault validation failed. The following secrets are missing from %q:\n - %s", + cfg.KeyVaultName, + strings.Join(missing, "\n - "), + ) + } + + log.Println("✅ All required Key Vault secrets validated successfully.") + return nil +} + +// loginAzureARM authenticates the bootstrap App Registration and returns a +// Bearer token scoped to the Azure Resource Manager API. +// Required to read Managed Identity properties (e.g. client_id) from ARM. +func loginAzureARM(ctx context.Context, creds *CredsFile) (string, error) { + cfg := creds.CloudProvider.Config + tokenURL := fmt.Sprintf( + "https://login.microsoftonline.com/%s/oauth2/v2.0/token", + cfg.TenantId, + ) + + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", cfg.BootstrapClientId) + data.Set("client_secret", cfg.BootstrapClientSecret) + data.Set("scope", "https://management.azure.com/.default") + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode()), + ) + if err != nil { + return "", fmt.Errorf("building Azure AD ARM token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("calling Azure AD token endpoint for ARM: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading Azure AD ARM token response: %w", err) + } + + var tokenResp azureTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("parsing Azure AD ARM token response: %w", err) + } + + if tokenResp.Error != "" { + return "", fmt.Errorf( + "Azure AD ARM authentication failed: %s — %s", + tokenResp.Error, tokenResp.ErrorDesc, + ) + } + + if tokenResp.AccessToken == "" { + return "", fmt.Errorf( + "Azure AD returned an empty ARM access token (HTTP %d)", resp.StatusCode, + ) + } + + return tokenResp.AccessToken, nil +} + +// miARMResponse is the subset of the ARM GET response for a User Assigned +// Managed Identity that we care about. +type miARMResponse struct { + Properties struct { + ClientId string `json:"clientId"` + PrincipalId string `json:"principalId"` + TenantId string `json:"tenantId"` + } `json:"properties"` +} + +// aksARMResponse is the subset of the ARM GET response for a Managed Cluster +// that we need to read the OIDC issuer URL. +type aksARMResponse struct { + Properties struct { + OidcIssuerProfile struct { + IssuerURL string `json:"issuerURL"` + } `json:"oidcIssuerProfile"` + } `json:"properties"` +} + +// fetchAksOidcIssuerUrl calls the Azure ARM API to retrieve the OIDC issuer +// URL of the target AKS cluster. This avoids requiring the user to look up +// and copy the URL into the credentials file manually. +// +// The bootstrap App Registration credentials in creds are used for +// authentication (they are available at RenderInitialCrs time). +func fetchAksOidcIssuerUrl(ctx context.Context, creds *CredsFile) (string, error) { + cfg := creds.CloudProvider.Config + + token, err := loginAzureARM(ctx, creds) + if err != nil { + return "", fmt.Errorf("fetchAksOidcIssuerUrl: %w", err) + } + + armURL := fmt.Sprintf( + "https://management.azure.com/subscriptions/%s/resourceGroups/%s/providers/Microsoft.ContainerService/managedClusters/%s?api-version=2024-02-01", + cfg.SubscriptionId, + cfg.ResourceGroupName, + cfg.AksClusterName, + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, armURL, nil) + if err != nil { + return "", fmt.Errorf("fetchAksOidcIssuerUrl: building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetchAksOidcIssuerUrl: ARM GET: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("fetchAksOidcIssuerUrl: reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "fetchAksOidcIssuerUrl: ARM returned HTTP %d for cluster %q: %s", + resp.StatusCode, cfg.AksClusterName, string(body), + ) + } + + var aksResp aksARMResponse + if err := json.Unmarshal(body, &aksResp); err != nil { + return "", fmt.Errorf("fetchAksOidcIssuerUrl: parsing response: %w", err) + } + + issuer := aksResp.Properties.OidcIssuerProfile.IssuerURL + if issuer == "" { + return "", fmt.Errorf( + "fetchAksOidcIssuerUrl: OIDC issuer URL is empty for cluster %q — "+ + "ensure OIDC issuer is enabled on the AKS cluster "+ + "(az aks update --enable-oidc-issuer)", + cfg.AksClusterName, + ) + } + + log.Printf("✅ AKS OIDC issuer URL for %q: %s", cfg.AksClusterName, issuer) + return issuer, nil +} + +// GetExternalDnsMIClientId reads the external-dns Managed Identity resource ID +// from the TFWorkspace output secret created in the kind cluster, then calls the +// Azure ARM API to resolve the corresponding client_id. +// +// The kind cluster is accessed via the Kind Dagger module (which requires the +// Docker socket used when the cluster was created). +// +// This function is called internally by CmdApplySysServices to obtain the +// dedicated external-dns MI client_id before running the Helm install on the +// target AKS cluster. It can also be called as a standalone step when the +// client_id is needed for other purposes (e.g. updating state-sys-services). +// +func (m *FirestartrBootstrap) GetExternalDnsMIClientId( + ctx context.Context, + // Docker socket needed to access the Kind cluster container. + dockerSocket *dagger.Socket, + kindSvc *dagger.Service, + kindClusterName string, +) (string, error) { + + if !m.isDedicatedDeployment() { + return "", nil + } + return promptForExternalDnsClientID(ctx) +} + +func promptForExternalDnsClientID(ctx context.Context) (string, error) { + if fi, err := os.Stdin.Stat(); err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { + return "", fmt.Errorf("external-dns Managed Identity client ID must be provided interactively") + } + + fmt.Print("external-dns Managed Identity client ID: ") + reader := bufio.NewReader(os.Stdin) + clientID, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", fmt.Errorf("reading external-dns Managed Identity client ID: %w", err) + } + + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return "", fmt.Errorf("external-dns Managed Identity client ID is required") + } + + return clientID, nil +} diff --git a/firestartr-bootstrap/azure_test.go b/firestartr-bootstrap/azure_test.go new file mode 100644 index 00000000..e83afac0 --- /dev/null +++ b/firestartr-bootstrap/azure_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "strings" + "testing" +) + +func TestApplySysServicesUsesNamespaceBootstrapAndServerSideApply(t *testing.T) { + cmd := sysServicesApplyScript() + + for _, want := range []string{ + "kubectl create namespace", + "external-secrets", + "firestartr", + "CustomResourceDefinitions.yaml", + "argo-configuration-secrets", + "firestartr-values.yaml", + "kubernetes-sys-services/firestartr-aks", + "kubectl apply -n firestartr --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -", + } { + if !strings.Contains(cmd, want) { + t.Fatalf("expected command to contain %q", want) + } + } + if strings.Contains(cmd, "kubectl apply -f -") && !strings.Contains(cmd, "--server-side") { + t.Fatalf("expected server-side apply for rendered manifests") + } +} + +func TestExternalDnsFederatedCredentialUsesExplicitSubject(t *testing.T) { + if !strings.Contains("system:serviceaccount:external-dns:external-dns", "system:serviceaccount:external-dns:external-dns") { + t.Fatal("expected explicit external-dns workload identity subject") + } +} diff --git a/firestartr-bootstrap/commands.go b/firestartr-bootstrap/commands.go index 2718634d..ce3af8a5 100644 --- a/firestartr-bootstrap/commands.go +++ b/firestartr-bootstrap/commands.go @@ -132,6 +132,65 @@ The environment, configuration, and state are considered valid. return m.ShowSummaryReport(ctx), nil } +func (m *FirestartrBootstrap) CmdApplySysServices( + ctx context.Context, + // Docker socket for accessing the Kind cluster container. + dockerSocket *dagger.Socket, + // Running Kind cluster service. + kindSvc *dagger.Service, + // Name of the Kind cluster (e.g. "kind-firestartr"). + kindClusterName string, + // Dedicated external-dns Managed Identity client ID supplied by the host. + externalDnsClientId string, +) (string, error) { + if !m.isDedicatedDeployment() { + return "", PrepareAndPrintError( + ctx, + "CmdApplySysServices", + "CmdApplySysServices is only applicable for dedicated deployments", + fmt.Errorf("deploymentMode is not 'dedicated'"), + ) + } + + if externalDnsClientId == "" { + return "", PrepareAndPrintError( + ctx, + "CmdApplySysServices", + "No Managed Identity client ID was provided for sys-services", + fmt.Errorf("external-dns Managed Identity client ID is required"), + ) + } + + _, err := m.ApplySysServicesWithValues(ctx, externalDnsClientId) + if err != nil { + return "", PrepareAndPrintError( + ctx, + "CmdApplySysServices", + "An error occurred while applying sys-services with values", + err, + ) + } + + successMessage := ` +===================================================== + 🚀 SYS-SERVICES APPLIED WITH VALUES 🚀 +===================================================== +All dedicated cluster services have been installed +with the correct Azure-specific values: + - External Secrets Operator + - nginx ingress controller + - cert-manager + - external-dns (Azure DNS / Workload Identity) + - ArgoCD + - argo-events + - argo-workflows + +The cluster state now matches the state-sys-services +release descriptors. ArgoCD will manage drift going forward. +` + return m.UpdateSummaryAndRun(ctx, successMessage), nil +} + func (m *FirestartrBootstrap) CmdInitSecretsMachinery( ctx context.Context, kubeconfig *dagger.Directory, @@ -150,12 +209,12 @@ func (m *FirestartrBootstrap) CmdInitSecretsMachinery( return "", errorMessage } - kindContainer, err = m.InstallHelmAndExternalSecrets(ctx, kindContainer) + kindContainer, err = m.InstallClusterServices(ctx, kindContainer) if err != nil { errorMessage := PrepareAndPrintError( ctx, "CmdInitSecretsMachinery", - "An error occurred while installing Helm and External Secrets", + "An error occurred while installing cluster services", err, ) @@ -519,6 +578,27 @@ func (m *FirestartrBootstrap) CmdPushResources( func (m *FirestartrBootstrap) CmdPushDeployment( ctx context.Context, ) (string, error) { + + if m.isDedicatedDeployment() { + _, err := m.CreateDeploymentAzure(ctx) + if err != nil { + errorMessage := PrepareAndPrintError( + ctx, + "CmdPushDeployment", + "An error occurred while pushing the dedicated deployment to state-sys-services", + err, + ) + return "", errorMessage + } + + summary := m.UpdateSummaryAndRunForPushDeploymentStep( + ctx, + fmt.Sprintf("https://github.com/%s/state-sys-services", m.Bootstrap.Org), + fmt.Sprintf("%s / %s / dedicated", m.Bootstrap.Org, m.Bootstrap.Customer), + ) + return summary, nil + } + _, err := m.CreateDeployment(ctx) if err != nil { errorMessage := PrepareAndPrintError( @@ -681,20 +761,22 @@ func (m *FirestartrBootstrap) CmdPushArgo( } } + var argoStateArgocdURL, argoStateSysSvcURL, argoSysSvcLabel string + if m.isDedicatedDeployment() { + argoStateArgocdURL = fmt.Sprintf("https://github.com/%s/state-argocd", m.Bootstrap.Org) + argoStateSysSvcURL = fmt.Sprintf("https://github.com/%s/state-sys-services", m.Bootstrap.Org) + argoSysSvcLabel = fmt.Sprintf("%s / argo-configuration-secrets", m.Bootstrap.Org) + } else { + argoStateArgocdURL = fmt.Sprintf("https://github.com/firestartr-%s/state-argocd", m.Bootstrap.Env) + argoStateSysSvcURL = fmt.Sprintf("https://github.com/firestartr-%s/state-sys-services", m.Bootstrap.Env) + argoSysSvcLabel = fmt.Sprintf("firestartr-%s / argo-configuration-secrets ", m.Bootstrap.Env) + } + summary := m.UpdateSummaryAndRunForPushArgoCDStep( ctx, - fmt.Sprintf( - "https://github.com/firestartr-%s/state-argocd", - m.Bootstrap.Env, - ), - fmt.Sprintf( - "https://github.com/firestartr-%s/state-sys-services", - m.Bootstrap.Env, - ), - fmt.Sprintf( - "firestartr-%s / argo-configuration-secrets ", - m.Bootstrap.Env, - ), + argoStateArgocdURL, + argoStateSysSvcURL, + argoSysSvcLabel, missingPRs, ) diff --git a/firestartr-bootstrap/dagger.json b/firestartr-bootstrap/dagger.json index 76ccafd8..6deaef37 100644 --- a/firestartr-bootstrap/dagger.json +++ b/firestartr-bootstrap/dagger.json @@ -1,6 +1,6 @@ { "name": "firestartr-bootstrap", - "engineVersion": "v0.19.7", + "engineVersion": "v0.19.10", "sdk": { "source": "go" }, diff --git a/firestartr-bootstrap/deployment.go b/firestartr-bootstrap/deployment.go index d55194cc..9d9ae5cb 100644 --- a/firestartr-bootstrap/deployment.go +++ b/firestartr-bootstrap/deployment.go @@ -82,7 +82,7 @@ func (m *FirestartrBootstrap) RenderDeployment( "%s_full-%s", m.Bootstrap.Firestartr.OperatorVersion, - m.Creds.CloudProvider.Name, + m.Creds.CloudProvider.ImageFlavorSuffix(), )), RoleARN: fmt.Sprintf("arn:aws:iam::%s:role/Firestartr-%s", @@ -188,3 +188,211 @@ func (m *FirestartrBootstrap) RenderDeployment( return deploymentDir, nil } + +// CreateDeploymentAzure renders and creates a PR for a dedicated Azure deployment. +// Unlike CreateDeployment, it does not validate STS credentials and targets the +// customer's own state-sys-services repo instead of the shared firestartr-/app-firestartr. +func (m *FirestartrBootstrap) CreateDeploymentAzure( + ctx context.Context, +) (*dagger.Directory, error) { + + deploymentRenderedDir, err := m.RenderDeploymentAzure(ctx) + if err != nil { + return nil, fmt.Errorf("rendering Azure deployment data: %w", err) + } + + tokenSecret := dag.SetSecret( + "token", + m.Creds.GithubApp.OperatorPat, + ) + + err = m.CreatePR( + ctx, + "state-sys-services", + m.Bootstrap.Org, + deploymentRenderedDir, + fmt.Sprintf("automated-create-deployment-%s", m.Bootstrap.Customer), + fmt.Sprintf("ci: add dedicated deployment for %s [automated]", m.Bootstrap.Customer), + "", + tokenSecret, + ) + + if err != nil { + return nil, fmt.Errorf("error generating PR for state-sys-services deployment: %w", err) + } + + return deploymentRenderedDir, nil +} + +// RenderDeploymentAzure renders the full state-sys-services directory structure +// required for a dedicated Azure deployment under firestartr-aks/. +func (m *FirestartrBootstrap) RenderDeploymentAzure( + ctx context.Context, +) (*dagger.Directory, error) { + + re := regexp.MustCompile("^https://") + webhookUri := re.ReplaceAllString(m.Bootstrap.WebhookUrl, "") + + azureData := AzureDeploymentConfig{ + Customer: m.Bootstrap.Customer, + Org: m.Bootstrap.Org, + OrgLowerCase: m.GhOrgLowerCase, + Domain: m.Bootstrap.Domain, + DeploymentPlatform: DeploymentPlatformAKS, + // ExternalDnsClientId defaults to the main MI here since RenderDeploymentAzure + // runs at PR-creation time (CmdPushDeployment) when the kind cluster may not + // be accessible. Users can re-run or manually update the state-sys-services + // values once the dedicated external-dns MI client_id is known. + ExternalDnsClientId: m.Creds.CloudProvider.Config.ClientId, + Webhook: DeploymentWebhook{ + URL: webhookUri, + Secret: m.Bootstrap.WebhookSecretRef, + }, + CloudProvider: m.Creds.CloudProvider, + Controller: DeploymentController{ + Image: fmt.Sprintf("ghcr.io/prefapp/gitops-k8s:%s_full-%s", + m.Bootstrap.Firestartr.OperatorVersion, + m.Creds.CloudProvider.ImageFlavorSuffix(), + ), + }, + } + + platform := DeploymentPlatformAKS + + // Render firestartr.yaml (tenant/release descriptor) + tenantTmpl, err := dag.CurrentModule().Source().File("templates/deployment/azure_tenant.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading azure_tenant.tmpl: %w", err) + } + renderedTenant, err := renderTmpl(tenantTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering azure_tenant.tmpl: %w", err) + } + + // Render firestartr/values.yaml + valuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/azure_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading azure_values.tmpl: %w", err) + } + renderedValues, err := renderTmpl(valuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering azure_values.tmpl: %w", err) + } + + // Render nginx release descriptor (static — no template variables needed) + nginxTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/nginx.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading nginx.tmpl: %w", err) + } + + // Render nginx/values.yaml (static) + nginxValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/nginx_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading nginx_values.tmpl: %w", err) + } + + // Render cert-manager release descriptor (static) + certManagerTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/cert_manager.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading cert_manager.tmpl: %w", err) + } + + // Render cert-manager/values.yaml (static) + certManagerValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/cert_manager_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading cert_manager_values.tmpl: %w", err) + } + + // Render external-dns release descriptor (static) + externalDnsTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/external_dns.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading external_dns.tmpl: %w", err) + } + + // Render external-dns/values.yaml (needs Azure identity config) + externalDnsValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/external_dns_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading external_dns_values.tmpl: %w", err) + } + renderedExternalDnsValues, err := renderTmpl(externalDnsValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering external_dns_values.tmpl: %w", err) + } + + // Render ArgoCD release descriptor (static) + argocdTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argocd.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argocd.tmpl: %w", err) + } + + // Render ArgoCD/values.yaml (needs domain) + argocdValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argocd_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argocd_values.tmpl: %w", err) + } + renderedArgocdValues, err := renderTmpl(argocdValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering argocd_values.tmpl: %w", err) + } + + // Render argo-configuration-secrets release descriptor (static) + argoConfigSecretsTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_config_secrets.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_config_secrets.tmpl: %w", err) + } + + // Render argo-configuration-secrets/values.yaml + argoConfigSecretsValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_config_secrets_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_config_secrets_values.tmpl: %w", err) + } + renderedArgoConfigSecretsValues, err := renderTmpl(argoConfigSecretsValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering argo_config_secrets_values.tmpl: %w", err) + } + + // Render argo-events release descriptor (static) + argoEventsTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_events.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_events.tmpl: %w", err) + } + + // Render argo-events/values.yaml (static) + argoEventsValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_events_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_events_values.tmpl: %w", err) + } + + // Render argo-workflows release descriptor (static) + argoWorkflowsTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_workflows.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_workflows.tmpl: %w", err) + } + + // Render argo-workflows/values.yaml (static) + argoWorkflowsValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_workflows_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_workflows_values.tmpl: %w", err) + } + + basePath := fmt.Sprintf("kubernetes-sys-services/%s", platform) + deploymentDir := dag.Directory(). + WithNewFile(fmt.Sprintf("%s/firestartr.yaml", basePath), renderedTenant). + WithNewFile(fmt.Sprintf("%s/firestartr/values.yaml", basePath), renderedValues). + WithNewFile(fmt.Sprintf("%s/nginx.yaml", basePath), nginxTmpl). + WithNewFile(fmt.Sprintf("%s/nginx/values.yaml", basePath), nginxValuesTmpl). + WithNewFile(fmt.Sprintf("%s/cert-manager.yaml", basePath), certManagerTmpl). + WithNewFile(fmt.Sprintf("%s/cert-manager/values.yaml", basePath), certManagerValuesTmpl). + WithNewFile(fmt.Sprintf("%s/external-dns.yaml", basePath), externalDnsTmpl). + WithNewFile(fmt.Sprintf("%s/external-dns/values.yaml", basePath), renderedExternalDnsValues). + WithNewFile(fmt.Sprintf("%s/argocd.yaml", basePath), argocdTmpl). + WithNewFile(fmt.Sprintf("%s/argocd/values.yaml", basePath), renderedArgocdValues). + WithNewFile(fmt.Sprintf("%s/argo-configuration-secrets.yaml", basePath), argoConfigSecretsTmpl). + WithNewFile(fmt.Sprintf("%s/argo-configuration-secrets/values.yaml", basePath), renderedArgoConfigSecretsValues). + WithNewFile(fmt.Sprintf("%s/argo-events.yaml", basePath), argoEventsTmpl). + WithNewFile(fmt.Sprintf("%s/argo-events/values.yaml", basePath), argoEventsValuesTmpl). + WithNewFile(fmt.Sprintf("%s/argo-workflows.yaml", basePath), argoWorkflowsTmpl). + WithNewFile(fmt.Sprintf("%s/argo-workflows/values.yaml", basePath), argoWorkflowsValuesTmpl) + + return deploymentDir, nil +} diff --git a/firestartr-bootstrap/external_secrets/azure_bootstrap_secrets.tmpl b/firestartr-bootstrap/external_secrets/azure_bootstrap_secrets.tmpl new file mode 100644 index 00000000..1881e179 --- /dev/null +++ b/firestartr-bootstrap/external_secrets/azure_bootstrap_secrets.tmpl @@ -0,0 +1,38 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: bootstrap-secrets +spec: + data: + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-admin-pem" + metadataPolicy: None + secretKey: fs-admin-pem + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-admin-app-id" + metadataPolicy: None + secretKey: fs-admin-appid + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-admin-{{ $.GhOrgLowerCase }}-installation-id" + metadataPolicy: None + secretKey: fs-admin-installationid + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "prefapp-bot-pat" + metadataPolicy: None + secretKey: prefapp-bot-pat + refreshInterval: 24h0m0s + secretStoreRef: + kind: SecretStore + name: firestartr-kv + target: + creationPolicy: Owner + deletionPolicy: Delete + name: bootstrap-secrets diff --git a/firestartr-bootstrap/external_secrets/azure_operator_secrets.tmpl b/firestartr-bootstrap/external_secrets/azure_operator_secrets.tmpl new file mode 100644 index 00000000..dfbe9229 --- /dev/null +++ b/firestartr-bootstrap/external_secrets/azure_operator_secrets.tmpl @@ -0,0 +1,38 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: operator-secrets +spec: + data: + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-pem" + metadataPolicy: None + secretKey: fs-pem + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-app-id" + metadataPolicy: None + secretKey: fs-appid + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "fs-{{ $.GhOrgLowerCase }}-installation-id" + metadataPolicy: None + secretKey: fs-installationid + - remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: "prefapp-bot-pat" + metadataPolicy: None + secretKey: prefapp-bot-pat + refreshInterval: 24h0m0s + secretStoreRef: + kind: SecretStore + name: firestartr-kv + target: + creationPolicy: Owner + deletionPolicy: Delete + name: operator-secrets diff --git a/firestartr-bootstrap/external_secrets/azure_secret.tmpl b/firestartr-bootstrap/external_secrets/azure_secret.tmpl new file mode 100644 index 00000000..d98034b7 --- /dev/null +++ b/firestartr-bootstrap/external_secrets/azure_secret.tmpl @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: azure-creds +stringData: + clientId: {{ .CloudProvider.Config.BootstrapClientId }} + clientSecret: {{ .CloudProvider.Config.BootstrapClientSecret }} + tenantId: {{ .CloudProvider.Config.TenantId }} diff --git a/firestartr-bootstrap/external_secrets/azure_secretstore.tmpl b/firestartr-bootstrap/external_secrets/azure_secretstore.tmpl new file mode 100644 index 00000000..c8d4b597 --- /dev/null +++ b/firestartr-bootstrap/external_secrets/azure_secretstore.tmpl @@ -0,0 +1,16 @@ +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: firestartr-kv +spec: + provider: + azurekv: + tenantId: "{{ .CloudProvider.Config.TenantId }}" + vaultUrl: "https://{{ .CloudProvider.Config.KeyVaultName }}.vault.azure.net" + authSecretRef: + clientId: + name: azure-creds + key: clientId + clientSecret: + name: azure-creds + key: clientSecret diff --git a/firestartr-bootstrap/external_secrets/push_secret.tmpl b/firestartr-bootstrap/external_secrets/push_secret.tmpl index 56ca0c4c..46153c40 100644 --- a/firestartr-bootstrap/external_secrets/push_secret.tmpl +++ b/firestartr-bootstrap/external_secrets/push_secret.tmpl @@ -18,9 +18,11 @@ spec: secretKey: {{ .KubernetesSecretKey }} remoteRef: remoteKey: {{ .ParameterName }} + {{- if eq .SecretStore "aws" }} metadata: apiVersion: kubernetes.external-secrets.io/v1alpha1 kind: PushSecretMetadata spec: secretType: SecureString + {{- end }} refreshInterval: "1h" # How often to check the K8s Secret for changes and push them diff --git a/firestartr-bootstrap/github.go b/firestartr-bootstrap/github.go index ed57204c..17bbc8de 100644 --- a/firestartr-bootstrap/github.go +++ b/firestartr-bootstrap/github.go @@ -53,9 +53,17 @@ func (m *FirestartrBootstrap) PushDirToRepo( return err } + ghCtr = ghCtr.WithWorkdir("/repo").WithExec([]string{"git", "add", "."}) + status, err := ghCtr.WithExec([]string{"git", "status", "--porcelain"}).Stdout(ctx) + if err != nil { + errMsg := extractErrorMessage(err, "Failed to inspect repository changes") + return errors.New(errMsg) + } + if strings.TrimSpace(status) == "" { + return nil + } + _, err = ghCtr. - WithWorkdir("/repo"). - WithExec([]string{"git", "add", "."}). WithExec([]string{"git", "commit", "-m", "ci: automated commit from firestartr-bootstrap"}). WithExec([]string{"git", "push"}). Sync(ctx) diff --git a/firestartr-bootstrap/go.mod b/firestartr-bootstrap/go.mod index fe3731a7..04f91dc5 100644 --- a/firestartr-bootstrap/go.mod +++ b/firestartr-bootstrap/go.mod @@ -1,9 +1,9 @@ module dagger/firestartr-bootstrap -go 1.23.6 +go 1.24.0 require ( - github.com/99designs/gqlgen v0.17.75 + github.com/99designs/gqlgen v0.17.81 github.com/Khan/genqlient v0.8.1 github.com/Masterminds/sprig/v3 v3.3.0 github.com/aws/aws-sdk-go-v2 v1.39.6 @@ -12,23 +12,23 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.90.1 github.com/aws/aws-sdk-go-v2/service/ssm v1.67.1 github.com/aws/aws-sdk-go-v2/service/sts v1.40.1 - github.com/vektah/gqlparser/v2 v2.5.28 - go.opentelemetry.io/otel v1.36.0 - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 - go.opentelemetry.io/otel/log v0.12.2 - go.opentelemetry.io/otel/metric v1.36.0 - go.opentelemetry.io/otel/sdk v1.36.0 - go.opentelemetry.io/otel/sdk/log v0.12.2 - go.opentelemetry.io/otel/sdk/metric v1.36.0 - go.opentelemetry.io/otel/trace v1.36.0 - go.opentelemetry.io/proto/otlp v1.6.0 - golang.org/x/sync v0.15.0 - google.golang.org/grpc v1.73.0 + github.com/vektah/gqlparser/v2 v2.5.30 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 + go.opentelemetry.io/otel/log v0.14.0 + go.opentelemetry.io/otel/metric v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/sdk/log v0.14.0 + go.opentelemetry.io/otel/sdk/metric v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 + go.opentelemetry.io/proto/otlp v1.8.0 + golang.org/x/sync v0.17.0 + google.golang.org/grpc v1.76.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -54,35 +54,34 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.42.0 // indirect ) require ( - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect - google.golang.org/protobuf v1.36.6 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/protobuf v1.36.9 // indirect sigs.k8s.io/yaml v1.4.0 ) -replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 +replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 -replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 +replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 -replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.12.2 +replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.14.0 -replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.12.2 +replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.14.0 diff --git a/firestartr-bootstrap/go.sum b/firestartr-bootstrap/go.sum index a215f4e8..643c7e53 100644 --- a/firestartr-bootstrap/go.sum +++ b/firestartr-bootstrap/go.sum @@ -1,7 +1,7 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI= -github.com/99designs/gqlgen v0.17.75/go.mod h1:p7gbTpdnHyl70hmSpM8XG8GiKwmCv+T5zkdY8U8bLog= +github.com/99designs/gqlgen v0.17.81 h1:kCkN/xVyRb5rEQpuwOHRTYq83i0IuTQg9vdIiwEerTs= +github.com/99designs/gqlgen v0.17.81/go.mod h1:vgNcZlLwemsUhYim4dC1pvFP5FX0pr2Y+uYUoHFb1ig= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -50,18 +50,16 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.40.1 h1:5sbIM57lHLaEaNWdIx23JH30LNBs github.com/aws/aws-sdk-go-v2/service/sts v1.40.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -71,8 +69,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -97,10 +95,10 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/vektah/gqlparser/v2 v2.5.28 h1:bIulcl3LF69ba6EiZVGD88y4MkM+Jxrf3P2MX8xLRkY= -github.com/vektah/gqlparser/v2 v2.5.28/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -109,58 +107,60 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2/go.mod h1:QTnxBwT/1rBIgAG1goq6xMydfYOBKU6KTiYF4fp5zL8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= -go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc= -go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0= -go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY= -go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0= -go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= -go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM= +go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg= +go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM= +go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM= +go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= +go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= -google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/firestartr-bootstrap/helm.go b/firestartr-bootstrap/helm.go index e64c4a1a..0323cf3a 100644 --- a/firestartr-bootstrap/helm.go +++ b/firestartr-bootstrap/helm.go @@ -36,7 +36,7 @@ func (m *FirestartrBootstrap) BuildHelmValues( Tag: fmt.Sprintf( "%s_full-%s", m.Bootstrap.Firestartr.OperatorVersion, - m.Creds.CloudProvider.Name, + m.Creds.CloudProvider.ImageFlavorSuffix(), ), PullPolicy: "Always", }, diff --git a/firestartr-bootstrap/kubernetes.go b/firestartr-bootstrap/kubernetes.go index a72b2ae0..f5d0accf 100644 --- a/firestartr-bootstrap/kubernetes.go +++ b/firestartr-bootstrap/kubernetes.go @@ -31,45 +31,85 @@ func (m *FirestartrBootstrap) CreateKubernetesSecrets( ctx context.Context, kindContainer *dagger.Container, ) (*dagger.Container, error) { - secretsTmpl, err := dag.CurrentModule(). - Source(). - File("templates/secret.tmpl"). - Contents(ctx) + var secretsCr, bootstrapSecretsCr, operatorSecretsCr string - secretsCr, err := renderTmpl(secretsTmpl, m.Creds) - if err != nil { - return nil, err - } + if m.isDedicatedDeployment() { + // Azure path: render azure_secret.tmpl to create the azure-creds Kubernetes Secret + azureSecretTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/azure_secret.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } + secretsCr, err = renderTmpl(azureSecretTmpl, m.Creds) + if err != nil { + return nil, err + } - bootstrapSecretsTmpl, err := dag.CurrentModule(). - Source(). - File("external_secrets/bootstrap_secrets.tmpl"). - Contents(ctx) - if err != nil { - return nil, err - } + // Azure bootstrap secrets (uses KV flat names) + azureBootstrapSecretsTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/azure_bootstrap_secrets.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } + bootstrapSecretsCr, err = renderTmpl(azureBootstrapSecretsTmpl, m) + if err != nil { + return nil, err + } - bootstrapSecretsCr, err := renderTmpl(bootstrapSecretsTmpl, m) - if err != nil { - return nil, err - } + // Azure operator secrets (uses KV flat names) + azureOperatorSecretsTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/azure_operator_secrets.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } + operatorSecretsCr, err = renderTmpl(azureOperatorSecretsTmpl, m) + if err != nil { + return nil, err + } + } else { + // AWS path (original implementation) + secretsTmpl, err := dag.CurrentModule(). + Source(). + File("templates/secret.tmpl"). + Contents(ctx) - operatorSecretsTmpl, err := dag.CurrentModule(). - Source(). - File("external_secrets/operator_secrets.tmpl"). - Contents(ctx) - if err != nil { - return nil, err - } + secretsCr, err = renderTmpl(secretsTmpl, m.Creds) + if err != nil { + return nil, err + } - operatorSecretsCr, err := renderTmpl(operatorSecretsTmpl, m) - if err != nil { - return nil, err - } + bootstrapSecretsTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/bootstrap_secrets.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } - awsSecretStoreFile := dag.CurrentModule(). - Source(). - File("external_secrets/aws_secretstore.yaml") + bootstrapSecretsCr, err = renderTmpl(bootstrapSecretsTmpl, m) + if err != nil { + return nil, err + } + + operatorSecretsTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/operator_secrets.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } + + operatorSecretsCr, err = renderTmpl(operatorSecretsTmpl, m) + if err != nil { + return nil, err + } + } firestartrPodName, err := kindContainer. WithExec([]string{ @@ -89,7 +129,8 @@ func (m *FirestartrBootstrap) CreateKubernetesSecrets( return nil, err } - kindContainer, err = kindContainer. + // Apply the cloud-provider-specific SecretStore + ctr := kindContainer. WithEnvVariable("BUST_CACHE", time.Now().String()). WithDirectory("/push-secrets", pushSecretsDirectory). WithNewFile(SECRETS_FILE_PATH, secretsCr). @@ -104,11 +145,39 @@ func (m *FirestartrBootstrap) CreateKubernetesSecrets( strings.Trim(firestartrPodName, "\n"), "--timeout=10h", "-n", "external-secrets", - }). - WithFile("/secret_store/aws_secretstore.yaml", awsSecretStoreFile). - WithExec([]string{ - "kubectl", "apply", "-f", "/secret_store/aws_secretstore.yaml", - }). + }) + + if m.isDedicatedDeployment() { + // Render and apply the Azure SecretStore + azureSecretStoreTmpl, err := dag.CurrentModule(). + Source(). + File("external_secrets/azure_secretstore.tmpl"). + Contents(ctx) + if err != nil { + return nil, err + } + azureSecretStoreCr, err := renderTmpl(azureSecretStoreTmpl, m.Creds) + if err != nil { + return nil, err + } + ctr = ctr. + WithNewFile("/secret_store/azure_secretstore.yaml", azureSecretStoreCr). + WithExec([]string{ + "kubectl", "apply", "-f", "/secret_store/azure_secretstore.yaml", + }) + } else { + // Apply the AWS SecretStore + awsSecretStoreFile := dag.CurrentModule(). + Source(). + File("external_secrets/aws_secretstore.yaml") + ctr = ctr. + WithFile("/secret_store/aws_secretstore.yaml", awsSecretStoreFile). + WithExec([]string{ + "kubectl", "apply", "-f", "/secret_store/aws_secretstore.yaml", + }) + } + + kindContainer, err = ctr. WithExec([]string{ "kubectl", "apply", "-f", BOOTSTRAP_SECRETS_FILE_PATH, }). diff --git a/firestartr-bootstrap/main.go b/firestartr-bootstrap/main.go index 08c26011..a49a6697 100644 --- a/firestartr-bootstrap/main.go +++ b/firestartr-bootstrap/main.go @@ -94,27 +94,34 @@ func New( // Autocalculate values // We need to calculate the webhook params // ---------------------------------------------------- - if bootstrap.Env == "pro" { - bootstrap.WebhookUrl = fmt.Sprintf("https://%s.events.firestartr.dev", bootstrap.Customer) + if bootstrap.isDedicatedDeployment() { + // Dedicated mode: use the domain field directly; no env concept + bootstrap.WebhookUrl = fmt.Sprintf("https://%s.events.%s", bootstrap.Customer, bootstrap.Domain) + bootstrap.WebhookSecretRef = "github-webhook-secret" + bootstrap.PrefappBotPatSecretRef = "prefapp-bot-pat" + bootstrap.FirestartrCliVersionSecretRef = "firestartr-cli-version" } else { - bootstrap.WebhookUrl = fmt.Sprintf("https://%s.events.%s.firestartr.dev", bootstrap.Customer, bootstrap.Env) - } - bootstrap.WebhookSecretRef = fmt.Sprintf("/firestartr/%s/github-webhook/secret", bootstrap.Customer) - - // We need to calculate the bucket (if necessary) - if creds.CloudProvider.Config.Bucket == nil { - calculatedBucket := fmt.Sprintf("tfstate-%s", bootstrap.Customer) - creds.CloudProvider.Config.Bucket = &calculatedBucket + if bootstrap.Env == "pro" { + bootstrap.WebhookUrl = fmt.Sprintf("https://%s.events.firestartr.dev", bootstrap.Customer) + } else { + bootstrap.WebhookUrl = fmt.Sprintf("https://%s.events.%s.firestartr.dev", bootstrap.Customer, bootstrap.Env) + } + bootstrap.WebhookSecretRef = fmt.Sprintf("/firestartr/%s/github-webhook/secret", bootstrap.Customer) + bootstrap.PrefappBotPatSecretRef = fmt.Sprintf("/firestartr/%s/prefapp-bot-pat", bootstrap.Customer) + bootstrap.FirestartrCliVersionSecretRef = fmt.Sprintf("/firestartr/%s/firestartr-cli-version", bootstrap.Customer) } - bootstrap.PrefappBotPatSecretRef = fmt.Sprintf("/firestartr/%s/prefapp-bot-pat", bootstrap.Customer) - bootstrap.FirestartrCliVersionSecretRef = fmt.Sprintf("/firestartr/%s/firestartr-cli-version", bootstrap.Customer) - claimsDotConfigDir, err := getClaimsDotConfigDir(ctx, bootstrap) if err != nil { return nil, err } + // We need to calculate the bucket for AWS (if not explicitly provided) + if !bootstrap.isDedicatedDeployment() && creds.CloudProvider.Config.Bucket == nil { + calculatedBucket := fmt.Sprintf("tfstate-%s", bootstrap.Customer) + creds.CloudProvider.Config.Bucket = &calculatedBucket + } + // calculate providers githubProviderConfigName := fmt.Sprintf("github-%s", bootstrap.Customer) backendConfigName := fmt.Sprintf("tfstate-%s", bootstrap.Customer) @@ -129,7 +136,14 @@ func New( creds.GithubApp.Owner = bootstrap.Org // calculate store name - bootstrap.FinalSecretStoreName = fmt.Sprintf("%s-firestartr-secret-store", bootstrap.Customer) + // For dedicated (Azure) deployments the deployed AKS cluster exposes a + // SecretStore named "firestartr-kv". For SaaS (AWS) deployments the store + // follows the -aws-parameter-store convention. + if bootstrap.isDedicatedDeployment() { + bootstrap.FinalSecretStoreName = "firestartr-kv" + } else { + bootstrap.FinalSecretStoreName = fmt.Sprintf("%s-aws-parameter-store", bootstrap.Customer) + } crsDotConfigDir, err := getCrsDotConfigDir(ctx, bootstrap, defaultsInterface) if err != nil { @@ -156,6 +170,11 @@ func New( ghOrgLowerCase := strings.ToLower(bootstrap.Org) + // Auto-inject required repos for dedicated deployments + if bootstrap.isDedicatedDeployment() { + bootstrap.Components = injectDedicatedComponents(bootstrap) + } + return &FirestartrBootstrap{ Bootstrap: bootstrap, BootstrapFile: bootstrapFile, @@ -191,6 +210,73 @@ func calculateParameters(customer string, githuborg string) []string { return results } +// isDedicatedDeployment returns true when the bootstrap is configured for a +// dedicated (non-SaaS, currently Azure) deployment. +func (m *FirestartrBootstrap) isDedicatedDeployment() bool { + return m.Bootstrap.isDedicatedDeployment() +} + +// injectDedicatedComponents ensures that state-sys-services and state-argocd +// are present in the component list for dedicated deployments. These repos must +// exist in the customer GitHub org before deployment PRs can be created. +func injectDedicatedComponents(bootstrap *Bootstrap) []Component { + components := bootstrap.Components + + hasSysSvc := false + hasArgoCD := false + + for _, c := range components { + if c.Name == "state-sys-services" { + hasSysSvc = true + } + if c.Name == "state-argocd" { + hasArgoCD = true + } + } + + for i := range components { + if components[i].Name == "state-sys-services" { + hasFeature := false + for _, feature := range components[i].Features { + if feature.Name == "state_repo_sys_services" && feature.Version == "2.4.0" { + hasFeature = true + break + } + } + if !hasFeature { + components[i].Features = append(components[i].Features, Feature{ + Name: "state_repo_sys_services", + Version: "2.4.0", + }) + } + break + } + } + + if !hasSysSvc { + components = append(components, Component{ + Name: "state-sys-services", + Description: "Firestartr dedicated deployment system services repository", + DefaultBranch: bootstrap.DefaultBranch, + Features: []Feature{{ + Name: "state_repo_sys_services", + Version: "2.4.0", + }}, + }) + } + + if !hasArgoCD { + components = append(components, Component{ + Name: "state-argocd", + Description: "Firestartr dedicated deployment ArgoCD configuration repository", + DefaultBranch: bootstrap.DefaultBranch, + Features: []Feature{}, + }) + } + + return components +} + func (m *FirestartrBootstrap) ValidateBootstrap( ctx context.Context, kubeconfig *dagger.Directory, @@ -221,19 +307,31 @@ func (m *FirestartrBootstrap) ValidateBootstrap( errorMsgs = append(errorMsgs, err.Error()) } - _, err = m.ValidateSTSCredentials(ctx) - if err != nil { - errorMsgs = append(errorMsgs, err.Error()) - } + if !m.isDedicatedDeployment() { + _, err = m.ValidateSTSCredentials(ctx) + if err != nil { + errorMsgs = append(errorMsgs, err.Error()) + } - err = m.ValidateBucket(ctx) - if err != nil { - errorMsgs = append(errorMsgs, err.Error()) - } + err = m.ValidateBucket(ctx) + if err != nil { + errorMsgs = append(errorMsgs, err.Error()) + } - err = m.ValidateParameters(ctx, fmt.Sprintf("/firestartr/%s", m.Bootstrap.Customer)) - if err != nil { - errorMsgs = append(errorMsgs, err.Error()) + err = m.ValidateParameters(ctx, fmt.Sprintf("/firestartr/%s", m.Bootstrap.Customer)) + if err != nil { + errorMsgs = append(errorMsgs, err.Error()) + } + } else { + err = m.ValidateAzureSPCredentials(ctx) + if err != nil { + errorMsgs = append(errorMsgs, err.Error()) + } + + err = m.ValidateAzureKeyVaultSecrets(ctx) + if err != nil { + errorMsgs = append(errorMsgs, err.Error()) + } } err = m.ValidatePrefappBotPat(ctx) diff --git a/firestartr-bootstrap/operator.go b/firestartr-bootstrap/operator.go index a6cf2856..0464905a 100644 --- a/firestartr-bootstrap/operator.go +++ b/firestartr-bootstrap/operator.go @@ -5,6 +5,7 @@ import ( "dagger/firestartr-bootstrap/internal/dagger" "errors" "fmt" + "regexp" "strings" "sync" "time" @@ -13,6 +14,21 @@ import ( "gopkg.in/yaml.v3" ) +// Chart versions for dedicated sys-services — must stay in sync with the +// release descriptor templates under templates/deployment/sys_services/. +const ( + chartVersionNginx = "4.10.1" + chartVersionCertManager = "v1.15.0" + chartVersionExternalDns = "1.14.4" + chartVersionArgoCD = "7.6.8" + chartVersionArgoEvents = "2.4.10" + chartVersionArgoWorkflows = "0.42.5" +) + +func sysServicesApplyScript() string { + return "set -euo pipefail\nfor ns in external-secrets ingress-nginx cert-manager external-dns firestartr argocd argo-events argo-workflows; do\n kubectl create namespace \"$ns\" --dry-run=client -o yaml | kubectl apply -f -\ndone\nhelm template external-secrets external-secrets/external-secrets -n external-secrets --include-crds | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\ncurl -fsSL https://raw.githubusercontent.com/firestartr-pro/docs/refs/heads/main/site/raw/core/crds/v2.6.4/index.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --version " + chartVersionNginx + " --include-crds --values /sys-values/nginx-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template cert-manager jetstack/cert-manager -n cert-manager --version " + chartVersionCertManager + " --include-crds --values /sys-values/cert-manager-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template external-dns external-dns/external-dns -n external-dns --version " + chartVersionExternalDns + " --include-crds --values /sys-values/external-dns-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm repo add firestartr-controller https://prefapp.github.io/charts/firestartr-controller\nhelm template firestartr firestartr-controller/firestartr -n firestartr --version 3.5.0 --include-crds --values /sys-values/firestartr-values.yaml | kubectl apply -n firestartr --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template argocd argo/argo-cd -n argocd --version " + chartVersionArgoCD + " --include-crds --values /sys-values/argocd-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm repo add argo-configuration-secrets https://prefapp.github.io/charts/argo-configuration-secrets\nhelm template argo-config-secrets argo-configuration-secrets/argocd-configuration-secrets -n argocd --version 1.1.0 --include-crds --values /sys-values/argo-configuration-secrets-values.yaml | kubectl apply -n argocd --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template argo-events argo/argo-events -n argo-events --version " + chartVersionArgoEvents + " --include-crds --values /sys-values/argo-events-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -\nhelm template argo-workflows argo/argo-workflows -n argo-workflows --version " + chartVersionArgoWorkflows + " --include-crds --values /sys-values/argo-workflows-values.yaml | kubectl apply --server-side --force-conflicts --field-manager=firestartr-bootstrap -f -" +} + func (m *FirestartrBootstrap) RunOperator( ctx context.Context, kindContainer *dagger.Container, @@ -26,6 +42,22 @@ func (m *FirestartrBootstrap) RunOperator( kindContainer = kindContainer. WithDirectory("/resources", renderedCrsDir) + // For dedicated deployments, wait for the TFWorkspace CRs that were + // already applied by InstallInitialCRsAndBuildHelmValues (they live in + // /resources/initial-crs). We apply them again idempotently so that + // applyCrAndWaitForProvisioned can block until PROVISIONED=True. + if m.isDedicatedDeployment() { + kindContainer, err = m.ApplyFirestartrCrs( + ctx, + kindContainer, + "/resources/initial-crs", + []string{"FirestartrTerraformWorkspace.*"}, + ) + if err != nil { + return nil, fmt.Errorf("waiting for dedicated TFWorkspace CRs: %w", err) + } + } + kindContainer, err = m.ApplyFirestartrCrs( ctx, kindContainer, @@ -60,12 +92,13 @@ func (m *FirestartrBootstrap) RunOperator( } -func (m *FirestartrBootstrap) InstallHelmAndExternalSecrets( +func (m *FirestartrBootstrap) InstallClusterServices( ctx context.Context, kindContainer *dagger.Container, ) (*dagger.Container, error) { - kindContainerWithSecrets, err := kindContainer. + // External Secrets Operator — required for both SaaS and dedicated paths + result, err := kindContainer. WithExec([]string{ "helm", "repo", "add", "external-secrets", "https://charts.external-secrets.io", @@ -83,7 +116,11 @@ func (m *FirestartrBootstrap) InstallHelmAndExternalSecrets( return nil, errors.New(errMsg) } - return kindContainerWithSecrets, nil + // For dedicated deployments the remaining cluster services (nginx, cert-manager, + // external-dns, ArgoCD, argo-events, argo-workflows) are installed on the + // target AKS cluster via ApplySysServicesWithValues — NOT on the local kind + // cluster. Kind only needs ESO to resolve ExternalSecrets during bootstrap. + return result, nil } func (m *FirestartrBootstrap) InstallInitialCRsAndBuildHelmValues( @@ -342,3 +379,193 @@ func getSingularByKind(kind string) (string, error) { } } + +// ApplySysServicesWithValues renders the Azure-specific Helm values for every +// dedicated sys-service and applies them to the target AKS cluster via +// `helm upgrade --install --values`. This fills the gap between +// InstallClusterServices (bare Helm install, no custom values) and the +// state-sys-services PR that ArgoCD will manage going forward. +// +// The AKS kubeconfig is fetched automatically via `az aks get-credentials` +// using the bootstrap App Registration credentials stored in the credentials +// file, so no pre-built kubeconfig directory is required. +// +// The AKS cluster name and resource group are read from +// ConfigProvider.AksClusterName and ConfigProvider.ResourceGroupName. +// +// externalDnsClientId is the client ID of the dedicated external-dns Managed +// Identity provisioned by the TFWorkspace CR. It must be non-empty; callers +// should obtain it via GetExternalDnsMIClientId before calling this function. +func (m *FirestartrBootstrap) ApplySysServicesWithValues( + ctx context.Context, + // Client ID of the dedicated external-dns Managed Identity (from GetExternalDnsMIClientId). + externalDnsClientId string, +) (*dagger.Container, error) { + + if !m.isDedicatedDeployment() { + return nil, fmt.Errorf("ApplySysServicesWithValues is only applicable for dedicated deployments") + } + + cfg := m.Creds.CloudProvider.Config + + re := regexp.MustCompile("^https://") + webhookUri := re.ReplaceAllString(m.Bootstrap.WebhookUrl, "") + + azureData := AzureDeploymentConfig{ + Customer: m.Bootstrap.Customer, + Org: m.Bootstrap.Org, + OrgLowerCase: m.GhOrgLowerCase, + Domain: m.Bootstrap.Domain, + DeploymentPlatform: DeploymentPlatformAKS, + ExternalDnsClientId: externalDnsClientId, + Webhook: DeploymentWebhook{ + URL: webhookUri, + Secret: m.Bootstrap.WebhookSecretRef, + }, + CloudProvider: m.Creds.CloudProvider, + Controller: DeploymentController{ + Image: fmt.Sprintf("ghcr.io/prefapp/gitops-k8s:%s_full-%s", + m.Bootstrap.Firestartr.OperatorVersion, + m.Creds.CloudProvider.ImageFlavorSuffix(), + ), + }, + } + + // Render the values file for each service that has dynamic content. + // Static services (nginx, cert-manager) still get their values applied + // so the cluster state matches the state-sys-services repo exactly. + + nginxValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/nginx_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading nginx_values.tmpl: %w", err) + } + + certManagerValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/cert_manager_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading cert_manager_values.tmpl: %w", err) + } + + externalDnsValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/external_dns_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading external_dns_values.tmpl: %w", err) + } + renderedExternalDnsValues, err := renderTmpl(externalDnsValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering external_dns_values.tmpl: %w", err) + } + + argoConfigSecretsValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/sys_services/argo_config_secrets_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_config_secrets_values.tmpl: %w", err) + } + renderedArgoConfigSecretsValues, err := renderTmpl(argoConfigSecretsValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering argo_config_secrets_values.tmpl: %w", err) + } + + firestartrValuesTmpl, err := dag.CurrentModule().Source().File("templates/deployment/azure_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading azure_values.tmpl: %w", err) + } + renderedValues, err := renderTmpl(firestartrValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering azure_values.tmpl: %w", err) + } + + argocdValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/argocd_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argocd_values.tmpl: %w", err) + } + renderedArgocdValues, err := renderTmpl(argocdValuesTmpl, azureData) + if err != nil { + return nil, fmt.Errorf("rendering argocd_values.tmpl: %w", err) + } + + // argo-events — static values (no Azure-specific interpolation needed) + argoEventsValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/argo_events_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_events_values.tmpl: %w", err) + } + + // argo-workflows — static values + argoWorkflowsValuesTmpl, err := dag.CurrentModule().Source(). + File("templates/deployment/sys_services/argo_workflows_values.tmpl").Contents(ctx) + if err != nil { + return nil, fmt.Errorf("reading argo_workflows_values.tmpl: %w", err) + } + + // Fetch the AKS admin kubeconfig in an Azure CLI container, then mount it + // into the render/apply container that applies raw manifests. + clientSecret := dag.SetSecret("azure-bootstrap-client-secret", cfg.BootstrapClientSecret) + + aksCtr, err := dag.Container(). + From("mcr.microsoft.com/azure-cli:latest"). + WithSecretVariable("AZURE_CLIENT_SECRET", clientSecret). + // Login with bootstrap Service Principal (password read from env var via shell + // so the secret value is not logged in Dagger's exec args). + WithExec([]string{ + "sh", "-c", + fmt.Sprintf( + "az login --service-principal --username %s --password $AZURE_CLIENT_SECRET --tenant %s", + cfg.BootstrapClientId, + cfg.TenantId, + ), + }). + WithExec([]string{ + "az", "account", "set", + "--subscription", cfg.SubscriptionId, + }). + // Fetch AKS admin kubeconfig — no external kubeconfig directory required + WithExec([]string{ + "az", "aks", "get-credentials", + "--resource-group", cfg.ResourceGroupName, + "--name", cfg.AksClusterName, + "--overwrite-existing", + "--admin", + }). + Sync(ctx) + + if err != nil { + errMsg := extractErrorMessage(err, "Failed to fetch AKS kubeconfig") + return nil, errors.New(errMsg) + } + + kubeconfigDir := aksCtr.Directory("/root/.kube") + valuesDir := dag.Directory(). + WithNewFile("firestartr-values.yaml", renderedValues). + WithNewFile("nginx-values.yaml", nginxValuesTmpl). + WithNewFile("cert-manager-values.yaml", certManagerValuesTmpl). + WithNewFile("external-dns-values.yaml", renderedExternalDnsValues). + WithNewFile("argocd-values.yaml", renderedArgocdValues). + WithNewFile("argo-configuration-secrets-values.yaml", renderedArgoConfigSecretsValues). + WithNewFile("argo-events-values.yaml", argoEventsValuesTmpl). + WithNewFile("argo-workflows-values.yaml", argoWorkflowsValuesTmpl) + + ctr, err := dag.Container(). + From("ghcr.io/helmfile/helmfile:latest"). + WithEnvVariable("BUST_CACHE", time.Now().String()). + WithMountedDirectory("/root/.kube", kubeconfigDir). + WithEnvVariable("KUBECONFIG", "/root/.kube/config"). + WithDirectory("/sys-values", valuesDir). + WithExec([]string{"helm", "repo", "add", "external-secrets", "https://charts.external-secrets.io"}). + WithExec([]string{"helm", "repo", "add", "ingress-nginx", "https://kubernetes.github.io/ingress-nginx"}). + WithExec([]string{"helm", "repo", "add", "jetstack", "https://charts.jetstack.io"}). + WithExec([]string{"helm", "repo", "add", "external-dns", "https://kubernetes-sigs.github.io/external-dns/"}). + WithExec([]string{"helm", "repo", "add", "argo", "https://argoproj.github.io/argo-helm"}). + WithExec([]string{"helm", "repo", "add", "prefapp", "https://prefapp.github.io/charts"}). + WithExec([]string{"helm", "repo", "update"}). + WithExec([]string{"sh", "-c", sysServicesApplyScript()}). + Sync(ctx) + + if err != nil { + errMsg := extractErrorMessage(err, "Failed to apply sys-services with values") + return nil, errors.New(errMsg) + } + + return ctr, nil +} diff --git a/firestartr-bootstrap/push_secrets.go b/firestartr-bootstrap/push_secrets.go index 45680c0e..28070288 100644 --- a/firestartr-bootstrap/push_secrets.go +++ b/firestartr-bootstrap/push_secrets.go @@ -9,13 +9,20 @@ func (m *FirestartrBootstrap) GeneratePushSecrets( ctx context.Context, ) (*dagger.Directory, error) { + // The secret store name matches the ESO SecretStore created during bootstrap: + // "aws" for SaaS (AWS), "firestartr-kv" for dedicated (Azure). + secretStoreName := "aws" + if m.isDedicatedDeployment() { + secretStoreName = "firestartr-kv" + } + webHookPushSecret := PushSecretElement{ Name: "webhook-pushsecret", KubernetesSecret: "webhook-secret", KubernetesSecretKey: "webhook-secret-key", ParameterName: m.Bootstrap.WebhookSecretRef, Value: "my-secret-secret", - SecretStore: "aws", + SecretStore: secretStoreName, } prefappBotPatSecret := PushSecretElement{ @@ -24,7 +31,7 @@ func (m *FirestartrBootstrap) GeneratePushSecrets( KubernetesSecretKey: "botpat-secret-key", ParameterName: m.Bootstrap.PrefappBotPatSecretRef, Value: m.Creds.GithubApp.PrefappBotPat, - SecretStore: "aws", + SecretStore: secretStoreName, } prefappCliVersion := PushSecretElement{ @@ -33,7 +40,7 @@ func (m *FirestartrBootstrap) GeneratePushSecrets( KubernetesSecretKey: "cli-version-key", ParameterName: m.Bootstrap.FirestartrCliVersionSecretRef, Value: m.Bootstrap.Firestartr.CliVersion, - SecretStore: "aws", + SecretStore: secretStoreName, } rendered, err := renderPushSecret(ctx, &webHookPushSecret, "external_secrets/push_secret.tmpl") diff --git a/firestartr-bootstrap/render.go b/firestartr-bootstrap/render.go index c4976eac..393219b2 100644 --- a/firestartr-bootstrap/render.go +++ b/firestartr-bootstrap/render.go @@ -120,7 +120,57 @@ func (m *FirestartrBootstrap) RenderInitialCrs(ctx context.Context, templ *dagge if err != nil { return "", err } - return renderTmpl(templateContent, m.Creds) + + creds := m.Creds + + if m.isDedicatedDeployment() { + // For the kind cluster FirestartrProviderConfig, the Terraform azurerm provider + // needs SP credentials (client_id + client_secret) to authenticate to Azure RM + // for state backend operations. The runtime firestartr-mi Managed Identity cannot + // be used here because MIs have no client_secret and the kind cluster has no + // Workload Identity support. We therefore substitute the bootstrap SP credentials + // into the standard client_id / client_secret fields before serialisation so + // that toJson emits the correct keys for the Terraform azurerm provider. + credsCopy := *creds + cloudProviderCopy := credsCopy.CloudProvider + configCopy := cloudProviderCopy.Config + configCopy.ClientId = configCopy.BootstrapClientId + configCopy.ClientSecret = configCopy.BootstrapClientSecret + configCopy.BootstrapClientId = "" + configCopy.BootstrapClientSecret = "" + // KeyVaultName is a bootstrap-only field used by ESO and credential validation; + // it is not a valid azurerm Terraform backend argument and must be omitted from + // the FirestartrProviderConfig config JSON. + configCopy.KeyVaultName = "" + + // Auto-fetch the AKS OIDC issuer URL from the ARM API when not provided + // in the credentials file, so the user doesn't have to look it up manually. + if configCopy.AksOidcIssuerUrl == "" { + issuerUrl, err := fetchAksOidcIssuerUrl(ctx, m.Creds) + if err != nil { + return "", fmt.Errorf("RenderInitialCrs: %w", err) + } + configCopy.AksOidcIssuerUrl = issuerUrl + } + + cloudProviderCopy.Config = configCopy + credsCopy.CloudProvider = cloudProviderCopy + creds = &credsCopy + } + + // Build the combined data struct so templates can access both credential + // fields and Bootstrap fields (e.g. for dedicated-mode TFWorkspace CRs). + data := InitialCrsData{ + CloudProvider: creds.CloudProvider, + GithubApp: creds.GithubApp, + GithubAppOperator: creds.GithubAppOperator, + Customer: m.Bootstrap.Customer, + Org: m.Bootstrap.Org, + DeploymentMode: m.Bootstrap.DeploymentMode, + Domain: m.Bootstrap.Domain, + } + + return renderTmpl(templateContent, data) } func (m *FirestartrBootstrap) RenderBootstrapFile(ctx context.Context, templ *dagger.File) (string, error) { diff --git a/firestartr-bootstrap/schemas/bootstrap-file.json b/firestartr-bootstrap/schemas/bootstrap-file.json index a2f30208..5b2c70ac 100644 --- a/firestartr-bootstrap/schemas/bootstrap-file.json +++ b/firestartr-bootstrap/schemas/bootstrap-file.json @@ -18,12 +18,22 @@ "cli" ] }, + "deploymentMode": { + "type": "string", + "enum": ["saas", "dedicated"], + "description": "Deployment topology. 'saas' targets the shared firestartr- org (AWS). 'dedicated' targets the customer's own GitHub org (currently Azure only)." + }, "env": { "type": "string", "enum": [ "pre", "pro" - ] + ], + "description": "Environment name. Required for SaaS deployments; absent for dedicated deployments." + }, + "domain": { + "type": "string", + "description": "Fully-qualified base domain for the dedicated deployment (e.g. 'azure-pre.firestartr.dev'). Required when deploymentMode is 'dedicated'." }, "org": { "type": "string" @@ -191,7 +201,6 @@ } }, "required": [ - "env", "firestartr", "org", "pushFiles", @@ -204,5 +213,20 @@ "defaultBranchStrategy", "defaultFirestartrGroup", "defaultGroup" - ] + ], + "if": { + "properties": { + "deploymentMode": { "const": "dedicated" } + }, + "required": ["deploymentMode"] + }, + "then": { + "required": ["domain"], + "properties": { + "domain": { "type": "string", "minLength": 1 } + } + }, + "else": { + "required": ["env"] + } } diff --git a/firestartr-bootstrap/schemas/credentials-file.json b/firestartr-bootstrap/schemas/credentials-file.json index 8eabe4e9..a2b35a2a 100644 --- a/firestartr-bootstrap/schemas/credentials-file.json +++ b/firestartr-bootstrap/schemas/credentials-file.json @@ -6,6 +6,64 @@ "type": "string", "pattern": "^\\d+$", "description": "A string containing only one or more digits." + }, + "awsConfig": { + "type": "object", + "properties": { + "bucket": { "type": "string" }, + "region": { "type": "string" }, + "access_key": { "type": "string" }, + "secret_key": { "type": "string" } + }, + "required": ["region", "access_key", "secret_key"] + }, + "azureConfig": { + "type": "object", + "properties": { + "tenant_id": { "type": "string" }, + "subscription_id": { "type": "string" }, + "client_id": { + "type": "string", + "description": "Client ID of the firestartr-mi Managed Identity. Used in deployed AKS state via Workload Identity." + }, + "bootstrap_client_id": { + "type": "string", + "description": "Client ID of the bootstrap App Registration (Service Principal). Used only in the kind cluster during bootstrap. Delete the App Registration after bootstrap completes." + }, + "bootstrap_client_secret": { + "type": "string", + "description": "Client secret of the bootstrap App Registration. Used only in the kind cluster during bootstrap. Delete the App Registration after bootstrap completes." + }, + "storage_account_name": { "type": "string" }, + "container_name": { "type": "string" }, + "resource_group_name": { "type": "string" }, + "key_vault_name": { "type": "string" }, + "location": { + "type": "string", + "description": "Azure region of the resource group (e.g. 'westeurope'). Used when provisioning dedicated Managed Identities via TFWorkspace." + }, + "aks_oidc_issuer_url": { + "type": "string", + "description": "OIDC issuer URL of the target AKS cluster. Optional — auto-fetched from the ARM API at bootstrap time if omitted. Provide only to override the auto-detected value." + }, + "aks_cluster_name": { + "type": "string", + "description": "Name of the target AKS cluster. Used in 'az aks get-credentials' during the sys-services bootstrap install." + } + }, + "required": [ + "tenant_id", + "subscription_id", + "client_id", + "bootstrap_client_id", + "bootstrap_client_secret", + "storage_account_name", + "container_name", + "resource_group_name", + "key_vault_name", + "aks_cluster_name", + "location" + ] } }, "type": "object", @@ -14,33 +72,12 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "enum": ["aws", "azure"] }, "providerConfigName": { "type": "string" }, - "config": { - "type": "object", - "properties": { - "bucket": { - "type": "string" - }, - "region": { - "type": "string" - }, - "access_key": { - "type": "string" - }, - "secret_key": { - "type": "string" - } - }, - "required": [ - "region", - "access_key", - "secret_key" - ] - }, "source": { "type": "string" }, @@ -51,13 +88,19 @@ "type": "string" } }, - "required": [ - "name", - "config", - "source", - "type", - "version" - ] + "required": ["name", "source", "type", "version"], + "if": { + "properties": { "name": { "const": "azure" } }, + "required": ["name"] + }, + "then": { + "properties": { "config": { "$ref": "#/$defs/azureConfig" } }, + "required": ["config"] + }, + "else": { + "properties": { "config": { "$ref": "#/$defs/awsConfig" } }, + "required": ["config"] + } }, "github": { "type": "object", diff --git a/firestartr-bootstrap/step_by_step.sh b/firestartr-bootstrap/step_by_step.sh index d47afa80..a90d3c82 100644 --- a/firestartr-bootstrap/step_by_step.sh +++ b/firestartr-bootstrap/step_by_step.sh @@ -151,6 +151,16 @@ prompt_or_auto() { fi } +prompt_external_dns_client_id() { + local CLIENT_ID + read -r -p "external-dns Managed Identity client ID: " CLIENT_ID + if [ -z "$CLIENT_ID" ]; then + echo "❌ external-dns Managed Identity client ID is required" >&2 + exit 1 + fi + echo "$CLIENT_ID" +} + execute_step() { local ACTION="$1" shift @@ -308,6 +318,25 @@ execute_step "$ACTION" dagger \ --cache-volume="${VOLUME_ID}" +# Apply sys-services on dedicated deployments only +if grep -q '^deploymentMode: *dedicated' "${BOOTSTRAP_FILE}"; then + EXTERNAL_DNS_CLIENT_ID=$(prompt_external_dns_client_id) + ACTION=$(prompt_or_auto "Apply sys-services with values to the AKS cluster?" "Applying sys-services with values") + execute_step "$ACTION" dagger \ + --bootstrap-file="${BOOTSTRAP_FILE}" \ + --credentials-secret="file:${CREDENTIALS_FILE}" \ + call cmd-apply-sys-services \ + --docker-socket=/var/run/docker.sock \ + --kind-svc="tcp://localhost:${PORT}" \ + --kind-cluster-name="${CLUSTER_NAME}" \ + --external-dns-client-id="${EXTERNAL_DNS_CLIENT_ID}" + + if [ "$ACTION" = "continue" ]; then + wait_for_user + fi +fi + + # Push state secrets ACTION=$(prompt_or_auto "Push organization state secrets (only for non-free orgs)?" "Pushing organization state secrets") execute_step "$ACTION" dagger \ diff --git a/firestartr-bootstrap/templates/deployment/azure_tenant.tmpl b/firestartr-bootstrap/templates/deployment/azure_tenant.tmpl new file mode 100644 index 00000000..fee3c09a --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/azure_tenant.tmpl @@ -0,0 +1,8 @@ +releaseName: {{ .Customer }} +registry: https://prefapp.github.io/charts/firestartr-controller +version: 3.5.0 +chart: firestartr-controller/firestartr +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/azure_values.tmpl b/firestartr-bootstrap/templates/deployment/azure_values.tmpl new file mode 100644 index 00000000..66da15a2 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/azure_values.tmpl @@ -0,0 +1,95 @@ +org: {{ .Org }} + +argoEvents: + host: {{ .Webhook.URL }} + ingress: + enabled: true + ingressClassName: nginx + webhooks: + - state-infra + - state-github + - state-secrets + secretStoreKeysRefs: + webhookSecret: {{ .Webhook.Secret }} + +general: + image: {{ .Controller.Image }} + auth: + provider: azure + config: + azureIdentity: + clientId: "{{ .CloudProvider.Config.ClientId }}" + tenantId: "{{ .CloudProvider.Config.TenantId }}" + +externalSecrets: + auth: + provider: azure + config: + azureIdentity: + clientId: "{{ .CloudProvider.Config.ClientId }}" + tenantId: "{{ .CloudProvider.Config.TenantId }}" + vaultUrl: "https://{{ .CloudProvider.Config.KeyVaultName }}.vault.azure.net" + +controller: + enabled: true + secretStoreKeysRefs: + githubAppPem: "fs-pem" + githubAppId: "fs-app-id" + metrics: + enabled: true + datadog: + enabled: false + +crsAnalyzer: + enabled: false + +providerConfigs: + tfstate-{{ .Customer }}: + config: | + { + "storage_account_name": "{{ .CloudProvider.Config.StorageAccountName }}", + "container_name": "{{ .CloudProvider.Config.ContainerName }}", + "resource_group_name": "{{ .CloudProvider.Config.ResourceGroupName }}", + "subscription_id": "{{ .CloudProvider.Config.SubscriptionId }}", + "tenant_id": "{{ .CloudProvider.Config.TenantId }}", + "client_id": "{{ .CloudProvider.Config.ClientId }}", + "use_oidc": true + } + type: azurerm + source: "hashicorp/azurerm" + version: "~> 3.0" + github-{{ .Customer }}: + config: | + { + "owner": "{{ .Org }}", + "app_auth": { + "id": "${{"{{"}} secrets.GITHUB_APP_ID {{"}}"}}", + "installation_id": "${{"{{"}} secrets.GITHUB_APP_INSTALLATION_ID {{"}}"}}", + "pem_file": "${{"{{"}} secrets.GITHUB_APP_PEM_FILE {{"}}"}}" + } + } + secrets: + GITHUB_APP_PEM_FILE: + secretRef: + key: github-app-pem-file + secretStoreKeyRef: "fs-pem" + GITHUB_APP_ID: + secretRef: + key: github-app-id + secretStoreKeyRef: "fs-app-id" + GITHUB_APP_INSTALLATION_ID: + secretRef: + key: github-app-installation-id + secretStoreKeyRef: "fs-{{ .OrgLowerCase }}-installation-id" + type: github + source: integrations/github + version: "~> 6.0" + +tfPlanner: + enabled: true + secretStoreKeysRefs: + githubAppId: "fs-app-id" + githubAppPem: "fs-pem" + +argoRefresher: + enabled: true diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets.tmpl new file mode 100644 index 00000000..2b190940 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets.tmpl @@ -0,0 +1,8 @@ +releaseName: argo-configuration-secrets +registry: https://prefapp.github.io/charts +version: 1.0.0 +chart: prefapp/argo-configuration-secrets +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets_values.tmpl new file mode 100644 index 00000000..e15b927e --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_config_secrets_values.tmpl @@ -0,0 +1,21 @@ +externalSecrets: + auth: + provider: azure + config: + azureIdentity: + vaultUrl: "https://{{ .CloudProvider.Config.KeyVaultName }}.vault.azure.net/" + tenantId: "{{ .CloudProvider.Config.TenantId }}" + clientId: "{{ .CloudProvider.Config.ClientId }}" + +githubOrgAccess: + prefapp: + prefappBotToken: + remoteRef: prefapp-bot-pat + clients: + {{ .Org }}: + githubAppId: + remoteRef: fs-argocd-app-id + githubAppInstallationId: + remoteRef: fs-argocd-{{ trimSuffix "-org" .Org }}-installation-id + githubAppPrivateKey: + remoteRef: fs-argocd-pem diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_events.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_events.tmpl new file mode 100644 index 00000000..893c574f --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_events.tmpl @@ -0,0 +1,8 @@ +releaseName: argo-events +registry: https://argoproj.github.io/argo-helm +version: 2.4.10 +chart: argo/argo-events +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_events_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_events_values.tmpl new file mode 100644 index 00000000..cf04bc8f --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_events_values.tmpl @@ -0,0 +1,2 @@ +crds: + install: true diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows.tmpl new file mode 100644 index 00000000..88f798e1 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows.tmpl @@ -0,0 +1,8 @@ +releaseName: argo-workflows +registry: https://argoproj.github.io/argo-helm +version: 0.42.5 +chart: argo/argo-workflows +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows_values.tmpl new file mode 100644 index 00000000..cf04bc8f --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argo_workflows_values.tmpl @@ -0,0 +1,2 @@ +crds: + install: true diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argocd.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argocd.tmpl new file mode 100644 index 00000000..3025e665 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argocd.tmpl @@ -0,0 +1,10 @@ +releaseName: argocd +registry: https://argoproj.github.io/argo-helm +version: 7.6.8 +chart: argo/argo-cd +hooks: [] +extraPatches: [] +remoteArtifacts: + - filename: CustomResourceDefinitions.yaml + url: https://raw.githubusercontent.com/firestartr-pro/docs/refs/heads/main/site/raw/core/crds/v2.6.4/index.yaml +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/argocd_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/argocd_values.tmpl new file mode 100644 index 00000000..3fbe3025 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/argocd_values.tmpl @@ -0,0 +1,55 @@ +global: + domain: "argocd.{{ .Domain }}" + +configs: + cm: + admin.enabled: "true" + # Backstage account — used for catalog token generation + accounts.backstage: apiKey, login + accounts.backstage.enabled: "true" + # Argo Workflows account — for workflow automation + accounts.argo-workflows: apiKey, login + accounts.argo-workflows.enabled: "true" + create: true + url: "https://argocd.{{ .Domain }}" + rbac: + create: true + policy.default: role:readonly + policy.csv: | + p, argo-workflows, projects, update, *, allow + scopes: "[groups, email]" + params: + server.insecure: "true" + controller.diff.server.side: "true" + +# ArgoCD Server — Workload Identity annotations so ArgoCD can read Azure +# Key Vault secrets via ESO and authenticate to Azure AD for OIDC. +server: + serviceAccount: + annotations: + azure.workload.identity/client-id: "{{ .CloudProvider.Config.ClientId }}" + azure.workload.identity/tenant-id: "{{ .CloudProvider.Config.TenantId }}" + podLabels: + azure.workload.identity/use: "true" + +# ArgoCD Application Controller +controller: + serviceAccount: + annotations: + azure.workload.identity/client-id: "{{ .CloudProvider.Config.ClientId }}" + azure.workload.identity/tenant-id: "{{ .CloudProvider.Config.TenantId }}" + podLabels: + azure.workload.identity/use: "true" + +# ArgoCD ApplicationSet Controller +applicationSet: + serviceAccount: + annotations: + azure.workload.identity/client-id: "{{ .CloudProvider.Config.ClientId }}" + azure.workload.identity/tenant-id: "{{ .CloudProvider.Config.TenantId }}" + podLabels: + azure.workload.identity/use: "true" + +crds: + install: true + keep: true diff --git a/firestartr-bootstrap/templates/deployment/sys_services/cert_manager.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/cert_manager.tmpl new file mode 100644 index 00000000..be6fa59e --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/cert_manager.tmpl @@ -0,0 +1,8 @@ +releaseName: cert-manager +registry: https://charts.jetstack.io +version: v1.15.0 +chart: cert-manager/cert-manager +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/cert_manager_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/cert_manager_values.tmpl new file mode 100644 index 00000000..4a6614f7 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/cert_manager_values.tmpl @@ -0,0 +1,5 @@ +crds: + enabled: true +webhook: + validatingWebhookConfiguration: + namespaceSelector: {} diff --git a/firestartr-bootstrap/templates/deployment/sys_services/external_dns.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/external_dns.tmpl new file mode 100644 index 00000000..d53cfa1c --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/external_dns.tmpl @@ -0,0 +1,8 @@ +releaseName: external-dns +registry: https://kubernetes-sigs.github.io/external-dns/ +version: 1.14.4 +chart: external-dns/external-dns +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/external_dns_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/external_dns_values.tmpl new file mode 100644 index 00000000..7e215aba --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/external_dns_values.tmpl @@ -0,0 +1,27 @@ +provider: azure +azure: + resourceGroup: "{{ .CloudProvider.Config.ResourceGroupName }}" + tenantId: "{{ .CloudProvider.Config.TenantId }}" + subscriptionId: "{{ .CloudProvider.Config.SubscriptionId }}" + useWorkloadIdentityExtension: true +serviceAccount: + annotations: + azure.workload.identity/client-id: "{{ .ExternalDnsClientId }}" +podLabels: + azure.workload.identity/use: "true" +secretConfiguration: + enabled: true + mountPath: /etc/kubernetes + data: + azure.json: | + { + "tenantId": "{{ .CloudProvider.Config.TenantId }}", + "subscriptionId": "{{ .CloudProvider.Config.SubscriptionId }}", + "resourceGroup": "{{ .CloudProvider.Config.ResourceGroupName }}", + "useWorkloadIdentityExtension": true + } +txtOwnerId: "{{ .DeploymentPlatform }}" +sources: + - ingress +domainFilters: + - "{{ .Domain }}" diff --git a/firestartr-bootstrap/templates/deployment/sys_services/nginx.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/nginx.tmpl new file mode 100644 index 00000000..be6f174f --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/nginx.tmpl @@ -0,0 +1,8 @@ +releaseName: ingress-nginx +registry: https://kubernetes.github.io/ingress-nginx +version: 4.10.1 +chart: ingress-nginx/ingress-nginx +hooks: [] +extraPatches: [] +remoteArtifacts: [] +execs: [] diff --git a/firestartr-bootstrap/templates/deployment/sys_services/nginx_values.tmpl b/firestartr-bootstrap/templates/deployment/sys_services/nginx_values.tmpl new file mode 100644 index 00000000..53f04179 --- /dev/null +++ b/firestartr-bootstrap/templates/deployment/sys_services/nginx_values.tmpl @@ -0,0 +1,5 @@ +controller: + service: + annotations: {} + config: + use-forwarded-headers: "true" diff --git a/firestartr-bootstrap/templates/initial_claims.tmpl b/firestartr-bootstrap/templates/initial_claims.tmpl index 667f5e4c..14c8d87f 100644 --- a/firestartr-bootstrap/templates/initial_claims.tmpl +++ b/firestartr-bootstrap/templates/initial_claims.tmpl @@ -56,7 +56,7 @@ providers: name: firestartr-secrets secretStore: kind: SecretStore - name: aws # This will be swapped with {{ $.FinalSecretStoreName }} before uploading + name: {{ $.FinalSecretStoreName }} externalSecrets: refreshInterval: 24h secrets: @@ -65,7 +65,21 @@ providers: - secretName: "prefapp-bot-pat" remoteRef: "{{ $.PrefappBotPatSecretRef }}" - secretName: "firestartr-cli-version" - remoteRef: "/firestartr/{{ $.Customer }}/firestartr-cli-version" + remoteRef: "{{ $.FirestartrCliVersionSecretRef }}" + {{- if eq $.DeploymentMode "dedicated" }} + - secretName: "fs-state-pem" + remoteRef: "fs-state-pem" + - secretName: "fs-checks-pem" + remoteRef: "fs-checks-pem" + - secretName: "fs-import-pem" + remoteRef: "fs-import-pem" + - secretName: "fs-state-appid" + remoteRef: "fs-state-app-id" + - secretName: "fs-checks-appid" + remoteRef: "fs-checks-app-id" + - secretName: "fs-import-appid" + remoteRef: "fs-import-app-id" + {{- else }} - secretName: "fs-state-pem" remoteRef: "/firestartr/{{ $.Customer }}/fs-{{ $.Customer }}-state/pem" - secretName: "fs-checks-pem" @@ -78,6 +92,7 @@ providers: remoteRef: "/firestartr/{{ $.Customer }}/fs-{{ $.Customer }}-checks/app-id" - secretName: "fs-import-appid" remoteRef: "/firestartr/{{ $.Customer }}/fs-{{ $.Customer }}-import/app-id" + {{- end }} --- kind: OrgWebhookClaim version: "1.0" diff --git a/firestartr-bootstrap/templates/initial_crs.tmpl b/firestartr-bootstrap/templates/initial_crs.tmpl index 8340f1c0..ab33dcf5 100644 --- a/firestartr-bootstrap/templates/initial_crs.tmpl +++ b/firestartr-bootstrap/templates/initial_crs.tmpl @@ -38,7 +38,7 @@ kind: FirestartrProviderConfig metadata: name: {{ .CloudProvider.ProviderConfigName }} spec: - config: '{{ .CloudProvider.Config | toJson }}' + config: '{{ .CloudProvider.Config.ToConfigJSON }}' source: '{{ .CloudProvider.Source }}' type: '{{ .CloudProvider.Type }}' version: '{{ .CloudProvider.Version }}' @@ -74,3 +74,66 @@ spec: - key: id - key: nodeId - key: slug +{{- if eq .DeploymentMode "dedicated" }} +--- +# Provider-only FirestartrProviderConfig for dedicated mode. +# Named - (e.g. azprefapp-azurerm) to signal it is the +# azurerm *provider* identity, not the Terraform backend storage config. +# The full config (backend storage fields included) remains in the +# {{ .CloudProvider.ProviderConfigName }} FirestartrProviderConfig above. +# Backend-only fields (storage_account_name, container_name, +# resource_group_name) are intentionally excluded here to avoid the +# "Extraneous JSON object property" error from Terraform. +apiVersion: firestartr.dev/v1 +kind: FirestartrProviderConfig +metadata: + name: {{ .Customer }}-{{ .CloudProvider.Type }} +spec: + config: '{{ .CloudProvider.Config.ToAzureProviderConfigJSON }}' + source: '{{ .CloudProvider.Source }}' + type: '{{ .CloudProvider.Type }}' + version: '{{ .CloudProvider.Version }}' +--- +# Dedicated-mode: provision a dedicated Managed Identity for external-dns. +# This gives external-dns least-privilege access (DNS Zone Contributor) and +# its own Workload Identity federation on the AKS cluster, keeping it +# isolated from the main firestartr-mi identity. +# +# After bootstrap, CmdApplySysServices reads the MI resource ID from the +# "external-dns-mi-outputs" K8s secret (written below), calls the Azure ARM +# API to resolve the client_id, and supplies it to the external-dns Helm chart. +apiVersion: firestartr.dev/v1 +kind: FirestartrTerraformWorkspace +metadata: + annotations: + firestartr.dev/claim-ref: TFWorkspaceClaim/external-dns-mi + firestartr.dev/external-name: external-dns-mi + firestartr.dev/policy: apply + firestartr.dev/sync-enabled: "true" + firestartr.dev/sync-period: 24h + firestartr.dev/sync-policy: observe + firestartr.dev/bootstrapped: "true" + labels: + claim-ref: external-dns-mi + name: external-dns-mi-b7e3f1a2-c4d5-4e6f-8a9b-0c1d2e3f4a5b +spec: + context: + backend: + ref: + kind: FirestartrProviderConfig + name: {{ .CloudProvider.ProviderConfigName }} + providers: + - ref: + kind: FirestartrProviderConfig + name: {{ .Customer }}-{{ .CloudProvider.Type }} + firestartr: + tfStateKey: b7e3f1a2-c4d5-4e6f-8a9b-0c1d2e3f4a5b + module: git::https://github.com/prefapp/tfm.git//modules/azure-mi + source: Remote + values: '{"name":"external-dns-mi","resource_group":"{{ .CloudProvider.Config.ResourceGroupName }}","location":"{{ .CloudProvider.Config.Location }}","rbac":[{"name":"external-dns-dns-contributor","scope":"/subscriptions/{{ .CloudProvider.Config.SubscriptionId }}","roles":["DNS Zone Contributor"]}],"federated_credentials":[{"name":"external-dns-aks","type":"other","issuer":"{{ .CloudProvider.Config.AksOidcIssuerUrl }}","subject":"system:serviceaccount:external-dns:external-dns"}]}' + references: [] + writeConnectionSecretToRef: + name: external-dns-mi-outputs + outputs: + - key: id +{{- end }} diff --git a/firestartr-bootstrap/types.go b/firestartr-bootstrap/types.go index 3f15e0b7..84ca9055 100644 --- a/firestartr-bootstrap/types.go +++ b/firestartr-bootstrap/types.go @@ -1,5 +1,82 @@ package main +import "encoding/json" + +// ToConfigJSON serialises ConfigProvider to JSON with omitempty semantics, +// emitting only fields that have non-zero values. +// +// We cannot rely on sprig's toJson (json.Marshal) honouring the omitempty +// tags in types.go because dagger develop generates a ConfigProvider.MarshalJSON +// method in dagger.gen.go whose intermediate struct lacks omitempty. That +// generated method takes precedence over struct tags and always serialises every +// field. ToConfigJSON bypasses it by marshalling a local mirror type whose tags +// include omitempty. +func (c ConfigProvider) ToConfigJSON() string { + type mirror struct { + Bucket *string `json:"bucket,omitempty"` + Region string `json:"region,omitempty"` + AccessKey string `json:"access_key,omitempty"` + SecretKey string `json:"secret_key,omitempty"` + Token string `json:"token,omitempty"` + TenantId string `json:"tenant_id,omitempty"` + SubscriptionId string `json:"subscription_id,omitempty"` + ClientId string `json:"client_id,omitempty"` + StorageAccountName string `json:"storage_account_name,omitempty"` + ContainerName string `json:"container_name,omitempty"` + ResourceGroupName string `json:"resource_group_name,omitempty"` + KeyVaultName string `json:"key_vault_name,omitempty"` + BootstrapClientId string `json:"bootstrap_client_id,omitempty"` + BootstrapClientSecret string `json:"bootstrap_client_secret,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + } + b, _ := json.Marshal(mirror{ + Bucket: c.Bucket, + Region: c.Region, + AccessKey: c.AccessKey, + SecretKey: c.SecretKey, + Token: c.Token, + TenantId: c.TenantId, + SubscriptionId: c.SubscriptionId, + ClientId: c.ClientId, + StorageAccountName: c.StorageAccountName, + ContainerName: c.ContainerName, + ResourceGroupName: c.ResourceGroupName, + KeyVaultName: c.KeyVaultName, + BootstrapClientId: c.BootstrapClientId, + BootstrapClientSecret: c.BootstrapClientSecret, + ClientSecret: c.ClientSecret, + }) + return string(b) +} + +// ToAzureProviderConfigJSON serialises only the fields that are valid for the +// Terraform azurerm *provider* block (tenant_id, subscription_id, client_id, +// client_secret). Backend-specific fields (storage_account_name, +// container_name, resource_group_name) are intentionally excluded because the +// Terraform azurerm provider rejects them as "extraneous JSON object +// properties". +// +// Use ToConfigJSON when you need the full config for the backend +// FirestartrProviderConfig, and this method when you need the provider-only +// FirestartrProviderConfig. +func (c ConfigProvider) ToAzureProviderConfigJSON() string { + type mirror struct { + TenantId string `json:"tenant_id,omitempty"` + SubscriptionId string `json:"subscription_id,omitempty"` + ClientId string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + Features map[string]interface{} `json:"features"` + } + b, _ := json.Marshal(mirror{ + TenantId: c.TenantId, + SubscriptionId: c.SubscriptionId, + ClientId: c.ClientId, + ClientSecret: c.ClientSecret, + Features: map[string]interface{}{}, + }) + return string(b) +} + type Component struct { Name string `yaml:"name"` RepoName string `yaml:"repoName"` @@ -22,6 +99,13 @@ type Feature struct { Version string `yaml:"version"` } +// DeploymentModeSaaS and DeploymentModeDedicated are the two supported deployment modes. +const DeploymentModeSaaS = "saas" +const DeploymentModeDedicated = "dedicated" + +// DeploymentPlatformAKS is the hardcoded platform name for dedicated Azure deployments. +const DeploymentPlatformAKS = "firestartr-aks" + type Bootstrap struct { Env string `yaml:"env"` Firestartr Firestartr `yaml:"firestartr"` @@ -38,6 +122,8 @@ type Bootstrap struct { DefaultGroup string `yaml:"defaultGroup"` CreateWebhook bool `yaml:"createWebhook"` FinalSecretStoreName string `yaml:"finalSecretStoreName"` + DeploymentMode string `yaml:"deploymentMode"` // "saas" (default) or "dedicated" + Domain string `yaml:"domain"` // Base domain for dedicated deployments WebhookUrl string // Autocalculated WebhookSecretRef string // Autocalculated PrefappBotPatSecretRef string // Autocalculated @@ -45,6 +131,12 @@ type Bootstrap struct { HasFreePlan bool // Autocalculated } +// isDedicatedDeployment returns true when the bootstrap is configured for a +// dedicated (non-SaaS, currently Azure) deployment. +func (b *Bootstrap) isDedicatedDeployment() bool { + return b.DeploymentMode == DeploymentModeDedicated +} + type PushFiles struct { Claims PushFilesRepo `yaml:"claims"` Crs Crs `yaml:"crs"` @@ -86,12 +178,55 @@ type CloudProvider struct { Name string `yaml:"name"` } +// ImageFlavorSuffix returns the suffix used in the gitops-k8s image tag for this +// cloud provider. Azure is published as "az", not "azure". +func (cp CloudProvider) ImageFlavorSuffix() string { + if cp.Name == "azure" { + return "az" + } + return cp.Name +} + type ConfigProvider struct { - Bucket *string `json:"bucket" yaml:"bucket"` - Region string `json:"region" yaml:"region"` - AccessKey string `json:"access_key" yaml:"access_key"` - SecretKey string `json:"secret_key" yaml:"secret_key"` - Token string `json:"token" yaml:"token"` + // AWS fields + Bucket *string `json:"bucket,omitempty" yaml:"bucket"` + Region string `json:"region,omitempty" yaml:"region"` + AccessKey string `json:"access_key,omitempty" yaml:"access_key"` + SecretKey string `json:"secret_key,omitempty" yaml:"secret_key"` + Token string `json:"token,omitempty" yaml:"token"` + + // Azure fields + // ClientId is the client ID of the firestartr-mi User-Assigned Managed Identity. + // Used exclusively in the deployed AKS state via Workload Identity (no secret). + TenantId string `json:"tenant_id,omitempty" yaml:"tenant_id"` + SubscriptionId string `json:"subscription_id,omitempty" yaml:"subscription_id"` + ClientId string `json:"client_id,omitempty" yaml:"client_id"` + StorageAccountName string `json:"storage_account_name,omitempty" yaml:"storage_account_name"` + ContainerName string `json:"container_name,omitempty" yaml:"container_name"` + ResourceGroupName string `json:"resource_group_name,omitempty" yaml:"resource_group_name"` + KeyVaultName string `json:"key_vault_name,omitempty" yaml:"key_vault_name"` + // Location is the Azure region for the resource group (e.g. "westeurope"). + // Used when provisioning Azure resources (Managed Identities) via TFWorkspace. + Location string `json:"location,omitempty" yaml:"location"` + // AksOidcIssuerUrl is the OIDC issuer URL of the target AKS cluster. + // Required for configuring Workload Identity federated credentials on + // dedicated Managed Identities (e.g. the external-dns MI). + AksOidcIssuerUrl string `json:"aks_oidc_issuer_url,omitempty" yaml:"aks_oidc_issuer_url"` + // AksClusterName is the name of the target AKS cluster. + // Used in `az aks get-credentials` during ApplySysServicesWithValues. + AksClusterName string `json:"aks_cluster_name,omitempty" yaml:"aks_cluster_name"` + // Bootstrap identity fields — dedicated App Registration (Service Principal). + // Used only during bootstrap in the local kind cluster by ESO and Terraform. + // The entire App Registration must be deleted after bootstrap completes. + BootstrapClientId string `json:"bootstrap_client_id,omitempty" yaml:"bootstrap_client_id"` + BootstrapClientSecret string `json:"bootstrap_client_secret,omitempty" yaml:"bootstrap_client_secret"` + + // ClientSecret is an internal computed field populated at render time for dedicated + // deployments. It is set to BootstrapClientSecret before the FirestartrProviderConfig + // is serialised so the Terraform azurerm provider in the kind cluster receives the + // bootstrap SP credentials under the standard client_secret JSON key. + // It is never read from the credentials file. + ClientSecret string `json:"client_secret,omitempty" yaml:"-"` } type GithubApp struct { @@ -172,3 +307,36 @@ type ArgoCDConfig struct { Repo string Namespace string } + +// AzureDeploymentConfig holds the data used to render Azure-specific deployment +// templates (azure_values.tmpl, azure_tenant.tmpl, sys-service descriptors). +type AzureDeploymentConfig struct { + Customer string + Org string + OrgLowerCase string + Domain string + DeploymentPlatform string // Always "firestartr-aks" for dedicated + Webhook DeploymentWebhook + CloudProvider CloudProvider + Controller DeploymentController + // ExternalDnsClientId is the client ID of the dedicated external-dns Managed + // Identity. It is read from the kind cluster after the TFWorkspace CR has + // been provisioned. If empty, the main firestartr-mi ClientId is used. + ExternalDnsClientId string +} + +// InitialCrsData is the combined data struct passed to initial_crs.tmpl. +// It merges the relevant CredsFile fields (keeping the same template variable +// names) with Bootstrap fields that are needed for dedicated-mode CRs such as +// TFWorkspace claims. +type InitialCrsData struct { + // Preserved from CredsFile so existing template variables still work. + CloudProvider CloudProvider + GithubApp GithubApp + GithubAppOperator GithubApp + // Bootstrap fields available in dedicated-mode template blocks. + Customer string + Org string + DeploymentMode string + Domain string +} diff --git a/firestartr-bootstrap/validations.go b/firestartr-bootstrap/validations.go index ee64ab20..b9cf9d67 100644 --- a/firestartr-bootstrap/validations.go +++ b/firestartr-bootstrap/validations.go @@ -149,7 +149,7 @@ func (m *FirestartrBootstrap) ValidateExistenceOfNeededImages( "%s_full-%s", m.Bootstrap.Firestartr.OperatorVersion, - m.Creds.CloudProvider.Name, + m.Creds.CloudProvider.ImageFlavorSuffix(), )) err = validateExistenceOfImage(ctx, fullImage) @@ -218,8 +218,6 @@ func (m *FirestartrBootstrap) ValidateCliExistence( func (m *FirestartrBootstrap) ValidateOperatorPat( ctx context.Context, ) error { - owner := fmt.Sprintf("firestartr-%s", m.Bootstrap.Env) - repo := "app-firestartr" tokenSecret := dag.SetSecret( "token", m.Creds.GithubApp.OperatorPat, @@ -249,7 +247,15 @@ func (m *FirestartrBootstrap) ValidateOperatorPat( ) } - // --- Step 2: Check the repository permission for that user --- + // For dedicated deployments the target repos (state-sys-services, state-argocd) + // do not exist yet at validation time, so we only verify the token is valid. + if m.isDedicatedDeployment() { + return nil + } + + // SaaS path: verify write access to the shared app-firestartr repo. + owner := fmt.Sprintf("firestartr-%s", m.Bootstrap.Env) + repo := "app-firestartr" // API Endpoint: GET /repos/:owner/:repo/collaborators/:username/permission permissionURL := fmt.Sprintf(