Skip to content

chore: add OpenTofu and current Terraform to the jsii-terraform image - #394

Merged
so0k merged 2 commits into
open-constructs:mainfrom
jsteinich:claude/docker-container-versions-6fb18c
Sep 7, 2026
Merged

so0k merged 2 commits into
open-constructs:mainfrom
jsteinich:claude/docker-container-versions-6fb18c

Conversation

@jsteinich

Copy link
Copy Markdown
Contributor

Related issue

Refs #337 — this is step 1 of the version-coverage sequencing discussed there. It changes image contents only; tested is untouched, so the matrix still runs 1.5.5 and 1.6.5 and no job count changes.

Description

@jsteinich's comment on #337 proposes three tested versions: latest Terraform, latest OpenTofu, and the newest pre-fork Terraform. None of those are in the image today, so this PR makes them available first.

Terraform. available gains 1.5.7 (newest pre-fork release, and the floor DEFAULT_TARGET_VERSIONS already declares in packages/cdktn/src/validations/target-versions.ts) and 1.16.1 (current stable). 1.1.9, 1.2.8, 1.3.4 and 1.4.6 are dropped — all below that declared floor, and no workflow referenced them.

OpenTofu 1.12.6, installed with the same layout Terraform uses: a tofu<version> binary per available version plus a bare tofu for the default. That means TERRAFORM_BINARY_NAME selects a product and version exactly the way it already selects a Terraform version, so a future matrix leg needs no new plumbing in the image. Downloads are checksum-verified against the release's published SHA256SUMS.

.terraform.versions.json grows an additive opentofu block rather than being restructured, so the existing consumers (build-test-matrix.mjs, build-example-matrix.mjs) are untouched.

Base image moves from 1-bookworm-slim-node22-nightly to the released 1-bookworm-slim-node22. Upstream documents nightly images as built from HEAD and "should typically not be used for production workloads", and the digest we had pinned is no longer tagged at all. I checked whether #373 (jsii 6.0) argues for jumping to trixie instead: it does not. jsii-superchain installs no jsii packages into the image, and its build varies only DEBIAN_VERSION and NODE_MAJOR_VERSION per tag — bookworm and trixie ship identical .NET 6.0, corretto-20, Go 1.25 and Maven 3.9.15. The only delta is distro Python (3.11 vs 3.13), which belongs with #391/#282 rather than here.

Two drive-by fixes in the same file, both pre-existing:

  • TF_PLUGIN_CACHE_DIR was set but the directory never created, so every Terraform and OpenTofu invocation printed a multi-line There are some problems with the CLI configuration block to stderr. CI masked it by overriding the variable; local docker run and anything parsing CLI output did not.
  • The Terraform download used curl -LOk, disabling TLS verification against releases.hashicorp.com.

Verification

Built the image locally and ran every binary:

terraform1.5.5     Terraform v1.5.5      tofu1.12.6   OpenTofu v1.12.6
terraform1.5.7     Terraform v1.5.7      terraform    Terraform v1.6.5   (default)
terraform1.6.5     Terraform v1.6.5      tofu         OpenTofu v1.12.6   (default)
terraform1.15.8    Terraform v1.15.8
terraform1.16.1    Terraform v1.16.1

Pruned versions confirmed absent. Toolchain unchanged: Node 22.23.2, Python 3.11.2, .NET 6.0.428, corretto-20.0.2.1, Go 1.25.0, Maven 3.9.15, Gradle 8.2.1, Debian 12.15. Image is 5.01 GB uncompressed.

Also exercised both CLIs against the providers #337 wants to test:

tofu 1.12.6        registry.opentofu.org/hashicorp/random   ephemeral=1
                   registry.opentofu.org/hashicorp/time     functions=3
terraform 1.16.1   registry.terraform.io/hashicorp/random   ephemeral=1
                   registry.terraform.io/hashicorp/time     functions=3

Both install cleanly under OpenTofu — the #301 signing failure is specific to kreuzwerker/docker — and both emit matching functions / ephemeral_resource_schemas, differing only in the registry hostname of the schema key. That divergence is now tracked as #393.

Follow-ups

