diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index 0413ab5d23..ac24ba485d 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -80,6 +80,11 @@ import ( "github.com/radius-project/radius/pkg/cli/cmd/rollback" rollback_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/rollback/kubernetes" "github.com/radius-project/radius/pkg/cli/cmd/run" + "github.com/radius-project/radius/pkg/cli/cmd/terraform" + terraform_install "github.com/radius-project/radius/pkg/cli/cmd/terraform/install" + terraform_list "github.com/radius-project/radius/pkg/cli/cmd/terraform/list" + terraform_status "github.com/radius-project/radius/pkg/cli/cmd/terraform/status" + terraform_uninstall "github.com/radius-project/radius/pkg/cli/cmd/terraform/uninstall" "github.com/radius-project/radius/pkg/cli/cmd/uninstall" uninstall_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/uninstall/kubernetes" "github.com/radius-project/radius/pkg/cli/cmd/upgrade" @@ -452,6 +457,21 @@ func initSubCommands() { versionCmd, _ := version.NewCommand(framework) RootCmd.AddCommand(versionCmd) + + terraformCmd := terraform.NewCommand() + RootCmd.AddCommand(terraformCmd) + + terraformInstallCmd, _ := terraform_install.NewCommand(framework) + terraformCmd.AddCommand(terraformInstallCmd) + + terraformUninstallCmd, _ := terraform_uninstall.NewCommand(framework) + terraformCmd.AddCommand(terraformUninstallCmd) + + terraformStatusCmd, _ := terraform_status.NewCommand(framework) + terraformCmd.AddCommand(terraformStatusCmd) + + terraformListCmd, _ := terraform_list.NewCommand(framework) + terraformCmd.AddCommand(terraformListCmd) } // The dance we do with config is kinda complex. We want commands to be able to retrieve a config (*viper.Viper) diff --git a/pkg/cli/cmd/terraform/common/client.go b/pkg/cli/cmd/terraform/common/client.go new file mode 100644 index 0000000000..962041cb8e --- /dev/null +++ b/pkg/cli/cmd/terraform/common/client.go @@ -0,0 +1,176 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/terraform/installer" +) + +// VersionInfo represents a Terraform version for display purposes. +type VersionInfo struct { + Version string `json:"version"` + State string `json:"state"` + Health string `json:"health"` + InstalledAt time.Time `json:"installedAt"` + IsCurrent bool `json:"isCurrent"` +} + +// Client provides methods for interacting with the Terraform installer API. +type Client struct { + connection sdk.Connection +} + +// NewClient creates a new installer client using the provided SDK connection. +func NewClient(connection sdk.Connection) *Client { + return &Client{connection: connection} +} + +// baseURL returns the installer API base URL. +func (c *Client) baseURL() string { + endpoint := strings.TrimSuffix(c.connection.Endpoint(), "/") + return endpoint + "/installer/terraform" +} + +// Install sends an install request to the installer API. +func (c *Client) Install(ctx context.Context, req installer.InstallRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal install request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/install", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create install request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send install request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseErrorResponse(resp) + } + + return nil +} + +// Uninstall sends an uninstall request to the installer API. +func (c *Client) Uninstall(ctx context.Context, req installer.UninstallRequest) error { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal uninstall request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/uninstall", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create uninstall request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send uninstall request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseErrorResponse(resp) + } + + return nil +} + +// Status retrieves the current installer status. +func (c *Client) Status(ctx context.Context) (*installer.StatusResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+"/status", nil) + if err != nil { + return nil, fmt.Errorf("failed to create status request: %w", err) + } + + resp, err := c.connection.Client().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send status request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, c.parseErrorResponse(resp) + } + + var status installer.StatusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("failed to decode status response: %w", err) + } + + return &status, nil +} + +// parseErrorResponse reads the error response body and returns an appropriate error. +func (c *Client) parseErrorResponse(resp *http.Response) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return clierrors.Message("Request failed with status %d", resp.StatusCode) + } + + bodyStr := strings.TrimSpace(string(body)) + if bodyStr == "" { + return clierrors.Message("Request failed with status %d", resp.StatusCode) + } + + return clierrors.Message("Request failed with status %d: %s", resp.StatusCode, bodyStr) +} + +// VersionsToList converts a versions map to a sorted slice for display. +// The current version is marked with IsCurrent=true. +func VersionsToList(versions map[string]installer.VersionStatus, currentVersion string) []VersionInfo { + if len(versions) == 0 { + return nil + } + + result := make([]VersionInfo, 0, len(versions)) + for _, vs := range versions { + result = append(result, VersionInfo{ + Version: vs.Version, + State: string(vs.State), + Health: string(vs.Health), + InstalledAt: vs.InstalledAt, + IsCurrent: vs.Version == currentVersion, + }) + } + + // Sort by version descending (newest first) + sort.Slice(result, func(i, j int) bool { + return result[i].Version > result[j].Version + }) + + return result +} diff --git a/pkg/cli/cmd/terraform/install/install.go b/pkg/cli/cmd/terraform/install/install.go new file mode 100644 index 0000000000..ced2986918 --- /dev/null +++ b/pkg/cli/cmd/terraform/install/install.go @@ -0,0 +1,342 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "context" + "os" + "time" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/spf13/cobra" +) + +const ( + // DefaultTimeout is the default timeout for waiting for installation to complete. + DefaultTimeout = 10 * time.Minute + + // DefaultPollInterval is the default interval for polling installation status. + DefaultPollInterval = 2 * time.Second +) + +// NewCommand creates an instance of the `rad terraform install` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "install", + Short: "Install Terraform for use with Radius recipes", + Long: "Install Terraform for use with Radius recipes. Terraform is downloaded and managed by Radius.", + Example: ` +# Install a specific version of Terraform +rad terraform install --version 1.6.4 + +# Install Terraform and wait for completion +rad terraform install --version 1.6.4 --wait + +# Install Terraform from a custom URL +rad terraform install --url https://example.com/terraform.zip + +# Install Terraform from a custom URL with checksum verification +rad terraform install --url https://example.com/terraform.zip --checksum sha256:abc123... + +# Install from a private registry with a custom CA bundle +rad terraform install --url https://internal.example.com/terraform.zip --ca-bundle /path/to/ca.pem + +# Install from a private registry with authentication +rad terraform install --url https://internal.example.com/terraform.zip --auth-header "Bearer " + +# Install from a private registry with mTLS client certificate +rad terraform install --url https://internal.example.com/terraform.zip --client-cert /path/to/cert.pem --client-key /path/to/key.pem + +# Install through a corporate proxy +rad terraform install --url https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip --proxy http://proxy.corp.com:8080 + +# Install with a custom timeout (when using --wait) +rad terraform install --version 1.6.4 --wait --timeout 15m +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + cmd.Flags().String("version", "", "The Terraform version to install (e.g., 1.6.4)") + cmd.Flags().String("url", "", "The URL to download Terraform from (alternative to --version)") + cmd.Flags().String("checksum", "", "The checksum to verify the download (format: sha256:)") + cmd.Flags().Bool("wait", false, "Wait for the installation to complete") + cmd.Flags().Duration("timeout", DefaultTimeout, "Timeout when waiting for installation (requires --wait)") + cmd.Flags().String("ca-bundle", "", "Path to a PEM-encoded CA bundle file for TLS verification with private registries") + cmd.Flags().String("auth-header", "", "HTTP Authorization header value (e.g., \"Bearer \" or \"Basic \")") + cmd.Flags().String("client-cert", "", "Path to a PEM-encoded client certificate for mTLS authentication") + cmd.Flags().String("client-key", "", "Path to a PEM-encoded client private key for mTLS authentication") + cmd.Flags().String("proxy", "", "HTTP/HTTPS proxy URL (e.g., \"http://proxy.corp.com:8080\")") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform install` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + + Version string + SourceURL string + Checksum string + Wait bool + Timeout time.Duration + CABundle string + AuthHeader string + ClientCert string + ClientKey string + ProxyURL string + PollInterval time.Duration +} + +// NewRunner creates a new instance of the `rad terraform install` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + PollInterval: DefaultPollInterval, + } +} + +// Validate runs validation for the `rad terraform install` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + r.Version, err = cmd.Flags().GetString("version") + if err != nil { + return err + } + + r.SourceURL, err = cmd.Flags().GetString("url") + if err != nil { + return err + } + + r.Checksum, err = cmd.Flags().GetString("checksum") + if err != nil { + return err + } + + r.Wait, err = cmd.Flags().GetBool("wait") + if err != nil { + return err + } + + r.Timeout, err = cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + + r.CABundle, err = cmd.Flags().GetString("ca-bundle") + if err != nil { + return err + } + + r.AuthHeader, err = cmd.Flags().GetString("auth-header") + if err != nil { + return err + } + + r.ClientCert, err = cmd.Flags().GetString("client-cert") + if err != nil { + return err + } + + r.ClientKey, err = cmd.Flags().GetString("client-key") + if err != nil { + return err + } + + r.ProxyURL, err = cmd.Flags().GetString("proxy") + if err != nil { + return err + } + + // Validate that at least one of --version or --url is provided + if r.Version == "" && r.SourceURL == "" { + return clierrors.Message("Either --version or --url must be specified.") + } + + // Validate that --version is required when using --wait (server generates a version hash from URL which cannot be predicted) + if r.Wait && r.Version == "" { + return clierrors.Message("--version is required when using --wait (the server generates a version hash from the URL which cannot be predicted).") + } + + // Validate that --timeout requires --wait + if cmd.Flags().Changed("timeout") && !r.Wait { + return clierrors.Message("--timeout requires --wait to be set.") + } + + // Validate that --ca-bundle requires --url (only makes sense for custom URLs) + if r.CABundle != "" && r.SourceURL == "" { + return clierrors.Message("--ca-bundle requires --url to be set.") + } + + // Validate that --auth-header requires --url + if r.AuthHeader != "" && r.SourceURL == "" { + return clierrors.Message("--auth-header requires --url to be set.") + } + + // Validate that --client-cert and --client-key must be used together + if (r.ClientCert != "" && r.ClientKey == "") || (r.ClientCert == "" && r.ClientKey != "") { + return clierrors.Message("--client-cert and --client-key must be specified together.") + } + + // Validate that --client-cert requires --url + if r.ClientCert != "" && r.SourceURL == "" { + return clierrors.Message("--client-cert requires --url to be set.") + } + + // Validate that --proxy requires --url + if r.ProxyURL != "" && r.SourceURL == "" { + return clierrors.Message("--proxy requires --url to be set.") + } + + return nil +} + +// Run runs the `rad terraform install` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + req := installer.InstallRequest{ + Version: r.Version, + SourceURL: r.SourceURL, + Checksum: r.Checksum, + AuthHeader: r.AuthHeader, + ProxyURL: r.ProxyURL, + } + + // Read CA bundle file if specified + if r.CABundle != "" { + caBytes, err := os.ReadFile(r.CABundle) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read CA bundle file %q.", r.CABundle) + } + req.CABundle = string(caBytes) + } + + // Read client certificate file if specified + if r.ClientCert != "" { + certBytes, err := os.ReadFile(r.ClientCert) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read client certificate file %q.", r.ClientCert) + } + req.ClientCert = string(certBytes) + } + + // Read client key file if specified + if r.ClientKey != "" { + keyBytes, err := os.ReadFile(r.ClientKey) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to read client key file %q.", r.ClientKey) + } + req.ClientKey = string(keyBytes) + } + + r.Output.LogInfo("Installing Terraform...") + + if err := client.Install(ctx, req); err != nil { + return err + } + + versionInfo := r.Version + if versionInfo == "" { + versionInfo = r.SourceURL + } + r.Output.LogInfo("Terraform install queued (version=%s)", versionInfo) + + if r.Wait { + return r.waitForInstallation(ctx, client) + } + + return nil +} + +// waitForInstallation polls the status endpoint until the installation completes or fails. +func (r *Runner) waitForInstallation(ctx context.Context, client *common.Client) error { + r.Output.LogInfo("Waiting for installation to complete...") + + deadline := time.Now().Add(r.Timeout) + pollInterval := r.PollInterval + + for { + if time.Now().After(deadline) { + return clierrors.Message("Timed out waiting for Terraform installation to complete.") + } + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // Check if the target version is installed + if vs, ok := status.Versions[r.Version]; ok { + switch vs.State { + case installer.VersionStateSucceeded: + if status.CurrentVersion == r.Version { + r.Output.LogInfo("Terraform %s installed successfully.", r.Version) + return nil + } + // Version succeeded but isn't current - this is an unexpected state. + // The server always sets current version when marking succeeded, so this + // indicates a bug or race condition. Return an error rather than polling forever. + return clierrors.Message("Terraform %s installed but not set as current version (current: %s). This may indicate a server-side issue.", r.Version, status.CurrentVersion) + case installer.VersionStateFailed: + if vs.LastError != "" { + return clierrors.Message("Terraform installation failed: %s", vs.LastError) + } + return clierrors.Message("Terraform installation failed.") + } + } + + // Check overall state for failures (e.g., server fails before populating version status) + if status.State == installer.ResponseStateFailed { + if status.LastError != "" { + return clierrors.Message("Terraform installation failed: %s", status.LastError) + } + return clierrors.Message("Terraform installation failed.") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + // Continue polling + } + } +} diff --git a/pkg/cli/cmd/terraform/install/install_test.go b/pkg/cli/cmd/terraform/install/install_test.go new file mode 100644 index 0000000000..73a6160343 --- /dev/null +++ b/pkg/cli/cmd/terraform/install/install_test.go @@ -0,0 +1,656 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package install + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Install with version", + Input: []string{"--version", "1.6.4"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Install with URL", + Input: []string{"--url", "https://example.com/terraform.zip"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Install with version and wait", + Input: []string{"--version", "1.6.4", "--wait"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - neither version nor URL", + Input: []string{}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - wait without version (URL only)", + Input: []string{"--url", "https://example.com/terraform.zip", "--wait"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - timeout without wait", + Input: []string{"--version", "1.6.4", "--timeout", "5m"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - ca-bundle with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--ca-bundle", "/path/to/ca.pem"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - ca-bundle without URL", + Input: []string{"--version", "1.6.4", "--ca-bundle", "/path/to/ca.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - auth-header with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--auth-header", "Bearer token123"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - auth-header without URL", + Input: []string{"--version", "1.6.4", "--auth-header", "Bearer token123"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - client-cert and client-key with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-cert", "/path/to/cert.pem", "--client-key", "/path/to/key.pem"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-cert without client-key", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-cert", "/path/to/cert.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-key without client-cert", + Input: []string{"--url", "https://example.com/terraform.zip", "--client-key", "/path/to/key.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - client-cert without URL", + Input: []string{"--version", "1.6.4", "--client-cert", "/path/to/cert.pem", "--client-key", "/path/to/key.pem"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - proxy with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--proxy", "http://proxy:8080"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - proxy without URL", + Input: []string{"--version", "1.6.4", "--proxy", "http://proxy:8080"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid - all options with URL", + Input: []string{"--url", "https://example.com/terraform.zip", "--ca-bundle", "/ca.pem", "--auth-header", "Bearer token", "--client-cert", "/cert.pem", "--client-key", "/key.pem", "--proxy", "http://proxy:8080"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success - Install without wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify output messages + require.True(t, len(outputSink.Writes) >= 2) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "Installing Terraform") + require.Contains(t, outputSink.Writes[1].(output.LogOutput).Format, "Terraform install queued") + }) + + t.Run("Success - Install with wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + var state installer.VersionState + var currentVersion string + if calls < 2 { + state = installer.VersionStateInstalling + currentVersion = "" + } else { + state = installer.VersionStateSucceeded + currentVersion = "1.6.4" + } + + statusResponse := installer.StatusResponse{ + CurrentVersion: currentVersion, + State: installer.ResponseStateReady, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: state, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify at least 2 status calls were made + require.GreaterOrEqual(t, statusCalls.Load(), int32(2)) + }) + + t.Run("Error - Install failed during wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateFailed, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateFailed, + LastError: "download failed", + }, + }, + LastError: "download failed", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "download failed") + }) + + t.Run("Error - Overall state failed without version status", func(t *testing.T) { + // Tests the case where the server fails before populating version status + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + "version": "1.6.4", + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Return failed state without populating version status + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateFailed, + Versions: nil, // No version status populated + LastError: "queue processing error", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "1.6.4", + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "queue processing error") + }) + + t.Run("Error - Server rejects install request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid version format")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Version: "invalid", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid version format") + }) + + t.Run("Success - Install with CA bundle", func(t *testing.T) { + // Create a temporary CA bundle file + tempDir := t.TempDir() + caFile := filepath.Join(tempDir, "ca.pem") + testCACert := `-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIUM06Yo/BKCPvBfZwztaJPszhAO98wDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDEyMTEwMjAzNVoXDTI3MDEyMTEw +MjAzNVowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA0wyOmcNaSz1AQHGNVmNzzkDO5VhUCv56KRybhLR/uXhapxQ4T+Rr +beMUExEaxyWDnTjsnirNUvwadBONWzm8cDQSW2KldbnzjteBRlNDbRI6TgKE0TRR +ljAM77Dczzuye2PsQS002Ny3UR+MnzI1kA3/XjAeAVefKn31Col0Ssn7OdvZ1VTH +aK04b2szaAla5Sl+eWKUsxj6UA/V/Xq94Z4AEnqk7zkGxnpILvxcz0QY/U/7e5iQ +IM/NkIeMoJe+Cfij+yPqLgh2f5L4Vi9WvRB8P0rbvl5WrEU6K6bjuZ5zKxiC+rbU +5hjAlR5lyrgo8cwiB5cOah+qQzl/3c26yQIDAQABo1MwUTAdBgNVHQ4EFgQU8/CI +UhXWPvHMCIynxKS4D+PQdy0wHwYDVR0jBBgwFoAU8/CIUhXWPvHMCIynxKS4D+PQ +dy0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAevFg7NV4D6UP +qYdvGjWgMFEUiUBp5EtEU5KD7FZwKop/lFqnvo+L1bUUy2hab76eO+g0perp8b8j +/ZwMgdIVNjNEWgM8h+Gg3HG8Rvdle5NqMq4lIGzmTN+MhPnQ8rECMSm0nVGTtFA0 +qE+O0LoSl/4FL9pUQuwZi+WibxoTOlw3NXpxx2WUFzU/Giwx6OYCTb773M9noKCH +7VAkvFImjSbr4SU05DGe+cUcWmtWcfhj2geiCHl/EEpe/oEi5/XnpgeMj4vkE6zK +fiCLJ0WJ77/ohDKnNecDZKIWLsUo9ywMJqi9TLSiBf5oMOc9uZtDoPTPzsXzcPZP +2JkLUbkliQ== +-----END CERTIFICATE-----` + err := os.WriteFile(caFile, []byte(testCACert), 0o644) + require.NoError(t, err) + + var receivedCABundle string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + // Capture the CA bundle from the request + var req installer.InstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + receivedCABundle = req.CABundle + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://internal.example.com/terraform.zip", + CABundle: caFile, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Verify CA bundle was sent to server + require.Equal(t, testCACert, receivedCABundle) + }) + + t.Run("Error - CA bundle file not found", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://internal.example.com/terraform.zip", + CABundle: "/nonexistent/path/to/ca.pem", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to read CA bundle file") + }) + + t.Run("Success - Install with URL and checksum (no CA bundle)", func(t *testing.T) { + var receivedReq installer.InstallRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/install" && r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&receivedReq) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "install enqueued", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + SourceURL: "https://example.com/terraform.zip", + Checksum: "sha256:abc123", + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify request contents + require.Equal(t, "https://example.com/terraform.zip", receivedReq.SourceURL) + require.Equal(t, "sha256:abc123", receivedReq.Checksum) + require.Empty(t, receivedReq.CABundle, "CABundle should be empty when not specified") + }) +} diff --git a/pkg/cli/cmd/terraform/list/list.go b/pkg/cli/cmd/terraform/list/list.go new file mode 100644 index 0000000000..c3d34c2001 --- /dev/null +++ b/pkg/cli/cmd/terraform/list/list.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package list + +import ( + "context" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform list` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "list", + Short: "List installed Terraform versions", + Long: "List all Terraform versions that have been installed, including their state and health status.", + Example: ` +# List all installed Terraform versions +rad terraform list + +# List versions in JSON format +rad terraform list --output json +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform list` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string +} + +// NewRunner creates a new instance of the `rad terraform list` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad terraform list` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + return nil +} + +// Run runs the `rad terraform list` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // Convert versions map to sorted slice for display + versions := common.VersionsToList(status.Versions, status.CurrentVersion) + + if len(versions) == 0 { + r.Output.LogInfo("No Terraform versions installed.") + return nil + } + + err = r.Output.WriteFormatted(r.Format, versions, versionsFormat()) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cli/cmd/terraform/list/objectformats.go b/pkg/cli/cmd/terraform/list/objectformats.go new file mode 100644 index 0000000000..4452220c8e --- /dev/null +++ b/pkg/cli/cmd/terraform/list/objectformats.go @@ -0,0 +1,84 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package list + +import ( + "strings" + + "github.com/radius-project/radius/pkg/cli/output" +) + +// versionsFormat returns the formatter options for displaying Terraform versions list. +func versionsFormat() output.FormatterOptions { + transformer := &versionTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "VERSION", + JSONPath: "{ .Version }", + Transformer: transformer, + }, + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: transformer, + }, + { + Heading: "HEALTH", + JSONPath: "{ .Health }", + Transformer: transformer, + }, + { + Heading: "INSTALLED AT", + JSONPath: "{ .InstalledAt }", + Transformer: transformer, + }, + { + Heading: "CURRENT", + JSONPath: "{ .IsCurrent }", + Transformer: ¤tTransformer{}, + }, + }, + } +} + +type versionTransformer struct{} + +func (*versionTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" || trimmed == "" || trimmed == "" { + return "-" + } + if trimmed == "0001-01-01T00:00:00Z" || trimmed == "\"0001-01-01T00:00:00Z\"" { + return "-" + } + // Strip surrounding quotes + if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + return trimmed[1 : len(trimmed)-1] + } + return input +} + +type currentTransformer struct{} + +func (*currentTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "true" { + return "*" + } + return "" +} diff --git a/pkg/cli/cmd/terraform/status/objectformats.go b/pkg/cli/cmd/terraform/status/objectformats.go new file mode 100644 index 0000000000..366398ff14 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/objectformats.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "strings" + + "github.com/radius-project/radius/pkg/cli/output" +) + +// statusFormat returns the formatter options for displaying Terraform installer status. +// Note: JSONPath uses Go struct field names (capitalized), not json tags. +// Shows essential columns only. Use --output json for full details. +func statusFormat() output.FormatterOptions { + noValue := &emptyIfNoValueTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: noValue, + }, + { + Heading: "VERSION", + JSONPath: "{ .CurrentVersion }", + Transformer: noValue, + }, + { + Heading: "LAST ERROR", + JSONPath: "{ .LastError }", + Transformer: noValue, + }, + { + Heading: "LAST UPDATED", + JSONPath: "{ .LastUpdated }", + Transformer: noValue, + }, + }, + } +} + +type emptyIfNoValueTransformer struct{} + +func (*emptyIfNoValueTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + // Handle various "no value" representations from JSONPath + if trimmed == "" || trimmed == "" || trimmed == "" { + return "-" + } + // Handle zero time values + if trimmed == "0001-01-01T00:00:00Z" || trimmed == "\"0001-01-01T00:00:00Z\"" { + return "-" + } + // Strip surrounding quotes from values (e.g., timestamps) + if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + return trimmed[1 : len(trimmed)-1] + } + return input +} + +// versionsFormat returns the formatter options for displaying Terraform versions list. +func versionsFormat() output.FormatterOptions { + transformer := &emptyIfNoValueTransformer{} + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "VERSION", + JSONPath: "{ .Version }", + Transformer: transformer, + }, + { + Heading: "STATE", + JSONPath: "{ .State }", + Transformer: transformer, + }, + { + Heading: "HEALTH", + JSONPath: "{ .Health }", + Transformer: transformer, + }, + { + Heading: "INSTALLED AT", + JSONPath: "{ .InstalledAt }", + Transformer: transformer, + }, + { + Heading: "CURRENT", + JSONPath: "{ .IsCurrent }", + Transformer: ¤tTransformer{}, + }, + }, + } +} + +type currentTransformer struct{} + +func (*currentTransformer) Transform(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "true" { + return "*" + } + return "" +} diff --git a/pkg/cli/cmd/terraform/status/objectformats_test.go b/pkg/cli/cmd/terraform/status/objectformats_test.go new file mode 100644 index 0000000000..52438eac12 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/objectformats_test.go @@ -0,0 +1,185 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "bytes" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/stretchr/testify/require" +) + +func Test_statusFormat(t *testing.T) { + format := statusFormat() + require.NotNil(t, format.Columns) + require.Len(t, format.Columns, 4) + + // Verify all expected columns are present (concise view, use --output json for full details) + columnHeadings := make([]string, len(format.Columns)) + for i, col := range format.Columns { + columnHeadings[i] = col.Heading + } + + expectedHeadings := []string{ + "STATE", + "VERSION", + "LAST ERROR", + "LAST UPDATED", + } + + require.Equal(t, expectedHeadings, columnHeadings) +} + +func Test_statusFormat_TableOutput(t *testing.T) { + // Test that the JSONPath expressions work with the actual struct + installedAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + lastUpdated := time.Date(2024, 1, 15, 10, 35, 0, 0, time.UTC) + + status := &installer.StatusResponse{ + State: installer.ResponseStateReady, + CurrentVersion: "1.6.4", + BinaryPath: "/terraform/versions/1.6.4/terraform", + InstalledAt: &installedAt, + Source: &installer.SourceInfo{ + URL: "https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip", + Checksum: "sha256:abc123", + }, + Queue: &installer.QueueInfo{ + Pending: 0, + }, + LastUpdated: lastUpdated, + } + + format := statusFormat() + formatter := &output.TableFormatter{} + buf := &bytes.Buffer{} + + err := formatter.Format(status, buf, format) + require.NoError(t, err) + + tableOutput := buf.String() + + // Verify key values appear in table output (concise view shows only essential columns) + require.Contains(t, tableOutput, "STATE") + require.Contains(t, tableOutput, "VERSION") + require.Contains(t, tableOutput, "ready") + require.Contains(t, tableOutput, "1.6.4") +} + +func Test_statusFormat_TableOutput_NotInstalled(t *testing.T) { + status := &installer.StatusResponse{ + State: installer.ResponseStateNotInstalled, + CurrentVersion: "", + BinaryPath: "", + InstalledAt: nil, + Source: nil, + Queue: nil, + LastUpdated: time.Time{}, + } + + format := statusFormat() + formatter := &output.TableFormatter{} + buf := &bytes.Buffer{} + + err := formatter.Format(status, buf, format) + require.NoError(t, err) + + tableOutput := buf.String() + require.Contains(t, tableOutput, "STATE") + require.NotContains(t, tableOutput, "") +} + +func Test_emptyIfNoValueTransformer(t *testing.T) { + transformer := &emptyIfNoValueTransformer{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "no value marker returns dash", + input: "", + expected: "-", + }, + { + name: "nil marker returns dash", + input: "", + expected: "-", + }, + { + name: "empty string returns dash", + input: "", + expected: "-", + }, + { + name: "whitespace only returns dash", + input: " ", + expected: "-", + }, + { + name: "zero time returns dash", + input: "0001-01-01T00:00:00Z", + expected: "-", + }, + { + name: "quoted zero time returns dash", + input: "\"0001-01-01T00:00:00Z\"", + expected: "-", + }, + { + name: "normal value is preserved", + input: "1.6.4", + expected: "1.6.4", + }, + { + name: "path value is preserved", + input: "/terraform/versions/1.6.4/terraform", + expected: "/terraform/versions/1.6.4/terraform", + }, + { + name: "state value is preserved", + input: "ready", + expected: "ready", + }, + { + name: "timestamp is preserved", + input: "2024-01-15T10:30:00Z", + expected: "2024-01-15T10:30:00Z", + }, + { + name: "quoted timestamp has quotes stripped", + input: "\"2024-01-15T10:30:00Z\"", + expected: "2024-01-15T10:30:00Z", + }, + { + name: "zero number is preserved", + input: "0", + expected: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := transformer.Transform(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cli/cmd/terraform/status/status.go b/pkg/cli/cmd/terraform/status/status.go new file mode 100644 index 0000000000..f10433ac02 --- /dev/null +++ b/pkg/cli/cmd/terraform/status/status.go @@ -0,0 +1,130 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform status` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "status", + Short: "Show Terraform installation status", + Long: "Show Terraform installation status, including the current version, state, and other details.", + Example: ` +# Show Terraform status +rad terraform status + +# Show Terraform status with all installed versions +rad terraform status --all + +# Show Terraform status in JSON format +rad terraform status --output json +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddOutputFlag(cmd) + cmd.Flags().BoolP("all", "a", false, "Show all installed versions") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform status` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string + ShowAll bool +} + +// NewRunner creates a new instance of the `rad terraform status` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad terraform status` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + showAll, err := cmd.Flags().GetBool("all") + if err != nil { + return err + } + r.ShowAll = showAll + + return nil +} + +// Run runs the `rad terraform status` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + status, err := client.Status(ctx) + if err != nil { + return err + } + + // If --all flag is set, show all versions instead of just current status + if r.ShowAll { + versions := common.VersionsToList(status.Versions, status.CurrentVersion) + if len(versions) == 0 { + r.Output.LogInfo("No Terraform versions installed.") + return nil + } + return r.Output.WriteFormatted(r.Format, versions, versionsFormat()) + } + + err = r.Output.WriteFormatted(r.Format, status, statusFormat()) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cli/cmd/terraform/status/status_test.go b/pkg/cli/cmd/terraform/status/status_test.go new file mode 100644 index 0000000000..2b8a3c6f5d --- /dev/null +++ b/pkg/cli/cmd/terraform/status/status_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package status + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Status Command", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Status Command with fallback workspace", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Status Command with too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success", func(t *testing.T) { + installedAt := time.Now().UTC() + lastUpdated := time.Now().UTC() + + statusResponse := installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + BinaryPath: "/terraform/versions/1.6.4/terraform", + InstalledAt: &installedAt, + Source: &installer.SourceInfo{ + URL: "https://releases.hashicorp.com/terraform/1.6.4/terraform_1.6.4_linux_amd64.zip", + Checksum: "sha256:abc123", + }, + Queue: &installer.QueueInfo{ + Pending: 0, + }, + LastUpdated: lastUpdated, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Format: "table", + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + require.Len(t, outputSink.Writes, 1) + formattedOutput, ok := outputSink.Writes[0].(output.FormattedOutput) + require.True(t, ok) + require.Equal(t, "table", formattedOutput.Format) + + // Verify the response was passed through + responseData, ok := formattedOutput.Obj.(*installer.StatusResponse) + require.True(t, ok) + require.Equal(t, "1.6.4", responseData.CurrentVersion) + require.Equal(t, installer.ResponseStateReady, responseData.State) + }) + + t.Run("Error - Server Error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Format: "table", + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "500") + }) +} diff --git a/pkg/cli/cmd/terraform/terraform.go b/pkg/cli/cmd/terraform/terraform.go new file mode 100644 index 0000000000..e1d08234ef --- /dev/null +++ b/pkg/cli/cmd/terraform/terraform.go @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraform + +import ( + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the `rad terraform` command. +func NewCommand() *cobra.Command { + // This command is not runnable, and thus has no runner. + cmd := &cobra.Command{ + Use: "terraform", + Short: "Manage Terraform installation for Radius", + Long: `Manage Terraform installation for Radius. Terraform is used by Radius to execute Terraform recipes. + +Use subcommands to install, uninstall, or check the status of Terraform.`, + } + + return cmd +} diff --git a/pkg/cli/cmd/terraform/uninstall/uninstall.go b/pkg/cli/cmd/terraform/uninstall/uninstall.go new file mode 100644 index 0000000000..7f5e212a83 --- /dev/null +++ b/pkg/cli/cmd/terraform/uninstall/uninstall.go @@ -0,0 +1,330 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package uninstall + +import ( + "context" + "strings" + "time" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/terraform/common" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/spf13/cobra" +) + +const ( + // DefaultTimeout is the default timeout for waiting for uninstallation to complete. + DefaultTimeout = 10 * time.Minute + + // DefaultPollInterval is the default interval for polling uninstallation status. + DefaultPollInterval = 2 * time.Second +) + +// NewCommand creates an instance of the `rad terraform uninstall` command and runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall Terraform from Radius", + Long: "Uninstall Terraform from Radius. This removes the currently installed Terraform binary.", + Example: ` +# Uninstall current Terraform version +rad terraform uninstall + +# Uninstall a specific version +rad terraform uninstall --version 1.6.3 + +# Uninstall all installed versions +rad terraform uninstall --all + +# Uninstall and remove version metadata (purge history) +rad terraform uninstall --purge + +# Uninstall all versions and purge all metadata +rad terraform uninstall --all --purge + +# Uninstall Terraform and wait for completion +rad terraform uninstall --wait + +# Uninstall with a custom timeout (when using --wait) +rad terraform uninstall --wait --timeout 5m +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + cmd.Flags().StringP("version", "v", "", "Specific version to uninstall") + cmd.Flags().Bool("all", false, "Uninstall all installed versions") + cmd.Flags().Bool("purge", false, "Remove version metadata from database (clears history)") + cmd.Flags().Bool("wait", false, "Wait for the uninstallation to complete") + cmd.Flags().Duration("timeout", DefaultTimeout, "Timeout when waiting for uninstallation (requires --wait)") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad terraform uninstall` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + + Version string + UninstallAll bool + Purge bool + Wait bool + Timeout time.Duration + PollInterval time.Duration +} + +// NewRunner creates a new instance of the `rad terraform uninstall` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + PollInterval: DefaultPollInterval, + } +} + +// Validate runs validation for the `rad terraform uninstall` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + r.Version, err = cmd.Flags().GetString("version") + if err != nil { + return err + } + + r.UninstallAll, err = cmd.Flags().GetBool("all") + if err != nil { + return err + } + + r.Purge, err = cmd.Flags().GetBool("purge") + if err != nil { + return err + } + + r.Wait, err = cmd.Flags().GetBool("wait") + if err != nil { + return err + } + + r.Timeout, err = cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + + // Validate that --timeout requires --wait + if cmd.Flags().Changed("timeout") && !r.Wait { + return clierrors.Message("--timeout requires --wait to be set.") + } + + // Validate that --version and --all are mutually exclusive + if r.Version != "" && r.UninstallAll { + return clierrors.Message("--version and --all cannot be used together.") + } + + // Validate that --wait cannot be used with --all (would need complex tracking) + if r.UninstallAll && r.Wait { + return clierrors.Message("--wait cannot be used with --all.") + } + + return nil +} + +// Run runs the `rad terraform uninstall` command. +func (r *Runner) Run(ctx context.Context) error { + connection, err := r.Workspace.Connect(ctx) + if err != nil { + return err + } + + client := common.NewClient(connection) + + // Handle --all flag: uninstall all versions + if r.UninstallAll { + return r.uninstallAll(ctx, client) + } + + // Get current version before uninstalling so we can track its state + var priorVersion string + if r.Wait || r.Version == "" { + status, err := client.Status(ctx) + if err != nil { + return err + } + priorVersion = status.CurrentVersion + if priorVersion == "" && r.Version == "" { + r.Output.LogInfo("No Terraform version is currently installed.") + return nil + } + } + + r.Output.LogInfo("Uninstalling Terraform...") + + // Send uninstall request + req := installer.UninstallRequest{ + Version: r.Version, // Empty string means uninstall current version + Purge: r.Purge, + } + if err := client.Uninstall(ctx, req); err != nil { + return err + } + + if r.Version != "" { + r.Output.LogInfo("Terraform uninstall queued (version=%s).", r.Version) + } else { + r.Output.LogInfo("Terraform uninstall queued.") + } + + if r.Wait { + return r.waitForUninstallation(ctx, client, priorVersion) + } + + return nil +} + +// uninstallAll uninstalls all installed Terraform versions. +func (r *Runner) uninstallAll(ctx context.Context, client *common.Client) error { + status, err := client.Status(ctx) + if err != nil { + return err + } + + if len(status.Versions) == 0 { + r.Output.LogInfo("No Terraform versions to process.") + return nil + } + + if r.Purge { + r.Output.LogInfo("Purging all Terraform versions...") + } else { + r.Output.LogInfo("Uninstalling all Terraform versions...") + } + + // Process each version + processCount := 0 + for version, vs := range status.Versions { + // Skip versions that are already uninstalled or failed (unless purging) + if !r.Purge && (vs.State == installer.VersionStateUninstalled || vs.State == installer.VersionStateFailed) { + continue + } + + req := installer.UninstallRequest{Version: version, Purge: r.Purge} + if err := client.Uninstall(ctx, req); err != nil { + r.Output.LogInfo("Failed to queue uninstall for version %s: %s", version, err) + continue + } + if r.Purge { + r.Output.LogInfo("Queued purge for version %s", version) + } else { + r.Output.LogInfo("Queued uninstall for version %s", version) + } + processCount++ + } + + if processCount == 0 { + r.Output.LogInfo("No versions to process.") + } else { + r.Output.LogInfo("All requests queued.") + } + return nil +} + +// waitForUninstallation polls the status endpoint until the uninstallation completes or fails. +// Success is defined as CurrentVersion being empty (no Terraform installed). +func (r *Runner) waitForUninstallation(ctx context.Context, client *common.Client, priorVersion string) error { + r.Output.LogInfo("Waiting for uninstallation to complete...") + + deadline := time.Now().Add(r.Timeout) + pollInterval := r.PollInterval + + for { + if time.Now().After(deadline) { + return clierrors.Message("Timed out waiting for Terraform uninstallation to complete.") + } + + status, err := client.Status(ctx) + if err != nil { + return err + } + + if status.Queue != nil && status.Queue.InProgress != nil { + op := operationFromQueue(*status.Queue.InProgress) + if op == installer.OperationInstall { + return clierrors.Message("Terraform install in progress; uninstall wait requires no Terraform installed.") + } + } + + // Success: no current version installed + if status.CurrentVersion == "" { + r.Output.LogInfo("Terraform uninstalled successfully.") + return nil + } + + if priorVersion != "" && status.CurrentVersion != priorVersion { + return clierrors.Message("Terraform version %s is now installed; uninstall wait requires no Terraform installed.", status.CurrentVersion) + } + + // Check if the prior version uninstall failed + if priorVersion != "" { + if vs, ok := status.Versions[priorVersion]; ok { + if vs.State == installer.VersionStateFailed { + if vs.LastError != "" { + return clierrors.Message("Terraform uninstallation failed: %s", vs.LastError) + } + return clierrors.Message("Terraform uninstallation failed.") + } + } + } + + // Check overall state for failures + if status.State == installer.ResponseStateFailed { + if status.LastError != "" { + return clierrors.Message("Terraform uninstallation failed: %s", status.LastError) + } + return clierrors.Message("Terraform uninstallation failed.") + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + // Continue polling + } + } +} + +func operationFromQueue(inProgress string) installer.Operation { + parts := strings.SplitN(inProgress, ":", 2) + if len(parts) == 0 { + return "" + } + return installer.Operation(parts[0]) +} diff --git a/pkg/cli/cmd/terraform/uninstall/uninstall_test.go b/pkg/cli/cmd/terraform/uninstall/uninstall_test.go new file mode 100644 index 0000000000..d58585caef --- /dev/null +++ b/pkg/cli/cmd/terraform/uninstall/uninstall_test.go @@ -0,0 +1,571 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package uninstall + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/terraform/installer" + "github.com/radius-project/radius/test/radcli" + "github.com/stretchr/testify/require" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + testcases := []radcli.ValidateInput{ + { + Name: "Valid Uninstall Command", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Uninstall with wait", + Input: []string{"--wait"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Valid Uninstall with wait and timeout", + Input: []string{"--wait", "--timeout", "5m"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - timeout without wait", + Input: []string{"--timeout", "5m"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Invalid - too many args", + Input: []string{"extra-arg"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Success - Uninstall without wait", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Status is now fetched to check if there's a current version + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify output messages + require.True(t, len(outputSink.Writes) >= 2) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "Uninstalling Terraform") + require.Contains(t, outputSink.Writes[1].(output.LogOutput).Format, "Terraform uninstall queued") + }) + + t.Run("Success - Uninstall with wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var currentVersion string + var versions map[string]installer.VersionStatus + + if calls <= 1 { + // First call (before uninstall request) - return current version + currentVersion = "1.6.4" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + } + } else if calls == 2 { + // Second call - still uninstalling + currentVersion = "1.6.4" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalling, + }, + } + } else { + // Third call and beyond - uninstalled + currentVersion = "" + versions = map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalled, + }, + } + } + + statusResponse := installer.StatusResponse{ + CurrentVersion: currentVersion, + Versions: versions, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Verify status calls were made + require.GreaterOrEqual(t, statusCalls.Load(), int32(3)) + }) + + t.Run("Success - No current version installed", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + statusResponse := installer.StatusResponse{ + CurrentVersion: "", + State: installer.ResponseStateNotInstalled, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Should indicate no version is installed + require.True(t, len(outputSink.Writes) >= 1) + require.Contains(t, outputSink.Writes[0].(output.LogOutput).Format, "No Terraform version is currently installed") + }) + + t.Run("Error - Current version changed during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + statusResponse := installer.StatusResponse{} + if calls == 1 { + // Before uninstall, current version is 1.6.4 + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + "1.5.0": { + Version: "1.5.0", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + // After uninstall, previous version is promoted + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.5.0", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateUninstalled, + }, + "1.5.0": { + Version: "1.5.0", + State: installer.VersionStateSucceeded, + }, + }, + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "now installed") + }) + + t.Run("Error - Install in progress during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + statusResponse := installer.StatusResponse{} + if calls == 1 { + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + inProgress := "install:1.7.0" + statusResponse = installer.StatusResponse{ + CurrentVersion: "", + Queue: &installer.QueueInfo{ + Pending: 0, + InProgress: &inProgress, + }, + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "install in progress") + }) + + t.Run("Error - Uninstall failed during wait", func(t *testing.T) { + var statusCalls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + calls := statusCalls.Add(1) + + var statusResponse installer.StatusResponse + if calls <= 1 { + // First call (before uninstall) - return current version + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateSucceeded, + }, + }, + } + } else { + // Subsequent calls - return failed state + statusResponse = installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateFailed, + Versions: map[string]installer.VersionStatus{ + "1.6.4": { + Version: "1.6.4", + State: installer.VersionStateFailed, + LastError: "terraform in use", + }, + }, + LastError: "terraform in use", + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(statusResponse) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "uninstall enqueued", + "version": "1.6.4", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: true, + Timeout: 10 * time.Second, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "terraform in use") + }) + + t.Run("Error - Server rejects uninstall request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/status" && r.Method == http.MethodGet: + // Status is now fetched to check if there's a current version + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(installer.StatusResponse{ + CurrentVersion: "1.6.4", + State: installer.ResponseStateReady, + }) + case r.URL.Path == "/apis/api.ucp.dev/v1alpha3/installer/terraform/uninstall" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("uninstall rejected by server")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": "my-context", + "overrides": map[string]any{ + "ucp": server.URL, + }, + }, + } + + outputSink := &output.MockOutput{} + + runner := &Runner{ + Output: outputSink, + Workspace: workspace, + Wait: false, + PollInterval: 10 * time.Millisecond, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "uninstall rejected by server") + }) +} diff --git a/pkg/terraform/installer/handler.go b/pkg/terraform/installer/handler.go index 0681d3c9d4..545a1b030f 100644 --- a/pkg/terraform/installer/handler.go +++ b/pkg/terraform/installer/handler.go @@ -158,7 +158,7 @@ func (h *Handler) handleInstall(ctx context.Context, job *JobMessage) error { } targetDir := h.versionDir(job.Version) - if err := os.MkdirAll(targetDir, 0o755); err != nil { + if err := os.MkdirAll(targetDir, 0o750); err != nil { return fmt.Errorf("failed to create target dir: %w", err) } @@ -189,7 +189,9 @@ func (h *Handler) handleInstall(ctx context.Context, job *JobMessage) error { log.V(1).Info("failed to remove download archive", "path", archivePath, "error", err) } - if err := os.Chmod(binaryPath, 0o755); err != nil { + // Use 0o700 for executable - only owner needs access. Gosec recommends 0o600 but + // executables require the execute bit to function. + if err := os.Chmod(binaryPath, 0o700); err != nil { chmodErr := fmt.Errorf("failed to chmod terraform binary: %w", err) _ = h.recordFailure(ctx, status, job.Version, chmodErr) return chmodErr @@ -383,6 +385,8 @@ type downloadOptions struct { } func (h *Handler) download(ctx context.Context, opts *downloadOptions) error { + log := ucplog.FromContextOrDiscard(ctx) + // Validate URL scheme to prevent file://, ftp://, or other potentially dangerous schemes parsedURL, err := url.Parse(opts.URL) if err != nil { @@ -437,10 +441,16 @@ func (h *Handler) download(ctx context.Context, opts *downloadOptions) error { if err != nil { return err } - // Cleanup temp file on any error; os.Remove will no-op if file was renamed. + // Cleanup temp file on any error; os.Remove will no-op if file was already renamed. defer func() { - out.Close() - os.Remove(tmp) // Safe: will fail silently if file was already renamed + if err := out.Close(); err != nil { + // Log but don't fail - main operation error is more important + log.V(1).Info("failed to close temp file during cleanup", "error", err) + } + if err := os.Remove(tmp); err != nil && !os.IsNotExist(err) { + // Log but don't fail - file may have been renamed successfully + log.V(1).Info("failed to remove temp file during cleanup", "error", err) + } }() hasher := newHasher(opts.Checksum) @@ -598,7 +608,14 @@ func (h *Handler) currentSymlinkPath() string { } func (h *Handler) versionDir(version string) string { - return filepath.Join(h.rootPath(), "versions", version) + // Version is validated by ValidateVersionForPath() before reaching here. + // safePath provides defense-in-depth against path traversal. + path, err := safePath(h.rootPath(), "versions", version) + if err != nil { + // This should never happen with validated version - indicates a bug + panic(fmt.Sprintf("versionDir: invalid path for version %q: %v", version, err)) + } + return path } func (h *Handler) versionBinaryPath(version string) string { @@ -616,6 +633,29 @@ func (h *Handler) rootPath() string { return h.RootPath } +// safePath constructs a path within root and validates it doesn't escape. +// This prevents path traversal attacks even if version validation is bypassed. +func safePath(root string, subpaths ...string) (string, error) { + // Clean the root first + root = filepath.Clean(root) + + // Join and clean the full path + parts := append([]string{root}, subpaths...) + full := filepath.Clean(filepath.Join(parts...)) + + // Verify the result is within root (has root as prefix) + // Use filepath.Rel to check - if result starts with "..", it escaped + rel, err := filepath.Rel(root, full) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("path escapes root directory") + } + + return full, nil +} + func (h *Handler) defaultTerraformURL(version string) string { base := strings.TrimSuffix(h.BaseURL, "/") if base == "" { @@ -715,7 +755,8 @@ func copyFile(src, dst string) error { } defer in.Close() - return writeFile(in, dst, 0o755) + // Use 0o700 for executable - only owner needs access + return writeFile(in, dst, 0o700) } func writeFile(r io.Reader, dst string, perm os.FileMode) error { @@ -801,7 +842,8 @@ func (h *Handler) clearQueueInProgress(ctx context.Context) { } func (h *Handler) acquireLock() (*os.File, error) { - lockPath := filepath.Join(h.rootPath(), ".terraform-installer.lock") + // lockPath uses only trusted h.rootPath() and a constant filename - no user input + lockPath := filepath.Clean(filepath.Join(h.rootPath(), ".terraform-installer.lock")) f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) if err != nil { if os.IsExist(err) { @@ -824,5 +866,5 @@ func (h *Handler) releaseLock(log logr.Logger, f *os.File) { } func (h *Handler) ensureRoot() error { - return os.MkdirAll(h.rootPath(), 0o755) + return os.MkdirAll(h.rootPath(), 0o750) }