diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cd1003..4d47a86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: # input: the action then only installs the CLI (no login, no credentials). - uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0 with: - pulumi-version: "3.251.0" + pulumi-version: "3.253.0" - name: go build run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 602fdcc..2a04d73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: # that quietly skipped them. - uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0 with: - pulumi-version: "3.251.0" + pulumi-version: "3.253.0" - name: go build run: go build ./... diff --git a/AGENTS.md b/AGENTS.md index 1739e41..d137dd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -706,14 +706,36 @@ realized in two halves: with `auto.NewLocalWorkspace` / `auto.*StackInlineSource` — the **Automation API**, which is a Go SDK that *drives a `pulumi` binary*: it shells out to whatever `pulumi` is first on `PATH`. There is no in-process engine. Treat the CLI as a real dependency with a pinned version: - `action.yml` installs it (`pulumi-version` input, currently **3.253.0**) so every consumer gets the - engine we validated rather than whatever its runner image ships, and - `.github/workflows/{ci,release}.yml` pin their own via `pulumi/actions`. + `action.yml` installs it so every consumer gets the engine we validated rather than whatever its + runner image ships (see the four-way pin table below). Leaving it unpinned is not a theoretical risk — a GitHub runner-image roll moved the preinstalled CLI 3.253.0 → 3.256.0 and broke every prd deploy with `InvalidDigest` on the R2 checkpoint write, with no diff in inforge itself. When bumping, bump it deliberately and verify a real deploy against - the R2 backend; a newer CLI may additionally need - `AWS_REQUEST_CHECKSUM_CALCULATION=when_required` for S3-compatible stores. + the R2 backend. **CI cannot catch a regression here**: our own workflows install the CLI via + `pulumi/actions`, not via `action.yml`, so the consumer install path this repo ships is exercised + only by a real consumer deploy. +- **The Pulumi version is pinned in FOUR places and they must move together.** All four are + currently **3.253.0**: + + | Where | What it pins | + |---|---| + | `go.mod` → `github.com/pulumi/pulumi/sdk/v3` | the SDK compiled into the binary | + | `action.yml` → `pulumi-version` input | the CLI every **consumer** deploys with | + | `.github/workflows/ci.yml` → `pulumi/actions` | the CLI our tests run against | + | `.github/workflows/release.yml` → `pulumi/actions` | the CLI the release build runs against | + + The SDK and the CLI are the same product on two sides of a process boundary, and **nothing in the + toolchain links any of them** — Dependabot bumps `go.mod` and cannot see the other three, so + accepting an SDK PR on its own silently reopens the skew. Move all four in one PR, or none. + Grep `3\.25` before claiming they agree; a partial bump is the failure mode, and it is invisible + until a deploy behaves differently from CI. +- **Third-party binaries install from `wardnet/toolchain-mirror`, verified.** The Pulumi CLI + (`action.yml`) and every provider plugin (`cmd/inforge/plugins.go`) are downloaded from our mirror + and checked against the `SHA256SUMS` it publishes. **Mirror a version before pinning it here** — an + unmirrored version fails the install with a message naming the mirror. Fetching from a mirror + without verifying the digest would only relocate the trust, so the check is not optional: the plugin + path previously ran unverified bytes as part of a production deploy. The mirror's SHA-256 is + stronger than upstream's own guarantee for plugins — the provider repos publish only **SHA-1**. - **A third-party apt repo must be PROBED before its sources file is written.** `apt-get update` fails hard on an unreachable source and our installers wrap it in retry-then-exit-1, so a sources file naming a suite the vendor does not publish breaks **every later apt-using step on diff --git a/action.yml b/action.yml index 2236f44..fa063f4 100644 --- a/action.yml +++ b/action.yml @@ -26,12 +26,63 @@ runs: # preinstalled CLI 3.253.0 -> 3.256.0, and 3.256.0 fails to write the R2 # checkpoint with `InvalidDigest: The checksum or Content-MD5 you specified is # not valid` (S3 PutObject 400). The same inforge v6.1.3 binary succeeded on - # the older image the day before. Pinning here makes the CLI a version we - # control and bump deliberately, like any other dependency. + # the older image the day before. + # + # We install from wardnet/toolchain-mirror, not from Pulumi, and verify the + # download against the SHA256SUMS that mirror published. Pinning alone stops + # drift but still leaves a third-party host in the deploy path; mirroring means + # the bytes are ours and a pinned version cannot change or vanish. The digest + # check is the point of the exercise — fetching from a mirror without verifying + # moves the trust rather than establishing it. + # + # $GITHUB_PATH PREPENDS, so this CLI wins over any preinstalled on the image. - name: Install Pulumi CLI - uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0 - with: - pulumi-version: ${{ inputs.pulumi-version }} + shell: bash + env: + PULUMI_VERSION: ${{ inputs.pulumi-version }} + run: | + set -euo pipefail + + case "$(uname -s)/$(uname -m)" in + Linux/x86_64) PLATFORM=linux-x64 ;; + Linux/aarch64) PLATFORM=linux-arm64 ;; + Darwin/arm64) PLATFORM=darwin-arm64 ;; + *) echo "::error::no mirrored Pulumi CLI for $(uname -s)/$(uname -m). The mirror carries only the platforms we run; add it in wardnet/toolchain-mirror if this is a real target."; exit 1 ;; + esac + + MIRROR="https://github.com/wardnet/toolchain-mirror/releases/download/pulumi-v${PULUMI_VERSION}" + ARCHIVE="pulumi-v${PULUMI_VERSION}-${PLATFORM}.tar.gz" + DEST="${RUNNER_TEMP}/pulumi-cli" + mkdir -p "${DEST}" + + echo "installing Pulumi CLI ${PULUMI_VERSION} (${PLATFORM}) from the mirror..." + curl -fsSL -o "${DEST}/${ARCHIVE}" "${MIRROR}/${ARCHIVE}" + curl -fsSL -o "${DEST}/SHA256SUMS" "${MIRROR}/SHA256SUMS" + + # Verify ONLY the archive we fetched: SHA256SUMS covers every mirrored + # platform, and `sha256sum -c` fails on entries whose files are absent. + ( cd "${DEST}" && grep -F " ${ARCHIVE}" SHA256SUMS | sha256sum -c - ) + + tar -xzf "${DEST}/${ARCHIVE}" -C "${DEST}" + # The archive unpacks to a pulumi/ directory holding the CLI and its + # language hosts; they must stay together, so PATH points at that dir. + echo "${DEST}/pulumi" >> "$GITHUB_PATH" + + - name: Verify the Pulumi CLI on PATH is the pinned one + shell: bash + env: + PULUMI_VERSION: ${{ inputs.pulumi-version }} + run: | + set -euo pipefail + # Guards the failure this whole change exists to prevent: if PATH ordering + # ever resolves to the runner image's CLI instead of ours, fail here with a + # clear message rather than silently deploying on an unpinned engine. + got="$(pulumi version)" + if [ "${got#v}" != "${PULUMI_VERSION}" ]; then + echo "::error::pulumi on PATH is ${got}, expected v${PULUMI_VERSION} — the mirrored CLI is not winning PATH resolution" + exit 1 + fi + echo "pulumi ${got} (mirrored, verified)" - name: Install inforge shell: bash diff --git a/cmd/inforge/plugins.go b/cmd/inforge/plugins.go index 105de7e..f4c5eb3 100644 --- a/cmd/inforge/plugins.go +++ b/cmd/inforge/plugins.go @@ -4,12 +4,15 @@ import ( "archive/tar" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "runtime" + "strings" "github.com/spf13/cobra" ) @@ -32,32 +35,7 @@ func newPluginsCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - // Standard Pulumi providers: download tar.gz from their GitHub releases. - // Versions are pinned to match the SDK modules in go.mod. - type stdPlugin struct{ name, version, repo string } - for _, p := range []stdPlugin{ - {"hcloud", "1.38.0", "pulumi/pulumi-hcloud"}, - {"cloudflare", "6.17.0", "pulumi/pulumi-cloudflare"}, - // pulumi-random backs stable per-service database passwords (ADR-0036). - {"random", "4.16.8", "pulumi/pulumi-random"}, - // pulumiverse/grafana pushes dashboards + alerts (ADR-0038). Note the - // pulumiverse org publishes the same asset layout as pulumi/*. - {"grafana", "1.0.0", "pulumiverse/pulumi-grafana"}, - } { - fmt.Printf("installing pulumi-resource-%s v%s...\n", p.name, p.version) - if err := installPulumiPlugin(ctx, p.name, p.version, p.repo); err != nil { - return fmt.Errorf("install %s: %w", p.name, err) - } - fmt.Printf(" installed pulumi-resource-%s\n", p.name) - } - - // No custom (raw-binary) providers ship today: ADR-0036 retired the Neon - // plugin and self-hosted Postgres needs none. The seam remains if one returns. - - fmt.Println("all plugins installed") - return nil + return installAllPlugins(cmd.Context(), mirrorPluginBase) }, } @@ -65,22 +43,134 @@ func newPluginsCmd() *cobra.Command { return plugins } -// installPulumiPlugin downloads a published Pulumi provider archive from GitHub -// and extracts the binary into the Pulumi plugins directory. -func installPulumiPlugin(ctx context.Context, name, ver, repo string) error { - goos := runtime.GOOS - goarch := runtime.GOARCH +// stdPlugin is one pinned provider plugin. +type stdPlugin struct{ name, version string } + +// stdPlugins is the set of provider plugins every deploy needs. It is DATA, kept +// at package scope rather than buried in the command closure so it can be +// asserted directly — a typo in a version here surfaces as a mirror 404 at deploy +// time, which is a slow and confusing way to find it. +// +// Versions are pinned to match the SDK modules in go.mod, and each must be +// mirrored (see mirrorRepo) before it can be installed. +var stdPlugins = []stdPlugin{ + {"hcloud", "1.38.0"}, + {"cloudflare", "6.17.0"}, + // pulumi-random backs stable per-service database passwords (ADR-0036). + {"random", "4.16.8"}, + // pulumiverse/grafana pushes dashboards + alerts (ADR-0038). + {"grafana", "1.0.0"}, +} + +// installAllPlugins installs every pinned plugin, resolving each one's source +// through baseFor. Production passes mirrorPluginBase; a test can pass a stub so +// the loop is exercisable without reaching the network. +// +// No custom (raw-binary) providers ship today: ADR-0036 retired the Neon plugin +// and self-hosted Postgres needs none. The seam remains if one returns. +func installAllPlugins(ctx context.Context, baseFor func(name, ver string) string) error { + for _, p := range stdPlugins { + fmt.Printf("installing pulumi-resource-%s v%s...\n", p.name, p.version) + if err := installPulumiPlugin(ctx, p.name, p.version, baseFor(p.name, p.version)); err != nil { + return fmt.Errorf("install %s: %w", p.name, err) + } + fmt.Printf(" installed pulumi-resource-%s\n", p.name) + } + fmt.Println("all plugins installed") + return nil +} + +// mirrorRepo is the release host for every third-party binary the toolchain +// installs. Mirroring is what makes a pinned version actually pinned: a pin stops +// us from silently moving, but the artifact still lived on someone else's host and +// could change or disappear underneath it. Add a version there before pinning it +// here — see that repo's README. +const mirrorRepo = "wardnet/toolchain-mirror" + +// installPulumiPlugin downloads a Pulumi provider archive from our mirror, +// verifies it against the mirror's SHA256SUMS, and extracts the binary into the +// Pulumi plugins directory. +// +// Previously this fetched straight from each provider's GitHub releases with no +// verification at all — whatever bytes arrived were extracted and executed as part +// of a production deploy. Two things changed: +// +// - the source is our mirror, so a pinned version cannot change or vanish; +// - the download is verified, because fetching from a mirror without checking +// the digest just relocates the trust instead of establishing it. +// +// Note the digest is SHA-256 even though the upstream provider repos publish only +// SHA-1: the mirror computes its own over the bytes it stored, so what we verify +// here is stronger than anything upstream offers for these artifacts. +// base is the release URL to fetch from — supplied by the caller rather than +// derived here so the whole install path (fetch digest → verify → extract) is +// exercisable against a local server in tests. Production callers pass +// mirrorPluginBase; nothing else is a supported source. +func installPulumiPlugin(ctx context.Context, name, ver, base string) error { binary := "pulumi-resource-" + name + archive := pluginArchiveName(name, ver, runtime.GOOS, runtime.GOARCH) - // Pulumi provider archives use hyphen-separated os-arch (e.g. linux-amd64). - archive := fmt.Sprintf("%s-v%s-%s-%s.tar.gz", binary, ver, goos, goarch) - url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, ver, archive) + want, err := mirrorDigest(ctx, base+"/SHA256SUMS", archive) + if err != nil { + return err + } pluginDir, err := pulumiPluginDir(name, ver) if err != nil { return err } - return downloadAndExtractTarGz(ctx, url, pluginDir, binary) + return downloadAndExtractTarGzVerified(ctx, base+"/"+archive, pluginDir, binary, want) +} + +// pluginArchiveName builds the provider archive filename for an os/arch pair. +// +// Provider archives use the Go GOARCH spelling (linux-amd64), which is NOT the +// spelling the Pulumi CLI's own archives use for the same machine (linux-x64). +// Getting this wrong produces a 404 that reads like "this version was never +// mirrored", so the convention is isolated here and tested rather than inlined. +func pluginArchiveName(name, ver, goos, goarch string) string { + return fmt.Sprintf("pulumi-resource-%s-v%s-%s-%s.tar.gz", name, ver, goos, goarch) +} + +// mirrorPluginBase is the mirror release URL for one plugin version. The tag +// scheme (plugin--v) is the mirror's contract — see that repo's +// README — so producer and consumer must agree on it exactly. +func mirrorPluginBase(name, ver string) string { + return fmt.Sprintf("https://github.com/%s/releases/download/plugin-%s-v%s", mirrorRepo, name, ver) +} + +// mirrorDigest fetches a mirror release's SHA256SUMS and returns the expected +// digest for one file. A missing entry is an error, not a skip: the whole point is +// that nothing is installed unverified, so "no digest published" must fail loudly +// rather than quietly degrade to the old unverified behaviour. +func mirrorDigest(ctx context.Context, sumsURL, file string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sumsURL, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d fetching %s — is this version mirrored? see %s", resp.StatusCode, sumsURL, mirrorRepo) + } + + // SHA256SUMS is small and fully trusted input from our own release; cap the + // read anyway so a wrong URL can't stream unbounded into memory. + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + for line := range strings.SplitSeq(string(body), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == file { + return fields[0], nil + } + } + return "", fmt.Errorf("%s has no entry for %s — the mirrored release is incomplete for %s/%s", + sumsURL, file, runtime.GOOS, runtime.GOARCH) } func pulumiPluginDir(name, ver string) (string, error) { @@ -125,7 +215,15 @@ func downloadBinary(ctx context.Context, url, dst string, mode os.FileMode) erro return closeErr } -func downloadAndExtractTarGz(ctx context.Context, url, dir, binaryName string) error { +// downloadAndExtractTarGzVerified downloads an archive, checks its SHA-256 +// against wantDigest, and only then extracts binaryName from it. +// +// The archive is staged to a temp file and verified BEFORE a single byte is +// extracted. Hashing while streaming straight into the extractor would be less +// code, but it would write an executable to the plugin directory and only +// afterwards discover the bytes were wrong — and that executable is run as part +// of a production deploy. Verify first, then extract. +func downloadAndExtractTarGzVerified(ctx context.Context, url, dir, binaryName, wantDigest string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err @@ -136,11 +234,40 @@ func downloadAndExtractTarGz(ctx context.Context, url, dir, binaryName string) e } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d fetching %s — asset not found for %s/%s", - resp.StatusCode, url, runtime.GOOS, runtime.GOARCH) + return fmt.Errorf("HTTP %d fetching %s — asset not found for %s/%s (is this version mirrored? see %s)", + resp.StatusCode, url, runtime.GOOS, runtime.GOARCH, mirrorRepo) + } + + tmp, err := os.CreateTemp("", "inforge-plugin-*.tar.gz") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + + sum := sha256.New() + n, copyErr := io.CopyN(io.MultiWriter(tmp, sum), resp.Body, maxPluginBinarySize+1) + if closeErr := tmp.Close(); closeErr != nil && copyErr == nil { + return closeErr + } + if copyErr != nil && copyErr != io.EOF { + return copyErr + } + if n > maxPluginBinarySize { + return fmt.Errorf("archive %s exceeds %d bytes, refusing to extract", url, maxPluginBinarySize) + } + + if got := hex.EncodeToString(sum.Sum(nil)); got != wantDigest { + return fmt.Errorf("checksum mismatch for %s:\n want %s\n got %s\nrefusing to install — the mirrored artifact does not match its published SHA256SUMS", + url, wantDigest, got) + } + + f, err := os.Open(tmp.Name()) // #nosec G304 -- tmp.Name() is our own os.CreateTemp path, not external input + if err != nil { + return err } + defer func() { _ = f.Close() }() - gz, err := gzip.NewReader(resp.Body) + gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("gzip: %w", err) } diff --git a/cmd/inforge/plugins_test.go b/cmd/inforge/plugins_test.go new file mode 100644 index 0000000..6d9eaa1 --- /dev/null +++ b/cmd/inforge/plugins_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// mirrorDigest must resolve the entry for the requested file and nothing else — +// a SHA256SUMS covers every mirrored platform, so picking the wrong line would +// install a different platform's binary or fail confusingly. +func TestMirrorDigestSelectsTheRequestedFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte( + "aaaa pulumi-resource-hcloud-v1.38.0-linux-amd64.tar.gz\n" + + "bbbb pulumi-resource-hcloud-v1.38.0-linux-arm64.tar.gz\n" + + "cccc pulumi-resource-hcloud-v1.38.0-darwin-arm64.tar.gz\n")) + })) + defer srv.Close() + + got, err := mirrorDigest(context.Background(), srv.URL, "pulumi-resource-hcloud-v1.38.0-linux-arm64.tar.gz") + if err != nil { + t.Fatalf("mirrorDigest: %v", err) + } + if got != "bbbb" { + t.Errorf("digest = %q, want %q", got, "bbbb") + } +} + +// A file absent from SHA256SUMS must be a hard error. Falling back to installing +// it unverified would silently restore the behaviour this change removes. +func TestMirrorDigestRejectsMissingEntry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("aaaa some-other-file.tar.gz\n")) + })) + defer srv.Close() + + if _, err := mirrorDigest(context.Background(), srv.URL, "wanted.tar.gz"); err == nil { + t.Fatal("expected an error for a file with no published digest, got nil") + } +} + +// An unmirrored version must name the mirror in the error — the fix is always +// "mirror it first", and that is not guessable from a bare 404. +func TestMirrorDigestReportsUnmirroredVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := mirrorDigest(context.Background(), srv.URL, "anything.tar.gz") + if err == nil { + t.Fatal("expected an error for a missing SHA256SUMS, got nil") + } + if !strings.Contains(err.Error(), mirrorRepo) { + t.Errorf("error should point at the mirror, got: %v", err) + } +} + +// The whole point of the change: bytes that do not match the published digest are +// never extracted. A tampered or corrupt archive must fail before anything is +// written to the plugin directory. +func TestDownloadAndExtractRejectsChecksumMismatch(t *testing.T) { + payload := []byte("not really a tarball") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + })) + defer srv.Close() + + real := sha256.Sum256(payload) + wrong := strings.Repeat("0", 64) + + err := downloadAndExtractTarGzVerified(context.Background(), srv.URL, t.TempDir(), "pulumi-resource-x", wrong) + if err == nil { + t.Fatal("expected a checksum mismatch error, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Errorf("want a checksum mismatch error, got: %v", err) + } + // The real digest must not leak into the failure as though it were expected. + if strings.Contains(err.Error(), hex.EncodeToString(real[:])) && !strings.Contains(err.Error(), wrong) { + t.Error("error should report both wanted and got digests") + } +} + +// tarGzWith builds a gzipped tar containing one entry, as the provider archives +// do, so the extraction path can be exercised without the network. +func tarGzWith(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + }); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("tar write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +// The happy path: a correctly-signed archive is extracted and the binary lands +// executable in the plugin directory. +func TestDownloadAndExtractVerifiedExtractsBinary(t *testing.T) { + const binary = "pulumi-resource-hcloud" + want := []byte("#!/bin/sh\necho plugin\n") + archive := tarGzWith(t, binary, want) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + defer srv.Close() + + sum := sha256.Sum256(archive) + dir := t.TempDir() + + if err := downloadAndExtractTarGzVerified(context.Background(), srv.URL, dir, binary, hex.EncodeToString(sum[:])); err != nil { + t.Fatalf("extract: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, binary)) + if err != nil { + t.Fatalf("read extracted binary: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("extracted content = %q, want %q", got, want) + } + + info, err := os.Stat(filepath.Join(dir, binary)) + if err != nil { + t.Fatal(err) + } + // Pulumi execs the plugin directly; a non-executable file fails at deploy time. + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("extracted binary is not executable (mode %v)", info.Mode().Perm()) + } +} + +// An archive that verifies but does not contain the expected binary must error +// rather than silently leave the plugin directory empty — the deploy would then +// fail much later with a confusing "plugin not found". +func TestDownloadAndExtractVerifiedRejectsArchiveWithoutBinary(t *testing.T) { + archive := tarGzWith(t, "some-other-file", []byte("x")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + defer srv.Close() + + sum := sha256.Sum256(archive) + err := downloadAndExtractTarGzVerified(context.Background(), srv.URL, t.TempDir(), "pulumi-resource-hcloud", hex.EncodeToString(sum[:])) + if err == nil { + t.Fatal("expected an error when the binary is absent from the archive, got nil") + } + if !strings.Contains(err.Error(), "not found in archive") { + t.Errorf("unexpected error: %v", err) + } +} + +// A 404 from the mirror must name the mirror: the fix is always "mirror that +// version first", which is not guessable from a bare HTTP status. +func TestDownloadAndExtractVerifiedReportsUnmirroredArtifact(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + err := downloadAndExtractTarGzVerified(context.Background(), srv.URL, t.TempDir(), "pulumi-resource-hcloud", strings.Repeat("0", 64)) + if err == nil { + t.Fatal("expected an error on 404, got nil") + } + if !strings.Contains(err.Error(), mirrorRepo) { + t.Errorf("error should point at the mirror, got: %v", err) + } +} + +// The two naming conventions are easy to conflate and fail as an unhelpful 404, +// so pin them: provider archives use GOARCH (amd64), while the Pulumi CLI's own +// archives use x64 for the same machine. +func TestPluginArchiveNameUsesGoarchSpelling(t *testing.T) { + tests := []struct { + goos, goarch, want string + }{ + {"linux", "amd64", "pulumi-resource-hcloud-v1.38.0-linux-amd64.tar.gz"}, + {"linux", "arm64", "pulumi-resource-hcloud-v1.38.0-linux-arm64.tar.gz"}, + {"darwin", "arm64", "pulumi-resource-hcloud-v1.38.0-darwin-arm64.tar.gz"}, + } + for _, tt := range tests { + if got := pluginArchiveName("hcloud", "1.38.0", tt.goos, tt.goarch); got != tt.want { + t.Errorf("pluginArchiveName(%s/%s) = %q, want %q", tt.goos, tt.goarch, got, tt.want) + } + } +} + +// The tag scheme is the mirror's contract; producer and consumer must agree. +func TestMirrorPluginBaseMatchesTheMirrorTagScheme(t *testing.T) { + want := "https://github.com/wardnet/toolchain-mirror/releases/download/plugin-grafana-v1.0.0" + if got := mirrorPluginBase("grafana", "1.0.0"); got != want { + t.Errorf("mirrorPluginBase = %q, want %q", got, want) + } +} + +// The full install path against a local stand-in for the mirror: fetch the +// digest, verify the archive, extract into the Pulumi plugin directory. This is +// the shape of what runs before every deploy, so it is worth exercising as one +// piece rather than only as its parts. +func TestInstallPulumiPluginVerifiesAndInstalls(t *testing.T) { + const ( + name = "hcloud" + ver = "1.38.0" + ) + binary := "pulumi-resource-" + name + want := []byte("#!/bin/sh\necho hcloud\n") + archive := tarGzWith(t, binary, want) + sum := sha256.Sum256(archive) + archiveName := pluginArchiveName(name, ver, runtime.GOOS, runtime.GOARCH) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/SHA256SUMS"): + // Cover every platform, as a real mirror release does, so the test also + // proves the right line is selected rather than the first one. + for _, p := range []string{"linux-amd64", "linux-arm64", "darwin-arm64"} { + digest := "0000000000000000000000000000000000000000000000000000000000000000" + file := pluginArchiveName(name, ver, strings.Split(p, "-")[0], strings.Split(p, "-")[1]) + if file == archiveName { + digest = hex.EncodeToString(sum[:]) + } + _, _ = w.Write([]byte(digest + " " + file + "\n")) + } + case strings.HasSuffix(r.URL.Path, archiveName): + _, _ = w.Write(archive) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + // pulumiPluginDir resolves under the user's home; point it at a temp dir so the + // test never touches the real ~/.pulumi. + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installPulumiPlugin(context.Background(), name, ver, srv.URL); err != nil { + t.Fatalf("installPulumiPlugin: %v", err) + } + + got, err := os.ReadFile(filepath.Join(home, ".pulumi", "plugins", "resource-"+name+"-v"+ver, binary)) + if err != nil { + t.Fatalf("plugin binary not installed where Pulumi looks for it: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("installed content = %q, want %q", got, want) + } +} + +// A tampered archive must not reach the plugin directory at all — verification +// happens before extraction precisely so nothing executable is written first. +func TestInstallPulumiPluginLeavesNothingBehindOnMismatch(t *testing.T) { + const ( + name = "hcloud" + ver = "1.38.0" + ) + archiveName := pluginArchiveName(name, ver, runtime.GOOS, runtime.GOARCH) + tampered := tarGzWith(t, "pulumi-resource-"+name, []byte("malicious")) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/SHA256SUMS") { + // A digest that does NOT match the bytes served below. + _, _ = w.Write([]byte(strings.Repeat("a", 64) + " " + archiveName + "\n")) + return + } + _, _ = w.Write(tampered) + })) + defer srv.Close() + + home := t.TempDir() + t.Setenv("HOME", home) + + if err := installPulumiPlugin(context.Background(), name, ver, srv.URL); err == nil { + t.Fatal("expected a checksum mismatch, got nil") + } + + if _, err := os.Stat(filepath.Join(home, ".pulumi", "plugins", "resource-"+name+"-v"+ver, "pulumi-resource-"+name)); !os.IsNotExist(err) { + t.Error("a binary was written despite the checksum mismatch — verification must precede extraction") + } +} + +// The pinned set must be well-formed: a blank field or a duplicate name is a +// typo that would otherwise surface as a mirror 404 during a deploy. +func TestStdPluginsAreWellFormed(t *testing.T) { + if len(stdPlugins) == 0 { + t.Fatal("stdPlugins is empty — no provider would be installed") + } + seen := map[string]bool{} + for _, p := range stdPlugins { + if p.name == "" || p.version == "" { + t.Errorf("incomplete entry: %+v", p) + } + if strings.HasPrefix(p.version, "v") { + t.Errorf("%s version %q must not carry a leading v — it is added when building the tag", p.name, p.version) + } + if seen[p.name] { + t.Errorf("duplicate plugin %q", p.name) + } + seen[p.name] = true + } +} + +// The install loop over the real pinned set, against a local stand-in for the +// mirror: every plugin is fetched, verified and installed, and a failure on any +// one of them aborts rather than reporting success. +func TestInstallAllPluginsInstallsEveryPinnedPlugin(t *testing.T) { + archives := map[string][]byte{} + sums := map[string]string{} + for _, p := range stdPlugins { + name := pluginArchiveName(p.name, p.version, runtime.GOOS, runtime.GOARCH) + a := tarGzWith(t, "pulumi-resource-"+p.name, []byte(p.name)) + archives[name] = a + s := sha256.Sum256(a) + sums[name] = hex.EncodeToString(s[:]) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/SHA256SUMS") { + // The tag carries the plugin name; serve that plugin's digest. + for file, digest := range sums { + if strings.Contains(r.URL.Path, tagNameOf(file)) { + _, _ = w.Write([]byte(digest + " " + file + "\n")) + return + } + } + w.WriteHeader(http.StatusNotFound) + return + } + for file, a := range archives { + if strings.HasSuffix(r.URL.Path, file) { + _, _ = w.Write(a) + return + } + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + home := t.TempDir() + t.Setenv("HOME", home) + + base := func(name, ver string) string { return srv.URL + "/plugin-" + name + "-v" + ver } + if err := installAllPlugins(context.Background(), base); err != nil { + t.Fatalf("installAllPlugins: %v", err) + } + + for _, p := range stdPlugins { + path := filepath.Join(home, ".pulumi", "plugins", "resource-"+p.name+"-v"+p.version, "pulumi-resource-"+p.name) + if _, err := os.Stat(path); err != nil { + t.Errorf("%s not installed: %v", p.name, err) + } + } +} + +// tagNameOf maps an archive filename back to its plugin name, so the stub server +// can tell which plugin's SHA256SUMS is being requested. +func tagNameOf(archive string) string { + rest := strings.TrimPrefix(archive, "pulumi-resource-") + if i := strings.Index(rest, "-v"); i >= 0 { + return rest[:i] + } + return rest +} + +// A single plugin failing must abort the run — reporting "all plugins installed" +// after a partial install would leave the deploy to fail later, further from the +// cause. +func TestInstallAllPluginsStopsOnFirstFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + base := func(_, _ string) string { return srv.URL } + + err := installAllPlugins(context.Background(), base) + if err == nil { + t.Fatal("expected the run to abort, got nil") + } + if !strings.Contains(err.Error(), stdPlugins[0].name) { + t.Errorf("error should name the plugin that failed first, got: %v", err) + } +} diff --git a/go.mod b/go.mod index 74aee7a..d21c92c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/pulumi/pulumi-command/sdk v1.2.1 github.com/pulumi/pulumi-hcloud/sdk v1.39.0 github.com/pulumi/pulumi-random/sdk/v4 v4.21.0 - github.com/pulumi/pulumi/sdk/v3 v3.251.0 + github.com/pulumi/pulumi/sdk/v3 v3.253.0 github.com/pulumiverse/pulumi-grafana/sdk v1.0.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 94dab73..4735597 100644 --- a/go.sum +++ b/go.sum @@ -242,8 +242,8 @@ github.com/pulumi/pulumi-hcloud/sdk v1.39.0 h1:Rm2Xh/EnPs53v1CEWxJzAasxTT8Exycz+ github.com/pulumi/pulumi-hcloud/sdk v1.39.0/go.mod h1:rfOSimT+VT5KOiEO62R+wibR+Y3zDGHigOvzPecJ56g= github.com/pulumi/pulumi-random/sdk/v4 v4.21.0 h1:j6LtoXue77y16trYOR40iqCUxXxv8TnxvOCOP/a0zVI= github.com/pulumi/pulumi-random/sdk/v4 v4.21.0/go.mod h1:92+w+95clbBPdrYdi0uCajihmgol52w/pWgZJ9wT0To= -github.com/pulumi/pulumi/sdk/v3 v3.251.0 h1:H1D42Jra2jvnEij0M0dZcNGz3Q9DyANsvzQqalncsXU= -github.com/pulumi/pulumi/sdk/v3 v3.251.0/go.mod h1:ZXd0WRd89VLX2juP+HEXMZ+J2AFPfcJTXhdmmv9cMgY= +github.com/pulumi/pulumi/sdk/v3 v3.253.0 h1:YWdKcZMZC7DtYSqNBn1Yv2UXmFMcgx7RqG3lhhBNZyE= +github.com/pulumi/pulumi/sdk/v3 v3.253.0/go.mod h1:iTb2Yb9mn0kfUfMjwHgSuI5sxVUOcXRy0JcV7DLWsrc= github.com/pulumiverse/pulumi-grafana/sdk v1.0.0 h1:9qLa98MV/0E/JTbsAfVo/w348FK19qDYI8C6Z5i8IDc= github.com/pulumiverse/pulumi-grafana/sdk v1.0.0/go.mod h1:UKCepGCFF9rLtTvdpqyW0HqJsz3Nx7UGkEkNZw0T77w= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=