Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 4.0.0 #77

Merged
merged 15 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,34 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
-
name: Fetch all tags
run: git fetch --force --tags
-
name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Fetch all tags
run: git fetch --force --tags
-
name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
-
name: Run GoReleaser in snapshot mode
uses: goreleaser/goreleaser-action@v5
if: github.event.pull_request
with:
version: '~> v1'
args: release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
name: Run GoReleaser on a release tag
uses: goreleaser/goreleaser-action@v5
if: startsWith(github.ref, 'refs/tags/')
with:
version: '~> v1'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10 changes: 5 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
name: Test

on:
pull_request:
push:
branches: [ "**" ]

jobs:

build:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: 1.19
go-version-file: 'go.mod'

- name: Build
run: go build -v ./...
Expand Down
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
# Terraform Cloud Ops Tool

This application can be helpful in making copies/clones of a workspace and bringing its variables over
to the new one. It can also be used for listing or updating workspace attributes and listing or
modifying variables in workspaces.

## Required ENV vars
- `ATLAS_TOKEN` - Must be set as an environment variable. Get this by going to
https://app.terraform.io/app/settings/tokens and generating a new token.
- `ATLAS_TOKEN_DESTINATION` - Only necessary if cloning to a new organization in TF Cloud.

## Optional ENV vars
- `TFC_OPS_DEBUG` - Set to `true` to enable debug output

## Installation

There are three ways to download/install this script:

