From ac606f3fea4ffa1126a578504fa78e6513bb55dc Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Wed, 14 Jun 2023 18:28:45 +0800 Subject: [PATCH 1/2] add a workspace clone option to apply variable sets --- cmd/root.go | 8 +-- cmd/varsetsList.go | 74 +++++++++++++++++++++++++++ cmd/workspacesClone.go | 14 +++++- lib/client.go | 112 ++++++++++++++++++++++++++++++++++------- 4 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 cmd/varsetsList.go diff --git a/cmd/root.go b/cmd/root.go index eec4244..5a3af6b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,7 +20,6 @@ import ( "os" "strings" - "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -84,11 +83,8 @@ func initConfig() { viper.SetConfigFile(cfgFile) } else { // Find home directory. - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } + home, err := os.UserHomeDir() + cobra.CheckErr(err) // Search config in home directory with name ".tfc-ops" (without extension). viper.AddConfigPath(home) diff --git a/cmd/varsetsList.go b/cmd/varsetsList.go new file mode 100644 index 0000000..1697635 --- /dev/null +++ b/cmd/varsetsList.go @@ -0,0 +1,74 @@ +// Copyright © 2023 SIL International +// +// 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 cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/silinternational/tfc-ops/lib" +) + +var varsetsListCmd = &cobra.Command{ + Use: "list", + Short: "List Variable Sets", + Long: `List variable sets applied to a workspace`, + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + runVarsetsList() + }, +} + +func init() { + varsetsCmd.AddCommand(varsetsListCmd) + + varsetsListCmd.Flags().StringVarP(&workspace, "workspace", "w", "", + "Name of the Workspace in Terraform Cloud") + + varsetsListCmd.Flags().StringVar(&workspaceFilter, "workspace-filter", "", + "Partial workspace name to search across all workspaces") +} + +func runVarsetsList() { + if workspace == "" && workspaceFilter == "" { + errLog.Fatalln("Either --workspace or --workspace-filter must be specified.") + } + + var workspaces map[string]string + if workspace != "" { + w, err := lib.GetWorkspaceByName(organization, workspace) + if err != nil { + errLog.Fatalf("error getting workspace from Terraform: %s", err) + } + workspaces = map[string]string{w.ID: workspace} + } else { + workspaces = lib.FindWorkspaces(organization, workspaceFilter) + if len(workspaces) == 0 { + errLog.Fatalf("no workspaces match the filter '%s'", workspaceFilter) + } + } + + for id, name := range workspaces { + sets, err := lib.ListWorkspaceVariableSets(id) + if err != nil { + return + } + fmt.Printf("Variable sets applied to workspace %s:\n", name) + for _, set := range sets.Data { + fmt.Printf(" %s\n", set.Attributes.Name) + } + } +} diff --git a/cmd/workspacesClone.go b/cmd/workspacesClone.go index f60a8df..f854ac3 100644 --- a/cmd/workspacesClone.go +++ b/cmd/workspacesClone.go @@ -26,6 +26,7 @@ import ( var ( copyState bool copyVariables bool + applyVariableSets bool differentDestinationAccount bool newOrganization string sourceWorkspace string @@ -60,6 +61,7 @@ var cloneCmd = &cobra.Command{ NewVCSTokenID: newVCSTokenID, CopyState: copyState, CopyVariables: copyVariables, + ApplyVariableSets: applyVariableSets, DifferentDestinationAccount: differentDestinationAccount, } @@ -111,6 +113,12 @@ func init() { false, `optional (e.g. "-c=true") whether to copy the values of the Source Workspace variables.`, ) + cloneCmd.Flags().BoolVar( + &applyVariableSets, + "applyVariableSets", + false, + `optional, whether to apply the same variable sets to the new workspace (only for same-account clone).`, + ) cloneCmd.Flags().BoolVarP( &differentDestinationAccount, "differentDestinationAccount", @@ -137,8 +145,10 @@ func runClone(cfg cloner.CloneConfig) { fmt.Print("Info: ATLAS_TOKEN_DESTINATION is not set, using ATLAS_TOKEN for destination account.\n\n") } - fmt.Printf("clone called using %s, %s, %s, copyState: %t, copyVariables: %t, differentDestinationAccount: %t\n", - cfg.Organization, cfg.SourceWorkspace, cfg.NewWorkspace, cfg.CopyState, cfg.CopyVariables, cfg.DifferentDestinationAccount) + fmt.Printf("clone called using %s, %s, %s, copyState: %t, copyVariables: %t, "+ + "applyVariableSets: %t, differentDestinationAccount: %t\n", + cfg.Organization, cfg.SourceWorkspace, cfg.NewWorkspace, cfg.CopyState, cfg.CopyVariables, + cfg.ApplyVariableSets, cfg.DifferentDestinationAccount) sensitiveVars, err := cloner.CloneWorkspace(cfg) if err != nil { diff --git a/lib/client.go b/lib/client.go index ffa6261..427b2d6 100644 --- a/lib/client.go +++ b/lib/client.go @@ -52,6 +52,7 @@ type CloneConfig struct { AtlasTokenDestination string CopyState bool CopyVariables bool + ApplyVariableSets bool DifferentDestinationAccount bool } @@ -244,7 +245,7 @@ type WorkspaceUpdateParams struct { } // ConvertHCLVariable changes a TFVar struct in place by escaping -// the double quotes and line endings in the Value attribute +// the double quotes and line endings in the Value attribute func ConvertHCLVariable(tfVar *TFVar) { if !tfVar.Hcl { return @@ -615,12 +616,37 @@ func CreateWorkspace(oc OpsConfig, vcsTokenID string) (string, error) { return wsData.Data.ID, nil } +// CreateWorkspace2 makes a Terraform workspaces API call to create a workspace for a given organization, including +// setting up its VCS repo integration. Returns the properties of the new workspace. +func CreateWorkspace2(oc OpsConfig, vcsTokenID string) (Workspace, error) { + url := fmt.Sprintf( + baseURL+"/organizations/%s/workspaces", + oc.NewOrg, + ) + + postData := GetCreateWorkspacePayload(oc, vcsTokenID) + + resp := callAPI("POST", url, postData, nil) + + defer resp.Body.Close() + // bodyBytes, _ := ioutil.ReadAll(resp.Body) + // fmt.Println(string(bodyBytes)) + + var wsData WorkspaceJSON + + if err := json.NewDecoder(resp.Body).Decode(&wsData); err != nil { + return Workspace{}, fmt.Errorf("error getting created workspace data: %s\n", err) + } + return wsData.Data, nil +} + // RunTFInit ... -// - removes old terraform.tfstate files -// - runs terraform init with old versions -// - runs terraform init with new version +// - removes old terraform.tfstate files +// - runs terraform init with old versions +// - runs terraform init with new version +// // NOTE: This procedure can be used to copy/migrate a workspace's state to a new one. -// (see the -backend-config mention below and the backend.tf file in this repo) +// (see the -backend-config mention below and the backend.tf file in this repo) func RunTFInit(oc OpsConfig, tfToken, tfTokenDestination string) error { var tfInit string var err error @@ -696,11 +722,12 @@ func RunTFInit(oc OpsConfig, tfToken, tfTokenDestination string) error { } // CloneWorkspace gets the data, variables and team access data for an existing Terraform Cloud workspace -// and then creates a clone of it with the same data. +// and then creates a clone of it with the same data. +// // If the copyVariables param is set to true, then all the non-sensitive variable values will be added to the new -// workspace. Otherwise, they will be set to "REPLACE_THIS_VALUE" +// workspace. Otherwise, they will be set to "REPLACE_THIS_VALUE" func CloneWorkspace(cfg CloneConfig) ([]string, error) { - wsData, err := GetWorkspaceData(cfg.Organization, cfg.SourceWorkspace) + sourceWsData, err := GetWorkspaceData(cfg.Organization, cfg.SourceWorkspace) if err != nil { return []string{}, err } @@ -712,18 +739,18 @@ func CloneWorkspace(cfg CloneConfig) ([]string, error) { if !cfg.DifferentDestinationAccount { cfg.NewOrganization = cfg.Organization - cfg.NewVCSTokenID = wsData.Data.Attributes.VCSRepo.Identifier + cfg.NewVCSTokenID = sourceWsData.Data.Attributes.VCSRepo.Identifier } oc := OpsConfig{ SourceOrg: cfg.Organization, - SourceName: wsData.Data.Attributes.Name, + SourceName: sourceWsData.Data.Attributes.Name, NewOrg: cfg.NewOrganization, NewName: cfg.NewWorkspace, - TerraformVersion: wsData.Data.Attributes.TerraformVersion, - RepoID: wsData.Data.Attributes.VCSRepo.Identifier, - Branch: wsData.Data.Attributes.VCSRepo.Branch, - Directory: wsData.Data.Attributes.WorkingDirectory, + TerraformVersion: sourceWsData.Data.Attributes.TerraformVersion, + RepoID: sourceWsData.Data.Attributes.VCSRepo.Identifier, + Branch: sourceWsData.Data.Attributes.VCSRepo.Branch, + Directory: sourceWsData.Data.Attributes.WorkingDirectory, } sensitiveVars := []string{} @@ -773,11 +800,20 @@ func CloneWorkspace(cfg CloneConfig) ([]string, error) { return sensitiveVars, nil } - _, err = CreateWorkspace(oc, wsData.Data.Attributes.VCSRepo.TokenID) + destWsProps, err := CreateWorkspace2(oc, sourceWsData.Data.Attributes.VCSRepo.TokenID) + if err != nil { + return nil, fmt.Errorf("failed to create new workspace: %w", err) + } + + err = copyVariableSetList(sourceWsData.Data.ID, destWsProps.ID) + if err != nil { + return nil, fmt.Errorf("failed to clone variable sets: %w", err) + } + CreateAllVariables(oc.NewOrg, oc.NewName, tfVars) // Get Team Access Data for source Workspace - allTeamData, err := GetTeamAccessFrom(wsData.Data.ID) + allTeamData, err := GetTeamAccessFrom(sourceWsData.Data.ID) if err != nil { return sensitiveVars, err } @@ -795,7 +831,7 @@ func CloneWorkspace(cfg CloneConfig) ([]string, error) { // AddOrUpdateVariable adds or updates an existing Terraform Cloud workspace variable // If the copyVariables param is set to true, then all the non-sensitive variable values will be added to the new -// workspace. Otherwise, they will be set to "REPLACE_THIS_VALUE" +// workspace. Otherwise, they will be set to "REPLACE_THIS_VALUE" func AddOrUpdateVariable(cfg UpdateConfig) (string, error) { variables, err := GetVarsFromWorkspace(cfg.Organization, cfg.Workspace) if err != nil { @@ -1169,3 +1205,45 @@ func ApplyVariableSet(varsetID string, workspaceIDs []string) error { // TODO: need to look at response? return nil } + +func copyVariableSetList(sourceWorkspaceID, destinationWorkspaceID string) error { + sets, err := ListWorkspaceVariableSets(sourceWorkspaceID) + if err != nil { + return fmt.Errorf("copy variable sets: %w", err) + } + if err := ApplyVariableSetsToWorkspace(sets, destinationWorkspaceID); err != nil { + return fmt.Errorf("copy variable sets: %w", err) + } + return nil +} + +func ApplyVariableSetsToWorkspace(sets VariableSetList, workspaceID string) error { + var failed []string + var err error + for _, set := range sets.Data { + err = ApplyVariableSet(set.ID, []string{workspaceID}) + if err != nil { + failed = append(failed, set.Attributes.Name) + } + } + if len(failed) == len(sets.Data) { + return fmt.Errorf("failed to apply variable sets: %w", err) + } + if len(failed) > 0 { + return fmt.Errorf("failed to apply variable sets %s: %w", strings.Join(failed, ", "), err) + } + return nil +} + +func ListWorkspaceVariableSets(workspaceID string) (VariableSetList, error) { + u := NewTfcUrl(fmt.Sprintf("/workspaces/%s/varsets", workspaceID)) + + resp := callAPI(http.MethodGet, u.String(), "", nil) + + var variableSetList VariableSetList + if err := json.NewDecoder(resp.Body).Decode(&variableSetList); err != nil { + return variableSetList, fmt.Errorf("unexpected content retrieving variable set list: %w", err) + } + + return variableSetList, nil +} From f4aa0360a3d40a12ef11824c0d302e831f2fe2ab Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Thu, 15 Jun 2023 14:27:38 +0800 Subject: [PATCH 2/2] PR feedback: print workspace name in error; rephrase message --- cmd/varsetsList.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/varsetsList.go b/cmd/varsetsList.go index 1697635..0f5cf3d 100644 --- a/cmd/varsetsList.go +++ b/cmd/varsetsList.go @@ -51,7 +51,7 @@ func runVarsetsList() { if workspace != "" { w, err := lib.GetWorkspaceByName(organization, workspace) if err != nil { - errLog.Fatalf("error getting workspace from Terraform: %s", err) + errLog.Fatalf("error getting workspace %q from Terraform: %s", workspace, err) } workspaces = map[string]string{w.ID: workspace} } else { @@ -66,7 +66,7 @@ func runVarsetsList() { if err != nil { return } - fmt.Printf("Variable sets applied to workspace %s:\n", name) + fmt.Printf("Workspace %s has the following variable sets:\n", name) for _, set := range sets.Data { fmt.Printf(" %s\n", set.Attributes.Name) }