diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6eefe39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Lint and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: latest + + - name: Run go test + run: go test ./... -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4bf9eae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3affc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/tfcloud diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..0db3d15 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,48 @@ +# Copyright IBM Corp. 2021, 2026 + +version: "2" +run: + tests: true +linters: + enable: + - bodyclose + - gocritic + - godot + - misspell + - revive + - staticcheck + - unconvert + settings: + errcheck: + check-blank: true + misspell: + locale: US + exclusions: + generated: lax + rules: + - linters: + - bodyclose + - errcheck + - revive + path: _test\.go + - path: (.+)\.go$ + text: ifElseChain + - path: (.+)\.go$ + text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - goimports + settings: + goimports: + local-prefixes: + - github.com/hashicorp/hcloud + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..1e72238 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,26 @@ +# Copyright IBM Corp. 2021, 2026 + +version: 2 +before: + hooks: + - make go/tidy +builds: + - id: default + main: . + env: + - CGO_ENABLED=0 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + - -buildvcs=false + ldflags: + - "-s -w -X github.com/hashicorp/tfcloud/internal/config.version={{.Version}} -X github.com/hashicorp/tfcloud/internal/config.Commit={{.Commit}} -X github.com/hashicorp/tfcloud/internal/config.committedTime={{.CommitDate}}" + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 +archives: + - formats: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..306bfa2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +ARG base_image=docker.artifactory.hashicorp.engineering/ubuntu:24.04 +FROM ${base_image} + +ARG PRODUCT_NAME +ARG PRODUCT_VERSION +# TARGETARCH and TARGETOS are set automatically when --platform is provided. +ARG TARGETOS TARGETARCH +ARG BUILD_DIRECTORY=dist/$TARGETOS/$TARGETARCH +ENV BIN_DIR=$BUILD_DIRECTORY + +LABEL maintainer="HCP Terraform Support " +LABEL "com.hashicorp.${PRODUCT_NAME}.version"="${PRODUCT_VERSION}" +LABEL name=$PRODUCT_NAME +LABEL vendor="HashiCorp" +LABEL version=$PRODUCT_VERSION + +RUN apt-get -y clean +RUN apt-get -y update && apt-get -y dist-upgrade + +RUN apt-get -y install ca-certificates jq unzip curl + +RUN groupadd --system tfcloud && useradd --system --create-home --gid tfcloud tfcloud + +USER tfcloud +RUN mkdir /home/tfcloud/bin +COPY --chown=tfcloud $BIN_DIR/tfcloud /home/tfcloud/bin/ + +RUN mkdir -p /home/tfcloud/.config/tfcloud + +ENV PATH=$PATH:/local/bin + +WORKDIR /home/tfcloud + +ENTRYPOINT ["/home/tfcloud/bin/tfcloud"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..990bd14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2026 IBM + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..44fb3cb --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +SHELL=/usr/bin/env bash +NAME=tfcloud +BIN_PATH ?= bin/$(NAME) + +ifeq ($(GOARCH), arm64) + GOARCH = arm64 +else ifeq ($(GOARCH), s390x) + GOARCH = s390x +else + GOARCH = amd64 +endif + +default: $(BIN_PATH) + +.PHONY: linux +linux: + GOOS=linux GOARCH=$(GOARCH) $(MAKE) bin + +.PHONY: docker +docker: linux + docker build --platform=linux/$(GOARCH) --build-arg BUILD_DIRECTORY="bin" -t hashicorp/$(NAME):latest . + +.PHONY: bin +bin: $(BIN_PATH) + +.PHONY: $(BIN_PATH) +$(BIN_PATH): + CGO_ENABLED=0 go build -o $(BIN_PATH) -trimpath -buildvcs=false ./cmd + +.PHONY: clean +clean: + rm -rf $(CURDIR)/$(dir $(BIN_PATH)) + diff --git a/README.md b/README.md index 8665d6c..555941a 100644 --- a/README.md +++ b/README.md @@ -1 +1,127 @@ -# tfcloud \ No newline at end of file +## tfcloud: The HCP Terraform CLI + +Effectively interact with the HCP Terraform platform. + +#### Quick Start + +tfcloud uses a host-centric, layered configuration with a logical precedence. Configuration commands +do not yet exist in the CLI, so start by writing this file to `$HOME/.config/tfcloud/tfcloud.hcl` +(or `%AppData%/tfcloud/tfcloud.hcl` on Windows) substituting your own hostname, token, and organization. + +```hcl +profile "default" "app.terraform.io" { + token = "your-token" + organization = "user-org" +} +``` + +``` +# Migrate a tfvars file to the current workspace +tfcloud variable import bigsecret.tfvars + +# Migrate a tfvars file to a new variable set +tfcloud variable import bigsecret.tfvars -variable-set-name "production" + +# Migrate ENV variables available to the current workspace +tfcloud variable import -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY + +# Execute any API v2 GET query +tfcloud api /account/details # Table format +tfcloud api /organizations -json # JSON format + +# Execute any POST query by specifying -a for request body attributes in key=value format or -i for raw request body input +tfcloud api /organizations/acme/projects -a "name=my-project" -a "description=it\'s a very fine project" + +# ...or use a JSON input file as the body +tfcloud api /organizations/acme/projects -input my-project.json + +# ...or use stdin as the request body +./generate_hcptf_run.sh | tfcloud api /runs -input - + +# If using parameters in a GET request, set the method to GET. +# This example fetches all pages of data (up to 1000 items) and sorts by created-at descending +tfcloud api /organizations/acme/workspaces -paginate -method GET -f "sort=-created-at" +``` + +#### Configuration Reference + +**Profile-level Configuration** + +Linux/MacOS: `~/.config/tfcloud/tfcloud.hcl` +Windows: `%AppData%/tfcloud/tfcloud.hcl` + +**Working Directory Configuration** + +Working directory config overwrites profile-level config, when available. + +`.tfcloud.hcl` + +**Token created by `terraform login`** + +`~/.terraform.d/credentials.tfrc.json` is checked for the configured hostname if the token is not set by configuration file. + +**Token in Environment Variables** + +`TFCLOUD_TOKEN`: An API token to use in conjunction with the default profile, only used if token is not set by any other configuration file. + +`TFCLOUD_TOKEN_`: Reserved for future use with multiple profiles. + +`TF_TOKEN_`: An API token to use with the specified hostname with punycode formatting, e.g. `TF_TOKEN_app_terraform_io`, only used if the token is not specified in any other way. + + +#### Usage + +You can use `tfcloud -help` for detailed usage instructions. + +**`tfcloud api [flags]`** + +Perform an API request. + +`-H, -header ` + Add a HTTP request header in key:value format + +`-i, -input ` + The file to use as body for the HTTP request (use "-" to read from standard input) + +`-X, -method ` + The HTTP method for the request (default "GET", unless using -a attributes) + +`-t, -type ` + When used with a JSON:API request body for POST/PATCH, the resource type (default to the resource implied by the path) + +`-paginate` + Make additional HTTP requests to fetch all pages of results but emit in a streamable manner + +`-a, -attribute ` + Add a typed resource attribute to the request body in key=value format + +`-f, -field ` + Add a query string parameter to the request URL in key=value format + +`-agent` + Print the raw response body. + +`-json` + Print the raw response body, colorized, if a terminal is attached. + +`-v, -verbose` + Log HTTP request and response details to stderr + +**`tfcloud variable import [tfvars-file] [flags]`** + +Import variables from a tfvars file or the process environment into the current workspace or a variable set. + +`-e ` + Import an environment variable by name. Repeat to import multiple values. + +`-variable-set-name ` + Target a variable set by name instead of the current workspace. + +`-organization ` + Organization name. Optional when it can be resolved from the default organization in `tfcloud.hcl` or local Terraform configuration. + +`-workspace ` + Override the target workspace name. + +`-overwrite` + Update matching existing variables instead of failing when duplicates are found. diff --git a/cmd/tfcloud.go b/cmd/tfcloud.go new file mode 100644 index 0000000..83e5584 --- /dev/null +++ b/cmd/tfcloud.go @@ -0,0 +1,63 @@ +// Package main provides the tfcloud CLI entrypoint. +package main + +import ( + "fmt" + "os" + + "github.com/brandonc/tfcloud/internal/command" + "github.com/brandonc/tfcloud/internal/config" + cli "github.com/hashicorp/cli" + "github.com/mattn/go-isatty" +) + +func main() { + if err := realMain(); err != nil { + fmt.Fprintln(os.Stderr, err) + if exitErr, ok := err.(command.ExitError); ok { + os.Exit(exitErr.Code) + } + } +} + +func realMain() error { + stdoutIsTTY := isTerminal(os.Stdout) + stderrIsTTY := isTerminal(os.Stderr) + + ui := &cli.BasicUi{ + Reader: os.Stdin, + Writer: os.Stdout, + ErrorWriter: os.Stderr, + } + + meta := &command.Meta{ + UI: ui, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + StdoutIsTTY: stdoutIsTTY, + StderrIsTTY: stderrIsTTY, + HumanOutput: stdoutIsTTY, + } + + c := cli.NewCLI(config.Name, config.Version) + c.Args = os.Args[1:] + c.HelpWriter = os.Stdout + c.ErrorWriter = os.Stderr + c.Commands = command.Commands(meta) + + exitCode, err := c.Run() + if err != nil { + return err + } + if exitCode != 0 { + return command.ExitError{Code: exitCode} + } + + return nil +} + +func isTerminal(f *os.File) bool { + fd := f.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..09a4212 --- /dev/null +++ b/go.mod @@ -0,0 +1,67 @@ +module github.com/brandonc/tfcloud + +go 1.25.5 + +require ( + github.com/Masterminds/semver/v3 v3.2.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/hashicorp/cli v1.1.7 + github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4 + github.com/hashicorp/hcl/v2 v2.24.0 + github.com/mattn/go-isatty v0.0.20 + github.com/microsoft/kiota-abstractions-go v1.9.4 + github.com/zclconf/go-cty v1.16.3 + golang.org/x/net v0.43.0 +) + +require ( + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.0.0 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.11 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microsoft/kiota-http-go v1.5.5 // indirect + github.com/microsoft/kiota-serialization-form-go v1.1.3 // indirect + github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect + github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/posener/complete v1.2.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..529089c --- /dev/null +++ b/go.sum @@ -0,0 +1,173 @@ +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU= +github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4 h1:+jBVHPQheF/zwa5Yi2A7zby+2jl6zV+IiteiOwA36ls= +github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4/go.mod h1:NCc9n8HN05g6Bu5v0a3JhkOoWN42DF/Jk0nDQSaZwFI= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microsoft/kiota-abstractions-go v1.9.4 h1:VI3UVzSCQHHhRswe3jyaAQHUQWIFhUMp0z5mtZbTbcs= +github.com/microsoft/kiota-abstractions-go v1.9.4/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= +github.com/microsoft/kiota-http-go v1.5.5 h1:K+jC3lq0kejeUETmh/C7TSIV+vdLuw1LT7NPPoNCxAE= +github.com/microsoft/kiota-http-go v1.5.5/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= +github.com/microsoft/kiota-serialization-form-go v1.1.3 h1:eUY8eHXPFe4ma8cAdx0ya3g4NPlZgbPT+GlFC3xcgGY= +github.com/microsoft/kiota-serialization-form-go v1.1.3/go.mod h1:RMO99zyik+NvZjdVcIeyu6ikyfuKhQtzq2RK0fWJJio= +github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= +github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= +github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= +github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 h1:gMBdYMTHt2mmTdXW8YfvRjRUZ0GhyGV+IqSH9H15bGw= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= +github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..57fb5ad --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,161 @@ +// Package client provides configured HCP Terraform API clients and raw request helpers. +package client + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/brandonc/tfcloud/internal/config" + tfe "github.com/hashicorp/go-tfe" + abs "github.com/microsoft/kiota-abstractions-go" +) + +// Client wraps the configured HCP Terraform API clients and request helpers. +type Client struct { + // TFE is the underlying go-tfe client. + TFE *tfe.Client + // HTTP is the shared HTTP client used for raw requests. + HTTP *http.Client + // Adapter is the Kiota request adapter from the go-tfe client. + Adapter abs.RequestAdapter + // BaseURL is the resolved API base URL. + BaseURL *url.URL + // DefaultHeaders are applied to every request. + DefaultHeaders http.Header +} + +// Request describes a raw HTTP request to send to the API. +type Request struct { + // Method is the HTTP method to use. + Method string + // URL is the fully resolved request URL. + URL *url.URL + // Headers are additional HTTP headers for the request. + Headers http.Header + // Body is the raw request payload. + Body []byte +} + +// Response contains the result of a raw HTTP request. +type Response struct { + // StatusCode is the numeric HTTP status code. + StatusCode int + // Status is the full HTTP status line. + Status string + // Headers are the response headers. + Headers http.Header + // Body is the raw response body. + Body []byte +} + +// New constructs a configured API client from CLI configuration. +func New(cfg *config.Config) (*Client, error) { + tfeClient, err := tfe.NewClient(&tfe.Config{ + Address: fmt.Sprintf("https://%s", cfg.Hostname), + Token: cfg.Token, + Headers: cfg.DefaultHeaders, + }) + if err != nil { + return nil, err + } + + adapter := tfeClient.API.RequestAdapter + native, ok := adapter.(*tfe.TFERequestAdapter) + if !ok { + return nil, fmt.Errorf("unsupported request adapter type %T", adapter) + } + + baseURL := tfeClient.BaseURL() + return &Client{ + TFE: tfeClient, + HTTP: native.Client, + Adapter: adapter, + BaseURL: &baseURL, + DefaultHeaders: cfg.DefaultHeaders, + }, nil +} + +// RawRequest sends a low-level request and returns the raw response. +func (c *Client) RawRequest(ctx context.Context, req *Request) (*Response, error) { + requestInfo := abs.NewRequestInformation() + requestInfo.Method = httpMethod(strings.ToUpper(req.Method)) + requestInfo.SetUri(*req.URL) + + for key, values := range c.DefaultHeaders { + for _, value := range values { + requestInfo.Headers.Add(key, value) + } + } + for key, values := range req.Headers { + for _, value := range values { + requestInfo.Headers.Add(key, value) + } + } + if len(req.Body) > 0 { + requestInfo.Content = req.Body + } + + nativeRequest, err := c.Adapter.ConvertToNativeRequest(ctx, requestInfo) + if err != nil { + return nil, err + } + + httpReq, ok := nativeRequest.(*http.Request) + if !ok { + return nil, fmt.Errorf("unexpected native request type %T", nativeRequest) + } + + httpResp, err := c.HTTP.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + return &Response{ + StatusCode: httpResp.StatusCode, + Status: httpResp.Status, + Headers: httpResp.Header.Clone(), + Body: body, + }, nil +} + +// ResolveURL resolves an absolute or base-relative API path against base. +func ResolveURL(base *url.URL, path string) (*url.URL, error) { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return url.Parse(path) + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + resolved := *base + resolved.Path = strings.TrimRight(base.Path, "/") + path + resolved.RawQuery = "" + resolved.Fragment = "" + return &resolved, nil +} + +func httpMethod(method string) abs.HttpMethod { + switch method { + case http.MethodDelete: + return abs.DELETE + case http.MethodPatch: + return abs.PATCH + case http.MethodPost: + return abs.POST + case http.MethodPut: + return abs.PUT + default: + return abs.GET + } +} diff --git a/internal/command/api.go b/internal/command/api.go new file mode 100644 index 0000000..ad1c3ad --- /dev/null +++ b/internal/command/api.go @@ -0,0 +1,615 @@ +// Package command implements the tfcloud CLI command tree. +package command + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/brandonc/tfcloud/internal/client" + "github.com/brandonc/tfcloud/internal/config" + "github.com/brandonc/tfcloud/internal/render" + "github.com/charmbracelet/lipgloss" +) + +// APICommand performs arbitrary HCP Terraform API requests. +type APICommand struct { + // Meta provides UI and stream access for command execution. + Meta *Meta + loadConfig func() (*config.Config, error) + newClient func(*config.Config) (apiRequester, error) +} + +type apiRequester interface { + Base() *url.URL + RawRequest(context.Context, *client.Request) (*client.Response, error) +} + +type realAPIClient struct { + client *client.Client +} + +func (c *realAPIClient) Base() *url.URL { + return c.client.BaseURL +} + +func (c *realAPIClient) RawRequest(ctx context.Context, req *client.Request) (*client.Response, error) { + return c.client.RawRequest(ctx, req) +} + +type apiOutputMode int + +const ( + apiOutputHuman apiOutputMode = iota + apiOutputMachine +) + +type apiStyles struct { + header lipgloss.Style + label lipgloss.Style + error lipgloss.Style + json lipgloss.Style +} + +const ansiHelpBoldWhite = "\x1b[1;97m" + +// Synopsis returns a short summary of the command. +func (c *APICommand) Synopsis() string { return "Make arbitrary API requests" } + +// Help returns the command help text. +func (c *APICommand) Help() string { + help := strings.TrimSpace(`Usage: tfcloud api [flags] + +Perform an HCP Terraform API v2 request. Table output by default; use -json, +-agent or pipe output for JSON. + +Options: + + -H, -header "key: value" Add request header + -i, -input file Read raw JSON request body from file or - for stdin + -X, -method method HTTP method + -t, -type type Resource type for -attribute JSON:API bodies + -paginate Follow links.next and combine up to 1000 resources + -a, -attribute key=value Add typed JSON:API request attribute + -f, -field key=value Add query parameter + -silent Suppress response body output + -agent, -json Print JSON output + -v, -verbose Log request and response metadata to stderr + -(path token) name E.g. -organization myorg to replace {organization} + +Path templates: + + Use tokens like {workspace} or {organization} in paths to have that token + automatically replaced with the corresponding name given by flag or + configuration + +Examples: + + # List workspaces in the default organization + $ tfcloud api /organizations/{organization}/workspaces + + # Create a project using attributes + $ tfcloud api /projects -a name=myproject + + # Create a workspace variable using a JSON:API request body + $ tfcloud api /vars -i '{ "data": { + "type":"vars", + "attributes": { + "key":"AWS_ACCESS_KEY_ID", + "value":"FOOBARBAZQUX", + "category":"env", + "sensitive":true + }, + "relationships": { + "workspace": { + "data": { + "id":"ws-mjAtT5DSQKuY8pAJ", + "type":"workspaces" + } + } + } + }}' +`) + + for _, heading := range []string{"Options:", "Path templates:", "Examples:"} { + help = strings.Replace(help, heading, ansiHelpBoldWhite+heading+"\x1b[0m", 1) + } + return help +} + +// Run executes the API command. +func (c *APICommand) Run(args []string) int { + var headers multiFlag + var attrs multiFlag + var filters multiFlag + var input string + var method string + var resourceType string + var paginate bool + var silent bool + var rawJSON bool + var verbose bool + + fs := flag.NewFlagSet("api", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.Var(&headers, "H", "header") + fs.Var(&headers, "header", "header") + fs.StringVar(&input, "input", "", "input") + fs.StringVar(&input, "i", "", "input") + fs.StringVar(&method, "X", "", "method") + fs.StringVar(&method, "method", "", "method") + fs.StringVar(&resourceType, "t", "", "type") + fs.StringVar(&resourceType, "type", "", "type") + fs.BoolVar(&paginate, "paginate", false, "paginate") + fs.Var(&attrs, "a", "attribute") + fs.Var(&attrs, "attribute", "attribute") + fs.Var(&filters, "f", "field") + fs.Var(&filters, "field", "field") + fs.BoolVar(&silent, "silent", false, "silent") + fs.BoolVar(&rawJSON, "agent", false, "agent") + fs.BoolVar(&rawJSON, "json", false, "json") + fs.BoolVar(&verbose, "v", false, "verbose") + fs.BoolVar(&verbose, "verbose", false, "verbose") + + path, err := parseSingleArg(args) + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + if err := fs.Parse(args[1:]); err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + if len(attrs) > 0 && input != "" { + c.Meta.UI.Error("-attribute and -input are mutually exclusive") + return 1 + } + + loadConfig := c.loadConfig + if loadConfig == nil { + loadConfig = config.Load + } + + cfg, err := loadConfig() + if err != nil { + c.emitError(err.Error()) + return 1 + } + + newClient := c.newClient + if newClient == nil { + newClient = func(cfg *config.Config) (apiRequester, error) { + apiClient, err := client.New(cfg) + if err != nil { + return nil, err + } + return &realAPIClient{client: apiClient}, nil + } + } + + apiClient, err := newClient(cfg) + if err != nil { + c.emitError(err.Error()) + return 1 + } + + resolvedURL, err := client.ResolveURL(apiClient.Base(), path) + if err != nil { + c.emitError(err.Error()) + return 1 + } + + for _, item := range filters { + key, value, err := splitPair(item, '=') + if err != nil { + c.emitError(err.Error()) + return 1 + } + query := resolvedURL.Query() + query.Set(key, value) + resolvedURL.RawQuery = query.Encode() + } + + body, contentType, err := buildRequestBody(path, input, attrs, resourceType, c.Meta.Stdin) + if err != nil { + c.emitError(err.Error()) + return 1 + } + + method = inferMethod(method, len(attrs) > 0, input != "") + requestHeaders, err := parseHeaders(headers) + if err != nil { + c.emitError(err.Error()) + return 1 + } + if contentType != "" && requestHeaders.Get("Content-Type") == "" { + requestHeaders.Set("Content-Type", contentType) + } + if requestHeaders.Get("Accept") == "" { + requestHeaders.Set("Accept", "application/vnd.api+json") + } + + response, err := apiClient.RawRequest(context.Background(), &client.Request{ + Method: method, + URL: resolvedURL, + Headers: requestHeaders, + Body: body, + }) + if err != nil { + c.emitError(err.Error()) + return 1 + } + + if verbose { + logRequestResponse(c.Meta.Stderr, method, resolvedURL, requestHeaders, response) + } + + if paginate && response.StatusCode >= 200 && response.StatusCode < 300 { + response, err = paginateResponse(context.Background(), apiClient, response, requestHeaders, verbose, c.Meta.Stderr) + if err != nil { + c.emitError(err.Error()) + return 1 + } + } + + if response.Headers.Get("Content-Type") != "" && strings.Contains(response.Headers.Get("Content-Type"), "text/html") { + c.emitError("HTML response received, likely an error page. Check the URL and try again.") + return 1 + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + message := summarizeAPIErrors(response.Body) + if message == "" { + message = string(bytes.TrimSpace(response.Body)) + } + if message != "" { + c.emitError(fmt.Sprintf("%s: %s", response.Status, message)) + } else { + c.emitError(response.Status) + } + return 1 + } + + if silent || len(bytes.TrimSpace(response.Body)) == 0 { + return 0 + } + + mode := c.outputMode(rawJSON) + if rawJSON || mode == apiOutputMachine { + c.emitOutput(c.renderJSON(response.Body, mode)) + return 0 + } + + table, ok, err := render.JSONAPITable(response.Body) + if err != nil { + c.emitError(err.Error()) + return 1 + } + if ok { + c.emitOutput(table) + return 0 + } + + c.emitOutput(c.renderJSON(response.Body, mode)) + return 0 +} + +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(value string) error { + *m = append(*m, value) + return nil +} + +func inferMethod(explicit string, hasAttributes, hasInput bool) string { + if explicit != "" { + return strings.ToUpper(explicit) + } + if hasAttributes || hasInput { + return http.MethodPost + } + return http.MethodGet +} + +func inferResourceType(path string) string { + segments := strings.FieldsFunc(strings.Trim(path, "/"), func(r rune) bool { return r == '/' }) + if len(segments) == 0 { + return "" + } + last := segments[len(segments)-1] + if len(segments) >= 2 { + prev := segments[len(segments)-2] + if !looksLikeCollection(last) && looksLikeCollection(prev) { + return prev + } + } + return last +} + +func looksLikeCollection(segment string) bool { + return strings.HasSuffix(segment, "s") +} + +func parseTypedValue(raw string) any { + if raw == "null" { + return nil + } + if raw == "true" || raw == "false" { + return raw == "true" + } + if i, err := strconv.ParseInt(raw, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(raw, 64); err == nil { + return f + } + if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { + var value any + if err := json.Unmarshal([]byte(raw), &value); err == nil { + return value + } + } + return raw +} + +func buildRequestBody(path, input string, attrs multiFlag, resourceType string, stdin io.Reader) ([]byte, string, error) { + if input != "" { + var data []byte + var err error + if input == "-" { + data, err = io.ReadAll(stdin) + } else if strings.HasPrefix(input, "{") || strings.HasPrefix(input, "[") { + data = []byte(input) + } else { + data, err = os.ReadFile(input) + } + if err != nil { + return nil, "", err + } + return data, "application/vnd.api+json", nil + } + + if len(attrs) == 0 { + return nil, "", nil + } + + if resourceType == "" { + resourceType = inferResourceType(path) + } + if resourceType == "" { + return nil, "", errors.New("could not infer resource type from path; use -type") + } + + attributes := make(map[string]any, len(attrs)) + for _, item := range attrs { + key, value, err := splitPair(item, '=') + if err != nil { + return nil, "", err + } + attributes[key] = parseTypedValue(value) + } + + body := map[string]any{ + "data": map[string]any{ + "type": resourceType, + "attributes": attributes, + }, + } + + encoded, err := json.Marshal(body) + if err != nil { + return nil, "", err + } + return encoded, "application/vnd.api+json", nil +} + +func parseHeaders(values []string) (http.Header, error) { + headers := make(http.Header) + for _, item := range values { + key, value, err := splitPair(item, ':') + if err != nil { + return nil, err + } + headers.Add(key, strings.TrimSpace(value)) + } + return headers, nil +} + +func splitPair(item string, sep rune) (string, string, error) { + parts := strings.SplitN(item, string(sep), 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + return "", "", fmt.Errorf("invalid pair %q", item) + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil +} + +func paginateResponse(ctx context.Context, apiClient apiRequester, initial *client.Response, headers http.Header, verbose bool, stderr io.Writer) (*client.Response, error) { + combined, nextURL, err := parsePaginationPayload(initial.Body) + if err != nil || nextURL == nil { + return initial, err + } + + for len(combined) < 1000 && nextURL != nil { + resp, reqErr := apiClient.RawRequest(ctx, &client.Request{ + Method: http.MethodGet, + URL: nextURL, + Headers: headers, + }) + if reqErr != nil { + return nil, reqErr + } + if verbose { + logRequestResponse(stderr, http.MethodGet, nextURL, headers, resp) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp, nil + } + + pageData, pageNext, pageErr := parsePaginationPayload(resp.Body) + if pageErr != nil { + return nil, pageErr + } + combined = append(combined, pageData...) + nextURL = pageNext + if len(combined) > 1000 { + combined = combined[:1000] + } + initial = resp + } + + merged, err := mergePaginatedBody(initial.Body, combined) + if err != nil { + return nil, err + } + initial.Body = merged + return initial, nil +} + +func (c *APICommand) outputMode(rawJSON bool) apiOutputMode { + if rawJSON { + return apiOutputHuman + } + if c.Meta == nil { + return apiOutputMachine + } + if c.Meta.HumanOutput { + return apiOutputHuman + } + if c.Meta.StdoutIsTTY { + return apiOutputHuman + } + return apiOutputMachine +} + +func (c *APICommand) emitOutput(text string) { + c.Meta.UI.Output(text) +} + +func (c *APICommand) emitError(text string) { + if c.Meta == nil || !c.Meta.HumanOutput || !c.Meta.StderrIsTTY { + c.Meta.UI.Error(text) + return + } + c.Meta.UI.Error(c.styles().error.Render(text)) +} + +func (c *APICommand) renderJSON(body []byte, mode apiOutputMode) string { + pretty := render.PrettyJSON(body) + if mode == apiOutputMachine || c.Meta == nil || !c.Meta.HumanOutput || !c.Meta.StdoutIsTTY { + return pretty + } + return c.styles().json.Render(pretty) +} + +func (c *APICommand) styles() apiStyles { + return apiStyles{ + header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")), + label: lipgloss.NewStyle().Foreground(lipgloss.Color("110")).Bold(true), + error: lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Bold(true), + json: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), + } +} + +func parsePaginationPayload(body []byte) ([]any, *url.URL, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, err + } + data, ok := payload["data"].([]any) + if !ok { + return nil, nil, nil + } + links, ok := payload["links"].(map[string]any) + if !ok { + return data, nil, nil + } + nextRaw, _ := links["next"].(string) + if nextRaw == "" { + return data, nil, nil + } + nextURL, err := url.Parse(nextRaw) + if err != nil { + return nil, nil, err + } + return data, nextURL, nil +} + +func mergePaginatedBody(body []byte, combined []any) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + payload["data"] = combined + if meta, ok := payload["meta"].(map[string]any); ok { + if pagination, ok := meta["pagination"].(map[string]any); ok { + pagination["total-count"] = len(combined) + } + } + if links, ok := payload["links"].(map[string]any); ok { + links["next"] = nil + } + return json.Marshal(payload) +} + +func summarizeAPIErrors(body []byte) string { + var payload struct { + Errors []struct { + Status string `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if len(payload.Errors) > 0 { + parts := make([]string, 0, len(payload.Errors)) + for _, item := range payload.Errors { + if item.Detail != "" { + parts = append(parts, strings.TrimSpace(item.Title+": "+item.Detail)) + continue + } + if item.Title != "" { + parts = append(parts, item.Title) + } + } + return strings.Join(parts, ", ") + } + if payload.Message != "" { + return payload.Message + } + return payload.Error +} + +func logRequestResponse(w io.Writer, method string, u *url.URL, reqHeaders http.Header, response *client.Response) { + fmt.Fprintf(w, "> %s %s\n", method, u.String()) + writeHeaders(w, reqHeaders) + fmt.Fprintf(w, "< %s\n", response.Status) + writeHeaders(w, response.Headers) +} + +func writeHeaders(w io.Writer, headers http.Header) { + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(w, "%s: %s\n", key, strings.Join(headers.Values(key), ", ")) + } +} diff --git a/internal/command/api_schema.go b/internal/command/api_schema.go new file mode 100644 index 0000000..58304d8 --- /dev/null +++ b/internal/command/api_schema.go @@ -0,0 +1,21 @@ +package command + +// APISchemaCommand displays API schema information. +type APISchemaCommand struct { + // Meta provides UI and stream access for command execution. + Meta *Meta +} + +// Synopsis returns a short summary of the command. +func (c *APISchemaCommand) Synopsis() string { return "Display API schema information" } + +// Help returns the command help text. +func (c *APISchemaCommand) Help() string { + return "" +} + +// Run executes the API schema command. +func (c *APISchemaCommand) Run(_ []string) int { + c.Meta.UI.Error("not implemented") + return 1 +} diff --git a/internal/command/api_test.go b/internal/command/api_test.go new file mode 100644 index 0000000..e50ec70 --- /dev/null +++ b/internal/command/api_test.go @@ -0,0 +1,451 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/brandonc/tfcloud/internal/client" + "github.com/brandonc/tfcloud/internal/config" + cli "github.com/hashicorp/cli" +) + +func TestRun(t *testing.T) { + t.Parallel() + + t.Run("returns a single jsonapi resource and renders a vertical table", func(t *testing.T) { + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":{"id":"run-1","type":"runs","attributes":{"message":"deploy","status":"planned_and_finished"}}}`}}, nil) + + if code := cmd.Run([]string{"/runs/run-1"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + got := ui.OutputWriter.String() + if !strings.Contains(got, "message") || !strings.Contains(got, "deploy") { + t.Fatalf("unexpected output %q", got) + } + }) + + t.Run("returns a collection and renders a horizontal table", func(t *testing.T) { + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha","description":"one"}},{"id":"ws-2","type":"workspaces","attributes":{"name":"beta"}}]}`}}, nil) + + if code := cmd.Run([]string{"/workspaces"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + got := ui.OutputWriter.String() + if !strings.Contains(got, "alpha") || !strings.Contains(got, "workspaces") { + t.Fatalf("unexpected output %q", got) + } + }) + + t.Run("prints a useful error message when the api returns an error response", func(t *testing.T) { + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 422, status: "422 Unprocessable Entity", body: `{"errors":[{"title":"invalid attribute","detail":"name is required"}]}`}}, nil) + + if code := cmd.Run([]string{"/projects"}); code != 1 { + t.Fatalf("got exit code %d", code) + } + got := ui.ErrorWriter.String() + if !strings.Contains(got, "422 Unprocessable Entity") || !strings.Contains(got, "name is required") { + t.Fatalf("unexpected error output %q", got) + } + }) + + t.Run("reads request body from stdin with -i dash", func(t *testing.T) { + var captured []byte + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[]}`}}, func(req *client.Request) { + captured = append([]byte(nil), req.Body...) + }) + cmd.Meta.Stdin = strings.NewReader(`{"data":{"type":"vars"}}`) + + if code := cmd.Run([]string{"/vars", "-i", "-"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + if string(captured) != `{"data":{"type":"vars"}}` { + t.Fatalf("got body %q", captured) + } + }) + + t.Run("supports input file, headers, fields, type, method, silent, verbose, and json alias flags", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Test"); got != "yes" { + t.Fatalf("got header %q", got) + } + if r.URL.RawQuery != "page%5Bnumber%5D=2" { + t.Fatalf("got query %q", r.URL.RawQuery) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if len(body) == 0 { + t.Fatal("expected request body") + } + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + + inputFile := t.TempDir() + "/input.json" + if err := os.WriteFile(inputFile, []byte(`{"data":{"type":"vars","attributes":{"key":"AWS_REGION"}}}`), 0o600); err != nil { + t.Fatal(err) + } + + cmd, ui := newTestAPICommandWithServer(t, false, server) + + if code := cmd.Run([]string{"/vars", "-i", inputFile, "-X", "post", "-H", "X-Test: yes", "-f", "page[number]=2", "-v", "-silent"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + if ui.OutputWriter.String() != "" { + t.Fatalf("expected no output, got %q", ui.OutputWriter.String()) + } + if got := ui.ErrorWriter.String(); !strings.Contains(got, "> POST") || !strings.Contains(got, "< 200 OK") { + t.Fatalf("unexpected verbose output %q", got) + } + }) + + t.Run("builds typed jsonapi body with attributes and explicit type", func(t *testing.T) { + var captured []byte + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[]}`}}, func(req *client.Request) { + captured = append([]byte(nil), req.Body...) + }) + + if code := cmd.Run([]string{"/vars", "-t", "vars", "-a", "key=AWS_REGION", "-a", "hcl=false"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + + var payload map[string]any + if err := json.Unmarshal(captured, &payload); err != nil { + t.Fatal(err) + } + data := payload["data"].(map[string]any) + if data["type"] != "vars" { + t.Fatalf("got type %v", data["type"]) + } + }) + + t.Run("paginates and merges resources", func(t *testing.T) { + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + switch r.URL.Path { + case "/api/v2/workspaces": + _, _ = fmt.Fprintf(w, `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}],"links":{"next":%q},"meta":{"pagination":{"total-count":1}}}`, serverURL+"/api/v2/page/2") + case "/api/v2/page/2": + _, _ = w.Write([]byte(`{"data":[{"id":"ws-2","type":"workspaces","attributes":{"name":"beta"}}],"links":{"next":null}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + serverURL = server.URL + + cmd, ui := newTestAPICommandWithServer(t, false, server) + if code := cmd.Run([]string{"/workspaces", "-paginate"}); code != 0 { + t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) + } + got := ui.OutputWriter.String() + if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") { + t.Fatalf("unexpected output %q", got) + } + }) + + t.Run("when output is piped to a file the output is raw json instead of a table", func(t *testing.T) { + cmd, ui := newTestAPICommand(t, true, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}]}`}}, nil) + + if code := cmd.Run([]string{"/workspaces"}); code != 0 { + t.Fatalf("got exit code %d", code) + } + if got := strings.TrimSpace(ui.OutputWriter.String()); !strings.HasPrefix(got, "{") { + t.Fatalf("expected json output, got %q", got) + } + }) + + t.Run("agent is an alias for json", func(t *testing.T) { + cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}]}`}}, nil) + + if code := cmd.Run([]string{"/workspaces", "-agent"}); code != 0 { + t.Fatalf("got exit code %d", code) + } + if got := strings.TrimSpace(ui.OutputWriter.String()); !strings.HasPrefix(got, "{") { + t.Fatalf("expected json output, got %q", got) + } + }) +} + +func TestAPIHelpStylesSectionHeaders(t *testing.T) { + t.Parallel() + + help := (&APICommand{}).Help() + for _, section := range []string{"Options:", "Path templates:", "Examples:"} { + styled := "\x1b[1;97m" + section + "\x1b[0m" + if !strings.Contains(help, styled) { + t.Fatalf("missing section %q in help %q", section, help) + } + } +} + +func TestInferMethod(t *testing.T) { + t.Parallel() + + if got := inferMethod("", false, false); got != "GET" { + t.Fatalf("got %q", got) + } + if got := inferMethod("", true, false); got != "POST" { + t.Fatalf("got %q", got) + } + if got := inferMethod("patch", false, false); got != "PATCH" { + t.Fatalf("got %q", got) + } +} + +func TestInferResourceType(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "/organizations/acme/projects": "projects", + "/runs": "runs", + "/projects/prj-123/tag-bindings": "tag-bindings", + "/organizations/acme/workspaces/ws-1": "workspaces", + "/workspaces/ws-1/vars": "vars", + "/varsets/vs-1/relationships/vars": "vars", + } + + for input, want := range tests { + if got := inferResourceType(input); got != want { + t.Fatalf("inferResourceType(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLooksLikeCollection(t *testing.T) { + t.Parallel() + + tests := map[string]bool{ + "projects": true, + "runs": true, + "workspaces": true, + "vars": true, + "varsets": true, + "policy-sets": true, + "organizations": true, + "ws-1": false, + "relationships": true, + "tag-bindings": true, + } + + for input, want := range tests { + if got := looksLikeCollection(input); got != want { + t.Fatalf("looksLikeCollection(%q) = %v, want %v", input, got, want) + } + } +} + +func TestSplitPair(t *testing.T) { + t.Parallel() + + if gotKey, gotValue, err := splitPair("foo=bar", '='); err != nil || gotKey != "foo" || gotValue != "bar" { + t.Fatalf("got %q %q %v", gotKey, gotValue, err) + } + + if _, _, err := splitPair("missing", '='); err == nil { + t.Fatal("expected error") + } +} + +func TestParseTypedValue(t *testing.T) { + t.Parallel() + + if got := parseTypedValue("true"); got != true { + t.Fatalf("got %#v", got) + } + if got := parseTypedValue("42"); got != int64(42) { + t.Fatalf("got %#v", got) + } + if got := parseTypedValue("3.14"); got != 3.14 { + t.Fatalf("got %#v", got) + } + if got := parseTypedValue("hello"); got != "hello" { + t.Fatalf("got %#v", got) + } +} + +func TestMergePaginatedBody(t *testing.T) { + t.Parallel() + + body := []byte(`{"data":[{"id":"1"}],"links":{"next":"https://example.com/page/2"},"meta":{"pagination":{"total-count":1}}}`) + merged, err := mergePaginatedBody(body, []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}) + if err != nil { + t.Fatal(err) + } + + var payload map[string]any + if err := json.Unmarshal(merged, &payload); err != nil { + t.Fatal(err) + } + data := payload["data"].([]any) + if len(data) != 2 { + t.Fatalf("got %d rows", len(data)) + } + if payload["links"].(map[string]any)["next"] != nil { + t.Fatal("expected next link cleared") + } +} + +func TestBuildRequestBodyInfersTypedJSONAPI(t *testing.T) { + t.Parallel() + + body, contentType, err := buildRequestBody("/workspaces/ws-1/vars", "", multiFlag{"enabled=true", "count=42", "config={\"mode\":\"safe\"}"}, "", nil) + if err != nil { + t.Fatal(err) + } + if contentType != "application/vnd.api+json" { + t.Fatalf("got content type %q", contentType) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + data := payload["data"].(map[string]any) + if data["type"] != "vars" { + t.Fatalf("got type %v", data["type"]) + } + attrs := data["attributes"].(map[string]any) + if got, want := attrs["enabled"], true; !reflect.DeepEqual(got, want) { + t.Fatalf("enabled = %#v, want %#v", got, want) + } +} + +type responseStub struct { + statusCode int + status string + body string + headers http.Header +} + +type stubAPIClient struct { + baseURL *url.URL + responses []responseStub + requestHandler func(*client.Request) + index int +} + +type serverAPIClient struct { + httpClient *http.Client + baseURL *url.URL +} + +func (c *stubAPIClient) Base() *url.URL { + return c.baseURL +} + +func (c *stubAPIClient) RawRequest(_ context.Context, req *client.Request) (*client.Response, error) { + if c.requestHandler != nil { + c.requestHandler(req) + } + if c.index >= len(c.responses) { + return nil, fmt.Errorf("unexpected request %s %s", req.Method, req.URL) + } + resp := c.responses[c.index] + c.index++ + status := resp.status + if status == "" { + status = strconv.Itoa(resp.statusCode) + " " + http.StatusText(resp.statusCode) + } + headers := resp.headers + if headers == nil { + headers = make(http.Header) + } + return &client.Response{StatusCode: resp.statusCode, Status: status, Headers: headers, Body: []byte(resp.body)}, nil +} + +func (c *serverAPIClient) Base() *url.URL { + return c.baseURL +} + +func (c *serverAPIClient) RawRequest(ctx context.Context, req *client.Request) (*client.Response, error) { + httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL.String(), bytes.NewReader(req.Body)) + if err != nil { + return nil, err + } + for key, values := range req.Headers { + for _, value := range values { + httpReq.Header.Add(key, value) + } + } + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + return &client.Response{StatusCode: httpResp.StatusCode, Status: httpResp.Status, Headers: httpResp.Header.Clone(), Body: body}, nil +} + +func newTestAPICommand(t *testing.T, machine bool, responses []responseStub, handler func(*client.Request)) (*APICommand, *cli.MockUi) { + t.Helper() + baseURL, err := url.Parse("https://app.terraform.test/api/v2") + if err != nil { + t.Fatal(err) + } + ui := cli.NewMockUi() + meta := &Meta{ + UI: ui, + Stdin: bytes.NewBuffer(nil), + Stdout: ui.OutputWriter, + Stderr: ui.ErrorWriter, + StdoutIsTTY: !machine, + StderrIsTTY: !machine, + HumanOutput: !machine, + } + cmd := &APICommand{ + Meta: meta, + loadConfig: func() (*config.Config, error) { + return &config.Config{Hostname: "app.terraform.test", Token: "token", DefaultHeaders: make(http.Header)}, nil + }, + newClient: func(*config.Config) (apiRequester, error) { + return &stubAPIClient{baseURL: baseURL, responses: responses, requestHandler: handler}, nil + }, + } + return cmd, ui +} + +func newTestAPICommandWithServer(t *testing.T, machine bool, server *httptest.Server) (*APICommand, *cli.MockUi) { + t.Helper() + baseURL, err := url.Parse(server.URL + "/api/v2") + if err != nil { + t.Fatal(err) + } + ui := cli.NewMockUi() + meta := &Meta{ + UI: ui, + Stdin: bytes.NewBuffer(nil), + Stdout: ui.OutputWriter, + Stderr: ui.ErrorWriter, + StdoutIsTTY: !machine, + StderrIsTTY: !machine, + HumanOutput: !machine, + } + cmd := &APICommand{ + Meta: meta, + loadConfig: func() (*config.Config, error) { + return &config.Config{Hostname: baseURL.Hostname(), Token: "token", DefaultHeaders: make(http.Header)}, nil + }, + newClient: func(cfg *config.Config) (apiRequester, error) { + return &serverAPIClient{httpClient: server.Client(), baseURL: baseURL}, nil + }, + } + return cmd, ui +} diff --git a/internal/command/meta.go b/internal/command/meta.go new file mode 100644 index 0000000..a3ef22c --- /dev/null +++ b/internal/command/meta.go @@ -0,0 +1,111 @@ +package command + +import ( + "fmt" + "io" + "log" + + cli "github.com/hashicorp/cli" +) + +// Meta carries UI and stdio details that commands use while running. +type Meta struct { + // UI is the command-line UI implementation. + UI cli.Ui + // Stdin is the command input stream. + Stdin io.Reader + // Stdout is the command output stream. + Stdout io.Writer + // Stderr is the command error stream. + Stderr io.Writer + // StdoutIsTTY reports whether Stdout is attached to a terminal. + StdoutIsTTY bool + // StderrIsTTY reports whether Stderr is attached to a terminal. + StderrIsTTY bool + // HumanOutput reports whether commands should prefer human-oriented rendering. + HumanOutput bool +} + +// ExitError represents a process exit code from the CLI. +type ExitError struct { + // Code is the process exit status. + Code int +} + +// Error returns a message for the exit code. +func (e ExitError) Error() string { + switch e.Code { + case 0: + return "" + case 1: + return "invalid command" + case 2: + return "request error" + case 3: + return "server error" + default: + log.Printf("Exit code %d should be added to the ExitError description", e.Code) + return fmt.Sprintf("command failed (code %d)", e.Code) + } +} + +// Commands returns the CLI command registry. +func Commands(meta *Meta) map[string]cli.CommandFactory { + return map[string]cli.CommandFactory{ + "api": func() (cli.Command, error) { + return &APICommand{Meta: meta}, nil + }, + "api schema": func() (cli.Command, error) { + return &APISchemaCommand{Meta: meta}, nil + }, + "workspace": func() (cli.Command, error) { + return &NamespaceCommand{Meta: meta, Name: "workspace"}, nil + }, + "variable": func() (cli.Command, error) { + return &NamespaceCommand{Meta: meta, Name: "variable"}, nil + }, + "variable import": func() (cli.Command, error) { + return &VariableImportCommand{Meta: meta}, nil + }, + } +} + +// NamespaceCommand groups related subcommands under a shared namespace. +type NamespaceCommand struct { + // Meta provides UI and stream access for command execution. + Meta *Meta + // Name is the namespace name shown in help and synopsis output. + Name string +} + +// Help returns the command help text. +func (c *NamespaceCommand) Help() string { + if c.Name == "workspace" { + return "Usage: tfcloud workspace \n\n vcs Create or update workspace VCS settings" + } + + return "Usage: tfcloud variable \n\n import Import tfvars or environment variables" +} + +// Run executes the namespace command. +func (c *NamespaceCommand) Run(args []string) int { + if len(args) > 0 { + c.Meta.UI.Error("unknown subcommand: " + args[0]) + } + return cli.RunResultHelp +} + +// Synopsis returns a short summary of the command. +func (c *NamespaceCommand) Synopsis() string { + if c.Name == "workspace" { + return "Workspace workflows" + } + return "Variable workflows" +} + +func parseSingleArg(args []string) (string, error) { + if len(args) == 0 { + return "", fmt.Errorf("accepts 1 argument, but got %d. Try using -help", len(args)) + } + return args[0], nil +} diff --git a/internal/command/variable_import.go b/internal/command/variable_import.go new file mode 100644 index 0000000..de55cba --- /dev/null +++ b/internal/command/variable_import.go @@ -0,0 +1,416 @@ +package command + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/brandonc/tfcloud/internal/client" + "github.com/brandonc/tfcloud/internal/config" + terraformcfg "github.com/brandonc/tfcloud/internal/terraform" +) + +// VariableImportCommand imports variables into a workspace or variable set. +type VariableImportCommand struct { + // Meta provides UI and stream access for command execution. + Meta *Meta + loadConfig func() (*config.Config, error) +} + +// Synopsis returns a short summary of the command. +func (c *VariableImportCommand) Synopsis() string { + return "Import workspace or variable set variables" +} + +// Help returns the command help text. +func (c *VariableImportCommand) Help() string { + return strings.TrimSpace(`Usage: tfcloud variable import [tfvars-file] [flags] + +Import variables into the current workspace or a variable set. + + -e name Import an environment variable (repeatable) + -variable-set-name name Target variable set by name + -organization string Organization name + -workspace string Workspace name override + -overwrite Update matching existing variables`) +} + +// Run executes the variable import command. +func (c *VariableImportCommand) Run(args []string) int { + var envNames multiFlag + var variableSetName string + var organization string + var workspaceName string + var overwrite bool + + fs := flag.NewFlagSet("variable import", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.Var(&envNames, "e", "env") + fs.StringVar(&variableSetName, "variable-set-name", "", "variable set") + fs.StringVar(&organization, "organization", "", "organization") + fs.StringVar(&workspaceName, "workspace", "", "workspace") + fs.BoolVar(&overwrite, "overwrite", false, "overwrite") + + if err := fs.Parse(args); err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + var imported []terraformcfg.ImportedVariable + if fs.NArg() > 1 { + c.Meta.UI.Error("usage: tfcloud variable import [tfvars-file]") + return 1 + } + if fs.NArg() == 1 { + vars, err := terraformcfg.ParseTFVarsFile(fs.Arg(0)) + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + imported = append(imported, vars...) + } + for _, name := range envNames { + value, ok := os.LookupEnv(name) + if !ok { + c.Meta.UI.Error(fmt.Sprintf("environment variable %q is not set", name)) + return 1 + } + imported = append(imported, terraformcfg.ImportedVariable{ + Key: name, + Value: value, + Category: "env", + HCL: false, + Sensitive: true, + }) + } + if len(imported) == 0 { + c.Meta.UI.Error("provide a tfvars file, -e entries, or both") + return 1 + } + + loadConfig := c.loadConfig + if loadConfig == nil { + loadConfig = config.Load + } + + cfg, err := loadConfig() + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + if organization == "" { + organization = cfg.DefaultOrganization + } + + if organization == "" || workspaceName == "" { + cfg, err := terraformcfg.FindCloudConfig(".") + if err == nil { + if organization == "" { + organization = cfg.Organization + } + if workspaceName == "" { + workspaceName = cfg.Workspace + } + } + } + + if variableSetName != "" && organization == "" { + c.Meta.UI.Error("-organization is required when targeting a variable set and no HCP Terraform configuration was found") + return 1 + } + if variableSetName == "" && (organization == "" || workspaceName == "") { + c.Meta.UI.Error("could not resolve target workspace; set -organization and -workspace or run inside a repository with HCP Terraform workspace configuration") + return 1 + } + + apiClient, err := client.New(cfg) + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + target, err := c.resolveTarget(apiClient, organization, workspaceName, variableSetName) + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + existing, err := c.listExistingVariables(apiClient, target) + if err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + + duplicates := make([]string, 0) + for _, variable := range imported { + key := existingKey(variable.Key, variable.Category) + if _, ok := existing[key]; ok && !overwrite { + duplicates = append(duplicates, fmt.Sprintf("%s (%s)", variable.Key, variable.Category)) + } + } + if len(duplicates) > 0 { + c.Meta.UI.Error("variables already exist; rerun with -overwrite to update: " + strings.Join(duplicates, ", ")) + return 1 + } + + created := 0 + updated := 0 + for _, variable := range imported { + key := existingKey(variable.Key, variable.Category) + if current, ok := existing[key]; ok { + if err := c.updateVariable(apiClient, target, current.ID, variable); err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + updated++ + continue + } + if err := c.createVariable(apiClient, target, variable); err != nil { + c.Meta.UI.Error(err.Error()) + return 1 + } + created++ + } + + c.Meta.UI.Output(fmt.Sprintf("imported %d variables into %s (%d created, %d updated)", len(imported), target.DisplayName, created, updated)) + return 0 +} + +type variableTarget struct { + Kind string + ID string + DisplayName string + Path string + ItemPath string +} + +type existingVariable struct { + ID string + Key string + Category string +} + +func (c *VariableImportCommand) resolveTarget(apiClient *client.Client, organization, workspaceName, variableSetName string) (*variableTarget, error) { + if variableSetName != "" { + id, err := c.resolveVariableSet(apiClient, organization, variableSetName) + if err != nil { + return nil, err + } + return &variableTarget{ + Kind: "variable set", + ID: id, + DisplayName: fmt.Sprintf("variable set %q", variableSetName), + Path: fmt.Sprintf("/varsets/%s/relationships/vars", url.PathEscape(id)), + ItemPath: fmt.Sprintf("/varsets/%s/relationships/vars/%%s", url.PathEscape(id)), + }, nil + } + + workspaceID, err := c.resolveWorkspace(apiClient, organization, workspaceName) + if err != nil { + return nil, err + } + return &variableTarget{ + Kind: "workspace", + ID: workspaceID, + DisplayName: fmt.Sprintf("workspace %q", workspaceName), + Path: fmt.Sprintf("/workspaces/%s/vars", url.PathEscape(workspaceID)), + ItemPath: fmt.Sprintf("/workspaces/%s/vars/%%s", url.PathEscape(workspaceID)), + }, nil +} + +func (c *VariableImportCommand) resolveWorkspace(apiClient *client.Client, organization, workspace string) (string, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/workspaces/%s", url.PathEscape(organization), url.PathEscape(workspace))) + if err != nil { + return "", err + } + resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) + } + var payload struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return "", err + } + if payload.Data.ID == "" { + return "", fmt.Errorf("workspace %q returned no id", workspace) + } + return payload.Data.ID, nil +} + +func (c *VariableImportCommand) resolveVariableSet(apiClient *client.Client, organization, name string) (string, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/varsets", url.PathEscape(organization))) + if err != nil { + return "", err + } + query := endpoint.Query() + query.Set("q", name) + endpoint.RawQuery = query.Encode() + + resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) + } + + var payload struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return "", err + } + for _, item := range payload.Data { + if item.Attributes.Name == name { + return item.ID, nil + } + } + + body := map[string]any{ + "data": map[string]any{ + "type": "varsets", + "attributes": map[string]any{ + "name": name, + }, + }, + } + encoded, err := json.Marshal(body) + if err != nil { + return "", err + } + createResp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: encoded}) + if err != nil { + return "", err + } + if createResp.StatusCode < 200 || createResp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", createResp.Status, summarizeAPIErrors(createResp.Body)) + } + var created struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(createResp.Body, &created); err != nil { + return "", err + } + if created.Data.ID == "" { + return "", fmt.Errorf("created variable set %q returned no id", name) + } + return created.Data.ID, nil +} + +func (c *VariableImportCommand) listExistingVariables(apiClient *client.Client, target *variableTarget) (map[string]existingVariable, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) + if err != nil { + return nil, err + } + resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) + } + var payload struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Key string `json:"key"` + Category string `json:"category"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return nil, err + } + existing := make(map[string]existingVariable, len(payload.Data)) + for _, item := range payload.Data { + existing[existingKey(item.Attributes.Key, item.Attributes.Category)] = existingVariable{ID: item.ID, Key: item.Attributes.Key, Category: item.Attributes.Category} + } + return existing, nil +} + +func (c *VariableImportCommand) createVariable(apiClient *client.Client, target *variableTarget, variable terraformcfg.ImportedVariable) error { + endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) + if err != nil { + return err + } + body, err := json.Marshal(variablePayload(variable)) + if err != nil { + return err + } + resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) + } + return nil +} + +func (c *VariableImportCommand) updateVariable(apiClient *client.Client, target *variableTarget, variableID string, variable terraformcfg.ImportedVariable) error { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf(target.ItemPath, url.PathEscape(variableID))) + if err != nil { + return err + } + body, err := json.Marshal(variablePayload(variable)) + if err != nil { + return err + } + resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPatch, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) + } + return nil +} + +func variablePayload(variable terraformcfg.ImportedVariable) map[string]any { + return map[string]any{ + "data": map[string]any{ + "type": "vars", + "attributes": map[string]any{ + "key": variable.Key, + "value": variable.Value, + "category": variable.Category, + "hcl": variable.HCL, + "sensitive": variable.Sensitive, + }, + }, + } +} + +func existingKey(key, category string) string { + return category + "\x00" + key +} + +func jsonAPIHeaders() http.Header { + return http.Header{ + "Accept": []string{"application/vnd.api+json"}, + "Content-Type": []string{"application/vnd.api+json"}, + } +} + +func (c *VariableImportCommand) background() context.Context { return context.Background() } diff --git a/internal/command/variable_import_test.go b/internal/command/variable_import_test.go new file mode 100644 index 0000000..61049ea --- /dev/null +++ b/internal/command/variable_import_test.go @@ -0,0 +1,44 @@ +package command + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "github.com/brandonc/tfcloud/internal/config" + cli "github.com/hashicorp/cli" +) + +func TestVariableImportUsesDefaultOrganizationFromConfig(t *testing.T) { + t.Parallel() + + ui := cli.NewMockUi() + cmd := &VariableImportCommand{ + Meta: &Meta{ + UI: ui, + Stdin: bytes.NewBuffer(nil), + Stdout: ui.OutputWriter, + Stderr: ui.ErrorWriter, + StdoutIsTTY: false, + StderrIsTTY: false, + HumanOutput: true, + }, + loadConfig: func() (*config.Config, error) { + return &config.Config{ + Hostname: "app.terraform.test", + Token: "token", + DefaultOrganization: "config-org", + DefaultHeaders: make(http.Header), + }, nil + }, + } + + code := cmd.Run([]string{"-e", "AWS_REGION", "-variable-set-name", "production"}) + if code != 1 { + t.Fatalf("got exit code %d", code) + } + if got := ui.ErrorWriter.String(); strings.Contains(got, "-organization is required") { + t.Fatalf("expected config default organization to satisfy validation, got %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..21e0b8d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,327 @@ +// Package config resolves CLI configuration and build metadata. +package config + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "unicode" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsimple" + "golang.org/x/net/idna" +) + +// DefaultHostname is the hostname used when no default profile is configured. +const DefaultHostname = "app.terraform.io" + +// Config is the fully resolved CLI configuration after all configuration +// layers have been applied. +type Config struct { + // Hostname is the HCP Terraform hostname to target. + Hostname string + // DefaultOrganization is the fallback organization for commands that need one. + DefaultOrganization string + // Token is the resolved API token used for authentication. + Token string + // DefaultHeaders are added to every outgoing API request. + DefaultHeaders http.Header +} + +type credentialsFile struct { + Credentials map[string]struct { + Token string `json:"token"` + } `json:"credentials"` +} + +type hclConfig struct { + Profiles []hclProfile `hcl:"profile,block"` +} + +type hclProfile struct { + Name string `hcl:",label"` + Hostname string `hcl:",label"` + Token *string `hcl:"token,optional"` + Organization *string `hcl:"organization,optional"` +} + +type fileConfig struct { + ProfileName string + Hostname string + Token string + DefaultOrganization string +} + +type resolvedProfile struct { + Name string + Hostname string +} + +// Load resolves CLI configuration from config files, credentials, and +// environment variables using the default profile. +func Load() (*Config, error) { + profile, err := resolveProfile() + if err != nil { + return nil, err + } + + resolved := fileConfig{ProfileName: profile.Name, Hostname: profile.Hostname} + for _, path := range configSearchPaths() { + cfg, err := loadHCLConfig(path, profile) + if err != nil { + return nil, err + } + resolved = mergeConfig(resolved, cfg) + } + + if resolved.Token == "" { + resolved.Token, err = tokenFromCredentials(profile.Hostname) + if err != nil { + return nil, err + } + } + + if envToken := os.Getenv(profileTokenEnvVar(resolved.ProfileName)); envToken != "" { + resolved.Token = envToken + } else if resolved.Token == "" { + resolved.Token = os.Getenv(legacyTokenEnvVar(profile.Hostname)) + } + + if resolved.Token == "" { + return nil, fmt.Errorf("missing token for %s; set %s, add it to tfcloud.hcl, add it to ~/.terraform.d/credentials.tfrc.json, or use terraform-compatible %s", profile.Hostname, profileTokenEnvVar(resolved.ProfileName), legacyTokenEnvVar(profile.Hostname)) + } + + headers := make(http.Header) + headers.Set("User-Agent", fmt.Sprintf("tfcloud CLI %s", Version)) + + return &Config{ + Hostname: profile.Hostname, + DefaultOrganization: resolved.DefaultOrganization, + Token: resolved.Token, + DefaultHeaders: headers, + }, nil +} + +// resolveProfile selects the active default profile for configuration loading. +func resolveProfile() (resolvedProfile, error) { + profile := resolvedProfile{Name: "default"} + for _, path := range configSearchPaths() { + cfg, err := loadDefaultProfile(path) + if err != nil { + return resolvedProfile{}, err + } + if cfg.Hostname != "" { + profile.Name = cfg.ProfileName + profile.Hostname = cfg.Hostname + } + } + if profile.Hostname == "" { + profile.Hostname = DefaultHostname + } + return profile, nil +} + +func configSearchPaths() []string { + paths := make([]string, 0, 2) + if userConfigDir, err := userConfigDir(); err == nil { + paths = append(paths, filepath.Join(userConfigDir, "tfcloud", "tfcloud.hcl")) + } + paths = append(paths, ".tfcloud.hcl") + return paths +} + +// userConfigDir returns the base directory for user-specific configuration files. On +// most platforms, this the os.UserConfigDir(), but on darwin, if XDG_CONFIG_HOME is not set, +// this falls back to $HOME/.config to align with other unix systems. +func userConfigDir() (string, error) { + if runtime.GOOS == "darwin" && os.Getenv("XDG_CONFIG_HOME") == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config"), nil + } + return os.UserConfigDir() +} + +func loadHCLConfig(path string, target resolvedProfile) (fileConfig, error) { + decoded, err := readHCLConfig(path) + if err != nil { + return fileConfig{}, err + } + + selected, err := selectProfileByName(decoded.Profiles, target.Name) + if err != nil { + return fileConfig{}, fmt.Errorf("parse %s: %w", path, err) + } + if selected == nil { + return fileConfig{}, nil + } + + return profileToFileConfig(*selected), nil +} + +func loadDefaultProfile(path string) (fileConfig, error) { + decoded, err := readHCLConfig(path) + if err != nil { + return fileConfig{}, err + } + + selected, err := selectProfileByName(decoded.Profiles, "default") + if err != nil { + return fileConfig{}, fmt.Errorf("parse %s: %w", path, err) + } + if selected == nil { + return fileConfig{}, nil + } + + return profileToFileConfig(*selected), nil +} + +func readHCLConfig(path string) (hclConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return hclConfig{}, nil + } + return hclConfig{}, err + } + + var decoded hclConfig + if err := hclsimple.Decode(path, data, nil, &decoded); err != nil { + return hclConfig{}, formatHCLError(path, err) + } + return decoded, nil +} + +// selectProfileByName returns the matching profile block for name after +// validating supported profile names and normalizing hostnames. +func selectProfileByName(profiles []hclProfile, name string) (*hclProfile, error) { + var selected *hclProfile + for i := range profiles { + profile := profiles[i] + if err := validateProfileName(profile.Name); err != nil { + return nil, err + } + profile.Hostname = normalizeHostname(profile.Hostname) + if profile.Name == name { + selected = &profile + } + } + return selected, nil +} + +// profileToFileConfig converts a decoded HCL profile block into the internal +// file-backed configuration shape used during layering. +func profileToFileConfig(profile hclProfile) fileConfig { + cfg := fileConfig{ProfileName: profile.Name, Hostname: profile.Hostname} + if profile.Token != nil { + cfg.Token = *profile.Token + } + if profile.Organization != nil { + cfg.DefaultOrganization = *profile.Organization + } + return cfg +} + +func mergeConfig(base fileConfig, overlay fileConfig) fileConfig { + if overlay.ProfileName != "" { + base.ProfileName = overlay.ProfileName + } + if overlay.Hostname != "" { + base.Hostname = overlay.Hostname + } + if overlay.Token != "" { + base.Token = overlay.Token + } + if overlay.DefaultOrganization != "" { + base.DefaultOrganization = overlay.DefaultOrganization + } + return base +} + +func validateProfileName(name string) error { + if name == "default" { + return nil + } + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { + continue + } + return fmt.Errorf("invalid profile name %q; only letters, digits, and underscores are supported", name) + } + return nil +} + +func formatHCLError(path string, err error) error { + if diags, ok := err.(hcl.Diagnostics); ok { + return fmt.Errorf("parse %s: %s", path, strings.TrimSpace(diags.Error())) + } + return fmt.Errorf("parse %s: %w", path, err) +} + +func normalizeHostname(hostname string) string { + hostname = strings.TrimSpace(hostname) + hostname = strings.TrimPrefix(hostname, "https://") + hostname = strings.TrimPrefix(hostname, "http://") + hostname = strings.TrimRight(hostname, "/") + if asciiHost, err := idna.Lookup.ToASCII(hostname); err == nil { + return asciiHost + } + return hostname +} + +func profileTokenEnvVar(profileName string) string { + if profileName == "" || profileName == "default" { + return "TFCLOUD_TOKEN" + } + return "TFCLOUD_TOKEN_" + profileName +} + +func legacyTokenEnvVar(hostname string) string { + hostname = normalizeHostname(hostname) + + var b strings.Builder + b.WriteString("TF_TOKEN_") + for _, r := range strings.ToUpper(hostname) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + continue + } + b.WriteRune('_') + } + return b.String() +} + +func tokenFromCredentials(hostname string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + path := filepath.Join(home, ".terraform.d", "credentials.tfrc.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var creds credentialsFile + if err := json.Unmarshal(data, &creds); err != nil { + return "", fmt.Errorf("parse %s: %w", path, err) + } + + hostname = normalizeHostname(hostname) + entry, ok := creds.Credentials[hostname] + if !ok { + return "", nil + } + + return entry.Token, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..aa5f30b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,324 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProfileTokenEnvVar(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "": "TFCLOUD_TOKEN", + "default": "TFCLOUD_TOKEN", + "work": "TFCLOUD_TOKEN_work", + "dev_2026": "TFCLOUD_TOKEN_dev_2026", + } + + for input, want := range tests { + if got := profileTokenEnvVar(input); got != want { + t.Fatalf("profileTokenEnvVar(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLegacyTokenEnvVar(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "app.terraform.io": "TF_TOKEN_APP_TERRAFORM_IO", + "tfe.example-host.com": "TF_TOKEN_TFE_EXAMPLE_HOST_COM", + "xn--bcher-kva.example": "TF_TOKEN_XN__BCHER_KVA_EXAMPLE", + "xn--caf-dma.fr": "TF_TOKEN_XN__CAF_DMA_FR", + "app.terraform.io:443": "TF_TOKEN_APP_TERRAFORM_IO_443", + } + + for input, want := range tests { + if got := legacyTokenEnvVar(input); got != want { + t.Fatalf("legacyTokenEnvVar(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLoadUsesDefaultProfileAndEnvToken(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.eu.terraform.io" { + token = "user-token" + organization = "ops" +}`) + t.Setenv("TFCLOUD_TOKEN", "env-token") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Hostname != "app.eu.terraform.io" { + t.Fatalf("hostname = %q", cfg.Hostname) + } + if cfg.Token != "env-token" { + t.Fatalf("token = %q", cfg.Token) + } + if cfg.DefaultOrganization != "ops" { + t.Fatalf("default organization = %q", cfg.DefaultOrganization) + } +} + +func TestLoadUsesLayeredHCLPrecedence(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + token = "user-token" + organization = "user-org" +}`) + env.writeLocalConfig(`profile "default" "app.terraform.io" { + organization = "local-org" +}`) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Hostname != "app.terraform.io" { + t.Fatalf("hostname = %q", cfg.Hostname) + } + if cfg.Token != "user-token" { + t.Fatalf("token = %q", cfg.Token) + } + if cfg.DefaultOrganization != "local-org" { + t.Fatalf("default organization = %q", cfg.DefaultOrganization) + } + if got := cfg.DefaultHeaders.Get("User-Agent"); !strings.HasPrefix(got, "tfcloud CLI ") { + t.Fatalf("user-agent = %q", got) + } +} + +func TestLoadUsesLocalDefaultHostnameWhenNotExplicit(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + token = "user-token" +}`) + env.writeLocalConfig(`profile "default" "app.eu.terraform.io" { + token = "local-token" + organization = "local-org" +}`) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Hostname != "app.eu.terraform.io" { + t.Fatalf("hostname = %q", cfg.Hostname) + } + if cfg.Token != "local-token" { + t.Fatalf("token = %q", cfg.Token) + } + if cfg.DefaultOrganization != "local-org" { + t.Fatalf("default organization = %q", cfg.DefaultOrganization) + } +} + +func TestLoadFallsBackToCredentialsWhenHCLTokenMissing(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + organization = "from-hcl" +}`) + env.writeCredentials(`{"credentials":{"app.terraform.io":{"token":"cred-token"}}}`) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Token != "cred-token" { + t.Fatalf("token = %q", cfg.Token) + } + if cfg.DefaultOrganization != "from-hcl" { + t.Fatalf("default organization = %q", cfg.DefaultOrganization) + } +} + +func TestLoadEnvOverridesCredentialsAndHCLToken(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + token = "user-token" +}`) + env.writeCredentials(`{"credentials":{"app.terraform.io":{"token":"cred-token"}}}`) + t.Setenv("TFCLOUD_TOKEN", "env-token") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Token != "env-token" { + t.Fatalf("token = %q", cfg.Token) + } +} + +func TestLoadIgnoresNamedProfileEnvTokenWhenDefaultProfileIsActive(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + organization = "ops" +}`) + t.Setenv("TFCLOUD_TOKEN_work", "env-token") + + _, err := Load() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "missing token") { + t.Fatalf("error = %v", err) + } +} + +func TestLoadDefaultsHostnameWithoutConfig(t *testing.T) { + env := newConfigTestEnv(t) + t.Setenv("TFCLOUD_TOKEN", "env-token") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Hostname != DefaultHostname { + t.Fatalf("hostname = %q", cfg.Hostname) + } + if cfg.Token != "env-token" { + t.Fatalf("token = %q", cfg.Token) + } + _ = env + if cfg.DefaultOrganization != "" { + t.Fatalf("default organization = %q", cfg.DefaultOrganization) + } +} + +func TestLoadFallsBackToLegacyHostnameEnvVar(t *testing.T) { + env := newConfigTestEnv(t) + t.Setenv("TF_TOKEN_APP_TERRAFORM_IO", "legacy-token") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Token != "legacy-token" { + t.Fatalf("token = %q", cfg.Token) + } + _ = env +} + +func TestLoadRejectsMalformedHCL(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" {`) + + _, err := Load() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "parse") { + t.Fatalf("error = %v", err) + } +} + +func TestLoadRejectsUnsupportedProfileName(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "bad-name" "app.terraform.io" { + token = "token" +}`) + + _, err := Load() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "invalid profile name") { + t.Fatalf("error = %v", err) + } +} + +func TestLoadErrorsWhenTokenStillMissing(t *testing.T) { + env := newConfigTestEnv(t) + env.writeUserConfig(`profile "default" "app.terraform.io" { + organization = "bcroft" +}`) + + _, err := Load() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "missing token") { + t.Fatalf("error = %v", err) + } +} + +type configTestEnv struct { + t *testing.T + root string + home string + userConfigDir string + originalWD string +} + +func newConfigTestEnv(t *testing.T) *configTestEnv { + t.Helper() + + root := t.TempDir() + home := filepath.Join(root, "home") + workingDir := filepath.Join(home, "work") + xdgConfigHome := filepath.Join(home, ".config") + for _, dir := range []string{home, workingDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + originalWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(workingDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + + t.Logf("XDG_CONFIG_HOME = %q", os.Getenv("XDG_CONFIG_HOME")) + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + userConfigDir, err := userConfigDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(userConfigDir, 0o755); err != nil { + t.Fatal(err) + } + + return &configTestEnv{t: t, root: root, home: home, userConfigDir: userConfigDir, originalWD: originalWD} +} + +func (e *configTestEnv) writeUserConfig(body string) { + e.t.Helper() + path := filepath.Join(e.userConfigDir, "tfcloud", "tfcloud.hcl") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + e.t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + e.t.Fatal(err) + } +} + +func (e *configTestEnv) writeLocalConfig(body string) { + e.t.Helper() + if err := os.WriteFile(".tfcloud.hcl", []byte(body), 0o600); err != nil { + e.t.Fatal(err) + } +} + +func (e *configTestEnv) writeCredentials(body string) { + e.t.Helper() + path := filepath.Join(e.home, ".terraform.d", "credentials.tfrc.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + e.t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + e.t.Fatal(err) + } +} diff --git a/internal/config/version.go b/internal/config/version.go new file mode 100644 index 0000000..37fefd8 --- /dev/null +++ b/internal/config/version.go @@ -0,0 +1,68 @@ +// Copyright IBM Corp. 2020, 2026 + +package config + +import ( + "fmt" + "time" + + "github.com/Masterminds/semver/v3" +) + +// Name is the application name used throughout the CLI. +const Name = "tfcloud" + +var ( + // Version defines what version this application is currently running as. + // This needs to be a variable rather than a constant as we use build + // arguments to overwrite this when we release a new version. + version = "dev" + + // Version defines what version this application is currently running as. It + // is the publicly used version, which will be prefixed with a `v` if it is + // a SemVer version. + Version = publicVersion(version) + + // Commit defines the git commit used for this specific version. + Commit = "HEAD" + + // committedTime defines the time at which the compiled binary's latest git + // commit was committed. This needs to be a string so the build flags can + // overwrite it upon building official releases. + committedTime = "" + + // CommitTime is the exposed time.Time version of the commitTime. It's + // introduced so we can do time comparison as desired. + CommitTime = mustParseTime(committedTime) +) + +// IsDev returns true if the current version is a development version. +func IsDev() bool { + return version == "dev" +} + +// mustParseTime will parse a time string and panic if it is not able to. +func mustParseTime(ts string) time.Time { + if ts == "" { + ts = time.Now().Format(time.RFC3339) + } + + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + panic(err) + } + return t +} + +// publicVersion takes a version string and converts it into a publicly +// displayable string. This means that if the given version string is a SemVer +// version, we will ensure it is prefixed with a `v`. If not, we will leave the +// version as is and return it. +func publicVersion(v string) string { + sv, err := semver.StrictNewVersion(v) + if err != nil { + return v + } + + return fmt.Sprintf("v%s", sv.String()) +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..54c63e3 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,114 @@ +// Package git inspects the current repository and parses git remote metadata. +package git + +import ( + "fmt" + "net/url" + "os/exec" + "path" + "strings" +) + +// RepoContext describes the current git repository and selected remote. +type RepoContext struct { + // Root is the repository root directory. + Root string + // RemoteName is the git remote name that was inspected. + RemoteName string + // RemoteURL is the git remote URL. + RemoteURL string + // RepoOwner is the remote repository owner or namespace. + RepoOwner string + // RepoName is the remote repository name. + RepoName string + // RepoSlug is the owner and repository name joined as owner/repo. + RepoSlug string + // RemoteHost is the remote repository host. + RemoteHost string + // CurrentBranch is the currently checked out branch name. + CurrentBranch string +} + +// Inspect gathers repository details for the current working tree and remote. +func Inspect(remote string) (*RepoContext, error) { + if remote == "" { + remote = "origin" + } + + root, err := runGit("rev-parse", "--show-toplevel") + if err != nil { + return nil, fmt.Errorf("not a git repository: %w", err) + } + + remoteURL, err := runGit("remote", "get-url", remote) + if err != nil { + return nil, fmt.Errorf("read git remote %q: %w", remote, err) + } + + host, owner, repo, err := ParseRemoteURL(remoteURL) + if err != nil { + return nil, err + } + + branch, err := runGit("branch", "--show-current") + if err != nil { + branch = "" + } + + return &RepoContext{ + Root: root, + RemoteName: remote, + RemoteURL: remoteURL, + RepoOwner: owner, + RepoName: repo, + RepoSlug: owner + "/" + repo, + RemoteHost: host, + CurrentBranch: branch, + }, nil +} + +// ParseRemoteURL parses a git remote URL into host, owner, and repository name. +func ParseRemoteURL(raw string) (host, owner, repo string, err error) { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "git@") { + parts := strings.SplitN(strings.TrimPrefix(trimmed, "git@"), ":", 2) + if len(parts) != 2 { + return "", "", "", fmt.Errorf("unsupported git remote %q", raw) + } + host = parts[0] + owner, repo, err = splitPath(parts[1]) + return host, owner, repo, err + } + + if strings.HasPrefix(trimmed, "ssh://") || strings.HasPrefix(trimmed, "https://") || strings.HasPrefix(trimmed, "http://") { + u, parseErr := url.Parse(trimmed) + if parseErr != nil { + return "", "", "", parseErr + } + host = u.Hostname() + owner, repo, err = splitPath(strings.TrimPrefix(u.Path, "/")) + return host, owner, repo, err + } + + return "", "", "", fmt.Errorf("unsupported git remote %q", raw) +} + +func splitPath(p string) (string, string, error) { + p = strings.TrimSuffix(p, ".git") + parts := strings.Split(strings.Trim(p, "/"), "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("unsupported repository path %q", p) + } + repo := parts[len(parts)-1] + owner := path.Join(parts[:len(parts)-1]...) + return owner, repo, nil +} + +func runGit(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/render/json.go b/internal/render/json.go new file mode 100644 index 0000000..1c5c04e --- /dev/null +++ b/internal/render/json.go @@ -0,0 +1,16 @@ +// Package render formats API responses for human-readable CLI output. +package render + +import ( + "bytes" + "encoding/json" +) + +// PrettyJSON indents JSON for human-readable output. +func PrettyJSON(raw []byte) string { + var out bytes.Buffer + if err := json.Indent(&out, raw, "", " "); err != nil { + return string(raw) + } + return out.String() +} diff --git a/internal/render/jsonapi_table.go b/internal/render/jsonapi_table.go new file mode 100644 index 0000000..e64794e --- /dev/null +++ b/internal/render/jsonapi_table.go @@ -0,0 +1,411 @@ +package render + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strconv" + "strings" +) + +// MaxTableColumns is the maximum number of columns shown in horizontal table output. +const MaxTableColumns = 6 + +type rowValue struct { + text string + styled string + visible int +} + +var ( + ansiReset = "\x1b[0m" + ansiGreen = "\x1b[32m" + ansiRed = "\x1b[31m" + ansiMagenta = "\x1b[35m" + ansiBlueBold = "\x1b[1;94m" +) + +// JSONAPITable renders JSON:API resource data as a human-readable table when possible. +func JSONAPITable(raw []byte) (string, bool, error) { + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "", false, err + } + + data, ok := payload["data"] + if !ok { + return "", false, nil + } + + var rows []map[string]any + switch typed := data.(type) { + case []any: + for _, item := range typed { + row, ok := flattenResource(item) + if !ok { + return "", false, nil + } + rows = append(rows, row) + } + case map[string]any: + row, ok := flattenResource(typed) + if !ok { + return "", false, nil + } + return renderVerticalTable(row, typeColumns[stringValue(row["type"])]), true, nil + default: + return "", false, nil + } + + if len(rows) == 0 { + return "", false, nil + } + + resourceType := stringValue(rows[0]["type"]) + return renderHorizontalTable(rows, typeColumns[resourceType], excludeColumns[resourceType]), true, nil +} + +func renderHorizontalTable(rows []map[string]any, preferred []string, exclude []string) string { + columns := collectColumns(rows, preferred, exclude) + headerValues := make([]rowValue, len(columns)) + widths := make([]int, len(columns)) + for i, col := range columns { + headerValues[i] = formatLabel(col) + widths[i] = headerValues[i].visible + } + + renderedRows := make([][]rowValue, len(rows)) + for i, row := range rows { + renderedRows[i] = make([]rowValue, len(columns)) + for j, col := range columns { + value, ok := row[col] + if !ok { + renderedRows[i][j] = rowValue{} + continue + } + value = summarizeNestedValue(value) + rendered := formatScalar(value) + renderedRows[i][j] = rendered + if rendered.visible > widths[j] { + widths[j] = rendered.visible + } + } + } + + lines := make([]string, 0, len(rows)+1) + lines = append(lines, renderAlignedRow(headerValues, widths)) + for _, row := range renderedRows { + lines = append(lines, renderAlignedRow(row, widths)) + } + return strings.Join(lines, "\n") +} + +func renderVerticalTable(row map[string]any, preferred []string) string { + keys := orderedFields(row, preferred) + width := 0 + for _, key := range keys { + if len(key) > width { + width = len(key) + } + } + + lines := make([]string, 0, len(keys)) + for _, key := range keys { + value := row[key] + label := formatLabel(key) + if nested, ok := value.(map[string]any); ok { + lines = append(lines, label.styled+strings.Repeat(" ", width-label.visible)+" \\") + lines = append(lines, renderNestedMap(nested)...) + continue + } + if items, ok := value.([]any); ok { + if len(items) == 0 { + formatted := formatScalar(items) + lines = append(lines, label.styled+strings.Repeat(" ", width-label.visible)+" "+formatted.styled) + continue + } + if isSimpleArray(items) { + lines = append(lines, label.styled+strings.Repeat(" ", width-label.visible)+" \\") + lines = append(lines, renderSimpleArray(items)...) + continue + } + } + formatted := formatScalar(summarizeNestedValue(value)) + lines = append(lines, label.styled+strings.Repeat(" ", width-label.visible)+" "+formatted.styled) + } + return strings.Join(lines, "\n") +} + +func renderNestedMap(value map[string]any) []string { + keys := sortedKeys(value) + width := 0 + for _, key := range keys { + if len(key) > width { + width = len(key) + } + } + + lines := make([]string, 0, len(keys)) + for _, key := range keys { + label := formatLabel(key) + formatted := formatScalar(summarizeNestedValue(value[key])) + lines = append(lines, " "+label.styled+strings.Repeat(" ", width-label.visible)+" "+formatted.styled) + } + return lines +} + +func renderSimpleArray(value []any) []string { + lines := make([]string, 0, len(value)) + for _, item := range value { + formatted := formatScalar(item) + lines = append(lines, " - "+formatted.styled) + } + return lines +} + +func flattenResource(item any) (map[string]any, bool) { + obj, ok := item.(map[string]any) + if !ok { + return nil, false + } + + row := map[string]any{} + if id, ok := obj["id"]; ok { + row["id"] = id + } + if kind, ok := obj["type"]; ok { + row["type"] = kind + } + + attrs, ok := obj["attributes"] + if !ok { + return row, len(row) > 0 + } + attrMap, ok := attrs.(map[string]any) + if !ok { + return nil, false + } + for key, value := range attrMap { + row[key] = value + } + return row, true +} + +func collectColumns(rows []map[string]any, preferred []string, exclude []string) []string { + seen := map[string]struct{}{} + for _, row := range rows { + for key := range row { + seen[key] = struct{}{} + } + } + + columns := make([]string, 0, len(seen)) + if _, ok := seen["id"]; ok { + columns = append(columns, "id") + delete(seen, "id") + if len(columns) >= MaxTableColumns { + return columns + } + } + + for _, key := range preferred { + if _, ok := seen[key]; ok { + columns = append(columns, key) + delete(seen, key) + if len(columns) >= MaxTableColumns { + return columns + } + } + } + + remaining := make([]string, 0, len(seen)) + for key := range seen { + if exclude == nil || !slices.Contains(exclude, key) { + remaining = append(remaining, key) + } + } + + sort.Strings(remaining) + if len(remaining) < MaxTableColumns-len(columns) { + return append(columns, remaining...) + } + return append(columns, remaining[:MaxTableColumns-len(columns)]...) +} + +func orderedFields(row map[string]any, preferred []string) []string { + seen := make(map[string]struct{}, len(row)) + ordered := make([]string, 0, len(row)) + if _, ok := row["id"]; ok { + ordered = append(ordered, "id") + seen["id"] = struct{}{} + } + for _, key := range preferred { + if _, ok := row[key]; ok { + ordered = append(ordered, key) + seen[key] = struct{}{} + } + } + + remaining := make([]string, 0, len(row)-len(ordered)) + nested := make([]string, 0, len(row)) + typeTrailer := false + for key := range row { + if _, ok := seen[key]; ok { + continue + } + if key == "type" { + typeTrailer = true + continue + } + if isNestedValue(row[key]) { + nested = append(nested, key) + continue + } + remaining = append(remaining, key) + } + sort.Strings(remaining) + sort.Strings(nested) + ordered = append(ordered, remaining...) + ordered = append(ordered, nested...) + if typeTrailer { + ordered = append(ordered, "type") + } + return ordered +} + +func renderAlignedRow(values []rowValue, widths []int) string { + parts := make([]string, len(values)) + for i, value := range values { + padding := widths[i] - value.visible + parts[i] = value.styled + strings.Repeat(" ", padding) + } + return strings.TrimRight(strings.Join(parts, " "), " ") +} + +func formatScalar(value any) rowValue { + text := stringValue(value) + styled := text + switch v := value.(type) { + case nil: + styled = ansiRed + text + ansiReset + case bool: + if v { + styled = ansiGreen + text + ansiReset + } else { + styled = ansiRed + text + ansiReset + } + case float64: + if v > 0 { + styled = ansiGreen + text + ansiReset + } else { + styled = ansiRed + text + ansiReset + } + case int: + if v > 0 { + styled = ansiGreen + text + ansiReset + } else { + styled = ansiRed + text + ansiReset + } + case int64: + if v > 0 { + styled = ansiGreen + text + ansiReset + } else { + styled = ansiRed + text + ansiReset + } + case json.Number: + if numericValue(v.String()) > 0 { + styled = ansiGreen + text + ansiReset + } else { + styled = ansiRed + text + ansiReset + } + case []any: + if len(v) == 0 { + styled = ansiRed + text + ansiReset + } + case string: + if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") { + styled = ansiMagenta + text + ansiReset + } + } + return rowValue{text: text, styled: styled, visible: len(text)} +} + +func formatLabel(text string) rowValue { + return rowValue{text: text, styled: ansiBlueBold + text + ansiReset, visible: len(text)} +} + +func numericValue(raw string) float64 { + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0 + } + return value +} + +func summarizeNestedValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return "{...}" + case []any: + if len(typed) == 0 { + return typed + } + return "[...]" + default: + return typed + } +} + +func isSimpleArray(value []any) bool { + for _, item := range value { + if isNestedValue(item) { + return false + } + } + return true +} + +func stringValue(value any) string { + switch v := value.(type) { + case nil: + return "null" + case string: + return v + case bool, float64, int, int64: + return fmt.Sprint(v) + case json.Number: + return v.String() + default: + encoded, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(encoded) + } +} + +func sortedKeys(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + leftNested := isNestedValue(value[keys[i]]) + rightNested := isNestedValue(value[keys[j]]) + if leftNested != rightNested { + return !leftNested + } + return keys[i] < keys[j] + }) + return keys +} + +func isNestedValue(value any) bool { + switch value.(type) { + case map[string]any, []any: + return true + default: + return false + } +} diff --git a/internal/render/jsonapi_table_test.go b/internal/render/jsonapi_table_test.go new file mode 100644 index 0000000..0009027 --- /dev/null +++ b/internal/render/jsonapi_table_test.go @@ -0,0 +1,366 @@ +package render + +import ( + "reflect" + "regexp" + "strings" + "testing" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func hasBlueBoldANSI(s string) bool { + return strings.Contains(s, "\x1b[1;") && strings.Contains(s, "94m") +} + +func TestJSONAPITable(t *testing.T) { + t.Parallel() + + t.Run("renders horizontal table with aligned preferred columns and colored values", func(t *testing.T) { + body := []byte(`{ + "data": [ + {"id":"ws-1","type":"workspaces","attributes":{"name":"alpha","description":"one","locked":true,"resource-count":3}}, + {"id":"ws-2","type":"workspaces","attributes":{"name":"beta","locked":false,"resource-count":0}} + ] + }`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + lines := strings.Split(table, "\n") + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3: %q", len(lines), table) + } + + plainLines := make([]string, len(lines)) + for i, line := range lines { + plainLines[i] = stripANSI(line) + } + + for i, want := range []string{ + "id name description locked resource-count type", + "ws-1 alpha one true 3 workspaces", + "ws-2 beta false 0 workspaces", + } { + if strings.TrimRight(plainLines[i], " ") != want { + t.Fatalf("line %d = %q, want %q", i+1, plainLines[i], want) + } + } + + headerStarts := fieldStarts(plainLines[0]) + rowStarts := fieldStarts(plainLines[1]) + if !reflect.DeepEqual(headerStarts, rowStarts) { + t.Fatalf("header columns %v do not align with row columns %v", headerStarts, rowStarts) + } + + if !strings.Contains(lines[1], "\x1b[") || !strings.Contains(lines[2], "\x1b[") { + t.Fatalf("expected colored values in rows: %q", table) + } + if !hasBlueBoldANSI(lines[0]) { + t.Fatalf("expected styled header row: %q", lines[0]) + } + }) + + t.Run("renders single resource vertically with aligned values and no header formatting", func(t *testing.T) { + body := []byte(`{ + "data": { + "id":"run-1", + "type":"runs", + "attributes":{ + "status":"planned_and_finished", + "message":"deploy app", + "has-changes":true, + "metadata":{"source":"cli"} + } + } + }`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + lines := strings.Split(table, "\n") + plainLines := make([]string, len(lines)) + for i, line := range lines { + plainLines[i] = stripANSI(line) + } + + for i, want := range []string{ + "id run-1", + "message deploy app", + "status planned_and_finished", + "has-changes true", + "metadata \\", + " source cli", + "type runs", + } { + if i >= len(plainLines) { + t.Fatalf("missing line %d in %q", i+1, table) + } + if strings.TrimRight(plainLines[i], " ") != want { + t.Fatalf("line %d = %q, want %q", i+1, plainLines[i], want) + } + } + + if !hasBlueBoldANSI(lines[0]) { + t.Fatalf("expected styled vertical label: %q", lines[0]) + } + if !strings.Contains(lines[3], "\x1b[") { + t.Fatalf("expected colored boolean value: %q", lines[3]) + } + if !hasBlueBoldANSI(lines[4]) { + t.Fatalf("expected styled nested label row: %q", lines[4]) + } + + valueStarts := []int{} + for _, line := range []string{plainLines[0], plainLines[1], plainLines[2], plainLines[3], plainLines[6]} { + valueStarts = append(valueStarts, valueStart(line)) + } + for i := 1; i < len(valueStarts); i++ { + if valueStarts[i] != valueStarts[0] { + t.Fatalf("expected aligned vertical values, got starts %v", valueStarts) + } + } + }) + + t.Run("renders unknown single resource in stable fallback order", func(t *testing.T) { + body := []byte(`{ + "data": { + "id":"thing-1", + "type":"widgets", + "attributes":{"beta":"two","alpha":"one"} + } + }`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + got := strings.Split(stripANSI(table), "\n") + for i, want := range []string{"id thing-1", "alpha one", "beta two", "type widgets"} { + if got[i] != want { + t.Fatalf("line %d = %q, want %q", i+1, got[i], want) + } + } + }) + + t.Run("renders single resource with id and type only", func(t *testing.T) { + body := []byte(`{"data":{"id":"org-1","type":"organizations"}}`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + got := strings.Split(stripANSI(table), "\n") + want := []string{"id org-1", "type organizations"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + }) + + t.Run("renders top level scalar arrays vertically and summarizes nested arrays", func(t *testing.T) { + body := []byte(`{ + "data": { + "id":"ws-1", + "type":"workspaces", + "attributes":{ + "empty-values":[], + "tag-names":["foo","bar","baz"], + "structured-values":[{"name":"foo"}], + "nested-scalars":[["foo"]] + } + } + }`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + lines := strings.Split(table, "\n") + got := strings.Split(stripANSI(table), "\n") + want := []string{ + "id ws-1", + "empty-values []", + "nested-scalars [...]", + "structured-values [...]", + "tag-names \\", + " - foo", + " - bar", + " - baz", + "type workspaces", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + if !strings.Contains(lines[1], "\x1b[31m[]\x1b[0m") { + t.Fatalf("expected empty array to be red: %q", table) + } + }) + + t.Run("renders nested json values one level deep and summarizes deeper nesting", func(t *testing.T) { + body := []byte(`{ + "data": { + "id":"user-1", + "type":"users", + "attributes":{ + "password":null, + "permissions":{ + "can-change-email":false, + "can-change-password":false, + "can-change-username":false, + "can-manage-hcp-account":false, + "account-permissions":{"billing":true} + }, + "two-factor":{ + "enabled":true, + "verified":true + } + } + } + }`) + + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + got := strings.Split(stripANSI(table), "\n") + want := []string{ + "id user-1", + "password null", + "permissions \\", + " can-change-email false", + " can-change-password false", + " can-change-username false", + " can-manage-hcp-account false", + " account-permissions {...}", + "two-factor \\", + " enabled true", + " verified true", + "type users", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + if !strings.Contains(table, "\x1b[31mnull\x1b[0m") { + t.Fatalf("expected null value to be red: %q", table) + } + + nestedStarts := []int{} + for _, line := range []string{got[3], got[4], got[5], got[6], got[7]} { + nestedStarts = append(nestedStarts, valueStart(line)) + } + for i := 1; i < len(nestedStarts); i++ { + if nestedStarts[i] != nestedStarts[0] { + t.Fatalf("expected aligned nested values, got starts %v", nestedStarts) + } + } + }) + + t.Run("returns false for empty payload or non jsonapi shape", func(t *testing.T) { + for _, body := range [][]byte{[]byte(`{}`), []byte(`{"errors":[{"title":"bad"}]}`)} { + table, ok, err := JSONAPITable(body) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("expected ok=false, got table %q", table) + } + } + }) + + t.Run("uses preferred columns for additional resource types", func(t *testing.T) { + cases := map[string]struct { + body []byte + want []string + }{ + "projects": { + body: []byte(`{"data":[{"id":"prj-1","type":"projects","attributes":{"name":"core","description":"shared","organization-name":"acme","irrelevant":"x"}}]}`), + want: []string{"id", "name", "description", "organization-name"}, + }, + "organizations": { + body: []byte(`{"data":[{"id":"org-1","type":"organizations","attributes":{"name":"acme","email":"ops@example.com","external-id":"ext-1","irrelevant":"x"}}]}`), + want: []string{"id", "name", "email", "external-id"}, + }, + "vars": { + body: []byte(`{"data":[{"id":"var-1","type":"vars","attributes":{"key":"AWS_REGION","value":"us-east-1","category":"env","hcl":false,"irrelevant":"x"}}]}`), + want: []string{"id", "key", "value", "category", "hcl"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + table, ok, err := JSONAPITable(tc.body) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected table output") + } + + headers := strings.Fields(stripANSI(strings.Split(table, "\n")[0])) + if !reflect.DeepEqual(headers[:len(tc.want)], tc.want) { + t.Fatalf("headers = %#v, want prefix %#v", headers, tc.want) + } + }) + } + }) +} + +func fieldStarts(line string) []int { + starts := []int{} + inField := false + for i, r := range line { + if r != ' ' && !inField { + starts = append(starts, i) + inField = true + continue + } + if r == ' ' { + inField = false + } + } + return starts +} + +func valueStart(line string) int { + seenGap := false + for i := 0; i < len(line); i++ { + if line[i] == ' ' { + seenGap = true + continue + } + if seenGap { + return i + } + } + return -1 +} diff --git a/internal/render/type_columns.go b/internal/render/type_columns.go new file mode 100644 index 0000000..9e2c448 --- /dev/null +++ b/internal/render/type_columns.go @@ -0,0 +1,32 @@ +package render + +// typeColumns is a mapping of API resource types to their preferred columns for horizontal table rendering. +var typeColumns = map[string][]string{ + "agent-pools": {"name", "organization-scoped", "agent-count"}, + "applies": {"status", "status-timestamps", "log-read-url"}, + "configuration-versions": {"status", "speculative", "provisional"}, + "cost-estimates": {"status", "delta-monthly-cost", "proposed-monthly-cost"}, + "notification-configurations": {"name", "destination-type", "enabled", "triggers"}, + "organization-memberships": {"email", "status", "role"}, + "organizations": {"name", "email", "external-id"}, + "plan-exports": {"status", "data-type", "url"}, + "plans": {"status", "has-changes", "generated-configuration"}, + "policy-checks": {"status", "scope", "actions", "permissions"}, + "policy-evaluations": {"status", "result-count", "passed"}, + "policy-sets": {"name", "kind", "global", "overridable"}, + "projects": {"name", "description", "organization-name"}, + "run-tasks": {"name", "url", "category", "enabled"}, + "run-triggers": {"name", "sourceable-name", "workspace-name"}, + "runs": {"message", "status", "is-destroy", "has-changes"}, + "state-version-outputs": {"name", "sensitive", "type"}, + "state-versions": {"serial", "status", "resource-count", "size"}, + "subscriptions": {"status", "plan-name", "quantity"}, + "task-stages": {"status", "stage", "task-result-count"}, + "varsets": {"name", "description", "global", "priority"}, + "vars": {"key", "value", "category", "hcl", "sensitive"}, + "workspaces": {"name", "description", "execution-mode", "locked", "resource-count"}, +} + +var excludeColumns = map[string][]string{ + "workspaces": {"actions"}, +} diff --git a/internal/terraform/workspace.go b/internal/terraform/workspace.go new file mode 100644 index 0000000..6c8df5b --- /dev/null +++ b/internal/terraform/workspace.go @@ -0,0 +1,270 @@ +// Package terraform reads local Terraform configuration and tfvars inputs. +package terraform + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +// CloudConfig describes the organization and workspace discovered from Terraform configuration. +type CloudConfig struct { + // Organization is the configured HCP Terraform organization name. + Organization string + // Workspace is the configured HCP Terraform workspace name. + Workspace string +} + +// ImportedVariable describes a variable ready to send to the HCP Terraform API. +type ImportedVariable struct { + // Key is the variable name. + Key string + // Value is the serialized variable value. + Value string + // Category is the HCP Terraform variable category, such as terraform or env. + Category string + // HCL reports whether Value should be interpreted as HCL. + HCL bool + // Sensitive reports whether the variable should be marked sensitive. + Sensitive bool +} + +// FindCloudConfig scans the given directory for Terraform cloud or remote workspace configuration. +func FindCloudConfig(root string) (*CloudConfig, error) { + parser := hclparse.NewParser() + var result *CloudConfig + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() && d.Name() != root { + return filepath.SkipDir + } + if filepath.Ext(path) != ".tf" { + return nil + } + + file, diags := parser.ParseHCLFile(path) + if diags.HasErrors() { + return nil + } + + cfg := extractCloudConfig(file.Body) + if cfg != nil { + result = cfg + return filepath.SkipAll + } + return nil + }) + if err != nil && err != filepath.SkipAll { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("no terraform cloud/remote workspace configuration found") + } + return result, nil +} + +// ParseTFVarsFile parses an HCL or JSON tfvars file into importable variables. +func ParseTFVarsFile(path string) ([]ImportedVariable, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if strings.HasSuffix(path, ".json") { + return parseTFVarsJSON(data) + } + + file, diags := hclsyntax.ParseConfig(data, path, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, fmt.Errorf("%s", diags.Error()) + } + + attrs, diags := file.Body.JustAttributes() + if diags.HasErrors() { + return nil, fmt.Errorf("%s", diags.Error()) + } + + vars := make([]ImportedVariable, 0, len(attrs)) + for name, attr := range attrs { + value, diags := attr.Expr.Value(nil) + if diags.HasErrors() { + return nil, fmt.Errorf("evaluate %s: %s", name, diags.Error()) + } + imported, err := importedVariableFromCTY(name, value) + if err != nil { + return nil, err + } + vars = append(vars, imported) + } + return vars, nil +} + +func extractCloudConfig(body hcl.Body) *CloudConfig { + content, _, diags := body.PartialContent(&hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{{Type: "terraform"}}, + }) + if diags.HasErrors() { + return nil + } + + for _, block := range content.Blocks { + terraformBody, diags := block.Body.Content(&hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: "cloud"}, + {Type: "backend", LabelNames: []string{"type"}}, + }, + }) + if diags.HasErrors() { + continue + } + + for _, nested := range terraformBody.Blocks { + switch nested.Type { + case "cloud": + if cfg := extractWorkspaceBlock(nested.Body, "organization"); cfg != nil { + return cfg + } + case "backend": + if len(nested.Labels) == 1 && nested.Labels[0] == "remote" { + if cfg := extractWorkspaceBlock(nested.Body, "organization"); cfg != nil { + return cfg + } + } + } + } + } + + return nil +} + +func extractWorkspaceBlock(body hcl.Body, organizationAttr string) *CloudConfig { + content, diags := body.Content(&hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{{Name: organizationAttr}}, + Blocks: []hcl.BlockHeaderSchema{{Type: "workspaces"}}, + }) + if diags.HasErrors() { + return nil + } + + org := attrString(content.Attributes[organizationAttr]) + if org == "" { + return nil + } + + for _, block := range content.Blocks { + workspaceBody, diags := block.Body.Content(&hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{{Name: "name"}}, + }) + if diags.HasErrors() { + continue + } + name := attrString(workspaceBody.Attributes["name"]) + if name != "" { + return &CloudConfig{Organization: org, Workspace: name} + } + } + + return nil +} + +func attrString(attr *hcl.Attribute) string { + if attr == nil { + return "" + } + value, diags := attr.Expr.Value(nil) + if diags.HasErrors() || value.IsNull() || value.Type() != cty.String { + return "" + } + return value.AsString() +} + +func parseTFVarsJSON(data []byte) ([]ImportedVariable, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + + vars := make([]ImportedVariable, 0, len(payload)) + for key, value := range payload { + imported, err := importedVariableFromJSON(key, value) + if err != nil { + return nil, err + } + vars = append(vars, imported) + } + return vars, nil +} + +func importedVariableFromCTY(key string, value cty.Value) (ImportedVariable, error) { + if !value.IsKnown() { + return ImportedVariable{}, fmt.Errorf("variable %s is unknown", key) + } + + if !value.IsNull() && value.Type() == cty.String { + return ImportedVariable{ + Key: key, + Value: value.AsString(), + Category: "terraform", + HCL: false, + Sensitive: looksSensitive(key), + }, nil + } + + encoded, err := ctyjson.Marshal(value, value.Type()) + if err != nil { + return ImportedVariable{}, err + } + + return ImportedVariable{ + Key: key, + Value: string(encoded), + Category: "terraform", + HCL: true, + Sensitive: looksSensitive(key), + }, nil +} + +func importedVariableFromJSON(key string, value any) (ImportedVariable, error) { + if str, ok := value.(string); ok { + return ImportedVariable{ + Key: key, + Value: str, + Category: "terraform", + HCL: false, + Sensitive: looksSensitive(key), + }, nil + } + + encoded, err := json.Marshal(value) + if err != nil { + return ImportedVariable{}, err + } + return ImportedVariable{ + Key: key, + Value: string(encoded), + Category: "terraform", + HCL: true, + Sensitive: looksSensitive(key), + }, nil +} + +func looksSensitive(name string) bool { + key := strings.ToLower(name) + for _, needle := range []string{"secret", "token", "password", "passwd", "credential", "private", "access_key", "secret_key", "api_key"} { + if strings.Contains(key, needle) { + return true + } + } + return false +} diff --git a/internal/terraform/workspace_test.go b/internal/terraform/workspace_test.go new file mode 100644 index 0000000..9db1d48 --- /dev/null +++ b/internal/terraform/workspace_test.go @@ -0,0 +1,43 @@ +package terraform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseTFVarsFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "vars.tfvars") + content := []byte("name = \"example\"\ncount = 3\nenabled = true\nsettings = { env = \"prod\" }\nsecret_token = \"abc\"\n") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + + vars, err := ParseTFVarsFile(path) + if err != nil { + t.Fatal(err) + } + if len(vars) != 5 { + t.Fatalf("got %d vars", len(vars)) + } + + seen := map[string]ImportedVariable{} + for _, variable := range vars { + seen[variable.Key] = variable + } + if seen["name"].HCL { + t.Fatal("expected string variable to stay non-HCL") + } + if !seen["count"].HCL { + t.Fatal("expected number variable to use HCL mode") + } + if !seen["settings"].HCL { + t.Fatal("expected object variable to use HCL mode") + } + if !seen["secret_token"].Sensitive { + t.Fatal("expected secret_token to be marked sensitive") + } +}