1. Download a pre-built binary for your operating system from the [Releases](https://github.com/silinternational/tfc-ops/releases) page.
2. If you're a Go developer you can install it by running `go get -u https://github.com/silinternational/tfc-ops.git`
3. If you're a Go developer and want to modify the source before running, clone this repo and run with `go run main.go ...`

## Configuration

To provide access to HCP Terraform (Terraform Cloud) run the `terraform login` command and follow the prompts. This
will store a short-lived token on your computer. tfc-ops uses this token to make API calls to HCP Terraform.

## Environment vars
- `TFC_OPS_DEBUG` - Set to `true` to enable debug output
- `ATLAS_TOKEN` - An HCP Terraform token can be set as an environment variable. Get this by going to
https://app.terraform.io/app/settings/tokens and generating a new token. The recommended alternative is to use
the `terraform login` command to request a short-lived token.
- `ATLAS_TOKEN_DESTINATION` - Only necessary if cloning to a new organization in TF Cloud.

## Cloning a TF Cloud Workspace
Examples.

Expand Down Expand Up @@ -176,7 +182,6 @@ Usage:

Flags:
-a, --attribute string required - Workspace attribute to update, use Terraform Cloud API workspace attribute names
-d, --dry-run-mode dry run mode only. (e.g. "-d")
-h, --help help for update
-v, --value string required - Value
-w, --workspace string required - Partial workspace name to search across all workspaces
Expand Down
77 changes: 71 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
package cmd

import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -59,12 +63,7 @@ func init() {
}

func initRoot(cmd *cobra.Command, args []string) {
// Get Tokens from env vars
atlasToken := os.Getenv("ATLAS_TOKEN")
if atlasToken == "" {
errLog.Fatalln("Error: Environment variable for ATLAS_TOKEN is required to execute plan and migration")
}
lib.SetToken(atlasToken)
getToken()

debugStr := os.Getenv("TFC_OPS_DEBUG")
if debugStr == "TRUE" || debugStr == "true" {
Expand All @@ -76,6 +75,72 @@ func initRoot(cmd *cobra.Command, args []string) {
}
}

type Credentials struct {
Credentials struct {
AppTerraformIo struct {
Token string `json:"token"`
} `json:"app.terraform.io"`
} `json:"credentials"`
}

func getToken() {
credentials, err := readTerraformCredentials()
if err != nil {
errLog.Fatalln("failed to get Terraform credentials:", err)
}

if credentials != nil {
token := credentials.Credentials.AppTerraformIo.Token
if token != "" {
lib.SetToken(token)
return
}
}

// fall back to using ATLAS_TOKEN environment variable
atlasToken := os.Getenv("ATLAS_TOKEN")
if atlasToken != "" {
lib.SetToken(atlasToken)
return
}

errLog.Fatalln("no credentials found, use 'terraform login' to create a token")
}

func readTerraformCredentials() (*Credentials, error) {
userConfigDir := os.UserHomeDir
if runtime.GOOS == "windows" {
userConfigDir = os.UserConfigDir
}

var err error
configDir, err := userConfigDir()
if err != nil {
return nil, fmt.Errorf("unable to get the home directory: %v", err)
}

credentialsPath := filepath.Join(configDir, ".terraform.d", "credentials.tfrc.json")
fmt.Println(credentialsPath)
if _, err := os.Stat(credentialsPath); errors.Is(err, os.ErrNotExist) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("error checking file existence: %v", err)
}

fileContents, err := os.ReadFile(credentialsPath)
if err != nil {
return nil, fmt.Errorf("unable to read credentials file: %v", err)
}

var creds Credentials
err = json.Unmarshal(fileContents, &creds)
if err != nil {
return nil, fmt.Errorf("unable to parse JSON: %v", err)
}

return &creds, nil
}

// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
Expand Down
10 changes: 0 additions & 10 deletions cmd/variablesUpdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,6 @@ func init() {
false,
`optional (e.g. "-v=true") whether to do the search based on the value of the variables. (Must be false if add-key-if-not-found is true`,
)
updateCmd.Flags().BoolVarP(
&readOnlyMode,
"dry-run-mode",
"d",
false,
`optional (e.g. "-d=true") dry run mode only.`,
)
if err := updateCmd.Flags().MarkDeprecated("dry-run-mode", "use -r for read-only-mode"); err != nil {
errLog.Fatalln(err)
}
updateCmd.Flags().BoolVarP(
&sensitiveVariable,
"sensitive-variable",
Expand Down
4 changes: 2 additions & 2 deletions cmd/workspacesClone.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ func runClone(cfg cloner.CloneConfig) {

cfg.AtlasTokenDestination = os.Getenv("ATLAS_TOKEN_DESTINATION")
if cfg.AtlasTokenDestination == "" {
cfg.AtlasTokenDestination = os.Getenv("ATLAS_TOKEN")
fmt.Print("Info: ATLAS_TOKEN_DESTINATION is not set, using ATLAS_TOKEN for destination account.\n\n")
cfg.AtlasTokenDestination = cloner.GetToken()
fmt.Print("Info: ATLAS_TOKEN_DESTINATION is not set, using primary credential for destination account.\n\n")
}

fmt.Printf("clone called using %s, %s, %s, copyState: %t, copyVariables: %t, "+
Expand Down
7 changes: 1 addition & 6 deletions cmd/workspacesUpdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,7 @@ func init() {
requiredPrefix+"Value")
workspaceUpdateCmd.Flags().StringVarP(&workspaceFilter, flagWorkspaceFilter, "w", "",
requiredPrefix+"Partial workspace name to search across all workspaces")
workspaceUpdateCmd.Flags().BoolVarP(&readOnlyMode, "dry-run-mode", "d", false,
`dry run mode only. (e.g. "-d")`,
)
if err := updateCmd.Flags().MarkDeprecated("dry-run-mode", "use -r for read-only-mode"); err != nil {
errLog.Fatalln(err)
}

requiredFlags := []string{flagAttribute, flagValue, flagWorkspaceFilter}
for _, flag := range requiredFlags {
if err := workspaceUpdateCmd.MarkFlagRequired(flag); err != nil {
Expand Down
20 changes: 8 additions & 12 deletions lib/apiClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ package lib

import (
"fmt"
"io/ioutil"
"io"
"net/http"
"os"
"strings"
)

// callAPI creates a http.Request object, attaches headers to it and makes the
// requested api call.
func callAPI(method, url, postData string, headers map[string]string) *http.Response {
func callAPI(method, url, postData string, headers map[string]string) (*http.Response, error) {
var err error
var req *http.Request

Expand All @@ -21,8 +20,7 @@ func callAPI(method, url, postData string, headers map[string]string) *http.Resp
}

if err != nil {
fmt.Println(err.Error())
os.Exit(1)
return nil, err
}

req.Header.Set("Authorization", "Bearer "+config.token)
Expand All @@ -36,15 +34,13 @@ func callAPI(method, url, postData string, headers map[string]string) *http.Resp

resp, err := client.Do(req)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
return nil, err
} else if resp.StatusCode >= 300 {
bodyBytes, _ := ioutil.ReadAll(resp.Body)
fmt.Println(fmt.Sprintf(
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf(
"API returned an error.\n\tMethod: %s\n\tURL: %s\n\tCode: %v\n\tStatus: %s\n\tRequest Body: %s\n\tResponse Body: %s",
method, url, resp.StatusCode, resp.Status, postData, bodyBytes))
os.Exit(1)
method, url, resp.StatusCode, resp.Status, postData, bodyBytes)
}

return resp
return resp, nil
}
Loading