diff --git a/Makefile b/Makefile index ebef880..084e45c 100644 --- a/Makefile +++ b/Makefile @@ -70,8 +70,8 @@ go/fmt: @gofmt -s -w . # Check formatting -.PHONY: fmt-check -fmt-check: +.PHONY: go/fmt-check +go/fmt-check: @test -z "$$(gofmt -s -l . | tee /dev/stderr)" || (echo "Code is not formatted. Run 'make go/fmt'" && exit 1) # Release targets @@ -120,7 +120,11 @@ logotools: } .PHONY: check -check: fmt-check go/lint go/test +check: go/fmt-check go/lint go/test + +.PHONY: e2e +e2e: bin + @bash e2e/test.sh # Help (make usage) .PHONY: help @@ -143,7 +147,8 @@ help: @echo " go/test Run all tests" @echo " go/lint Run golangci-lint" @echo " go/fmt Format go code" - @echo " fmt-check Check go code formatting" + @echo " go/fmt-check Check go code formatting" + @echo " e2e Run the HCP Terraform end-to-end test" @echo "" @echo "Release:" @echo " gen/openapi Update embedded OpenAPI spec" @@ -151,4 +156,4 @@ help: @echo " requires VERSION argument" @echo " cleanup-release Clean up after a release" @echo " requires DEV_VERSION argument" - @echo "" \ No newline at end of file + @echo "" diff --git a/e2e/main.tf b/e2e/main.tf new file mode 100644 index 0000000..4f67894 --- /dev/null +++ b/e2e/main.tf @@ -0,0 +1,10 @@ +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_version = ">= 1.4.0" +} + +resource "terraform_data" "e2e" { + input = "tfctl-e2e" +} diff --git a/e2e/test.sh b/e2e/test.sh new file mode 100755 index 0000000..e2c1758 --- /dev/null +++ b/e2e/test.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: MPL-2.0 + + +set -euo pipefail + +# End-to-end test for tfctl +# +# Runs dist/tfctl through some basic test cases. Presumes the default profile +# is already configured. 'setup' creates $organization and all cases should +# use it. +# +# System prerequisites are: +# tar +#. curl + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tfctl_bin="${TFCTL_BIN:-$root_dir/dist/tfctl}" +run_id="${GITHUB_RUN_ID:-$(date +%s)}-${GITHUB_RUN_ATTEMPT:-$RANDOM}" +organization="tfctl-e2e-${run_id}" +organization_created=false + +teardown() { + local status=$? + + if [ "$organization_created" = true ]; then + "$tfctl_bin" harness exec --allow-delete=organizations -- \ + "$tfctl_bin" api "/organizations/$organization" -X DELETE || status=1 + fi + + exit "$status" +} +trap teardown EXIT + +setup() { + if [ ! -x "$tfctl_bin" ]; then + printf 'tfctl binary not found at %s\n' "$tfctl_bin" >&2 + exit 1 + fi + + for command in curl tar; do + if ! command -v "$command" >/dev/null 2>&1; then + printf '%s is required to run the end-to-end test\n' "$command" >&2 + exit 1 + fi + done + + printf 'Creating organization %s\n' "$organization" + "$tfctl_bin" api "/organizations" -X POST -a "name=$organization" -a "email=tfctl-e2e@example.com" --quiet + organization_created=true +} + +run_case() { + local name=$1 + printf '\n=== %s ===\n' "$name" + "$name" +} + +assert_contains() { + local value=$1 + local expected=$2 + + case "$value" in + *"$expected"*) ;; + *) + printf 'expected output to contain %q:\n%s\n' "$expected" "$value" >&2 + return 1 + ;; + esac +} + +assert_not_contains() { + local value=$1 + local unexpected=$2 + + case "$value" in + *"$unexpected"*) + printf 'expected output not to contain %q:\n%s\n' "$unexpected" "$value" >&2 + return 1 + ;; + *) ;; + esac +} + +create_auto_apply_workspace() { + local workspace="tfctl-e2e-$RANDOM" + + "$tfctl_bin" create workspace --organization "$organization" --jq '.data.id' \ + -a "name=$workspace" -a auto-apply=true +} + +upload_configuration() { + local workspace_id=$1 + local archive=$2 + local configuration_id configuration_status upload_url + + upload_url="$("$tfctl_bin" api "/workspaces/$workspace_id/configuration-versions" --no-redact --jq '.data.attributes["upload-url"]' -i \ + '{"data":{"type":"configuration-versions","attributes":{"auto-queue-runs":false}}}')" + + printf 'Uploading configuration\n' + curl --fail --silent --show-error --request PUT --upload-file "$archive" "$upload_url" + + configuration_id="$("$tfctl_bin" api "/workspaces/$workspace_id" --jq \ + '.data.relationships["current-configuration-version"].data.id')" + + for _ in $(seq 1 60); do + configuration_status="$("$tfctl_bin" api "/configuration-versions/$configuration_id" --jq '.data.attributes.status')" + if [ "$configuration_status" = "uploaded" ]; then + return + fi + if [ "$configuration_status" = "errored" ]; then + printf 'current configuration version %s failed to upload\n' "$configuration_id" >&2 + return 1 + fi + sleep 2 + done + + printf 'current configuration version %s did not finish uploading\n' "$configuration_id" >&2 + return 1 +} + +case_get_formats() { + local workspace workspace_id output + workspace="tfctl-e2e-formats-$RANDOM" + workspace_id="$("$tfctl_bin" create workspace --organization "$organization" --jq '.data.id' -a "name=$workspace")" + + output="$("$tfctl_bin" get workspaces --organization "$organization")" + assert_contains "$output" "ID" + output="$("$tfctl_bin" get workspaces --organization "$organization" --json)" + assert_contains "$output" "\"$workspace\"" + output="$("$tfctl_bin" get workspaces --organization "$organization" --markdown)" + assert_contains "$output" "$workspace" + + output="$("$tfctl_bin" get workspace "$workspace_id")" + assert_contains "$output" "$workspace" + output="$("$tfctl_bin" get workspace "$workspace_id" --json)" + assert_contains "$output" "\"id\": \"$workspace_id\"" + output="$("$tfctl_bin" get workspace "$workspace_id" --markdown)" + assert_contains "$output" "$workspace" +} + +case_dry_run_is_no_op() { + local workspace output + workspace="tfctl-e2e-dry-run-$RANDOM" + + output="$("$tfctl_bin" create workspace --organization "$organization" -a "name=$workspace" --dry-run 2>&1)" + assert_contains "$output" "would send POST request" + + output="$("$tfctl_bin" get workspaces --organization "$organization" --json)" + assert_not_contains "$output" "$workspace" +} + +case_quiet_minimizes_output() ( + local workspace stdout stderr + workspace="tfctl-e2e-quiet-$RANDOM" + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + "$tfctl_bin" create workspace --organization "$organization" -a "name=$workspace" --quiet >"$stdout" 2>"$stderr" + [ ! -s "$stdout" ] + [ ! -s "$stderr" ] +) + +case_harness_install() ( + local temp_dir skill_path + temp_dir="$(mktemp -d)" + skill_path="$temp_dir/.agents/skills/tfctl/SKILL.md" + trap 'rm -rf "$temp_dir"' EXIT + + ( + cd "$temp_dir" + "$tfctl_bin" harness install opencode + ) + [ -s "$skill_path" ] +) + +case_profile_display() { + local output + output="$("$tfctl_bin" profile display --json)" + assert_contains "$output" "\"Name\":" + assert_not_contains "$output" "token" +} + +case_create_and_apply_workspace() ( + local archive workspace_id + archive="$(mktemp)" + trap 'rm -f "$archive"' EXIT + + printf 'Creating auto-apply workspace\n' + workspace_id="$(create_auto_apply_workspace)" + tar -C "$root_dir/e2e" -czf "$archive" main.tf + upload_configuration "$workspace_id" "$archive" + + printf 'Starting and waiting for the auto-apply run\n' + "$tfctl_bin" run start "$workspace_id" --wait --timeout 20m +) + +setup +run_case case_get_formats +run_case case_dry_run_is_no_op +run_case case_quiet_minimizes_output +run_case case_harness_install +run_case case_profile_display +run_case case_create_and_apply_workspace diff --git a/internal/commands/versioncmd/versioncmd.go b/internal/commands/versioncmd/versioncmd.go index ca165c0..b3f8caf 100644 --- a/internal/commands/versioncmd/versioncmd.go +++ b/internal/commands/versioncmd/versioncmd.go @@ -64,8 +64,12 @@ func runDetectOutdatedVersion(_ context.Context, io iostreams.IOStreams) { fmt.Fprintf(io.ErrUnessential(), "A new version of %s is available: %s\n", version.Name, cs.String(fmt.Sprintf("v%s", versionInfo.Latest)).Color(cs.Purple()).Bold()) fmt.Fprintln(io.ErrUnessential()) } else { - fmt.Fprintln(io.ErrUnessential(), heredoc.New(io).Mustf(`Release notes for this version are available at - {{ template "mdCodeOrBold" "https://github.com/hashicorp/tfctl-cli/blob/%s/CHANGELOG.md" }}`, version.Version)) + if version.IsDev() { + fmt.Fprintln(io.ErrUnessential(), heredoc.New(io).Mustf(`This is a development version of %s, not an official release.`, version.Name)) + } else { + fmt.Fprintln(io.ErrUnessential(), heredoc.New(io).Mustf(`Release notes for this version are available at + {{ template "mdCodeOrBold" "https://github.com/hashicorp/tfctl-cli/blob/%s/CHANGELOG.md" }}`, version.Version)) + } fmt.Fprintln(io.ErrUnessential()) }