From 85105dea1e730cceaa2391e00b94611fb7744ca0 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 16 Sep 2026 13:11:45 -0600 Subject: [PATCH 1/5] tests: add e2e suite and main branch job --- .github/workflows/e2e.yml | 46 ++++++++++++++++ Makefile | 7 ++- e2e/main.tf | 10 ++++ e2e/test.sh | 111 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/main.tf create mode 100755 e2e/test.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..47f53fd --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,46 @@ +name: E2E Staging + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + e2e: + name: Test tfctl using HCP Terraform Staging + runs-on: ["self-hosted", "ubuntu-22.04"] + timeout-minutes: 30 + permissions: + contents: read + id-token: write # Required for Vault access. + env: + TFCTL_HOSTNAME: app.staging.terraform.io + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: "vault - authenticate" + id: vault-auth + run: vault-auth + - name: "vault - fetch" + id: vault-fetch + uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0 + with: + url: ${{ steps.vault-auth.outputs.addr }} + caCertificate: ${{ steps.vault-auth.outputs.ca_certificate }} + token: ${{ steps.vault-auth.outputs.token }} + secrets: kv/data/github/hashicorp/team-tf-core-cloud tfc-staging | staging-token; + + - name: Set TFCTL_TOKEN Variable from secret + run: | + echo "TFCTL_TOKEN=${{ steps.vault-fetch.outputs.staging-token }}" >> $GITHUB_ENV + + - name: Run end-to-end test + run: | + make e2e diff --git a/Makefile b/Makefile index ebef880..b3928eb 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,10 @@ logotools: .PHONY: check check: fmt-check go/lint go/test +.PHONY: e2e +e2e: bin + @bash e2e/test.sh + # Help (make usage) .PHONY: help help: @@ -144,6 +148,7 @@ help: @echo " go/lint Run golangci-lint" @echo " go/fmt Format go code" @echo " 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..0ba305a --- /dev/null +++ b/e2e/test.sh @@ -0,0 +1,111 @@ +#!/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 configuration +# profile is already correct and ready. setup creates an 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" +} + +create_workspace() { + local workspace="tfctl-e2e-${run_id}" + "$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_create_and_apply_workspace() ( + local archive workspace_id + archive="$(mktemp)" + trap 'rm -f "$archive"' EXIT + + printf 'Creating auto-apply workspace\n' + workspace_id="$(create_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_create_and_apply_workspace From 4bdf065407c0ddb9524e1a2ec45c53b94aae9500 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 16 Sep 2026 14:07:47 -0600 Subject: [PATCH 2/5] self-review/cleanup --- Makefile | 8 ++++---- e2e/test.sh | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index b3928eb..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,7 @@ logotools: } .PHONY: check -check: fmt-check go/lint go/test +check: go/fmt-check go/lint go/test .PHONY: e2e e2e: bin @@ -147,7 +147,7 @@ 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:" diff --git a/e2e/test.sh b/e2e/test.sh index 0ba305a..85fc385 100755 --- a/e2e/test.sh +++ b/e2e/test.sh @@ -7,9 +7,9 @@ set -euo pipefail # End-to-end test for tfctl # -# Runs dist/tfctl through some basic test cases. Presumes the default configuration -# profile is already correct and ready. setup creates an organization and all -# cases should use it. +# 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 @@ -57,8 +57,9 @@ run_case() { "$name" } -create_workspace() { - local workspace="tfctl-e2e-${run_id}" +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 } @@ -99,7 +100,7 @@ case_create_and_apply_workspace() ( trap 'rm -f "$archive"' EXIT printf 'Creating auto-apply workspace\n' - workspace_id="$(create_workspace)" + workspace_id="$(create_auto_apply_workspace)" tar -C "$root_dir/e2e" -czf "$archive" main.tf upload_configuration "$workspace_id" "$archive" From a326a7267ab0a7a166628f6c1b83076b2b3d79ba Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 16 Sep 2026 14:33:53 -0600 Subject: [PATCH 3/5] e2e: add more test cases --- e2e/test.sh | 94 ++++++++++++++++++++++ internal/commands/versioncmd/versioncmd.go | 8 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/e2e/test.sh b/e2e/test.sh index 85fc385..e2c1758 100755 --- a/e2e/test.sh +++ b/e2e/test.sh @@ -57,6 +57,32 @@ run_case() { "$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" @@ -94,6 +120,69 @@ upload_configuration() { 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)" @@ -109,4 +198,9 @@ case_create_and_apply_workspace() ( ) 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()) } From 4fd0cc101c1b1d7aec9be6505a0bf2e3b7a03a3f Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 16 Sep 2026 14:34:42 -0600 Subject: [PATCH 4/5] run e2e on this branch --- .github/workflows/e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 47f53fd..7a864de 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - brandonc/e2e_tests jobs: e2e: From 867dad27d9fd6f534ab36b6912a42c714086e203 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 16 Sep 2026 14:59:48 -0600 Subject: [PATCH 5/5] Delete e2e.yml --- .github/workflows/e2e.yml | 47 --------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 7a864de..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: E2E Staging - -on: - workflow_dispatch: - push: - branches: - - main - - brandonc/e2e_tests - -jobs: - e2e: - name: Test tfctl using HCP Terraform Staging - runs-on: ["self-hosted", "ubuntu-22.04"] - timeout-minutes: 30 - permissions: - contents: read - id-token: write # Required for Vault access. - env: - TFCTL_HOSTNAME: app.staging.terraform.io - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - - - name: "vault - authenticate" - id: vault-auth - run: vault-auth - - name: "vault - fetch" - id: vault-fetch - uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0 - with: - url: ${{ steps.vault-auth.outputs.addr }} - caCertificate: ${{ steps.vault-auth.outputs.ca_certificate }} - token: ${{ steps.vault-auth.outputs.token }} - secrets: kv/data/github/hashicorp/team-tf-core-cloud tfc-staging | staging-token; - - - name: Set TFCTL_TOKEN Variable from secret - run: | - echo "TFCTL_TOKEN=${{ steps.vault-fetch.outputs.staging-token }}" >> $GITHUB_ENV - - - name: Run end-to-end test - run: | - make e2e