From b41ae3d42476c613aae80b8ab7fbd1a22412ef97 Mon Sep 17 00:00:00 2001 From: ytimocin Date: Tue, 25 Nov 2025 17:08:27 -0800 Subject: [PATCH] feat: Terraform controller for install/uninstall/status Signed-off-by: ytimocin --- .gitignore | 3 + build/configs/ucp.yaml | 4 + cmd/applications-rp/cmd/root.go | 10 +- deploy/Chart/tests/terraform_test.yaml | 124 ++ deploy/Chart/values.yaml | 7 + docs/ucp/readme.md | 23 +- docs/ucp/terraform/terraform-installer.md | 115 + pkg/armrpc/hostoptions/providerconfig.go | 3 + .../queue/queueprovider/provider.go | 11 + pkg/recipes/driver/terraform/terraform.go | 3 + pkg/recipes/terraform/doc.go | 50 + pkg/recipes/terraform/execute.go | 6 +- pkg/recipes/terraform/install.go | 169 +- pkg/recipes/terraform/install_test.go | 307 ++- pkg/recipes/terraform/types.go | 4 + pkg/server/apiservice.go | 43 +- pkg/terraform/installer/constants.go | 24 + pkg/terraform/installer/handler.go | 828 +++++++ pkg/terraform/installer/handler_test.go | 1906 +++++++++++++++++ pkg/terraform/installer/job.go | 37 + pkg/terraform/installer/queue_status.go | 32 + pkg/terraform/installer/routes.go | 342 +++ pkg/terraform/installer/status_store.go | 79 + pkg/terraform/installer/types.go | 247 +++ pkg/terraform/installer/validation.go | 57 + pkg/terraform/installer/validation_test.go | 113 + pkg/terraform/installer/worker.go | 223 ++ pkg/ucp/config.go | 3 + pkg/ucp/frontend/api/routes.go | 103 + pkg/ucp/frontend/api/routes_test.go | 59 + 30 files changed, 4883 insertions(+), 52 deletions(-) create mode 100644 deploy/Chart/tests/terraform_test.yaml create mode 100644 docs/ucp/terraform/terraform-installer.md create mode 100644 pkg/recipes/terraform/doc.go create mode 100644 pkg/terraform/installer/constants.go create mode 100644 pkg/terraform/installer/handler.go create mode 100644 pkg/terraform/installer/handler_test.go create mode 100644 pkg/terraform/installer/job.go create mode 100644 pkg/terraform/installer/queue_status.go create mode 100644 pkg/terraform/installer/routes.go create mode 100644 pkg/terraform/installer/status_store.go create mode 100644 pkg/terraform/installer/types.go create mode 100644 pkg/terraform/installer/validation.go create mode 100644 pkg/terraform/installer/validation_test.go create mode 100644 pkg/terraform/installer/worker.go 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/deploy/Chart/tests/terraform_test.yaml b/deploy/Chart/tests/terraform_test.yaml new file mode 100644 index 0000000000..eee59898d7 --- /dev/null +++ b/deploy/Chart/tests/terraform_test.yaml @@ -0,0 +1,124 @@ +suite: test terraform configuration +templates: + - rp/deployment.yaml + - rp/configmaps.yaml + - dynamic-rp/deployment.yaml +tests: + # applications-rp terraform volume tests + - it: should create emptyDir 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 + emptyDir: {} + 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 create emptyDir 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 + emptyDir: {} + 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 independent emptyDir volumes (pod-local storage) + - it: should use independent emptyDir volumes for each deployment + set: + global.terraform.enabled: true + rp.image: applications-rp + rp.tag: latest + dynamicrp.image: dynamic-rp + dynamicrp.tag: latest + asserts: + # applications-rp uses emptyDir + - contains: + path: spec.template.spec.volumes + content: + name: terraform + emptyDir: {} + template: rp/deployment.yaml + # dynamic-rp uses emptyDir + - contains: + path: spec.template.spec.volumes + content: + name: terraform + emptyDir: {} + template: dynamic-rp/deployment.yaml diff --git a/deploy/Chart/values.yaml b/deploy/Chart/values.yaml index 76e2d04859..9494eb8056 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/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/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/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..3ef54ff9bc 100644 --- a/pkg/recipes/terraform/install_test.go +++ b/pkg/recipes/terraform/install_test.go @@ -200,6 +200,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") + os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", globalTmpDir) + defer os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", oldGlobalEnv) + + oldInstallerEnv := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR") + os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir) + defer 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, 0755)) + + // 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(execPath) + require.NoError(t, err) + + installerBinaryPath := filepath.Join(versionsDir, "terraform") + require.NoError(t, os.WriteFile(installerBinaryPath, binaryData, 0755)) + + // 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, 0755)) + + // 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") + os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", globalTmpDir) + defer os.Setenv("TERRAFORM_TEST_GLOBAL_DIR", oldGlobalEnv) + + oldInstallerEnv := os.Getenv("TERRAFORM_TEST_INSTALLER_DIR") + os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir) + defer 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 + os.Unsetenv("TERRAFORM_TEST_INSTALLER_DIR") + tf, err := Install(ctx, installer, InstallOptions{RootDir: tmpDir, LogLevel: "ERROR"}) + require.NoError(t, err) + os.Setenv("TERRAFORM_TEST_INSTALLER_DIR", installerTmpDir) + + // Copy the downloaded binary to two simulated versions + execPath := tf.ExecPath() + binaryData, err := os.ReadFile(execPath) + require.NoError(t, err) + + // Create version 1.6.4 + version164Dir := filepath.Join(installerTmpDir, "versions", "1.6.4") + require.NoError(t, os.MkdirAll(version164Dir, 0755)) + binary164Path := filepath.Join(version164Dir, "terraform") + require.NoError(t, os.WriteFile(binary164Path, binaryData, 0755)) + + // Create version 1.7.0 + version170Dir := filepath.Join(installerTmpDir, "versions", "1.7.0") + require.NoError(t, os.MkdirAll(version170Dir, 0755)) + binary170Path := filepath.Join(version170Dir, "terraform") + require.NoError(t, os.WriteFile(binary170Path, binaryData, 0755)) + + // 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") + os.Unsetenv("TERRAFORM_TEST_GLOBAL_DIR") + 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(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, 0755)) + root1Binary := filepath.Join(root1VersionDir, "terraform") + require.NoError(t, os.WriteFile(root1Binary, binaryData, 0755)) + 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, 0755)) + root2Binary := filepath.Join(root2VersionDir, "terraform") + require.NoError(t, os.WriteFile(root2Binary, binaryData, 0755)) + 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 +552,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..0681d3c9d4 --- /dev/null +++ b/pkg/terraform/installer/handler.go @@ -0,0 +1,828 @@ +/* +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, 0o755); 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) + } + + if err := os.Chmod(binaryPath, 0o755); err != nil { + chmodErr := fmt.Errorf("failed to chmod terraform binary: %w", err) + _ = h.recordFailure(ctx, status, job.Version, chmodErr) + return chmodErr + } + + 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 { + // 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.DefaultClient + } + } + + 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 := 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 renamed. + defer func() { + out.Close() + os.Remove(tmp) // Safe: will fail silently if file was already renamed + }() + + 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(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 { + return filepath.Join(h.rootPath(), "versions", version) +} + +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 +} + +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 + } + + if err := writeFile(rc, targetPath, f.Mode()); 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(src) + if err != nil { + return err + } + defer in.Close() + + return writeFile(in, dst, 0o755) +} + +func writeFile(r io.Reader, dst string, perm os.FileMode) error { + tmp := 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 := 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(), 0o755) +} diff --git a/pkg/terraform/installer/handler_test.go b/pkg/terraform/installer/handler_test.go new file mode 100644 index 0000000000..15c5267156 --- /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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o755)) + + 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, 0o755)) + lock, err := os.OpenFile(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, 0o755)) + lock, err := os.OpenFile(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, 0o644)) + + 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, 0o644)) + + 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(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, 0o644)) + + 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(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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("existing binary"), 0o755)) + + // 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.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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir100, "terraform"), []byte("binary 1.0.0"), 0o755)) + + targetDir120 := filepath.Join(tempDir, "versions", "1.2.0") + require.NoError(t, os.MkdirAll(targetDir120, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir120, "terraform"), []byte("binary 1.2.0"), 0o755)) + + // 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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o755)) + + // 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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o755)) + + // 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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "terraform"), []byte("tf"), 0o755)) + + // 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(), 0o644)) + + err := extractZip(zipPath, targetPath) + require.NoError(t, err) + + content, err := os.ReadFile(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(), 0o644)) + + 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(), 0o644)) + + 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(), 0o644)) + + 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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "terraform"), []byte("tf-"+v), 0o755)) + } + + 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, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "terraform"), []byte("tf"), 0o755)) + + // 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(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(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 { }