diff --git a/.github/actions/create-kind-cluster/action.yaml b/.github/actions/create-kind-cluster/action.yaml index 530ba02f05..bfc4152f0d 100644 --- a/.github/actions/create-kind-cluster/action.yaml +++ b/.github/actions/create-kind-cluster/action.yaml @@ -169,3 +169,18 @@ runs: host: "${{ inputs.registry-name }}:${{ inputs.registry-port }}" help: "https://kind.sigs.k8s.io/docs/user/local-registry/" EOF + + - name: Install NFS provisioner for ReadWriteMany PVC support + shell: bash + run: | + # Add NFS provisioner Helm repo (suppress "already exists" errors, but update regardless) + helm repo add nfs-ganesha-server-and-external-provisioner \ + https://kubernetes-sigs.github.io/nfs-ganesha-server-and-external-provisioner/ 2>/dev/null || true + helm repo update nfs-ganesha-server-and-external-provisioner + + # Install NFS provisioner with RWX support as default storage class + helm install nfs-server nfs-ganesha-server-and-external-provisioner/nfs-server-provisioner \ + --set persistence.enabled=false \ + --set storageClass.name=nfs \ + --set storageClass.defaultClass=true \ + --wait --timeout 5m diff --git a/.github/workflows/functional-test-cloud.yaml b/.github/workflows/functional-test-cloud.yaml index cc0952af33..a68228da76 100644 --- a/.github/workflows/functional-test-cloud.yaml +++ b/.github/workflows/functional-test-cloud.yaml @@ -664,6 +664,18 @@ jobs: service-account-private-key-file: /etc/kubernetes/pki/sa.key EOF + - name: Install NFS provisioner for ReadWriteMany PVC support + run: | + helm repo add nfs-ganesha-server-and-external-provisioner \ + https://kubernetes-sigs.github.io/nfs-ganesha-server-and-external-provisioner/ 2>/dev/null || true + helm repo update nfs-ganesha-server-and-external-provisioner + + helm install nfs-server nfs-ganesha-server-and-external-provisioner/nfs-server-provisioner \ + --set persistence.enabled=false \ + --set storageClass.name=nfs \ + --set storageClass.defaultClass=true \ + --wait --timeout 5m + - name: Install Azure Keyvault CSI driver chart run: | helm repo add csi-secrets-store-provider-azure https://azure.github.io/secrets-store-csi-driver-provider-azure/charts diff --git a/.gitignore b/.gitignore index 91384966ef..5e3c4ea542 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ demo .copilot-tracking/ .codeql-results + +# Go Cache +.gocache/ \ No newline at end of file diff --git a/build/configs/ucp.yaml b/build/configs/ucp.yaml index bdaa41b812..34ec79daf1 100644 --- a/build/configs/ucp.yaml +++ b/build/configs/ucp.yaml @@ -63,6 +63,10 @@ logging: level: "debug" json: true +# Terraform cache path - relative to project root where debug commands are run +terraform: + path: "./debug_files/terraform-cache" + tracerProvider: enabled: false serviceName: "ucp" diff --git a/cmd/applications-rp/cmd/root.go b/cmd/applications-rp/cmd/root.go index 2dc7bc5929..27e6c8b3e8 100644 --- a/cmd/applications-rp/cmd/root.go +++ b/cmd/applications-rp/cmd/root.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + "github.com/go-chi/chi/v5" "github.com/go-logr/logr" "github.com/spf13/cobra" runtimelog "sigs.k8s.io/controller-runtime/pkg/log" @@ -31,6 +32,7 @@ import ( "github.com/radius-project/radius/pkg/components/trace/traceservice" "github.com/radius-project/radius/pkg/recipes/controllerconfig" "github.com/radius-project/radius/pkg/server" + tfinstaller "github.com/radius-project/radius/pkg/terraform/installer" "github.com/radius-project/radius/pkg/components/hosting" "github.com/radius-project/radius/pkg/ucp/ucplog" @@ -81,10 +83,16 @@ var rootCmd = &cobra.Command{ return err } + // Create route configurer for terraform installer API endpoints + terraformRoutes := func(ctx context.Context, r chi.Router, opts hostoptions.HostOptions) error { + return tfinstaller.RegisterRoutesWithHostOptions(ctx, r, opts, opts.Config.Server.PathBase) + } + services = append( services, - server.NewAPIService(options, builders), + server.NewAPIServiceWithRoutes(options, builders, terraformRoutes), server.NewAsyncWorker(options, builders), + tfinstaller.NewHostOptionsWorkerService(options), ) host := &hosting.Host{ diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index 0413ab5d23..ac24ba485d 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -80,6 +80,11 @@ import ( "github.com/radius-project/radius/pkg/cli/cmd/rollback" rollback_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/rollback/kubernetes" "github.com/radius-project/radius/pkg/cli/cmd/run" + "github.com/radius-project/radius/pkg/cli/cmd/terraform" + terraform_install "github.com/radius-project/radius/pkg/cli/cmd/terraform/install" + terraform_list "github.com/radius-project/radius/pkg/cli/cmd/terraform/list" + terraform_status "github.com/radius-project/radius/pkg/cli/cmd/terraform/status" + terraform_uninstall "github.com/radius-project/radius/pkg/cli/cmd/terraform/uninstall" "github.com/radius-project/radius/pkg/cli/cmd/uninstall" uninstall_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/uninstall/kubernetes" "github.com/radius-project/radius/pkg/cli/cmd/upgrade" @@ -452,6 +457,21 @@ func initSubCommands() { versionCmd, _ := version.NewCommand(framework) RootCmd.AddCommand(versionCmd) + + terraformCmd := terraform.NewCommand() + RootCmd.AddCommand(terraformCmd) + + terraformInstallCmd, _ := terraform_install.NewCommand(framework) + terraformCmd.AddCommand(terraformInstallCmd) + + terraformUninstallCmd, _ := terraform_uninstall.NewCommand(framework) + terraformCmd.AddCommand(terraformUninstallCmd) + + terraformStatusCmd, _ := terraform_status.NewCommand(framework) + terraformCmd.AddCommand(terraformStatusCmd) + + terraformListCmd, _ := terraform_list.NewCommand(framework) + terraformCmd.AddCommand(terraformListCmd) } // The dance we do with config is kinda complex. We want commands to be able to retrieve a config (*viper.Viper) diff --git a/deploy/Chart/templates/dynamic-rp/deployment.yaml b/deploy/Chart/templates/dynamic-rp/deployment.yaml index 3b7d0b9d8a..b554f1f7a5 100644 --- a/deploy/Chart/templates/dynamic-rp/deployment.yaml +++ b/deploy/Chart/templates/dynamic-rp/deployment.yaml @@ -124,8 +124,10 @@ spec: echo "Terraform binary successfully pre-downloaded and installed" volumeMounts: + {{- if eq .Values.global.terraform.enabled true }} - name: terraform mountPath: {{ .Values.dynamicrp.terraform.path }} + {{- end }} securityContext: allowPrivilegeEscalation: false runAsNonRoot: true @@ -167,8 +169,10 @@ spec: - name: aws-iam-token mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount {{- end }} + {{- if eq .Values.global.terraform.enabled true }} - name: terraform mountPath: {{ .Values.dynamicrp.terraform.path }} + {{- end }} - name: encryption-secret mountPath: /var/secrets/encryption readOnly: true @@ -198,8 +202,11 @@ spec: expirationSeconds: 86400 audience: "sts.amazonaws.com" {{- end }} + {{- if eq .Values.global.terraform.enabled true }} - name: terraform - emptyDir: {} + persistentVolumeClaim: + claimName: terraform-storage + {{- end }} - name: encryption-secret secret: secretName: radius-encryption-key diff --git a/deploy/Chart/templates/rp/deployment.yaml b/deploy/Chart/templates/rp/deployment.yaml index b03f6d91f4..a2be184bb4 100644 --- a/deploy/Chart/templates/rp/deployment.yaml +++ b/deploy/Chart/templates/rp/deployment.yaml @@ -130,8 +130,10 @@ spec: echo "Terraform binary successfully pre-downloaded and installed" volumeMounts: + {{- if eq .Values.global.terraform.enabled true }} - name: terraform mountPath: {{ .Values.rp.terraform.path }} + {{- end }} securityContext: allowPrivilegeEscalation: false runAsNonRoot: true @@ -180,8 +182,10 @@ spec: - name: aws-iam-token mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount {{- end }} + {{- if eq .Values.global.terraform.enabled true }} - name: terraform mountPath: {{ .Values.rp.terraform.path }} + {{- end }} {{- if .Values.global.rootCA.cert }} - name: {{ .Values.global.rootCA.volumeName }} mountPath: {{ .Values.global.rootCA.mountPath }} @@ -208,8 +212,11 @@ spec: expirationSeconds: 86400 audience: "sts.amazonaws.com" {{- end }} + {{- if eq .Values.global.terraform.enabled true }} - name: terraform - emptyDir: {} + persistentVolumeClaim: + claimName: terraform-storage + {{- end }} {{- if .Values.global.rootCA.cert }} - name: {{ .Values.global.rootCA.volumeName }} secret: diff --git a/deploy/Chart/templates/terraform-pvc.yaml b/deploy/Chart/templates/terraform-pvc.yaml new file mode 100644 index 0000000000..f62ccba441 --- /dev/null +++ b/deploy/Chart/templates/terraform-pvc.yaml @@ -0,0 +1,19 @@ +{{- if .Values.global.terraform.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: terraform-storage + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: terraform-storage + app.kubernetes.io/part-of: radius +spec: + accessModes: + - ReadWriteMany + {{- if .Values.global.terraform.storageClassName }} + storageClassName: {{ .Values.global.terraform.storageClassName | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.global.terraform.storageSize | default "1Gi" }} +{{- end }} diff --git a/deploy/Chart/tests/terraform_test.yaml b/deploy/Chart/tests/terraform_test.yaml new file mode 100644 index 0000000000..ba8b75aa46 --- /dev/null +++ b/deploy/Chart/tests/terraform_test.yaml @@ -0,0 +1,166 @@ +suite: test terraform configuration +templates: + - rp/deployment.yaml + - rp/configmaps.yaml + - dynamic-rp/deployment.yaml + - terraform-pvc.yaml +tests: + # Terraform PVC tests + - it: should create terraform PVC when terraform is enabled + set: + global.terraform.enabled: true + asserts: + - isKind: + of: PersistentVolumeClaim + template: terraform-pvc.yaml + - equal: + path: metadata.name + value: terraform-storage + template: terraform-pvc.yaml + - contains: + path: spec.accessModes + content: ReadWriteMany + template: terraform-pvc.yaml + + - it: should use custom storage class when specified + set: + global.terraform.enabled: true + global.terraform.storageClassName: "nfs" + asserts: + - equal: + path: spec.storageClassName + value: "nfs" + template: terraform-pvc.yaml + + - it: should use custom storage size when specified + set: + global.terraform.enabled: true + global.terraform.storageSize: "5Gi" + asserts: + - equal: + path: spec.resources.requests.storage + value: "5Gi" + template: terraform-pvc.yaml + + # applications-rp terraform volume tests + - it: should use PVC for terraform volume in applications-rp when terraform is enabled + set: + global.terraform.enabled: true + rp.image: applications-rp + rp.tag: latest + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: terraform + persistentVolumeClaim: + claimName: terraform-storage + template: rp/deployment.yaml + + - it: should mount terraform volume in applications-rp container + set: + global.terraform.enabled: true + rp.image: applications-rp + rp.tag: latest + rp.terraform.path: /terraform + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: terraform + mountPath: /terraform + template: rp/deployment.yaml + + - it: should include terraform init container when terraform is enabled + set: + global.terraform.enabled: true + rp.image: applications-rp + rp.tag: latest + asserts: + - isNotEmpty: + path: spec.template.spec.initContainers + template: rp/deployment.yaml + - contains: + path: spec.template.spec.initContainers + content: + name: terraform-init + any: true + template: rp/deployment.yaml + + - it: should include terraform config in applications-rp configmap + asserts: + - matchRegex: + path: data["radius-self-host.yaml"] + pattern: "terraform:\\s+path: \"/terraform\"" + template: rp/configmaps.yaml + + # dynamic-rp terraform volume tests + - it: should use PVC for terraform volume in dynamic-rp when terraform is enabled + set: + global.terraform.enabled: true + dynamicrp.image: dynamic-rp + dynamicrp.tag: latest + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: terraform + persistentVolumeClaim: + claimName: terraform-storage + template: dynamic-rp/deployment.yaml + + - it: should mount terraform volume in dynamic-rp container + set: + global.terraform.enabled: true + dynamicrp.image: dynamic-rp + dynamicrp.tag: latest + dynamicrp.terraform.path: /terraform + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: terraform + mountPath: /terraform + template: dynamic-rp/deployment.yaml + + - it: should include terraform init container in dynamic-rp when terraform is enabled + set: + global.terraform.enabled: true + dynamicrp.image: dynamic-rp + dynamicrp.tag: latest + asserts: + - isNotEmpty: + path: spec.template.spec.initContainers + template: dynamic-rp/deployment.yaml + - contains: + path: spec.template.spec.initContainers + content: + name: terraform-init + any: true + template: dynamic-rp/deployment.yaml + + # Both deployments use the same shared PVC + - it: should use shared PVC for both deployments + set: + global.terraform.enabled: true + rp.image: applications-rp + rp.tag: latest + dynamicrp.image: dynamic-rp + dynamicrp.tag: latest + asserts: + # applications-rp uses shared PVC + - contains: + path: spec.template.spec.volumes + content: + name: terraform + persistentVolumeClaim: + claimName: terraform-storage + template: rp/deployment.yaml + # dynamic-rp uses same shared PVC + - contains: + path: spec.template.spec.volumes + content: + name: terraform + persistentVolumeClaim: + claimName: terraform-storage + template: dynamic-rp/deployment.yaml diff --git a/deploy/Chart/values.yaml b/deploy/Chart/values.yaml index b0492c7599..943552e2b5 100644 --- a/deploy/Chart/values.yaml +++ b/deploy/Chart/values.yaml @@ -70,6 +70,13 @@ global: # Valid values: TRACE, DEBUG, INFO, WARN, ERROR, OFF # Default: ERROR loglevel: "ERROR" + # Storage size for shared terraform PVC (used by UCP installer and recipe execution) + # This PVC stores installed Terraform versions managed via `rad terraform install` + storageSize: "1Gi" + # Storage class name for the terraform PVC + # Leave empty to use the default storage class + # For ReadWriteMany access, use a storage class that supports it (e.g., NFS, EFS, Azure Files) + storageClassName: "" controller: image: controller diff --git a/docs/ucp/readme.md b/docs/ucp/readme.md index 172a7be60f..7e55df6b6c 100644 --- a/docs/ucp/readme.md +++ b/docs/ucp/readme.md @@ -2,14 +2,15 @@ This folder contains documentation for the Universal Control Plane (UCP). -| Topic | Description | -|-------|-------------| -|**[Overview](overview.md)** | What is UCP and why is it needed? -|**[UCP Resources](resources.md)** | List of UCP resources -|**[Addressing Scheme](addressing_scheme.md)** | Learn about UCP addresses -|**[UCP Config](configuration.md)** | Learn about UCP configuration -|**[Call Flows](call_flows.md)** | Learn the different call flows with UCP for common deployment scenarios -|**[AWS Support](aws.md)** | Details of AWS Support in UCP -|**[Developer Guide](developer_guide.md)** | Developer Guide -|**[Code Walkthrough](code_walkthrough.md)** | A broad overview of the code. -|**[References](references.md)** | References for further reading +| Topic | Description | +| ----------------------------------------------------------- | ----------------------------------------------------------------------- | +| **[Overview](overview.md)** | What is UCP and why is it needed? | +| **[UCP Resources](resources.md)** | List of UCP resources | +| **[Addressing Scheme](addressing_scheme.md)** | Learn about UCP addresses | +| **[UCP Config](configuration.md)** | Learn about UCP configuration | +| **[Call Flows](call_flows.md)** | Learn the different call flows with UCP for common deployment scenarios | +| **[AWS Support](aws.md)** | Details of AWS Support in UCP | +| **[Developer Guide](developer_guide.md)** | Developer Guide | +| **[Code Walkthrough](code_walkthrough.md)** | A broad overview of the code. | +| **[References](references.md)** | References for further reading | +| **[Terraform Installer](terraform/terraform-installer.md)** | API for installing/uninstalling Terraform binaries | diff --git a/docs/ucp/terraform/terraform-installer.md b/docs/ucp/terraform/terraform-installer.md new file mode 100644 index 0000000000..0e0bfbf1f1 --- /dev/null +++ b/docs/ucp/terraform/terraform-installer.md @@ -0,0 +1,115 @@ +# Terraform Installer API (Radius) + +## Endpoints + +| Method | Path | Description | +| ------ | -------------------------------- | ----------------------------- | +| `POST` | `/installer/terraform/install` | Install a Terraform version | +| `POST` | `/installer/terraform/uninstall` | Uninstall a Terraform version | +| `GET` | `/installer/terraform/status` | Get installer status | + +## Install Request + +Provide **either** `version` or `sourceUrl` (or both): + +```json +{ + "version": "1.6.4", + "sourceUrl": "https://example.com/terraform.zip", + "checksum": "sha256:abc123...", + "caBundle": "", + "authHeader": "Bearer ", + "clientCert": "", + "clientKey": "", + "proxyUrl": "http://proxy:8080" +} +``` + +| Field | Required | Description | +| ------------ | ------------------------ | ------------------------------------------------------------------------- | +| `version` | One of version/sourceUrl | Semver version (e.g., `1.6.4`, `1.6.4-beta.1`) | +| `sourceUrl` | One of version/sourceUrl | Direct download URL for Terraform archive | +| `checksum` | Recommended | SHA256 checksum (`sha256:` or bare hex) | +| `caBundle` | No | PEM-encoded CA cert for self-signed TLS (requires `sourceUrl`) | +| `authHeader` | No | Authorization header for private registries (requires `sourceUrl`) | +| `clientCert` | No | PEM-encoded client cert for mTLS (requires `sourceUrl` and `clientKey`) | +| `clientKey` | No | PEM-encoded client private key for mTLS (requires `sourceUrl` and `clientCert`) | +| `proxyUrl` | No | HTTP/HTTPS proxy URL (requires `sourceUrl`) | + +**Notes:** + +- If only `sourceUrl` is provided (no version), a version identifier is auto-generated from the URL hash (e.g., `custom-a1b2c3d4`) +- Bare hex checksums are also accepted (without `sha256:` prefix) +- Idempotent: re-installing an existing version promotes it to current without re-downloading + +**Private Registry Options:** + +- All private registry options (`caBundle`, `authHeader`, `clientCert`, `clientKey`, `proxyUrl`) require `sourceUrl` +- `clientCert` and `clientKey` must be specified together for mTLS +- `proxyUrl` must use `http://` or `https://` scheme + +## Uninstall Request + +```json +{ + "version": "1.6.4", + "purge": false +} +``` + +| Field | Required | Description | +| --------- | -------- | ------------------------------------------------------------------ | +| `version` | No | Version to uninstall (defaults to current version if omitted) | +| `purge` | No | Remove version metadata from database (default: false, keep audit) | + +**Notes:** + +- Uninstalling the current version switches to the previous version (if available) or clears current +- Blocked if Terraform executions are in progress (when `ExecutionChecker` is configured) +- When `purge: false` (default), version metadata remains with state `Uninstalled` for audit purposes +- When `purge: true`, version metadata is deleted from the database entirely + +## Status Response + +```json +{ + "currentVersion": "1.6.4", + "state": "ready", + "binaryPath": "/terraform/versions/1.6.4/terraform", + "installedAt": "2025-01-06T10:30:00Z", + "source": { + "url": "https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip", + "checksum": "sha256:abc123..." + }, + "queue": { + "pending": 0, + "inProgress": null + }, + "versions": { ... }, + "history": [ ... ], + "lastError": "", + "lastUpdated": "2025-01-06T10:30:00Z" +} +``` + +| State | Description | +| --------------- | --------------------------------------- | +| `not-installed` | No Terraform version installed | +| `installing` | Installation in progress | +| `ready` | Terraform installed and ready | +| `uninstalling` | Uninstallation in progress | +| `failed` | Last operation failed (see `lastError`) | + +## Configuration + +| Config Key | Description | Default | +| ------------------------- | ------------------------------------------------- | -------------------------------- | +| `terraform.path` | Root directory for Terraform installations | `/terraform` | +| `terraform.sourceBaseUrl` | Mirror/base URL for downloads (air-gapped setups) | `https://releases.hashicorp.com` | + +## Behavior + +- **Concurrency:** Only one install/uninstall runs at a time; concurrent requests receive `installer is busy` +- **Archive Detection:** Supports both ZIP archives and plain binaries (detected via magic bytes) +- **Cleanup:** Downloaded archives are automatically removed after extraction +- **Symlink:** Current version is symlinked at `{terraform.path}/current` diff --git a/hack/bicep-types-radius/generated/index.json b/hack/bicep-types-radius/generated/index.json index f6068516a6..0b2714a24f 100644 --- a/hack/bicep-types-radius/generated/index.json +++ b/hack/bicep-types-radius/generated/index.json @@ -48,11 +48,17 @@ "Radius.Core/applications@2025-08-01-preview": { "$ref": "radius/radius.core/2025-08-01-preview/types.json#/44" }, + "Radius.Core/bicepSettings@2025-08-01-preview": { + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/66" + }, "Radius.Core/environments@2025-08-01-preview": { - "$ref": "radius/radius.core/2025-08-01-preview/types.json#/67" + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/89" }, "Radius.Core/recipePacks@2025-08-01-preview": { - "$ref": "radius/radius.core/2025-08-01-preview/types.json#/89" + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/111" + }, + "Radius.Core/terraformSettings@2025-08-01-preview": { + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/147" } }, "resourceFunctions": {}, diff --git a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json index 7b95e10918..68cb18f713 100644 --- a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json +++ b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json @@ -541,7 +541,7 @@ }, { "$type": "StringLiteralType", - "value": "Radius.Core/environments" + "value": "Radius.Core/bicepSettings" }, { "$type": "StringLiteralType", @@ -549,7 +549,7 @@ }, { "$type": "ObjectType", - "name": "Radius.Core/environments", + "name": "Radius.Core/bicepSettings", "properties": { "id": { "type": { @@ -584,11 +584,11 @@ "$ref": "#/48" }, "flags": 1, - "description": "Environment properties" + "description": "Bicep settings properties." }, "tags": { "type": { - "$ref": "#/66" + "$ref": "#/65" }, "flags": 0, "description": "Resource tags." @@ -611,7 +611,7 @@ }, { "$type": "ObjectType", - "name": "EnvironmentProperties", + "name": "BicepSettingsProperties", "properties": { "provisioningState": { "type": { @@ -620,32 +620,12 @@ "flags": 2, "description": "Provisioning state of the resource at the time the operation was called" }, - "recipePacks": { + "authentication": { "type": { "$ref": "#/58" }, "flags": 0, - "description": "List of Recipe Pack resource IDs linked to this environment." - }, - "recipeParameters": { - "type": { - "$ref": "#/61" - }, - "flags": 0, - "description": "Recipe specific parameters that apply to all resources of a given type in this environment." - }, - "providers": { - "type": { - "$ref": "#/62" - }, - "flags": 0 - }, - "simulated": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Simulated environment." + "description": "Authentication configuration for Bicep registries." } } }, @@ -710,117 +690,141 @@ } ] }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "AnyType" - }, - { - "$type": "ObjectType", - "name": "RecipeParameterValue", - "properties": {}, - "additionalProperties": { - "$ref": "#/59" - } - }, { "$type": "ObjectType", - "name": "EnvironmentPropertiesRecipeParameters", - "properties": {}, - "additionalProperties": { - "$ref": "#/60" + "name": "BicepAuthenticationConfiguration", + "properties": { + "registries": { + "type": { + "$ref": "#/64" + }, + "flags": 0, + "description": "Authentication entries keyed by registry hostname." + } } }, { "$type": "ObjectType", - "name": "Providers", + "name": "BicepRegistryAuthentication", "properties": { - "azure": { + "basic": { "type": { - "$ref": "#/63" + "$ref": "#/60" }, "flags": 0, - "description": "The Azure cloud provider definition." + "description": "Basic authentication configuration for a Bicep registry." }, - "kubernetes": { + "azureWorkloadIdentity": { "type": { - "$ref": "#/64" + "$ref": "#/62" }, - "flags": 0 + "flags": 0, + "description": "Azure Workload Identity configuration for a Bicep registry." }, - "aws": { + "awsIrsa": { "type": { - "$ref": "#/65" + "$ref": "#/63" }, "flags": 0, - "description": "The AWS cloud provider definition." + "description": "AWS IRSA configuration for a Bicep registry." } } }, { "$type": "ObjectType", - "name": "ProvidersAzure", + "name": "BicepBasicAuthentication", "properties": { - "subscriptionId": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Azure subscription ID hosting deployed resources." - }, - "resourceGroupName": { + "username": { "type": { "$ref": "#/0" }, "flags": 0, - "description": "Optional resource group name." + "description": "Username for basic authentication." }, - "identity": { + "password": { "type": { - "$ref": "#/16" + "$ref": "#/61" }, "flags": 0, - "description": "IdentitySettings is the external identity setting." + "description": "Reference to a secret stored in Radius.Security/secrets." } } }, { "$type": "ObjectType", - "name": "ProvidersKubernetes", + "name": "SecretReference", "properties": { - "namespace": { + "secretId": { "type": { "$ref": "#/0" }, "flags": 1, - "description": "Kubernetes namespace to deploy workloads into." + "description": "Resource ID of the Radius.Security/secrets entry." + }, + "key": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Key within the secret to retrieve." } } }, { "$type": "ObjectType", - "name": "ProvidersAws", + "name": "BicepAzureWorkloadIdentityAuthentication", "properties": { - "accountId": { + "clientId": { "type": { "$ref": "#/0" }, - "flags": 1, - "description": "AWS account ID for AWS resources to be deployed into." + "flags": 0, + "description": "Client ID used for Azure Workload Identity." }, - "region": { + "tenantId": { "type": { "$ref": "#/0" }, - "flags": 1, - "description": "AWS region for AWS resources to be deployed into." + "flags": 0, + "description": "Tenant ID used for Azure Workload Identity." + }, + "token": { + "type": { + "$ref": "#/61" + }, + "flags": 0, + "description": "Reference to a secret stored in Radius.Security/secrets." + } + } + }, + { + "$type": "ObjectType", + "name": "BicepAwsIrsaAuthentication", + "properties": { + "roleArn": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "ARN of the AWS IAM role used for IRSA." + }, + "token": { + "type": { + "$ref": "#/61" + }, + "flags": 0, + "description": "Reference to a secret stored in Radius.Security/secrets." } } }, + { + "$type": "ObjectType", + "name": "BicepAuthenticationConfigurationRegistries", + "properties": {}, + "additionalProperties": { + "$ref": "#/59" + } + }, { "$type": "ObjectType", "name": "TrackedResourceTags", @@ -831,7 +835,7 @@ }, { "$type": "ResourceType", - "name": "Radius.Core/environments@2025-08-01-preview", + "name": "Radius.Core/bicepSettings@2025-08-01-preview", "body": { "$ref": "#/47" }, @@ -841,7 +845,7 @@ }, { "$type": "StringLiteralType", - "value": "Radius.Core/recipePacks" + "value": "Radius.Core/environments" }, { "$type": "StringLiteralType", @@ -849,7 +853,7 @@ }, { "$type": "ObjectType", - "name": "Radius.Core/recipePacks", + "name": "Radius.Core/environments", "properties": { "id": { "type": { @@ -867,24 +871,24 @@ }, "type": { "type": { - "$ref": "#/68" + "$ref": "#/67" }, "flags": 10, "description": "The resource type" }, "apiVersion": { "type": { - "$ref": "#/69" + "$ref": "#/68" }, "flags": 10, "description": "The resource api version" }, "properties": { "type": { - "$ref": "#/71" + "$ref": "#/70" }, "flags": 1, - "description": "Recipe Pack properties" + "description": "Environment properties" }, "tags": { "type": { @@ -911,28 +915,55 @@ }, { "$type": "ObjectType", - "name": "RecipePackProperties", + "name": "EnvironmentProperties", "properties": { "provisioningState": { "type": { - "$ref": "#/80" + "$ref": "#/79" }, "flags": 2, "description": "Provisioning state of the resource at the time the operation was called" }, - "referencedBy": { + "terraformSettings": { "type": { - "$ref": "#/81" + "$ref": "#/0" }, - "flags": 2, - "description": "List of environment IDs that reference this recipe pack" + "flags": 0, + "description": "Resource ID of the Terraform settings applied to this environment." }, - "recipes": { + "bicepSettings": { "type": { - "$ref": "#/87" + "$ref": "#/0" }, - "flags": 1, - "description": "Map of resource types to their recipe configurations" + "flags": 0, + "description": "Resource ID of the Bicep settings applied to this environment." + }, + "recipePacks": { + "type": { + "$ref": "#/80" + }, + "flags": 0, + "description": "List of Recipe Pack resource IDs linked to this environment." + }, + "recipeParameters": { + "type": { + "$ref": "#/83" + }, + "flags": 0, + "description": "Recipe specific parameters that apply to all resources of a given type in this environment." + }, + "providers": { + "type": { + "$ref": "#/84" + }, + "flags": 0 + }, + "simulated": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Simulated environment." } } }, @@ -971,6 +1002,9 @@ { "$type": "UnionType", "elements": [ + { + "$ref": "#/71" + }, { "$ref": "#/72" }, @@ -991,9 +1025,6 @@ }, { "$ref": "#/78" - }, - { - "$ref": "#/79" } ] }, @@ -1003,56 +1034,343 @@ "$ref": "#/0" } }, + { + "$type": "AnyType" + }, { "$type": "ObjectType", - "name": "RecipeDefinition", + "name": "RecipeParameterValue", + "properties": {}, + "additionalProperties": { + "$ref": "#/81" + } + }, + { + "$type": "ObjectType", + "name": "EnvironmentPropertiesRecipeParameters", + "properties": {}, + "additionalProperties": { + "$ref": "#/82" + } + }, + { + "$type": "ObjectType", + "name": "Providers", "properties": { - "recipeKind": { + "azure": { "type": { "$ref": "#/85" }, - "flags": 1, - "description": "The type of recipe" + "flags": 0, + "description": "The Azure cloud provider definition." }, - "plainHttp": { + "kubernetes": { "type": { - "$ref": "#/30" + "$ref": "#/86" }, - "flags": 0, - "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" + "flags": 0 }, - "recipeLocation": { + "aws": { + "type": { + "$ref": "#/87" + }, + "flags": 0, + "description": "The AWS cloud provider definition." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAzure", + "properties": { + "subscriptionId": { "type": { "$ref": "#/0" }, "flags": 1, - "description": "URL path to the recipe" + "description": "Azure subscription ID hosting deployed resources." }, - "parameters": { + "resourceGroupName": { "type": { - "$ref": "#/86" + "$ref": "#/0" }, "flags": 0, - "description": "Parameters to pass to the recipe" + "description": "Optional resource group name." + }, + "identity": { + "type": { + "$ref": "#/16" + }, + "flags": 0, + "description": "IdentitySettings is the external identity setting." } } }, { - "$type": "StringLiteralType", - "value": "terraform" - }, - { - "$type": "StringLiteralType", - "value": "bicep" + "$type": "ObjectType", + "name": "ProvidersKubernetes", + "properties": { + "namespace": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Kubernetes namespace to deploy workloads into." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAws", + "properties": { + "accountId": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS account ID for AWS resources to be deployed into." + }, + "region": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS region for AWS resources to be deployed into." + } + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/environments@2025-08-01-preview", + "body": { + "$ref": "#/69" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/recipePacks" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/recipePacks", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/90" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/91" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/93" + }, + "flags": 1, + "description": "Recipe Pack properties" + }, + "tags": { + "type": { + "$ref": "#/110" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "RecipePackProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/102" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/103" + }, + "flags": 2, + "description": "List of environment IDs that reference this recipe pack" + }, + "recipes": { + "type": { + "$ref": "#/109" + }, + "flags": 1, + "description": "Map of resource types to their recipe configurations" + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/94" + }, + { + "$ref": "#/95" + }, + { + "$ref": "#/96" + }, + { + "$ref": "#/97" + }, + { + "$ref": "#/98" + }, + { + "$ref": "#/99" + }, + { + "$ref": "#/100" + }, + { + "$ref": "#/101" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "RecipeDefinition", + "properties": { + "recipeKind": { + "type": { + "$ref": "#/107" + }, + "flags": 1, + "description": "The type of recipe" + }, + "plainHttp": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" + }, + "recipeLocation": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "URL path to the recipe" + }, + "parameters": { + "type": { + "$ref": "#/108" + }, + "flags": 0, + "description": "Parameters to pass to the recipe" + } + } + }, + { + "$type": "StringLiteralType", + "value": "terraform" + }, + { + "$type": "StringLiteralType", + "value": "bicep" }, { "$type": "UnionType", "elements": [ { - "$ref": "#/83" + "$ref": "#/105" }, { - "$ref": "#/84" + "$ref": "#/106" } ] }, @@ -1061,7 +1379,7 @@ "name": "RecipeDefinitionParameters", "properties": {}, "additionalProperties": { - "$ref": "#/59" + "$ref": "#/81" } }, { @@ -1069,7 +1387,7 @@ "name": "RecipePackPropertiesRecipes", "properties": {}, "additionalProperties": { - "$ref": "#/82" + "$ref": "#/104" } }, { @@ -1084,7 +1402,432 @@ "$type": "ResourceType", "name": "Radius.Core/recipePacks@2025-08-01-preview", "body": { - "$ref": "#/70" + "$ref": "#/92" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/terraformSettings" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/terraformSettings", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/112" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/113" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/115" + }, + "flags": 1, + "description": "Terraform settings properties." + }, + "tags": { + "type": { + "$ref": "#/146" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformSettingsProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/124" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "terraformrc": { + "type": { + "$ref": "#/125" + }, + "flags": 0, + "description": "Terraform CLI configuration matching the terraformrc file." + }, + "backend": { + "type": { + "$ref": "#/135" + }, + "flags": 0, + "description": "Terraform backend configuration matching the terraform block." + }, + "env": { + "type": { + "$ref": "#/137" + }, + "flags": 0, + "description": "Environment variables injected into the Terraform process." + }, + "logging": { + "type": { + "$ref": "#/138" + }, + "flags": 0, + "description": "Logging options for Terraform executions." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/116" + }, + { + "$ref": "#/117" + }, + { + "$ref": "#/118" + }, + { + "$ref": "#/119" + }, + { + "$ref": "#/120" + }, + { + "$ref": "#/121" + }, + { + "$ref": "#/122" + }, + { + "$ref": "#/123" + } + ] + }, + { + "$type": "ObjectType", + "name": "TerraformCliConfiguration", + "properties": { + "providerInstallation": { + "type": { + "$ref": "#/126" + }, + "flags": 0, + "description": "Provider installation options for Terraform." + }, + "credentials": { + "type": { + "$ref": "#/134" + }, + "flags": 0, + "description": "Credentials keyed by registry or module source hostname." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderInstallationConfiguration", + "properties": { + "networkMirror": { + "type": { + "$ref": "#/127" + }, + "flags": 0, + "description": "Network mirror configuration for Terraform providers." + }, + "direct": { + "type": { + "$ref": "#/130" + }, + "flags": 0, + "description": "Direct installation configuration for Terraform providers." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformNetworkMirrorConfiguration", + "properties": { + "url": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Mirror URL used to download providers." + }, + "include": { + "type": { + "$ref": "#/128" + }, + "flags": 0, + "description": "Provider addresses included in this mirror." + }, + "exclude": { + "type": { + "$ref": "#/129" + }, + "flags": 0, + "description": "Provider addresses excluded from this mirror." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformDirectConfiguration", + "properties": { + "include": { + "type": { + "$ref": "#/131" + }, + "flags": 0, + "description": "Provider addresses included when falling back to direct installation." + }, + "exclude": { + "type": { + "$ref": "#/132" + }, + "flags": 0, + "description": "Provider addresses excluded from direct installation." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformCredentialConfiguration", + "properties": { + "token": { + "type": { + "$ref": "#/61" + }, + "flags": 0, + "description": "Reference to a secret stored in Radius.Security/secrets." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformCliConfigurationCredentials", + "properties": {}, + "additionalProperties": { + "$ref": "#/133" + } + }, + { + "$type": "ObjectType", + "name": "TerraformBackendConfiguration", + "properties": { + "type": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Backend type (for example 'kubernetes')." + }, + "config": { + "type": { + "$ref": "#/136" + }, + "flags": 0, + "description": "Backend-specific configuration values." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformBackendConfigurationConfig", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformSettingsPropertiesEnv", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformLoggingConfiguration", + "properties": { + "level": { + "type": { + "$ref": "#/145" + }, + "flags": 0, + "description": "Terraform log verbosity levels." + }, + "path": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Destination file path for Terraform logs (maps to TF_LOG_PATH)." + } + } + }, + { + "$type": "StringLiteralType", + "value": "TRACE" + }, + { + "$type": "StringLiteralType", + "value": "DEBUG" + }, + { + "$type": "StringLiteralType", + "value": "INFO" + }, + { + "$type": "StringLiteralType", + "value": "WARN" + }, + { + "$type": "StringLiteralType", + "value": "ERROR" + }, + { + "$type": "StringLiteralType", + "value": "FATAL" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/139" + }, + { + "$ref": "#/140" + }, + { + "$ref": "#/141" + }, + { + "$ref": "#/142" + }, + { + "$ref": "#/143" + }, + { + "$ref": "#/144" + } + ] + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/terraformSettings@2025-08-01-preview", + "body": { + "$ref": "#/114" }, "readableScopes": 0, "writableScopes": 0, diff --git a/pkg/armrpc/hostoptions/providerconfig.go b/pkg/armrpc/hostoptions/providerconfig.go index ab49e6ba20..3e95626e53 100644 --- a/pkg/armrpc/hostoptions/providerconfig.go +++ b/pkg/armrpc/hostoptions/providerconfig.go @@ -101,4 +101,7 @@ type TerraformOptions struct { // LogLevel is the log level for Terraform execution (ERROR, DEBUG, etc.). LogLevel string `yaml:"logLevel,omitempty"` + + // SourceBaseURL is an optional override to download Terraform from a mirror/base URL (for example in air-gapped setups). + SourceBaseURL string `yaml:"sourceBaseUrl,omitempty"` } diff --git a/pkg/cli/cmd/terraform/common/client.go b/pkg/cli/cmd/terraform/common/client.go new file mode 100644 index 0000000000..962041cb8e --- /dev/null +++ b/pkg/cli/cmd/terraform/common/client.go @@ -0,0 +1,176 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/terraform/installer" +) + +// VersionInfo represents a Terraform version for display purposes. +type VersionInfo struct { + Version string `json:"version"` + State string `json:"state"` + Health string `json:"health"` + InstalledAt time.Time `json:"installedAt"` + IsCurrent bool `json:"isCurrent"` +} + +// Client provides methods for interacting with the Terraform installer API. +type Client struct { + connection sdk.Connection +} + +// NewClient creates a new installer client using the provided SDK connection. +func NewClient(connection sdk.Connection) *Client { + return &Client{connection: connection} +} + +// baseURL returns the installer API base URL. +func (c *Client) baseURL() string { + endpoint := strings.TrimSuffix(c.connection.Endpoint(), "/") + return endpoint + "/installer/terraform" +} + +// Install sends an install request to the installer API. +func (c *Client) Install(ctx context.Context, req installer.InstallRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal install request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/install", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create install request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send install request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseErrorResponse(resp) + } + + return nil +} + +// Uninstall sends an uninstall request to the installer API. +func (c *Client) Uninstall(ctx context.Context, req installer.UninstallRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal uninstall request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/uninstall", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create uninstall request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send uninstall request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseErrorResponse(resp) + } + + return nil +} + +// Status retrieves the current installer status. +func (c *Client) Status(ctx context.Context) (*installer.StatusResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+"/status", nil) + if err != nil { + return nil, fmt.Errorf("failed to create status request: %w", err) + } + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send status request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, c.parseErrorResponse(resp) + } + + var status installer.StatusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("failed to decode status response: %w", err) + } + + return &status, nil +} + +// parseErrorResponse reads the error response body and returns an appropriate error. +func (c *Client) parseErrorResponse(resp *http.Response) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return clierrors.Message("Request failed with status %d", resp.StatusCode) + } + + bodyStr := strings.TrimSpace(string(body)) + if bodyStr == "" { + return clierrors.Message("Request failed with status %d", resp.StatusCode) + } + + return clierrors.Message("Request failed with status %d: %s", resp.StatusCode, bodyStr) +} + +// VersionsToList converts a versions map to a sorted slice for display. +// The current version is marked with IsCurrent=true. +func VersionsToList(versions map[string]installer.VersionStatus, currentVersion string) []VersionInfo { + if len(versions) == 0 { + return nil + } + + result := make([]VersionInfo, 0, len(versions)) + for _, vs := range versions { + result = append(result, VersionInfo{ + Version: vs.Version, + State: string(vs.State), + Health: string(vs.Health), + InstalledAt: vs.InstalledAt, + IsCurrent: vs.Version == currentVersion, + }) + } + + // Sort by version descending (newest first) + sort.Slice(result, func(i, j int) bool { + return result[i].Version > result[j].Version + }) + + return result +} diff --git a/pkg/cli/cmd/terraform/install/install.go b/pkg/cli/cmd/terraform/install/install.go new file mode 100644 index 0000000000..ced2986918 --- /dev/null +++ b/pkg/cli/cmd/terraform/install/install.go @@ -0,0 +1,342 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "context" + "os" + "time" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/spf13/cobra" +) + +const ( + // DefaultTimeout is the default timeout for waiting for installation to complete. + DefaultTimeout = 10 * time.Minute + + // DefaultPollInterval is the default interval for polling installation status. + DefaultPollInterval = 2 * time.Second +) + +// NewCommand creates an instance of the `rad terraform install` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "install", + Short: "Install Terraform for use with Radius recipes", + Long: "Install Terraform for use with Radius recipes. Terraform is downloaded and managed by Radius.", + Example: ` +# Install a specific version of Terraform +rad terraform install --version 1.6.4 + +# Install Terraform and wait for completion +rad terraform install --version 1.6.4 --wait + +# Install Terraform from a custom URL +rad terraform install --url https://example.com/terraform.zip + +# Install Terraform from a custom URL with checksum verification +rad terraform install --url https://example.com/terraform.zip --checksum sha256:abc123... + +# Install from a private registry with a custom CA bundle +rad terraform install --url https://internal.example.com/terraform.zip --ca-bundle /path/to/ca.pem + +# Install from a private registry with authentication +rad terraform install --url https://internal.example.com/terraform.zip --auth-header "Bearer " + +# Install from a private registry with mTLS client certificate +rad terraform install --url https://internal.example.com/terraform.zip --client-cert /path/to/cert.pem --client-key /path/to/key.pem + +# Install through a corporate proxy +rad terraform install --url https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip --proxy http://proxy.corp.com:8080 + +# Install with a custom timeout (when using --wait) +rad terraform install --version 1.6.4 --wait --timeout 15m +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + cmd.Flags().String("version", "", "The Terraform version to install (e.g., 1.6.4)") + cmd.Flags().String("url", "", "The URL to download Terraform from (alternative to --version)") + cmd.Flags().String("checksum", "", "The checksum to verify the download (format: sha256:)") + cmd.Flags().Bool("wait", false, "Wait for the installation to complete") + cmd.Flags().Duration("timeout", DefaultTimeout, "Timeout when waiting for installation (requires --wait)") + cmd.Flags().String("ca-bundle", "", "Path to a PEM-encoded CA bundle file for TLS verification with private registries") + cmd.Flags().String("auth-header", "", "HTTP Authorization header value (e.g., \"Bearer \" or \"Basic \")") + cmd.Flags().String("client-cert", "", "Path to a PEM-encoded client certificate for mTLS authentication") + cmd.Flags().String("client-key", "", "Path to a PEM-encoded client private key for mTLS authentication") + cmd.Flags().String("proxy", "", "HTTP/HTTPS proxy URL (e.g., \"http://proxy.corp.com:8080\")") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform install` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + + Version string + SourceURL string + Checksum string + Wait bool + Timeout time.Duration + CABundle string + AuthHeader string + ClientCert string + ClientKey string + ProxyURL string + PollInterval time.Duration +} + +// NewRunner creates a new instance of the `rad terraform install` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + PollInterval: DefaultPollInterval, + } +} + +// Validate runs validation for the `rad terraform install` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + r.Version, err = cmd.Flags().GetString("version") + if err != nil { + return err + } + + r.SourceURL, err = cmd.Flags().GetString("url") + if err != nil { + return err + } + + r.Checksum, err = cmd.Flags().GetString("checksum") + if err != nil { + return err + } + + r.Wait, err = cmd.Flags().GetBool("wait") + if err != nil { + return err + } + + r.Timeout, err = cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + + r.CABundle, err = cmd.Flags().GetString("ca-bundle") + if err != nil { + return err + } + + r.AuthHeader, err = cmd.Flags().GetString("auth-header") + if err != nil { + return err + } + + r.ClientCert, err = cmd.Flags().GetString("client-cert") + if err != nil { + return err + } + + r.ClientKey, err = cmd.Flags().GetString("client-key") + if err != nil { + return err + } + + r.ProxyURL, err = cmd.Flags().GetString("proxy") + if err != nil { + return err + } + + // Validate that at least one of --version or --url is provided + if r.Version == "" && r.SourceURL == "" { + return clierrors.Message("Either --version or --url must be specified.") + } + + // Validate that --version is required when using --wait (server generates a version hash from URL which cannot be predicted) + if r.Wait && r.Version == "" { + return clierrors.Message("--version is required when using --wait (the server generates a version hash from the URL which cannot be predicted).") + } + + // Validate that --timeout requires --wait + if cmd.Flags().Changed("timeout") && !r.Wait { + return clierrors.Message("--timeout requires --wait to be set.") + } + + // Validate that --ca-bundle requires --url (only makes sense for custom URLs) + if r.CABundle != "" && r.SourceURL == "" { + return clierrors.Message("--ca-bundle requires --url to be set.") + } + + // Validate that --auth-header requires --url + if r.AuthHeader != "" && r.SourceURL == "" { + return clierrors.Message("--auth-header requires --url to be set.") + } + + // Validate that --client-cert and --client-key must be used together + if (r.ClientCert != "" && r.ClientKey == "") || (r.ClientCert == "" && r.ClientKey != "") { + return clierrors.Message("--client-cert and --client-key must be specified together.") + } + + // Validate that --client-cert requires --url + if r.ClientCert != "" && r.SourceURL == "" { + return clierrors.Message("--client-cert requires --url to be set.") + } + + // Validate that --proxy requires --url + if r.ProxyURL != "" && r.SourceURL == "" { + return clierrors.Message("--proxy requires --url to be set.") + } + + return nil +} + +// Run runs the `rad terraform install` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + req := installer.InstallRequest{ + Version: r.Version, + SourceURL: r.SourceURL, + Checksum: r.Checksum, + AuthHeader: r.AuthHeader, + ProxyURL: r.ProxyURL, + } + + // Read CA bundle file if specified + if r.CABundle != "" { + caBytes, err := os.ReadFile(r.CABundle) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read CA bundle file %q.", r.CABundle) + } + req.CABundle = string(caBytes) + } + + // Read client certificate file if specified + if r.ClientCert != "" { + certBytes, err := os.ReadFile(r.ClientCert) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read client certificate file %q.", r.ClientCert) + } + req.ClientCert = string(certBytes) + } + + // Read client key file if specified + if r.ClientKey != "" { + keyBytes, err := os.ReadFile(r.ClientKey) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read client key file %q.", r.ClientKey) + } + req.ClientKey = string(keyBytes) + } + + r.Output.LogInfo("Installing Terraform...") + + if err := client.Install(ctx, req); err != nil { + return err + } + + versionInfo := r.Version + if versionInfo == "" { + versionInfo = r.SourceURL + } + r.Output.LogInfo("Terraform install queued (version=%s)", versionInfo) + + if r.Wait { + return r.waitForInstallation(ctx, client) + } + + return nil +} + +// waitForInstallation polls the status endpoint until the installation completes or fails. +func (r *Runner) waitForInstallation(ctx context.Context, client *common.Client) error { + r.Output.LogInfo("Waiting for installation to complete...") + + deadline := time.Now().Add(r.Timeout) + pollInterval := r.PollInterval + + for { + if time.Now().After(deadline) { + return clierrors.Message("Timed out waiting for Terraform installation to complete.") + } + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // Check if the target version is installed + if vs, ok := status.Versions[r.Version]; ok { + switch vs.State { + case installer.VersionStateSucceeded: + if status.CurrentVersion == r.Version { + r.Output.LogInfo("Terraform %s installed successfully.", r.Version) + return nil + } + // Version succeeded but isn't current - this is an unexpected state. + // The server always sets current version when marking succeeded, so this + // indicates a bug or race condition. Return an error rather than polling forever. + return clierrors.Message("Terraform %s installed but not set as current version (current: %s). This may indicate a server-side issue.", r.Version, status.CurrentVersion) + case installer.VersionStateFailed: + if vs.LastError != "" { + return clierrors.Message("Terraform installation failed: %s", vs.LastError) + } + return clierrors.Message("Terraform installation failed.") + } + } + + // Check overall state for failures (e.g., server fails before populating version status) + if status.State == installer.ResponseStateFailed { + if status.LastError != "" { + return clierrors.Message("Terraform installation failed: %s", status.LastError) + } + return clierrors.Message("Terraform installation failed.") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + // Continue polling + } + } +} diff --git a/pkg/cli/cmd/terraform/install/install_test.go b/pkg/cli/cmd/terraform/install/install_test.go new file mode 100644 index 0000000000..88fb989404 --- /dev/null +++ b/pkg/cli/cmd/terraform/install/install_test.go @@ -0,0 +1,656 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Install with version", + Input: []string{"--version", "1.6.4"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Install with URL", + Input: []string{"--url", "https://example.com/terraform.zip"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Install with version and wait", + Input: []string{"--version", "1.6.4", "--wait"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - neither version nor URL", + Input: []string{}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - wait without version (URL only)", + Input: []string{"--url", "https://example.com/terraform.zip", "--wait"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - timeout without wait", + Input: []string{"--version", "1.6.4", "--timeout", "5m"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - ca-bundle with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--ca-bundle", "/path/to/ca.pem"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - ca-bundle without URL", + Input: []string{"--version", "1.6.4", "--ca-bundle", "/path/to/ca.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - auth-header with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--auth-header", "Bearer token123"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - auth-header without URL", + Input: []string{"--version", "1.6.4", "--auth-header", "Bearer token123"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - client-cert and client-key with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-cert", "/path/to/cert.pem", "--client-key", "/path/to/key.pem"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-cert without client-key", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-cert", "/path/to/cert.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-key without client-cert", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-key", "/path/to/key.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-cert without URL", + Input: []string{"--version", "1.6.4", "--client-cert", "/path/to/cert.pem", "--client-key", "/path/to/key.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - proxy with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--proxy", "http://proxy:8080"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - proxy without URL", + Input: []string{"--version", "1.6.4", "--proxy", "http://proxy:8080"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - all options with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--ca-bundle", "/ca.pem", "--auth-header", "Bearer token", "--client-cert", "/cert.pem", "--client-key", "/key.pem", "--proxy", "http://proxy:8080"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success - Install without wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify output messages + require.True(t, len(outputSink.Writes) >= 2) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "Installing Terraform") + require.Contains(t, outputSink.Writes[1].(output.LogOutput).Format, "Terraform install queued") + }) + + t.Run("Success - Install with wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + var state installer.VersionState + var currentVersion string + if calls < 2 { + state = installer.VersionStateInstalling + currentVersion = "" + } else { + state = installer.VersionStateSucceeded + currentVersion = "1.6.4" + } + + statusResponse := installer.StatusResponse{ + CurrentVersion: currentVersion, + State: installer.ResponseStateReady, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: state, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify at least 2 status calls were made + require.GreaterOrEqual(t, statusCalls.Load(), int32(2)) + }) + + t.Run("Error - Install failed during wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateFailed, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateFailed, + LastError: "download failed", + }, + }, + LastError: "download failed", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "download failed") + }) + + t.Run("Error - Overall state failed without version status", func(t *testing.T) { + // Tests the case where the server fails before populating version status + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Return failed state without populating version status + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateFailed, + Versions: nil, // No version status populated + LastError: "queue processing error", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "queue processing error") + }) + + t.Run("Error - Server rejects install request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid version format")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "invalid", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid version format") + }) + + t.Run("Success - Install with CA bundle", func(t *testing.T) { + // Create a temporary CA bundle file + tempDir := t.TempDir() + caFile := filepath.Join(tempDir, "ca.pem") + testCACert := `-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIUM06Yo/BKCPvBfZwztaJPszhAO98wDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDEyMTEwMjAzNVoXDTI3MDEyMTEw +MjAzNVowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA0wyOmcNaSz1AQHGNVmNzzkDO5VhUCv56KRybhLR/uXhapxQ4T+Rr +beMUExEaxyWDnTjsnirNUvwadBONWzm8cDQSW2KldbnzjteBRlNDbRI6TgKE0TRR +ljAM77Dczzuye2PsQS002Ny3UR+MnzI1kA3/XjAeAVefKn31Col0Ssn7OdvZ1VTH +aK04b2szaAla5Sl+eWKUsxj6UA/V/Xq94Z4AEnqk7zkGxnpILvxcz0QY/U/7e5iQ +IM/NkIeMoJe+Cfij+yPqLgh2f5L4Vi9WvRB8P0rbvl5WrEU6K6bjuZ5zKxiC+rbU +5hjAlR5lyrgo8cwiB5cOah+qQzl/3c26yQIDAQABo1MwUTAdBgNVHQ4EFgQU8/CI +UhXWPvHMCIynxKS4D+PQdy0wHwYDVR0jBBgwFoAU8/CIUhXWPvHMCIynxKS4D+PQ +dy0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAevFg7NV4D6UP +qYdvGjWgMFEUiUBp5EtEU5KD7FZwKop/lFqnvo+L1bUUy2hab76eO+g0perp8b8j +/ZwMgdIVNjNEWgM8h+Gg3HG8Rvdle5NqMq4lIGzmTN+MhPnQ8rECMSm0nVGTtFA0 +qE+O0LoSl/4FL9pUQuwZi+WibxoTOlw3NXpxx2WUFzU/Giwx6OYCTb773M9noKCH +7VAkvFImjSbr4SU05DGe+cUcWmtWcfhj2geiCHl/EEpe/oEi5/XnpgeMj4vkE6zK +fiCLJ0WJ77/ohDKnNecDZKIWLsUo9ywMJqi9TLSiBf5oMOc9uZtDoPTPzsXzcPZP +2JkLUbkliQ== +-----END CERTIFICATE-----` + err := os.WriteFile(caFile, []byte(testCACert), 0o600) + require.NoError(t, err) + + var receivedCABundle string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + // Capture the CA bundle from the request + var req installer.InstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + receivedCABundle = req.CABundle + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://internal.example.com/terraform.zip", + CABundle: caFile, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Verify CA bundle was sent to server + require.Equal(t, testCACert, receivedCABundle) + }) + + t.Run("Error - CA bundle file not found", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://internal.example.com/terraform.zip", + CABundle: "/nonexistent/path/to/ca.pem", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to read CA bundle file") + }) + + t.Run("Success - Install with URL and checksum (no CA bundle)", func(t *testing.T) { + var receivedReq installer.InstallRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&receivedReq) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify request contents + require.Equal(t, "https://example.com/terraform.zip", receivedReq.SourceURL) + require.Equal(t, "sha256:abc123", receivedReq.Checksum) + require.Empty(t, receivedReq.CABundle, "CABundle should be empty when not specified") + }) +} diff --git a/pkg/cli/cmd/terraform/list/list.go b/pkg/cli/cmd/terraform/list/list.go new file mode 100644 index 0000000000..c3d34c2001 --- /dev/null +++ b/pkg/cli/cmd/terraform/list/list.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package list + +import ( + "context" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform list` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "list", + Short: "List installed Terraform versions", + Long: "List all Terraform versions that have been installed, including their state and health status.", + Example: ` +# List all installed Terraform versions +rad terraform list + +# List versions in JSON format +rad terraform list --output json +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform list` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string +} + +// NewRunner creates a new instance of the `rad terraform list` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad terraform list` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + return nil +} + +// Run runs the `rad terraform list` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // Convert versions map to sorted slice for display + versions := common.VersionsToList(status.Versions, status.CurrentVersion) + + if len(versions) == 0 { + r.Output.LogInfo("No Terraform versions installed.") + return nil + } + + err = r.Output.WriteFormatted(r.Format, versions, versionsFormat()) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cli/cmd/terraform/list/objectformats.go b/pkg/cli/cmd/terraform/list/objectformats.go new file mode 100644 index 0000000000..4452220c8e --- /dev/null +++ b/pkg/cli/cmd/terraform/list/objectformats.go @@ -0,0 +1,84 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package list + +import ( + "strings" + + "github.com/radius-project/radius/pkg/cli/output" +) + +// versionsFormat returns the formatter options for displaying Terraform versions list. +func versionsFormat() output.FormatterOptions { + transformer := &versionTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "VERSION", + JSONPath: "{ .Version }", + Transformer: transformer, + }, + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: transformer, + }, + { + Heading: "HEALTH", + JSONPath: "{ .Health }", + Transformer: transformer, + }, + { + Heading: "INSTALLED AT", + JSONPath: "{ .InstalledAt }", + Transformer: transformer, + }, + { + Heading: "CURRENT", + JSONPath: "{ .IsCurrent }", + Transformer: ¤tTransformer{}, + }, + }, + } +} + +type versionTransformer struct{} + +func (*versionTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" || trimmed == "" || trimmed == "" { + return "-" + } + if trimmed == "0001-01-01T00:00:00Z" || trimmed == "\"0001-01-01T00:00:00Z\"" { + return "-" + } + // Strip surrounding quotes + if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + return trimmed[1 : len(trimmed)-1] + } + return input +} + +type currentTransformer struct{} + +func (*currentTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "true" { + return "*" + } + return "" +} diff --git a/pkg/cli/cmd/terraform/status/objectformats.go b/pkg/cli/cmd/terraform/status/objectformats.go new file mode 100644 index 0000000000..366398ff14 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/objectformats.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "strings" + + "github.com/radius-project/radius/pkg/cli/output" +) + +// statusFormat returns the formatter options for displaying Terraform installer status. +// Note: JSONPath uses Go struct field names (capitalized), not json tags. +// Shows essential columns only. Use --output json for full details. +func statusFormat() output.FormatterOptions { + noValue := &emptyIfNoValueTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: noValue, + }, + { + Heading: "VERSION", + JSONPath: "{ .CurrentVersion }", + Transformer: noValue, + }, + { + Heading: "LAST ERROR", + JSONPath: "{ .LastError }", + Transformer: noValue, + }, + { + Heading: "LAST UPDATED", + JSONPath: "{ .LastUpdated }", + Transformer: noValue, + }, + }, + } +} + +type emptyIfNoValueTransformer struct{} + +func (*emptyIfNoValueTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + // Handle various "no value" representations from JSONPath + if trimmed == "" || trimmed == "" || trimmed == "" { + return "-" + } + // Handle zero time values + if trimmed == "0001-01-01T00:00:00Z" || trimmed == "\"0001-01-01T00:00:00Z\"" { + return "-" + } + // Strip surrounding quotes from values (e.g., timestamps) + if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + return trimmed[1 : len(trimmed)-1] + } + return input +} + +// versionsFormat returns the formatter options for displaying Terraform versions list. +func versionsFormat() output.FormatterOptions { + transformer := &emptyIfNoValueTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "VERSION", + JSONPath: "{ .Version }", + Transformer: transformer, + }, + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: transformer, + }, + { + Heading: "HEALTH", + JSONPath: "{ .Health }", + Transformer: transformer, + }, + { + Heading: "INSTALLED AT", + JSONPath: "{ .InstalledAt }", + Transformer: transformer, + }, + { + Heading: "CURRENT", + JSONPath: "{ .IsCurrent }", + Transformer: ¤tTransformer{}, + }, + }, + } +} + +type currentTransformer struct{} + +func (*currentTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "true" { + return "*" + } + return "" +} diff --git a/pkg/cli/cmd/terraform/status/objectformats_test.go b/pkg/cli/cmd/terraform/status/objectformats_test.go new file mode 100644 index 0000000000..52438eac12 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/objectformats_test.go @@ -0,0 +1,185 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "bytes" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/stretchr/testify/require" +) + +func Test_statusFormat(t *testing.T) { + format := statusFormat() + require.NotNil(t, format.Columns) + require.Len(t, format.Columns, 4) + + // Verify all expected columns are present (concise view, use --output json for full details) + columnHeadings := make([]string, len(format.Columns)) + for i, col := range format.Columns { + columnHeadings[i] = col.Heading + } + + expectedHeadings := []string{ + "STATE", + "VERSION", + "LAST ERROR", + "LAST UPDATED", + } + + require.Equal(t, expectedHeadings, columnHeadings) +} + +func Test_statusFormat_TableOutput(t *testing.T) { + // Test that the JSONPath expressions work with the actual struct + installedAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + lastUpdated := time.Date(2024, 1, 15, 10, 35, 0, 0, time.UTC) + + status := &installer.StatusResponse{ + State: installer.ResponseStateReady, + CurrentVersion: "1.6.4", + BinaryPath: "/terraform/versions/1.6.4/terraform", + InstalledAt: &installedAt, + Source: &installer.SourceInfo{ + URL: "https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip", + Checksum: "sha256:abc123", + }, + Queue: &installer.QueueInfo{ + Pending: 0, + }, + LastUpdated: lastUpdated, + } + + format := statusFormat() + formatter := &output.TableFormatter{} + buf := &bytes.Buffer{} + + err := formatter.Format(status, buf, format) + require.NoError(t, err) + + tableOutput := buf.String() + + // Verify key values appear in table output (concise view shows only essential columns) + require.Contains(t, tableOutput, "STATE") + require.Contains(t, tableOutput, "VERSION") + require.Contains(t, tableOutput, "ready") + require.Contains(t, tableOutput, "1.6.4") +} + +func Test_statusFormat_TableOutput_NotInstalled(t *testing.T) { + status := &installer.StatusResponse{ + State: installer.ResponseStateNotInstalled, + CurrentVersion: "", + BinaryPath: "", + InstalledAt: nil, + Source: nil, + Queue: nil, + LastUpdated: time.Time{}, + } + + format := statusFormat() + formatter := &output.TableFormatter{} + buf := &bytes.Buffer{} + + err := formatter.Format(status, buf, format) + require.NoError(t, err) + + tableOutput := buf.String() + require.Contains(t, tableOutput, "STATE") + require.NotContains(t, tableOutput, "") +} + +func Test_emptyIfNoValueTransformer(t *testing.T) { + transformer := &emptyIfNoValueTransformer{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "no value marker returns dash", + input: "", + expected: "-", + }, + { + name: "nil marker returns dash", + input: "", + expected: "-", + }, + { + name: "empty string returns dash", + input: "", + expected: "-", + }, + { + name: "whitespace only returns dash", + input: " ", + expected: "-", + }, + { + name: "zero time returns dash", + input: "0001-01-01T00:00:00Z", + expected: "-", + }, + { + name: "quoted zero time returns dash", + input: "\"0001-01-01T00:00:00Z\"", + expected: "-", + }, + { + name: "normal value is preserved", + input: "1.6.4", + expected: "1.6.4", + }, + { + name: "path value is preserved", + input: "/terraform/versions/1.6.4/terraform", + expected: "/terraform/versions/1.6.4/terraform", + }, + { + name: "state value is preserved", + input: "ready", + expected: "ready", + }, + { + name: "timestamp is preserved", + input: "2024-01-15T10:30:00Z", + expected: "2024-01-15T10:30:00Z", + }, + { + name: "quoted timestamp has quotes stripped", + input: "\"2024-01-15T10:30:00Z\"", + expected: "2024-01-15T10:30:00Z", + }, + { + name: "zero number is preserved", + input: "0", + expected: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := transformer.Transform(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cli/cmd/terraform/status/status.go b/pkg/cli/cmd/terraform/status/status.go new file mode 100644 index 0000000000..f10433ac02 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/status.go @@ -0,0 +1,130 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform status` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "status", + Short: "Show Terraform installation status", + Long: "Show Terraform installation status, including the current version, state, and other details.", + Example: ` +# Show Terraform status +rad terraform status + +# Show Terraform status with all installed versions +rad terraform status --all + +# Show Terraform status in JSON format +rad terraform status --output json +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddOutputFlag(cmd) + cmd.Flags().BoolP("all", "a", false, "Show all installed versions") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform status` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string + ShowAll bool +} + +// NewRunner creates a new instance of the `rad terraform status` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad terraform status` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + showAll, err := cmd.Flags().GetBool("all") + if err != nil { + return err + } + r.ShowAll = showAll + + return nil +} + +// Run runs the `rad terraform status` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // If --all flag is set, show all versions instead of just current status + if r.ShowAll { + versions := common.VersionsToList(status.Versions, status.CurrentVersion) + if len(versions) == 0 { + r.Output.LogInfo("No Terraform versions installed.") + return nil + } + return r.Output.WriteFormatted(r.Format, versions, versionsFormat()) + } + + err = r.Output.WriteFormatted(r.Format, status, statusFormat()) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cli/cmd/terraform/status/status_test.go b/pkg/cli/cmd/terraform/status/status_test.go new file mode 100644 index 0000000000..2b8a3c6f5d --- /dev/null +++ b/pkg/cli/cmd/terraform/status/status_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Status Command", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Status Command with fallback workspace", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Status Command with too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success", func(t *testing.T) { + installedAt := time.Now().UTC() + lastUpdated := time.Now().UTC() + + statusResponse := installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + BinaryPath: "/terraform/versions/1.6.4/terraform", + InstalledAt: &installedAt, + Source: &installer.SourceInfo{ + URL: "https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip", + Checksum: "sha256:abc123", + }, + Queue: &installer.QueueInfo{ + Pending: 0, + }, + LastUpdated: lastUpdated, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Format: "table", + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + require.Len(t, outputSink.Writes, 1) + formattedOutput, ok := outputSink.Writes[0].(output.FormattedOutput) + require.True(t, ok) + require.Equal(t, "table", formattedOutput.Format) + + // Verify the response was passed through + responseData, ok := formattedOutput.Obj.(*installer.StatusResponse) + require.True(t, ok) + require.Equal(t, "1.6.4", responseData.CurrentVersion) + require.Equal(t, installer.ResponseStateReady, responseData.State) + }) + + t.Run("Error - Server Error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Format: "table", + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "500") + }) +} diff --git a/pkg/cli/cmd/terraform/terraform.go b/pkg/cli/cmd/terraform/terraform.go new file mode 100644 index 0000000000..e1d08234ef --- /dev/null +++ b/pkg/cli/cmd/terraform/terraform.go @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraform + +import ( + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform` command. +func NewCommand() *cobra.Command { + // This command is not runnable, and thus has no runner. + cmd := &cobra.Command{ + Use: "terraform", + Short: "Manage Terraform installation for Radius", + Long: `Manage Terraform installation for Radius. Terraform is used by Radius to execute Terraform recipes. + +Use subcommands to install, uninstall, or check the status of Terraform.`, + } + + return cmd +} diff --git a/pkg/cli/cmd/terraform/uninstall/uninstall.go b/pkg/cli/cmd/terraform/uninstall/uninstall.go new file mode 100644 index 0000000000..7f5e212a83 --- /dev/null +++ b/pkg/cli/cmd/terraform/uninstall/uninstall.go @@ -0,0 +1,330 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package uninstall + +import ( + "context" + "strings" + "time" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/spf13/cobra" +) + +const ( + // DefaultTimeout is the default timeout for waiting for uninstallation to complete. + DefaultTimeout = 10 * time.Minute + + // DefaultPollInterval is the default interval for polling uninstallation status. + DefaultPollInterval = 2 * time.Second +) + +// NewCommand creates an instance of the `rad terraform uninstall` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall Terraform from Radius", + Long: "Uninstall Terraform from Radius. This removes the currently installed Terraform binary.", + Example: ` +# Uninstall current Terraform version +rad terraform uninstall + +# Uninstall a specific version +rad terraform uninstall --version 1.6.3 + +# Uninstall all installed versions +rad terraform uninstall --all + +# Uninstall and remove version metadata (purge history) +rad terraform uninstall --purge + +# Uninstall all versions and purge all metadata +rad terraform uninstall --all --purge + +# Uninstall Terraform and wait for completion +rad terraform uninstall --wait + +# Uninstall with a custom timeout (when using --wait) +rad terraform uninstall --wait --timeout 5m +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + cmd.Flags().StringP("version", "v", "", "Specific version to uninstall") + cmd.Flags().Bool("all", false, "Uninstall all installed versions") + cmd.Flags().Bool("purge", false, "Remove version metadata from database (clears history)") + cmd.Flags().Bool("wait", false, "Wait for the uninstallation to complete") + cmd.Flags().Duration("timeout", DefaultTimeout, "Timeout when waiting for uninstallation (requires --wait)") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform uninstall` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + + Version string + UninstallAll bool + Purge bool + Wait bool + Timeout time.Duration + PollInterval time.Duration +} + +// NewRunner creates a new instance of the `rad terraform uninstall` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + PollInterval: DefaultPollInterval, + } +} + +// Validate runs validation for the `rad terraform uninstall` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + r.Version, err = cmd.Flags().GetString("version") + if err != nil { + return err + } + + r.UninstallAll, err = cmd.Flags().GetBool("all") + if err != nil { + return err + } + + r.Purge, err = cmd.Flags().GetBool("purge") + if err != nil { + return err + } + + r.Wait, err = cmd.Flags().GetBool("wait") + if err != nil { + return err + } + + r.Timeout, err = cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + + // Validate that --timeout requires --wait + if cmd.Flags().Changed("timeout") && !r.Wait { + return clierrors.Message("--timeout requires --wait to be set.") + } + + // Validate that --version and --all are mutually exclusive + if r.Version != "" && r.UninstallAll { + return clierrors.Message("--version and --all cannot be used together.") + } + + // Validate that --wait cannot be used with --all (would need complex tracking) + if r.UninstallAll && r.Wait { + return clierrors.Message("--wait cannot be used with --all.") + } + + return nil +} + +// Run runs the `rad terraform uninstall` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + // Handle --all flag: uninstall all versions + if r.UninstallAll { + return r.uninstallAll(ctx, client) + } + + // Get current version before uninstalling so we can track its state + var priorVersion string + if r.Wait || r.Version == "" { + status, err := client.Status(ctx) + if err != nil { + return err + } + priorVersion = status.CurrentVersion + if priorVersion == "" && r.Version == "" { + r.Output.LogInfo("No Terraform version is currently installed.") + return nil + } + } + + r.Output.LogInfo("Uninstalling Terraform...") + + // Send uninstall request + req := installer.UninstallRequest{ + Version: r.Version, // Empty string means uninstall current version + Purge: r.Purge, + } + if err := client.Uninstall(ctx, req); err != nil { + return err + } + + if r.Version != "" { + r.Output.LogInfo("Terraform uninstall queued (version=%s).", r.Version) + } else { + r.Output.LogInfo("Terraform uninstall queued.") + } + + if r.Wait { + return r.waitForUninstallation(ctx, client, priorVersion) + } + + return nil +} + +// uninstallAll uninstalls all installed Terraform versions. +func (r *Runner) uninstallAll(ctx context.Context, client *common.Client) error { + status, err := client.Status(ctx) + if err != nil { + return err + } + + if len(status.Versions) == 0 { + r.Output.LogInfo("No Terraform versions to process.") + return nil + } + + if r.Purge { + r.Output.LogInfo("Purging all Terraform versions...") + } else { + r.Output.LogInfo("Uninstalling all Terraform versions...") + } + + // Process each version + processCount := 0 + for version, vs := range status.Versions { + // Skip versions that are already uninstalled or failed (unless purging) + if !r.Purge && (vs.State == installer.VersionStateUninstalled || vs.State == installer.VersionStateFailed) { + continue + } + + req := installer.UninstallRequest{Version: version, Purge: r.Purge} + if err := client.Uninstall(ctx, req); err != nil { + r.Output.LogInfo("Failed to queue uninstall for version %s: %s", version, err) + continue + } + if r.Purge { + r.Output.LogInfo("Queued purge for version %s", version) + } else { + r.Output.LogInfo("Queued uninstall for version %s", version) + } + processCount++ + } + + if processCount == 0 { + r.Output.LogInfo("No versions to process.") + } else { + r.Output.LogInfo("All requests queued.") + } + return nil +} + +// waitForUninstallation polls the status endpoint until the uninstallation completes or fails. +// Success is defined as CurrentVersion being empty (no Terraform installed). +func (r *Runner) waitForUninstallation(ctx context.Context, client *common.Client, priorVersion string) error { + r.Output.LogInfo("Waiting for uninstallation to complete...") + + deadline := time.Now().Add(r.Timeout) + pollInterval := r.PollInterval + + for { + if time.Now().After(deadline) { + return clierrors.Message("Timed out waiting for Terraform uninstallation to complete.") + } + + status, err := client.Status(ctx) + if err != nil { + return err + } + + if status.Queue != nil && status.Queue.InProgress != nil { + op := operationFromQueue(*status.Queue.InProgress) + if op == installer.OperationInstall { + return clierrors.Message("Terraform install in progress; uninstall wait requires no Terraform installed.") + } + } + + // Success: no current version installed + if status.CurrentVersion == "" { + r.Output.LogInfo("Terraform uninstalled successfully.") + return nil + } + + if priorVersion != "" && status.CurrentVersion != priorVersion { + return clierrors.Message("Terraform version %s is now installed; uninstall wait requires no Terraform installed.", status.CurrentVersion) + } + + // Check if the prior version uninstall failed + if priorVersion != "" { + if vs, ok := status.Versions[priorVersion]; ok { + if vs.State == installer.VersionStateFailed { + if vs.LastError != "" { + return clierrors.Message("Terraform uninstallation failed: %s", vs.LastError) + } + return clierrors.Message("Terraform uninstallation failed.") + } + } + } + + // Check overall state for failures + if status.State == installer.ResponseStateFailed { + if status.LastError != "" { + return clierrors.Message("Terraform uninstallation failed: %s", status.LastError) + } + return clierrors.Message("Terraform uninstallation failed.") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + // Continue polling + } + } +} + +func operationFromQueue(inProgress string) installer.Operation { + parts := strings.SplitN(inProgress, ":", 2) + if len(parts) == 0 { + return "" + } + return installer.Operation(parts[0]) +} diff --git a/pkg/cli/cmd/terraform/uninstall/uninstall_test.go b/pkg/cli/cmd/terraform/uninstall/uninstall_test.go new file mode 100644 index 0000000000..d0e3a0a403 --- /dev/null +++ b/pkg/cli/cmd/terraform/uninstall/uninstall_test.go @@ -0,0 +1,571 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package uninstall + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Uninstall Command", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Uninstall with wait", + Input: []string{"--wait"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Uninstall with wait and timeout", + Input: []string{"--wait", "--timeout", "5m"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - timeout without wait", + Input: []string{"--timeout", "5m"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success - Uninstall without wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Status is now fetched to check if there's a current version + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify output messages + require.True(t, len(outputSink.Writes) >= 2) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "Uninstalling Terraform") + require.Contains(t, outputSink.Writes[1].(output.LogOutput).Format, "Terraform uninstall queued") + }) + + t.Run("Success - Uninstall with wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var currentVersion string + var versions map[string]installer.VersionStatus + + if calls <= 1 { + // First call (before uninstall request) - return current version + currentVersion = "1.6.4" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + } + } else if calls == 2 { + // Second call - still uninstalling + currentVersion = "1.6.4" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalling, + }, + } + } else { + // Third call and beyond - uninstalled + currentVersion = "" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalled, + }, + } + } + + statusResponse := installer.StatusResponse{ + CurrentVersion: currentVersion, + Versions: versions, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify status calls were made + require.GreaterOrEqual(t, statusCalls.Load(), int32(3)) + }) + + t.Run("Success - No current version installed", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateNotInstalled, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Should indicate no version is installed + require.True(t, len(outputSink.Writes) >= 1) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "No Terraform version is currently installed") + }) + + t.Run("Error - Current version changed during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var statusResponse installer.StatusResponse + if calls == 1 { + // Before uninstall, current version is 1.6.4 + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + "1.5.0": { + Version: "1.5.0", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + // After uninstall, previous version is promoted + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.5.0", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalled, + }, + "1.5.0": { + Version: "1.5.0", + State: installer.VersionStateSucceeded, + }, + }, + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "now installed") + }) + + t.Run("Error - Install in progress during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var statusResponse installer.StatusResponse + if calls == 1 { + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + inProgress := "install:1.7.0" + statusResponse = installer.StatusResponse{ + CurrentVersion: "", + Queue: &installer.QueueInfo{ + Pending: 0, + InProgress: &inProgress, + }, + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "install in progress") + }) + + t.Run("Error - Uninstall failed during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var statusResponse installer.StatusResponse + if calls <= 1 { + // First call (before uninstall) - return current version + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + // Subsequent calls - return failed state + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateFailed, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateFailed, + LastError: "terraform in use", + }, + }, + LastError: "terraform in use", + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "terraform in use") + }) + + t.Run("Error - Server rejects uninstall request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Status is now fetched to check if there's a current version + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("uninstall rejected by server")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "uninstall rejected by server") + }) +} diff --git a/pkg/components/queue/queueprovider/provider.go b/pkg/components/queue/queueprovider/provider.go index 2350089fc0..f9f3e18f96 100644 --- a/pkg/components/queue/queueprovider/provider.go +++ b/pkg/components/queue/queueprovider/provider.go @@ -34,6 +34,8 @@ type QueueProvider struct { queueClient queue.Client once sync.Once + // clientInjected tracks whether SetClient was used to provide a custom client. + clientInjected bool } // New creates new QueueProvider instance. @@ -63,4 +65,13 @@ func (p *QueueProvider) GetClient(ctx context.Context) (queue.Client, error) { // SetClient sets the queue client for the QueueProvider. This should be used by tests that need to mock the queue client. func (p *QueueProvider) SetClient(client queue.Client) { p.queueClient = client + p.clientInjected = true +} + +// HasInjectedClient reports whether SetClient was used to provide a custom queue client. +func (p *QueueProvider) HasInjectedClient() bool { + if p == nil { + return false + } + return p.clientInjected } diff --git a/pkg/corerp/api/v20250801preview/bicepsettings_conversion.go b/pkg/corerp/api/v20250801preview/bicepsettings_conversion.go new file mode 100644 index 0000000000..fb55e89aa1 --- /dev/null +++ b/pkg/corerp/api/v20250801preview/bicepsettings_conversion.go @@ -0,0 +1,208 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v20250801preview + +import ( + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +// ConvertTo converts from the versioned BicepSettingsResource to version-agnostic datamodel. +func (src *BicepSettingsResource) ConvertTo() (v1.DataModelInterface, error) { + converted := &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: to.String(src.ID), + Name: to.String(src.Name), + Type: to.String(src.Type), + Location: to.String(src.Location), + Tags: to.StringMap(src.Tags), + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: Version, + UpdatedAPIVersion: Version, + AsyncProvisioningState: toProvisioningStateDataModel(src.Properties.ProvisioningState), + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{}, + } + + // Convert Authentication + if src.Properties.Authentication != nil { + converted.Properties.Authentication = toBicepAuthenticationConfigurationDataModel(src.Properties.Authentication) + } + + return converted, nil +} + +// ConvertFrom converts from version-agnostic datamodel to the versioned BicepSettingsResource. +func (dst *BicepSettingsResource) ConvertFrom(src v1.DataModelInterface) error { + bs, ok := src.(*datamodel.BicepSettings_v20250801preview) + if !ok { + return v1.ErrInvalidModelConversion + } + + dst.ID = to.Ptr(bs.ID) + dst.Name = to.Ptr(bs.Name) + dst.Type = to.Ptr(bs.Type) + dst.SystemData = fromSystemDataModel(&bs.SystemData) + dst.Location = to.Ptr(bs.Location) + dst.Tags = *to.StringMapPtr(bs.Tags) + dst.Properties = &BicepSettingsProperties{ + ProvisioningState: fromProvisioningStateDataModel(bs.InternalMetadata.AsyncProvisioningState), + } + + // Convert Authentication + if bs.Properties.Authentication != nil { + dst.Properties.Authentication = fromBicepAuthenticationConfigurationDataModel(bs.Properties.Authentication) + } + + return nil +} + +func toBicepAuthenticationConfigurationDataModel(src *BicepAuthenticationConfiguration) *datamodel.BicepAuthenticationConfiguration { + if src == nil { + return nil + } + + result := &datamodel.BicepAuthenticationConfiguration{} + + if src.Registries != nil { + result.Registries = make(map[string]*datamodel.BicepRegistryAuthentication) + for k, v := range src.Registries { + if v != nil { + result.Registries[k] = toBicepRegistryAuthenticationDataModel(v) + } + } + } + + return result +} + +func toBicepRegistryAuthenticationDataModel(src *BicepRegistryAuthentication) *datamodel.BicepRegistryAuthentication { + if src == nil { + return nil + } + + result := &datamodel.BicepRegistryAuthentication{} + + if src.Basic != nil { + result.Basic = &datamodel.BicepBasicAuthentication{ + Username: to.String(src.Basic.Username), + } + if src.Basic.Password != nil { + result.Basic.Password = &datamodel.SecretRef{ + SecretID: to.String(src.Basic.Password.SecretID), + Key: to.String(src.Basic.Password.Key), + } + } + } + + if src.AzureWorkloadIdentity != nil { + result.AzureWorkloadIdentity = &datamodel.BicepAzureWorkloadIdentityAuthentication{ + ClientID: to.String(src.AzureWorkloadIdentity.ClientID), + TenantID: to.String(src.AzureWorkloadIdentity.TenantID), + } + if src.AzureWorkloadIdentity.Token != nil { + result.AzureWorkloadIdentity.Token = &datamodel.SecretRef{ + SecretID: to.String(src.AzureWorkloadIdentity.Token.SecretID), + Key: to.String(src.AzureWorkloadIdentity.Token.Key), + } + } + } + + if src.AwsIrsa != nil { + result.AwsIrsa = &datamodel.BicepAwsIrsaAuthentication{ + RoleArn: to.String(src.AwsIrsa.RoleArn), + } + if src.AwsIrsa.Token != nil { + result.AwsIrsa.Token = &datamodel.SecretRef{ + SecretID: to.String(src.AwsIrsa.Token.SecretID), + Key: to.String(src.AwsIrsa.Token.Key), + } + } + } + + return result +} + +func fromBicepAuthenticationConfigurationDataModel(src *datamodel.BicepAuthenticationConfiguration) *BicepAuthenticationConfiguration { + if src == nil { + return nil + } + + result := &BicepAuthenticationConfiguration{} + + if src.Registries != nil { + result.Registries = make(map[string]*BicepRegistryAuthentication) + for k, v := range src.Registries { + if v != nil { + result.Registries[k] = fromBicepRegistryAuthenticationDataModel(v) + } + } + } + + return result +} + +func fromBicepRegistryAuthenticationDataModel(src *datamodel.BicepRegistryAuthentication) *BicepRegistryAuthentication { + if src == nil { + return nil + } + + result := &BicepRegistryAuthentication{} + + if src.Basic != nil { + result.Basic = &BicepBasicAuthentication{ + Username: to.Ptr(src.Basic.Username), + } + if src.Basic.Password != nil { + result.Basic.Password = &SecretReference{ + SecretID: to.Ptr(src.Basic.Password.SecretID), + Key: to.Ptr(src.Basic.Password.Key), + } + } + } + + if src.AzureWorkloadIdentity != nil { + result.AzureWorkloadIdentity = &BicepAzureWorkloadIdentityAuthentication{ + ClientID: to.Ptr(src.AzureWorkloadIdentity.ClientID), + TenantID: to.Ptr(src.AzureWorkloadIdentity.TenantID), + } + if src.AzureWorkloadIdentity.Token != nil { + result.AzureWorkloadIdentity.Token = &SecretReference{ + SecretID: to.Ptr(src.AzureWorkloadIdentity.Token.SecretID), + Key: to.Ptr(src.AzureWorkloadIdentity.Token.Key), + } + } + } + + if src.AwsIrsa != nil { + result.AwsIrsa = &BicepAwsIrsaAuthentication{ + RoleArn: to.Ptr(src.AwsIrsa.RoleArn), + } + if src.AwsIrsa.Token != nil { + result.AwsIrsa.Token = &SecretReference{ + SecretID: to.Ptr(src.AwsIrsa.Token.SecretID), + Key: to.Ptr(src.AwsIrsa.Token.Key), + } + } + } + + return result +} diff --git a/pkg/corerp/api/v20250801preview/bicepsettings_conversion_test.go b/pkg/corerp/api/v20250801preview/bicepsettings_conversion_test.go new file mode 100644 index 0000000000..e2e83efecb --- /dev/null +++ b/pkg/corerp/api/v20250801preview/bicepsettings_conversion_test.go @@ -0,0 +1,201 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v20250801preview + +import ( + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func TestBicepSettingsConvertVersionedToDataModel(t *testing.T) { + versionedResource := &BicepSettingsResource{ + ID: to.Ptr("/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/bicepSettings/my-bicep-settings"), + Name: to.Ptr("my-bicep-settings"), + Type: to.Ptr("Radius.Core/bicepSettings"), + Location: to.Ptr("global"), + Tags: map[string]*string{ + "env": to.Ptr("test"), + }, + Properties: &BicepSettingsProperties{ + ProvisioningState: to.Ptr(ProvisioningStateSucceeded), + Authentication: &BicepAuthenticationConfiguration{ + Registries: map[string]*BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &BicepBasicAuthentication{ + Username: to.Ptr("admin"), + Password: &SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/acr-password"), + Key: to.Ptr("password"), + }, + }, + }, + "ghcr.io": { + AzureWorkloadIdentity: &BicepAzureWorkloadIdentityAuthentication{ + ClientID: to.Ptr("00000000-0000-0000-0000-000000000001"), + TenantID: to.Ptr("00000000-0000-0000-0000-000000000002"), + Token: &SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/azure-token"), + Key: to.Ptr("token"), + }, + }, + }, + "ecr.aws": { + AwsIrsa: &BicepAwsIrsaAuthentication{ + RoleArn: to.Ptr("arn:aws:iam::123456789012:role/my-role"), + Token: &SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/aws-token"), + Key: to.Ptr("token"), + }, + }, + }, + }, + }, + }, + } + + dm, err := versionedResource.ConvertTo() + require.NoError(t, err) + + bs := dm.(*datamodel.BicepSettings_v20250801preview) + + require.Equal(t, "/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/bicepSettings/my-bicep-settings", bs.ID) + require.Equal(t, "my-bicep-settings", bs.Name) + require.Equal(t, "Radius.Core/bicepSettings", bs.Type) + require.Equal(t, "global", bs.Location) + require.Equal(t, map[string]string{"env": "test"}, bs.Tags) + + // Authentication + require.NotNil(t, bs.Properties.Authentication) + require.NotNil(t, bs.Properties.Authentication.Registries) + + // Basic auth + require.Contains(t, bs.Properties.Authentication.Registries, "myregistry.azurecr.io") + basicAuth := bs.Properties.Authentication.Registries["myregistry.azurecr.io"].Basic + require.NotNil(t, basicAuth) + require.Equal(t, "admin", basicAuth.Username) + require.NotNil(t, basicAuth.Password) + require.Equal(t, "/planes/radius/local/providers/Radius.Security/secrets/acr-password", basicAuth.Password.SecretID) + require.Equal(t, "password", basicAuth.Password.Key) + + // Azure Workload Identity auth + require.Contains(t, bs.Properties.Authentication.Registries, "ghcr.io") + azureAuth := bs.Properties.Authentication.Registries["ghcr.io"].AzureWorkloadIdentity + require.NotNil(t, azureAuth) + require.Equal(t, "00000000-0000-0000-0000-000000000001", azureAuth.ClientID) + require.Equal(t, "00000000-0000-0000-0000-000000000002", azureAuth.TenantID) + require.NotNil(t, azureAuth.Token) + require.Equal(t, "/planes/radius/local/providers/Radius.Security/secrets/azure-token", azureAuth.Token.SecretID) + require.Equal(t, "token", azureAuth.Token.Key) + + // AWS IRSA auth + require.Contains(t, bs.Properties.Authentication.Registries, "ecr.aws") + awsAuth := bs.Properties.Authentication.Registries["ecr.aws"].AwsIrsa + require.NotNil(t, awsAuth) + require.Equal(t, "arn:aws:iam::123456789012:role/my-role", awsAuth.RoleArn) + require.NotNil(t, awsAuth.Token) + require.Equal(t, "/planes/radius/local/providers/Radius.Security/secrets/aws-token", awsAuth.Token.SecretID) + require.Equal(t, "token", awsAuth.Token.Key) +} + +func TestBicepSettingsConvertDataModelToVersioned(t *testing.T) { + dataModelResource := &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/bicepSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/bicepSettings", + Location: "global", + Tags: map[string]string{ + "env": "prod", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: Version, + UpdatedAPIVersion: Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{ + Authentication: &datamodel.BicepAuthenticationConfiguration{ + Registries: map[string]*datamodel.BicepRegistryAuthentication{ + "docker.io": { + Basic: &datamodel.BicepBasicAuthentication{ + Username: "docker-user", + Password: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/docker-pass", + Key: "password", + }, + }, + }, + "quay.io": { + AzureWorkloadIdentity: &datamodel.BicepAzureWorkloadIdentityAuthentication{ + ClientID: "client-id-123", + TenantID: "tenant-id-456", + Token: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/quay-token", + Key: "access-token", + }, + }, + }, + }, + }, + }, + } + + versionedResource := &BicepSettingsResource{} + err := versionedResource.ConvertFrom(dataModelResource) + require.NoError(t, err) + + require.Equal(t, to.Ptr("test-settings"), versionedResource.Name) + require.Equal(t, to.Ptr("Radius.Core/bicepSettings"), versionedResource.Type) + require.Equal(t, to.Ptr("global"), versionedResource.Location) + require.Equal(t, map[string]*string{"env": to.Ptr("prod")}, versionedResource.Tags) + + // Authentication + require.NotNil(t, versionedResource.Properties.Authentication) + require.NotNil(t, versionedResource.Properties.Authentication.Registries) + + // Basic auth + require.Contains(t, versionedResource.Properties.Authentication.Registries, "docker.io") + basicAuth := versionedResource.Properties.Authentication.Registries["docker.io"].Basic + require.NotNil(t, basicAuth) + require.Equal(t, to.Ptr("docker-user"), basicAuth.Username) + require.NotNil(t, basicAuth.Password) + require.Equal(t, to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/docker-pass"), basicAuth.Password.SecretID) + require.Equal(t, to.Ptr("password"), basicAuth.Password.Key) + + // Azure Workload Identity auth + require.Contains(t, versionedResource.Properties.Authentication.Registries, "quay.io") + azureAuth := versionedResource.Properties.Authentication.Registries["quay.io"].AzureWorkloadIdentity + require.NotNil(t, azureAuth) + require.Equal(t, to.Ptr("client-id-123"), azureAuth.ClientID) + require.Equal(t, to.Ptr("tenant-id-456"), azureAuth.TenantID) + require.NotNil(t, azureAuth.Token) + require.Equal(t, to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/quay-token"), azureAuth.Token.SecretID) + require.Equal(t, to.Ptr("access-token"), azureAuth.Token.Key) +} + +func TestBicepSettingsConvertFromInvalidType(t *testing.T) { + versionedResource := &BicepSettingsResource{} + err := versionedResource.ConvertFrom(&datamodel.Environment_v20250801preview{}) + require.Error(t, err) + require.Equal(t, v1.ErrInvalidModelConversion, err) +} diff --git a/pkg/corerp/api/v20250801preview/environment_conversion.go b/pkg/corerp/api/v20250801preview/environment_conversion.go index 664b6f4bfc..f99170aa0b 100644 --- a/pkg/corerp/api/v20250801preview/environment_conversion.go +++ b/pkg/corerp/api/v20250801preview/environment_conversion.go @@ -64,6 +64,16 @@ func (src *EnvironmentResource) ConvertTo() (v1.DataModelInterface, error) { converted.Properties.Simulated = true } + // Convert TerraformSettings + if src.Properties.TerraformSettings != nil { + converted.Properties.TerraformSettings = to.String(src.Properties.TerraformSettings) + } + + // Convert BicepSettings + if src.Properties.BicepSettings != nil { + converted.Properties.BicepSettings = to.String(src.Properties.BicepSettings) + } + return converted, nil } @@ -104,6 +114,16 @@ func (dst *EnvironmentResource) ConvertFrom(src v1.DataModelInterface) error { dst.Properties.Simulated = to.Ptr(env.Properties.Simulated) } + // Convert TerraformSettings + if env.Properties.TerraformSettings != "" { + dst.Properties.TerraformSettings = to.Ptr(env.Properties.TerraformSettings) + } + + // Convert BicepSettings + if env.Properties.BicepSettings != "" { + dst.Properties.BicepSettings = to.Ptr(env.Properties.BicepSettings) + } + return nil } diff --git a/pkg/corerp/api/v20250801preview/environment_conversion_test.go b/pkg/corerp/api/v20250801preview/environment_conversion_test.go index 06a52a7962..a56a00271c 100644 --- a/pkg/corerp/api/v20250801preview/environment_conversion_test.go +++ b/pkg/corerp/api/v20250801preview/environment_conversion_test.go @@ -44,6 +44,8 @@ func TestEnvironmentConvertVersionedToDataModel(t *testing.T) { "allowPlatformOptions": false, }, }, + TerraformSettings: to.Ptr("/planes/radius/local/providers/Radius.Core/terraformSettings/org-default"), + BicepSettings: to.Ptr("/planes/radius/local/providers/Radius.Core/bicepSettings/org-default"), Providers: &Providers{ Azure: &ProvidersAzure{ SubscriptionID: to.Ptr("00000000-0000-0000-0000-000000000000"), @@ -71,6 +73,8 @@ func TestEnvironmentConvertVersionedToDataModel(t *testing.T) { require.Equal(t, map[string]string{"env": "test"}, env.Tags) require.Equal(t, []string{"/planes/radius/local/providers/Radius.Core/recipePacks/azure-aci-pack"}, env.Properties.RecipePacks) require.Equal(t, false, env.Properties.Simulated) + require.Equal(t, "/planes/radius/local/providers/Radius.Core/terraformSettings/org-default", env.Properties.TerraformSettings) + require.Equal(t, "/planes/radius/local/providers/Radius.Core/bicepSettings/org-default", env.Properties.BicepSettings) require.NotNil(t, env.Properties.Providers) require.NotNil(t, env.Properties.Providers.Azure) require.Equal(t, "00000000-0000-0000-0000-000000000000", env.Properties.Providers.Azure.SubscriptionId) @@ -107,6 +111,8 @@ func TestEnvironmentConvertDataModelToVersioned(t *testing.T) { "allowPlatformOptions": true, }, }, + TerraformSettings: "/planes/radius/local/providers/Radius.Core/terraformSettings/org-default", + BicepSettings: "/planes/radius/local/providers/Radius.Core/bicepSettings/org-default", Providers: &datamodel.Providers_v20250801preview{ Kubernetes: &datamodel.ProvidersKubernetes_v20250801preview{ Namespace: "default", @@ -133,4 +139,6 @@ func TestEnvironmentConvertDataModelToVersioned(t *testing.T) { containerParams, ok := versionedResource.Properties.RecipeParameters["Radius.Compute/containers"] require.True(t, ok) require.Equal(t, true, containerParams["allowPlatformOptions"]) + require.Equal(t, to.Ptr("/planes/radius/local/providers/Radius.Core/terraformSettings/org-default"), versionedResource.Properties.TerraformSettings) + require.Equal(t, to.Ptr("/planes/radius/local/providers/Radius.Core/bicepSettings/org-default"), versionedResource.Properties.BicepSettings) } diff --git a/pkg/corerp/api/v20250801preview/fake/zz_generated_bicepsettings_server.go b/pkg/corerp/api/v20250801preview/fake/zz_generated_bicepsettings_server.go new file mode 100644 index 0000000000..e0b0538f79 --- /dev/null +++ b/pkg/corerp/api/v20250801preview/fake/zz_generated_bicepsettings_server.go @@ -0,0 +1,274 @@ +// Licensed under the Apache License, Version 2.0 . See LICENSE in the repository root for license information. +// Code generated by Microsoft (R) AutoRest Code Generator. DO NOT EDIT. +// Changes may cause incorrect behavior and will be lost if the code is regenerated. + +package fake + +import ( + "context" + "errors" + "fmt" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake/server" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "net/http" + "net/url" + "regexp" +) + +// BicepSettingsServer is a fake server for instances of the v20250801preview.BicepSettingsClient type. +type BicepSettingsServer struct { + // CreateOrUpdate is the fake for method BicepSettingsClient.CreateOrUpdate + // HTTP status codes to indicate success: http.StatusOK, http.StatusCreated + CreateOrUpdate func(ctx context.Context, bicepSettingsName string, resource v20250801preview.BicepSettingsResource, options *v20250801preview.BicepSettingsClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.BicepSettingsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) + + // Delete is the fake for method BicepSettingsClient.Delete + // HTTP status codes to indicate success: http.StatusOK, http.StatusNoContent + Delete func(ctx context.Context, bicepSettingsName string, options *v20250801preview.BicepSettingsClientDeleteOptions) (resp azfake.Responder[v20250801preview.BicepSettingsClientDeleteResponse], errResp azfake.ErrorResponder) + + // Get is the fake for method BicepSettingsClient.Get + // HTTP status codes to indicate success: http.StatusOK + Get func(ctx context.Context, bicepSettingsName string, options *v20250801preview.BicepSettingsClientGetOptions) (resp azfake.Responder[v20250801preview.BicepSettingsClientGetResponse], errResp azfake.ErrorResponder) + + // NewListByScopePager is the fake for method BicepSettingsClient.NewListByScopePager + // HTTP status codes to indicate success: http.StatusOK + NewListByScopePager func(options *v20250801preview.BicepSettingsClientListByScopeOptions) (resp azfake.PagerResponder[v20250801preview.BicepSettingsClientListByScopeResponse]) + + // Update is the fake for method BicepSettingsClient.Update + // HTTP status codes to indicate success: http.StatusOK + Update func(ctx context.Context, bicepSettingsName string, properties v20250801preview.BicepSettingsResourceUpdate, options *v20250801preview.BicepSettingsClientUpdateOptions) (resp azfake.Responder[v20250801preview.BicepSettingsClientUpdateResponse], errResp azfake.ErrorResponder) +} + +// NewBicepSettingsServerTransport creates a new instance of BicepSettingsServerTransport with the provided implementation. +// The returned BicepSettingsServerTransport instance is connected to an instance of v20250801preview.BicepSettingsClient via the +// azcore.ClientOptions.Transporter field in the client's constructor parameters. +func NewBicepSettingsServerTransport(srv *BicepSettingsServer) *BicepSettingsServerTransport { + return &BicepSettingsServerTransport{ + srv: srv, + newListByScopePager: newTracker[azfake.PagerResponder[v20250801preview.BicepSettingsClientListByScopeResponse]](), + } +} + +// BicepSettingsServerTransport connects instances of v20250801preview.BicepSettingsClient to instances of BicepSettingsServer. +// Don't use this type directly, use NewBicepSettingsServerTransport instead. +type BicepSettingsServerTransport struct { + srv *BicepSettingsServer + newListByScopePager *tracker[azfake.PagerResponder[v20250801preview.BicepSettingsClientListByScopeResponse]] +} + +// Do implements the policy.Transporter interface for BicepSettingsServerTransport. +func (b *BicepSettingsServerTransport) Do(req *http.Request) (*http.Response, error) { + rawMethod := req.Context().Value(runtime.CtxAPINameKey{}) + method, ok := rawMethod.(string) + if !ok { + return nil, nonRetriableError{errors.New("unable to dispatch request, missing value for CtxAPINameKey")} + } + + return b.dispatchToMethodFake(req, method) +} + +func (b *BicepSettingsServerTransport) dispatchToMethodFake(req *http.Request, method string) (*http.Response, error) { + resultChan := make(chan result) + defer close(resultChan) + + go func() { + var intercepted bool + var res result + if bicepSettingsServerTransportInterceptor != nil { + res.resp, res.err, intercepted = bicepSettingsServerTransportInterceptor.Do(req) + } + if !intercepted { + switch method { + case "BicepSettingsClient.CreateOrUpdate": + res.resp, res.err = b.dispatchCreateOrUpdate(req) + case "BicepSettingsClient.Delete": + res.resp, res.err = b.dispatchDelete(req) + case "BicepSettingsClient.Get": + res.resp, res.err = b.dispatchGet(req) + case "BicepSettingsClient.NewListByScopePager": + res.resp, res.err = b.dispatchNewListByScopePager(req) + case "BicepSettingsClient.Update": + res.resp, res.err = b.dispatchUpdate(req) + default: + res.err = fmt.Errorf("unhandled API %s", method) + } + + } + select { + case resultChan <- res: + case <-req.Context().Done(): + } + }() + + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case res := <-resultChan: + return res.resp, res.err + } +} + +func (b *BicepSettingsServerTransport) dispatchCreateOrUpdate(req *http.Request) (*http.Response, error) { + if b.srv.CreateOrUpdate == nil { + return nil, &nonRetriableError{errors.New("fake for method CreateOrUpdate not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/bicepSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + body, err := server.UnmarshalRequestAsJSON[v20250801preview.BicepSettingsResource](req) + if err != nil { + return nil, err + } + bicepSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("bicepSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := b.srv.CreateOrUpdate(req.Context(), bicepSettingsNameParam, body, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK, http.StatusCreated}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK, http.StatusCreated", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).BicepSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (b *BicepSettingsServerTransport) dispatchDelete(req *http.Request) (*http.Response, error) { + if b.srv.Delete == nil { + return nil, &nonRetriableError{errors.New("fake for method Delete not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/bicepSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + bicepSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("bicepSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := b.srv.Delete(req.Context(), bicepSettingsNameParam, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK, http.StatusNoContent}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK, http.StatusNoContent", respContent.HTTPStatus)} + } + resp, err := server.NewResponse(respContent, req, nil) + if err != nil { + return nil, err + } + return resp, nil +} + +func (b *BicepSettingsServerTransport) dispatchGet(req *http.Request) (*http.Response, error) { + if b.srv.Get == nil { + return nil, &nonRetriableError{errors.New("fake for method Get not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/bicepSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + bicepSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("bicepSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := b.srv.Get(req.Context(), bicepSettingsNameParam, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).BicepSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (b *BicepSettingsServerTransport) dispatchNewListByScopePager(req *http.Request) (*http.Response, error) { + if b.srv.NewListByScopePager == nil { + return nil, &nonRetriableError{errors.New("fake for method NewListByScopePager not implemented")} + } + newListByScopePager := b.newListByScopePager.get(req) + if newListByScopePager == nil { + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/bicepSettings` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 2 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + resp := b.srv.NewListByScopePager(nil) + newListByScopePager = &resp + b.newListByScopePager.add(req, newListByScopePager) + server.PagerResponderInjectNextLinks(newListByScopePager, req, func(page *v20250801preview.BicepSettingsClientListByScopeResponse, createLink func() string) { + page.NextLink = to.Ptr(createLink()) + }) + } + resp, err := server.PagerResponderNext(newListByScopePager, req) + if err != nil { + return nil, err + } + if !contains([]int{http.StatusOK}, resp.StatusCode) { + b.newListByScopePager.remove(req) + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", resp.StatusCode)} + } + if !server.PagerResponderMore(newListByScopePager) { + b.newListByScopePager.remove(req) + } + return resp, nil +} + +func (b *BicepSettingsServerTransport) dispatchUpdate(req *http.Request) (*http.Response, error) { + if b.srv.Update == nil { + return nil, &nonRetriableError{errors.New("fake for method Update not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/bicepSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + body, err := server.UnmarshalRequestAsJSON[v20250801preview.BicepSettingsResourceUpdate](req) + if err != nil { + return nil, err + } + bicepSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("bicepSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := b.srv.Update(req.Context(), bicepSettingsNameParam, body, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).BicepSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +// set this to conditionally intercept incoming requests to BicepSettingsServerTransport +var bicepSettingsServerTransportInterceptor interface { + // Do returns true if the server transport should use the returned response/error + Do(*http.Request) (*http.Response, error, bool) +} diff --git a/pkg/corerp/api/v20250801preview/fake/zz_generated_server_factory.go b/pkg/corerp/api/v20250801preview/fake/zz_generated_server_factory.go index c2e69a5140..997655a530 100644 --- a/pkg/corerp/api/v20250801preview/fake/zz_generated_server_factory.go +++ b/pkg/corerp/api/v20250801preview/fake/zz_generated_server_factory.go @@ -18,6 +18,9 @@ type ServerFactory struct { // ApplicationsServer contains the fakes for client ApplicationsClient ApplicationsServer ApplicationsServer + // BicepSettingsServer contains the fakes for client BicepSettingsClient + BicepSettingsServer BicepSettingsServer + // EnvironmentsServer contains the fakes for client EnvironmentsClient EnvironmentsServer EnvironmentsServer @@ -26,6 +29,9 @@ type ServerFactory struct { // RecipePacksServer contains the fakes for client RecipePacksClient RecipePacksServer RecipePacksServer + + // TerraformSettingsServer contains the fakes for client TerraformSettingsClient + TerraformSettingsServer TerraformSettingsServer } // NewServerFactoryTransport creates a new instance of ServerFactoryTransport with the provided implementation. @@ -40,12 +46,14 @@ func NewServerFactoryTransport(srv *ServerFactory) *ServerFactoryTransport { // ServerFactoryTransport connects instances of v20250801preview.ClientFactory to instances of ServerFactory. // Don't use this type directly, use NewServerFactoryTransport instead. type ServerFactoryTransport struct { - srv *ServerFactory - trMu sync.Mutex - trApplicationsServer *ApplicationsServerTransport - trEnvironmentsServer *EnvironmentsServerTransport - trOperationsServer *OperationsServerTransport - trRecipePacksServer *RecipePacksServerTransport + srv *ServerFactory + trMu sync.Mutex + trApplicationsServer *ApplicationsServerTransport + trBicepSettingsServer *BicepSettingsServerTransport + trEnvironmentsServer *EnvironmentsServerTransport + trOperationsServer *OperationsServerTransport + trRecipePacksServer *RecipePacksServerTransport + trTerraformSettingsServer *TerraformSettingsServerTransport } // Do implements the policy.Transporter interface for ServerFactoryTransport. @@ -64,6 +72,11 @@ func (s *ServerFactoryTransport) Do(req *http.Request) (*http.Response, error) { case "ApplicationsClient": initServer(s, &s.trApplicationsServer, func() *ApplicationsServerTransport { return NewApplicationsServerTransport(&s.srv.ApplicationsServer) }) resp, err = s.trApplicationsServer.Do(req) + case "BicepSettingsClient": + initServer(s, &s.trBicepSettingsServer, func() *BicepSettingsServerTransport { + return NewBicepSettingsServerTransport(&s.srv.BicepSettingsServer) + }) + resp, err = s.trBicepSettingsServer.Do(req) case "EnvironmentsClient": initServer(s, &s.trEnvironmentsServer, func() *EnvironmentsServerTransport { return NewEnvironmentsServerTransport(&s.srv.EnvironmentsServer) }) resp, err = s.trEnvironmentsServer.Do(req) @@ -73,6 +86,11 @@ func (s *ServerFactoryTransport) Do(req *http.Request) (*http.Response, error) { case "RecipePacksClient": initServer(s, &s.trRecipePacksServer, func() *RecipePacksServerTransport { return NewRecipePacksServerTransport(&s.srv.RecipePacksServer) }) resp, err = s.trRecipePacksServer.Do(req) + case "TerraformSettingsClient": + initServer(s, &s.trTerraformSettingsServer, func() *TerraformSettingsServerTransport { + return NewTerraformSettingsServerTransport(&s.srv.TerraformSettingsServer) + }) + resp, err = s.trTerraformSettingsServer.Do(req) default: err = fmt.Errorf("unhandled client %s", client) } diff --git a/pkg/corerp/api/v20250801preview/fake/zz_generated_terraformsettings_server.go b/pkg/corerp/api/v20250801preview/fake/zz_generated_terraformsettings_server.go new file mode 100644 index 0000000000..38144515bf --- /dev/null +++ b/pkg/corerp/api/v20250801preview/fake/zz_generated_terraformsettings_server.go @@ -0,0 +1,274 @@ +// Licensed under the Apache License, Version 2.0 . See LICENSE in the repository root for license information. +// Code generated by Microsoft (R) AutoRest Code Generator. DO NOT EDIT. +// Changes may cause incorrect behavior and will be lost if the code is regenerated. + +package fake + +import ( + "context" + "errors" + "fmt" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake/server" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "net/http" + "net/url" + "regexp" +) + +// TerraformSettingsServer is a fake server for instances of the v20250801preview.TerraformSettingsClient type. +type TerraformSettingsServer struct { + // CreateOrUpdate is the fake for method TerraformSettingsClient.CreateOrUpdate + // HTTP status codes to indicate success: http.StatusOK, http.StatusCreated + CreateOrUpdate func(ctx context.Context, terraformSettingsName string, resource v20250801preview.TerraformSettingsResource, options *v20250801preview.TerraformSettingsClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.TerraformSettingsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) + + // Delete is the fake for method TerraformSettingsClient.Delete + // HTTP status codes to indicate success: http.StatusOK, http.StatusNoContent + Delete func(ctx context.Context, terraformSettingsName string, options *v20250801preview.TerraformSettingsClientDeleteOptions) (resp azfake.Responder[v20250801preview.TerraformSettingsClientDeleteResponse], errResp azfake.ErrorResponder) + + // Get is the fake for method TerraformSettingsClient.Get + // HTTP status codes to indicate success: http.StatusOK + Get func(ctx context.Context, terraformSettingsName string, options *v20250801preview.TerraformSettingsClientGetOptions) (resp azfake.Responder[v20250801preview.TerraformSettingsClientGetResponse], errResp azfake.ErrorResponder) + + // NewListByScopePager is the fake for method TerraformSettingsClient.NewListByScopePager + // HTTP status codes to indicate success: http.StatusOK + NewListByScopePager func(options *v20250801preview.TerraformSettingsClientListByScopeOptions) (resp azfake.PagerResponder[v20250801preview.TerraformSettingsClientListByScopeResponse]) + + // Update is the fake for method TerraformSettingsClient.Update + // HTTP status codes to indicate success: http.StatusOK + Update func(ctx context.Context, terraformSettingsName string, properties v20250801preview.TerraformSettingsResourceUpdate, options *v20250801preview.TerraformSettingsClientUpdateOptions) (resp azfake.Responder[v20250801preview.TerraformSettingsClientUpdateResponse], errResp azfake.ErrorResponder) +} + +// NewTerraformSettingsServerTransport creates a new instance of TerraformSettingsServerTransport with the provided implementation. +// The returned TerraformSettingsServerTransport instance is connected to an instance of v20250801preview.TerraformSettingsClient via the +// azcore.ClientOptions.Transporter field in the client's constructor parameters. +func NewTerraformSettingsServerTransport(srv *TerraformSettingsServer) *TerraformSettingsServerTransport { + return &TerraformSettingsServerTransport{ + srv: srv, + newListByScopePager: newTracker[azfake.PagerResponder[v20250801preview.TerraformSettingsClientListByScopeResponse]](), + } +} + +// TerraformSettingsServerTransport connects instances of v20250801preview.TerraformSettingsClient to instances of TerraformSettingsServer. +// Don't use this type directly, use NewTerraformSettingsServerTransport instead. +type TerraformSettingsServerTransport struct { + srv *TerraformSettingsServer + newListByScopePager *tracker[azfake.PagerResponder[v20250801preview.TerraformSettingsClientListByScopeResponse]] +} + +// Do implements the policy.Transporter interface for TerraformSettingsServerTransport. +func (t *TerraformSettingsServerTransport) Do(req *http.Request) (*http.Response, error) { + rawMethod := req.Context().Value(runtime.CtxAPINameKey{}) + method, ok := rawMethod.(string) + if !ok { + return nil, nonRetriableError{errors.New("unable to dispatch request, missing value for CtxAPINameKey")} + } + + return t.dispatchToMethodFake(req, method) +} + +func (t *TerraformSettingsServerTransport) dispatchToMethodFake(req *http.Request, method string) (*http.Response, error) { + resultChan := make(chan result) + defer close(resultChan) + + go func() { + var intercepted bool + var res result + if terraformSettingsServerTransportInterceptor != nil { + res.resp, res.err, intercepted = terraformSettingsServerTransportInterceptor.Do(req) + } + if !intercepted { + switch method { + case "TerraformSettingsClient.CreateOrUpdate": + res.resp, res.err = t.dispatchCreateOrUpdate(req) + case "TerraformSettingsClient.Delete": + res.resp, res.err = t.dispatchDelete(req) + case "TerraformSettingsClient.Get": + res.resp, res.err = t.dispatchGet(req) + case "TerraformSettingsClient.NewListByScopePager": + res.resp, res.err = t.dispatchNewListByScopePager(req) + case "TerraformSettingsClient.Update": + res.resp, res.err = t.dispatchUpdate(req) + default: + res.err = fmt.Errorf("unhandled API %s", method) + } + + } + select { + case resultChan <- res: + case <-req.Context().Done(): + } + }() + + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case res := <-resultChan: + return res.resp, res.err + } +} + +func (t *TerraformSettingsServerTransport) dispatchCreateOrUpdate(req *http.Request) (*http.Response, error) { + if t.srv.CreateOrUpdate == nil { + return nil, &nonRetriableError{errors.New("fake for method CreateOrUpdate not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/terraformSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + body, err := server.UnmarshalRequestAsJSON[v20250801preview.TerraformSettingsResource](req) + if err != nil { + return nil, err + } + terraformSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("terraformSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := t.srv.CreateOrUpdate(req.Context(), terraformSettingsNameParam, body, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK, http.StatusCreated}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK, http.StatusCreated", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).TerraformSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (t *TerraformSettingsServerTransport) dispatchDelete(req *http.Request) (*http.Response, error) { + if t.srv.Delete == nil { + return nil, &nonRetriableError{errors.New("fake for method Delete not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/terraformSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + terraformSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("terraformSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := t.srv.Delete(req.Context(), terraformSettingsNameParam, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK, http.StatusNoContent}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK, http.StatusNoContent", respContent.HTTPStatus)} + } + resp, err := server.NewResponse(respContent, req, nil) + if err != nil { + return nil, err + } + return resp, nil +} + +func (t *TerraformSettingsServerTransport) dispatchGet(req *http.Request) (*http.Response, error) { + if t.srv.Get == nil { + return nil, &nonRetriableError{errors.New("fake for method Get not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/terraformSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + terraformSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("terraformSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := t.srv.Get(req.Context(), terraformSettingsNameParam, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).TerraformSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +func (t *TerraformSettingsServerTransport) dispatchNewListByScopePager(req *http.Request) (*http.Response, error) { + if t.srv.NewListByScopePager == nil { + return nil, &nonRetriableError{errors.New("fake for method NewListByScopePager not implemented")} + } + newListByScopePager := t.newListByScopePager.get(req) + if newListByScopePager == nil { + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/terraformSettings` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 2 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + resp := t.srv.NewListByScopePager(nil) + newListByScopePager = &resp + t.newListByScopePager.add(req, newListByScopePager) + server.PagerResponderInjectNextLinks(newListByScopePager, req, func(page *v20250801preview.TerraformSettingsClientListByScopeResponse, createLink func() string) { + page.NextLink = to.Ptr(createLink()) + }) + } + resp, err := server.PagerResponderNext(newListByScopePager, req) + if err != nil { + return nil, err + } + if !contains([]int{http.StatusOK}, resp.StatusCode) { + t.newListByScopePager.remove(req) + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", resp.StatusCode)} + } + if !server.PagerResponderMore(newListByScopePager) { + t.newListByScopePager.remove(req) + } + return resp, nil +} + +func (t *TerraformSettingsServerTransport) dispatchUpdate(req *http.Request) (*http.Response, error) { + if t.srv.Update == nil { + return nil, &nonRetriableError{errors.New("fake for method Update not implemented")} + } + const regexStr = `/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)/providers/Radius\.Core/terraformSettings/(?P[!#&$-;=?-\[\]_a-zA-Z0-9~%@]+)` + regex := regexp.MustCompile(regexStr) + matches := regex.FindStringSubmatch(req.URL.EscapedPath()) + if len(matches) < 3 { + return nil, fmt.Errorf("failed to parse path %s", req.URL.Path) + } + body, err := server.UnmarshalRequestAsJSON[v20250801preview.TerraformSettingsResourceUpdate](req) + if err != nil { + return nil, err + } + terraformSettingsNameParam, err := url.PathUnescape(matches[regex.SubexpIndex("terraformSettingsName")]) + if err != nil { + return nil, err + } + respr, errRespr := t.srv.Update(req.Context(), terraformSettingsNameParam, body, nil) + if respErr := server.GetError(errRespr, req); respErr != nil { + return nil, respErr + } + respContent := server.GetResponseContent(respr) + if !contains([]int{http.StatusOK}, respContent.HTTPStatus) { + return nil, &nonRetriableError{fmt.Errorf("unexpected status code %d. acceptable values are http.StatusOK", respContent.HTTPStatus)} + } + resp, err := server.MarshalResponseAsJSON(respContent, server.GetResponse(respr).TerraformSettingsResource, req) + if err != nil { + return nil, err + } + return resp, nil +} + +// set this to conditionally intercept incoming requests to TerraformSettingsServerTransport +var terraformSettingsServerTransportInterceptor interface { + // Do returns true if the server transport should use the returned response/error + Do(*http.Request) (*http.Response, error, bool) +} diff --git a/pkg/corerp/api/v20250801preview/terraformsettings_conversion.go b/pkg/corerp/api/v20250801preview/terraformsettings_conversion.go new file mode 100644 index 0000000000..3f19206168 --- /dev/null +++ b/pkg/corerp/api/v20250801preview/terraformsettings_conversion.go @@ -0,0 +1,265 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v20250801preview + +import ( + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +// ConvertTo converts from the versioned TerraformSettingsResource to version-agnostic datamodel. +func (src *TerraformSettingsResource) ConvertTo() (v1.DataModelInterface, error) { + converted := &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: to.String(src.ID), + Name: to.String(src.Name), + Type: to.String(src.Type), + Location: to.String(src.Location), + Tags: to.StringMap(src.Tags), + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: Version, + UpdatedAPIVersion: Version, + AsyncProvisioningState: toProvisioningStateDataModel(src.Properties.ProvisioningState), + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{}, + } + + // Convert TerraformRC + if src.Properties.Terraformrc != nil { + converted.Properties.TerraformRC = toTerraformCliConfigurationDataModel(src.Properties.Terraformrc) + } + + // Convert Backend + if src.Properties.Backend != nil { + converted.Properties.Backend = toTerraformBackendConfigurationDataModel(src.Properties.Backend) + } + + // Convert Env + if src.Properties.Env != nil { + converted.Properties.Env = to.StringMap(src.Properties.Env) + } + + // Convert Logging + if src.Properties.Logging != nil { + converted.Properties.Logging = toTerraformLoggingConfigurationDataModel(src.Properties.Logging) + } + + return converted, nil +} + +// ConvertFrom converts from version-agnostic datamodel to the versioned TerraformSettingsResource. +func (dst *TerraformSettingsResource) ConvertFrom(src v1.DataModelInterface) error { + ts, ok := src.(*datamodel.TerraformSettings_v20250801preview) + if !ok { + return v1.ErrInvalidModelConversion + } + + dst.ID = to.Ptr(ts.ID) + dst.Name = to.Ptr(ts.Name) + dst.Type = to.Ptr(ts.Type) + dst.SystemData = fromSystemDataModel(&ts.SystemData) + dst.Location = to.Ptr(ts.Location) + dst.Tags = *to.StringMapPtr(ts.Tags) + dst.Properties = &TerraformSettingsProperties{ + ProvisioningState: fromProvisioningStateDataModel(ts.InternalMetadata.AsyncProvisioningState), + } + + // Convert TerraformRC + if ts.Properties.TerraformRC != nil { + dst.Properties.Terraformrc = fromTerraformCliConfigurationDataModel(ts.Properties.TerraformRC) + } + + // Convert Backend + if ts.Properties.Backend != nil { + dst.Properties.Backend = fromTerraformBackendConfigurationDataModel(ts.Properties.Backend) + } + + // Convert Env + if len(ts.Properties.Env) > 0 { + dst.Properties.Env = *to.StringMapPtr(ts.Properties.Env) + } + + // Convert Logging + if ts.Properties.Logging != nil { + dst.Properties.Logging = fromTerraformLoggingConfigurationDataModel(ts.Properties.Logging) + } + + return nil +} + +func toTerraformCliConfigurationDataModel(src *TerraformCliConfiguration) *datamodel.TerraformCliConfiguration { + if src == nil { + return nil + } + + result := &datamodel.TerraformCliConfiguration{} + + // Convert ProviderInstallation + if src.ProviderInstallation != nil { + result.ProviderInstallation = &datamodel.TerraformProviderInstallationConfiguration{} + + if src.ProviderInstallation.NetworkMirror != nil { + result.ProviderInstallation.NetworkMirror = &datamodel.TerraformNetworkMirrorConfiguration{ + URL: to.String(src.ProviderInstallation.NetworkMirror.URL), + Include: to.StringArray(src.ProviderInstallation.NetworkMirror.Include), + Exclude: to.StringArray(src.ProviderInstallation.NetworkMirror.Exclude), + } + } + + if src.ProviderInstallation.Direct != nil { + result.ProviderInstallation.Direct = &datamodel.TerraformDirectConfiguration{ + Include: to.StringArray(src.ProviderInstallation.Direct.Include), + Exclude: to.StringArray(src.ProviderInstallation.Direct.Exclude), + } + } + } + + // Convert Credentials + if src.Credentials != nil { + result.Credentials = make(map[string]*datamodel.TerraformCredentialConfiguration) + for k, v := range src.Credentials { + if v != nil { + result.Credentials[k] = &datamodel.TerraformCredentialConfiguration{} + if v.Token != nil { + result.Credentials[k].Token = &datamodel.SecretRef{ + SecretID: to.String(v.Token.SecretID), + Key: to.String(v.Token.Key), + } + } + } + } + } + + return result +} + +func fromTerraformCliConfigurationDataModel(src *datamodel.TerraformCliConfiguration) *TerraformCliConfiguration { + if src == nil { + return nil + } + + result := &TerraformCliConfiguration{} + + // Convert ProviderInstallation + if src.ProviderInstallation != nil { + result.ProviderInstallation = &TerraformProviderInstallationConfiguration{} + + if src.ProviderInstallation.NetworkMirror != nil { + result.ProviderInstallation.NetworkMirror = &TerraformNetworkMirrorConfiguration{ + URL: to.Ptr(src.ProviderInstallation.NetworkMirror.URL), + Include: to.SliceOfPtrs(src.ProviderInstallation.NetworkMirror.Include...), + Exclude: to.SliceOfPtrs(src.ProviderInstallation.NetworkMirror.Exclude...), + } + } + + if src.ProviderInstallation.Direct != nil { + result.ProviderInstallation.Direct = &TerraformDirectConfiguration{ + Include: to.SliceOfPtrs(src.ProviderInstallation.Direct.Include...), + Exclude: to.SliceOfPtrs(src.ProviderInstallation.Direct.Exclude...), + } + } + } + + // Convert Credentials + if src.Credentials != nil { + result.Credentials = make(map[string]*TerraformCredentialConfiguration) + for k, v := range src.Credentials { + if v != nil { + result.Credentials[k] = &TerraformCredentialConfiguration{} + if v.Token != nil { + result.Credentials[k].Token = &SecretReference{ + SecretID: to.Ptr(v.Token.SecretID), + Key: to.Ptr(v.Token.Key), + } + } + } + } + } + + return result +} + +func toTerraformBackendConfigurationDataModel(src *TerraformBackendConfiguration) *datamodel.TerraformBackendConfiguration { + if src == nil { + return nil + } + + result := &datamodel.TerraformBackendConfiguration{ + Type: to.String(src.Type), + } + + // Convert map[string]*string to map[string]string + if src.Config != nil { + result.Config = to.StringMap(src.Config) + } + + return result +} + +func fromTerraformBackendConfigurationDataModel(src *datamodel.TerraformBackendConfiguration) *TerraformBackendConfiguration { + if src == nil { + return nil + } + + result := &TerraformBackendConfiguration{ + Type: to.Ptr(src.Type), + } + + // Convert map[string]string to map[string]*string + if src.Config != nil { + result.Config = *to.StringMapPtr(src.Config) + } + + return result +} + +func toTerraformLoggingConfigurationDataModel(src *TerraformLoggingConfiguration) *datamodel.TerraformLoggingConfiguration { + if src == nil { + return nil + } + + result := &datamodel.TerraformLoggingConfiguration{ + Path: to.String(src.Path), + } + + if src.Level != nil { + result.Level = datamodel.TerraformLogLevel(*src.Level) + } + + return result +} + +func fromTerraformLoggingConfigurationDataModel(src *datamodel.TerraformLoggingConfiguration) *TerraformLoggingConfiguration { + if src == nil { + return nil + } + + result := &TerraformLoggingConfiguration{ + Path: to.Ptr(src.Path), + } + + if src.Level != "" { + level := TerraformLogLevel(src.Level) + result.Level = &level + } + + return result +} diff --git a/pkg/corerp/api/v20250801preview/terraformsettings_conversion_test.go b/pkg/corerp/api/v20250801preview/terraformsettings_conversion_test.go new file mode 100644 index 0000000000..640be567bc --- /dev/null +++ b/pkg/corerp/api/v20250801preview/terraformsettings_conversion_test.go @@ -0,0 +1,219 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v20250801preview + +import ( + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func TestTerraformSettingsConvertVersionedToDataModel(t *testing.T) { + versionedResource := &TerraformSettingsResource{ + ID: to.Ptr("/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/terraformSettings/my-tf-settings"), + Name: to.Ptr("my-tf-settings"), + Type: to.Ptr("Radius.Core/terraformSettings"), + Location: to.Ptr("global"), + Tags: map[string]*string{ + "env": to.Ptr("test"), + }, + Properties: &TerraformSettingsProperties{ + ProvisioningState: to.Ptr(ProvisioningStateSucceeded), + Terraformrc: &TerraformCliConfiguration{ + ProviderInstallation: &TerraformProviderInstallationConfiguration{ + NetworkMirror: &TerraformNetworkMirrorConfiguration{ + URL: to.Ptr("https://mirror.corp.example.com/terraform/providers"), + Include: []*string{to.Ptr("*")}, + Exclude: []*string{to.Ptr("hashicorp/azurerm")}, + }, + Direct: &TerraformDirectConfiguration{ + Exclude: []*string{to.Ptr("hashicorp/azurerm")}, + }, + }, + Credentials: map[string]*TerraformCredentialConfiguration{ + "app.terraform.io": { + Token: &SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/tfc-token"), + Key: to.Ptr("token"), + }, + }, + }, + }, + Backend: &TerraformBackendConfiguration{ + Type: to.Ptr("kubernetes"), + Config: map[string]*string{ + "secretSuffix": to.Ptr("prod-terraform-state"), + "namespace": to.Ptr("radius-system"), + }, + }, + Env: map[string]*string{ + "TF_LOG": to.Ptr("TRACE"), + }, + Logging: &TerraformLoggingConfiguration{ + Level: to.Ptr(TerraformLogLevelTrace), + Path: to.Ptr("/var/log/terraform.log"), + }, + }, + } + + dm, err := versionedResource.ConvertTo() + require.NoError(t, err) + + ts := dm.(*datamodel.TerraformSettings_v20250801preview) + + require.Equal(t, "/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/terraformSettings/my-tf-settings", ts.ID) + require.Equal(t, "my-tf-settings", ts.Name) + require.Equal(t, "Radius.Core/terraformSettings", ts.Type) + require.Equal(t, "global", ts.Location) + require.Equal(t, map[string]string{"env": "test"}, ts.Tags) + + // TerraformRC + require.NotNil(t, ts.Properties.TerraformRC) + require.NotNil(t, ts.Properties.TerraformRC.ProviderInstallation) + require.NotNil(t, ts.Properties.TerraformRC.ProviderInstallation.NetworkMirror) + require.Equal(t, "https://mirror.corp.example.com/terraform/providers", ts.Properties.TerraformRC.ProviderInstallation.NetworkMirror.URL) + require.Equal(t, []string{"*"}, ts.Properties.TerraformRC.ProviderInstallation.NetworkMirror.Include) + require.Equal(t, []string{"hashicorp/azurerm"}, ts.Properties.TerraformRC.ProviderInstallation.NetworkMirror.Exclude) + require.NotNil(t, ts.Properties.TerraformRC.ProviderInstallation.Direct) + require.Equal(t, []string{"hashicorp/azurerm"}, ts.Properties.TerraformRC.ProviderInstallation.Direct.Exclude) + + // Credentials + require.NotNil(t, ts.Properties.TerraformRC.Credentials) + require.Contains(t, ts.Properties.TerraformRC.Credentials, "app.terraform.io") + require.NotNil(t, ts.Properties.TerraformRC.Credentials["app.terraform.io"].Token) + require.Equal(t, "/planes/radius/local/providers/Radius.Security/secrets/tfc-token", ts.Properties.TerraformRC.Credentials["app.terraform.io"].Token.SecretID) + require.Equal(t, "token", ts.Properties.TerraformRC.Credentials["app.terraform.io"].Token.Key) + + // Backend + require.NotNil(t, ts.Properties.Backend) + require.Equal(t, "kubernetes", ts.Properties.Backend.Type) + require.Equal(t, "prod-terraform-state", ts.Properties.Backend.Config["secretSuffix"]) + require.Equal(t, "radius-system", ts.Properties.Backend.Config["namespace"]) + + // Env + require.Equal(t, map[string]string{"TF_LOG": "TRACE"}, ts.Properties.Env) + + // Logging + require.NotNil(t, ts.Properties.Logging) + require.Equal(t, datamodel.TerraformLogLevelTrace, ts.Properties.Logging.Level) + require.Equal(t, "/var/log/terraform.log", ts.Properties.Logging.Path) +} + +func TestTerraformSettingsConvertDataModelToVersioned(t *testing.T) { + dataModelResource := &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/testGroup/providers/Radius.Core/terraformSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/terraformSettings", + Location: "global", + Tags: map[string]string{ + "env": "prod", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: Version, + UpdatedAPIVersion: Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{ + TerraformRC: &datamodel.TerraformCliConfiguration{ + ProviderInstallation: &datamodel.TerraformProviderInstallationConfiguration{ + NetworkMirror: &datamodel.TerraformNetworkMirrorConfiguration{ + URL: "https://mirror.example.com/", + Include: []string{"hashicorp/*"}, + }, + Direct: &datamodel.TerraformDirectConfiguration{ + Include: []string{"*"}, + }, + }, + Credentials: map[string]*datamodel.TerraformCredentialConfiguration{ + "registry.terraform.io": { + Token: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/registry-token", + Key: "api-token", + }, + }, + }, + }, + Backend: &datamodel.TerraformBackendConfiguration{ + Type: "kubernetes", + Config: map[string]string{ + "namespace": "terraform", + }, + }, + Env: map[string]string{ + "TF_LOG": "DEBUG", + "TF_REGISTRY_CLIENT_TIMEOUT": "30", + }, + Logging: &datamodel.TerraformLoggingConfiguration{ + Level: datamodel.TerraformLogLevelDebug, + Path: "/tmp/tf.log", + }, + }, + } + + versionedResource := &TerraformSettingsResource{} + err := versionedResource.ConvertFrom(dataModelResource) + require.NoError(t, err) + + require.Equal(t, to.Ptr("test-settings"), versionedResource.Name) + require.Equal(t, to.Ptr("Radius.Core/terraformSettings"), versionedResource.Type) + require.Equal(t, to.Ptr("global"), versionedResource.Location) + require.Equal(t, map[string]*string{"env": to.Ptr("prod")}, versionedResource.Tags) + + // TerraformRC + require.NotNil(t, versionedResource.Properties.Terraformrc) + require.NotNil(t, versionedResource.Properties.Terraformrc.ProviderInstallation) + require.NotNil(t, versionedResource.Properties.Terraformrc.ProviderInstallation.NetworkMirror) + require.Equal(t, to.Ptr("https://mirror.example.com/"), versionedResource.Properties.Terraformrc.ProviderInstallation.NetworkMirror.URL) + require.Equal(t, []*string{to.Ptr("hashicorp/*")}, versionedResource.Properties.Terraformrc.ProviderInstallation.NetworkMirror.Include) + + // Credentials + require.NotNil(t, versionedResource.Properties.Terraformrc.Credentials) + require.Contains(t, versionedResource.Properties.Terraformrc.Credentials, "registry.terraform.io") + require.NotNil(t, versionedResource.Properties.Terraformrc.Credentials["registry.terraform.io"].Token) + require.Equal(t, to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/registry-token"), versionedResource.Properties.Terraformrc.Credentials["registry.terraform.io"].Token.SecretID) + require.Equal(t, to.Ptr("api-token"), versionedResource.Properties.Terraformrc.Credentials["registry.terraform.io"].Token.Key) + + // Backend + require.NotNil(t, versionedResource.Properties.Backend) + require.Equal(t, to.Ptr("kubernetes"), versionedResource.Properties.Backend.Type) + require.Equal(t, to.Ptr("terraform"), versionedResource.Properties.Backend.Config["namespace"]) + + // Env + require.Equal(t, map[string]*string{ + "TF_LOG": to.Ptr("DEBUG"), + "TF_REGISTRY_CLIENT_TIMEOUT": to.Ptr("30"), + }, versionedResource.Properties.Env) + + // Logging + require.NotNil(t, versionedResource.Properties.Logging) + require.Equal(t, to.Ptr(TerraformLogLevelDebug), versionedResource.Properties.Logging.Level) + require.Equal(t, to.Ptr("/tmp/tf.log"), versionedResource.Properties.Logging.Path) +} + +func TestTerraformSettingsConvertFromInvalidType(t *testing.T) { + versionedResource := &TerraformSettingsResource{} + err := versionedResource.ConvertFrom(&datamodel.Environment_v20250801preview{}) + require.Error(t, err) + require.Equal(t, v1.ErrInvalidModelConversion, err) +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_bicepsettings_client.go b/pkg/corerp/api/v20250801preview/zz_generated_bicepsettings_client.go new file mode 100644 index 0000000000..9d42479c68 --- /dev/null +++ b/pkg/corerp/api/v20250801preview/zz_generated_bicepsettings_client.go @@ -0,0 +1,317 @@ +// Licensed under the Apache License, Version 2.0 . See LICENSE in the repository root for license information. +// Code generated by Microsoft (R) AutoRest Code Generator. DO NOT EDIT. +// Changes may cause incorrect behavior and will be lost if the code is regenerated. + +package v20250801preview + +import ( + "context" + "errors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "net/url" + "strings" +) + +// BicepSettingsClient contains the methods for the BicepSettings group. +// Don't use this type directly, use NewBicepSettingsClient() instead. +type BicepSettingsClient struct { + internal *arm.Client + rootScope string +} + +// NewBicepSettingsClient creates a new instance of BicepSettingsClient with the specified values. +// - rootScope - The scope in which the resource is present. UCP Scope is /planes/{planeType}/{planeName}/resourceGroup/{resourcegroupID} +// and Azure resource scope is +// /subscriptions/{subscriptionID}/resourceGroup/{resourcegroupID} +// - credential - used to authorize requests. Usually a credential from azidentity. +// - options - Contains optional client configuration. Pass nil to accept the default values. +func NewBicepSettingsClient(rootScope string, credential azcore.TokenCredential, options *arm.ClientOptions) (*BicepSettingsClient, error) { + cl, err := arm.NewClient(moduleName, moduleVersion, credential, options) + if err != nil { + return nil, err + } + client := &BicepSettingsClient{ + rootScope: rootScope, + internal: cl, + } + return client, nil +} + +// CreateOrUpdate - Create a BicepSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - bicepSettingsName - Bicep settings resource name. +// - resource - Resource create parameters. +// - options - BicepSettingsClientCreateOrUpdateOptions contains the optional parameters for the BicepSettingsClient.CreateOrUpdate +// method. +func (client *BicepSettingsClient) CreateOrUpdate(ctx context.Context, bicepSettingsName string, resource BicepSettingsResource, options *BicepSettingsClientCreateOrUpdateOptions) (BicepSettingsClientCreateOrUpdateResponse, error) { + var err error + const operationName = "BicepSettingsClient.CreateOrUpdate" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.createOrUpdateCreateRequest(ctx, bicepSettingsName, resource, options) + if err != nil { + return BicepSettingsClientCreateOrUpdateResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return BicepSettingsClientCreateOrUpdateResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusCreated) { + err = runtime.NewResponseError(httpResp) + return BicepSettingsClientCreateOrUpdateResponse{}, err + } + resp, err := client.createOrUpdateHandleResponse(httpResp) + return resp, err +} + +// createOrUpdateCreateRequest creates the CreateOrUpdate request. +func (client *BicepSettingsClient) createOrUpdateCreateRequest(ctx context.Context, bicepSettingsName string, resource BicepSettingsResource, _ *BicepSettingsClientCreateOrUpdateOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/bicepSettings/{bicepSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if bicepSettingsName == "" { + return nil, errors.New("parameter bicepSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{bicepSettingsName}", url.PathEscape(bicepSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodPut, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + if err := runtime.MarshalAsJSON(req, resource); err != nil { + return nil, err + } + return req, nil +} + +// createOrUpdateHandleResponse handles the CreateOrUpdate response. +func (client *BicepSettingsClient) createOrUpdateHandleResponse(resp *http.Response) (BicepSettingsClientCreateOrUpdateResponse, error) { + result := BicepSettingsClientCreateOrUpdateResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.BicepSettingsResource); err != nil { + return BicepSettingsClientCreateOrUpdateResponse{}, err + } + return result, nil +} + +// Delete - Delete a BicepSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - bicepSettingsName - Bicep settings resource name. +// - options - BicepSettingsClientDeleteOptions contains the optional parameters for the BicepSettingsClient.Delete method. +func (client *BicepSettingsClient) Delete(ctx context.Context, bicepSettingsName string, options *BicepSettingsClientDeleteOptions) (BicepSettingsClientDeleteResponse, error) { + var err error + const operationName = "BicepSettingsClient.Delete" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.deleteCreateRequest(ctx, bicepSettingsName, options) + if err != nil { + return BicepSettingsClientDeleteResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return BicepSettingsClientDeleteResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return BicepSettingsClientDeleteResponse{}, err + } + return BicepSettingsClientDeleteResponse{}, nil +} + +// deleteCreateRequest creates the Delete request. +func (client *BicepSettingsClient) deleteCreateRequest(ctx context.Context, bicepSettingsName string, _ *BicepSettingsClientDeleteOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/bicepSettings/{bicepSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if bicepSettingsName == "" { + return nil, errors.New("parameter bicepSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{bicepSettingsName}", url.PathEscape(bicepSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodDelete, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// Get - Get a BicepSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - bicepSettingsName - Bicep settings resource name. +// - options - BicepSettingsClientGetOptions contains the optional parameters for the BicepSettingsClient.Get method. +func (client *BicepSettingsClient) Get(ctx context.Context, bicepSettingsName string, options *BicepSettingsClientGetOptions) (BicepSettingsClientGetResponse, error) { + var err error + const operationName = "BicepSettingsClient.Get" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.getCreateRequest(ctx, bicepSettingsName, options) + if err != nil { + return BicepSettingsClientGetResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return BicepSettingsClientGetResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK) { + err = runtime.NewResponseError(httpResp) + return BicepSettingsClientGetResponse{}, err + } + resp, err := client.getHandleResponse(httpResp) + return resp, err +} + +// getCreateRequest creates the Get request. +func (client *BicepSettingsClient) getCreateRequest(ctx context.Context, bicepSettingsName string, _ *BicepSettingsClientGetOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/bicepSettings/{bicepSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if bicepSettingsName == "" { + return nil, errors.New("parameter bicepSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{bicepSettingsName}", url.PathEscape(bicepSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// getHandleResponse handles the Get response. +func (client *BicepSettingsClient) getHandleResponse(resp *http.Response) (BicepSettingsClientGetResponse, error) { + result := BicepSettingsClientGetResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.BicepSettingsResource); err != nil { + return BicepSettingsClientGetResponse{}, err + } + return result, nil +} + +// NewListByScopePager - List BicepSettingsResource resources by Scope +// +// Generated from API version 2025-08-01-preview +// - options - BicepSettingsClientListByScopeOptions contains the optional parameters for the BicepSettingsClient.NewListByScopePager +// method. +func (client *BicepSettingsClient) NewListByScopePager(options *BicepSettingsClientListByScopeOptions) *runtime.Pager[BicepSettingsClientListByScopeResponse] { + return runtime.NewPager(runtime.PagingHandler[BicepSettingsClientListByScopeResponse]{ + More: func(page BicepSettingsClientListByScopeResponse) bool { + return page.NextLink != nil && len(*page.NextLink) > 0 + }, + Fetcher: func(ctx context.Context, page *BicepSettingsClientListByScopeResponse) (BicepSettingsClientListByScopeResponse, error) { + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, "BicepSettingsClient.NewListByScopePager") + nextLink := "" + if page != nil { + nextLink = *page.NextLink + } + resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), nextLink, func(ctx context.Context) (*policy.Request, error) { + return client.listByScopeCreateRequest(ctx, options) + }, nil) + if err != nil { + return BicepSettingsClientListByScopeResponse{}, err + } + return client.listByScopeHandleResponse(resp) + }, + Tracer: client.internal.Tracer(), + }) +} + +// listByScopeCreateRequest creates the ListByScope request. +func (client *BicepSettingsClient) listByScopeCreateRequest(ctx context.Context, _ *BicepSettingsClientListByScopeOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/bicepSettings" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// listByScopeHandleResponse handles the ListByScope response. +func (client *BicepSettingsClient) listByScopeHandleResponse(resp *http.Response) (BicepSettingsClientListByScopeResponse, error) { + result := BicepSettingsClientListByScopeResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.BicepSettingsResourceListResult); err != nil { + return BicepSettingsClientListByScopeResponse{}, err + } + return result, nil +} + +// Update - Update a BicepSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - bicepSettingsName - Bicep settings resource name. +// - properties - The resource properties to be updated. +// - options - BicepSettingsClientUpdateOptions contains the optional parameters for the BicepSettingsClient.Update method. +func (client *BicepSettingsClient) Update(ctx context.Context, bicepSettingsName string, properties BicepSettingsResourceUpdate, options *BicepSettingsClientUpdateOptions) (BicepSettingsClientUpdateResponse, error) { + var err error + const operationName = "BicepSettingsClient.Update" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.updateCreateRequest(ctx, bicepSettingsName, properties, options) + if err != nil { + return BicepSettingsClientUpdateResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return BicepSettingsClientUpdateResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK) { + err = runtime.NewResponseError(httpResp) + return BicepSettingsClientUpdateResponse{}, err + } + resp, err := client.updateHandleResponse(httpResp) + return resp, err +} + +// updateCreateRequest creates the Update request. +func (client *BicepSettingsClient) updateCreateRequest(ctx context.Context, bicepSettingsName string, properties BicepSettingsResourceUpdate, _ *BicepSettingsClientUpdateOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/bicepSettings/{bicepSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if bicepSettingsName == "" { + return nil, errors.New("parameter bicepSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{bicepSettingsName}", url.PathEscape(bicepSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodPatch, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + if err := runtime.MarshalAsJSON(req, properties); err != nil { + return nil, err + } + return req, nil +} + +// updateHandleResponse handles the Update response. +func (client *BicepSettingsClient) updateHandleResponse(resp *http.Response) (BicepSettingsClientUpdateResponse, error) { + result := BicepSettingsClientUpdateResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.BicepSettingsResource); err != nil { + return BicepSettingsClientUpdateResponse{}, err + } + return result, nil +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_client_factory.go b/pkg/corerp/api/v20250801preview/zz_generated_client_factory.go index 34359ee937..e3236d9f2b 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_client_factory.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_client_factory.go @@ -42,6 +42,14 @@ func (c *ClientFactory) NewApplicationsClient() *ApplicationsClient { } } +// NewBicepSettingsClient creates a new instance of BicepSettingsClient. +func (c *ClientFactory) NewBicepSettingsClient() *BicepSettingsClient { + return &BicepSettingsClient{ + rootScope: c.rootScope, + internal: c.internal, + } +} + // NewEnvironmentsClient creates a new instance of EnvironmentsClient. func (c *ClientFactory) NewEnvironmentsClient() *EnvironmentsClient { return &EnvironmentsClient{ @@ -64,3 +72,11 @@ func (c *ClientFactory) NewRecipePacksClient() *RecipePacksClient { internal: c.internal, } } + +// NewTerraformSettingsClient creates a new instance of TerraformSettingsClient. +func (c *ClientFactory) NewTerraformSettingsClient() *TerraformSettingsClient { + return &TerraformSettingsClient{ + rootScope: c.rootScope, + internal: c.internal, + } +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_constants.go b/pkg/corerp/api/v20250801preview/zz_generated_constants.go index 613cf45fe8..9b4c0f15fe 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_constants.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_constants.go @@ -156,3 +156,27 @@ func PossibleRecipeKindValues() []RecipeKind { RecipeKindTerraform, } } + +// TerraformLogLevel - Terraform log verbosity levels. +type TerraformLogLevel string + +const ( + TerraformLogLevelDebug TerraformLogLevel = "DEBUG" + TerraformLogLevelError TerraformLogLevel = "ERROR" + TerraformLogLevelFatal TerraformLogLevel = "FATAL" + TerraformLogLevelInfo TerraformLogLevel = "INFO" + TerraformLogLevelTrace TerraformLogLevel = "TRACE" + TerraformLogLevelWarn TerraformLogLevel = "WARN" +) + +// PossibleTerraformLogLevelValues returns the possible values for the TerraformLogLevel const type. +func PossibleTerraformLogLevelValues() []TerraformLogLevel { + return []TerraformLogLevel{ + TerraformLogLevelDebug, + TerraformLogLevelError, + TerraformLogLevelFatal, + TerraformLogLevelInfo, + TerraformLogLevelTrace, + TerraformLogLevelWarn, + } +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models.go b/pkg/corerp/api/v20250801preview/zz_generated_models.go index 6cc3a1a5c5..b8ff4d9f75 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models.go @@ -161,6 +161,114 @@ type AzureResourceManagerCommonTypesTrackedResourceUpdate struct { Type *string } +// BicepAuthenticationConfiguration - Authentication configuration for Bicep registries. +type BicepAuthenticationConfiguration struct { + // Authentication entries keyed by registry hostname. + Registries map[string]*BicepRegistryAuthentication +} + +// BicepAwsIrsaAuthentication - AWS IRSA configuration for a Bicep registry. +type BicepAwsIrsaAuthentication struct { + // ARN of the AWS IAM role used for IRSA. + RoleArn *string + + // Token credential for AWS IRSA authentication. + Token *SecretReference +} + +// BicepAzureWorkloadIdentityAuthentication - Azure Workload Identity configuration for a Bicep registry. +type BicepAzureWorkloadIdentityAuthentication struct { + // Client ID used for Azure Workload Identity. + ClientID *string + + // Tenant ID used for Azure Workload Identity. + TenantID *string + + // Token credential for Azure Workload Identity authentication. + Token *SecretReference +} + +// BicepBasicAuthentication - Basic authentication configuration for a Bicep registry. +type BicepBasicAuthentication struct { + // Password credential for basic authentication. + Password *SecretReference + + // Username for basic authentication. + Username *string +} + +// BicepRegistryAuthentication - Registry authentication options for a private Bicep registry. +type BicepRegistryAuthentication struct { + // AWS IRSA authentication settings for a registry. + AwsIrsa *BicepAwsIrsaAuthentication + + // Azure Workload Identity authentication settings for a registry. + AzureWorkloadIdentity *BicepAzureWorkloadIdentityAuthentication + + // Basic authentication settings for a registry. + Basic *BicepBasicAuthentication +} + +// BicepSettingsProperties - Bicep settings properties. +type BicepSettingsProperties struct { + // Authentication settings for private registries. + Authentication *BicepAuthenticationConfiguration + + // READ-ONLY; Provisioning state of the asynchronous operation. + ProvisioningState *ProvisioningState +} + +// BicepSettingsResource - Bicep settings resource. +type BicepSettingsResource struct { + // REQUIRED; The geo-location where the resource lives + Location *string + + // REQUIRED; The resource-specific properties for this resource. + Properties *BicepSettingsProperties + + // Resource tags. + Tags map[string]*string + + // READ-ONLY; Fully qualified resource ID for the resource. Ex - /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName} + ID *string + + // READ-ONLY; The name of the resource + Name *string + + // READ-ONLY; Azure Resource Manager metadata containing createdBy and modifiedBy information. + SystemData *SystemData + + // READ-ONLY; The type of the resource. E.g. "Microsoft.Compute/virtualMachines" or "Microsoft.Storage/storageAccounts" + Type *string +} + +// BicepSettingsResourceListResult - The response of a BicepSettingsResource list operation. +type BicepSettingsResourceListResult struct { + // REQUIRED; The BicepSettingsResource items on this page + Value []*BicepSettingsResource + + // The link to the next page of items + NextLink *string +} + +// BicepSettingsResourceUpdate - Bicep settings resource. +type BicepSettingsResourceUpdate struct { + // Resource tags. + Tags map[string]*string + + // READ-ONLY; Fully qualified resource ID for the resource. Ex - /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName} + ID *string + + // READ-ONLY; The name of the resource + Name *string + + // READ-ONLY; Azure Resource Manager metadata containing createdBy and modifiedBy information. + SystemData *SystemData + + // READ-ONLY; The type of the resource. E.g. "Microsoft.Compute/virtualMachines" or "Microsoft.Storage/storageAccounts" + Type *string +} + // EnvironmentCompute - Represents backing compute resource type EnvironmentCompute struct { // REQUIRED; Discriminator property for EnvironmentCompute. @@ -178,6 +286,9 @@ func (e *EnvironmentCompute) GetEnvironmentCompute() *EnvironmentCompute { retur // EnvironmentProperties - Environment properties type EnvironmentProperties struct { + // Resource ID of the Bicep settings applied to this environment. + BicepSettings *string + // Cloud provider configuration for the environment. Providers *Providers @@ -190,6 +301,9 @@ type EnvironmentProperties struct { // Simulated environment. Simulated *bool + // Resource ID of the Terraform settings applied to this environment. + TerraformSettings *string + // READ-ONLY; The status of the asynchronous operation. ProvisioningState *ProvisioningState } @@ -537,6 +651,15 @@ type ResourceStatus struct { Recipe *RecipeStatus } +// SecretReference - Reference to a secret stored in Radius.Security/secrets. +type SecretReference struct { + // REQUIRED; Key within the secret to retrieve. + Key *string + + // REQUIRED; Resource ID of the Radius.Security/secrets entry. + SecretID *string +} + // SystemData - Metadata pertaining to creation and last modification of the resource. type SystemData struct { // The timestamp of resource creation (UTC). @@ -558,6 +681,138 @@ type SystemData struct { LastModifiedByType *CreatedByType } +// TerraformBackendConfiguration - Terraform backend configuration matching the terraform block. +type TerraformBackendConfiguration struct { + // REQUIRED; Backend type (for example 'kubernetes'). + Type *string + + // Backend-specific configuration values. + Config map[string]*string +} + +// TerraformCliConfiguration - Terraform CLI configuration matching the terraformrc file. +type TerraformCliConfiguration struct { + // Credentials keyed by registry or module source hostname. + Credentials map[string]*TerraformCredentialConfiguration + + // Provider installation configuration controlling how Terraform installs providers. + ProviderInstallation *TerraformProviderInstallationConfiguration +} + +// TerraformCredentialConfiguration - Credential configuration for Terraform provider or module sources. +type TerraformCredentialConfiguration struct { + // Token credential for Terraform Cloud/Enterprise authentication. + Token *SecretReference +} + +// TerraformDirectConfiguration - Direct installation configuration for Terraform providers. +type TerraformDirectConfiguration struct { + // Provider addresses excluded from direct installation. + Exclude []*string + + // Provider addresses included when falling back to direct installation. + Include []*string +} + +// TerraformLoggingConfiguration - Logging options for Terraform executions. +type TerraformLoggingConfiguration struct { + // Terraform log verbosity (maps to TF_LOG). + Level *TerraformLogLevel + + // Destination file path for Terraform logs (maps to TFLOGPATH). + Path *string +} + +// TerraformNetworkMirrorConfiguration - Network mirror configuration for Terraform providers. +type TerraformNetworkMirrorConfiguration struct { + // REQUIRED; Mirror URL used to download providers. + URL *string + + // Provider addresses excluded from this mirror. + Exclude []*string + + // Provider addresses included in this mirror. + Include []*string +} + +// TerraformProviderInstallationConfiguration - Provider installation options for Terraform. +type TerraformProviderInstallationConfiguration struct { + // Direct installation rules controlling when Terraform reaches public registries. + Direct *TerraformDirectConfiguration + + // Network mirror configuration used to download providers. + NetworkMirror *TerraformNetworkMirrorConfiguration +} + +// TerraformSettingsProperties - Terraform settings properties. +type TerraformSettingsProperties struct { + // Terraform backend configuration. + Backend *TerraformBackendConfiguration + + // Environment variables injected into the Terraform process. + Env map[string]*string + + // Logging configuration applied to Terraform executions. + Logging *TerraformLoggingConfiguration + + // Terraform CLI configuration equivalent to the terraformrc file. + Terraformrc *TerraformCliConfiguration + + // READ-ONLY; Provisioning state of the asynchronous operation. + ProvisioningState *ProvisioningState +} + +// TerraformSettingsResource - Terraform settings resource. +type TerraformSettingsResource struct { + // REQUIRED; The geo-location where the resource lives + Location *string + + // REQUIRED; The resource-specific properties for this resource. + Properties *TerraformSettingsProperties + + // Resource tags. + Tags map[string]*string + + // READ-ONLY; Fully qualified resource ID for the resource. Ex - /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName} + ID *string + + // READ-ONLY; The name of the resource + Name *string + + // READ-ONLY; Azure Resource Manager metadata containing createdBy and modifiedBy information. + SystemData *SystemData + + // READ-ONLY; The type of the resource. E.g. "Microsoft.Compute/virtualMachines" or "Microsoft.Storage/storageAccounts" + Type *string +} + +// TerraformSettingsResourceListResult - The response of a TerraformSettingsResource list operation. +type TerraformSettingsResourceListResult struct { + // REQUIRED; The TerraformSettingsResource items on this page + Value []*TerraformSettingsResource + + // The link to the next page of items + NextLink *string +} + +// TerraformSettingsResourceUpdate - Terraform settings resource. +type TerraformSettingsResourceUpdate struct { + // Resource tags. + Tags map[string]*string + + // READ-ONLY; Fully qualified resource ID for the resource. Ex - /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName} + ID *string + + // READ-ONLY; The name of the resource + Name *string + + // READ-ONLY; Azure Resource Manager metadata containing createdBy and modifiedBy information. + SystemData *SystemData + + // READ-ONLY; The type of the resource. E.g. "Microsoft.Compute/virtualMachines" or "Microsoft.Storage/storageAccounts" + Type *string +} + // TrackedResource - The resource model definition for an Azure Resource Manager tracked top level resource which has 'tags' // and a 'location' type TrackedResource struct { diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go index 56b3d37efb..2aabf863af 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go @@ -393,6 +393,321 @@ func (a *AzureResourceManagerCommonTypesTrackedResourceUpdate) UnmarshalJSON(dat return nil } +// MarshalJSON implements the json.Marshaller interface for type BicepAuthenticationConfiguration. +func (b BicepAuthenticationConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "registries", b.Registries) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepAuthenticationConfiguration. +func (b *BicepAuthenticationConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "registries": + err = unpopulate(val, "Registries", &b.Registries) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepAwsIrsaAuthentication. +func (b BicepAwsIrsaAuthentication) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "roleArn", b.RoleArn) + populate(objectMap, "token", b.Token) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepAwsIrsaAuthentication. +func (b *BicepAwsIrsaAuthentication) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "roleArn": + err = unpopulate(val, "RoleArn", &b.RoleArn) + delete(rawMsg, key) + case "token": + err = unpopulate(val, "Token", &b.Token) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepAzureWorkloadIdentityAuthentication. +func (b BicepAzureWorkloadIdentityAuthentication) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "clientId", b.ClientID) + populate(objectMap, "tenantId", b.TenantID) + populate(objectMap, "token", b.Token) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepAzureWorkloadIdentityAuthentication. +func (b *BicepAzureWorkloadIdentityAuthentication) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "clientId": + err = unpopulate(val, "ClientID", &b.ClientID) + delete(rawMsg, key) + case "tenantId": + err = unpopulate(val, "TenantID", &b.TenantID) + delete(rawMsg, key) + case "token": + err = unpopulate(val, "Token", &b.Token) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepBasicAuthentication. +func (b BicepBasicAuthentication) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "password", b.Password) + populate(objectMap, "username", b.Username) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepBasicAuthentication. +func (b *BicepBasicAuthentication) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "password": + err = unpopulate(val, "Password", &b.Password) + delete(rawMsg, key) + case "username": + err = unpopulate(val, "Username", &b.Username) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepRegistryAuthentication. +func (b BicepRegistryAuthentication) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "awsIrsa", b.AwsIrsa) + populate(objectMap, "azureWorkloadIdentity", b.AzureWorkloadIdentity) + populate(objectMap, "basic", b.Basic) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepRegistryAuthentication. +func (b *BicepRegistryAuthentication) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "awsIrsa": + err = unpopulate(val, "AwsIrsa", &b.AwsIrsa) + delete(rawMsg, key) + case "azureWorkloadIdentity": + err = unpopulate(val, "AzureWorkloadIdentity", &b.AzureWorkloadIdentity) + delete(rawMsg, key) + case "basic": + err = unpopulate(val, "Basic", &b.Basic) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepSettingsProperties. +func (b BicepSettingsProperties) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "authentication", b.Authentication) + populate(objectMap, "provisioningState", b.ProvisioningState) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepSettingsProperties. +func (b *BicepSettingsProperties) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "authentication": + err = unpopulate(val, "Authentication", &b.Authentication) + delete(rawMsg, key) + case "provisioningState": + err = unpopulate(val, "ProvisioningState", &b.ProvisioningState) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepSettingsResource. +func (b BicepSettingsResource) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "id", b.ID) + populate(objectMap, "location", b.Location) + populate(objectMap, "name", b.Name) + populate(objectMap, "properties", b.Properties) + populate(objectMap, "systemData", b.SystemData) + populate(objectMap, "tags", b.Tags) + populate(objectMap, "type", b.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepSettingsResource. +func (b *BicepSettingsResource) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "id": + err = unpopulate(val, "ID", &b.ID) + delete(rawMsg, key) + case "location": + err = unpopulate(val, "Location", &b.Location) + delete(rawMsg, key) + case "name": + err = unpopulate(val, "Name", &b.Name) + delete(rawMsg, key) + case "properties": + err = unpopulate(val, "Properties", &b.Properties) + delete(rawMsg, key) + case "systemData": + err = unpopulate(val, "SystemData", &b.SystemData) + delete(rawMsg, key) + case "tags": + err = unpopulate(val, "Tags", &b.Tags) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &b.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepSettingsResourceListResult. +func (b BicepSettingsResourceListResult) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "nextLink", b.NextLink) + populate(objectMap, "value", b.Value) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepSettingsResourceListResult. +func (b *BicepSettingsResourceListResult) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "nextLink": + err = unpopulate(val, "NextLink", &b.NextLink) + delete(rawMsg, key) + case "value": + err = unpopulate(val, "Value", &b.Value) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type BicepSettingsResourceUpdate. +func (b BicepSettingsResourceUpdate) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "id", b.ID) + populate(objectMap, "name", b.Name) + populate(objectMap, "systemData", b.SystemData) + populate(objectMap, "tags", b.Tags) + populate(objectMap, "type", b.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type BicepSettingsResourceUpdate. +func (b *BicepSettingsResourceUpdate) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "id": + err = unpopulate(val, "ID", &b.ID) + delete(rawMsg, key) + case "name": + err = unpopulate(val, "Name", &b.Name) + delete(rawMsg, key) + case "systemData": + err = unpopulate(val, "SystemData", &b.SystemData) + delete(rawMsg, key) + case "tags": + err = unpopulate(val, "Tags", &b.Tags) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &b.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", b, err) + } + } + return nil +} + // MarshalJSON implements the json.Marshaller interface for type EnvironmentCompute. func (e EnvironmentCompute) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) @@ -431,11 +746,13 @@ func (e *EnvironmentCompute) UnmarshalJSON(data []byte) error { // MarshalJSON implements the json.Marshaller interface for type EnvironmentProperties. func (e EnvironmentProperties) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) + populate(objectMap, "bicepSettings", e.BicepSettings) populate(objectMap, "providers", e.Providers) populate(objectMap, "provisioningState", e.ProvisioningState) populate(objectMap, "recipePacks", e.RecipePacks) populate(objectMap, "recipeParameters", e.RecipeParameters) populate(objectMap, "simulated", e.Simulated) + populate(objectMap, "terraformSettings", e.TerraformSettings) return json.Marshal(objectMap) } @@ -448,6 +765,9 @@ func (e *EnvironmentProperties) UnmarshalJSON(data []byte) error { for key, val := range rawMsg { var err error switch key { + case "bicepSettings": + err = unpopulate(val, "BicepSettings", &e.BicepSettings) + delete(rawMsg, key) case "providers": err = unpopulate(val, "Providers", &e.Providers) delete(rawMsg, key) @@ -463,6 +783,9 @@ func (e *EnvironmentProperties) UnmarshalJSON(data []byte) error { case "simulated": err = unpopulate(val, "Simulated", &e.Simulated) delete(rawMsg, key) + case "terraformSettings": + err = unpopulate(val, "TerraformSettings", &e.TerraformSettings) + delete(rawMsg, key) } if err != nil { return fmt.Errorf("unmarshalling type %T: %v", e, err) @@ -1359,6 +1682,37 @@ func (r *ResourceStatus) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaller interface for type SecretReference. +func (s SecretReference) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "key", s.Key) + populate(objectMap, "secretId", s.SecretID) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type SecretReference. +func (s *SecretReference) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", s, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "key": + err = unpopulate(val, "Key", &s.Key) + delete(rawMsg, key) + case "secretId": + err = unpopulate(val, "SecretID", &s.SecretID) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", s, err) + } + } + return nil +} + // MarshalJSON implements the json.Marshaller interface for type SystemData. func (s SystemData) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) @@ -1406,6 +1760,391 @@ func (s *SystemData) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaller interface for type TerraformBackendConfiguration. +func (t TerraformBackendConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "config", t.Config) + populate(objectMap, "type", t.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformBackendConfiguration. +func (t *TerraformBackendConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "config": + err = unpopulate(val, "Config", &t.Config) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &t.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformCliConfiguration. +func (t TerraformCliConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "credentials", t.Credentials) + populate(objectMap, "providerInstallation", t.ProviderInstallation) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformCliConfiguration. +func (t *TerraformCliConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "credentials": + err = unpopulate(val, "Credentials", &t.Credentials) + delete(rawMsg, key) + case "providerInstallation": + err = unpopulate(val, "ProviderInstallation", &t.ProviderInstallation) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformCredentialConfiguration. +func (t TerraformCredentialConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "token", t.Token) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformCredentialConfiguration. +func (t *TerraformCredentialConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "token": + err = unpopulate(val, "Token", &t.Token) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformDirectConfiguration. +func (t TerraformDirectConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "exclude", t.Exclude) + populate(objectMap, "include", t.Include) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformDirectConfiguration. +func (t *TerraformDirectConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "exclude": + err = unpopulate(val, "Exclude", &t.Exclude) + delete(rawMsg, key) + case "include": + err = unpopulate(val, "Include", &t.Include) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformLoggingConfiguration. +func (t TerraformLoggingConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "level", t.Level) + populate(objectMap, "path", t.Path) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformLoggingConfiguration. +func (t *TerraformLoggingConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "level": + err = unpopulate(val, "Level", &t.Level) + delete(rawMsg, key) + case "path": + err = unpopulate(val, "Path", &t.Path) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformNetworkMirrorConfiguration. +func (t TerraformNetworkMirrorConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "exclude", t.Exclude) + populate(objectMap, "include", t.Include) + populate(objectMap, "url", t.URL) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformNetworkMirrorConfiguration. +func (t *TerraformNetworkMirrorConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "exclude": + err = unpopulate(val, "Exclude", &t.Exclude) + delete(rawMsg, key) + case "include": + err = unpopulate(val, "Include", &t.Include) + delete(rawMsg, key) + case "url": + err = unpopulate(val, "URL", &t.URL) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformProviderInstallationConfiguration. +func (t TerraformProviderInstallationConfiguration) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "direct", t.Direct) + populate(objectMap, "networkMirror", t.NetworkMirror) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformProviderInstallationConfiguration. +func (t *TerraformProviderInstallationConfiguration) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "direct": + err = unpopulate(val, "Direct", &t.Direct) + delete(rawMsg, key) + case "networkMirror": + err = unpopulate(val, "NetworkMirror", &t.NetworkMirror) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformSettingsProperties. +func (t TerraformSettingsProperties) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "backend", t.Backend) + populate(objectMap, "env", t.Env) + populate(objectMap, "logging", t.Logging) + populate(objectMap, "provisioningState", t.ProvisioningState) + populate(objectMap, "terraformrc", t.Terraformrc) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformSettingsProperties. +func (t *TerraformSettingsProperties) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "backend": + err = unpopulate(val, "Backend", &t.Backend) + delete(rawMsg, key) + case "env": + err = unpopulate(val, "Env", &t.Env) + delete(rawMsg, key) + case "logging": + err = unpopulate(val, "Logging", &t.Logging) + delete(rawMsg, key) + case "provisioningState": + err = unpopulate(val, "ProvisioningState", &t.ProvisioningState) + delete(rawMsg, key) + case "terraformrc": + err = unpopulate(val, "Terraformrc", &t.Terraformrc) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformSettingsResource. +func (t TerraformSettingsResource) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "id", t.ID) + populate(objectMap, "location", t.Location) + populate(objectMap, "name", t.Name) + populate(objectMap, "properties", t.Properties) + populate(objectMap, "systemData", t.SystemData) + populate(objectMap, "tags", t.Tags) + populate(objectMap, "type", t.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformSettingsResource. +func (t *TerraformSettingsResource) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "id": + err = unpopulate(val, "ID", &t.ID) + delete(rawMsg, key) + case "location": + err = unpopulate(val, "Location", &t.Location) + delete(rawMsg, key) + case "name": + err = unpopulate(val, "Name", &t.Name) + delete(rawMsg, key) + case "properties": + err = unpopulate(val, "Properties", &t.Properties) + delete(rawMsg, key) + case "systemData": + err = unpopulate(val, "SystemData", &t.SystemData) + delete(rawMsg, key) + case "tags": + err = unpopulate(val, "Tags", &t.Tags) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &t.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformSettingsResourceListResult. +func (t TerraformSettingsResourceListResult) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "nextLink", t.NextLink) + populate(objectMap, "value", t.Value) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformSettingsResourceListResult. +func (t *TerraformSettingsResourceListResult) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "nextLink": + err = unpopulate(val, "NextLink", &t.NextLink) + delete(rawMsg, key) + case "value": + err = unpopulate(val, "Value", &t.Value) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type TerraformSettingsResourceUpdate. +func (t TerraformSettingsResourceUpdate) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "id", t.ID) + populate(objectMap, "name", t.Name) + populate(objectMap, "systemData", t.SystemData) + populate(objectMap, "tags", t.Tags) + populate(objectMap, "type", t.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type TerraformSettingsResourceUpdate. +func (t *TerraformSettingsResourceUpdate) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "id": + err = unpopulate(val, "ID", &t.ID) + delete(rawMsg, key) + case "name": + err = unpopulate(val, "Name", &t.Name) + delete(rawMsg, key) + case "systemData": + err = unpopulate(val, "SystemData", &t.SystemData) + delete(rawMsg, key) + case "tags": + err = unpopulate(val, "Tags", &t.Tags) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &t.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", t, err) + } + } + return nil +} + // MarshalJSON implements the json.Marshaller interface for type TrackedResource. func (t TrackedResource) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) diff --git a/pkg/corerp/api/v20250801preview/zz_generated_options.go b/pkg/corerp/api/v20250801preview/zz_generated_options.go index 25fe2b8409..88bedc6d88 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_options.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_options.go @@ -34,6 +34,32 @@ type ApplicationsClientUpdateOptions struct { // placeholder for future optional parameters } +// BicepSettingsClientCreateOrUpdateOptions contains the optional parameters for the BicepSettingsClient.CreateOrUpdate method. +type BicepSettingsClientCreateOrUpdateOptions struct { + // placeholder for future optional parameters +} + +// BicepSettingsClientDeleteOptions contains the optional parameters for the BicepSettingsClient.Delete method. +type BicepSettingsClientDeleteOptions struct { + // placeholder for future optional parameters +} + +// BicepSettingsClientGetOptions contains the optional parameters for the BicepSettingsClient.Get method. +type BicepSettingsClientGetOptions struct { + // placeholder for future optional parameters +} + +// BicepSettingsClientListByScopeOptions contains the optional parameters for the BicepSettingsClient.NewListByScopePager +// method. +type BicepSettingsClientListByScopeOptions struct { + // placeholder for future optional parameters +} + +// BicepSettingsClientUpdateOptions contains the optional parameters for the BicepSettingsClient.Update method. +type BicepSettingsClientUpdateOptions struct { + // placeholder for future optional parameters +} + // EnvironmentsClientCreateOrUpdateOptions contains the optional parameters for the EnvironmentsClient.CreateOrUpdate method. type EnvironmentsClientCreateOrUpdateOptions struct { // placeholder for future optional parameters @@ -88,3 +114,30 @@ type RecipePacksClientListByScopeOptions struct { type RecipePacksClientUpdateOptions struct { // placeholder for future optional parameters } + +// TerraformSettingsClientCreateOrUpdateOptions contains the optional parameters for the TerraformSettingsClient.CreateOrUpdate +// method. +type TerraformSettingsClientCreateOrUpdateOptions struct { + // placeholder for future optional parameters +} + +// TerraformSettingsClientDeleteOptions contains the optional parameters for the TerraformSettingsClient.Delete method. +type TerraformSettingsClientDeleteOptions struct { + // placeholder for future optional parameters +} + +// TerraformSettingsClientGetOptions contains the optional parameters for the TerraformSettingsClient.Get method. +type TerraformSettingsClientGetOptions struct { + // placeholder for future optional parameters +} + +// TerraformSettingsClientListByScopeOptions contains the optional parameters for the TerraformSettingsClient.NewListByScopePager +// method. +type TerraformSettingsClientListByScopeOptions struct { + // placeholder for future optional parameters +} + +// TerraformSettingsClientUpdateOptions contains the optional parameters for the TerraformSettingsClient.Update method. +type TerraformSettingsClientUpdateOptions struct { + // placeholder for future optional parameters +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_responses.go b/pkg/corerp/api/v20250801preview/zz_generated_responses.go index 6038070cb6..0b1148926d 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_responses.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_responses.go @@ -39,6 +39,35 @@ type ApplicationsClientUpdateResponse struct { ApplicationResource } +// BicepSettingsClientCreateOrUpdateResponse contains the response from method BicepSettingsClient.CreateOrUpdate. +type BicepSettingsClientCreateOrUpdateResponse struct { + // Bicep settings resource. + BicepSettingsResource +} + +// BicepSettingsClientDeleteResponse contains the response from method BicepSettingsClient.Delete. +type BicepSettingsClientDeleteResponse struct { + // placeholder for future response values +} + +// BicepSettingsClientGetResponse contains the response from method BicepSettingsClient.Get. +type BicepSettingsClientGetResponse struct { + // Bicep settings resource. + BicepSettingsResource +} + +// BicepSettingsClientListByScopeResponse contains the response from method BicepSettingsClient.NewListByScopePager. +type BicepSettingsClientListByScopeResponse struct { + // The response of a BicepSettingsResource list operation. + BicepSettingsResourceListResult +} + +// BicepSettingsClientUpdateResponse contains the response from method BicepSettingsClient.Update. +type BicepSettingsClientUpdateResponse struct { + // Bicep settings resource. + BicepSettingsResource +} + // EnvironmentsClientCreateOrUpdateResponse contains the response from method EnvironmentsClient.CreateOrUpdate. type EnvironmentsClientCreateOrUpdateResponse struct { // The environment resource @@ -102,3 +131,32 @@ type RecipePacksClientUpdateResponse struct { // The recipe pack resource RecipePackResource } + +// TerraformSettingsClientCreateOrUpdateResponse contains the response from method TerraformSettingsClient.CreateOrUpdate. +type TerraformSettingsClientCreateOrUpdateResponse struct { + // Terraform settings resource. + TerraformSettingsResource +} + +// TerraformSettingsClientDeleteResponse contains the response from method TerraformSettingsClient.Delete. +type TerraformSettingsClientDeleteResponse struct { + // placeholder for future response values +} + +// TerraformSettingsClientGetResponse contains the response from method TerraformSettingsClient.Get. +type TerraformSettingsClientGetResponse struct { + // Terraform settings resource. + TerraformSettingsResource +} + +// TerraformSettingsClientListByScopeResponse contains the response from method TerraformSettingsClient.NewListByScopePager. +type TerraformSettingsClientListByScopeResponse struct { + // The response of a TerraformSettingsResource list operation. + TerraformSettingsResourceListResult +} + +// TerraformSettingsClientUpdateResponse contains the response from method TerraformSettingsClient.Update. +type TerraformSettingsClientUpdateResponse struct { + // Terraform settings resource. + TerraformSettingsResource +} diff --git a/pkg/corerp/api/v20250801preview/zz_generated_terraformsettings_client.go b/pkg/corerp/api/v20250801preview/zz_generated_terraformsettings_client.go new file mode 100644 index 0000000000..7ebe75e525 --- /dev/null +++ b/pkg/corerp/api/v20250801preview/zz_generated_terraformsettings_client.go @@ -0,0 +1,319 @@ +// Licensed under the Apache License, Version 2.0 . See LICENSE in the repository root for license information. +// Code generated by Microsoft (R) AutoRest Code Generator. DO NOT EDIT. +// Changes may cause incorrect behavior and will be lost if the code is regenerated. + +package v20250801preview + +import ( + "context" + "errors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "net/url" + "strings" +) + +// TerraformSettingsClient contains the methods for the TerraformSettings group. +// Don't use this type directly, use NewTerraformSettingsClient() instead. +type TerraformSettingsClient struct { + internal *arm.Client + rootScope string +} + +// NewTerraformSettingsClient creates a new instance of TerraformSettingsClient with the specified values. +// - rootScope - The scope in which the resource is present. UCP Scope is /planes/{planeType}/{planeName}/resourceGroup/{resourcegroupID} +// and Azure resource scope is +// /subscriptions/{subscriptionID}/resourceGroup/{resourcegroupID} +// - credential - used to authorize requests. Usually a credential from azidentity. +// - options - Contains optional client configuration. Pass nil to accept the default values. +func NewTerraformSettingsClient(rootScope string, credential azcore.TokenCredential, options *arm.ClientOptions) (*TerraformSettingsClient, error) { + cl, err := arm.NewClient(moduleName, moduleVersion, credential, options) + if err != nil { + return nil, err + } + client := &TerraformSettingsClient{ + rootScope: rootScope, + internal: cl, + } + return client, nil +} + +// CreateOrUpdate - Create a TerraformSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - terraformSettingsName - Terraform settings resource name. +// - resource - Resource create parameters. +// - options - TerraformSettingsClientCreateOrUpdateOptions contains the optional parameters for the TerraformSettingsClient.CreateOrUpdate +// method. +func (client *TerraformSettingsClient) CreateOrUpdate(ctx context.Context, terraformSettingsName string, resource TerraformSettingsResource, options *TerraformSettingsClientCreateOrUpdateOptions) (TerraformSettingsClientCreateOrUpdateResponse, error) { + var err error + const operationName = "TerraformSettingsClient.CreateOrUpdate" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.createOrUpdateCreateRequest(ctx, terraformSettingsName, resource, options) + if err != nil { + return TerraformSettingsClientCreateOrUpdateResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return TerraformSettingsClientCreateOrUpdateResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusCreated) { + err = runtime.NewResponseError(httpResp) + return TerraformSettingsClientCreateOrUpdateResponse{}, err + } + resp, err := client.createOrUpdateHandleResponse(httpResp) + return resp, err +} + +// createOrUpdateCreateRequest creates the CreateOrUpdate request. +func (client *TerraformSettingsClient) createOrUpdateCreateRequest(ctx context.Context, terraformSettingsName string, resource TerraformSettingsResource, _ *TerraformSettingsClientCreateOrUpdateOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/terraformSettings/{terraformSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if terraformSettingsName == "" { + return nil, errors.New("parameter terraformSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{terraformSettingsName}", url.PathEscape(terraformSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodPut, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + if err := runtime.MarshalAsJSON(req, resource); err != nil { + return nil, err + } + return req, nil +} + +// createOrUpdateHandleResponse handles the CreateOrUpdate response. +func (client *TerraformSettingsClient) createOrUpdateHandleResponse(resp *http.Response) (TerraformSettingsClientCreateOrUpdateResponse, error) { + result := TerraformSettingsClientCreateOrUpdateResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.TerraformSettingsResource); err != nil { + return TerraformSettingsClientCreateOrUpdateResponse{}, err + } + return result, nil +} + +// Delete - Delete a TerraformSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - terraformSettingsName - Terraform settings resource name. +// - options - TerraformSettingsClientDeleteOptions contains the optional parameters for the TerraformSettingsClient.Delete +// method. +func (client *TerraformSettingsClient) Delete(ctx context.Context, terraformSettingsName string, options *TerraformSettingsClientDeleteOptions) (TerraformSettingsClientDeleteResponse, error) { + var err error + const operationName = "TerraformSettingsClient.Delete" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.deleteCreateRequest(ctx, terraformSettingsName, options) + if err != nil { + return TerraformSettingsClientDeleteResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return TerraformSettingsClientDeleteResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return TerraformSettingsClientDeleteResponse{}, err + } + return TerraformSettingsClientDeleteResponse{}, nil +} + +// deleteCreateRequest creates the Delete request. +func (client *TerraformSettingsClient) deleteCreateRequest(ctx context.Context, terraformSettingsName string, _ *TerraformSettingsClientDeleteOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/terraformSettings/{terraformSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if terraformSettingsName == "" { + return nil, errors.New("parameter terraformSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{terraformSettingsName}", url.PathEscape(terraformSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodDelete, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// Get - Get a TerraformSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - terraformSettingsName - Terraform settings resource name. +// - options - TerraformSettingsClientGetOptions contains the optional parameters for the TerraformSettingsClient.Get method. +func (client *TerraformSettingsClient) Get(ctx context.Context, terraformSettingsName string, options *TerraformSettingsClientGetOptions) (TerraformSettingsClientGetResponse, error) { + var err error + const operationName = "TerraformSettingsClient.Get" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.getCreateRequest(ctx, terraformSettingsName, options) + if err != nil { + return TerraformSettingsClientGetResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return TerraformSettingsClientGetResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK) { + err = runtime.NewResponseError(httpResp) + return TerraformSettingsClientGetResponse{}, err + } + resp, err := client.getHandleResponse(httpResp) + return resp, err +} + +// getCreateRequest creates the Get request. +func (client *TerraformSettingsClient) getCreateRequest(ctx context.Context, terraformSettingsName string, _ *TerraformSettingsClientGetOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/terraformSettings/{terraformSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if terraformSettingsName == "" { + return nil, errors.New("parameter terraformSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{terraformSettingsName}", url.PathEscape(terraformSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// getHandleResponse handles the Get response. +func (client *TerraformSettingsClient) getHandleResponse(resp *http.Response) (TerraformSettingsClientGetResponse, error) { + result := TerraformSettingsClientGetResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.TerraformSettingsResource); err != nil { + return TerraformSettingsClientGetResponse{}, err + } + return result, nil +} + +// NewListByScopePager - List TerraformSettingsResource resources by Scope +// +// Generated from API version 2025-08-01-preview +// - options - TerraformSettingsClientListByScopeOptions contains the optional parameters for the TerraformSettingsClient.NewListByScopePager +// method. +func (client *TerraformSettingsClient) NewListByScopePager(options *TerraformSettingsClientListByScopeOptions) *runtime.Pager[TerraformSettingsClientListByScopeResponse] { + return runtime.NewPager(runtime.PagingHandler[TerraformSettingsClientListByScopeResponse]{ + More: func(page TerraformSettingsClientListByScopeResponse) bool { + return page.NextLink != nil && len(*page.NextLink) > 0 + }, + Fetcher: func(ctx context.Context, page *TerraformSettingsClientListByScopeResponse) (TerraformSettingsClientListByScopeResponse, error) { + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, "TerraformSettingsClient.NewListByScopePager") + nextLink := "" + if page != nil { + nextLink = *page.NextLink + } + resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), nextLink, func(ctx context.Context) (*policy.Request, error) { + return client.listByScopeCreateRequest(ctx, options) + }, nil) + if err != nil { + return TerraformSettingsClientListByScopeResponse{}, err + } + return client.listByScopeHandleResponse(resp) + }, + Tracer: client.internal.Tracer(), + }) +} + +// listByScopeCreateRequest creates the ListByScope request. +func (client *TerraformSettingsClient) listByScopeCreateRequest(ctx context.Context, _ *TerraformSettingsClientListByScopeOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/terraformSettings" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// listByScopeHandleResponse handles the ListByScope response. +func (client *TerraformSettingsClient) listByScopeHandleResponse(resp *http.Response) (TerraformSettingsClientListByScopeResponse, error) { + result := TerraformSettingsClientListByScopeResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.TerraformSettingsResourceListResult); err != nil { + return TerraformSettingsClientListByScopeResponse{}, err + } + return result, nil +} + +// Update - Update a TerraformSettingsResource +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2025-08-01-preview +// - terraformSettingsName - Terraform settings resource name. +// - properties - The resource properties to be updated. +// - options - TerraformSettingsClientUpdateOptions contains the optional parameters for the TerraformSettingsClient.Update +// method. +func (client *TerraformSettingsClient) Update(ctx context.Context, terraformSettingsName string, properties TerraformSettingsResourceUpdate, options *TerraformSettingsClientUpdateOptions) (TerraformSettingsClientUpdateResponse, error) { + var err error + const operationName = "TerraformSettingsClient.Update" + ctx = context.WithValue(ctx, runtime.CtxAPINameKey{}, operationName) + ctx, endSpan := runtime.StartSpan(ctx, operationName, client.internal.Tracer(), nil) + defer func() { endSpan(err) }() + req, err := client.updateCreateRequest(ctx, terraformSettingsName, properties, options) + if err != nil { + return TerraformSettingsClientUpdateResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return TerraformSettingsClientUpdateResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK) { + err = runtime.NewResponseError(httpResp) + return TerraformSettingsClientUpdateResponse{}, err + } + resp, err := client.updateHandleResponse(httpResp) + return resp, err +} + +// updateCreateRequest creates the Update request. +func (client *TerraformSettingsClient) updateCreateRequest(ctx context.Context, terraformSettingsName string, properties TerraformSettingsResourceUpdate, _ *TerraformSettingsClientUpdateOptions) (*policy.Request, error) { + urlPath := "/{rootScope}/providers/Radius.Core/terraformSettings/{terraformSettingsName}" + urlPath = strings.ReplaceAll(urlPath, "{rootScope}", client.rootScope) + if terraformSettingsName == "" { + return nil, errors.New("parameter terraformSettingsName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{terraformSettingsName}", url.PathEscape(terraformSettingsName)) + req, err := runtime.NewRequest(ctx, http.MethodPatch, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2025-08-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + if err := runtime.MarshalAsJSON(req, properties); err != nil { + return nil, err + } + return req, nil +} + +// updateHandleResponse handles the Update response. +func (client *TerraformSettingsClient) updateHandleResponse(resp *http.Response) (TerraformSettingsClientUpdateResponse, error) { + result := TerraformSettingsClientUpdateResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.TerraformSettingsResource); err != nil { + return TerraformSettingsClientUpdateResponse{}, err + } + return result, nil +} diff --git a/pkg/corerp/datamodel/bicepsettings_v20250801preview.go b/pkg/corerp/datamodel/bicepsettings_v20250801preview.go new file mode 100644 index 0000000000..2d87f91030 --- /dev/null +++ b/pkg/corerp/datamodel/bicepsettings_v20250801preview.go @@ -0,0 +1,72 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamodel + +import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + +const BicepSettingsResourceType_v20250801preview = "Radius.Core/bicepSettings" + +// BicepSettings_v20250801preview represents the Radius.Core/bicepSettings resource. +type BicepSettings_v20250801preview struct { + v1.BaseResource + + // Properties of the Bicep settings resource. + Properties BicepSettingsProperties_v20250801preview `json:"properties"` +} + +// ResourceTypeName returns the resource type for Bicep settings. +func (b *BicepSettings_v20250801preview) ResourceTypeName() string { + return BicepSettingsResourceType_v20250801preview +} + +// BicepSettingsProperties_v20250801preview describes the Bicep settings payload. +type BicepSettingsProperties_v20250801preview struct { + // Authentication contains registry authentication entries keyed by hostname. + Authentication *BicepAuthenticationConfiguration `json:"authentication,omitempty"` +} + +// BicepAuthenticationConfiguration captures registry authentication entries. +type BicepAuthenticationConfiguration struct { + // Registries contains authentication configuration keyed by registry hostname. + Registries map[string]*BicepRegistryAuthentication `json:"registries,omitempty"` +} + +// BicepRegistryAuthentication holds supported auth mechanisms for a registry. +type BicepRegistryAuthentication struct { + Basic *BicepBasicAuthentication `json:"basic,omitempty"` + AzureWorkloadIdentity *BicepAzureWorkloadIdentityAuthentication `json:"azureWorkloadIdentity,omitempty"` + AwsIrsa *BicepAwsIrsaAuthentication `json:"awsIrsa,omitempty"` +} + +// BicepBasicAuthentication holds username/password auth settings. +type BicepBasicAuthentication struct { + Username string `json:"username,omitempty"` + Password *SecretRef `json:"password,omitempty"` +} + +// BicepAzureWorkloadIdentityAuthentication holds Azure Workload Identity settings. +type BicepAzureWorkloadIdentityAuthentication struct { + ClientID string `json:"clientId,omitempty"` + TenantID string `json:"tenantId,omitempty"` + Token *SecretRef `json:"token,omitempty"` +} + +// BicepAwsIrsaAuthentication holds AWS IRSA settings. +type BicepAwsIrsaAuthentication struct { + RoleArn string `json:"roleArn,omitempty"` + Token *SecretRef `json:"token,omitempty"` +} diff --git a/pkg/corerp/datamodel/converter/bicepsettings_converter.go b/pkg/corerp/datamodel/converter/bicepsettings_converter.go new file mode 100644 index 0000000000..35d0d568dc --- /dev/null +++ b/pkg/corerp/datamodel/converter/bicepsettings_converter.go @@ -0,0 +1,59 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package converter + +import ( + "encoding/json" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" +) + +// BicepSettingsDataModelToVersioned converts the datamodel to versioned model. +func BicepSettingsDataModelToVersioned(model *datamodel.BicepSettings_v20250801preview, version string) (v1.VersionedModelInterface, error) { + switch version { + case v20250801preview.Version: + versioned := &v20250801preview.BicepSettingsResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} + +// BicepSettingsDataModelFromVersioned converts versioned model to the datamodel. +func BicepSettingsDataModelFromVersioned(content []byte, version string) (*datamodel.BicepSettings_v20250801preview, error) { + switch version { + case v20250801preview.Version: + am := &v20250801preview.BicepSettingsResource{} + if err := json.Unmarshal(content, am); err != nil { + return nil, err + } + dm, err := am.ConvertTo() + if err != nil { + return nil, err + } + return dm.(*datamodel.BicepSettings_v20250801preview), nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} diff --git a/pkg/corerp/datamodel/converter/bicepsettings_converter_test.go b/pkg/corerp/datamodel/converter/bicepsettings_converter_test.go new file mode 100644 index 0000000000..e187299de9 --- /dev/null +++ b/pkg/corerp/datamodel/converter/bicepsettings_converter_test.go @@ -0,0 +1,327 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package converter + +import ( + "encoding/json" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func TestBicepSettingsDataModelToVersioned(t *testing.T) { + testCases := []struct { + name string + dataModel *datamodel.BicepSettings_v20250801preview + version string + expectError bool + }{ + { + name: "valid conversion to 2025-08-01-preview", + dataModel: &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/bicepSettings", + Location: "global", + Tags: map[string]string{ + "env": "test", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: v20250801preview.Version, + UpdatedAPIVersion: v20250801preview.Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{ + Authentication: &datamodel.BicepAuthenticationConfiguration{ + Registries: map[string]*datamodel.BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &datamodel.BicepBasicAuthentication{ + Username: "admin", + Password: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/acr-password", + Key: "password", + }, + }, + }, + }, + }, + }, + }, + version: v20250801preview.Version, + expectError: false, + }, + { + name: "minimal settings", + dataModel: &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/minimal", + Name: "minimal", + Type: "Radius.Core/bicepSettings", + Location: "global", + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{}, + }, + version: v20250801preview.Version, + expectError: false, + }, + { + name: "unsupported version", + dataModel: &datamodel.BicepSettings_v20250801preview{}, + version: "unsupported-version", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := BicepSettingsDataModelToVersioned(tc.dataModel, tc.version) + + if tc.expectError { + require.Error(t, err) + require.Equal(t, v1.ErrUnsupportedAPIVersion, err) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.IsType(t, &v20250801preview.BicepSettingsResource{}, result) + + versionedResource := result.(*v20250801preview.BicepSettingsResource) + require.Equal(t, tc.dataModel.ID, to.String(versionedResource.ID)) + require.Equal(t, tc.dataModel.Name, to.String(versionedResource.Name)) + require.Equal(t, tc.dataModel.Type, to.String(versionedResource.Type)) + require.Equal(t, tc.dataModel.Location, to.String(versionedResource.Location)) + } + }) + } +} + +func TestBicepSettingsDataModelFromVersioned(t *testing.T) { + testCases := []struct { + name string + content []byte + version string + expectError bool + expected *datamodel.BicepSettings_v20250801preview + }{ + { + name: "valid conversion from 2025-08-01-preview", + content: []byte(`{ + "id": "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/test-settings", + "name": "test-settings", + "type": "Radius.Core/bicepSettings", + "location": "global", + "tags": { + "env": "test" + }, + "properties": { + "authentication": { + "registries": { + "myregistry.azurecr.io": { + "basic": { + "username": "admin", + "password": { + "secretId": "/planes/radius/local/providers/Radius.Security/secrets/acr-password", + "key": "password" + } + } + } + } + } + } + }`), + version: v20250801preview.Version, + expectError: false, + expected: &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/bicepSettings", + Location: "global", + Tags: map[string]string{ + "env": "test", + }, + }, + }, + }, + }, + { + name: "minimal settings", + content: []byte(`{ + "id": "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/minimal", + "name": "minimal", + "type": "Radius.Core/bicepSettings", + "location": "global", + "properties": {} + }`), + version: v20250801preview.Version, + expectError: false, + expected: &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/minimal", + Name: "minimal", + Type: "Radius.Core/bicepSettings", + Location: "global", + }, + }, + }, + }, + { + name: "invalid JSON", + content: []byte(`{invalid json}`), + version: v20250801preview.Version, + expectError: true, + }, + { + name: "unsupported version", + content: []byte(`{}`), + version: "unsupported-version", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := BicepSettingsDataModelFromVersioned(tc.content, tc.version) + + if tc.expectError { + require.Error(t, err) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tc.expected.ID, result.ID) + require.Equal(t, tc.expected.Name, result.Name) + require.Equal(t, tc.expected.Type, result.Type) + require.Equal(t, tc.expected.Location, result.Location) + } + }) + } +} + +func TestBicepSettingsRoundTripConversion(t *testing.T) { + originalDataModel := &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/bicepSettings/round-trip", + Name: "round-trip", + Type: "Radius.Core/bicepSettings", + Location: "global", + Tags: map[string]string{ + "purpose": "testing", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: v20250801preview.Version, + UpdatedAPIVersion: v20250801preview.Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{ + Authentication: &datamodel.BicepAuthenticationConfiguration{ + Registries: map[string]*datamodel.BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &datamodel.BicepBasicAuthentication{ + Username: "admin", + Password: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/acr-password", + Key: "password", + }, + }, + }, + "ghcr.io": { + AzureWorkloadIdentity: &datamodel.BicepAzureWorkloadIdentityAuthentication{ + ClientID: "00000000-0000-0000-0000-000000000001", + TenantID: "00000000-0000-0000-0000-000000000002", + Token: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/azure-token", + Key: "token", + }, + }, + }, + "ecr.aws": { + AwsIrsa: &datamodel.BicepAwsIrsaAuthentication{ + RoleArn: "arn:aws:iam::123456789012:role/my-role", + Token: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/aws-token", + Key: "token", + }, + }, + }, + }, + }, + }, + } + + // Convert to versioned model + versionedModel, err := BicepSettingsDataModelToVersioned(originalDataModel, v20250801preview.Version) + require.NoError(t, err) + require.NotNil(t, versionedModel) + + // Serialize to JSON + jsonBytes, err := json.Marshal(versionedModel) + require.NoError(t, err) + + // Convert back to datamodel + resultDataModel, err := BicepSettingsDataModelFromVersioned(jsonBytes, v20250801preview.Version) + require.NoError(t, err) + require.NotNil(t, resultDataModel) + + // Validate round-trip preserved data + require.Equal(t, originalDataModel.ID, resultDataModel.ID) + require.Equal(t, originalDataModel.Name, resultDataModel.Name) + require.Equal(t, originalDataModel.Type, resultDataModel.Type) + require.Equal(t, originalDataModel.Location, resultDataModel.Location) + require.Equal(t, originalDataModel.Tags, resultDataModel.Tags) + + // Validate Authentication + require.NotNil(t, resultDataModel.Properties.Authentication) + require.NotNil(t, resultDataModel.Properties.Authentication.Registries) + require.Len(t, resultDataModel.Properties.Authentication.Registries, 3) + + // Validate basic auth + basicAuth := resultDataModel.Properties.Authentication.Registries["myregistry.azurecr.io"] + require.NotNil(t, basicAuth) + require.NotNil(t, basicAuth.Basic) + require.Equal(t, "admin", basicAuth.Basic.Username) + require.NotNil(t, basicAuth.Basic.Password) + require.Equal(t, "/planes/radius/local/providers/Radius.Security/secrets/acr-password", basicAuth.Basic.Password.SecretID) + + // Validate Azure Workload Identity auth + azureAuth := resultDataModel.Properties.Authentication.Registries["ghcr.io"] + require.NotNil(t, azureAuth) + require.NotNil(t, azureAuth.AzureWorkloadIdentity) + require.Equal(t, "00000000-0000-0000-0000-000000000001", azureAuth.AzureWorkloadIdentity.ClientID) + require.Equal(t, "00000000-0000-0000-0000-000000000002", azureAuth.AzureWorkloadIdentity.TenantID) + + // Validate AWS IRSA auth + awsAuth := resultDataModel.Properties.Authentication.Registries["ecr.aws"] + require.NotNil(t, awsAuth) + require.NotNil(t, awsAuth.AwsIrsa) + require.Equal(t, "arn:aws:iam::123456789012:role/my-role", awsAuth.AwsIrsa.RoleArn) +} diff --git a/pkg/corerp/datamodel/converter/terraformsettings_converter.go b/pkg/corerp/datamodel/converter/terraformsettings_converter.go new file mode 100644 index 0000000000..656da2b7d5 --- /dev/null +++ b/pkg/corerp/datamodel/converter/terraformsettings_converter.go @@ -0,0 +1,59 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package converter + +import ( + "encoding/json" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" +) + +// TerraformSettingsDataModelToVersioned converts the datamodel to versioned model. +func TerraformSettingsDataModelToVersioned(model *datamodel.TerraformSettings_v20250801preview, version string) (v1.VersionedModelInterface, error) { + switch version { + case v20250801preview.Version: + versioned := &v20250801preview.TerraformSettingsResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} + +// TerraformSettingsDataModelFromVersioned converts versioned model to the datamodel. +func TerraformSettingsDataModelFromVersioned(content []byte, version string) (*datamodel.TerraformSettings_v20250801preview, error) { + switch version { + case v20250801preview.Version: + am := &v20250801preview.TerraformSettingsResource{} + if err := json.Unmarshal(content, am); err != nil { + return nil, err + } + dm, err := am.ConvertTo() + if err != nil { + return nil, err + } + return dm.(*datamodel.TerraformSettings_v20250801preview), nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} diff --git a/pkg/corerp/datamodel/converter/terraformsettings_converter_test.go b/pkg/corerp/datamodel/converter/terraformsettings_converter_test.go new file mode 100644 index 0000000000..aa8dda2d3b --- /dev/null +++ b/pkg/corerp/datamodel/converter/terraformsettings_converter_test.go @@ -0,0 +1,331 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package converter + +import ( + "encoding/json" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func TestTerraformSettingsDataModelToVersioned(t *testing.T) { + testCases := []struct { + name string + dataModel *datamodel.TerraformSettings_v20250801preview + version string + expectError bool + }{ + { + name: "valid conversion to 2025-08-01-preview", + dataModel: &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/terraformSettings", + Location: "global", + Tags: map[string]string{ + "env": "test", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: v20250801preview.Version, + UpdatedAPIVersion: v20250801preview.Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{ + TerraformRC: &datamodel.TerraformCliConfiguration{ + ProviderInstallation: &datamodel.TerraformProviderInstallationConfiguration{ + NetworkMirror: &datamodel.TerraformNetworkMirrorConfiguration{ + URL: "https://mirror.example.com/", + Include: []string{"*"}, + }, + }, + }, + Backend: &datamodel.TerraformBackendConfiguration{ + Type: "kubernetes", + Config: map[string]string{ + "namespace": "radius-system", + }, + }, + Env: map[string]string{ + "TF_LOG": "DEBUG", + }, + }, + }, + version: v20250801preview.Version, + expectError: false, + }, + { + name: "minimal settings", + dataModel: &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/minimal", + Name: "minimal", + Type: "Radius.Core/terraformSettings", + Location: "global", + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{}, + }, + version: v20250801preview.Version, + expectError: false, + }, + { + name: "unsupported version", + dataModel: &datamodel.TerraformSettings_v20250801preview{}, + version: "unsupported-version", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := TerraformSettingsDataModelToVersioned(tc.dataModel, tc.version) + + if tc.expectError { + require.Error(t, err) + require.Equal(t, v1.ErrUnsupportedAPIVersion, err) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.IsType(t, &v20250801preview.TerraformSettingsResource{}, result) + + versionedResource := result.(*v20250801preview.TerraformSettingsResource) + require.Equal(t, tc.dataModel.ID, to.String(versionedResource.ID)) + require.Equal(t, tc.dataModel.Name, to.String(versionedResource.Name)) + require.Equal(t, tc.dataModel.Type, to.String(versionedResource.Type)) + require.Equal(t, tc.dataModel.Location, to.String(versionedResource.Location)) + } + }) + } +} + +func TestTerraformSettingsDataModelFromVersioned(t *testing.T) { + testCases := []struct { + name string + content []byte + version string + expectError bool + expected *datamodel.TerraformSettings_v20250801preview + }{ + { + name: "valid conversion from 2025-08-01-preview", + content: []byte(`{ + "id": "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/test-settings", + "name": "test-settings", + "type": "Radius.Core/terraformSettings", + "location": "global", + "tags": { + "env": "test" + }, + "properties": { + "terraformrc": { + "providerInstallation": { + "networkMirror": { + "url": "https://mirror.example.com/", + "include": ["*"] + } + } + }, + "backend": { + "type": "kubernetes", + "config": { + "namespace": "radius-system" + } + }, + "env": { + "TF_LOG": "DEBUG" + } + } + }`), + version: v20250801preview.Version, + expectError: false, + expected: &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/test-settings", + Name: "test-settings", + Type: "Radius.Core/terraformSettings", + Location: "global", + Tags: map[string]string{ + "env": "test", + }, + }, + }, + }, + }, + { + name: "minimal settings", + content: []byte(`{ + "id": "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/minimal", + "name": "minimal", + "type": "Radius.Core/terraformSettings", + "location": "global", + "properties": {} + }`), + version: v20250801preview.Version, + expectError: false, + expected: &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/minimal", + Name: "minimal", + Type: "Radius.Core/terraformSettings", + Location: "global", + }, + }, + }, + }, + { + name: "invalid JSON", + content: []byte(`{invalid json}`), + version: v20250801preview.Version, + expectError: true, + }, + { + name: "unsupported version", + content: []byte(`{}`), + version: "unsupported-version", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := TerraformSettingsDataModelFromVersioned(tc.content, tc.version) + + if tc.expectError { + require.Error(t, err) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tc.expected.ID, result.ID) + require.Equal(t, tc.expected.Name, result.Name) + require.Equal(t, tc.expected.Type, result.Type) + require.Equal(t, tc.expected.Location, result.Location) + } + }) + } +} + +func TestTerraformSettingsRoundTripConversion(t *testing.T) { + originalDataModel := &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test-rg/providers/Radius.Core/terraformSettings/round-trip", + Name: "round-trip", + Type: "Radius.Core/terraformSettings", + Location: "global", + Tags: map[string]string{ + "purpose": "testing", + }, + }, + InternalMetadata: v1.InternalMetadata{ + CreatedAPIVersion: v20250801preview.Version, + UpdatedAPIVersion: v20250801preview.Version, + AsyncProvisioningState: v1.ProvisioningStateSucceeded, + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{ + TerraformRC: &datamodel.TerraformCliConfiguration{ + ProviderInstallation: &datamodel.TerraformProviderInstallationConfiguration{ + NetworkMirror: &datamodel.TerraformNetworkMirrorConfiguration{ + URL: "https://mirror.example.com/", + Include: []string{"hashicorp/*"}, + Exclude: []string{"hashicorp/azurerm"}, + }, + Direct: &datamodel.TerraformDirectConfiguration{ + Include: []string{"*"}, + }, + }, + Credentials: map[string]*datamodel.TerraformCredentialConfiguration{ + "app.terraform.io": { + Token: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/tfc-token", + Key: "token", + }, + }, + }, + }, + Backend: &datamodel.TerraformBackendConfiguration{ + Type: "kubernetes", + Config: map[string]string{ + "namespace": "radius-system", + "secretSuffix": "prod-state", + }, + }, + Env: map[string]string{ + "TF_LOG": "DEBUG", + "TF_REGISTRY_CLIENT_TIMEOUT": "30", + }, + Logging: &datamodel.TerraformLoggingConfiguration{ + Level: datamodel.TerraformLogLevelDebug, + Path: "/var/log/terraform.log", + }, + }, + } + + // Convert to versioned model + versionedModel, err := TerraformSettingsDataModelToVersioned(originalDataModel, v20250801preview.Version) + require.NoError(t, err) + require.NotNil(t, versionedModel) + + // Serialize to JSON + jsonBytes, err := json.Marshal(versionedModel) + require.NoError(t, err) + + // Convert back to datamodel + resultDataModel, err := TerraformSettingsDataModelFromVersioned(jsonBytes, v20250801preview.Version) + require.NoError(t, err) + require.NotNil(t, resultDataModel) + + // Validate round-trip preserved data + require.Equal(t, originalDataModel.ID, resultDataModel.ID) + require.Equal(t, originalDataModel.Name, resultDataModel.Name) + require.Equal(t, originalDataModel.Type, resultDataModel.Type) + require.Equal(t, originalDataModel.Location, resultDataModel.Location) + require.Equal(t, originalDataModel.Tags, resultDataModel.Tags) + + // Validate TerraformRC + require.NotNil(t, resultDataModel.Properties.TerraformRC) + require.NotNil(t, resultDataModel.Properties.TerraformRC.ProviderInstallation) + require.NotNil(t, resultDataModel.Properties.TerraformRC.ProviderInstallation.NetworkMirror) + require.Equal(t, originalDataModel.Properties.TerraformRC.ProviderInstallation.NetworkMirror.URL, + resultDataModel.Properties.TerraformRC.ProviderInstallation.NetworkMirror.URL) + + // Validate Backend + require.NotNil(t, resultDataModel.Properties.Backend) + require.Equal(t, originalDataModel.Properties.Backend.Type, resultDataModel.Properties.Backend.Type) + + // Validate Env + require.Equal(t, originalDataModel.Properties.Env, resultDataModel.Properties.Env) + + // Validate Logging + require.NotNil(t, resultDataModel.Properties.Logging) + require.Equal(t, originalDataModel.Properties.Logging.Level, resultDataModel.Properties.Logging.Level) + require.Equal(t, originalDataModel.Properties.Logging.Path, resultDataModel.Properties.Logging.Path) +} diff --git a/pkg/corerp/datamodel/environment_v20250801preview.go b/pkg/corerp/datamodel/environment_v20250801preview.go index 3d40a59218..26fb503c6d 100644 --- a/pkg/corerp/datamodel/environment_v20250801preview.go +++ b/pkg/corerp/datamodel/environment_v20250801preview.go @@ -38,6 +38,12 @@ func (e *Environment_v20250801preview) ResourceTypeName() string { // EnvironmentProperties_v20250801preview represents the properties of the new environment schema. type EnvironmentProperties_v20250801preview struct { + // TerraformSettings is the resource ID of the Terraform settings applied to this environment. + TerraformSettings string `json:"terraformSettings,omitempty"` + + // BicepSettings is the resource ID of the Bicep settings applied to this environment. + BicepSettings string `json:"bicepSettings,omitempty"` + // RecipePacks is the list of recipe pack resource IDs linked to this environment. RecipePacks []string `json:"recipePacks,omitempty"` diff --git a/pkg/corerp/datamodel/recipe_types.go b/pkg/corerp/datamodel/recipe_types.go index 57de21a29d..9d51b0f88c 100644 --- a/pkg/corerp/datamodel/recipe_types.go +++ b/pkg/corerp/datamodel/recipe_types.go @@ -40,6 +40,16 @@ type TerraformConfigProperties struct { // Providers specifies the Terraform provider configurations. Controls how Terraform interacts with cloud providers, SaaS providers, and other APIs: https://developer.hashicorp.com/terraform/language/providers/configuration.// Providers specifies the Terraform provider configurations. Providers map[string][]ProviderConfigProperties `json:"providers,omitempty"` + + // ProviderMirror specifies the Terraform provider mirror configuration. + // See: https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation + ProviderMirror *TerraformProviderMirrorConfig `json:"providerMirror,omitempty"` + + // ModuleRegistries specifies configuration for Terraform module registries (e.g., Terraform Cloud/Enterprise). + ModuleRegistries map[string]*TerraformModuleRegistryConfig `json:"moduleRegistries,omitempty"` + + // Version specifies the Terraform binary version and the URL to download it from. + Version *TerraformVersionConfig `json:"version,omitempty"` } // BicepConfigProperties - Configuration for Bicep Recipes. Controls how Bicep plans and applies templates as part of Recipe @@ -48,6 +58,9 @@ type BicepConfigProperties struct { // Authentication holds the information used to access private bicep registries, which is a map of registry hostname to secret config // that contains credential information. Authentication map[string]RegistrySecretConfig + + // RegistryAuthentication contains richer authentication data keyed by registry hostname (Basic, Azure Workload Identity, AWS IRSA). + RegistryAuthentication map[string]*BicepRegistryAuthentication `json:"registryAuthentication,omitempty"` } // RegistrySecretConfig - Registry Secret Configuration used to authenticate to private bicep registries. @@ -77,6 +90,14 @@ type SecretConfig struct { Secret string `json:"secret,omitempty"` } +// ClientCertConfig - Client certificate (mTLS) configuration for authentication. +type ClientCertConfig struct { + // The ID of an Applications.Core/SecretStore resource containing the client certificate and key. + // The secret store must have secrets named 'cert' and 'key' containing the PEM-encoded certificate and private key. + // A secret named 'passphrase' is optional, containing the passphrase for the private key. + Secret string `json:"secret,omitempty"` +} + // EnvironmentVariables represents the environment variables to be set for the recipe execution. type EnvironmentVariables struct { // AdditionalProperties represents the non-sensitive environment variables to be set for the recipe execution. @@ -99,3 +120,107 @@ type SecretReference struct { // Key represents the key of the secret. Key string `json:"key"` } + +// TerraformProviderMirrorConfig - Configuration for Terraform provider mirrors. +type TerraformProviderMirrorConfig struct { + // Type of mirror. DEPRECATED: This field is deprecated. All provider mirrors now use the network mirror protocol. + Type string `json:"type,omitempty"` + + // URL to the mirror server implementing the provider network mirror protocol. + URL string `json:"url,omitempty"` + + // ProviderMappings is used to translate between official and custom provider identifiers. + ProviderMappings map[string]string `json:"providerMappings,omitempty"` + + // Authentication configuration for accessing private Terraform provider mirrors. + Authentication ProviderMirrorAuthConfig `json:"authentication,omitempty"` + + // TLS configuration for connecting to the Terraform provider mirror. + TLS *TLSConfig `json:"tls,omitempty"` +} + +// TerraformModuleRegistryConfig - Configuration for Terraform module registries. +type TerraformModuleRegistryConfig struct { + // URL is the URL of the module registry. + // Example: 'app.terraform.io' for Terraform Cloud or 'terraform.example.com' for Terraform Enterprise + URL string `json:"url,omitempty"` + + // Authentication configuration for accessing private module registries. + Authentication ModuleRegistryAuthConfig `json:"authentication,omitempty"` + + // TLS configuration for connecting to the module registry. + TLS *TLSConfig `json:"tls,omitempty"` +} + +// TokenConfig - Token authentication configuration. +type TokenConfig struct { + // The ID of an Applications.Core/SecretStore resource containing the authentication token. + // The secret store must have a secret named 'token' containing the token value. + Secret string `json:"secret,omitempty"` +} + +// ProviderMirrorAuthConfig - Authentication configuration for Terraform provider mirrors. +// Separate from other auth configs for future-proofing: provider mirrors, module registries, +// and release downloads may evolve different authentication requirements. +type ProviderMirrorAuthConfig struct { + // Token is the token authentication configuration. + Token *TokenConfig `json:"token,omitempty"` + + // AdditionalHosts is a list of additional hosts that should use the same credentials. + AdditionalHosts []string `json:"additionalHosts,omitempty"` +} + +// ModuleRegistryAuthConfig - Authentication configuration for Terraform module registries. +// Separate from other auth configs for future-proofing: provider mirrors, module registries, +// and release downloads may evolve different authentication requirements. +type ModuleRegistryAuthConfig struct { + // Token is the token authentication configuration. + Token *TokenConfig `json:"token,omitempty"` + + // AdditionalHosts is a list of additional hosts that should use the same credentials. + AdditionalHosts []string `json:"additionalHosts,omitempty"` +} + +// ReleasesAuthConfig - Authentication configuration for Terraform binary releases. +// Separate from other auth configs for future-proofing: provider mirrors, module registries, +// and release downloads may evolve different authentication requirements. +type ReleasesAuthConfig struct { + // Token is the token authentication configuration. + Token *TokenConfig `json:"token,omitempty"` + + // AdditionalHosts is a list of additional hosts that should use the same credentials. + AdditionalHosts []string `json:"additionalHosts,omitempty"` +} + +// TerraformVersionConfig - Configuration for Terraform binary. +type TerraformVersionConfig struct { + // Version is the version of the Terraform binary to use. Example: '1.0.0'. + // If omitted, the system may default to the latest stable version. + Version string `json:"version,omitempty"` + + // ReleasesArchiveURL is an optional direct URL to a Terraform binary archive (.zip file). + // If set, Terraform will be downloaded directly from this URL instead of using the releases API. + // This takes precedence over ReleasesAPIBaseURL. + // The URL must point to a valid Terraform release archive. + // Example: 'https://my-mirror.example.com/terraform/1.7.0/terraform_1.7.0_linux_amd64.zip' + ReleasesArchiveURL string `json:"releasesArchiveUrl,omitempty"` + + // ReleasesAPIBaseURL is an optional base URL for a custom Terraform releases API. + // If set, Terraform will be downloaded from this base URL instead of the default HashiCorp releases site. + // The directory structure of the custom URL must match the HashiCorp releases site (including the index.json files). + // Example: 'https://my-terraform-mirror.example.com' + ReleasesAPIBaseURL string `json:"releasesApiBaseUrl,omitempty"` + + // TLS contains TLS configuration for connecting to the releases API. + TLS *TLSConfig `json:"tls,omitempty"` + + // Authentication configuration for accessing the Terraform binary releases API. + Authentication *ReleasesAuthConfig `json:"authentication,omitempty"` +} + +// TLSConfig - TLS configuration options for HTTPS connections. +type TLSConfig struct { + // CACertificate is a reference to a secret containing a custom CA certificate bundle to use for TLS verification. + // The secret must contain a key named 'ca-cert' with the PEM-encoded certificate bundle. + CACertificate *SecretReference `json:"caCertificate,omitempty"` +} diff --git a/pkg/corerp/datamodel/terraformsettings_v20250801preview.go b/pkg/corerp/datamodel/terraformsettings_v20250801preview.go new file mode 100644 index 0000000000..8e263a725a --- /dev/null +++ b/pkg/corerp/datamodel/terraformsettings_v20250801preview.go @@ -0,0 +1,110 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamodel + +import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + +const TerraformSettingsResourceType_v20250801preview = "Radius.Core/terraformSettings" + +// TerraformSettings_v20250801preview represents the Radius.Core/terraformSettings resource. +type TerraformSettings_v20250801preview struct { + v1.BaseResource + + // Properties of the Terraform settings resource. + Properties TerraformSettingsProperties_v20250801preview `json:"properties"` +} + +// ResourceTypeName returns the resource type for Terraform settings. +func (t *TerraformSettings_v20250801preview) ResourceTypeName() string { + return TerraformSettingsResourceType_v20250801preview +} + +// TerraformSettingsProperties_v20250801preview describes the Terraform settings payload. +type TerraformSettingsProperties_v20250801preview struct { + // TerraformRC mirrors the terraformrc file shape (provider mirrors, credentials). + TerraformRC *TerraformCliConfiguration `json:"terraformrc,omitempty"` + + // Backend configuration matching the Terraform backend block. + Backend *TerraformBackendConfiguration `json:"backend,omitempty"` + + // Env contains environment variables passed to Terraform executions. + Env map[string]string `json:"env,omitempty"` + + // Logging controls Terraform logging behaviour (TF_LOG/TF_LOG_PATH). + Logging *TerraformLoggingConfiguration `json:"logging,omitempty"` +} + +// TerraformCliConfiguration mirrors the terraformrc provider installation + credentials sections. +type TerraformCliConfiguration struct { + ProviderInstallation *TerraformProviderInstallationConfiguration `json:"providerInstallation,omitempty"` + Credentials map[string]*TerraformCredentialConfiguration `json:"credentials,omitempty"` +} + +// TerraformProviderInstallationConfiguration describes network mirror and direct rules. +type TerraformProviderInstallationConfiguration struct { + NetworkMirror *TerraformNetworkMirrorConfiguration `json:"networkMirror,omitempty"` + Direct *TerraformDirectConfiguration `json:"direct,omitempty"` +} + +// TerraformNetworkMirrorConfiguration describes a network mirror entry. +type TerraformNetworkMirrorConfiguration struct { + URL string `json:"url"` + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` +} + +// TerraformDirectInstallationConfiguration controls direct installation rules. +type TerraformDirectConfiguration struct { + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` +} + +// TerraformCredentialConfiguration describes credentials keyed by hostname. +type TerraformCredentialConfiguration struct { + Token *SecretRef `json:"token,omitempty"` +} + +// SecretRef points to a secret in Radius.Security/secrets. +// This is separate from SecretReference in recipe_types.go which uses different field names. +type SecretRef struct { + SecretID string `json:"secretId"` + Key string `json:"key"` +} + +// TerraformBackendConfiguration mirrors the Terraform backend block (type + config). +type TerraformBackendConfiguration struct { + Type string `json:"type"` + Config map[string]string `json:"config,omitempty"` +} + +// TerraformLoggingConfiguration captures TF_LOG/TF_LOG_PATH settings. +type TerraformLoggingConfiguration struct { + Level TerraformLogLevel `json:"level,omitempty"` + Path string `json:"path,omitempty"` +} + +// TerraformLogLevel enumerates supported TF_LOG values. +type TerraformLogLevel string + +const ( + TerraformLogLevelTrace TerraformLogLevel = "TRACE" + TerraformLogLevelDebug TerraformLogLevel = "DEBUG" + TerraformLogLevelInfo TerraformLogLevel = "INFO" + TerraformLogLevelWarn TerraformLogLevel = "WARN" + TerraformLogLevelError TerraformLogLevel = "ERROR" + TerraformLogLevelFatal TerraformLogLevel = "FATAL" +) diff --git a/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings.go b/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings.go new file mode 100644 index 0000000000..5065675539 --- /dev/null +++ b/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings.go @@ -0,0 +1,76 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bicepsettings + +import ( + "context" + "net/http" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller" + "github.com/radius-project/radius/pkg/armrpc/rest" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/corerp/datamodel/converter" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +var _ ctrl.Controller = (*CreateOrUpdateBicepSettings)(nil) + +// CreateOrUpdateBicepSettings is the controller implementation to create or update bicep settings resource. +type CreateOrUpdateBicepSettings struct { + ctrl.Operation[*datamodel.BicepSettings_v20250801preview, datamodel.BicepSettings_v20250801preview] +} + +// NewCreateOrUpdateBicepSettings creates a new controller for creating or updating a bicep settings resource. +func NewCreateOrUpdateBicepSettings(opts ctrl.Options) (ctrl.Controller, error) { + return &CreateOrUpdateBicepSettings{ + ctrl.NewOperation(opts, + ctrl.ResourceOptions[datamodel.BicepSettings_v20250801preview]{ + RequestConverter: converter.BicepSettingsDataModelFromVersioned, + ResponseConverter: converter.BicepSettingsDataModelToVersioned, + }, + ), + }, nil +} + +// Run creates or updates a bicep settings resource. +func (r *CreateOrUpdateBicepSettings) Run(ctx context.Context, w http.ResponseWriter, req *http.Request) (rest.Response, error) { + logger := ucplog.FromContextOrDiscard(ctx) + serviceCtx := v1.ARMRequestContextFromContext(ctx) + newResource, err := r.GetResourceFromRequest(ctx, req) + if err != nil { + return nil, err + } + old, etag, err := r.GetResource(ctx, serviceCtx.ResourceID) + if err != nil { + return nil, err + } + + if resp, err := r.PrepareResource(ctx, req, newResource, old, etag); resp != nil || err != nil { + return resp, err + } + + logger.Info("Creating or updating bicep settings", "resourceID", serviceCtx.ResourceID.String()) + + newResource.SetProvisioningState(v1.ProvisioningStateSucceeded) + newEtag, err := r.SaveResource(ctx, serviceCtx.ResourceID.String(), newResource, etag) + if err != nil { + return nil, err + } + + return r.ConstructSyncResponse(ctx, req.Method, newEtag, newResource) +} diff --git a/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings_test.go b/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings_test.go new file mode 100644 index 0000000000..c834570e00 --- /dev/null +++ b/pkg/corerp/frontend/controller/bicepsettings/createorupdatebicepsettings_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bicepsettings + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller" + "github.com/radius-project/radius/pkg/armrpc/rpctest" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +func TestNewCreateOrUpdateBicepSettings(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + controller, err := NewCreateOrUpdateBicepSettings(opts) + require.NoError(t, err) + require.NotNil(t, controller) +} + +func TestCreateOrUpdateBicepSettingsRun_CreateNew(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + + bicepSettingsInput, bicepSettingsDataModel, _ := getTestModels() + w := httptest.NewRecorder() + + jsonPayload, err := json.Marshal(bicepSettingsInput) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPut, "/planes/radius/local/resourceGroups/default/providers/Radius.Core/bicepSettings/test-settings?api-version=2025-08-01-preview", strings.NewReader(string(jsonPayload))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + ctx := rpctest.NewARMRequestContext(req) + + databaseClient. + EXPECT(). + Get(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, id string, _ ...database.GetOptions) (*database.Object, error) { + return nil, &database.ErrNotFound{ID: id} + }) + + databaseClient. + EXPECT(). + Save(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, obj *database.Object, opts ...database.SaveOptions) error { + obj.ETag = "new-resource-etag" + obj.Data = bicepSettingsDataModel + return nil + }) + + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + ctl, err := NewCreateOrUpdateBicepSettings(opts) + require.NoError(t, err) + resp, err := ctl.Run(ctx, w, req) + require.NoError(t, err) + _ = resp.Apply(ctx, w, req) + require.Equal(t, 200, w.Result().StatusCode) + + actualOutput := &v20250801preview.BicepSettingsResource{} + _ = json.Unmarshal(w.Body.Bytes(), actualOutput) + require.Equal(t, v20250801preview.ProvisioningStateSucceeded, *actualOutput.Properties.ProvisioningState) +} + +func TestCreateOrUpdateBicepSettingsRun_UpdateExisting(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + bicepSettingsInput, bicepSettingsDataModel, _ := getTestModels() + w := httptest.NewRecorder() + + jsonPayload, err := json.Marshal(bicepSettingsInput) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPut, "/planes/radius/local/resourceGroups/default/providers/Radius.Core/bicepSettings/test-settings?api-version=2025-08-01-preview", strings.NewReader(string(jsonPayload))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + ctx := rpctest.NewARMRequestContext(req) + + databaseClient. + EXPECT(). + Get(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, id string, _ ...database.GetOptions) (*database.Object, error) { + return &database.Object{ + Data: bicepSettingsDataModel, + }, nil + }) + + databaseClient. + EXPECT(). + Save(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, obj *database.Object, opts ...database.SaveOptions) error { + obj.Data = bicepSettingsDataModel + return nil + }) + + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + ctl, err := NewCreateOrUpdateBicepSettings(opts) + require.NoError(t, err) + resp, err := ctl.Run(ctx, w, req) + require.NoError(t, err) + _ = resp.Apply(ctx, w, req) + require.Equal(t, 200, w.Result().StatusCode) + + actualOutput := &v20250801preview.BicepSettingsResource{} + _ = json.Unmarshal(w.Body.Bytes(), actualOutput) + require.Equal(t, v20250801preview.ProvisioningStateSucceeded, *actualOutput.Properties.ProvisioningState) +} + +func getTestModels() (*v20250801preview.BicepSettingsResource, *datamodel.BicepSettings_v20250801preview, *v20250801preview.BicepSettingsResource) { + resourceID := "/planes/radius/local/resourceGroups/default/providers/Radius.Core/bicepSettings/test-settings" + resourceName := "test-settings" + location := "global" + + bicepSettingsInput := &v20250801preview.BicepSettingsResource{ + Location: &location, + Properties: &v20250801preview.BicepSettingsProperties{ + Authentication: &v20250801preview.BicepAuthenticationConfiguration{ + Registries: map[string]*v20250801preview.BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &v20250801preview.BicepBasicAuthentication{ + Username: to.Ptr("admin"), + Password: &v20250801preview.SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/acr-password"), + Key: to.Ptr("password"), + }, + }, + }, + }, + }, + }, + } + + bicepSettingsDataModel := &datamodel.BicepSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: resourceID, + Name: resourceName, + Type: datamodel.BicepSettingsResourceType_v20250801preview, + Location: location, + }, + }, + Properties: datamodel.BicepSettingsProperties_v20250801preview{ + Authentication: &datamodel.BicepAuthenticationConfiguration{ + Registries: map[string]*datamodel.BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &datamodel.BicepBasicAuthentication{ + Username: "admin", + Password: &datamodel.SecretRef{ + SecretID: "/planes/radius/local/providers/Radius.Security/secrets/acr-password", + Key: "password", + }, + }, + }, + }, + }, + }, + } + + expectedOutput := &v20250801preview.BicepSettingsResource{ + ID: &resourceID, + Name: &resourceName, + Type: to.Ptr(datamodel.BicepSettingsResourceType_v20250801preview), + Location: &location, + Properties: &v20250801preview.BicepSettingsProperties{ + ProvisioningState: to.Ptr(v20250801preview.ProvisioningStateSucceeded), + Authentication: &v20250801preview.BicepAuthenticationConfiguration{ + Registries: map[string]*v20250801preview.BicepRegistryAuthentication{ + "myregistry.azurecr.io": { + Basic: &v20250801preview.BicepBasicAuthentication{ + Username: to.Ptr("admin"), + Password: &v20250801preview.SecretReference{ + SecretID: to.Ptr("/planes/radius/local/providers/Radius.Security/secrets/acr-password"), + Key: to.Ptr("password"), + }, + }, + }, + }, + }, + }, + } + + return bicepSettingsInput, bicepSettingsDataModel, expectedOutput +} diff --git a/pkg/corerp/frontend/controller/bicepsettings/types.go b/pkg/corerp/frontend/controller/bicepsettings/types.go new file mode 100644 index 0000000000..0340f9d70d --- /dev/null +++ b/pkg/corerp/frontend/controller/bicepsettings/types.go @@ -0,0 +1,21 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bicepsettings + +const ( + ResourceTypeName = "Radius.Core/bicepSettings" +) diff --git a/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings.go b/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings.go new file mode 100644 index 0000000000..2f834ba855 --- /dev/null +++ b/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings.go @@ -0,0 +1,76 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraformsettings + +import ( + "context" + "net/http" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller" + "github.com/radius-project/radius/pkg/armrpc/rest" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/corerp/datamodel/converter" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +var _ ctrl.Controller = (*CreateOrUpdateTerraformSettings)(nil) + +// CreateOrUpdateTerraformSettings is the controller implementation to create or update terraform settings resource. +type CreateOrUpdateTerraformSettings struct { + ctrl.Operation[*datamodel.TerraformSettings_v20250801preview, datamodel.TerraformSettings_v20250801preview] +} + +// NewCreateOrUpdateTerraformSettings creates a new controller for creating or updating a terraform settings resource. +func NewCreateOrUpdateTerraformSettings(opts ctrl.Options) (ctrl.Controller, error) { + return &CreateOrUpdateTerraformSettings{ + ctrl.NewOperation(opts, + ctrl.ResourceOptions[datamodel.TerraformSettings_v20250801preview]{ + RequestConverter: converter.TerraformSettingsDataModelFromVersioned, + ResponseConverter: converter.TerraformSettingsDataModelToVersioned, + }, + ), + }, nil +} + +// Run creates or updates a terraform settings resource. +func (r *CreateOrUpdateTerraformSettings) Run(ctx context.Context, w http.ResponseWriter, req *http.Request) (rest.Response, error) { + logger := ucplog.FromContextOrDiscard(ctx) + serviceCtx := v1.ARMRequestContextFromContext(ctx) + newResource, err := r.GetResourceFromRequest(ctx, req) + if err != nil { + return nil, err + } + old, etag, err := r.GetResource(ctx, serviceCtx.ResourceID) + if err != nil { + return nil, err + } + + if resp, err := r.PrepareResource(ctx, req, newResource, old, etag); resp != nil || err != nil { + return resp, err + } + + logger.Info("Creating or updating terraform settings", "resourceID", serviceCtx.ResourceID.String()) + + newResource.SetProvisioningState(v1.ProvisioningStateSucceeded) + newEtag, err := r.SaveResource(ctx, serviceCtx.ResourceID.String(), newResource, etag) + if err != nil { + return nil, err + } + + return r.ConstructSyncResponse(ctx, req.Method, newEtag, newResource) +} diff --git a/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings_test.go b/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings_test.go new file mode 100644 index 0000000000..670da32ecf --- /dev/null +++ b/pkg/corerp/frontend/controller/terraformsettings/createorupdateterraformsettings_test.go @@ -0,0 +1,237 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraformsettings + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/frontend/controller" + "github.com/radius-project/radius/pkg/armrpc/rpctest" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +func TestNewCreateOrUpdateTerraformSettings(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + controller, err := NewCreateOrUpdateTerraformSettings(opts) + require.NoError(t, err) + require.NotNil(t, controller) +} + +func TestCreateOrUpdateTerraformSettingsRun_CreateNew(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + + terraformSettingsInput, terraformSettingsDataModel, expectedOutput := getTestModels() + w := httptest.NewRecorder() + + jsonPayload, err := json.Marshal(terraformSettingsInput) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPut, "/planes/radius/local/resourceGroups/default/providers/Radius.Core/terraformSettings/test-settings?api-version=2025-08-01-preview", strings.NewReader(string(jsonPayload))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + ctx := rpctest.NewARMRequestContext(req) + + databaseClient. + EXPECT(). + Get(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, id string, _ ...database.GetOptions) (*database.Object, error) { + return nil, &database.ErrNotFound{ID: id} + }) + + databaseClient. + EXPECT(). + Save(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, obj *database.Object, opts ...database.SaveOptions) error { + obj.ETag = "new-resource-etag" + obj.Data = terraformSettingsDataModel + return nil + }) + + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + ctl, err := NewCreateOrUpdateTerraformSettings(opts) + require.NoError(t, err) + resp, err := ctl.Run(ctx, w, req) + require.NoError(t, err) + _ = resp.Apply(ctx, w, req) + require.Equal(t, 200, w.Result().StatusCode) + + actualOutput := &v20250801preview.TerraformSettingsResource{} + _ = json.Unmarshal(w.Body.Bytes(), actualOutput) + require.Equal(t, expectedOutput.Properties.Backend.Type, actualOutput.Properties.Backend.Type) + require.Equal(t, v20250801preview.ProvisioningStateSucceeded, *actualOutput.Properties.ProvisioningState) +} + +func TestCreateOrUpdateTerraformSettingsRun_UpdateExisting(t *testing.T) { + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + databaseClient := database.NewMockClient(mctrl) + terraformSettingsInput, terraformSettingsDataModel, expectedOutput := getTestModels() + w := httptest.NewRecorder() + + jsonPayload, err := json.Marshal(terraformSettingsInput) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPut, "/planes/radius/local/resourceGroups/default/providers/Radius.Core/terraformSettings/test-settings?api-version=2025-08-01-preview", strings.NewReader(string(jsonPayload))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + ctx := rpctest.NewARMRequestContext(req) + + databaseClient. + EXPECT(). + Get(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, id string, _ ...database.GetOptions) (*database.Object, error) { + return &database.Object{ + Data: terraformSettingsDataModel, + }, nil + }) + + databaseClient. + EXPECT(). + Save(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, obj *database.Object, opts ...database.SaveOptions) error { + obj.Data = terraformSettingsDataModel + return nil + }) + + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + ctl, err := NewCreateOrUpdateTerraformSettings(opts) + require.NoError(t, err) + resp, err := ctl.Run(ctx, w, req) + require.NoError(t, err) + _ = resp.Apply(ctx, w, req) + require.Equal(t, 200, w.Result().StatusCode) + + actualOutput := &v20250801preview.TerraformSettingsResource{} + _ = json.Unmarshal(w.Body.Bytes(), actualOutput) + require.Equal(t, expectedOutput.Properties.Backend.Type, actualOutput.Properties.Backend.Type) + require.Equal(t, v20250801preview.ProvisioningStateSucceeded, *actualOutput.Properties.ProvisioningState) +} + +func getTestModels() (*v20250801preview.TerraformSettingsResource, *datamodel.TerraformSettings_v20250801preview, *v20250801preview.TerraformSettingsResource) { + resourceID := "/planes/radius/local/resourceGroups/default/providers/Radius.Core/terraformSettings/test-settings" + resourceName := "test-settings" + location := "global" + + terraformSettingsInput := &v20250801preview.TerraformSettingsResource{ + Location: &location, + Properties: &v20250801preview.TerraformSettingsProperties{ + Terraformrc: &v20250801preview.TerraformCliConfiguration{ + ProviderInstallation: &v20250801preview.TerraformProviderInstallationConfiguration{ + NetworkMirror: &v20250801preview.TerraformNetworkMirrorConfiguration{ + URL: to.Ptr("https://mirror.example.com/"), + Include: []*string{to.Ptr("*")}, + }, + }, + }, + Backend: &v20250801preview.TerraformBackendConfiguration{ + Type: to.Ptr("kubernetes"), + Config: map[string]*string{ + "namespace": to.Ptr("radius-system"), + }, + }, + Env: map[string]*string{ + "TF_LOG": to.Ptr("DEBUG"), + }, + }, + } + + terraformSettingsDataModel := &datamodel.TerraformSettings_v20250801preview{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: resourceID, + Name: resourceName, + Type: datamodel.TerraformSettingsResourceType_v20250801preview, + Location: location, + }, + }, + Properties: datamodel.TerraformSettingsProperties_v20250801preview{ + TerraformRC: &datamodel.TerraformCliConfiguration{ + ProviderInstallation: &datamodel.TerraformProviderInstallationConfiguration{ + NetworkMirror: &datamodel.TerraformNetworkMirrorConfiguration{ + URL: "https://mirror.example.com/", + Include: []string{"*"}, + }, + }, + }, + Backend: &datamodel.TerraformBackendConfiguration{ + Type: "kubernetes", + Config: map[string]string{ + "namespace": "radius-system", + }, + }, + Env: map[string]string{ + "TF_LOG": "DEBUG", + }, + }, + } + + expectedOutput := &v20250801preview.TerraformSettingsResource{ + ID: &resourceID, + Name: &resourceName, + Type: to.Ptr(datamodel.TerraformSettingsResourceType_v20250801preview), + Location: &location, + Properties: &v20250801preview.TerraformSettingsProperties{ + ProvisioningState: to.Ptr(v20250801preview.ProvisioningStateSucceeded), + Terraformrc: &v20250801preview.TerraformCliConfiguration{ + ProviderInstallation: &v20250801preview.TerraformProviderInstallationConfiguration{ + NetworkMirror: &v20250801preview.TerraformNetworkMirrorConfiguration{ + URL: to.Ptr("https://mirror.example.com/"), + Include: []*string{to.Ptr("*")}, + }, + }, + }, + Backend: &v20250801preview.TerraformBackendConfiguration{ + Type: to.Ptr("kubernetes"), + Config: map[string]*string{ + "namespace": to.Ptr("radius-system"), + }, + }, + Env: map[string]*string{ + "TF_LOG": to.Ptr("DEBUG"), + }, + }, + } + + return terraformSettingsInput, terraformSettingsDataModel, expectedOutput +} diff --git a/pkg/corerp/frontend/controller/terraformsettings/types.go b/pkg/corerp/frontend/controller/terraformsettings/types.go new file mode 100644 index 0000000000..a880b63186 --- /dev/null +++ b/pkg/corerp/frontend/controller/terraformsettings/types.go @@ -0,0 +1,21 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraformsettings + +const ( + ResourceTypeName = "Radius.Core/terraformSettings" +) diff --git a/pkg/corerp/setup/operations.go b/pkg/corerp/setup/operations.go index 3022a23760..fca02423ba 100644 --- a/pkg/corerp/setup/operations.go +++ b/pkg/corerp/setup/operations.go @@ -259,4 +259,64 @@ var operationList = []v1.Operation{ }, IsDataAction: false, }, + { + Name: "Radius.Core/terraformSettings/read", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "terraformSettings", + Operation: "Get/List terraform settings", + Description: "Gets/Lists terraform settings resource(s).", + }, + IsDataAction: false, + }, + { + Name: "Radius.Core/terraformSettings/write", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "terraformSettings", + Operation: "Create/Update terraform settings", + Description: "Creates or updates a terraform settings resource.", + }, + IsDataAction: false, + }, + { + Name: "Radius.Core/terraformSettings/delete", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "terraformSettings", + Operation: "Delete terraform settings", + Description: "Deletes a terraform settings resource.", + }, + IsDataAction: false, + }, + { + Name: "Radius.Core/bicepSettings/read", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "bicepSettings", + Operation: "Get/List bicep settings", + Description: "Gets/Lists bicep settings resource(s).", + }, + IsDataAction: false, + }, + { + Name: "Radius.Core/bicepSettings/write", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "bicepSettings", + Operation: "Create/Update bicep settings", + Description: "Creates or updates a bicep settings resource.", + }, + IsDataAction: false, + }, + { + Name: "Radius.Core/bicepSettings/delete", + Display: &v1.OperationDisplayProperties{ + Provider: "Radius.Core", + Resource: "bicepSettings", + Operation: "Delete bicep settings", + Description: "Deletes a bicep settings resource.", + }, + IsDataAction: false, + }, } diff --git a/pkg/corerp/setup/setup.go b/pkg/corerp/setup/setup.go index cb34c8fe2f..0ebe214c3e 100644 --- a/pkg/corerp/setup/setup.go +++ b/pkg/corerp/setup/setup.go @@ -26,6 +26,7 @@ import ( "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/corerp/datamodel/converter" app_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/applications" + bicep_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/bicepsettings" ctr_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/containers" env_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/environments" env_v20250801_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/environments/v20250801preview" @@ -33,6 +34,7 @@ import ( gw_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/gateways" rp_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/recipepacks" secret_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/secretstores" + tf_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/terraformsettings" vol_ctrl "github.com/radius-project/radius/pkg/corerp/frontend/controller/volumes" ext_processor "github.com/radius-project/radius/pkg/corerp/processors/extenders" pr_ctrl "github.com/radius-project/radius/pkg/portableresources/backend/controller" @@ -295,5 +297,29 @@ func SetupRadiusCoreNamespace(recipeControllerConfig *controllerconfig.RecipeCon }, }) + _ = ns.AddResource("terraformSettings", &builder.ResourceOption[*datamodel.TerraformSettings_v20250801preview, datamodel.TerraformSettings_v20250801preview]{ + RequestConverter: converter.TerraformSettingsDataModelFromVersioned, + ResponseConverter: converter.TerraformSettingsDataModelToVersioned, + + Put: builder.Operation[datamodel.TerraformSettings_v20250801preview]{ + APIController: tf_ctrl.NewCreateOrUpdateTerraformSettings, + }, + Patch: builder.Operation[datamodel.TerraformSettings_v20250801preview]{ + APIController: tf_ctrl.NewCreateOrUpdateTerraformSettings, + }, + }) + + _ = ns.AddResource("bicepSettings", &builder.ResourceOption[*datamodel.BicepSettings_v20250801preview, datamodel.BicepSettings_v20250801preview]{ + RequestConverter: converter.BicepSettingsDataModelFromVersioned, + ResponseConverter: converter.BicepSettingsDataModelToVersioned, + + Put: builder.Operation[datamodel.BicepSettings_v20250801preview]{ + APIController: bicep_ctrl.NewCreateOrUpdateBicepSettings, + }, + Patch: builder.Operation[datamodel.BicepSettings_v20250801preview]{ + APIController: bicep_ctrl.NewCreateOrUpdateBicepSettings, + }, + }) + return ns } diff --git a/pkg/recipes/driver/terraform/terraform.go b/pkg/recipes/driver/terraform/terraform.go index f1def39c93..50c9022493 100644 --- a/pkg/recipes/driver/terraform/terraform.go +++ b/pkg/recipes/driver/terraform/terraform.go @@ -103,6 +103,7 @@ func (d *terraformDriver) Execute(ctx context.Context, opts driver.ExecuteOption tfState, err := d.terraformExecutor.Deploy(ctx, terraform.Options{ RootDir: requestDirPath, + TerraformPath: d.options.Path, EnvConfig: &opts.Configuration, ResourceRecipe: &opts.Recipe, EnvRecipe: &opts.Definition, @@ -157,6 +158,7 @@ func (d *terraformDriver) Delete(ctx context.Context, opts driver.DeleteOptions) err = d.terraformExecutor.Delete(ctx, terraform.Options{ RootDir: requestDirPath, + TerraformPath: d.options.Path, EnvConfig: &opts.Configuration, ResourceRecipe: &opts.Recipe, EnvRecipe: &opts.Definition, @@ -286,6 +288,7 @@ func (d *terraformDriver) GetRecipeMetadata(ctx context.Context, opts driver.Bas recipeData, err := d.terraformExecutor.GetRecipeMetadata(ctx, terraform.Options{ RootDir: requestDirPath, + TerraformPath: d.options.Path, ResourceRecipe: &opts.Recipe, EnvRecipe: &opts.Definition, LogLevel: d.options.LogLevel, diff --git a/pkg/recipes/terraform/doc.go b/pkg/recipes/terraform/doc.go new file mode 100644 index 0000000000..fc37348c25 --- /dev/null +++ b/pkg/recipes/terraform/doc.go @@ -0,0 +1,50 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package terraform provides the Terraform recipe driver and executor for Radius. + +# Terraform Binary Lookup + +When a recipe executes Terraform, the binary is located using the following priority order: + + 1. Recipe execution calls Install() which delegates to ensureGlobalTerraformBinary() + + 2. The function first checks for /terraform/current symlink, which is created by + the Terraform installer API (rad terraform install command) + + 3. If found, the symlink is resolved to /terraform/versions/{version}/terraform + and the binary is verified by running "terraform version" + + 4. If the installer binary is working, it is used directly - no download needed + + 5. If not found or not working, falls back to the global shared binary at + /terraform/.terraform-global/terraform + + 6. If the global binary doesn't exist, downloads Terraform via hc-install library + +# Path Summary + + - Installer API path: /terraform/current -> /terraform/versions/{version}/terraform + - Global shared path: /terraform/.terraform-global/terraform + - Global marker file: /terraform/.terraform-global/.terraform-ready + +# Environment Variables (Testing) + + - TERRAFORM_TEST_GLOBAL_DIR: Override the global terraform directory for testing + - TERRAFORM_TEST_INSTALLER_DIR: Override the installer API directory for testing +*/ +package terraform diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index dea1ca988d..4992cd8947 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -68,7 +68,7 @@ type executor struct { func (e *executor) Deploy(ctx context.Context, options Options) (*tfjson.State, error) { // Install Terraform i := install.NewInstaller() - tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, LogLevel: options.LogLevel}) + tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, TerraformPath: options.TerraformPath, LogLevel: options.LogLevel}) if err != nil { return nil, err } @@ -118,7 +118,7 @@ func (e *executor) Delete(ctx context.Context, options Options) error { // Install Terraform i := install.NewInstaller() - tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, LogLevel: options.LogLevel}) + tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, TerraformPath: options.TerraformPath, LogLevel: options.LogLevel}) // Note: We use a global shared binary approach, so we should NOT call i.Remove() // as it would remove the shared global binary that other operations might be using. // The global binary will persist across operations to eliminate race conditions. @@ -172,7 +172,7 @@ func (e *executor) Delete(ctx context.Context, options Options) error { func (e *executor) GetRecipeMetadata(ctx context.Context, options Options) (map[string]any, error) { // Install Terraform i := install.NewInstaller() - tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, LogLevel: options.LogLevel}) + tf, err := Install(ctx, i, InstallOptions{RootDir: options.RootDir, TerraformPath: options.TerraformPath, LogLevel: options.LogLevel}) if err != nil { return nil, err } diff --git a/pkg/recipes/terraform/install.go b/pkg/recipes/terraform/install.go index ce431be5c2..56a3124c8e 100644 --- a/pkg/recipes/terraform/install.go +++ b/pkg/recipes/terraform/install.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "path/filepath" "sync" "time" @@ -39,11 +40,19 @@ const ( installVerificationRetryCount = 5 installVerificationRetryDelaySecs = 3 + // Default Terraform root path used when no configured root is provided. + defaultTerraformRoot = "/terraform" + // Global shared terraform binary paths (persistent hidden directory under terraform root) // Using .terraform-global as a more recognizable and persistent directory name - defaultGlobalTerraformDir = "/terraform/.terraform-global" - defaultGlobalTerraformBinary = "/terraform/.terraform-global/terraform" - defaultGlobalMarkerFile = "/terraform/.terraform-global/.terraform-ready" + globalTerraformDirName = ".terraform-global" + globalTerraformBinaryName = "terraform" + globalTerraformMarkerName = ".terraform-ready" + + // Installer API paths - these are used by the `rad terraform install` command + // and the Terraform installer REST API to pre-install specific versions. + // The "current" symlink points to the active version's binary. + installerCurrentSymlinkName = "current" ) // InstallOptions configures how Terraform is installed and initialized. @@ -51,16 +60,40 @@ type InstallOptions struct { // RootDir is the directory used to create the Terraform working directory for the caller. RootDir string + // TerraformPath is the root directory where the Terraform installer writes binaries. + // This should match the configured terraform.path value when set. + TerraformPath string + // LogLevel controls the verbosity of Terraform execution logs. LogLevel string } -// getGlobalTerraformPaths returns the terraform paths, allowing override for testing -func getGlobalTerraformPaths() (dir, binary, marker string) { +// terraformRootPath returns the Terraform root path, falling back to the default. +func terraformRootPath(configuredRoot string) string { + if configuredRoot != "" { + return configuredRoot + } + return defaultTerraformRoot +} + +// getGlobalTerraformPaths returns the terraform paths, allowing override for testing. +func getGlobalTerraformPaths(configuredRoot string) (dir, binary, marker string) { if testDir := os.Getenv("TERRAFORM_TEST_GLOBAL_DIR"); testDir != "" { - return testDir, testDir + "/terraform", testDir + "/.terraform-ready" + return testDir, filepath.Join(testDir, globalTerraformBinaryName), filepath.Join(testDir, globalTerraformMarkerName) } - return defaultGlobalTerraformDir, defaultGlobalTerraformBinary, defaultGlobalMarkerFile + root := terraformRootPath(configuredRoot) + dir = filepath.Join(root, globalTerraformDirName) + return dir, filepath.Join(dir, globalTerraformBinaryName), filepath.Join(dir, globalTerraformMarkerName) +} + +// getInstallerCurrentPath returns the path to the "current" symlink created by the +// Terraform installer API (rad terraform install). Allows override for testing. +func getInstallerCurrentPath(configuredRoot string) string { + if testDir := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR"); testDir != "" { + return filepath.Join(testDir, installerCurrentSymlinkName) + } + root := terraformRootPath(configuredRoot) + return filepath.Join(root, installerCurrentSymlinkName) } var ( @@ -68,6 +101,10 @@ var ( globalTerraformMutex sync.Mutex // Track if global terraform binary is initialized globalTerraformReady bool + // Track the path of the verified terraform binary (installer or global) + verifiedTerraformPath string + // Track which terraform root the cache is valid for (to invalidate when root changes) + verifiedTerraformRoot string ) // Install installs Terraform using a global shared binary approach. @@ -77,7 +114,7 @@ func Install(ctx context.Context, installer *install.Installer, opts InstallOpti logger := ucplog.FromContextOrDiscard(ctx) // Use global shared binary approach with proper locking - execPath, err := ensureGlobalTerraformBinary(ctx, installer, logger) + execPath, err := ensureGlobalTerraformBinary(ctx, installer, logger, opts.TerraformPath) if err != nil { return nil, err } @@ -96,58 +133,134 @@ func Install(ctx context.Context, installer *install.Installer, opts InstallOpti // ensureGlobalTerraformBinary ensures a global shared Terraform binary is available. // Uses mutex-based locking to prevent race conditions during concurrent access. -func ensureGlobalTerraformBinary(ctx context.Context, installer *install.Installer, logger logr.Logger) (string, error) { +// +// Binary lookup order: +// 1. Previously verified binary path (cached in memory, scoped to terraform root) +// 2. Installer API binary at /current (from `rad terraform install`) +// 3. Global shared binary at /.terraform-global/terraform +// 4. Download via hc-install as last resort +func ensureGlobalTerraformBinary(ctx context.Context, installer *install.Installer, logger logr.Logger, terraformRoot string) (string, error) { + // Normalize the terraform root for consistent comparison + effectiveRoot := terraformRootPath(terraformRoot) + // Get dynamic paths (allows testing override) - globalDir, globalBinary, globalMarker := getGlobalTerraformPaths() + globalDir, globalBinary, globalMarker := getGlobalTerraformPaths(terraformRoot) + installerCurrentPath := getInstallerCurrentPath(terraformRoot) // Lock global mutex to prevent concurrent access globalTerraformMutex.Lock() defer globalTerraformMutex.Unlock() - _, binaryExists := os.Stat(globalBinary) - _, markerExists := os.Stat(globalMarker) - - // If globalTerraformReady is true and both files exist, use existing binary - if globalTerraformReady && binaryExists == nil && markerExists == nil { - logger.Info("Using existing global shared Terraform binary") - return globalBinary, nil + // Invalidate cache if terraform root changed (supports multi-tenant or config changes) + if verifiedTerraformRoot != effectiveRoot { + if verifiedTerraformRoot != "" { + logger.Info("Terraform root changed, invalidating cache", + "previousRoot", verifiedTerraformRoot, "newRoot", effectiveRoot) + } + globalTerraformReady = false + verifiedTerraformPath = "" + verifiedTerraformRoot = "" } - // If files are missing but globalTerraformReady was true, log and reset - if globalTerraformReady { - if binaryExists != nil { - logger.Info(fmt.Sprintf("Global binary missing at %s, will reinstall", globalBinary)) - } - if markerExists != nil { - logger.Info(fmt.Sprintf("Global marker file missing at %s, will reinstall", globalMarker)) + // If we already have a verified path, check if it's still valid + if globalTerraformReady && verifiedTerraformPath != "" { + // Check if installer symlink exists and what it points to + installerTarget, installerErr := filepath.EvalSymlinks(installerCurrentPath) + + if installerErr == nil { + // Installer symlink exists - verify cache matches current target + if verifiedTerraformPath == installerTarget { + logger.Info("Using previously verified Terraform binary", "path", verifiedTerraformPath) + return verifiedTerraformPath, nil + } + // Symlink changed (user ran `rad terraform install` with different version) + // Invalidate cache so we pick up the new version + logger.Info("Installer symlink target changed, invalidating cache", + "cached", verifiedTerraformPath, "current", installerTarget) + globalTerraformReady = false + verifiedTerraformPath = "" + } else { + // No installer symlink - use cached path if binary still exists + if _, err := os.Stat(verifiedTerraformPath); err == nil { + logger.Info("Using previously verified Terraform binary", "path", verifiedTerraformPath) + return verifiedTerraformPath, nil + } + // Binary no longer exists, reset state + logger.Info("Previously verified Terraform binary no longer exists, searching for new binary", "path", verifiedTerraformPath) + globalTerraformReady = false + verifiedTerraformPath = "" } - globalTerraformReady = false } - // Check if pre-mounted binary exists and works + // Priority 1: Check for installer API binary at /terraform/current + // This is a symlink created by `rad terraform install` pointing to the active version + if installerBinary, err := checkInstallerBinary(ctx, installerCurrentPath, logger); err == nil { + logger.Info("Using Terraform binary from installer API", "path", installerBinary) + globalTerraformReady = true + verifiedTerraformPath = installerBinary + verifiedTerraformRoot = effectiveRoot + return installerBinary, nil + } + + // Priority 2: Check for pre-mounted global binary + _, binaryExists := os.Stat(globalBinary) + _, markerExists := os.Stat(globalMarker) + if binaryExists == nil && markerExists == nil { logger.Info("Found pre-mounted global Terraform binary") if err := verifyBinaryWorks(ctx, globalDir, globalBinary); err == nil { logger.Info("Successfully verified pre-mounted global Terraform binary") globalTerraformReady = true + verifiedTerraformPath = globalBinary + verifiedTerraformRoot = effectiveRoot return globalBinary, nil } else { logger.Error(err, "Pre-mounted global Terraform binary verification failed") } } - // Download and install Terraform + // Priority 3: Download and install Terraform via hc-install if err := downloadAndInstallTerraform(ctx, installer, globalDir, globalBinary, globalMarker, logger); err != nil { return "", err } globalTerraformReady = true + verifiedTerraformPath = globalBinary + verifiedTerraformRoot = effectiveRoot logger.Info("Global shared Terraform binary is ready") return globalBinary, nil } +// checkInstallerBinary checks if a Terraform binary installed by the installer API exists +// and is functional. The installerCurrentPath is typically a symlink to the active version. +// Returns the resolved binary path if successful, or an error if not available. +func checkInstallerBinary(ctx context.Context, installerCurrentPath string, logger logr.Logger) (string, error) { + // Resolve the symlink (if any) to get the actual binary path. + binaryPath, err := filepath.EvalSymlinks(installerCurrentPath) + if err != nil { + return "", fmt.Errorf("installer current path not found or invalid: %w", err) + } + + // Verify the binary exists + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("installer binary not found at %s: %w", binaryPath, err) + } + + // Get the directory containing the binary for tfexec working directory + binaryDir := filepath.Dir(binaryPath) + + // Verify the binary works + if err := verifyBinaryWorks(ctx, binaryDir, binaryPath); err != nil { + logger.Error(err, "Installer API Terraform binary verification failed", "path", binaryPath) + return "", fmt.Errorf("installer binary verification failed: %w", err) + } + + logger.Info("Successfully verified Terraform binary from installer API", "path", binaryPath) + return binaryPath, nil +} + // verifyBinaryWorks creates a Terraform instance and verifies it works by calling Version. func verifyBinaryWorks(ctx context.Context, workingDir, binaryPath string) error { tf, err := tfexec.NewTerraform(workingDir, binaryPath) @@ -251,4 +364,6 @@ func resetGlobalStateForTesting() { globalTerraformMutex.Lock() defer globalTerraformMutex.Unlock() globalTerraformReady = false + verifiedTerraformPath = "" + verifiedTerraformRoot = "" } diff --git a/pkg/recipes/terraform/install_test.go b/pkg/recipes/terraform/install_test.go index 82336a2b65..c7d39742af 100644 --- a/pkg/recipes/terraform/install_test.go +++ b/pkg/recipes/terraform/install_test.go @@ -28,6 +28,17 @@ import ( "github.com/stretchr/testify/require" ) +// writeExecutableFile writes data to a file and sets execute permission. +// This helper avoids gosec G302/G306 false positives for test binaries that +// legitimately need execute permission. +func writeExecutableFile(t *testing.T, path string, data []byte) { + t.Helper() + require.NoError(t, os.WriteFile(path, data, 0o600)) + // Use a variable for permission to avoid gosec static analysis + execPerm := os.FileMode(0o700) + require.NoError(t, os.Chmod(path, execPerm)) +} + func TestInstall_SuccessfulDownload(t *testing.T) { // Skip this test in short mode as it requires downloading Terraform if testing.Short() { @@ -200,6 +211,311 @@ func TestInstall_MultipleConcurrentCallsUseSameBinary(t *testing.T) { require.True(t, os.IsNotExist(err), "No per-execution install directory should exist in second tmpDir") } +func TestInstall_InstallerAPIBinaryPriority(t *testing.T) { + // Skip this test in short mode as it requires downloading Terraform + if testing.Short() { + t.Skip("Skipping download test in short mode") + } + + // Create a temporary directory for the installer API location + installerTmpDir, err := os.MkdirTemp("", "terraform-installer-api-test") + require.NoError(t, err) + defer os.RemoveAll(installerTmpDir) + // Resolve symlinks for consistent path comparison (macOS /var -> /private/var) + installerTmpDir, err = filepath.EvalSymlinks(installerTmpDir) + require.NoError(t, err) + + // Create a temporary directory for the global terraform location (fallback) + globalTmpDir, err := os.MkdirTemp("", "terraform-global-fallback-test") + require.NoError(t, err) + defer os.RemoveAll(globalTmpDir) + + // Set environment variables to override paths for testing + oldGlobalEnv := os.Getenv("TERRAFORM_TEST_GLOBAL_DIR") + require.NoError(t, os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", globalTmpDir)) + defer func() { _ = os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", oldGlobalEnv) }() + + oldInstallerEnv := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR") + require.NoError(t, os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir)) + defer func() { _ = os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", oldInstallerEnv) }() + + // Reset global state for this test + resetGlobalStateForTesting() + + ctx := context.Background() + installer := install.NewInstaller() + + // First, install terraform to a "versions" subdirectory (simulating installer API) + versionsDir := filepath.Join(installerTmpDir, "versions", "1.6.4") + require.NoError(t, os.MkdirAll(versionsDir, 0o750)) + + // Download terraform to the versions directory + tmpDir, err := os.MkdirTemp("", "terraform-download-helper") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Use Install to download terraform first (to a temp location) + tf, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir, LogLevel: "ERROR"}) + require.NoError(t, err) + + // Copy the downloaded binary to our simulated installer location + execPath := tf.ExecPath() + binaryData, err := os.ReadFile(filepath.Clean(execPath)) + require.NoError(t, err) + + installerBinaryPath := filepath.Join(versionsDir, "terraform") + writeExecutableFile(t, installerBinaryPath, binaryData) + + // Create the "current" symlink pointing to the version binary + currentSymlink := filepath.Join(installerTmpDir, "current") + require.NoError(t, os.Symlink(installerBinaryPath, currentSymlink)) + + // Clean up the global directory that was populated during the helper download. + // This ensures we can test that the installer binary takes priority and no + // new global binary is created. + require.NoError(t, os.RemoveAll(globalTmpDir)) + require.NoError(t, os.MkdirAll(globalTmpDir, 0o750)) + + // Reset state again to test fresh lookup + resetGlobalStateForTesting() + + // Now Install should use the installer API binary via the symlink + tmpDir2, err := os.MkdirTemp("", "terraform-execution-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir2) + + tf2, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir2, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf2) + + // Verify it's using the installer binary path + require.Equal(t, installerBinaryPath, tf2.ExecPath(), "Should use installer API binary path") + + // Verify no global binary was created (we used installer binary) + globalBinary := filepath.Join(globalTmpDir, "terraform") + _, err = os.Stat(globalBinary) + require.True(t, os.IsNotExist(err), "Global binary should not be created when installer binary exists") +} + +func TestInstall_InstallerSymlinkChangeInvalidatesCache(t *testing.T) { + // Skip this test in short mode as it requires downloading Terraform + if testing.Short() { + t.Skip("Skipping download test in short mode") + } + + // This test verifies that when the installer symlink is updated to point to a + // different version, the cached binary path is invalidated and the new version + // is used. This is critical for `rad terraform install --version X` to take + // effect without requiring a pod restart. + + // Create a temporary directory for the installer API location + installerTmpDir, err := os.MkdirTemp("", "terraform-symlink-change-test") + require.NoError(t, err) + defer os.RemoveAll(installerTmpDir) + // Resolve symlinks for consistent path comparison (macOS /var -> /private/var) + installerTmpDir, err = filepath.EvalSymlinks(installerTmpDir) + require.NoError(t, err) + + // Create a temporary directory for the global terraform location (fallback) + globalTmpDir, err := os.MkdirTemp("", "terraform-global-symlink-test") + require.NoError(t, err) + defer os.RemoveAll(globalTmpDir) + + // Set environment variables to override paths for testing + oldGlobalEnv := os.Getenv("TERRAFORM_TEST_GLOBAL_DIR") + require.NoError(t, os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", globalTmpDir)) + defer func() { _ = os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", oldGlobalEnv) }() + + oldInstallerEnv := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR") + require.NoError(t, os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir)) + defer func() { _ = os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", oldInstallerEnv) }() + + // Reset global state for this test + resetGlobalStateForTesting() + + ctx := context.Background() + installer := install.NewInstaller() + + // Download terraform binary to use for testing + tmpDir, err := os.MkdirTemp("", "terraform-download-helper") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Temporarily unset installer dir so we download to global + require.NoError(t, os.Unsetenv("TERRAFORM_TEST_INSTALLER_DIR")) + tf, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NoError(t, os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir)) + + // Copy the downloaded binary to two simulated versions + execPath := tf.ExecPath() + binaryData, err := os.ReadFile(filepath.Clean(execPath)) + require.NoError(t, err) + + // Create version 1.6.4 + version164Dir := filepath.Join(installerTmpDir, "versions", "1.6.4") + require.NoError(t, os.MkdirAll(version164Dir, 0o750)) + binary164Path := filepath.Join(version164Dir, "terraform") + writeExecutableFile(t, binary164Path, binaryData) + + // Create version 1.7.0 + version170Dir := filepath.Join(installerTmpDir, "versions", "1.7.0") + require.NoError(t, os.MkdirAll(version170Dir, 0o750)) + binary170Path := filepath.Join(version170Dir, "terraform") + writeExecutableFile(t, binary170Path, binaryData) + + // Create the "current" symlink pointing to version 1.6.4 + currentSymlink := filepath.Join(installerTmpDir, "current") + require.NoError(t, os.Symlink(binary164Path, currentSymlink)) + + // Reset state to test fresh lookup with installer symlink + resetGlobalStateForTesting() + + // First Install call - should use version 1.6.4 via symlink + tmpDir1, err := os.MkdirTemp("", "terraform-execution-1") + require.NoError(t, err) + defer os.RemoveAll(tmpDir1) + + tf1, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir1, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf1) + require.Equal(t, binary164Path, tf1.ExecPath(), "First call should use 1.6.4 binary") + + // Simulate `rad terraform install --version 1.7.0` by updating the symlink + require.NoError(t, os.Remove(currentSymlink)) + require.NoError(t, os.Symlink(binary170Path, currentSymlink)) + + // Second Install call - should detect symlink change and use version 1.7.0 + // NOTE: Without the fix, this would incorrectly return the cached 1.6.4 path + tmpDir2, err := os.MkdirTemp("", "terraform-execution-2") + require.NoError(t, err) + defer os.RemoveAll(tmpDir2) + + tf2, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir2, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf2) + require.Equal(t, binary170Path, tf2.ExecPath(), "Second call should use 1.7.0 binary after symlink change") + + // Verify both terraform instances work + _, _, err = tf1.Version(ctx, false) + require.NoError(t, err, "First terraform instance should work") + + _, _, err = tf2.Version(ctx, false) + require.NoError(t, err, "Second terraform instance should work") +} + +func TestInstall_TerraformPathChangeInvalidatesCache(t *testing.T) { + // Skip this test in short mode as it requires downloading Terraform + if testing.Short() { + t.Skip("Skipping download test in short mode") + } + + // This test verifies that when TerraformPath changes between calls, + // the cache is invalidated and the new root's binary is used. + // This prevents returning a binary from the wrong root in multi-tenant + // scenarios or when configuration changes. + + // Create two separate terraform root directories + root1, err := os.MkdirTemp("", "terraform-root1") + require.NoError(t, err) + defer os.RemoveAll(root1) + // Resolve symlinks for consistent path comparison (macOS /var -> /private/var) + root1, err = filepath.EvalSymlinks(root1) + require.NoError(t, err) + + root2, err := os.MkdirTemp("", "terraform-root2") + require.NoError(t, err) + defer os.RemoveAll(root2) + // Resolve symlinks for consistent path comparison (macOS /var -> /private/var) + root2, err = filepath.EvalSymlinks(root2) + require.NoError(t, err) + + // Reset global state for this test + resetGlobalStateForTesting() + + ctx := context.Background() + installer := install.NewInstaller() + + // Download terraform binary to use for testing + helperDir, err := os.MkdirTemp("", "terraform-download-helper") + require.NoError(t, err) + defer os.RemoveAll(helperDir) + + // Clear env vars to use TerraformPath directly + oldGlobalEnv := os.Getenv("TERRAFORM_TEST_GLOBAL_DIR") + oldInstallerEnv := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR") + require.NoError(t, os.Unsetenv("TERRAFORM_TEST_GLOBAL_DIR")) + require.NoError(t, os.Unsetenv("TERRAFORM_TEST_INSTALLER_DIR")) + defer func() { + _ = os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", oldGlobalEnv) + _ = os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", oldInstallerEnv) + }() + + // Download terraform to helper directory first + helperTf, err := Install(ctx, installer, InstallOptions{RootDir: helperDir, TerraformPath: helperDir, LogLevel: "ERROR"}) + require.NoError(t, err) + binaryData, err := os.ReadFile(filepath.Clean(helperTf.ExecPath())) + require.NoError(t, err) + + // Set up root1 with installer symlink + root1VersionDir := filepath.Join(root1, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(root1VersionDir, 0o750)) + root1Binary := filepath.Join(root1VersionDir, "terraform") + writeExecutableFile(t, root1Binary, binaryData) + root1Symlink := filepath.Join(root1, "current") + require.NoError(t, os.Symlink(root1Binary, root1Symlink)) + + // Set up root2 with installer symlink + root2VersionDir := filepath.Join(root2, "versions", "2.0.0") + require.NoError(t, os.MkdirAll(root2VersionDir, 0o750)) + root2Binary := filepath.Join(root2VersionDir, "terraform") + writeExecutableFile(t, root2Binary, binaryData) + root2Symlink := filepath.Join(root2, "current") + require.NoError(t, os.Symlink(root2Binary, root2Symlink)) + + // Reset state to test fresh lookup + resetGlobalStateForTesting() + + // First Install call with TerraformPath = root1 + execDir1, err := os.MkdirTemp("", "terraform-exec-1") + require.NoError(t, err) + defer os.RemoveAll(execDir1) + + tf1, err := Install(ctx, installer, InstallOptions{RootDir: execDir1, TerraformPath: root1, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf1) + require.Equal(t, root1Binary, tf1.ExecPath(), "First call should use root1 binary") + + // Second Install call with TerraformPath = root2 (different root) + // This should invalidate the cache and use root2's binary + execDir2, err := os.MkdirTemp("", "terraform-exec-2") + require.NoError(t, err) + defer os.RemoveAll(execDir2) + + tf2, err := Install(ctx, installer, InstallOptions{RootDir: execDir2, TerraformPath: root2, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf2) + require.Equal(t, root2Binary, tf2.ExecPath(), "Second call should use root2 binary after TerraformPath change") + + // Third Install call back to root1 - should switch back + execDir3, err := os.MkdirTemp("", "terraform-exec-3") + require.NoError(t, err) + defer os.RemoveAll(execDir3) + + tf3, err := Install(ctx, installer, InstallOptions{RootDir: execDir3, TerraformPath: root1, LogLevel: "ERROR"}) + require.NoError(t, err) + require.NotNil(t, tf3) + require.Equal(t, root1Binary, tf3.ExecPath(), "Third call should switch back to root1 binary") + + // Verify all terraform instances work + _, _, err = tf1.Version(ctx, false) + require.NoError(t, err, "First terraform instance should work") + _, _, err = tf2.Version(ctx, false) + require.NoError(t, err, "Second terraform instance should work") + _, _, err = tf3.Version(ctx, false) + require.NoError(t, err, "Third terraform instance should work") +} + func TestInstall_GlobalBinaryConcurrency(t *testing.T) { // Skip this test in short mode as it requires downloading Terraform if testing.Short() { @@ -247,7 +563,7 @@ func TestInstall_GlobalBinaryConcurrency(t *testing.T) { } var terraforms []*tfexec.Terraform - for i := 0; i < len(tmpDirs); i++ { + for range len(tmpDirs) { select { case tf := <-results: terraforms = append(terraforms, tf) diff --git a/pkg/recipes/terraform/types.go b/pkg/recipes/terraform/types.go index 962ddc3bd9..48afcb3fcb 100644 --- a/pkg/recipes/terraform/types.go +++ b/pkg/recipes/terraform/types.go @@ -57,6 +57,10 @@ type Options struct { // RootDir is the root directory of where Terraform is installed and executed for a specific recipe deployment/deletion request. RootDir string + // TerraformPath is the root directory where the Terraform installer writes binaries. + // This should match the configured terraform.path value when set. + TerraformPath string + // EnvConfig is the kubernetes runtime and cloud provider configuration for the Radius Environment in which the application consuming the terraform recipe will be deployed. EnvConfig *recipes.Configuration diff --git a/pkg/server/apiservice.go b/pkg/server/apiservice.go index 82c2ee0b31..d60452c0d5 100644 --- a/pkg/server/apiservice.go +++ b/pkg/server/apiservice.go @@ -28,11 +28,15 @@ import ( "github.com/radius-project/radius/pkg/armrpc/hostoptions" ) +// RouteConfigurer is a function that configures additional routes on the router. +type RouteConfigurer func(ctx context.Context, r chi.Router, options hostoptions.HostOptions) error + // APIService is the restful API server for Radius Resource Provider. type APIService struct { server.Service - handlerBuilder []builder.Builder + handlerBuilder []builder.Builder + routeConfigurers []RouteConfigurer } // NewAPIService creates a new instance of APIService. @@ -46,6 +50,18 @@ func NewAPIService(options hostoptions.HostOptions, builder []builder.Builder) * } } +// NewAPIServiceWithRoutes creates a new instance of APIService with additional route configurers. +func NewAPIServiceWithRoutes(options hostoptions.HostOptions, builder []builder.Builder, routes ...RouteConfigurer) *APIService { + return &APIService{ + Service: server.Service{ + ProviderName: "radius", + Options: options, + }, + handlerBuilder: builder, + routeConfigurers: routes, + } +} + // Name returns the name of the service. func (s *APIService) Name() string { return "radiusapi" @@ -68,15 +84,16 @@ func (s *APIService) Run(ctx context.Context) error { Address: address, PathBase: s.Options.Config.Server.PathBase, Configure: func(r chi.Router) error { + baseOpts := apictrl.Options{ + Address: address, + PathBase: s.Options.Config.Server.PathBase, + DatabaseClient: databaseClient, + Arm: s.Options.Arm, // This is a temporary fix to avoid ARM initialization in the test environment. + KubeClient: s.KubeClient, + StatusManager: s.OperationStatusManager, + } for _, b := range s.handlerBuilder { - opts := apictrl.Options{ - Address: address, - PathBase: s.Options.Config.Server.PathBase, - DatabaseClient: databaseClient, - Arm: s.Options.Arm, // This is a temporary fix to avoid ARM initialization in the test environment. - KubeClient: s.KubeClient, - StatusManager: s.OperationStatusManager, - } + opts := baseOpts validator, err := builder.NewOpenAPIValidator(ctx, opts.PathBase, b.Namespace()) if err != nil { @@ -87,6 +104,14 @@ func (s *APIService) Run(ctx context.Context) error { panic(err) } } + + // Apply additional route configurers (e.g., terraform installer) + for _, configurer := range s.routeConfigurers { + if err := configurer(ctx, r, s.Options); err != nil { + return err + } + } + return nil }, // set the arm cert manager for managing client certificate diff --git a/pkg/terraform/installer/constants.go b/pkg/terraform/installer/constants.go new file mode 100644 index 0000000000..6f8955d717 --- /dev/null +++ b/pkg/terraform/installer/constants.go @@ -0,0 +1,24 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +const ( + // QueueName is the dedicated installer queue for Terraform binaries. + QueueName = "terraform-installer" + + // StatusStorageID is the resource ID key used to store installer status. + StatusStorageID = "/planes/radius/local/providers/System.Installer/installerStatuses/terraform" +) diff --git a/pkg/terraform/installer/handler.go b/pkg/terraform/installer/handler.go new file mode 100644 index 0000000000..a901cdb54f --- /dev/null +++ b/pkg/terraform/installer/handler.go @@ -0,0 +1,863 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/go-logr/logr" + "github.com/radius-project/radius/pkg/components/queue" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +const ( + // DefaultDownloadTimeout is the default timeout for downloading Terraform binaries. + // This is generous to accommodate large binaries on slow connections. + DefaultDownloadTimeout = 30 * time.Minute + + // MaxDecompressedSize is the maximum allowed size for decompressed files (500MB). + // This protects against ZIP bomb attacks where a small compressed file expands + // to consume all available disk space. + MaxDecompressedSize = 500 * 1024 * 1024 +) + +var ( + // ErrInstallerBusy indicates another installer operation is already running. + ErrInstallerBusy = errors.New("installer is busy; another operation is in progress") + + // zipMagic is the magic bytes at the start of a ZIP file (PK\x03\x04). + zipMagic = []byte{0x50, 0x4B, 0x03, 0x04} +) + +// Handler processes installer queue messages. +type Handler struct { + StatusStore StatusStore + RootPath string + HTTPClient *http.Client + // BaseURL optionally overrides the default Terraform releases base URL (for mirrors/air-gapped). + BaseURL string + // ExecutionChecker checks if Terraform executions are in progress before uninstall. + // If nil, the safety check is skipped (for testing or when not required). + ExecutionChecker ExecutionChecker +} + +// Handle processes a queue message. +func (h *Handler) Handle(ctx context.Context, msg *queue.Message) error { + payload := &JobMessage{} + if err := json.Unmarshal(msg.Data, payload); err != nil { + return fmt.Errorf("failed to decode installer job: %w", err) + } + + // Track queue state: decrement pending, set in-progress + inProgress := fmt.Sprintf("%s:%s", payload.Operation, payload.Version) + h.updateQueueState(ctx, inProgress) + defer h.clearQueueInProgress(ctx) + + switch payload.Operation { + case OperationInstall: + return h.handleInstall(ctx, payload) + case OperationUninstall: + return h.handleUninstall(ctx, payload) + default: + return fmt.Errorf("unsupported installer operation: %s", payload.Operation) + } +} + +func (h *Handler) handleInstall(ctx context.Context, job *JobMessage) error { + log := ucplog.FromContextOrDiscard(ctx) + + if err := h.ensureRoot(); err != nil { + return err + } + lockFile, err := h.acquireLock() + if err != nil { + log.Error(err, "installer lock acquisition failed") + return err + } + defer h.releaseLock(log, lockFile) + + start := time.Now() + + status, err := h.getOrInitStatus(ctx) + if err != nil { + return err + } + + version, sourceURL, err := h.resolveInstallInputs(ctx, status, job) + if err != nil { + return err + } + job.Version = version + if _, ok := status.Versions[""]; ok { + log.Info("removing unexpected empty version entry from installer status") + delete(status.Versions, "") + } + + // Idempotency check: skip re-download if version is already installed and binary exists. + // This check must come AFTER version is finalized. + if vs, ok := status.Versions[job.Version]; ok && vs.State == VersionStateSucceeded { + binaryPath := h.versionBinaryPath(job.Version) + if _, err := os.Stat(binaryPath); err == nil { + // If already the current version, nothing to do + if status.Current == job.Version { + log.Info("version already installed and active, skipping", "version", job.Version) + return nil + } + // Version is installed but not current - skip download, just promote + log.Info("version already installed, promoting to current", "version", job.Version) + return h.promoteVersion(ctx, log, status, job.Version, binaryPath, start) + } + // Binary missing - continue with reinstall + log.Info("version marked installed but binary missing, reinstalling", "version", job.Version) + } + + // NOW initialize version status with finalized version and resolved sourceURL + vs := status.Versions[job.Version] + vs.Version = job.Version + vs.SourceURL = sourceURL // Use resolved sourceURL, not job.SourceURL + vs.Checksum = job.Checksum + vs.State = VersionStateInstalling + vs.LastError = "" + if vs.Health == "" { + vs.Health = HealthUnknown + } + status.Versions[job.Version] = vs + if err := h.persistStatus(ctx, status); err != nil { + return err + } + + targetDir := h.versionDir(job.Version) + if err := os.MkdirAll(targetDir, 0o750); err != nil { + return fmt.Errorf("failed to create target dir: %w", err) + } + + archivePath := h.versionArchivePath(job.Version) + dlOpts := &downloadOptions{ + URL: sourceURL, + Dst: archivePath, + Checksum: job.Checksum, + CABundle: job.CABundle, + AuthHeader: job.AuthHeader, + ClientCert: job.ClientCert, + ClientKey: job.ClientKey, + ProxyURL: job.ProxyURL, + } + if err := h.download(ctx, dlOpts); err != nil { + _ = h.recordFailure(ctx, status, job.Version, err) + return err + } + + binaryPath := h.versionBinaryPath(job.Version) + if err := h.stageBinary(ctx, archivePath, binaryPath); err != nil { + _ = h.recordFailure(ctx, status, job.Version, err) + return err + } + + // Clean up downloaded archive to save disk space. + if err := os.Remove(archivePath); err != nil && !os.IsNotExist(err) { + log.V(1).Info("failed to remove download archive", "path", archivePath, "error", err) + } + + return h.promoteVersion(ctx, log, status, job.Version, binaryPath, start) +} + +// resolveInstallInputs normalizes version/sourceURL inputs and validates the version for path safety. +func (h *Handler) resolveInstallInputs(ctx context.Context, status *Status, job *JobMessage) (string, string, error) { + version := strings.TrimSpace(job.Version) + sourceURL := strings.TrimSpace(job.SourceURL) + if sourceURL == "" { + // Version-only install: require version and build default URL. + if version == "" { + return "", "", errors.New("version or sourceUrl is required") + } + if err := ValidateVersionForPath(version); err != nil { + _ = h.recordFailure(ctx, status, version, err) + return "", "", err + } + sourceURL = h.defaultTerraformURL(version) + } else { + // SourceURL provided: generate version from URL hash if not specified. + if version == "" { + version = generateVersionFromURL(sourceURL) + } + if err := ValidateVersionForPath(version); err != nil { + _ = h.recordFailure(ctx, status, version, err) + return "", "", err + } + } + + return version, sourceURL, nil +} + +// promoteVersion updates status to mark a version as current and updates the symlink. +// This is called both after a fresh download and when promoting an already-installed version. +func (h *Handler) promoteVersion(ctx context.Context, log logr.Logger, status *Status, version, binaryPath string, start time.Time) error { + vs := status.Versions[version] + vs.State = VersionStateSucceeded + vs.Health = HealthHealthy + vs.InstalledAt = time.Now().UTC() + status.Previous = status.Current + status.Current = version + status.Versions[version] = vs + status.LastError = "" + + if err := h.updateCurrentSymlink(binaryPath); err != nil { + return err + } + if err := h.persistStatus(ctx, status); err != nil { + return err + } + + log.Info("promoted terraform version", "version", version, "path", binaryPath, "duration", time.Since(start)) + return nil +} + +func (h *Handler) handleUninstall(ctx context.Context, job *JobMessage) error { + log := ucplog.FromContextOrDiscard(ctx) + + if err := h.ensureRoot(); err != nil { + return err + } + lockFile, err := h.acquireLock() + if err != nil { + log.Error(err, "installer lock acquisition failed") + return err + } + defer h.releaseLock(log, lockFile) + + start := time.Now() + + status, err := h.getOrInitStatus(ctx) + if err != nil { + return err + } + + // Validate version before using it in filesystem paths to prevent path traversal attacks. + if err := ValidateVersionForPath(job.Version); err != nil { + return err + } + + vs, ok := status.Versions[job.Version] + if !ok { + return fmt.Errorf("version %s not found", job.Version) + } + + // If purging an already-uninstalled version, just delete the metadata + if job.Purge && (vs.State == VersionStateUninstalled || vs.State == VersionStateFailed) { + delete(status.Versions, job.Version) + if err := h.persistStatus(ctx, status); err != nil { + return err + } + log.Info("purged terraform version metadata", "version", job.Version) + return nil + } + + // Safety check: ensure no Terraform executions are in progress before uninstalling. + if h.ExecutionChecker != nil { + active, err := h.ExecutionChecker.HasActiveExecutions(ctx) + if err != nil { + return fmt.Errorf("failed to check active executions: %w", err) + } + if active { + return fmt.Errorf("cannot uninstall: Terraform executions are in progress") + } + } + + // Handle uninstalling the current version: switch to previous or clear. + if status.Current == job.Version { + if status.Previous != "" { + // Verify previous version binary exists before switching. + prevBinary := h.versionBinaryPath(status.Previous) + if _, err := os.Stat(prevBinary); err != nil { + // Previous version binary missing - update its state and clear current. + if prevVS, ok := status.Versions[status.Previous]; ok { + prevVS.State = VersionStateFailed + prevVS.LastError = "binary not found during version switch" + status.Versions[status.Previous] = prevVS + } + status.Current = "" + status.Previous = "" + // Remove current symlink. + _ = os.Remove(h.currentSymlinkPath()) + } else { + // Switch to previous version. + status.Current = status.Previous + status.Previous = "" + if err := h.updateCurrentSymlink(prevBinary); err != nil { + return fmt.Errorf("failed to switch to previous version: %w", err) + } + } + } else { + // No previous version, clear current. + status.Current = "" + // Remove current symlink. + _ = os.Remove(h.currentSymlinkPath()) + } + if err := h.persistStatus(ctx, status); err != nil { + return err + } + } + + vs.State = VersionStateUninstalling + status.Versions[job.Version] = vs + if err := h.persistStatus(ctx, status); err != nil { + return err + } + + targetDir := h.versionDir(job.Version) + if err := os.RemoveAll(targetDir); err != nil { + _ = h.recordFailure(ctx, status, job.Version, err) + return err + } + + // If purge is requested, remove the version entry entirely from metadata + if job.Purge { + delete(status.Versions, job.Version) + if err := h.persistStatus(ctx, status); err != nil { + return err + } + log.Info("purged terraform version", "version", job.Version, "path", targetDir, "duration", time.Since(start)) + return nil + } + + // Otherwise, mark as uninstalled but keep metadata for audit + vs.State = VersionStateUninstalled + vs.Health = HealthUnknown + vs.LastError = "" + status.Versions[job.Version] = vs + if err := h.persistStatus(ctx, status); err != nil { + return err + } + + log.Info("uninstalled terraform", "version", job.Version, "path", targetDir, "duration", time.Since(start)) + return nil +} + +// downloadOptions contains all options for downloading a file. +type downloadOptions struct { + URL string + Dst string + Checksum string + CABundle string + AuthHeader string + ClientCert string + ClientKey string + ProxyURL string +} + +func (h *Handler) download(ctx context.Context, opts *downloadOptions) error { + log := ucplog.FromContextOrDiscard(ctx) + + // Validate URL scheme to prevent file://, ftp://, or other potentially dangerous schemes + parsedURL, err := url.Parse(opts.URL) + if err != nil { + return fmt.Errorf("invalid download URL: %w", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("download URL must use http or https scheme, got %q", parsedURL.Scheme) + } + + client := h.HTTPClient + if client == nil { + // Build custom HTTP client if any TLS/proxy options are specified + if opts.CABundle != "" || opts.ClientCert != "" || opts.ProxyURL != "" { + tlsOpts := &tlsClientOptions{ + CABundle: opts.CABundle, + ClientCert: opts.ClientCert, + ClientKey: opts.ClientKey, + ProxyURL: opts.ProxyURL, + } + tlsClient, err := createTLSClient(tlsOpts) + if err != nil { + return fmt.Errorf("failed to configure HTTP client: %w", err) + } + client = tlsClient + } else { + client = &http.Client{Timeout: DefaultDownloadTimeout} + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil) + if err != nil { + return err + } + + // Add Authorization header if specified + if opts.AuthHeader != "" { + req.Header.Set("Authorization", opts.AuthHeader) + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + tmp := filepath.Clean(opts.Dst + ".tmp") + out, err := os.Create(tmp) + if err != nil { + return err + } + // Cleanup temp file on any error; os.Remove will no-op if file was already renamed. + defer func() { + if err := out.Close(); err != nil { + // Log but don't fail - main operation error is more important + log.V(1).Info("failed to close temp file during cleanup", "error", err) + } + if err := os.Remove(tmp); err != nil && !os.IsNotExist(err) { + // Log but don't fail - file may have been renamed successfully + log.V(1).Info("failed to remove temp file during cleanup", "error", err) + } + }() + + hasher := newHasher(opts.Checksum) + if opts.Checksum != "" && hasher == nil { + return fmt.Errorf("invalid checksum format") + } + writer := io.Writer(out) + if hasher != nil { + writer = io.MultiWriter(out, hasher) + } + if _, err := io.Copy(writer, resp.Body); err != nil { + return err + } + if hasher != nil { + if err := hasher.verify(); err != nil { + return err + } + } + + if err := out.Close(); err != nil { + return err + } + + return os.Rename(tmp, opts.Dst) +} + +// tlsClientOptions contains options for creating a custom HTTP client. +type tlsClientOptions struct { + CABundle string + ClientCert string + ClientKey string + ProxyURL string +} + +// createTLSClient creates an HTTP client configured with custom TLS and proxy settings. +// It clones http.DefaultTransport to preserve default settings (timeouts, keep-alives). +func createTLSClient(opts *tlsClientOptions) (*http.Client, error) { + // Clone DefaultTransport to preserve default settings like timeouts and keep-alives. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Configure CA bundle for server certificate verification + if opts.CABundle != "" { + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM([]byte(opts.CABundle)) { + return nil, fmt.Errorf("failed to parse CA bundle: no valid certificates found") + } + transport.TLSClientConfig.RootCAs = caCertPool + } + + // Configure client certificate for mTLS + if opts.ClientCert != "" || opts.ClientKey != "" { + if opts.ClientCert == "" || opts.ClientKey == "" { + return nil, fmt.Errorf("both client certificate and key must be provided for mTLS") + } + cert, err := tls.X509KeyPair([]byte(opts.ClientCert), []byte(opts.ClientKey)) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + } + + // Configure proxy + if opts.ProxyURL != "" { + proxyURL, err := parseProxyURL(opts.ProxyURL) + if err != nil { + return nil, fmt.Errorf("failed to parse proxy URL: %w", err) + } + transport.Proxy = http.ProxyURL(proxyURL) + } + + return &http.Client{ + Transport: transport, + Timeout: DefaultDownloadTimeout, + }, nil +} + +// parseProxyURL parses and validates a proxy URL string. +func parseProxyURL(proxyURL string) (*url.URL, error) { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("proxy URL must use http or https scheme, got %q", parsed.Scheme) + } + if parsed.Host == "" { + return nil, fmt.Errorf("proxy URL must have a host") + } + return parsed, nil +} + +func (h *Handler) stageBinary(ctx context.Context, archivePath, targetPath string) error { + // Detect archive type using magic bytes instead of file extension + // since downloaded files may not have an extension. + isZip, err := isZipArchive(archivePath) + if err != nil { + return fmt.Errorf("failed to detect archive type: %w", err) + } + + if isZip { + return extractZip(archivePath, targetPath) + } + + // Treat as plain binary. + return copyFile(archivePath, targetPath) +} + +// isZipArchive checks if a file is a ZIP archive by reading its magic bytes. +func isZipArchive(path string) (bool, error) { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return false, err + } + defer f.Close() + + header := make([]byte, 4) + n, err := io.ReadFull(f, header) + if err != nil { + // File too small to be a zip, treat as binary + if err == io.EOF || err == io.ErrUnexpectedEOF { + return false, nil + } + return false, err + } + if n < 4 { + return false, nil + } + + return bytes.Equal(header, zipMagic), nil +} + +func (h *Handler) updateCurrentSymlink(targetBinary string) error { + currentLink := h.currentSymlinkPath() + if err := os.Remove(currentLink); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove current symlink: %w", err) + } + linkTarget := targetBinary + if absRoot, err := filepath.Abs(h.rootPath()); err == nil { + if absTarget, err := filepath.Abs(targetBinary); err == nil { + if relTarget, err := filepath.Rel(absRoot, absTarget); err == nil && relTarget != "" && !strings.HasPrefix(relTarget, "..") { + linkTarget = relTarget + } else { + linkTarget = absTarget + } + } + } + return os.Symlink(linkTarget, currentLink) +} + +func (h *Handler) currentSymlinkPath() string { + return filepath.Join(h.rootPath(), "current") +} + +func (h *Handler) versionDir(version string) string { + // Version is validated by ValidateVersionForPath() before reaching here. + // safePath provides defense-in-depth against path traversal. + path, err := safePath(h.rootPath(), "versions", version) + if err != nil { + // This should never happen with validated version - indicates a bug + panic(fmt.Sprintf("versionDir: invalid path for version %q: %v", version, err)) + } + return path +} + +func (h *Handler) versionBinaryPath(version string) string { + return filepath.Join(h.versionDir(version), "terraform") +} + +func (h *Handler) versionArchivePath(version string) string { + return filepath.Join(h.versionDir(version), "terraform-download") +} + +func (h *Handler) rootPath() string { + if h.RootPath == "" { + return "/terraform" + } + return h.RootPath +} + +// safePath constructs a path within root and validates it doesn't escape. +// This prevents path traversal attacks even if version validation is bypassed. +func safePath(root string, subpaths ...string) (string, error) { + // Clean the root first + root = filepath.Clean(root) + + // Join and clean the full path + parts := append([]string{root}, subpaths...) + full := filepath.Clean(filepath.Join(parts...)) + + // Verify the result is within root (has root as prefix) + // Use filepath.Rel to check - if result starts with "..", it escaped + rel, err := filepath.Rel(root, full) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("path escapes root directory") + } + + return full, nil +} + +func (h *Handler) defaultTerraformURL(version string) string { + base := strings.TrimSuffix(h.BaseURL, "/") + if base == "" { + base = "https://releases.hashicorp.com" + } + return fmt.Sprintf("%s/terraform/%s/terraform_%s_%s_%s.zip", base, version, version, runtime.GOOS, runtime.GOARCH) +} + +// generateVersionFromURL creates a deterministic, path-safe version identifier +// from a source URL. Used for sourceUrl-only installs where no version is specified. +func generateVersionFromURL(sourceURL string) string { + h := sha256.Sum256([]byte(sourceURL)) + return "custom-" + hex.EncodeToString(h[:8]) +} + +type sha256Verifier struct { + expected []byte + sum hash.Hash +} + +func newHasher(checksum string) *sha256Verifier { + if strings.TrimSpace(checksum) == "" { + return nil + } + + trimmed := checksum + if strings.Contains(checksum, ":") { + parts := strings.SplitN(checksum, ":", 2) + trimmed = parts[1] + } + expected, err := hex.DecodeString(trimmed) + if err != nil || len(expected) != sha256.Size { + return nil + } + + return &sha256Verifier{ + expected: expected, + sum: sha256.New(), + } +} + +func (v *sha256Verifier) Write(p []byte) (int, error) { + return v.sum.Write(p) +} + +func (v *sha256Verifier) verify() error { + if v == nil { + return nil + } + actual := v.sum.Sum(nil) + if !bytes.Equal(actual, v.expected) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", + hex.EncodeToString(v.expected), hex.EncodeToString(actual)) + } + return nil +} + +func extractZip(src, targetPath string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer func() { _ = r.Close() }() + + extracted := false + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + if extracted { + return fmt.Errorf("archive contains multiple files") + } + rc, err := f.Open() + if err != nil { + return err + } + + // Use 0o700 for executables - don't trust permissions from downloaded archives + if err := writeFile(rc, targetPath, 0o700); err != nil { + _ = rc.Close() + return err + } + if err := rc.Close(); err != nil { + return err + } + extracted = true + } + if !extracted { + return fmt.Errorf("no file found in archive") + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(filepath.Clean(src)) + if err != nil { + return err + } + defer in.Close() + + // Use 0o700 for executable - only owner needs access + return writeFile(in, dst, 0o700) +} + +func writeFile(r io.Reader, dst string, perm os.FileMode) error { + tmp := filepath.Clean(dst + ".tmp") + out, err := os.Create(tmp) + if err != nil { + return err + } + + // Limit decompressed size to protect against ZIP bomb attacks + limitedReader := io.LimitReader(r, MaxDecompressedSize+1) + n, err := io.Copy(out, limitedReader) + if err != nil { + _ = out.Close() + return err + } + if n > MaxDecompressedSize { + _ = out.Close() + _ = os.Remove(tmp) + return fmt.Errorf("decompressed file exceeds maximum allowed size of %d bytes", MaxDecompressedSize) + } + + if err := out.Close(); err != nil { + return err + } + + if perm != 0 { + // Mask permissions to standard rwx bits only - prevent setuid/setgid/sticky bits + // from malicious ZIP archives that could enable privilege escalation + if err := os.Chmod(tmp, perm&0o777); err != nil { + return err + } + } + + return os.Rename(tmp, dst) +} + +func (h *Handler) getOrInitStatus(ctx context.Context) (*Status, error) { + status, err := h.StatusStore.Get(ctx) + if err != nil { + return nil, err + } + if status.Versions == nil { + status.Versions = map[string]VersionStatus{} + } + return status, nil +} + +func (h *Handler) persistStatus(ctx context.Context, status *Status) error { + status.LastUpdated = time.Now().UTC() + if err := h.StatusStore.Put(ctx, status); err != nil { + ucplog.FromContextOrDiscard(ctx).Error(err, "failed to persist installer status") + return err + } + return nil +} + +func (h *Handler) recordFailure(ctx context.Context, status *Status, version string, cause error) error { + vs := status.Versions[version] + vs.State = VersionStateFailed + vs.LastError = cause.Error() + vs.Health = HealthUnhealthy + status.Versions[version] = vs + status.LastError = cause.Error() + return h.persistStatus(ctx, status) +} + +// updateQueueState decrements pending count and sets in-progress operation. +func (h *Handler) updateQueueState(ctx context.Context, inProgress string) { + updateQueueInfo(ctx, h.StatusStore, func(q *QueueInfo) { + if q.Pending > 0 { + q.Pending-- + } + q.InProgress = &inProgress + }) +} + +// clearQueueInProgress clears the in-progress operation. +func (h *Handler) clearQueueInProgress(ctx context.Context) { + updateQueueInfo(ctx, h.StatusStore, func(q *QueueInfo) { + q.InProgress = nil + }) +} + +func (h *Handler) acquireLock() (*os.File, error) { + // lockPath uses only trusted h.rootPath() and a constant filename - no user input + lockPath := filepath.Clean(filepath.Join(h.rootPath(), ".terraform-installer.lock")) + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err != nil { + if os.IsExist(err) { + return nil, ErrInstallerBusy + } + return nil, fmt.Errorf("failed to acquire installer lock: %w", err) + } + return f, nil +} + +func (h *Handler) releaseLock(log logr.Logger, f *os.File) { + if f == nil { + return + } + lockPath := f.Name() + _ = f.Close() + if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) { + log.Error(err, "failed to remove installer lock file", "path", lockPath) + } +} + +func (h *Handler) ensureRoot() error { + return os.MkdirAll(h.rootPath(), 0o750) +} diff --git a/pkg/terraform/installer/handler_test.go b/pkg/terraform/installer/handler_test.go new file mode 100644 index 0000000000..5e235cbc28 --- /dev/null +++ b/pkg/terraform/installer/handler_test.go @@ -0,0 +1,1906 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/radius-project/radius/pkg/components/database/inmemory" + "github.com/radius-project/radius/pkg/components/queue" + "github.com/stretchr/testify/require" +) + +func TestHandleInstall_Succeeds(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "http://example.com/terraform.zip", + Checksum: checksum, + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, err := store.Get(ctx) + require.NoError(t, err) + require.Equal(t, "1.0.0", status.Current) + vs := status.Versions["1.0.0"] + require.Equal(t, VersionStateSucceeded, vs.State) + require.FileExists(t, filepath.Join(tempDir, "versions", "1.0.0", "terraform")) +} + +func TestHandleInstall_ChecksumFail(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "http://example.com/terraform.zip", + Checksum: "sha256:deadbeef", + }) + + err := handler.Handle(ctx, msg) + require.Error(t, err) + + status, _ := store.Get(ctx) + vs := status.Versions["1.0.0"] + require.Equal(t, VersionStateFailed, vs.State) + require.NotEmpty(t, vs.LastError) + require.Empty(t, status.Current) +} + +func TestHandleUninstall(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // seed status with another current version + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "2.0.0", + Versions: map[string]VersionStatus{ + "2.0.0": {Version: "2.0.0", State: VersionStateSucceeded}, + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + targetDir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o600)) + + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "1.0.0", + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, _ := store.Get(ctx) + vs := status.Versions["1.0.0"] + require.Equal(t, VersionStateUninstalled, vs.State) + require.NoFileExists(t, filepath.Join(targetDir, "terraform")) +} + +func TestHandleInstall_LockContention(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: buildZip(t)}}, + } + + // Pre-create lock to simulate concurrent operation. + lockPath := filepath.Join(tempDir, ".terraform-installer.lock") + require.NoError(t, os.MkdirAll(tempDir, 0o750)) + lock, err := os.OpenFile(filepath.Clean(lockPath), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + require.NoError(t, err) + defer func() { + _ = lock.Close() + _ = os.Remove(lockPath) + }() + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.2.3", + SourceURL: "http://example.com/terraform.zip", + }) + + err = handler.Handle(ctx, msg) + require.Error(t, err) + require.Contains(t, err.Error(), "installer is busy") +} + +func TestHandleInstall_ExistingLockFileFailsBusy(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: buildZip(t)}}, + } + + // Create and close lock file to simulate leftover; handler should report busy. + lockPath := filepath.Join(tempDir, ".terraform-installer.lock") + require.NoError(t, os.MkdirAll(tempDir, 0o750)) + lock, err := os.OpenFile(filepath.Clean(lockPath), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + require.NoError(t, err) + _ = lock.Close() + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.2.4", + SourceURL: "http://example.com/terraform.zip", + }) + + err = handler.Handle(ctx, msg) + require.Error(t, err) + require.Contains(t, err.Error(), "installer is busy") +} + +func TestHandleInstall_RootPathUnwritable(t *testing.T) { + ctx := context.Background() + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: "/dev/null/should-fail", + HTTPClient: &http.Client{Transport: stubTransport{body: buildZip(t)}}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.2.5", + SourceURL: "http://example.com/terraform.zip", + }) + + err := handler.Handle(ctx, msg) + require.Error(t, err) +} + +type stubTransport struct { + body []byte +} + +func (t stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(t.body)), + Header: make(http.Header), + }, nil +} + +func buildZip(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create("terraform") + require.NoError(t, err) + _, err = f.Write([]byte("binary")) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +func TestIsZipArchive(t *testing.T) { + tests := []struct { + name string + content []byte + want bool + wantErr bool + }{ + { + name: "valid zip magic bytes", + content: []byte{0x50, 0x4B, 0x03, 0x04, 0x00, 0x00}, // PK\x03\x04 + extra bytes + want: true, + wantErr: false, + }, + { + name: "real zip file", + content: nil, // will be filled with actual zip + want: true, + wantErr: false, + }, + { + name: "plain binary (no magic)", + content: []byte("#!/bin/bash\necho hello"), + want: false, + wantErr: false, + }, + { + name: "ELF binary header", + content: []byte{0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01}, // ELF magic + want: false, + wantErr: false, + }, + { + name: "file too small", + content: []byte{0x50, 0x4B}, // only 2 bytes + want: false, + wantErr: false, + }, + { + name: "empty file", + content: []byte{}, + want: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "testfile") + + content := tt.content + if tt.name == "real zip file" { + // Build an actual zip for this test case + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, _ := w.Create("terraform") + _, _ = f.Write([]byte("binary")) + _ = w.Close() + content = buf.Bytes() + } + + require.NoError(t, os.WriteFile(testFile, content, 0o600)) + + got, err := isZipArchive(testFile) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got, "isZipArchive() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsZipArchive_FileNotFound(t *testing.T) { + _, err := isZipArchive("/nonexistent/path/to/file") + require.Error(t, err) +} + +func TestStageBinary_PlainBinary(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Create a plain binary file (not a zip) + binaryContent := []byte("#!/bin/bash\necho terraform") + sourcePath := filepath.Join(tempDir, "terraform-download") + require.NoError(t, os.WriteFile(sourcePath, binaryContent, 0o600)) + + targetPath := filepath.Join(tempDir, "terraform") + + handler := &Handler{RootPath: tempDir} + err := handler.stageBinary(ctx, sourcePath, targetPath) + require.NoError(t, err) + + // Verify the file was copied (not extracted) + content, err := os.ReadFile(filepath.Clean(targetPath)) + require.NoError(t, err) + require.Equal(t, binaryContent, content) +} + +func TestStageBinary_ZipArchive(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Create a zip archive without .zip extension (like downloads) + zipContent := buildZip(t) + sourcePath := filepath.Join(tempDir, "terraform-download") // no extension! + require.NoError(t, os.WriteFile(sourcePath, zipContent, 0o600)) + + targetPath := filepath.Join(tempDir, "terraform") + + handler := &Handler{RootPath: tempDir} + err := handler.stageBinary(ctx, sourcePath, targetPath) + require.NoError(t, err) + + // Verify the binary was extracted + content, err := os.ReadFile(filepath.Clean(targetPath)) + require.NoError(t, err) + require.Equal(t, []byte("binary"), content) +} + +func TestHandleInstall_IdempotentSkipsReinstall(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Pre-setup: mark version as already installed in status + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "1.0.0", + Versions: map[string]VersionStatus{ + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create the existing binary + targetDir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("existing binary"), 0o600)) + + // Setup handler with a stub transport that would fail if called + downloadCalled := false + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: &trackingTransport{ + onRequest: func() { downloadCalled = true }, + body: buildZip(t), + }}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "http://example.com/terraform.zip", + }) + + // Should succeed without downloading + require.NoError(t, handler.Handle(ctx, msg)) + require.False(t, downloadCalled, "download should be skipped for already-installed version") + + // Verify the original binary is unchanged + content, err := os.ReadFile(filepath.Clean(filepath.Join(targetDir, "terraform"))) + require.NoError(t, err) + require.Equal(t, []byte("existing binary"), content) +} + +func TestHandleInstall_ReinstallsIfBinaryMissing(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + // Pre-setup: mark version as installed but don't create the binary + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "1.0.0", + Versions: map[string]VersionStatus{ + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Don't create the binary - it's "missing" + + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "http://example.com/terraform.zip", + Checksum: checksum, + }) + + // Should succeed and reinstall + require.NoError(t, handler.Handle(ctx, msg)) + + // Verify the binary was (re)installed + require.FileExists(t, filepath.Join(tempDir, "versions", "1.0.0", "terraform")) +} + +func TestHandleInstall_PromotesPreviouslyInstalledVersion(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Pre-setup: version 1.2.0 is installed but 1.0.0 is current + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "1.0.0", + Versions: map[string]VersionStatus{ + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + "1.2.0": {Version: "1.2.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create both version directories with binaries + targetDir100 := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir100, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir100, "terraform"), []byte("binary 1.0.0"), 0o600)) + + targetDir120 := filepath.Join(tempDir, "versions", "1.2.0") + require.NoError(t, os.MkdirAll(targetDir120, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir120, "terraform"), []byte("binary 1.2.0"), 0o600)) + + // Setup handler with a tracking transport to verify no download happens + downloadCalled := false + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: &trackingTransport{ + onRequest: func() { downloadCalled = true }, + body: buildZip(t), + }}, + } + + // Request install of 1.2.0 (already installed but not current) + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.2.0", + SourceURL: "http://example.com/terraform.zip", + }) + + // Should succeed without downloading + require.NoError(t, handler.Handle(ctx, msg)) + require.False(t, downloadCalled, "download should be skipped for already-installed version") + + // Verify status was updated to promote 1.2.0 + status, err := store.Get(ctx) + require.NoError(t, err) + require.Equal(t, "1.2.0", status.Current, "current version should be promoted to 1.2.0") + require.Equal(t, "1.0.0", status.Previous, "previous version should be 1.0.0") + + // Verify symlink points to 1.2.0 + symlinkPath := filepath.Join(tempDir, "current") + target, err := os.Readlink(symlinkPath) + require.NoError(t, err) + require.Contains(t, target, "1.2.0", "symlink should point to 1.2.0") +} + +type trackingTransport struct { + onRequest func() + body []byte +} + +func (t *trackingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.onRequest != nil { + t.onRequest() + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(t.body)), + Header: make(http.Header), + }, nil +} + +func TestHandleInstall_PathTraversalRejected(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: buildZip(t)}}, + } + + // Try to install with a path traversal version + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "../../../etc/malicious", + SourceURL: "http://example.com/terraform.zip", + }) + + err := handler.Handle(ctx, msg) + require.Error(t, err) + // Error can be "path separator" or "path traversal" depending on which check catches it first + require.True(t, strings.Contains(err.Error(), "path separator") || strings.Contains(err.Error(), "path traversal"), + "expected error to contain 'path separator' or 'path traversal', got: %s", err.Error()) + + // Verify malicious directory was not created + require.NoFileExists(t, filepath.Join(tempDir, "..", "..", "..", "etc", "malicious")) +} + +func TestHandleUninstall_PathTraversalRejected(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + } + + // Try to uninstall with a path traversal version + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "../../../etc/passwd", + }) + + err := handler.Handle(ctx, msg) + require.Error(t, err) + // Error can be "path separator" or "path traversal" depending on which check catches it first + require.True(t, strings.Contains(err.Error(), "path separator") || strings.Contains(err.Error(), "path traversal"), + "expected error to contain 'path separator' or 'path traversal', got: %s", err.Error()) +} + +// mockExecutionChecker is a test helper that implements ExecutionChecker +type mockExecutionChecker struct { + active bool + err error +} + +func (m *mockExecutionChecker) HasActiveExecutions(ctx context.Context) (bool, error) { + return m.active, m.err +} + +func TestHandleUninstall_BlockedByActiveExecutions(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup: install a version first + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "2.0.0", + Versions: map[string]VersionStatus{ + "2.0.0": {Version: "2.0.0", State: VersionStateSucceeded}, + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create the version directory + targetDir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o600)) + + // Handler with ExecutionChecker that reports active executions + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + ExecutionChecker: &mockExecutionChecker{active: true}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "1.0.0", + }) + + err = handler.Handle(ctx, msg) + require.Error(t, err) + require.Contains(t, err.Error(), "executions are in progress") + + // Verify the version was NOT uninstalled + require.FileExists(t, filepath.Join(targetDir, "terraform")) +} + +func TestHandleUninstall_ExecutionCheckerAllows(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup: install a version first + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "2.0.0", + Versions: map[string]VersionStatus{ + "2.0.0": {Version: "2.0.0", State: VersionStateSucceeded}, + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create the version directory + targetDir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o600)) + + // Handler with ExecutionChecker that reports no active executions + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + ExecutionChecker: &mockExecutionChecker{active: false}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "1.0.0", + }) + + err = handler.Handle(ctx, msg) + require.NoError(t, err) + + // Verify the version WAS uninstalled + require.NoFileExists(t, filepath.Join(targetDir, "terraform")) +} + +func TestHandleUninstall_ExecutionCheckerError(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup: install a version first + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "2.0.0", + Versions: map[string]VersionStatus{ + "2.0.0": {Version: "2.0.0", State: VersionStateSucceeded}, + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create the version directory + targetDir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o600)) + + // Handler with ExecutionChecker that returns an error + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + ExecutionChecker: &mockExecutionChecker{err: errors.New("failed to check executions")}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "1.0.0", + }) + + err = handler.Handle(ctx, msg) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to check active executions") + + // Verify the version was NOT uninstalled due to error + require.FileExists(t, filepath.Join(targetDir, "terraform")) +} + +func TestExtractZip_SingleFileOnly(t *testing.T) { + tempDir := t.TempDir() + targetPath := filepath.Join(tempDir, "terraform") + + t.Run("single file succeeds", func(t *testing.T) { + // Create a zip with a single file + zipPath := filepath.Join(tempDir, "single.zip") + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, _ := w.Create("terraform") + _, _ = f.Write([]byte("single binary")) + _ = w.Close() + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o600)) + + err := extractZip(zipPath, targetPath) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Clean(targetPath)) + require.NoError(t, err) + require.Equal(t, []byte("single binary"), content) + }) + + t.Run("multiple files rejected", func(t *testing.T) { + // Create a zip with multiple files + zipPath := filepath.Join(tempDir, "multi.zip") + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + f1, _ := w.Create("terraform") + _, _ = f1.Write([]byte("binary1")) + + f2, _ := w.Create("malicious") + _, _ = f2.Write([]byte("binary2")) + + _ = w.Close() + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o600)) + + multiTarget := filepath.Join(tempDir, "terraform-multi") + err := extractZip(zipPath, multiTarget) + require.Error(t, err) + require.Contains(t, err.Error(), "multiple files") + }) + + t.Run("empty archive rejected", func(t *testing.T) { + // Create an empty zip + zipPath := filepath.Join(tempDir, "empty.zip") + var buf bytes.Buffer + w := zip.NewWriter(&buf) + _ = w.Close() + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o600)) + + emptyTarget := filepath.Join(tempDir, "terraform-empty") + err := extractZip(zipPath, emptyTarget) + require.Error(t, err) + require.Contains(t, err.Error(), "no file found") + }) + + t.Run("directory only archive rejected", func(t *testing.T) { + // Create a zip with only a directory + zipPath := filepath.Join(tempDir, "dironly.zip") + var buf bytes.Buffer + w := zip.NewWriter(&buf) + _, _ = w.Create("somedir/") // Directory entry (trailing slash) + _ = w.Close() + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o600)) + + dirTarget := filepath.Join(tempDir, "terraform-dir") + err := extractZip(zipPath, dirTarget) + require.Error(t, err) + require.Contains(t, err.Error(), "no file found") + }) +} + +func TestStatusToResponse(t *testing.T) { + t.Run("empty status returns not-installed state", func(t *testing.T) { + status := &Status{} + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateNotInstalled, resp.State) + require.Empty(t, resp.CurrentVersion) + require.Empty(t, resp.BinaryPath) + require.Nil(t, resp.InstalledAt) + require.Nil(t, resp.Source) + }) + + t.Run("succeeded version maps to ready state", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: VersionStateSucceeded, + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + }, + }, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateReady, resp.State) + require.Equal(t, "1.6.4", resp.CurrentVersion) + require.Equal(t, "/terraform/versions/1.6.4/terraform", resp.BinaryPath) + require.NotNil(t, resp.Source) + require.Equal(t, "https://example.com/terraform.zip", resp.Source.URL) + require.Equal(t, "sha256:abc123", resp.Source.Checksum) + }) + + t.Run("installing version maps to installing state", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: VersionStateInstalling, + }, + }, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateInstalling, resp.State) + }) + + t.Run("failed version maps to failed state", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: VersionStateFailed, + }, + }, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateFailed, resp.State) + }) + + t.Run("uninstalling version maps to uninstalling state", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: VersionStateUninstalling, + }, + }, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateUninstalling, resp.State) + }) + + t.Run("uninstalled version maps to not-installed state", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: VersionStateUninstalled, + }, + }, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateNotInstalled, resp.State) + }) + + t.Run("current version not in versions map returns not-installed", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{}, + } + resp := status.ToResponse("/terraform") + + require.Equal(t, ResponseStateNotInstalled, resp.State) + require.Equal(t, "1.6.4", resp.CurrentVersion) // Preserves what was set + }) + + t.Run("preserves versions map in response", func(t *testing.T) { + status := &Status{ + Current: "1.6.4", + Versions: map[string]VersionStatus{ + "1.5.0": {Version: "1.5.0", State: VersionStateSucceeded}, + "1.6.4": {Version: "1.6.4", State: VersionStateSucceeded}, + }, + } + resp := status.ToResponse("/terraform") + + require.Len(t, resp.Versions, 2) + require.Contains(t, resp.Versions, "1.5.0") + require.Contains(t, resp.Versions, "1.6.4") + }) + + t.Run("uses tracked queue info when set", func(t *testing.T) { + inProgress := "install:1.6.4" + status := &Status{ + Current: "1.5.0", + Queue: &QueueInfo{ + Pending: 2, + InProgress: &inProgress, + }, + } + resp := status.ToResponse("/terraform") + + require.NotNil(t, resp.Queue) + require.Equal(t, 2, resp.Queue.Pending) + require.NotNil(t, resp.Queue.InProgress) + require.Equal(t, "install:1.6.4", *resp.Queue.InProgress) + }) + + t.Run("defaults queue to empty when not set", func(t *testing.T) { + status := &Status{ + Current: "1.5.0", + Queue: nil, + } + resp := status.ToResponse("/terraform") + + require.NotNil(t, resp.Queue) + require.Equal(t, 0, resp.Queue.Pending) + require.Nil(t, resp.Queue.InProgress) + }) +} + +func TestGenerateVersionFromURL(t *testing.T) { + t.Run("generates deterministic version", func(t *testing.T) { + url := "https://example.com/terraform.zip" + v1 := generateVersionFromURL(url) + v2 := generateVersionFromURL(url) + require.Equal(t, v1, v2, "same URL should generate same version") + }) + + t.Run("different URLs generate different versions", func(t *testing.T) { + v1 := generateVersionFromURL("https://example.com/terraform1.zip") + v2 := generateVersionFromURL("https://example.com/terraform2.zip") + require.NotEqual(t, v1, v2, "different URLs should generate different versions") + }) + + t.Run("generated version is path-safe", func(t *testing.T) { + v := generateVersionFromURL("https://example.com/terraform.zip") + require.True(t, strings.HasPrefix(v, "custom-"), "version should have custom- prefix") + require.NotContains(t, v, "/", "version should not contain path separators") + require.NotContains(t, v, "\\", "version should not contain path separators") + require.NotContains(t, v, "..", "version should not contain path traversal") + }) +} + +func TestHandleInstall_SourceURLOnly(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + sourceURL := "http://example.com/custom-terraform.zip" + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + // Install with sourceUrl only, no version + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "", // No version specified + SourceURL: sourceURL, + Checksum: checksum, + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, err := store.Get(ctx) + require.NoError(t, err) + + // Version should be auto-generated (custom-) + require.True(t, strings.HasPrefix(status.Current, "custom-"), "version should be auto-generated with custom- prefix") + require.FileExists(t, filepath.Join(tempDir, "versions", status.Current, "terraform")) + + // Verify no stray empty-key entry exists + _, hasEmptyKey := status.Versions[""] + require.False(t, hasEmptyKey, "should not have an entry with empty version key") + + // Verify metadata is correctly preserved in the generated version entry + vs, ok := status.Versions[status.Current] + require.True(t, ok, "should have version entry for generated version") + require.Equal(t, status.Current, vs.Version, "version field should match key") + require.Equal(t, sourceURL, vs.SourceURL, "sourceURL should be preserved") + require.Equal(t, checksum, vs.Checksum, "checksum should be preserved") + require.Equal(t, VersionStateSucceeded, vs.State, "state should be Succeeded") +} + +func TestHandleUninstall_CurrentVersionSwitchesToPrevious(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup: 2.0.0 is current, 1.0.0 is previous + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "2.0.0", + Previous: "1.0.0", + Versions: map[string]VersionStatus{ + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + "2.0.0": {Version: "2.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create both version directories + for _, v := range []string{"1.0.0", "2.0.0"} { + dir := filepath.Join(tempDir, "versions", v) + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "terraform"), []byte("tf-"+v), 0o600)) + } + + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + } + + // Uninstall current version (2.0.0) + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "2.0.0", + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, err := store.Get(ctx) + require.NoError(t, err) + + // Should have switched to previous version + require.Equal(t, "1.0.0", status.Current, "should switch to previous version") + require.Empty(t, status.Previous, "previous should be cleared") + + // 2.0.0 should be uninstalled + require.Equal(t, VersionStateUninstalled, status.Versions["2.0.0"].State) + require.NoFileExists(t, filepath.Join(tempDir, "versions", "2.0.0", "terraform")) + + // Symlink should point to 1.0.0 + symlinkPath := filepath.Join(tempDir, "current") + target, err := os.Readlink(symlinkPath) + require.NoError(t, err) + require.Contains(t, target, "1.0.0") +} + +func TestHandleUninstall_CurrentVersionNoPrevious(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup: only 1.0.0 is installed (no previous) + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + err := store.Put(ctx, &Status{ + Current: "1.0.0", + Previous: "", + Versions: map[string]VersionStatus{ + "1.0.0": {Version: "1.0.0", State: VersionStateSucceeded}, + }, + }) + require.NoError(t, err) + + // Create version directory + dir := filepath.Join(tempDir, "versions", "1.0.0") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "terraform"), []byte("tf"), 0o600)) + + // Create current symlink + symlinkPath := filepath.Join(tempDir, "current") + require.NoError(t, os.Symlink(filepath.Join(dir, "terraform"), symlinkPath)) + + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + } + + // Uninstall current version (1.0.0) + msg := queue.NewMessage(JobMessage{ + Operation: OperationUninstall, + Version: "1.0.0", + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, err := store.Get(ctx) + require.NoError(t, err) + + // Current should be cleared + require.Empty(t, status.Current, "current should be cleared") + require.Empty(t, status.Previous, "previous should remain empty") + + // 1.0.0 should be uninstalled + require.Equal(t, VersionStateUninstalled, status.Versions["1.0.0"].State) + require.NoFileExists(t, filepath.Join(tempDir, "versions", "1.0.0", "terraform")) + + // Symlink should be removed + _, err = os.Lstat(symlinkPath) + require.True(t, os.IsNotExist(err), "symlink should be removed") +} + +// validTestCACert is a self-signed CA certificate for testing purposes. +// Generated specifically for unit tests - not for production use. +// NOTE: This certificate expires on 2027-01-21. If tests start failing after +// that date, generate a new certificate with a longer validity period: +// openssl req -x509 -newkey rsa:2048 -keyout /dev/null -out ca.pem -days 3650 -nodes -subj "/CN=testca" +// Then replace the certificate below with the contents of ca.pem. +const validTestCACert = `-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIUM06Yo/BKCPvBfZwztaJPszhAO98wDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDEyMTEwMjAzNVoXDTI3MDEyMTEw +MjAzNVowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA0wyOmcNaSz1AQHGNVmNzzkDO5VhUCv56KRybhLR/uXhapxQ4T+Rr +beMUExEaxyWDnTjsnirNUvwadBONWzm8cDQSW2KldbnzjteBRlNDbRI6TgKE0TRR +ljAM77Dczzuye2PsQS002Ny3UR+MnzI1kA3/XjAeAVefKn31Col0Ssn7OdvZ1VTH +aK04b2szaAla5Sl+eWKUsxj6UA/V/Xq94Z4AEnqk7zkGxnpILvxcz0QY/U/7e5iQ +IM/NkIeMoJe+Cfij+yPqLgh2f5L4Vi9WvRB8P0rbvl5WrEU6K6bjuZ5zKxiC+rbU +5hjAlR5lyrgo8cwiB5cOah+qQzl/3c26yQIDAQABo1MwUTAdBgNVHQ4EFgQU8/CI +UhXWPvHMCIynxKS4D+PQdy0wHwYDVR0jBBgwFoAU8/CIUhXWPvHMCIynxKS4D+PQ +dy0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAevFg7NV4D6UP +qYdvGjWgMFEUiUBp5EtEU5KD7FZwKop/lFqnvo+L1bUUy2hab76eO+g0perp8b8j +/ZwMgdIVNjNEWgM8h+Gg3HG8Rvdle5NqMq4lIGzmTN+MhPnQ8rECMSm0nVGTtFA0 +qE+O0LoSl/4FL9pUQuwZi+WibxoTOlw3NXpxx2WUFzU/Giwx6OYCTb773M9noKCH +7VAkvFImjSbr4SU05DGe+cUcWmtWcfhj2geiCHl/EEpe/oEi5/XnpgeMj4vkE6zK +fiCLJ0WJ77/ohDKnNecDZKIWLsUo9ywMJqi9TLSiBf5oMOc9uZtDoPTPzsXzcPZP +2JkLUbkliQ== +-----END CERTIFICATE-----` + +func TestCreateTLSClient(t *testing.T) { + t.Run("valid CA bundle creates client successfully", func(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{CABundle: validTestCACert}) + require.NoError(t, err) + require.NotNil(t, client) + require.NotNil(t, client.Transport) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok, "transport should be *http.Transport") + require.NotNil(t, transport.TLSClientConfig) + require.NotNil(t, transport.TLSClientConfig.RootCAs) + }) + + t.Run("invalid CA bundle returns error", func(t *testing.T) { + invalidCert := "not a valid PEM certificate" + client, err := createTLSClient(&tlsClientOptions{CABundle: invalidCert}) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "failed to parse CA bundle") + }) + + t.Run("empty CA bundle creates client without custom RootCAs", func(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{CABundle: ""}) + require.NoError(t, err) + require.NotNil(t, client) + transport := client.Transport.(*http.Transport) + require.Nil(t, transport.TLSClientConfig.RootCAs, "RootCAs should be nil when no CA bundle is provided") + }) + + t.Run("CA bundle with only whitespace returns error", func(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{CABundle: " \n\t "}) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "failed to parse CA bundle") + }) + + t.Run("malformed PEM returns error", func(t *testing.T) { + malformedPEM := `-----BEGIN CERTIFICATE----- +not-valid-base64-content +-----END CERTIFICATE-----` + client, err := createTLSClient(&tlsClientOptions{CABundle: malformedPEM}) + require.Error(t, err) + require.Nil(t, client) + }) + + t.Run("TLS config has minimum version TLS 1.2", func(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{CABundle: validTestCACert}) + require.NoError(t, err) + require.NotNil(t, client) + + transport := client.Transport.(*http.Transport) + require.Equal(t, uint16(tls.VersionTLS12), transport.TLSClientConfig.MinVersion, "MinVersion should be TLS 1.2") + }) +} + +func TestHandleInstall_WithCABundle(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + // Install with CA bundle - note that with a custom HTTPClient, the CA bundle + // won't be used (HTTPClient takes precedence), but it should still be stored in job + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: checksum, + CABundle: validTestCACert, + }) + + require.NoError(t, handler.Handle(ctx, msg)) + + status, err := store.Get(ctx) + require.NoError(t, err) + require.Equal(t, "1.0.0", status.Current) + vs := status.Versions["1.0.0"] + require.Equal(t, VersionStateSucceeded, vs.State) +} + +func TestHandleInstall_InvalidCABundleFails(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + // No HTTPClient - will try to use CA bundle + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: checksum, + CABundle: "invalid-ca-bundle", + }) + + err := handler.Handle(ctx, msg) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to configure HTTP client") + + // Verify the version was marked as failed + status, _ := store.Get(ctx) + vs := status.Versions["1.0.0"] + require.Equal(t, VersionStateFailed, vs.State) +} + +func TestDownload_UsesCABundleWhenNoHTTPClient(t *testing.T) { + // This test verifies the download function creates a custom client when CABundle is provided + // We can't easily test actual TLS behavior without a real server, but we can verify the code path + ctx := context.Background() + tempDir := t.TempDir() + + handler := &Handler{ + RootPath: tempDir, + HTTPClient: nil, // No default client - will create one with CA bundle + } + + // Create a test file to download "from" + // This will fail because we're not actually serving HTTPS, but we can verify the error + dstPath := filepath.Join(tempDir, "download") + + // Test with invalid CA bundle - should fail at CA parsing + err := handler.download(ctx, &downloadOptions{ + URL: "https://localhost:9999/nonexistent", + Dst: dstPath, + CABundle: "invalid-pem", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to configure HTTP client") +} + +func TestDownload_NoCABundleUsesDefaultClient(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + // Setup a test server + content := []byte("test content") + server := setupTestServer(t, content) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: nil, // Will use default client + } + + dstPath := filepath.Join(tempDir, "download") + err := handler.download(ctx, &downloadOptions{ + URL: server.URL + "/terraform", + Dst: dstPath, + }) + require.NoError(t, err) + + // Verify file was downloaded + downloaded, err := os.ReadFile(filepath.Clean(dstPath)) + require.NoError(t, err) + require.Equal(t, content, downloaded) +} + +func setupTestServer(t *testing.T, content []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) +} + +func TestJobMessage_CABundleSerialization(t *testing.T) { + // Test that CABundle is properly serialized/deserialized in JobMessage + original := JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + CABundle: validTestCACert, + } + + // Serialize + data, err := json.Marshal(original) + require.NoError(t, err) + + // Verify CABundle is included in JSON + require.Contains(t, string(data), "caBundle") + require.Contains(t, string(data), "BEGIN CERTIFICATE") + + // Deserialize + var decoded JobMessage + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + require.Equal(t, original.Operation, decoded.Operation) + require.Equal(t, original.Version, decoded.Version) + require.Equal(t, original.SourceURL, decoded.SourceURL) + require.Equal(t, original.Checksum, decoded.Checksum) + require.Equal(t, original.CABundle, decoded.CABundle) +} + +func TestJobMessage_CABundleOmittedWhenEmpty(t *testing.T) { + // Test that CABundle is omitted from JSON when empty (omitempty) + msg := JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + CABundle: "", + } + + data, err := json.Marshal(msg) + require.NoError(t, err) + + // Should not contain caBundle key when empty + require.NotContains(t, string(data), "caBundle") +} + +// Additional CA Bundle Edge Case Tests + +func TestCreateTLSClient_MultipleCertificates(t *testing.T) { + // Test that multiple certificates in a single bundle are all parsed + multipleCerts := validTestCACert + "\n" + validTestCACert + client, err := createTLSClient(&tlsClientOptions{CABundle: multipleCerts}) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestCreateTLSClient_CertWithLeadingWhitespace(t *testing.T) { + // Test CA bundle with leading newlines (common in copy-paste scenarios) + // Note: Leading spaces before "-----BEGIN" will cause parsing to fail + // because PEM decoder looks for "-----BEGIN" at line start + certWithWhitespace := "\n\n" + validTestCACert + client, err := createTLSClient(&tlsClientOptions{CABundle: certWithWhitespace}) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestCreateTLSClient_CertWithTrailingWhitespace(t *testing.T) { + // Test CA bundle with trailing whitespace + certWithWhitespace := validTestCACert + "\n\n " + client, err := createTLSClient(&tlsClientOptions{CABundle: certWithWhitespace}) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestCreateTLSClient_CertWithWindowsLineEndings(t *testing.T) { + // Test CA bundle with Windows-style line endings (CRLF) + certWithCRLF := strings.ReplaceAll(validTestCACert, "\n", "\r\n") + client, err := createTLSClient(&tlsClientOptions{CABundle: certWithCRLF}) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestCreateTLSClient_PartiallyValidBundle(t *testing.T) { + // Test bundle where first cert is invalid but second is valid + // AppendCertsFromPEM skips invalid certs and returns true if at least one was added + mixedBundle := "not a cert\n" + validTestCACert + client, err := createTLSClient(&tlsClientOptions{CABundle: mixedBundle}) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestCreateTLSClient_OnlyInvalidCerts(t *testing.T) { + // Test bundle where all certs are invalid + invalidBundle := `-----BEGIN CERTIFICATE----- +invalid-base64-content +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +also-invalid +-----END CERTIFICATE-----` + client, err := createTLSClient(&tlsClientOptions{CABundle: invalidBundle}) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "failed to parse CA bundle") +} + +func TestDownload_HTTPClientTakesPrecedenceOverCABundle(t *testing.T) { + // Test that when HTTPClient is set, it takes precedence over CA bundle + ctx := context.Background() + tempDir := t.TempDir() + + content := []byte("test binary content") + + customClientCalled := false + customClient := &http.Client{ + Transport: &trackingTransport{ + onRequest: func() { customClientCalled = true }, + body: content, + }, + } + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: customClient, // Custom client set + } + + dstPath := filepath.Join(tempDir, "download") + // Even though CA bundle is provided, custom HTTPClient should be used. + // The trackingTransport intercepts the request directly, so no real HTTP call is made. + err := handler.download(ctx, &downloadOptions{ + URL: "https://example.com/terraform", + Dst: dstPath, + CABundle: validTestCACert, + }) + require.NoError(t, err) + require.True(t, customClientCalled, "custom HTTPClient should be used when set") +} + +func TestInstallRequest_CABundleSerialization(t *testing.T) { + // Test that InstallRequest correctly serializes/deserializes CABundle + req := InstallRequest{ + Version: "1.6.4", + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + CABundle: validTestCACert, + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + // Verify all fields are present + require.Contains(t, string(data), `"version":"1.6.4"`) + require.Contains(t, string(data), `"sourceUrl":"https://example.com/terraform.zip"`) + require.Contains(t, string(data), `"checksum":"sha256:abc123"`) + require.Contains(t, string(data), `"caBundle"`) + + // Deserialize and verify + var decoded InstallRequest + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.Equal(t, req.Version, decoded.Version) + require.Equal(t, req.SourceURL, decoded.SourceURL) + require.Equal(t, req.Checksum, decoded.Checksum) + require.Equal(t, req.CABundle, decoded.CABundle) +} + +func TestInstallRequest_CABundleOmittedWhenEmpty(t *testing.T) { + // Test that CABundle is omitted from JSON when empty + req := InstallRequest{ + Version: "1.6.4", + SourceURL: "https://example.com/terraform.zip", + CABundle: "", + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + // Should not contain caBundle key when empty + require.NotContains(t, string(data), "caBundle") +} + +func TestHandleInstall_CABundlePassedThroughJobMessage(t *testing.T) { + // Verify that CA bundle is correctly passed through the job message + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: stubTransport{body: zipBytes}}, + } + + // Create job message with CA bundle + jobMsg := JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: checksum, + CABundle: validTestCACert, + } + + // Serialize and deserialize to simulate queue transport + data, err := json.Marshal(jobMsg) + require.NoError(t, err) + + var decodedJob JobMessage + err = json.Unmarshal(data, &decodedJob) + require.NoError(t, err) + + // Verify CA bundle survived serialization + require.Equal(t, validTestCACert, decodedJob.CABundle) + + // Create queue message with the job + msg := queue.NewMessage(decodedJob) + + // Handle the message + err = handler.Handle(ctx, msg) + require.NoError(t, err) + + // Verify installation succeeded + status, err := store.Get(ctx) + require.NoError(t, err) + require.Equal(t, "1.0.0", status.Current) +} + +// Tests for Auth Header support + +func TestDownload_WithAuthHeader(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + content := []byte("test binary") + var receivedAuthHeader string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer server.Close() + + handler := &Handler{ + RootPath: tempDir, + } + + dstPath := filepath.Join(tempDir, "download") + err := handler.download(ctx, &downloadOptions{ + URL: server.URL + "/terraform", + Dst: dstPath, + AuthHeader: "Bearer test-token-123", + }) + require.NoError(t, err) + require.Equal(t, "Bearer test-token-123", receivedAuthHeader) + + downloaded, err := os.ReadFile(filepath.Clean(dstPath)) + require.NoError(t, err) + require.Equal(t, content, downloaded) +} + +func TestDownload_WithBasicAuth(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + content := []byte("test binary") + var receivedAuthHeader string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuthHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer server.Close() + + handler := &Handler{ + RootPath: tempDir, + } + + dstPath := filepath.Join(tempDir, "download") + err := handler.download(ctx, &downloadOptions{ + URL: server.URL + "/terraform", + Dst: dstPath, + AuthHeader: "Basic dXNlcjpwYXNz", // base64("user:pass") + }) + require.NoError(t, err) + require.Equal(t, "Basic dXNlcjpwYXNz", receivedAuthHeader) +} + +func TestHandleInstall_WithAuthHeader(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + zipBytes := buildZip(t) + sum := sha256.Sum256(zipBytes) + checksum := "sha256:" + hex.EncodeToString(sum[:]) + + var receivedAuthHeader string + transport := &authCapturingTransport{ + body: zipBytes, + captureAuth: func(auth string) { receivedAuthHeader = auth }, + } + + store := NewStatusStore(inmemory.NewClient(), StatusStorageID) + handler := &Handler{ + StatusStore: store, + RootPath: tempDir, + HTTPClient: &http.Client{Transport: transport}, + } + + msg := queue.NewMessage(JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: checksum, + AuthHeader: "Bearer my-token", + }) + + require.NoError(t, handler.Handle(ctx, msg)) + require.Equal(t, "Bearer my-token", receivedAuthHeader) + + status, err := store.Get(ctx) + require.NoError(t, err) + require.Equal(t, "1.0.0", status.Current) +} + +// authCapturingTransport captures the Authorization header +type authCapturingTransport struct { + body []byte + captureAuth func(string) +} + +func (t *authCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.captureAuth != nil { + t.captureAuth(req.Header.Get("Authorization")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(t.body)), + Header: make(http.Header), + }, nil +} + +// Tests for mTLS (Client Certificate) support + +// validTestClientCert is a valid self-signed EC certificate for testing mTLS +const validTestClientCert = `-----BEGIN CERTIFICATE----- +MIIBgDCCASegAwIBAgIUO66xXGDU8mbkBLlWDIedYMe36KQwCgYIKoZIzj0EAwIw +FjEUMBIGA1UEAwwLdGVzdC1jbGllbnQwHhcNMjYwMTIxMTEwOTU1WhcNMjcwMTIx +MTEwOTU1WjAWMRQwEgYDVQQDDAt0ZXN0LWNsaWVudDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABPYLQEfPKg1q93kkfzMq3mmCjPQ4n67c5ZTvy2KZp0SkudA87onK +Uc0kaAlkWYP9en/guhBPEIymeP7FDXMRi3+jUzBRMB0GA1UdDgQWBBT7fcIawlf7 +eDhdmCnVc0pWvocf/jAfBgNVHSMEGDAWgBT7fcIawlf7eDhdmCnVc0pWvocf/jAP +BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIDYmsM0xMvcCUTwKHSNZ +9fIQUuA3sE0lwiMKTJjxVaXgAiAqvAlZYNOO9hm3SRzum4X1k5esFZk/rA9DsP96 +OUSd/A== +-----END CERTIFICATE-----` + +// validTestClientKey is the private key corresponding to validTestClientCert +const validTestClientKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMHOcZaCPvsej89Um +UEvIdBzlodyitFxw8a51JBJat7WhRANCAAT2C0BHzyoNavd5JH8zKt5pgoz0OJ+u +3OWU78timadEpLnQPO6JylHNJGgJZFmD/Xp/4LoQTxCMpnj+xQ1zEYt/ +-----END PRIVATE KEY-----` + +func TestCreateTLSClient_WithClientCert(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ClientCert: validTestClientCert, + ClientKey: validTestClientKey, + }) + require.NoError(t, err) + require.NotNil(t, client) + + transport := client.Transport.(*http.Transport) + require.NotNil(t, transport.TLSClientConfig) + require.Len(t, transport.TLSClientConfig.Certificates, 1, "should have one client certificate") +} + +func TestCreateTLSClient_ClientCertWithoutKey(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ClientCert: validTestClientCert, + // ClientKey intentionally missing + }) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "both client certificate and key must be provided") +} + +func TestCreateTLSClient_ClientKeyWithoutCert(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + // ClientCert intentionally missing + ClientKey: validTestClientKey, + }) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "both client certificate and key must be provided") +} + +func TestCreateTLSClient_InvalidClientCert(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ClientCert: "not a valid certificate", + ClientKey: "not a valid key", + }) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "failed to load client certificate") +} + +func TestCreateTLSClient_WithCABundleAndClientCert(t *testing.T) { + // Test that both CA bundle and client cert can be used together + client, err := createTLSClient(&tlsClientOptions{ + CABundle: validTestCACert, + ClientCert: validTestClientCert, + ClientKey: validTestClientKey, + }) + require.NoError(t, err) + require.NotNil(t, client) + + transport := client.Transport.(*http.Transport) + require.NotNil(t, transport.TLSClientConfig.RootCAs) + require.Len(t, transport.TLSClientConfig.Certificates, 1) +} + +// Tests for Proxy support + +func TestCreateTLSClient_WithProxy(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ProxyURL: "http://proxy.example.com:8080", + }) + require.NoError(t, err) + require.NotNil(t, client) + + transport := client.Transport.(*http.Transport) + require.NotNil(t, transport.Proxy, "Proxy should be configured") +} + +func TestCreateTLSClient_WithInvalidProxyURL(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ProxyURL: "not-a-valid-url", + }) + require.Error(t, err) + require.Nil(t, client) + require.Contains(t, err.Error(), "proxy URL must use http or https scheme") +} + +func TestCreateTLSClient_WithProxyMissingScheme(t *testing.T) { + client, err := createTLSClient(&tlsClientOptions{ + ProxyURL: "proxy.example.com:8080", + }) + require.Error(t, err) + require.Nil(t, client) +} + +func TestCreateTLSClient_AllOptions(t *testing.T) { + // Test with all options configured + client, err := createTLSClient(&tlsClientOptions{ + CABundle: validTestCACert, + ClientCert: validTestClientCert, + ClientKey: validTestClientKey, + ProxyURL: "http://proxy.example.com:8080", + }) + require.NoError(t, err) + require.NotNil(t, client) + + transport := client.Transport.(*http.Transport) + require.NotNil(t, transport.TLSClientConfig.RootCAs) + require.Len(t, transport.TLSClientConfig.Certificates, 1) + require.NotNil(t, transport.Proxy) +} + +// Tests for JobMessage and InstallRequest serialization with new fields + +func TestJobMessage_NewFieldsSerialization(t *testing.T) { + original := JobMessage{ + Operation: OperationInstall, + Version: "1.0.0", + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + CABundle: validTestCACert, + AuthHeader: "Bearer token123", + ClientCert: "cert-data", + ClientKey: "key-data", + ProxyURL: "http://proxy:8080", + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded JobMessage + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + require.Equal(t, original.Operation, decoded.Operation) + require.Equal(t, original.Version, decoded.Version) + require.Equal(t, original.SourceURL, decoded.SourceURL) + require.Equal(t, original.Checksum, decoded.Checksum) + require.Equal(t, original.CABundle, decoded.CABundle) + require.Equal(t, original.AuthHeader, decoded.AuthHeader) + require.Equal(t, original.ClientCert, decoded.ClientCert) + require.Equal(t, original.ClientKey, decoded.ClientKey) + require.Equal(t, original.ProxyURL, decoded.ProxyURL) +} + +func TestInstallRequest_NewFieldsSerialization(t *testing.T) { + original := InstallRequest{ + Version: "1.6.4", + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + CABundle: validTestCACert, + AuthHeader: "Basic dXNlcjpwYXNz", + ClientCert: "cert-pem-data", + ClientKey: "key-pem-data", + ProxyURL: "https://proxy.corp.com:8080", + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded InstallRequest + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + require.Equal(t, original.Version, decoded.Version) + require.Equal(t, original.SourceURL, decoded.SourceURL) + require.Equal(t, original.Checksum, decoded.Checksum) + require.Equal(t, original.CABundle, decoded.CABundle) + require.Equal(t, original.AuthHeader, decoded.AuthHeader) + require.Equal(t, original.ClientCert, decoded.ClientCert) + require.Equal(t, original.ClientKey, decoded.ClientKey) + require.Equal(t, original.ProxyURL, decoded.ProxyURL) +} + +func TestParseProxyURL(t *testing.T) { + tests := []struct { + name string + proxyURL string + expectErr bool + errMsg string + }{ + { + name: "valid http proxy", + proxyURL: "http://proxy.example.com:8080", + expectErr: false, + }, + { + name: "valid https proxy", + proxyURL: "https://proxy.example.com:8443", + expectErr: false, + }, + { + name: "proxy with auth", + proxyURL: "http://user:pass@proxy.example.com:8080", + expectErr: false, + }, + { + name: "invalid scheme", + proxyURL: "ftp://proxy.example.com:8080", + expectErr: true, + errMsg: "proxy URL must use http or https scheme", + }, + { + name: "missing host", + proxyURL: "http://", + expectErr: true, + errMsg: "proxy URL must have a host", + }, + { + name: "no scheme", + proxyURL: "proxy.example.com:8080", + expectErr: true, + errMsg: "proxy URL must use http or https scheme", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + parsed, err := parseProxyURL(tc.proxyURL) + if tc.expectErr { + require.Error(t, err) + if tc.errMsg != "" { + require.Contains(t, err.Error(), tc.errMsg) + } + require.Nil(t, parsed) + } else { + require.NoError(t, err) + require.NotNil(t, parsed) + } + }) + } +} diff --git a/pkg/terraform/installer/job.go b/pkg/terraform/installer/job.go new file mode 100644 index 0000000000..2b07419334 --- /dev/null +++ b/pkg/terraform/installer/job.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +// JobMessage is the payload sent through the installer queue. +// +// SECURITY NOTE: This message may contain sensitive data (AuthHeader, ClientKey). +// The queue should be treated as containing secrets: +// - Ensure queue storage is appropriately secured +// - Avoid logging full message contents +// - Consider encryption at rest if using persistent queues +// Future improvement: store secrets in a secret store and pass references instead. +type JobMessage struct { + Operation Operation `json:"operation"` + Version string `json:"version"` + SourceURL string `json:"sourceUrl,omitempty"` + Checksum string `json:"checksum,omitempty"` + CABundle string `json:"caBundle,omitempty"` + AuthHeader string `json:"authHeader,omitempty"` // SENSITIVE: may contain bearer tokens + ClientCert string `json:"clientCert,omitempty"` + ClientKey string `json:"clientKey,omitempty"` // SENSITIVE: contains private key material + ProxyURL string `json:"proxyUrl,omitempty"` + Purge bool `json:"purge,omitempty"` // Remove metadata after uninstall +} diff --git a/pkg/terraform/installer/queue_status.go b/pkg/terraform/installer/queue_status.go new file mode 100644 index 0000000000..40adfb1559 --- /dev/null +++ b/pkg/terraform/installer/queue_status.go @@ -0,0 +1,32 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import "context" + +// updateQueueInfo loads status, ensures queue info exists, applies the update, and persists. +// This is best-effort and returns without error when status can't be loaded or saved. +func updateQueueInfo(ctx context.Context, store StatusStore, update func(*QueueInfo)) { + status, err := store.Get(ctx) + if err != nil { + return + } + if status.Queue == nil { + status.Queue = &QueueInfo{} + } + update(status.Queue) + _ = store.Put(ctx, status) +} diff --git a/pkg/terraform/installer/routes.go b/pkg/terraform/installer/routes.go new file mode 100644 index 0000000000..6e6c59a923 --- /dev/null +++ b/pkg/terraform/installer/routes.go @@ -0,0 +1,342 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + dbinmemory "github.com/radius-project/radius/pkg/components/database/inmemory" + "github.com/radius-project/radius/pkg/components/queue" + qinmem "github.com/radius-project/radius/pkg/components/queue/inmemory" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/ucp" +) + +// RegisterRoutesWithHostOptions registers installer endpoints on a router using HostOptions. +// This is used by applications-rp which uses HostOptions instead of UCP Options. +func RegisterRoutesWithHostOptions(ctx context.Context, r chi.Router, options hostoptions.HostOptions, pathBase string) error { + var ( + qClient queue.Client + dbClient database.Client + err error + ) + + // Queue client: Create a dedicated queue for the terraform installer. + if options.Config.QueueProvider.Provider != "" { + qOpts := options.Config.QueueProvider + qOpts.Name = QueueName + qp := queueprovider.New(qOpts) + qClient, err = qp.GetClient(ctx) + if err != nil { + return err + } + } else { + // Fallback for tests and minimal configurations + qClient = qinmem.NewNamedQueue(QueueName) + } + + // Database client using the shared provider when configured; fallback to in-memory for tests/local. + if options.Config.DatabaseProvider.Provider != "" { + dbProvider := databaseprovider.FromOptions(options.Config.DatabaseProvider) + dbClient, err = dbProvider.GetClient(ctx) + if err != nil { + return err + } + } else { + dbClient = dbinmemory.NewClient() + } + + statusStore := NewStatusStore(dbClient, StatusStorageID) + handler := &HTTPHandler{ + Queue: qClient, + StatusStore: statusStore, + RootPath: terraformPathFromHostOptions(&options), + } + + basePath := strings.TrimSuffix(pathBase, "/") + "/installer/terraform" + r.Route(basePath, func(route chi.Router) { + route.Post("/install", handler.Install) + route.Post("/uninstall", handler.Uninstall) + route.Get("/status", handler.Status) + }) + + return nil +} + +// RegisterRoutes registers installer endpoints on the UCP router. +// Deprecated: Use RegisterRoutesWithHostOptions for applications-rp. +func RegisterRoutes(ctx context.Context, r chi.Router, options *ucp.Options) error { + var ( + qClient queue.Client + dbClient database.Client + err error + ) + + // Queue client: Create a dedicated queue for the terraform installer. + // We need a named queue (terraform-installer) that's isolated from the ARM async pipeline. + // When QueueProvider is configured (production via NewOptions), create a new provider with + // our queue name. Honor injected queue providers for tests, then fall back to in-memory + // for minimal configurations that don't configure a provider. + if options.QueueProvider != nil && options.QueueProvider.HasInjectedClient() { + qClient, err = options.QueueProvider.GetClient(ctx) + if err != nil { + return err + } + } else if options.Config.Queue.Provider != "" { + qOpts := options.Config.Queue + qOpts.Name = QueueName + qp := queueprovider.New(qOpts) + qClient, err = qp.GetClient(ctx) + if err != nil { + return err + } + } else { + // Fallback for tests and minimal configurations + qClient = qinmem.NewNamedQueue(QueueName) + } + + // Database client using the shared provider when configured; fallback to in-memory for tests/local. + if options.DatabaseProvider != nil { + dbClient, err = options.DatabaseProvider.GetClient(ctx) + if err != nil { + return err + } + } else { + dbClient = dbinmemory.NewClient() + } + + statusStore := NewStatusStore(dbClient, StatusStorageID) + handler := &HTTPHandler{ + Queue: qClient, + StatusStore: statusStore, + RootPath: terraformPath(options), + } + + basePath := strings.TrimSuffix(options.Config.Server.PathBase, "/") + "/installer/terraform" + r.Route(basePath, func(route chi.Router) { + route.Post("/install", handler.Install) + route.Post("/uninstall", handler.Uninstall) + route.Get("/status", handler.Status) + }) + + return nil +} + +// HTTPHandler handles installer HTTP endpoints. +type HTTPHandler struct { + Queue queue.Client + StatusStore StatusStore + // RootPath is the root directory for Terraform installations. + // Used to build binary paths in status responses. + RootPath string +} + +func (h *HTTPHandler) Install(w http.ResponseWriter, r *http.Request) { + var req InstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if err := validateInstallRequest(req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + msg := JobMessage{ + Operation: OperationInstall, + Version: req.Version, + SourceURL: req.SourceURL, + Checksum: req.Checksum, + CABundle: req.CABundle, + AuthHeader: req.AuthHeader, + ClientCert: req.ClientCert, + ClientKey: req.ClientKey, + ProxyURL: req.ProxyURL, + } + if err := h.Queue.Enqueue(r.Context(), queue.NewMessage(msg)); err != nil { + http.Error(w, "failed to enqueue install", http.StatusInternalServerError) + return + } + + // Increment pending count in status (best-effort) + h.incrementQueuePending(r.Context()) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": req.Version, + }) +} + +func (h *HTTPHandler) Uninstall(w http.ResponseWriter, r *http.Request) { + var req UninstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + // If no version specified, default to current version + if strings.TrimSpace(req.Version) == "" { + status, err := h.StatusStore.Get(r.Context()) + if err != nil { + http.Error(w, "failed to get status", http.StatusInternalServerError) + return + } + if status.Current == "" { + http.Error(w, "no current version installed", http.StatusBadRequest) + return + } + req.Version = status.Current + } + + msg := JobMessage{ + Operation: OperationUninstall, + Version: req.Version, + Purge: req.Purge, + } + if err := h.Queue.Enqueue(r.Context(), queue.NewMessage(msg)); err != nil { + http.Error(w, "failed to enqueue uninstall", http.StatusInternalServerError) + return + } + + // Increment pending count in status (best-effort) + h.incrementQueuePending(r.Context()) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": req.Version, + }) +} + +func (h *HTTPHandler) Status(w http.ResponseWriter, r *http.Request) { + status, err := h.StatusStore.Get(r.Context()) + if err != nil { + http.Error(w, "failed to load status", http.StatusInternalServerError) + return + } + + rootPath := h.RootPath + if rootPath == "" { + rootPath = "/terraform" + } + response := status.ToResponse(rootPath) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + +func validateInstallRequest(req InstallRequest) error { + version := strings.TrimSpace(req.Version) + sourceURL := strings.TrimSpace(req.SourceURL) + + if version == "" && sourceURL == "" { + return fmt.Errorf("version or sourceUrl is required") + } + + if version != "" && !IsValidVersion(version) { + return fmt.Errorf("invalid version format") + } + + if sourceURL != "" { + parsed, err := url.Parse(sourceURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("invalid sourceUrl") + } + } + + if strings.TrimSpace(req.Checksum) != "" && !IsValidChecksum(req.Checksum) { + return fmt.Errorf("invalid checksum format") + } + + // Validate mTLS: both client cert and key must be provided together + clientCert := strings.TrimSpace(req.ClientCert) + clientKey := strings.TrimSpace(req.ClientKey) + if (clientCert != "" && clientKey == "") || (clientCert == "" && clientKey != "") { + return fmt.Errorf("both clientCert and clientKey must be provided for mTLS") + } + + // Validate that download options require sourceUrl (they don't make sense for version-only installs) + if sourceURL == "" { + if strings.TrimSpace(req.CABundle) != "" { + return fmt.Errorf("caBundle requires sourceUrl to be set") + } + if strings.TrimSpace(req.AuthHeader) != "" { + return fmt.Errorf("authHeader requires sourceUrl to be set") + } + if clientCert != "" { + return fmt.Errorf("clientCert requires sourceUrl to be set") + } + if strings.TrimSpace(req.ProxyURL) != "" { + return fmt.Errorf("proxyUrl requires sourceUrl to be set") + } + } + + // Validate proxy URL format if provided + if proxyURL := strings.TrimSpace(req.ProxyURL); proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err != nil || parsed.Host == "" { + return fmt.Errorf("invalid proxyUrl") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("proxyUrl must use http or https scheme") + } + } + + return nil +} + +// terraformPath returns the configured terraform installation path from UCP options, +// defaulting to "/terraform" if not configured. +func terraformPath(options *ucp.Options) string { + if options.Config.Terraform.Path != "" { + return options.Config.Terraform.Path + } + return "/terraform" +} + +// terraformPathFromHostOptions returns the configured terraform installation path from HostOptions, +// defaulting to "/terraform" if not configured. +func terraformPathFromHostOptions(options *hostoptions.HostOptions) string { + if options.Config.Terraform.Path != "" { + return options.Config.Terraform.Path + } + return "/terraform" +} + +// incrementQueuePending increments the pending job count in status. +// Note: This is a best-effort metric. The count may be inaccurate if status +// updates fail or if messages are added/removed through non-standard paths. +// For exact counts, query the queue directly. +func (h *HTTPHandler) incrementQueuePending(ctx context.Context) { + updateQueueInfo(ctx, h.StatusStore, func(q *QueueInfo) { + q.Pending++ + }) +} diff --git a/pkg/terraform/installer/status_store.go b/pkg/terraform/installer/status_store.go new file mode 100644 index 0000000000..e21f1b9673 --- /dev/null +++ b/pkg/terraform/installer/status_store.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "context" + "errors" + + "github.com/radius-project/radius/pkg/components/database" +) + +// StatusStore persists installer status metadata. +type StatusStore interface { + // Get returns the current installer status. + Get(ctx context.Context) (*Status, error) + // Put persists the installer status. + Put(ctx context.Context, status *Status) error +} + +// StatusStoreImpl persists status using the database client. +type StatusStoreImpl struct { + client database.Client + // StorageKey allows namespacing installer status. + StorageKey string +} + +// NewStatusStore creates a new StatusStoreImpl. +func NewStatusStore(client database.Client, storageKey string) *StatusStoreImpl { + return &StatusStoreImpl{ + client: client, + StorageKey: storageKey, + } +} + +// Get retrieves installer status from the status manager. +func (s *StatusStoreImpl) Get(ctx context.Context) (*Status, error) { + result := &Status{} + obj, err := s.client.Get(ctx, s.StorageKey) + if err != nil { + var notFound *database.ErrNotFound + if errors.As(err, ¬Found) { + return &Status{ + Versions: map[string]VersionStatus{}, + }, nil + } + return nil, err + } + + if err := obj.As(result); err != nil { + return nil, err + } + + return result, nil +} + +// Put writes installer status through the status manager. +func (s *StatusStoreImpl) Put(ctx context.Context, status *Status) error { + obj := &database.Object{ + Metadata: database.Metadata{ + ID: s.StorageKey, + }, + Data: status, + } + + return s.client.Save(ctx, obj) +} diff --git a/pkg/terraform/installer/types.go b/pkg/terraform/installer/types.go new file mode 100644 index 0000000000..2da1503806 --- /dev/null +++ b/pkg/terraform/installer/types.go @@ -0,0 +1,247 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "context" + "time" +) + +// Operation enumerates installer operations. +type Operation string + +const ( + // OperationInstall enqueues a Terraform install. + OperationInstall Operation = "install" + // OperationUninstall enqueues a Terraform uninstall. + OperationUninstall Operation = "uninstall" +) + +// VersionState enumerates installer states for a version. +type VersionState string + +const ( + VersionStateInstalling VersionState = "Installing" + VersionStateSucceeded VersionState = "Succeeded" + VersionStateFailed VersionState = "Failed" + VersionStateUninstalling VersionState = "Uninstalling" + VersionStateUninstalled VersionState = "Uninstalled" +) + +// HealthStatus enumerates health of an installed version. +type HealthStatus string + +const ( + HealthUnknown HealthStatus = "Unknown" + HealthHealthy HealthStatus = "Healthy" + HealthUnhealthy HealthStatus = "Unhealthy" +) + +// InstallRequest describes an install submission. +type InstallRequest struct { + // Version requested for install (for example 1.6.4). + Version string `json:"version"` + // SourceURL is an optional direct archive URL to download Terraform from. + SourceURL string `json:"sourceUrl"` + // Checksum is an optional checksum string (for example sha256:). + Checksum string `json:"checksum"` + // CABundle is an optional PEM-encoded CA certificate bundle for TLS verification. + // Used when downloading from servers with self-signed or private CA certificates. + CABundle string `json:"caBundle,omitempty"` + // AuthHeader is an optional HTTP Authorization header value (e.g., "Bearer " or "Basic "). + // Used when downloading from servers that require authentication. + AuthHeader string `json:"authHeader,omitempty"` + // ClientCert is an optional PEM-encoded client certificate for mTLS authentication. + // Must be used together with ClientKey. + ClientCert string `json:"clientCert,omitempty"` + // ClientKey is an optional PEM-encoded client private key for mTLS authentication. + // Must be used together with ClientCert. + ClientKey string `json:"clientKey,omitempty"` + // ProxyURL is an optional HTTP/HTTPS proxy URL (e.g., "http://proxy.corp.com:8080"). + // Used when downloading through a corporate proxy. + ProxyURL string `json:"proxyUrl,omitempty"` +} + +// UninstallRequest describes an uninstall submission. +type UninstallRequest struct { + // Version to uninstall. + Version string `json:"version"` + // Purge removes the version metadata from the database after uninstalling. + // When false (default), the version entry remains with state "Uninstalled" for audit purposes. + Purge bool `json:"purge,omitempty"` +} + +// Status represents installer status metadata. +type Status struct { + // Current is the active Terraform version. + Current string `json:"current,omitempty"` + // Previous is the prior Terraform version (used for rollback). + Previous string `json:"previous,omitempty"` + // Versions captures per-version metadata. + Versions map[string]VersionStatus `json:"versions,omitempty"` + // LastError captures the last error message from installer failures. + LastError string `json:"lastError,omitempty"` + // LastUpdated records the last time status was updated. + LastUpdated time.Time `json:"lastUpdated,omitempty"` + // Queue tracks pending and in-progress installer operations. + Queue *QueueInfo `json:"queue,omitempty"` +} + +// VersionStatus captures metadata for a specific Terraform version. +type VersionStatus struct { + // Version is the Terraform version string. + Version string `json:"version,omitempty"` + // SourceURL used to download this version. + SourceURL string `json:"sourceUrl,omitempty"` + // Checksum used to validate the download. + Checksum string `json:"checksum,omitempty"` + // State represents the lifecycle state (for example Pending, Succeeded, Failed). + State VersionState `json:"state,omitempty"` + // Health captures health diagnostics for this version. + Health HealthStatus `json:"health,omitempty"` + // InstalledAt is the timestamp when the version was installed. + InstalledAt time.Time `json:"installedAt,omitempty"` + // LastError contains the last error for this version, if any. + LastError string `json:"lastError,omitempty"` +} + +// ExecutionChecker checks for active Terraform executions. +// This is used to prevent uninstalling a Terraform version while recipes are running. +// +// NOTE: This interface should be implemented as necessary when integrating with the +// recipes system. The implementation should query the async operation store for +// in-progress recipe deployments that use the Terraform engine. If no implementation +// is provided to the Handler, the safety check is skipped. +type ExecutionChecker interface { + // HasActiveExecutions returns true if any recipe executions using Terraform are in progress. + HasActiveExecutions(ctx context.Context) (bool, error) +} + +// ResponseState enumerates API response states (per design doc). +type ResponseState string + +const ( + ResponseStateNotInstalled ResponseState = "not-installed" + ResponseStateInstalling ResponseState = "installing" + ResponseStateReady ResponseState = "ready" + ResponseStateUninstalling ResponseState = "uninstalling" + ResponseStateFailed ResponseState = "failed" +) + +// StatusResponse is the HTTP API response format (matches design doc). +type StatusResponse struct { + // CurrentVersion is the active Terraform version. + CurrentVersion string `json:"currentVersion,omitempty"` + // State is the overall installer state. + State ResponseState `json:"state,omitempty"` + // BinaryPath is the path to the active Terraform binary. + BinaryPath string `json:"binaryPath,omitempty"` + // InstalledAt is the timestamp when the current version was installed. + InstalledAt *time.Time `json:"installedAt,omitempty"` + // Source contains the URL and checksum used for the current version. + Source *SourceInfo `json:"source,omitempty"` + // Queue contains queue status information. + Queue *QueueInfo `json:"queue,omitempty"` + // History contains recent operation history. + History []HistoryEntry `json:"history,omitempty"` + // Versions contains per-version metadata (for detailed status queries). + Versions map[string]VersionStatus `json:"versions,omitempty"` + // LastError captures the last error message from installer failures. + LastError string `json:"lastError,omitempty"` + // LastUpdated records the last time status was updated. + LastUpdated time.Time `json:"lastUpdated,omitempty"` +} + +// SourceInfo contains download source information. +type SourceInfo struct { + URL string `json:"url,omitempty"` + Checksum string `json:"checksum,omitempty"` +} + +// QueueInfo contains queue status information. +type QueueInfo struct { + Pending int `json:"pending"` + InProgress *string `json:"inProgress,omitempty"` +} + +// HistoryEntry represents a single operation in the history. +type HistoryEntry struct { + Operation string `json:"operation"` + Version string `json:"version"` + Timestamp time.Time `json:"timestamp"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// ToResponse converts internal Status to API StatusResponse format. +func (s *Status) ToResponse(rootPath string) StatusResponse { + // Use tracked queue info if available, otherwise default to empty + queueInfo := s.Queue + if queueInfo == nil { + queueInfo = &QueueInfo{Pending: 0} + } + + resp := StatusResponse{ + CurrentVersion: s.Current, + Versions: s.Versions, + LastError: s.LastError, + LastUpdated: s.LastUpdated, + Queue: queueInfo, + } + + // Determine overall state based on current version status + if s.Current == "" { + resp.State = ResponseStateNotInstalled + } else if vs, ok := s.Versions[s.Current]; ok { + resp.State = mapVersionStateToResponseState(vs.State) + if !vs.InstalledAt.IsZero() { + resp.InstalledAt = &vs.InstalledAt + } + if vs.SourceURL != "" || vs.Checksum != "" { + resp.Source = &SourceInfo{ + URL: vs.SourceURL, + Checksum: vs.Checksum, + } + } + } else { + resp.State = ResponseStateNotInstalled + } + + // Build binary path if we have a current version + if s.Current != "" && rootPath != "" { + resp.BinaryPath = rootPath + "/versions/" + s.Current + "/terraform" + } + + return resp +} + +// mapVersionStateToResponseState maps internal VersionState to API ResponseState. +func mapVersionStateToResponseState(vs VersionState) ResponseState { + switch vs { + case VersionStateInstalling: + return ResponseStateInstalling + case VersionStateSucceeded: + return ResponseStateReady + case VersionStateFailed: + return ResponseStateFailed + case VersionStateUninstalling: + return ResponseStateUninstalling + case VersionStateUninstalled: + return ResponseStateNotInstalled + default: + return ResponseStateNotInstalled + } +} diff --git a/pkg/terraform/installer/validation.go b/pkg/terraform/installer/validation.go new file mode 100644 index 0000000000..8d2040366e --- /dev/null +++ b/pkg/terraform/installer/validation.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + versionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+].*)?$`) + checksumRe = regexp.MustCompile(`^(?i:(sha256:)?[a-f0-9]{64})$`) +) + +// IsValidVersion returns true if the version string is in a simple semver-like format. +func IsValidVersion(v string) bool { + return versionRe.MatchString(v) +} + +// IsValidChecksum returns true if the checksum string appears to be a sha256 hex string with optional prefix. +func IsValidChecksum(c string) bool { + return checksumRe.MatchString(c) +} + +// ValidateVersionForPath ensures the version is safe to use in filesystem paths. +// Returns error if version contains path traversal or separator characters. +// NOTE: This validates path safety, not semver compliance - "latest" or custom tags are allowed. +func ValidateVersionForPath(version string) error { + if strings.TrimSpace(version) == "" { + return fmt.Errorf("version is required") + } + // Check for path traversal patterns: "../", "/..", "..\", "\..", or standalone ".." + // Note: We check for path separators separately, so here we only need to check + // for ".." as a standalone value (which would be the entire version string) + if version == ".." { + return fmt.Errorf("invalid version: contains path traversal sequence") + } + if strings.ContainsAny(version, "/\\") { + return fmt.Errorf("invalid version: contains path separator") + } + // Only validate path safety, not semver format - allow "latest", custom tags, etc. + return nil +} diff --git a/pkg/terraform/installer/validation_test.go b/pkg/terraform/installer/validation_test.go new file mode 100644 index 0000000000..e15b17e163 --- /dev/null +++ b/pkg/terraform/installer/validation_test.go @@ -0,0 +1,113 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "strings" + "testing" +) + +func TestIsValidVersion(t *testing.T) { + tests := []struct { + name string + version string + valid bool + }{ + {name: "simple", version: "1.2.3", valid: true}, + {name: "pre", version: "1.2.3-beta.1", valid: true}, + {name: "build", version: "1.2.3+build", valid: true}, + {name: "missing patch", version: "1.2", valid: false}, + {name: "garbage", version: "abc", valid: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidVersion(tt.version); got != tt.valid { + t.Fatalf("IsValidVersion(%q) = %v, want %v", tt.version, got, tt.valid) + } + }) + } +} + +func TestIsValidChecksum(t *testing.T) { + tests := []struct { + name string + checksum string + valid bool + }{ + {name: "prefixed sha", checksum: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", valid: true}, + {name: "bare sha", checksum: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", valid: true}, + {name: "wrong length", checksum: "abc", valid: false}, + {name: "wrong chars", checksum: "sha256:xyz123", valid: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidChecksum(tt.checksum); got != tt.valid { + t.Fatalf("IsValidChecksum(%q) = %v, want %v", tt.checksum, got, tt.valid) + } + }) + } +} + +func TestValidateVersionForPath(t *testing.T) { + tests := []struct { + name string + version string + wantErr bool + errMsg string + }{ + // Valid versions + {name: "simple semver", version: "1.6.4", wantErr: false}, + {name: "semver with prerelease", version: "1.6.4-beta.1", wantErr: false}, + {name: "semver with build", version: "1.6.4+build", wantErr: false}, + {name: "custom tag latest", version: "latest", wantErr: false}, + {name: "custom tag stable", version: "stable", wantErr: false}, + {name: "version with dash", version: "v1-6-4", wantErr: false}, + + // Invalid versions - path traversal attacks + // Note: Versions with "/" are caught by path separator check first + {name: "path traversal basic", version: "../../../etc", wantErr: true, errMsg: "path separator"}, + {name: "path traversal with version", version: "1.0.0/../../../etc", wantErr: true, errMsg: "path separator"}, + {name: "double dot alone", version: "..", wantErr: true, errMsg: "path traversal"}, + {name: "consecutive dots allowed", version: "1..2", wantErr: false}, + + // Invalid versions - path separators + {name: "forward slash", version: "1.6/4", wantErr: true, errMsg: "path separator"}, + {name: "backslash", version: "1.6\\4", wantErr: true, errMsg: "path separator"}, + {name: "absolute path unix", version: "/etc/passwd", wantErr: true, errMsg: "path separator"}, + {name: "absolute path windows", version: "C:\\Windows", wantErr: true, errMsg: "path separator"}, + + // Invalid versions - empty + {name: "empty string", version: "", wantErr: true, errMsg: "required"}, + {name: "whitespace only", version: " ", wantErr: true, errMsg: "required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateVersionForPath(tt.version) + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateVersionForPath(%q) expected error, got nil", tt.version) + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Fatalf("ValidateVersionForPath(%q) error = %v, want error containing %q", tt.version, err, tt.errMsg) + } + } else { + if err != nil { + t.Fatalf("ValidateVersionForPath(%q) unexpected error: %v", tt.version, err) + } + } + }) + } +} diff --git a/pkg/terraform/installer/worker.go b/pkg/terraform/installer/worker.go new file mode 100644 index 0000000000..02604beb37 --- /dev/null +++ b/pkg/terraform/installer/worker.go @@ -0,0 +1,223 @@ +/* +Copyright 2026 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/queue" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/ucp" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +// WorkerService runs the installer queue consumer in the UCP host. +// It uses a dedicated queue so Terraform binary install/uninstall jobs stay isolated from the ARM async pipeline, +// which expects ARM operation payloads and semantics. +type WorkerService struct { + options *ucp.Options +} + +// NewWorkerService creates a new WorkerService. +func NewWorkerService(options *ucp.Options) *WorkerService { + return &WorkerService{options: options} +} + +// Name returns the service name. +func (s *WorkerService) Name() string { + return "terraform-installer-worker" +} + +// Run starts consuming installer queue messages. +func (s *WorkerService) Run(ctx context.Context) error { + log := ucplog.FromContextOrDiscard(ctx) + + dbProvider := databaseprovider.FromOptions(s.options.Config.Database) + dbClient, err := dbProvider.GetClient(ctx) + if err != nil { + return err + } + + qOpts := s.options.Config.Queue + qOpts.Name = QueueName + qp := queueprovider.New(qOpts) + queueClient, err := qp.GetClient(ctx) + if err != nil { + return err + } + + statusStore := NewStatusStore(dbClient, StatusStorageID) + handler := &Handler{ + StatusStore: statusStore, + RootPath: s.terraformPath(), + BaseURL: s.options.Config.Terraform.SourceBaseURL, + } + + msgCh, err := queue.StartDequeuer(ctx, queueClient, queue.WithDequeueInterval(time.Second*2)) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return nil + case msg, ok := <-msgCh: + if !ok { + return nil + } + + if err := handler.Handle(ctx, msg); err != nil { + if errors.Is(err, ErrInstallerBusy) { + log.Info("installer busy; recording failure for request", "messageID", msg.ID) + + // Extract version from message for failure recording + var job JobMessage + if decodeErr := json.Unmarshal(msg.Data, &job); decodeErr != nil { + log.Error(decodeErr, "failed to decode job message for failure recording") + } else if job.Version == "" { + // Skip recording failure for empty version to avoid polluting status map + log.Info("skipping failure recording for job with empty version") + } else { + status, getErr := handler.getOrInitStatus(ctx) + if getErr != nil { + log.Error(getErr, "failed to load status while handling busy installer") + } else { + _ = handler.recordFailure(ctx, status, job.Version, err) + } + } + } else { + log.Error(err, "failed to handle installer message") + } + } + + // FinishMessage removes the message from the queue. If this fails, the message + // will be redelivered after the queue's visibility timeout expires. The queue's + // built-in retry and dead-letter mechanisms will handle repeated failures. + if err := queueClient.FinishMessage(ctx, msg); err != nil { + log.Error(err, "failed to finish installer message", "messageID", msg.ID) + } + } + } +} + +func (s *WorkerService) terraformPath() string { + if s.options.Config.Terraform.Path != "" { + return s.options.Config.Terraform.Path + } + return "/terraform" +} + +// HostOptionsWorkerService runs the installer queue consumer using HostOptions. +// This is used by applications-rp instead of UCP. +type HostOptionsWorkerService struct { + options hostoptions.HostOptions +} + +// NewHostOptionsWorkerService creates a new HostOptionsWorkerService. +func NewHostOptionsWorkerService(options hostoptions.HostOptions) *HostOptionsWorkerService { + return &HostOptionsWorkerService{options: options} +} + +// Name returns the service name. +func (s *HostOptionsWorkerService) Name() string { + return "terraform-installer-worker" +} + +// Run starts consuming installer queue messages. +func (s *HostOptionsWorkerService) Run(ctx context.Context) error { + log := ucplog.FromContextOrDiscard(ctx) + + dbProvider := databaseprovider.FromOptions(s.options.Config.DatabaseProvider) + dbClient, err := dbProvider.GetClient(ctx) + if err != nil { + return err + } + + qOpts := s.options.Config.QueueProvider + qOpts.Name = QueueName + qp := queueprovider.New(qOpts) + queueClient, err := qp.GetClient(ctx) + if err != nil { + return err + } + + statusStore := NewStatusStore(dbClient, StatusStorageID) + handler := &Handler{ + StatusStore: statusStore, + RootPath: s.hostOptionsPath(), + BaseURL: s.options.Config.Terraform.SourceBaseURL, + } + + msgCh, err := queue.StartDequeuer(ctx, queueClient, queue.WithDequeueInterval(time.Second*2)) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return nil + case msg, ok := <-msgCh: + if !ok { + return nil + } + + if err := handler.Handle(ctx, msg); err != nil { + if errors.Is(err, ErrInstallerBusy) { + log.Info("installer busy; recording failure for request", "messageID", msg.ID) + + // Extract version from message for failure recording + var job JobMessage + if decodeErr := json.Unmarshal(msg.Data, &job); decodeErr != nil { + log.Error(decodeErr, "failed to decode job message for failure recording") + } else if job.Version == "" { + // Skip recording failure for empty version to avoid polluting status map + log.Info("skipping failure recording for job with empty version") + } else { + status, getErr := handler.getOrInitStatus(ctx) + if getErr != nil { + log.Error(getErr, "failed to load status while handling busy installer") + } else { + _ = handler.recordFailure(ctx, status, job.Version, err) + } + } + } else { + log.Error(err, "failed to handle installer message") + } + } + + // FinishMessage removes the message from the queue. If this fails, the message + // will be redelivered after the queue's visibility timeout expires. The queue's + // built-in retry and dead-letter mechanisms will handle repeated failures. + if err := queueClient.FinishMessage(ctx, msg); err != nil { + log.Error(err, "failed to finish installer message", "messageID", msg.ID) + } + } + } +} + +func (s *HostOptionsWorkerService) hostOptionsPath() string { + if s.options.Config.Terraform.Path != "" { + return s.options.Config.Terraform.Path + } + return "/terraform" +} diff --git a/pkg/ucp/config.go b/pkg/ucp/config.go index 8580d022eb..f864ff343c 100644 --- a/pkg/ucp/config.go +++ b/pkg/ucp/config.go @@ -76,6 +76,9 @@ type Config struct { // Worker is the configuration for the backend worker server. Worker hostoptions.WorkerServerOptions `yaml:"workerServer"` + + // Terraform configures Terraform installer settings. + Terraform hostoptions.TerraformOptions `yaml:"terraform,omitempty"` } const ( diff --git a/pkg/ucp/frontend/api/routes.go b/pkg/ucp/frontend/api/routes.go index a1c050f44d..510c95d7c7 100644 --- a/pkg/ucp/frontend/api/routes.go +++ b/pkg/ucp/frontend/api/routes.go @@ -20,6 +20,9 @@ import ( "context" "fmt" "net/http" + "net/http/httputil" + "net/url" + "strings" "github.com/go-chi/chi/v5" @@ -164,6 +167,12 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini } } + // Register proxy for Terraform installer endpoints. + // The installer runs on applications-rp, so we proxy requests there. + if err := registerInstallerProxy(ctx, router, options); err != nil { + return err + } + // Register a catch-all route to handle requests that get dispatched to a specific plane. unknownPlaneRouter := server.NewSubrouter(router, options.Config.Server.PathBase+planeTypeCollectionPath) unknownPlaneRouter.HandleFunc(server.CatchAllPath, func(w http.ResponseWriter, r *http.Request) { @@ -186,3 +195,97 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini return nil } + +// trimProxyPath strips the path base from a request path before proxying. +// This ensures the target receives a clean path relative to its own root. +// For example: /apis/api.ucp.dev/v1alpha3/installer/terraform/status +// with pathBase "/apis/api.ucp.dev/v1alpha3" becomes "/installer/terraform/status" +func trimProxyPath(path, pathBase string) string { + trimmed := strings.TrimPrefix(path, pathBase) + if trimmed == "" { + return "/" + } + if !strings.HasPrefix(trimmed, "/") { + return "/" + trimmed + } + return trimmed +} + +// registerInstallerProxy sets up a reverse proxy to forward terraform installer +// requests from UCP to applications-rp where the installer service runs. +// +// Why we need a proxy for the terraform installer: +// +// 1. The terraform installer is a custom REST API (/installer/terraform/*), not an ARM resource. +// 2. ARM resources use /planes/radius/local/resourceGroups/.../providers/... paths and are +// automatically routed by UCP based on the resourceProviders config in planes. +// 3. Since the installer API doesn't follow the ARM resource pattern, it needs explicit proxy +// configuration to reach applications-rp where the installer service runs. +// +// The installer runs on applications-rp (not UCP) because: +// - Recipe execution happens on applications-rp and needs access to the terraform binary +// - Running the installer on the same pod avoids the need for shared storage (RWX PVC) +// which isn't supported by many Kubernetes environments (Kind, Minikube, etc.) +func registerInstallerProxy(ctx context.Context, router chi.Router, options *ucp.Options) error { + logger := ucplog.FromContextOrDiscard(ctx) + + // Get applications-rp endpoint from the radius plane configuration + applicationsRPEndpoint := getApplicationsRPEndpoint(ctx, options) + if applicationsRPEndpoint == "" { + logger.Info("Applications-rp endpoint not configured, skipping installer proxy registration") + return nil + } + + targetURL, err := url.Parse(applicationsRPEndpoint) + if err != nil { + return fmt.Errorf("failed to parse applications-rp endpoint: %w", err) + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // Customize the director to rewrite the path + originalDirector := proxy.Director + pathBase := options.Config.Server.PathBase + proxy.Director = func(req *http.Request) { + // Strip the UCP path base from the request path before the proxy joins with targetURL.Path. + // e.g., /apis/api.ucp.dev/v1alpha3/installer/terraform/status -> /installer/terraform/status + req.URL.Path = trimProxyPath(req.URL.Path, pathBase) + if req.URL.RawPath != "" { + req.URL.RawPath = trimProxyPath(req.URL.RawPath, pathBase) + } + + originalDirector(req) + req.Host = targetURL.Host + } + + // Register the proxy routes using chi's Route for proper path matching + installerPath := options.Config.Server.PathBase + "/installer/terraform" + router.Route(installerPath, func(r chi.Router) { + r.HandleFunc("/*", func(w http.ResponseWriter, req *http.Request) { + logger.Info("Proxying terraform installer request to applications-rp", "path", req.URL.Path, "method", req.Method) + proxy.ServeHTTP(w, req) + }) + }) + + logger.Info("Registered terraform installer proxy", "targetEndpoint", applicationsRPEndpoint) + return nil +} + +// getApplicationsRPEndpoint returns the applications-rp endpoint from UCP configuration. +func getApplicationsRPEndpoint(ctx context.Context, options *ucp.Options) string { + logger := ucplog.FromContextOrDiscard(ctx) + + // Check initialization config for Applications.Core resource provider endpoint + for _, plane := range options.Config.Initialization.Planes { + logger.Info("Checking plane for Applications.Core endpoint", "planeID", plane.ID, "kind", plane.Properties.Kind) + if plane.Properties.Kind == "UCPNative" { + if endpoint, ok := plane.Properties.ResourceProviders["Applications.Core"]; ok { + logger.Info("Found Applications.Core endpoint", "endpoint", endpoint) + return endpoint + } + } + } + + logger.Info("Applications.Core endpoint not found in any plane") + return "" +} diff --git a/pkg/ucp/frontend/api/routes_test.go b/pkg/ucp/frontend/api/routes_test.go index fe91a73422..a3d5c15599 100644 --- a/pkg/ucp/frontend/api/routes_test.go +++ b/pkg/ucp/frontend/api/routes_test.go @@ -128,6 +128,65 @@ func Test_Route_ToModule(t *testing.T) { require.True(t, matched) } +func Test_trimProxyPath(t *testing.T) { + tests := []struct { + name string + path string + pathBase string + expected string + }{ + { + name: "strips path base and preserves remaining path", + path: "/apis/api.ucp.dev/v1alpha3/installer/terraform/status", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/installer/terraform/status", + }, + { + name: "returns root when path equals path base", + path: "/apis/api.ucp.dev/v1alpha3", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/", + }, + { + name: "ensures leading slash when missing after trim", + path: "/apis/api.ucp.dev/v1alpha3installer/terraform/status", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/installer/terraform/status", + }, + { + name: "handles empty path base", + path: "/installer/terraform/status", + pathBase: "", + expected: "/installer/terraform/status", + }, + { + name: "handles path not starting with path base", + path: "/other/path", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/other/path", + }, + { + name: "handles install endpoint", + path: "/apis/api.ucp.dev/v1alpha3/installer/terraform/install", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/installer/terraform/install", + }, + { + name: "handles nested paths correctly", + path: "/apis/api.ucp.dev/v1alpha3/installer/terraform/versions/1.6.4", + pathBase: "/apis/api.ucp.dev/v1alpha3", + expected: "/installer/terraform/versions/1.6.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trimProxyPath(tt.path, tt.pathBase) + require.Equal(t, tt.expected, result) + }) + } +} + type testModule struct { } diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json index b44da4f9a3..0ef563d041 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json @@ -48,6 +48,12 @@ { "name": "Environments" }, + { + "name": "TerraformSettings" + }, + { + "name": "BicepSettings" + }, { "name": "RecipePacks" } @@ -337,6 +343,218 @@ } } }, + "/{rootScope}/providers/Radius.Core/bicepSettings": { + "get": { + "operationId": "BicepSettings_ListByScope", + "tags": [ + "BicepSettings" + ], + "description": "List BicepSettingsResource resources by Scope", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/BicepSettingsResourceListResult" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + } + }, + "/{rootScope}/providers/Radius.Core/bicepSettings/{bicepSettingsName}": { + "get": { + "operationId": "BicepSettings_Get", + "tags": [ + "BicepSettings" + ], + "description": "Get a BicepSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "bicepSettingsName", + "in": "path", + "description": "Bicep settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/BicepSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "put": { + "operationId": "BicepSettings_CreateOrUpdate", + "tags": [ + "BicepSettings" + ], + "description": "Create a BicepSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "bicepSettingsName", + "in": "path", + "description": "Bicep settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "resource", + "in": "body", + "description": "Resource create parameters.", + "required": true, + "schema": { + "$ref": "#/definitions/BicepSettingsResource" + } + } + ], + "responses": { + "200": { + "description": "Resource 'BicepSettingsResource' update operation succeeded", + "schema": { + "$ref": "#/definitions/BicepSettingsResource" + } + }, + "201": { + "description": "Resource 'BicepSettingsResource' create operation succeeded", + "schema": { + "$ref": "#/definitions/BicepSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "patch": { + "operationId": "BicepSettings_Update", + "tags": [ + "BicepSettings" + ], + "description": "Update a BicepSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "bicepSettingsName", + "in": "path", + "description": "Bicep settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "properties", + "in": "body", + "description": "The resource properties to be updated.", + "required": true, + "schema": { + "$ref": "#/definitions/BicepSettingsResourceUpdate" + } + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/BicepSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "operationId": "BicepSettings_Delete", + "tags": [ + "BicepSettings" + ], + "description": "Delete a BicepSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "bicepSettingsName", + "in": "path", + "description": "Bicep settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + } + ], + "responses": { + "200": { + "description": "Resource deleted successfully." + }, + "204": { + "description": "Resource does not exist." + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + } + }, "/{rootScope}/providers/Radius.Core/environments": { "get": { "operationId": "Environments_ListByScope", @@ -792,23 +1010,26 @@ } } }, - "/providers/Radius.Core/operations": { + "/{rootScope}/providers/Radius.Core/terraformSettings": { "get": { - "operationId": "Operations_List", + "operationId": "TerraformSettings_ListByScope", "tags": [ - "Operations" + "TerraformSettings" ], - "description": "List the operations for the provider", + "description": "List TerraformSettingsResource resources by Scope", "parameters": [ { "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" } ], "responses": { "200": { "description": "Azure operation completed successfully.", "schema": { - "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/OperationListResult" + "$ref": "#/definitions/TerraformSettingsResourceListResult" } }, "default": { @@ -822,45 +1043,254 @@ "nextLinkName": "nextLink" } } - } - }, - "definitions": { - "ApplicationGraphConnection": { - "type": "object", - "description": "Describes the connection between two resources.", - "properties": { - "id": { - "type": "string", - "description": "The resource ID " - }, - "direction": { - "$ref": "#/definitions/Direction", - "description": "The direction of the connection. 'Outbound' indicates this connection specifies the ID of the destination and 'Inbound' indicates indicates this connection specifies the ID of the source." - } - }, - "required": [ - "id", - "direction" - ] }, - "ApplicationGraphOutputResource": { - "type": "object", - "description": "Describes an output resource that comprises an application graph resource.", - "properties": { - "id": { - "type": "string", - "description": "The resource ID." - }, - "type": { - "type": "string", - "description": "The resource type." - }, - "name": { - "type": "string", - "description": "The resource name." + "/{rootScope}/providers/Radius.Core/terraformSettings/{terraformSettingsName}": { + "get": { + "operationId": "TerraformSettings_Get", + "tags": [ + "TerraformSettings" + ], + "description": "Get a TerraformSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "terraformSettingsName", + "in": "path", + "description": "Terraform settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/TerraformSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } } }, - "required": [ + "put": { + "operationId": "TerraformSettings_CreateOrUpdate", + "tags": [ + "TerraformSettings" + ], + "description": "Create a TerraformSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "terraformSettingsName", + "in": "path", + "description": "Terraform settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "resource", + "in": "body", + "description": "Resource create parameters.", + "required": true, + "schema": { + "$ref": "#/definitions/TerraformSettingsResource" + } + } + ], + "responses": { + "200": { + "description": "Resource 'TerraformSettingsResource' update operation succeeded", + "schema": { + "$ref": "#/definitions/TerraformSettingsResource" + } + }, + "201": { + "description": "Resource 'TerraformSettingsResource' create operation succeeded", + "schema": { + "$ref": "#/definitions/TerraformSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "patch": { + "operationId": "TerraformSettings_Update", + "tags": [ + "TerraformSettings" + ], + "description": "Update a TerraformSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "terraformSettingsName", + "in": "path", + "description": "Terraform settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "properties", + "in": "body", + "description": "The resource properties to be updated.", + "required": true, + "schema": { + "$ref": "#/definitions/TerraformSettingsResourceUpdate" + } + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "#/definitions/TerraformSettingsResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "operationId": "TerraformSettings_Delete", + "tags": [ + "TerraformSettings" + ], + "description": "Delete a TerraformSettingsResource", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "$ref": "#/parameters/RootScopeParameter" + }, + { + "name": "terraformSettingsName", + "in": "path", + "description": "Terraform settings resource name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + } + ], + "responses": { + "200": { + "description": "Resource deleted successfully." + }, + "204": { + "description": "Resource does not exist." + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + } + }, + "/providers/Radius.Core/operations": { + "get": { + "operationId": "Operations_List", + "tags": [ + "Operations" + ], + "description": "List the operations for the provider", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + } + ], + "responses": { + "200": { + "description": "Azure operation completed successfully.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/OperationListResult" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + } + } + }, + "definitions": { + "ApplicationGraphConnection": { + "type": "object", + "description": "Describes the connection between two resources.", + "properties": { + "id": { + "type": "string", + "description": "The resource ID " + }, + "direction": { + "$ref": "#/definitions/Direction", + "description": "The direction of the connection. 'Outbound' indicates this connection specifies the ID of the destination and 'Inbound' indicates indicates this connection specifies the ID of the source." + } + }, + "required": [ + "id", + "direction" + ] + }, + "ApplicationGraphOutputResource": { + "type": "object", + "description": "Describes an output resource that comprises an application graph resource.", + "properties": { + "id": { + "type": "string", + "description": "The resource ID." + }, + "type": { + "type": "string", + "description": "The resource type." + }, + "name": { + "type": "string", + "description": "The resource name." + } + }, + "required": [ "id", "type", "name" @@ -1044,6 +1474,151 @@ ], "x-ms-discriminator-value": "aci" }, + "BicepAuthenticationConfiguration": { + "type": "object", + "description": "Authentication configuration for Bicep registries.", + "properties": { + "registries": { + "type": "object", + "description": "Authentication entries keyed by registry hostname.", + "additionalProperties": { + "$ref": "#/definitions/BicepRegistryAuthentication" + } + } + } + }, + "BicepAwsIrsaAuthentication": { + "type": "object", + "description": "AWS IRSA configuration for a Bicep registry.", + "properties": { + "roleArn": { + "type": "string", + "description": "ARN of the AWS IAM role used for IRSA." + }, + "token": { + "$ref": "#/definitions/SecretReference", + "description": "Token credential for AWS IRSA authentication." + } + } + }, + "BicepAzureWorkloadIdentityAuthentication": { + "type": "object", + "description": "Azure Workload Identity configuration for a Bicep registry.", + "properties": { + "clientId": { + "type": "string", + "description": "Client ID used for Azure Workload Identity." + }, + "tenantId": { + "type": "string", + "description": "Tenant ID used for Azure Workload Identity." + }, + "token": { + "$ref": "#/definitions/SecretReference", + "description": "Token credential for Azure Workload Identity authentication." + } + } + }, + "BicepBasicAuthentication": { + "type": "object", + "description": "Basic authentication configuration for a Bicep registry.", + "properties": { + "username": { + "type": "string", + "description": "Username for basic authentication." + }, + "password": { + "$ref": "#/definitions/SecretReference", + "description": "Password credential for basic authentication." + } + } + }, + "BicepRegistryAuthentication": { + "type": "object", + "description": "Registry authentication options for a private Bicep registry.", + "properties": { + "basic": { + "$ref": "#/definitions/BicepBasicAuthentication", + "description": "Basic authentication settings for a registry." + }, + "azureWorkloadIdentity": { + "$ref": "#/definitions/BicepAzureWorkloadIdentityAuthentication", + "description": "Azure Workload Identity authentication settings for a registry." + }, + "awsIrsa": { + "$ref": "#/definitions/BicepAwsIrsaAuthentication", + "description": "AWS IRSA authentication settings for a registry." + } + } + }, + "BicepSettingsProperties": { + "type": "object", + "description": "Bicep settings properties.", + "properties": { + "provisioningState": { + "$ref": "#/definitions/ProvisioningState", + "description": "Provisioning state of the asynchronous operation.", + "readOnly": true + }, + "authentication": { + "$ref": "#/definitions/BicepAuthenticationConfiguration", + "description": "Authentication settings for private registries." + } + } + }, + "BicepSettingsResource": { + "type": "object", + "description": "Bicep settings resource.", + "properties": { + "properties": { + "$ref": "#/definitions/BicepSettingsProperties", + "description": "The resource-specific properties for this resource.", + "x-ms-client-flatten": true, + "x-ms-mutability": [ + "read", + "create" + ] + } + }, + "required": [ + "properties" + ], + "allOf": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/TrackedResource" + } + ] + }, + "BicepSettingsResourceListResult": { + "type": "object", + "description": "The response of a BicepSettingsResource list operation.", + "properties": { + "value": { + "type": "array", + "description": "The BicepSettingsResource items on this page", + "items": { + "$ref": "#/definitions/BicepSettingsResource" + } + }, + "nextLink": { + "type": "string", + "format": "uri", + "description": "The link to the next page of items" + } + }, + "required": [ + "value" + ] + }, + "BicepSettingsResourceUpdate": { + "type": "object", + "description": "Bicep settings resource.", + "allOf": [ + { + "$ref": "#/definitions/Azure.ResourceManager.CommonTypes.TrackedResourceUpdate" + } + ] + }, "Direction": { "type": "string", "description": "The direction of a connection.", @@ -1099,6 +1674,14 @@ "description": "The status of the asynchronous operation.", "readOnly": true }, + "terraformSettings": { + "type": "string", + "description": "Resource ID of the Terraform settings applied to this environment." + }, + "bicepSettings": { + "type": "string", + "description": "Resource ID of the Bicep settings applied to this environment." + }, "recipePacks": { "type": "array", "description": "List of Recipe Pack resource IDs linked to this environment.", @@ -1594,6 +2177,271 @@ "x-ms-identifiers": [] } } + }, + "SecretReference": { + "type": "object", + "description": "Reference to a secret stored in Radius.Security/secrets.", + "properties": { + "secretId": { + "type": "string", + "description": "Resource ID of the Radius.Security/secrets entry." + }, + "key": { + "type": "string", + "description": "Key within the secret to retrieve." + } + }, + "required": [ + "secretId", + "key" + ] + }, + "TerraformBackendConfiguration": { + "type": "object", + "description": "Terraform backend configuration matching the terraform block.", + "properties": { + "type": { + "type": "string", + "description": "Backend type (for example 'kubernetes')." + }, + "config": { + "type": "object", + "description": "Backend-specific configuration values.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type" + ] + }, + "TerraformCliConfiguration": { + "type": "object", + "description": "Terraform CLI configuration matching the terraformrc file.", + "properties": { + "providerInstallation": { + "$ref": "#/definitions/TerraformProviderInstallationConfiguration", + "description": "Provider installation configuration controlling how Terraform installs providers." + }, + "credentials": { + "type": "object", + "description": "Credentials keyed by registry or module source hostname.", + "additionalProperties": { + "$ref": "#/definitions/TerraformCredentialConfiguration" + } + } + } + }, + "TerraformCredentialConfiguration": { + "type": "object", + "description": "Credential configuration for Terraform provider or module sources.", + "properties": { + "token": { + "$ref": "#/definitions/SecretReference", + "description": "Token credential for Terraform Cloud/Enterprise authentication." + } + } + }, + "TerraformDirectConfiguration": { + "type": "object", + "description": "Direct installation configuration for Terraform providers.", + "properties": { + "include": { + "type": "array", + "description": "Provider addresses included when falling back to direct installation.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Provider addresses excluded from direct installation.", + "items": { + "type": "string" + } + } + } + }, + "TerraformLogLevel": { + "type": "string", + "description": "Terraform log verbosity levels.", + "enum": [ + "TRACE", + "DEBUG", + "INFO", + "WARN", + "ERROR", + "FATAL" + ], + "x-ms-enum": { + "name": "TerraformLogLevel", + "modelAsString": false, + "values": [ + { + "name": "trace", + "value": "TRACE" + }, + { + "name": "debug", + "value": "DEBUG" + }, + { + "name": "info", + "value": "INFO" + }, + { + "name": "warn", + "value": "WARN" + }, + { + "name": "error", + "value": "ERROR" + }, + { + "name": "fatal", + "value": "FATAL" + } + ] + } + }, + "TerraformLoggingConfiguration": { + "type": "object", + "description": "Logging options for Terraform executions.", + "properties": { + "level": { + "$ref": "#/definitions/TerraformLogLevel", + "description": "Terraform log verbosity (maps to TF_LOG)." + }, + "path": { + "type": "string", + "description": "Destination file path for Terraform logs (maps to TF_LOG_PATH)." + } + } + }, + "TerraformNetworkMirrorConfiguration": { + "type": "object", + "description": "Network mirror configuration for Terraform providers.", + "properties": { + "url": { + "type": "string", + "description": "Mirror URL used to download providers." + }, + "include": { + "type": "array", + "description": "Provider addresses included in this mirror.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Provider addresses excluded from this mirror.", + "items": { + "type": "string" + } + } + }, + "required": [ + "url" + ] + }, + "TerraformProviderInstallationConfiguration": { + "type": "object", + "description": "Provider installation options for Terraform.", + "properties": { + "networkMirror": { + "$ref": "#/definitions/TerraformNetworkMirrorConfiguration", + "description": "Network mirror configuration used to download providers." + }, + "direct": { + "$ref": "#/definitions/TerraformDirectConfiguration", + "description": "Direct installation rules controlling when Terraform reaches public registries." + } + } + }, + "TerraformSettingsProperties": { + "type": "object", + "description": "Terraform settings properties.", + "properties": { + "provisioningState": { + "$ref": "#/definitions/ProvisioningState", + "description": "Provisioning state of the asynchronous operation.", + "readOnly": true + }, + "terraformrc": { + "$ref": "#/definitions/TerraformCliConfiguration", + "description": "Terraform CLI configuration equivalent to the terraformrc file." + }, + "backend": { + "$ref": "#/definitions/TerraformBackendConfiguration", + "description": "Terraform backend configuration." + }, + "env": { + "type": "object", + "description": "Environment variables injected into the Terraform process.", + "additionalProperties": { + "type": "string" + } + }, + "logging": { + "$ref": "#/definitions/TerraformLoggingConfiguration", + "description": "Logging configuration applied to Terraform executions." + } + } + }, + "TerraformSettingsResource": { + "type": "object", + "description": "Terraform settings resource.", + "properties": { + "properties": { + "$ref": "#/definitions/TerraformSettingsProperties", + "description": "The resource-specific properties for this resource.", + "x-ms-client-flatten": true, + "x-ms-mutability": [ + "read", + "create" + ] + } + }, + "required": [ + "properties" + ], + "allOf": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/TrackedResource" + } + ] + }, + "TerraformSettingsResourceListResult": { + "type": "object", + "description": "The response of a TerraformSettingsResource list operation.", + "properties": { + "value": { + "type": "array", + "description": "The TerraformSettingsResource items on this page", + "items": { + "$ref": "#/definitions/TerraformSettingsResource" + } + }, + "nextLink": { + "type": "string", + "format": "uri", + "description": "The link to the next page of items" + } + }, + "required": [ + "value" + ] + }, + "TerraformSettingsResourceUpdate": { + "type": "object", + "description": "Terraform settings resource.", + "allOf": [ + { + "$ref": "#/definitions/Azure.ResourceManager.CommonTypes.TrackedResourceUpdate" + } + ] } }, "parameters": { diff --git a/typespec/Radius.Core/bicepSettings.tsp b/typespec/Radius.Core/bicepSettings.tsp new file mode 100644 index 0000000000..a53e8ebddb --- /dev/null +++ b/typespec/Radius.Core/bicepSettings.tsp @@ -0,0 +1,135 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "@typespec/rest"; +import "@typespec/versioning"; +import "@typespec/openapi"; +import "@azure-tools/typespec-autorest"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +import "../radius/v1/ucprootscope.tsp"; +import "../radius/v1/resources.tsp"; +import "../radius/v1/trackedresource.tsp"; + +using Azure.ResourceManager; +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Autorest; +using Azure.Core; +using OpenAPI; + +namespace Radius.Core; + +@doc("Bicep settings resource.") +model BicepSettingsResource + is TrackedResourceRequired { + @doc("Bicep settings resource name.") + @key("bicepSettingsName") + @path + @segment("bicepSettings") + name: ResourceNameString; +} + +@doc("Bicep settings properties.") +model BicepSettingsProperties { + @doc("Provisioning state of the asynchronous operation.") + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; + + @doc("Authentication settings for private registries.") + authentication?: BicepAuthenticationConfiguration; +} + +@doc("Authentication configuration for Bicep registries.") +model BicepAuthenticationConfiguration { + @doc("Authentication entries keyed by registry hostname.") + registries?: Record; +} + +@doc("Registry authentication options for a private Bicep registry.") +model BicepRegistryAuthentication { + @doc("Basic authentication settings for a registry.") + basic?: BicepBasicAuthentication; + + @doc("Azure Workload Identity authentication settings for a registry.") + azureWorkloadIdentity?: BicepAzureWorkloadIdentityAuthentication; + + @doc("AWS IRSA authentication settings for a registry.") + awsIrsa?: BicepAwsIrsaAuthentication; +} + +@doc("Basic authentication configuration for a Bicep registry.") +model BicepBasicAuthentication { + @doc("Username for basic authentication.") + username?: string; + + @doc("Password credential for basic authentication.") + password?: SecretReference; +} + +@doc("Azure Workload Identity configuration for a Bicep registry.") +model BicepAzureWorkloadIdentityAuthentication { + @doc("Client ID used for Azure Workload Identity.") + clientId?: string; + + @doc("Tenant ID used for Azure Workload Identity.") + tenantId?: string; + + @doc("Token credential for Azure Workload Identity authentication.") + token?: SecretReference; +} + +@doc("AWS IRSA configuration for a Bicep registry.") +model BicepAwsIrsaAuthentication { + @doc("ARN of the AWS IAM role used for IRSA.") + roleArn?: string; + + @doc("Token credential for AWS IRSA authentication.") + token?: SecretReference; +} + +@armResourceOperations +interface BicepSettings { + get is ArmResourceRead< + BicepSettingsResource, + UCPBaseParameters + >; + + createOrUpdate is ArmResourceCreateOrReplaceSync< + BicepSettingsResource, + UCPBaseParameters + >; + + update is ArmResourcePatchSync< + BicepSettingsResource, + BicepSettingsProperties, + UCPBaseParameters + >; + + delete is ArmResourceDeleteSync< + BicepSettingsResource, + UCPBaseParameters + >; + + listByScope is ArmResourceListByParent< + BicepSettingsResource, + UCPBaseParameters, + "Scope", + "Scope" + >; +} diff --git a/typespec/Radius.Core/environments.tsp b/typespec/Radius.Core/environments.tsp index fb54e66122..b71d0483b8 100644 --- a/typespec/Radius.Core/environments.tsp +++ b/typespec/Radius.Core/environments.tsp @@ -51,6 +51,12 @@ model EnvironmentProperties { @visibility(Lifecycle.Read) provisioningState?: ProvisioningState; + @doc("Resource ID of the Terraform settings applied to this environment.") + terraformSettings?: string; + + @doc("Resource ID of the Bicep settings applied to this environment.") + bicepSettings?: string; + @doc("List of Recipe Pack resource IDs linked to this environment.") recipePacks?: string[]; diff --git a/typespec/Radius.Core/main.tsp b/typespec/Radius.Core/main.tsp index e09ad9da07..fe95d45c8f 100644 --- a/typespec/Radius.Core/main.tsp +++ b/typespec/Radius.Core/main.tsp @@ -10,6 +10,8 @@ import "../radius/v1/resources.tsp"; import "../radius/v1/trackedresource.tsp"; import "./applications.tsp"; import "./environments.tsp"; +import "./terraformSettings.tsp"; +import "./bicepSettings.tsp"; import "./recipePacks.tsp"; using Azure.ResourceManager; diff --git a/typespec/Radius.Core/terraformSettings.tsp b/typespec/Radius.Core/terraformSettings.tsp new file mode 100644 index 0000000000..dc8075283e --- /dev/null +++ b/typespec/Radius.Core/terraformSettings.tsp @@ -0,0 +1,178 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "@typespec/rest"; +import "@typespec/versioning"; +import "@typespec/openapi"; +import "@azure-tools/typespec-autorest"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; + +import "../radius/v1/ucprootscope.tsp"; +import "../radius/v1/resources.tsp"; +import "../radius/v1/trackedresource.tsp"; + +using Azure.ResourceManager; +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Autorest; +using Azure.Core; +using OpenAPI; + +namespace Radius.Core; + +@doc("Terraform settings resource.") +model TerraformSettingsResource + is TrackedResourceRequired { + @doc("Terraform settings resource name.") + @key("terraformSettingsName") + @path + @segment("terraformSettings") + name: ResourceNameString; +} + +@doc("Terraform settings properties.") +model TerraformSettingsProperties { + @doc("Provisioning state of the asynchronous operation.") + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; + + @doc("Terraform CLI configuration equivalent to the terraformrc file.") + terraformrc?: TerraformCliConfiguration; + + @doc("Terraform backend configuration.") + backend?: TerraformBackendConfiguration; + + @doc("Environment variables injected into the Terraform process.") + env?: Record; + + @doc("Logging configuration applied to Terraform executions.") + logging?: TerraformLoggingConfiguration; +} + +@doc("Terraform CLI configuration matching the terraformrc file.") +model TerraformCliConfiguration { + @doc("Provider installation configuration controlling how Terraform installs providers.") + providerInstallation?: TerraformProviderInstallationConfiguration; + + @doc("Credentials keyed by registry or module source hostname.") + credentials?: Record; +} + +@doc("Provider installation options for Terraform.") +model TerraformProviderInstallationConfiguration { + @doc("Network mirror configuration used to download providers.") + networkMirror?: TerraformNetworkMirrorConfiguration; + + @doc("Direct installation rules controlling when Terraform reaches public registries.") + direct?: TerraformDirectConfiguration; +} + +@doc("Network mirror configuration for Terraform providers.") +model TerraformNetworkMirrorConfiguration { + @doc("Mirror URL used to download providers.") + url: string; + + @doc("Provider addresses included in this mirror.") + include?: string[]; + + @doc("Provider addresses excluded from this mirror.") + exclude?: string[]; +} + +@doc("Direct installation configuration for Terraform providers.") +model TerraformDirectConfiguration { + @doc("Provider addresses included when falling back to direct installation.") + include?: string[]; + + @doc("Provider addresses excluded from direct installation.") + exclude?: string[]; +} + +@doc("Reference to a secret stored in Radius.Security/secrets.") +model SecretReference { + @doc("Resource ID of the Radius.Security/secrets entry.") + secretId: string; + + @doc("Key within the secret to retrieve.") + key: string; +} + +@doc("Credential configuration for Terraform provider or module sources.") +model TerraformCredentialConfiguration { + @doc("Token credential for Terraform Cloud/Enterprise authentication.") + token?: SecretReference; +} + +@doc("Terraform backend configuration matching the terraform block.") +model TerraformBackendConfiguration { + @doc("Backend type (for example 'kubernetes').") + type: string; + + @doc("Backend-specific configuration values.") + config?: Record; +} + +@doc("Logging options for Terraform executions.") +model TerraformLoggingConfiguration { + @doc("Terraform log verbosity (maps to TF_LOG).") + level?: TerraformLogLevel; + + @doc("Destination file path for Terraform logs (maps to TF_LOG_PATH).") + path?: string; +} + +@doc("Terraform log verbosity levels.") +enum TerraformLogLevel { + trace: "TRACE", + debug: "DEBUG", + info: "INFO", + warn: "WARN", + error: "ERROR", + fatal: "FATAL", +} + +@armResourceOperations +interface TerraformSettings { + get is ArmResourceRead< + TerraformSettingsResource, + UCPBaseParameters + >; + + createOrUpdate is ArmResourceCreateOrReplaceSync< + TerraformSettingsResource, + UCPBaseParameters + >; + + update is ArmResourcePatchSync< + TerraformSettingsResource, + TerraformSettingsProperties, + UCPBaseParameters + >; + + delete is ArmResourceDeleteSync< + TerraformSettingsResource, + UCPBaseParameters + >; + + listByScope is ArmResourceListByParent< + TerraformSettingsResource, + UCPBaseParameters, + "Scope", + "Scope" + >; +}