CI here still runs the old image, since the jsii-terraform digest is pinned in 27 places across 11 workflow files. Nothing in this PR is exercised until it merges and docker.yml publishes a new image, so the next step is a digest-bump PR, and only then can tested move to 1.5.7 + 1.16.1 (watching out for #379, which reproduces on 1.15.8).

Remaining blockers before OpenTofu can become a full matrix leg: #208, #301, #392, #393, plus the pin mechanism in #337.

Checklist

  • I have updated the PR title to match CDKTN's style guide
  • I have run the linter on my code locally
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation if applicable (CONTRIBUTING.md now documents the version-suffixed binaries and TERRAFORM_BINARY_NAME)
  • My changes generate no new warnings (this PR removes one)
  • I have added tests that prove my fix is effective or that my feature works if applicable — n/a, image contents; verified by building and running the image
  • New and existing unit tests pass locally with my changes — n/a, no source changes

🤖 Generated with Claude Code

Prepares the CI image for the version-coverage change discussed in open-constructs#337:
latest Terraform, latest OpenTofu, and the newest pre-fork Terraform.
Only the image contents change here — `tested` is untouched, so the
matrix still runs 1.5.5 and 1.6.5.

Terraform `available` gains 1.5.7 (the newest pre-fork release, and the
floor already declared by `DEFAULT_TARGET_VERSIONS` in
packages/cdktn/src/validations/target-versions.ts) and 1.16.1 (current
stable). 1.1.9, 1.2.8, 1.3.4 and 1.4.6 are dropped: all sit below that
declared floor and no workflow referenced them.

OpenTofu is installed the same way Terraform is — one `tofu<version>`
binary per available version plus a bare `tofu` for the default — so
`TERRAFORM_BINARY_NAME` selects a product and version the same way it
already selects a Terraform version. Downloads are checksum-verified
against the release's published SHA256SUMS.

Also:

- Base image moves from `1-bookworm-slim-node22-nightly` to the released
  `1-bookworm-slim-node22`. Upstream documents nightly builds as coming
  from HEAD and not for production use, and the previously pinned digest
  is no longer tagged. superchain ships no jsii packages itself, and its
  build varies only DEBIAN_VERSION and NODE_MAJOR_VERSION per tag, so
  this carries the same .NET 6.0, corretto-20, Go 1.25 and Maven 3.9.15
  toolchain as before.
- `mkdir -p` the TF_PLUGIN_CACHE_DIR. Both CLIs warned on every
  invocation that the configured cache dir did not exist, putting a
  multi-line CLI-configuration error on stderr for anything parsing
  their output.
- Drop `-k` from the Terraform download, which was disabling TLS
  verification against releases.hashicorp.com.

Verified by building the image and running every binary:
terraform 1.5.5/1.5.7/1.6.5/1.15.8/1.16.1, tofu 1.12.6, and both
defaults report the expected versions; pruned versions are absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jsteinich
jsteinich marked this pull request as ready for review September 5, 2026 20:57
@jsteinich
jsteinich requested a review from a team as a code owner September 5, 2026 20:57
@sakul-learning

Copy link
Copy Markdown
Contributor

Review: OpenTofu install error handling + two hardening notes

One primary concern about how the new OpenTofu install step handles errors, plus two non-blocking hardening observations.

1. The OpenTofu install loop can fail open — Dockerfile:64-71

The new OpenTofu installation chains download → checksum → extract → symlink with &&, but cleanup sits after a ; and the layer has no set -e:

RUN for VERSION in ${AVAILABLE_OPENTOFU_VERSIONS}; do RELEASE=https://github.com/opentofu/opentofu/releases/download/v${VERSION} && \
    curl -fsSLO ${RELEASE}/tofu_${VERSION}_linux_amd64.zip && \
    curl -fsSLO ${RELEASE}/tofu_${VERSION}_SHA256SUMS && \
    grep "  tofu_${VERSION}_linux_amd64.zip$" tofu_${VERSION}_SHA256SUMS | sha256sum --check - && \
    mkdir -p /usr/local/bin/otf/versions/${VERSION} && \
    unzip tofu_${VERSION}_linux_amd64.zip -d /usr/local/bin/otf/versions/${VERSION} && \
    ln -s /usr/local/bin/otf/versions/${VERSION}/tofu /usr/local/bin/tofu${VERSION};rm tofu_${VERSION}_linux_amd64.zip tofu_${VERSION}_SHA256SUMS;done && \
    ln -s /usr/local/bin/otf/versions/${DEFAULT_OPENTOFU_VERSION}/tofu /usr/local/bin/tofu

If sha256sum --check (or unzip, or the versioned ln -s) fails, the remaining && steps are skipped, but the trailing rm still runs and returns success — so that loop iteration reports success and the layer is recorded as built. The final ln -s for the bare tofu also succeeds even when its target directory was never created, producing a dangling symlink. The result would be a published image missing tofu1.12.6 and a broken tofu default, despite checksum verification having failed.

Suggested fix: make each iteration fail the layer on any install error (for example set -e plus grouping the install steps so a failure propagates), and add a post-install step that actually invokes every declared terraform<version> and tofu<version> binary plus the bare defaults, so the image cannot be published unless every version in .terraform.versions.json is present and runnable. The pre-existing Terraform loop (Dockerfile:55-59) has the same &&-then-; masking shape, so a shared fix would cover both.

2. (Non-blocking) Supply-chain hardening — Dockerfile:55-71

  • The OpenTofu ZIP and its SHA256SUMS are both fetched from the same GitHub release, so sha256sum --check detects corruption but does not authenticate the release — anything able to replace both files can supply a matching manifest. Verifying a signed release manifest against a pinned trusted OpenTofu signing key, or pinning reviewed SHA-256 values in the repo, would close this.
  • The Terraform download (curl -LO https://releases.hashicorp.com/..., line 55) has no checksum or signature verification at all. Removing -k restores TLS, but TLS is transport-only; pinning known Terraform SHA-256 values (or verifying HashiCorp's signed manifest) would add durable integrity checking.

3. (Non-blocking, pre-existing) workflow_dispatch can publish from any ref — .github/workflows/docker.yml

The push trigger is correctly limited to main (lines 4-5), but workflow_dispatch: {} (line 12) allows a manual dispatch against an arbitrary ref, and the only job guard is if: github.repository == 'open-constructs/cdk-terrain' (line 16). The job then receives packages: write (line 20) and actions/checkout (line 22) checks out the dispatched ref, so a non-main branch could drive image publication through the package-publishing token. Restricting manual dispatch to main (e.g. adding github.ref == 'refs/heads/main' to the job condition) would prevent that.

Addresses review feedback on the OpenTofu install layer.

The install loops chained steps with `&&` but ran cleanup after a `;`, and
Docker executes RUN under `/bin/sh -c` with no `set -e`. A `for` loop reports
the status of the last command in its body, so a failed checksum left the
trailing `rm` as the final command, the loop exited 0, and the layer was
recorded as built. The bare `terraform`/`tofu` symlinks were then created
against directories that may never have been populated, so the image could
publish with a missing version and a dangling default despite verification
having failed. Confirmed with an A/B build: the old shape completes
successfully against a deliberately wrong checksum, the new one fails the
layer.

Both loops (the OpenTofu one added here and the pre-existing Terraform one,
which had the same shape) now run under `set -eu` with plain sequential
commands, so any failure aborts the layer.

A new verification layer runs every declared `terraform<version>` and
`tofu<version>` binary plus both bare defaults and asserts each reports the
version it is named for, so the image cannot be published unless everything
in `.terraform.versions.json` is present and runnable.

Also from the review:

- The Terraform download now verifies against HashiCorp's published
  SHA256SUMS, matching what the OpenTofu install already did. This is an
  integrity check, not an authenticity one — both manifests come from the
  same origin as the artifact. Signature verification against pinned keys is
  left to open-constructs#214.
- docker.yml's job now additionally requires `github.ref` to be main. The
  push trigger was already limited to main, but `workflow_dispatch` accepts
  any ref, so a manual dispatch from another branch could publish `:latest`
  through the job's packages:write token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jsteinich

Copy link
Copy Markdown
Contributor Author

Thanks — point 1 was a real bug and you diagnosed the mechanism exactly. All three addressed in e738b09.

1. Fail-open install loop — fixed

I A/B built the two shapes against a deliberately wrong checksum to confirm the behaviour before changing anything:

old shape:  build exit=0, proceeded to the next layer
            → "BUILD COMPLETED DESPITE BAD CHECKSUM"
new shape:  tofu_1.12.6_linux_amd64.zip: FAILED
            → build exit=1, next layer never ran

Both loops now run under set -eu with plain sequential commands rather than an && chain with cleanup after ; (Dockerfile:58 and Dockerfile:74). As you noted, the pre-existing Terraform loop had the identical shape, so the fix covers both.

I also took the post-install verification suggestion (Dockerfile:90): it invokes every declared terraform<version> and tofu<version> plus both bare defaults and asserts each reports the version it is named for, so a silently skipped install or a dangling default symlink fails the build. From the rebuild:

verified terraform: 1.5.5 1.5.7 1.6.5 1.15.8 1.16.1 (default 1.6.5)
verified opentofu:  1.12.6 (default 1.12.6)

2. Supply chain — half now, half deferred, deliberately

Terraform downloads now verify against HashiCorp's published SHA256SUMS, so both products are checked instead of only OpenTofu. All five verified OK in the rebuild.

I did not add signature verification, and I would rather say so plainly than let the checksum imply more than it does: you are right that fetching the manifest from the same origin as the artifact gives integrity, not authenticity — anything that can replace the ZIP can replace the manifest alongside it. Closing that properly means one of:

  • pinning both vendors' GPG keys into the image, which puts key material in the repo and makes key rotation a build-breaking event; or
  • pinning reviewed SHA-256 values in .terraform.versions.json, which is the stronger trust model but changes a config file four consumers read and adds a hash lookup to every version bump.

Both are worth doing and neither felt right to decide inside a PR whose job is getting the binaries into the image, so I have left them to #214. If there is a preference between the two I am happy to follow up — the in-repo hash pinning is the one I would pick, since it avoids key management entirely and the values get reviewed at the same time the version does.

3. workflow_dispatch from any ref — fixed

The job condition now also requires github.ref == 'refs/heads/main'.

Worth flagging one consequence: this removes the ability to test-build the image from a branch via manual dispatch. I think that is correct rather than a regression, because the job pushes :latest and the version tags unconditionally, so a branch dispatch was publishing a mislabeled image rather than testing one. If a branch build is actually wanted, that probably deserves a separate ref-suffixed tag rather than reopening :latest.


Note that CI on this PR still runs the previous image — the jsii-terraform digest is pinned in 27 places across 11 workflows, so none of this is exercised until it merges and docker.yml publishes. Everything above was verified by building the image locally.

@sakul-learning

Copy link
Copy Markdown
Contributor

Following up on my review — all three findings are resolved in e738b09:

  1. Fail-open install loop (Dockerfile:58 and Dockerfile:74) — resolved. Both install loops now run under set -eu with sequential commands, so a failed download/checksum/extract aborts the layer instead of being masked by the trailing rm. Reproduced independently: a bad checksum exits 1 and nothing after it runs.
  2. Post-install verification (Dockerfile:90) — added. Invokes every terraform<version> / tofu<version> plus the bare defaults and asserts the reported version, so a silently skipped install or a dangling default symlink fails the build.
  3. workflow_dispatch from any ref (.github/workflows/docker.yml:16) — resolved. The job condition now requires github.ref == 'refs/heads/main'.

One remaining non-blocking item: signature/authenticity verification (the checksums establish integrity, not authenticity) is deferred to #214.

Approving.

@so0k
so0k merged commit 2f29d5a into open-constructs:main Sep 7, 2026
258 checks passed
@jsteinich
jsteinich deleted the claude/docker-container-versions-6fb18c branch September 8, 2026 00:25
jsteinich added a commit to jsteinich/cdk-terrain that referenced this pull request Sep 8, 2026
…TF 1.16.1

Points all 27 pinned references across the 11 workflow files at the image
published from 2f29d5a (open-constructs#394), which is the first one carrying OpenTofu
1.12.6, Terraform 1.5.7 and 1.16.1, the fail-fast install layers and the
post-install binary verification.

This is purely the digest bump. `tested` still runs 1.5.5 and 1.6.5, so no
job counts or version coverage change here — that comes next, now that the
binaries are actually present in the image CI runs.

Digest confirmed to be the build of 2f29d5a: the immutable
0.24.0-2f29d5a8d45b047c5450eee9640e1cbaaee98c8d tag and :latest both
resolve to sha256:62c00a31...

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
so0k pushed a commit to jsteinich/cdk-terrain that referenced this pull request Sep 9, 2026
…TF 1.16.1

Points all 27 pinned references across the 11 workflow files at the image
published from 2f29d5a (open-constructs#394), which is the first one carrying OpenTofu
1.12.6, Terraform 1.5.7 and 1.16.1, the fail-fast install layers and the
post-install binary verification.

This is purely the digest bump. `tested` still runs 1.5.5 and 1.6.5, so no
job counts or version coverage change here — that comes next, now that the
binaries are actually present in the image CI runs.

Digest confirmed to be the build of 2f29d5a: the immutable
0.24.0-2f29d5a8d45b047c5450eee9640e1cbaaee98c8d tag and :latest both
resolve to sha256:62c00a31...

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
so0k pushed a commit that referenced this pull request Sep 9, 2026
….7 + 1.16.1 (#398)

### Related issue

Follow-up to #394. Step 2 of the sequencing agreed in #337. Rebased on
#383, which unblocks the ceiling half.

### Description

Three commits, all consequences of #394 having published a new image and
#383 having fixed `cdktn get` on modern Terraform.

**1. Bump the pinned image digest** — all 27 references across 11
workflow files move to `sha256:62c00a31...`, the image built from
2f29d5a. Confirmed to be that build: `:latest` and the immutable
`0.24.0-2f29d5a8d45b047c5450eee9640e1cbaaee98c8d` tag resolve to the
same digest. First image carrying OpenTofu 1.12.6, Terraform 1.5.7 and
1.16.1, the fail-fast install layers and the post-install binary
verification.

**2. Move the floor leg from Terraform 1.5.5 to 1.5.7** — 1.5.7 is the
newest pre-fork release and the exact floor cdktn already declares.
`DEFAULT_TARGET_VERSIONS` in
`packages/cdktn/src/validations/target-versions.ts` is `{ terraform:
">=1.5.7", opentofu: ">=1.6.0" }`, so every project that declares no
`targetVersions` is told 1.5.7 is supported, while CI was testing 1.5.5
— a version nothing declares.

**3. Raise the ceiling from 1.6.5 to 1.16.1** — unblocked by #383
(54a8217). `cdktn get` was broken from Terraform 1.15.7 onward for any
module declaring `configuration_aliases` (#379); until that fix was in
main this would have landed red.

Both version changes are **renames of existing legs, not additions** —
`tested` goes from `["1.5.5", "1.6.5"]` to `["1.5.7", "1.16.1"]`, so the
integration cross-product keeps the same job count and the CI bill is
unchanged. The two legs now span the declared floor and current stable
instead of two adjacent 2023 releases, which is what #337 needed in
order to exercise the newer-protocol schema sections (`functions`,
`ephemeral_resource_schemas`, `write_only`) at all — the previous 1.6.5
ceiling structurally could not emit them.

`default` moves to 1.16.1 with it. That drives the examples matrix
(`tools/build-example-matrix.mjs` reads `.default`) and — less obviously
— every provider-integration run: `provider-integration.yml`'s
`terraform_version` input is defaulted rather than passed, and none of
its three callers override it. The duplicated unit-test matrices in
`pr-unit.yml` and `release.yml` move with it too.

### Deliberately not included

- **Pruning 1.5.5 and 1.6.5 from `available`.** They are unreferenced
now, but editing `.terraform.versions.json` only rebuilds the image on
merge — the workflows stay pinned to the current digest, so the prune
would not take effect until a later bump. It belongs with the next
digest bump.
-
**`tools/generate-function-bindings/function-availability/functions-matrix.json`**
still lists 1.6.5. That is generated function-availability data covering
every release, not a CI version.

### Follow-ups

- Derive the `terraform_version` matrices in `pr-unit.yml` and
`release.yml` from `.terraform.versions.json` instead of duplicating
`tested` in three places, so a version change is a one-file edit.
- OpenTofu coverage (#337 step 3) still needs the pin mechanism plus
#208, #301, #392, #393.

### Checklist

- [x] I have updated the PR title to match [CDKTN's style
guide](https://github.com/open-constructs/cdk-terrain/blob/main/CONTRIBUTING.md#pull-requests-1)
- [x] I have run the linter on my code locally
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation if
applicable — n/a
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works if applicable — n/a; this PR's own CI run is the
verification, since it exercises the new image on both new version legs
- [ ] New and existing unit tests pass locally with my changes — n/a, no
source changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@so0k so0k mentioned this pull request Sep 17, 2026
1 task
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

I'm going to lock this pull request because it has been closed for 30 days. This helps our maintainers find and focus on the active issues. If you've found a problem that seems related to this change, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants