Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changes/0.5.0-e2e1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## 0.5.0-e2e1 (September 16, 2026)


ENHANCEMENTS:

* `tfctl auth status` now explains why authentication failed instead of printing a bare "Unauthorized". It distinguishes a missing token, a token the server rejected (401), and a request that never reached the server, and prints the matching remedy. On SSO-protected Terraform Enterprise the 401 message also calls out a lapsed browser SSO session. JSON and agent output gain a machine-readable `reason` field.

* `run start` now accepts `--wait`, which blocks until the run reaches a terminal state, streaming each status transition and exiting non-zero if the run fails, is canceled, is discarded, or fails a mandatory policy. A run whose plan finishes but needs a manual apply (auto-apply disabled) stops instead of hanging. `--timeout` bounds how long to wait; if it elapses, tfctl stops watching and exits non-zero while the run continues in HCP Terraform. On completion the run URL is printed. `run status` now surfaces the same run URL as well.

* Added a `redact` profile property and a `TFCTL_REDACT` environment variable to control masking of sensitive values in output, with modes `strict` (the default), `known`, and `off`, plus a `--no-redact` global flag to show masked values for a single command

* Added a `-r name=id` flag to `api` and `create` for setting JSON:API relationships without a full request body. The relationship's linkage type is inferred from the schema (e.g. `-r project=prj-...` links to type `projects`); ambiguous or unknown relationships can be pinned with `-r name:type=id`, and to-many relationships accept comma-separated ids


BUG FIXES:

* Delete permissions now resolve correctly for resources with non-standard API paths (e.g. `explorer-saved-queries`, `run-tasks`)

* Credentials returned by the API are no longer printed. A created token, and the `hosted-state-download-url` and `hosted-json-state-download-url` of a state version, which grant access to state without a token, were rendered in every output format including `--json` and `--jq`. `--dry-run` echoed the request body and headers, so previewing a sensitive variable printed the value being set

* Fixed a gap in the output masking added in #101: CopyRaw only attempted to parse and mask a raw response body when its Content-Type was application/json (or ended in +json). At least one Terraform Enterprise endpoint (plan JSON export) serves valid JSON labeled application/octet-stream, so its response bypassed masking entirely. A body labeled application/octet-stream is now peeked at, without buffering it, to check whether it opens with a JSON object or array before deciding whether to mask it, so this content type is no longer treated as a blanket mask candidate: genuinely binary or large bodies served under the same label, such as state archives and plan or apply logs, are streamed through exactly as before


NOTES:

* Sensitive values in command output are now masked by default. A script that reads a state version download URL, or a newly created token, out of `tfctl` output will see `(redacted)` until it passes `--no-redact` or sets `redact = "off"` in its profile

6 changes: 0 additions & 6 deletions .changes/unreleased/BUG FIXES-20260729-114501.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/BUG FIXES-20260806-170000.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/BUG FIXES-20260820-083800.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/ENHANCEMENTS-20260717-015613.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/ENHANCEMENTS-20260717-023923.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/ENHANCEMENTS-20260806-170001.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/ENHANCEMENTS-20260819-161936.yaml

This file was deleted.

6 changes: 0 additions & 6 deletions .changes/unreleased/NOTES-20260806-170002.yaml

This file was deleted.

15 changes: 10 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -143,12 +147,13 @@ 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"
@echo " prepare-release Prepare next semantic version release,"
@echo " requires VERSION argument"
@echo " cleanup-release Clean up after a release"
@echo " requires DEV_VERSION argument"
@echo ""
@echo ""
10 changes: 10 additions & 0 deletions e2e/main.tf
Original file line number Diff line number Diff line change
@@ -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"
}
206 changes: 206 additions & 0 deletions e2e/test.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions internal/commands/versioncmd/versioncmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down
Loading