diff --git a/cmd/cluster.go b/cmd/cluster.go index 509901d872..5314424b42 100644 --- a/cmd/cluster.go +++ b/cmd/cluster.go @@ -180,6 +180,7 @@ func newClusterCreateConfig() cluster.ClusterConfig { ContainerEngineOverride: viper.GetString("container-engine"), KubectlOverride: os.Getenv("FUNC_TEST_KUBECTL"), // override binary path KindOverride: os.Getenv("FUNC_TEST_KIND"), // override binary path + ActOverride: os.Getenv("FUNC_TEST_ACT"), // override binary path GitHubActions: os.Getenv("GITHUB_ACTIONS") == "true", // detect CI environments } } @@ -239,6 +240,7 @@ func newClusterDeleteConfig() cluster.ClusterConfig { SkipRegistryConfig: viper.GetBool("skip-registry-config"), KubectlOverride: os.Getenv("FUNC_TEST_KUBECTL"), KindOverride: os.Getenv("FUNC_TEST_KIND"), + ActOverride: os.Getenv("FUNC_TEST_ACT"), GitHubActions: os.Getenv("GITHUB_ACTIONS") == "true", } } diff --git a/e2e/e2e_config_ci_test.go b/e2e/e2e_config_ci_test.go index a6eed94a25..7c76caccd5 100644 --- a/e2e/e2e_config_ci_test.go +++ b/e2e/e2e_config_ci_test.go @@ -11,6 +11,8 @@ import ( "testing" "gopkg.in/yaml.v3" + + "knative.dev/func/pkg/cluster" ) func TestConfigCI_DeployFuncViaGeneratedGitHubWorkflow(t *testing.T) { @@ -93,7 +95,9 @@ func runGitHubWorkflow(t *testing.T, dir string) { if strings.Contains(Registry, "registry.localtest.me") { args = append(args, "--env", "FUNC_REGISTRY_INSECURE=true") } - cmd := exec.Command("act", args...) + // Resolve act via cluster.Act() (managed BinDir, then PATH; FUNC_TEST_ACT override). + act := cluster.ClusterConfig{ActOverride: os.Getenv("FUNC_TEST_ACT")}.Act() + cmd := exec.Command(act, args...) cmd.Dir = dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/pkg/cluster/binaries.go b/pkg/cluster/binaries.go index 55c9d427b2..54a40a1d90 100644 --- a/pkg/cluster/binaries.go +++ b/pkg/cluster/binaries.go @@ -1,6 +1,8 @@ package cluster import ( + "archive/tar" + "compress/gzip" "context" "crypto/sha256" "encoding/hex" @@ -23,12 +25,16 @@ var downloadClient = &http.Client{Timeout: 5 * time.Minute} // bins lists the binaries to download and manage. Checksums pins a hex // SHA-256 per "/" key; the installer refuses to run on a -// platform not present in the map. +// platform not present in the map. ArchiveEntry, when non-empty, marks +// the URL as a .tar.gz archive containing the binary at that entry path +// (the checksum is verified against the archive, then the binary is +// extracted from it). var bins = []struct { - Name string - Version string - URL func(goos, goarch string) string - Checksums map[string]string + Name string + Version string + URL func(goos, goarch string) string + Checksums map[string]string + ArchiveEntry string }{ { Name: "kubectl", @@ -46,6 +52,22 @@ var bins = []struct { return fmt.Sprintf("https://github.com/kubernetes-sigs/kind/releases/download/v%s/kind-%s-%s", kindVersion, goos, goarch) }, }, + { + Name: "act", + Version: actVersion, + Checksums: actChecksums, + ArchiveEntry: "act", + URL: func(goos, goarch string) string { + // GitHub release asset names are case-insensitive, so goos + // works directly. amd64 needs translation since act's release + // uses x86_64 (a different name, not just different casing). + arch := goarch + if arch == "amd64" { + arch = "x86_64" + } + return fmt.Sprintf("https://github.com/nektos/act/releases/download/v%s/act_%s_%s.tar.gz", actVersion, goos, arch) + }, + }, } // ensureBins downloads required tool binaries if they are not already @@ -69,7 +91,7 @@ func ensureBins(ctx context.Context, cfg ClusterConfig, out io.Writer) error { if !ok { return fmt.Errorf("no pinned checksum for %s on %s", bin.Name, platform) } - if err := ensureBin(ctx, cfg.BinDir(), bin.Name, bin.Version, bin.URL(goos, goarch), sum, out); err != nil { + if err := ensureBin(ctx, cfg.BinDir(), bin.Name, bin.Version, bin.URL(goos, goarch), sum, bin.ArchiveEntry, out); err != nil { return fmt.Errorf("installing %s: %w", bin.Name, err) } } @@ -79,8 +101,15 @@ func ensureBins(ctx context.Context, cfg ClusterConfig, out io.Writer) error { } // ensureBin installs a single tool at the given version, verifying the -// downloaded bytes against the pinned SHA-256 wantSum. -func ensureBin(ctx context.Context, binDir, name, version, url, wantSum string, out io.Writer) error { +// downloaded bytes against the pinned SHA-256 wantSum. If archiveEntry is +// non-empty, the URL is treated as a .tar.gz archive and the named entry +// is extracted from it after checksum verification. +// +// Cache hit is existence-only (os.Stat): we do not re-hash the on-disk file. +// For archived tools the pin is the *tarball* sum; after extract the path +// holds the binary, so a future "verify cached checksum" feature must not +// compare the extracted bytes to wantSum or act installs will false-fail. +func ensureBin(ctx context.Context, binDir, name, version, url, wantSum, archiveEntry string, out io.Writer) error { fullName := fmt.Sprintf("%s-%s", name, version) path := filepath.Join(binDir, fullName) link := filepath.Join(binDir, name) @@ -96,6 +125,11 @@ func ensureBin(ctx context.Context, binDir, name, version, url, wantSum string, if err := download(ctx, url, wantSum, path); err != nil { return err } + if archiveEntry != "" { + if err := extractFromTarGz(path, archiveEntry); err != nil { + return fmt.Errorf("extracting %s from archive: %w", archiveEntry, err) + } + } if err := os.Chmod(path, 0o755); err != nil { return fmt.Errorf("chmod: %w", err) } @@ -104,6 +138,93 @@ func ensureBin(ctx context.Context, binDir, name, version, url, wantSum string, return updateLink(link, fullName) } +// maxExtractedBinSize bounds the bytes extracted from a tarball entry. +// The archive is already SHA-256 verified, but a hard cap here defends +// in depth: it prevents a malicious tarball — were the pin ever wrong — +// from filling the disk or decompressing as a gzip-bomb. 256 MiB is +// generous for any tool binary we'd realistically install. +const maxExtractedBinSize = 256 * 1024 * 1024 + +// extractFromTarGz replaces the .tar.gz file at archivePath with the +// contents of the entry whose name matches entryName. The file is +// rewritten in place via an atomic rename, so a failure leaves the +// original archive untouched. +// +// Defensive choices: rejects non-regular file types (symlinks, +// hardlinks, devices, etc.); rejects entries declaring a size outside +// [0, maxExtractedBinSize]; uses io.CopyN to assert the body delivered +// matches the header's declared size (catches truncated streams). +func extractFromTarGz(archivePath, entryName string) error { + // Extract into a sibling temp file first, then close every handle on the + // original archive before renaming over it. On Windows a still-open + // archivePath makes os.Rename return "Access is denied". + tmp, err := extractTarGzEntryToTemp(archivePath, entryName) + if err != nil { + return err + } + if err := os.Rename(tmp, archivePath); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// extractTarGzEntryToTemp opens archivePath, finds entryName, and writes it +// to archivePath+".extracted". Callers must close nothing; all handles are +// closed before return so Windows can rename over archivePath. +func extractTarGzEntryToTemp(archivePath, entryName string) (tmpPath string, err error) { + f, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return "", fmt.Errorf("entry %q not found in archive", entryName) + } + if err != nil { + return "", err + } + if hdr.Name != entryName { + continue + } + if hdr.Typeflag != tar.TypeReg { + return "", fmt.Errorf("entry %q has unexpected type 0x%x; only regular files are allowed", entryName, hdr.Typeflag) + } + if hdr.Size < 0 || hdr.Size > maxExtractedBinSize { + return "", fmt.Errorf("entry %q declared size %d outside permitted range [0, %d]", entryName, hdr.Size, maxExtractedBinSize) + } + tmp := archivePath + ".extracted" + w, err := os.Create(tmp) + if err != nil { + return "", err + } + if _, err := io.CopyN(w, tr, hdr.Size); err != nil { + w.Close() + os.Remove(tmp) + return "", err + } + if err := w.Close(); err != nil { + os.Remove(tmp) + return "", err + } + // Explicit close before return so defer does not race with rename + // after extractFromTarGz returns (defers run on function exit). + _ = gz.Close() + _ = f.Close() + return tmp, nil + } +} + // download fetches url to dest atomically: it writes to dest+".tmp" while // hashing, verifies against the pinned SHA-256 wantSum, then renames the // tmp into place. A failure anywhere leaves dest untouched. diff --git a/pkg/cluster/binaries_extract_test.go b/pkg/cluster/binaries_extract_test.go new file mode 100644 index 0000000000..4cc2bc074c --- /dev/null +++ b/pkg/cluster/binaries_extract_test.go @@ -0,0 +1,72 @@ +package cluster + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func TestExtractFromTarGz_happyPath(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "tool.tar.gz") + writeTarGz(t, archive, map[string][]byte{ + "LICENSE": []byte("mit"), + "act": []byte("#!/bin/sh\necho act\n"), + }) + + if err := extractFromTarGz(archive, "act"); err != nil { + t.Fatalf("extract: %v", err) + } + got, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + if string(got) != "#!/bin/sh\necho act\n" { + t.Fatalf("extracted body = %q", got) + } +} + +func TestExtractFromTarGz_missingEntry(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "tool.tar.gz") + writeTarGz(t, archive, map[string][]byte{"other": []byte("x")}) + + if err := extractFromTarGz(archive, "act"); err == nil { + t.Fatal("expected missing-entry error") + } +} + +func writeTarGz(t *testing.T, path string, files map[string][]byte) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, body := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(body)), + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/cluster/config.go b/pkg/cluster/config.go index 4e1dc5e541..486e9cfd3c 100644 --- a/pkg/cluster/config.go +++ b/pkg/cluster/config.go @@ -38,12 +38,13 @@ type ClusterConfig struct { SkipRegistryConfig bool // Skip host registry configuration NoCleanup bool // Don't delete cluster on failure - // Optional tool path overrides. When non-empty, the kubectl/kind + // Optional tool path overrides. When non-empty, the kubectl/kind/act // accessors return the override verbatim; otherwise they resolve via // BinDir then PATH. The CLI samples FUNC_TEST_ env vars into // these fields; the library itself never reads the environment. KubectlOverride string KindOverride string + ActOverride string // CI detection GitHubActions bool // Auto-detected from GITHUB_ACTIONS env @@ -108,6 +109,16 @@ func (c ClusterConfig) kind() string { return findTool("kind", c.BinDir()) } +// Act returns the resolved path to the act binary (nektos/act). +// Prefer managed BinDir (func cluster create install), then PATH. +// Exported so callers outside this package (e.g. e2e) share resolution. +func (c ClusterConfig) Act() string { + if c.ActOverride != "" { + return c.ActOverride + } + return findTool("act", c.BinDir()) +} + // findTool resolves a tool path by checking the managed BinDir first, // then falling back to the system PATH. Overrides (e.g. from FUNC_TEST_) // are handled by the ClusterConfig accessors before this is called. diff --git a/pkg/cluster/versions.go b/pkg/cluster/versions.go index 49350ac9a0..0a080f1e41 100644 --- a/pkg/cluster/versions.go +++ b/pkg/cluster/versions.go @@ -19,6 +19,7 @@ const ( const ( kubectlVersion = "1.33.1" kindVersion = "0.31.0" + actVersion = "0.2.88" ) // kubectlChecksums pins the expected SHA-256 of the kubectl binary for each @@ -40,3 +41,13 @@ var kindChecksums = map[string]string{ "darwin/amd64": "a8b3cf77b2ad77aec5bf710d1a2589d9117576132af812885cad41e9dede4d4e", "darwin/arm64": "88bf554fe9da6311c9f8c2d082613c002911a476f6b5090e9420b35d84e70c5c", } + +// actChecksums pins the expected SHA-256 of the act release tarball for each +// supported os/arch at actVersion. Update in lockstep with actVersion. +// Sourced from https://github.com/nektos/act/releases/download/v/checksums.txt. +var actChecksums = map[string]string{ + "linux/amd64": "1eb9996682dfcc053ac8f3f90f2ec50376f0cdfc229712d82da03d673c63a2b3", + "linux/arm64": "94d87738f7ea6650782c8505366c758c99db54cc67bd8c711583478c93305d78", + "darwin/amd64": "887cd13013fdd866f80872ef2b473b7d34af6c2e366cc77542c9b75dceea7a82", + "darwin/arm64": "5f52aa4151c1cc762b246534a7f5633ae2b4b89aa036fbba7d9ccf04ca37acd9", +}