Skip to content
Merged
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
2 changes: 2 additions & 0 deletions cmd/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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",
}
}
Expand Down
6 changes: 5 additions & 1 deletion e2e/e2e_config_ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"testing"

"gopkg.in/yaml.v3"

"knative.dev/func/pkg/cluster"
)

func TestConfigCI_DeployFuncViaGeneratedGitHubWorkflow(t *testing.T) {
Expand Down Expand Up @@ -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
Expand Down
137 changes: 129 additions & 8 deletions pkg/cluster/binaries.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cluster

import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
Expand All @@ -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 "<os>/<arch>" 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",
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I verified the case-insensivity too but did not find any documentation on it; if GH ever changes that CDN behaviour, we might get some failures :D

// 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
Expand All @@ -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)
}
}
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions pkg/cluster/binaries_extract_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 12 additions & 1 deletion pkg/cluster/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<TOOL> 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
Expand Down Expand Up @@ -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_<TOOL>)
// are handled by the ClusterConfig accessors before this is called.
Expand Down
11 changes: 11 additions & 0 deletions pkg/cluster/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<version>/checksums.txt.
var actChecksums = map[string]string{
"linux/amd64": "1eb9996682dfcc053ac8f3f90f2ec50376f0cdfc229712d82da03d673c63a2b3",
"linux/arm64": "94d87738f7ea6650782c8505366c758c99db54cc67bd8c711583478c93305d78",
"darwin/amd64": "887cd13013fdd866f80872ef2b473b7d34af6c2e366cc77542c9b75dceea7a82",
"darwin/arm64": "5f52aa4151c1cc762b246534a7f5633ae2b4b89aa036fbba7d9ccf04ca37acd9",
}
Loading