diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0491a5c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + branches: ["main", "release*"] + push: + branches: ["main", "release*"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.8.0 + args: --verbose --timeout=15m + + unit-test: + name: Unit tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: go build ./... + + - name: Run strategy tests + run: go test ./pkg/... -v -count=1 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..e5868ab --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,148 @@ +name: E2E + +on: + pull_request: + branches: ["main", "release*"] + push: + branches: ["main", "release*"] + workflow_dispatch: + inputs: + operator_image: + description: "Operator image (e.g. ghcr.io/securesign/secure-sign-operator:latest)" + required: false + operator_ref: + description: "secure-sign-operator branch/tag for CI infrastructure" + required: false + default: main + +env: + REGISTRY: ghcr.io + OPERATOR_REPO: securesign/secure-sign-operator + OPERATOR_REF: ${{ inputs.operator_ref || 'main' }} + TEST_NAMESPACE: test + +jobs: + e2e: + name: E2E tests + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + steps: + - name: Checkout secure-sign-operator + uses: actions/checkout@v4 + with: + repository: ${{ env.OPERATOR_REPO }} + ref: ${{ env.OPERATOR_REF }} + + - name: Checkout sigstore-e2e + uses: actions/checkout@v4 + with: + path: e2e + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: e2e/go.mod + + - name: Resolve operator image + id: operator-image + run: | + if [ -n "${{ inputs.operator_image }}" ]; then + echo "img=${{ inputs.operator_image }}" >> "$GITHUB_OUTPUT" + else + echo "img=registry.redhat.io/rhtas/rhtas-rhel9-operator:1.4.0" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to GitHub Container Registry + uses: redhat-actions/podman-login@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + auth_file_path: /tmp/config.json + + - name: Log in to registry.redhat.io + uses: redhat-actions/podman-login@v1 + with: + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + registry: registry.redhat.io + auth_file_path: /tmp/config.json + + - name: Install Kind cluster + id: kind + uses: ./.github/actions/kind-cluster + with: + config: ./ci/config.yaml + keycloak: "true" + olm: "true" + prometheus: "true" + + - name: Pull operator image + run: podman pull ${{ steps.operator-image.outputs.img }} + + - name: Load operator image into Kind + run: | + podman save ${{ steps.operator-image.outputs.img }} -o operator-oci.tar + kind load image-archive operator-oci.tar + + - name: Deploy operator + run: | + make dev-images generate && cat config/default/images.env + IMG=${{ steps.operator-image.outputs.img }} make deploy + + - name: Wait for operator + run: | + kubectl wait --for=condition=available deployment/rhtas-operator-controller-manager \ + --timeout=120s -n openshift-rhtas-operator + + - name: Add service hosts + run: | + echo "127.0.0.1 fulcio-server.local tuf.local rekor-server.local keycloak-internal.keycloak-system.svc rekor-search-ui.local cli-server.local tsa-server.local" \ + | sudo tee -a /etc/hosts + + - name: Install SecureSign + run: | + OIDC_HOST="${{ steps.kind.outputs.oidc_host }}" + sed -i "s#https://your-oidc-issuer-url#http://${OIDC_HOST}/realms/trusted-artifact-signer#" \ + config/samples/rhtas_v1alpha1_securesign.yaml + kubectl create ns ${{ env.TEST_NAMESPACE }} + kubectl create -f config/samples/rhtas_v1alpha1_securesign.yaml -n ${{ env.TEST_NAMESPACE }} + sleep 1 + kubectl wait --for=condition=Ready securesign/securesign-sample \ + --timeout=5m -n ${{ env.TEST_NAMESPACE }} + + - name: Run E2E tests + working-directory: e2e + env: + OIDC_ISSUER_URL: "http://${{ steps.kind.outputs.oidc_host }}/realms/trusted-artifact-signer" + CLI_STRATEGY: cli_server + CLI_SERVER_URL: "http://cli-server.local" + run: | + REKOR_UI_URL=$(kubectl get rekor -o jsonpath='{.items[0].status.rekorSearchUIUrl}' -n ${{ env.TEST_NAMESPACE }}) + export REKOR_UI_URL + TUF_URL=$(kubectl get tuf -o jsonpath='{.items[0].status.url}' -n ${{ env.TEST_NAMESPACE }}) + export TUF_URL + REKOR_URL=$(kubectl get rekor -o jsonpath='{.items[0].status.url}' -n ${{ env.TEST_NAMESPACE }}) + export REKOR_URL + FULCIO_URL=$(kubectl get fulcio -o jsonpath='{.items[0].status.url}' -n ${{ env.TEST_NAMESPACE }}) + export FULCIO_URL + + source ./tas-env-variables.sh + + go run github.com/playwright-community/playwright-go/cmd/playwright install --with-deps + go test -v ./test/... + + - name: Dump operator logs + if: failure() + run: | + kubectl logs -n openshift-rhtas-operator deployment/rhtas-operator-controller-manager + + - name: Archive test artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-results + path: e2e/test/**/k8s-dump-*.tar.gz + if-no-files-found: ignore diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index b3efbfb..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: validate - -on: - pull_request: - branches: - - main - push: - branches: - - main -jobs: - validate: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - name: Install Go - uses: actions/setup-go@v4 - with: - go-version-file: 'go.mod' - - name: golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - version: v1.55.2 - args: --verbose --deadline 15m diff --git a/.golangci.yml b/.golangci.yml index 2611432..641403c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,54 +1,38 @@ -linters-settings: - lll: - line-length: 170 +version: "2" + linters: - enable-all: true - disable: - - cyclop - - deadcode - - depguard - - dupl - - exhaustive - - exhaustivestruct - - exhaustruct - - forbidigo - - funlen - - gci - - gochecknoglobals - - gochecknoinits - - gocognit + default: none + enable: + - copyloopvar + - errcheck - goconst - gocyclo - - godox - - goerr113 - - gofumpt - - golint - - gomnd - - gomoddirectives - - ifshort - - interfacer - - ireturn - - lll - - maligned + - gosec + - govet + - ineffassign + - misspell - nakedret - - nestif - - nilnil - - nlreturn - - nolintlint - - nosnakecase - - paralleltest - - revive - - rowserrcheck - - scopelint - - structcheck - - sqlclosecheck - - tagalign - - tagliatelle - - tenv - - testpackage - - varcheck - - varnamelen - - wastedassign - - whitespace - - wrapcheck - - wsl \ No newline at end of file + - prealloc + - staticcheck + - unconvert + - unparam + - unused + exclusions: + generated: lax + rules: + - linters: + - errcheck + path: (test/|pkg/support/) + - linters: + - gosec + path: (test/|pkg/support/|pkg/kubernetes/|pkg/clients/) + - linters: + - goconst + - prealloc + - staticcheck + path: test/ + +formatters: + enable: + - gofmt + - goimports diff --git a/README.md b/README.md index 3d7b04b..1b23677 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,24 @@ This test suite aims to cover Trusted Artifact Signer deployment with end-to-end - Windows PowerShell: `tas-env-variables.ps1` -- Optional: Set `CLI_STRATEGY` environment variable to either `openshift` or `local`: +- Optional: Set `CLI_STRATEGY` environment variable to configure how CLI binaries are obtained: ``` export CLI_STRATEGY=openshift ``` -This configures the test suite to download `cosign`, `gitsign`, `rekor-cli`, `ec`, `tuftool` binaries from the cluster's console. If not set, the suite will use local binaries by default. + Available strategies: + - `local` (default) — uses binaries already on `$PATH` + - `openshift` — downloads from the cluster's `ConsoleCLIDownload` resources + - `cli_server` — downloads from a CLI server (requires `CLI_SERVER_URL`) + - `cgw` — downloads from the Red Hat content gateway (requires `CGW_URL`) + + For the `cgw` strategy, set the base URL including the RHTAS version: +``` +export CLI_STRATEGY=cgw +# GA +export CGW_URL=https://developers.redhat.com/content-gateway/file/cgw/RHTAS/1.4.0 +# Stage +# export CGW_URL=https://developers.qa.redhat.com/content-gateway/file/cgw/RHTAS/1.4.0 +``` - Optional: To use a manual image setup, set the `MANUAL_IMAGE_SETUP` environment variable to `true` and specify the `TARGET_IMAGE_NAME`. ``` diff --git a/go.mod b/go.mod index 09fa436..55f6918 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/securesign/sigstore-e2e -go 1.21 +go 1.24 replace github.com/go-jose/go-jose/v3 => github.com/go-jose/go-jose/v4 v4.0.5 @@ -25,6 +25,7 @@ require ( require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect diff --git a/go.sum b/go.sum index 6b94fb1..0d98ee1 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/pkg/api/values.go b/pkg/api/values.go index c78f1bd..f56ff42 100644 --- a/pkg/api/values.go +++ b/pkg/api/values.go @@ -19,6 +19,7 @@ const ( GithubRepo = "TEST_GITHUB_REPO" CliStrategy = "CLI_STRATEGY" CLIServerURL = "CLI_SERVER_URL" + CGWURL = "CGW_URL" ManualImageSetup = "MANUAL_IMAGE_SETUP" TargetImageName = "TARGET_IMAGE_NAME" CosignImage = "COSIGN_IMAGE" @@ -29,6 +30,12 @@ const ( TestSafari = "TEST_SAFARI" TestEdge = "TEST_EDGE" + ContainerImage = "CONTAINER_IMAGE" + ContainerPath = "CONTAINER_PATH" + GitURL = "GIT_URL" + GitBranch = "GIT_BRANCH" + GitBuildDir = "GIT_BUILD_DIR" + // 'DockerRegistry*' - Login credentials for 'registry.redhat.io'. DockerRegistryUsername = "REGISTRY_USERNAME" DockerRegistryPassword = "REGISTRY_PASSWORD" diff --git a/pkg/clients/cli.go b/pkg/clients/cli.go index c9731c8..74668e1 100644 --- a/pkg/clients/cli.go +++ b/pkg/clients/cli.go @@ -4,6 +4,8 @@ import ( "context" "os/exec" + "github.com/securesign/sigstore-e2e/pkg/api" + "github.com/securesign/sigstore-e2e/pkg/strategy" "github.com/sirupsen/logrus" ) @@ -14,7 +16,17 @@ type cli struct { versionCommand string } -type SetupStrategy func(context.Context, *cli) (string, error) +type SetupStrategy = strategy.Strategy + +func PreferredSetupStrategy() SetupStrategy { + name := api.GetValueFor(api.CliStrategy) + s, ok := strategy.Get(name) + if !ok { + logrus.Warnf("Unknown CLI_STRATEGY %q, falling back to local", name) + s, _ = strategy.Get("local") + } + return s +} func (c *cli) Command(ctx context.Context, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, c.pathToCLI, args...) // #nosec G204 - we don't expect the code to be running on PROD ENV @@ -39,14 +51,14 @@ func (c *cli) CommandOutput(ctx context.Context, args ...string) ([]byte, error) return output, err } -func (c *cli) WithSetupStrategy(strategy SetupStrategy) *cli { - c.setupStrategy = strategy +func (c *cli) WithSetupStrategy(s SetupStrategy) *cli { + c.setupStrategy = s return c } func (c *cli) Setup(ctx context.Context) error { var err error - c.pathToCLI, err = c.setupStrategy(ctx, c) + c.pathToCLI, err = c.setupStrategy(ctx, c.Name) if err == nil { if c.versionCommand != "" { logrus.Info("Done. Using '", c.pathToCLI, "' with version:") diff --git a/pkg/clients/clistrategy.go b/pkg/clients/clistrategy.go deleted file mode 100644 index dfef2cd..0000000 --- a/pkg/clients/clistrategy.go +++ /dev/null @@ -1,180 +0,0 @@ -package clients - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/docker/docker/api/types/container" - imageDocker "github.com/docker/docker/api/types/image" - "github.com/docker/docker/client" - "github.com/google/uuid" - v1 "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/securesign/sigstore-e2e/pkg/api" - "github.com/securesign/sigstore-e2e/pkg/kubernetes" - "github.com/securesign/sigstore-e2e/pkg/support" - "github.com/sirupsen/logrus" -) - -var ErrNotFound = errors.New("executable file not found in WSL") - -func PreferredSetupStrategy() SetupStrategy { - var preferredStrategy SetupStrategy - switch api.GetValueFor(api.CliStrategy) { - case "openshift": - preferredStrategy = DownloadFromOpenshift() - case "cli_server": - server := api.GetValueFor(api.CLIServerURL) - if server == "" { - panic("CLI server URL not specified") - } - preferredStrategy = DownloadFromCLIServer(server) - case "local": - preferredStrategy = LocalBinary() - default: - preferredStrategy = LocalBinary() - } - return preferredStrategy -} - -func BuildFromGit(url string, branch string, buildingDirectory string) SetupStrategy { - return func(ctx context.Context, c *cli) (string, error) { - logrus.Info("Building '", c.Name, "' from git: ", url, ", branch ", branch) - dir, _, err := support.GitClone(url, branch) - if err != nil { - return "", err - } - cmd := exec.Command("go", "build", "-C", dir, "-o", c.Name, buildingDirectory) - cmd.Stdout = logrus.NewEntry(logrus.StandardLogger()).WithField("app", c.Name).WriterLevel(logrus.InfoLevel) - cmd.Stderr = logrus.NewEntry(logrus.StandardLogger()).WithField("app", c.Name).WriterLevel(logrus.ErrorLevel) - err = cmd.Run() - - return dir + "/" + c.Name, err - } -} - -func DownloadFromOpenshift() SetupStrategy { - return func(ctx context.Context, c *cli) (string, error) { - logrus.Info("Getting binary '", c.Name, "' from Openshift") - // Get http link - link, err := kubernetes.ConsoleCLIDownload(ctx, c.Name, runtime.GOOS, runtime.GOARCH) - if err != nil { - return "", err - } - - return downloadFromLink(ctx, c, link) - } -} - -func LocalBinary() SetupStrategy { - return func(ctx context.Context, c *cli) (string, error) { - logrus.Info("Checking local binary '", c.Name, "'") - return exec.LookPath(c.Name) - } -} - -func ExtractFromContainer(image string, path string) SetupStrategy { - return func(ctx context.Context, c *cli) (string, error) { - dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return "", err - } - - registryAuth, err := support.DockerAuth() - if err != nil { - return "", err - } - pull, err := dockerCli.ImagePull(ctx, image, imageDocker.PullOptions{RegistryAuth: registryAuth}) - if err != nil { - return "", err - } - defer pull.Close() - out := logrus.NewEntry(logrus.StandardLogger()).WithField("app", "docker").WriterLevel(logrus.DebugLevel) - _, _ = io.Copy(out, pull) - - var cont container.CreateResponse - if cont, err = dockerCli.ContainerCreate(ctx, &container.Config{Image: image}, - nil, - nil, - &v1.Platform{OS: runtime.GOOS}, - uuid.New().String()); err != nil { - return "", err - } - - var tarOut io.ReadCloser - if tarOut, _, err = dockerCli.CopyFromContainer(ctx, cont.ID, path); err != nil { - return "", err - } - - defer tarOut.Close() - - cliName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - tmp, err := os.MkdirTemp("", cliName) - if err != nil { - return "", err - } - fileName := tmp + string(os.PathSeparator) + cliName - file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0711) //nolint:mnd - if err != nil { - return "", err - } - defer file.Close() - - r, w := io.Pipe() - defer r.Close() - - go func() { - defer w.Close() - if err := support.UntarFile(tarOut, w); err != nil { - panic(err) - - } - }() - - if err = support.Gunzip(r, file); err != nil { - return "", err - } - return file.Name(), err - } -} - -func DownloadFromCLIServer(serverDomainURL string) SetupStrategy { - return func(ctx context.Context, c *cli) (string, error) { - logrus.Info("Getting binary '", c.Name, "' from CLI server", "Server URL", serverDomainURL) - link := fmt.Sprintf("%s/clients/%s/%s-%s.gz", serverDomainURL, runtime.GOOS, c.Name, runtime.GOARCH) - return downloadFromLink(ctx, c, link) - } -} - -func downloadFromLink(ctx context.Context, c *cli, link string) (string, error) { - tmp, err := os.MkdirTemp("", c.Name) - if err != nil { - return "", err - } - - logrus.Info("Downloading ", c.Name, " from ", link) - - var fileName string - if runtime.GOOS == "windows" { - fileName = filepath.Join(tmp, c.Name+".exe") - } else { - fileName = filepath.Join(tmp, c.Name) - } - file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0711) //nolint:mnd - if err != nil { - return "", err - } - defer file.Close() - - if err = support.DownloadAndUnzip(ctx, link, file); err != nil { - return "", err - } - - return file.Name(), err -} diff --git a/pkg/clients/strategies.go b/pkg/clients/strategies.go new file mode 100644 index 0000000..df2e4ae --- /dev/null +++ b/pkg/clients/strategies.go @@ -0,0 +1,12 @@ +package clients + +// Blank imports register CLI strategies at init time. +// Remove a line to exclude that strategy and its dependency tree from the binary. +import ( + _ "github.com/securesign/sigstore-e2e/pkg/strategy/cgw" + _ "github.com/securesign/sigstore-e2e/pkg/strategy/cliserver" + _ "github.com/securesign/sigstore-e2e/pkg/strategy/container" + _ "github.com/securesign/sigstore-e2e/pkg/strategy/git" + _ "github.com/securesign/sigstore-e2e/pkg/strategy/local" + _ "github.com/securesign/sigstore-e2e/pkg/strategy/openshift" +) diff --git a/pkg/kubernetes/cliDownloads.go b/pkg/kubernetes/cliDownloads.go index 1d4de89..c0427a8 100644 --- a/pkg/kubernetes/cliDownloads.go +++ b/pkg/kubernetes/cliDownloads.go @@ -9,12 +9,12 @@ import ( controller "sigs.k8s.io/controller-runtime/pkg/client" ) -func ConsoleCLIDownload(ctx context.Context, cli string, os string, arch string) (string, error) { +func ConsoleCLIDownload(ctx context.Context, c controller.Reader, cli string, os string, arch string) (string, error) { cld := &consoleV1.ConsoleCLIDownload{} ok := controller.ObjectKey{ Name: cli, } - err := GetClient().Get(ctx, ok, cld) + err := c.Get(ctx, ok, cld) if err != nil { return "", err } diff --git a/pkg/strategy/cgw/cgw.go b/pkg/strategy/cgw/cgw.go new file mode 100644 index 0000000..3b61028 --- /dev/null +++ b/pkg/strategy/cgw/cgw.go @@ -0,0 +1,76 @@ +package cgw + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/securesign/sigstore-e2e/pkg/api" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/support" + "github.com/sirupsen/logrus" +) + +var cgwNameOverride = map[string]string{ + "gitsign": "gitsign_cli", + "rekor-cli": "rekor_cli", +} + +func contentGatewayName(name string) string { + if override, ok := cgwNameOverride[name]; ok { + return override + } + return strings.ReplaceAll(name, "-", "_") +} + +func init() { + strategy.Register("cgw", func() strategy.Strategy { + cgwURL := api.GetValueFor(api.CGWURL) + if cgwURL == "" { + panic("Content gateway URL (CGW_URL) not specified") + } + return func(ctx context.Context, cliName string) (string, error) { + return download(ctx, cgwURL, cliName) + } + }) +} + +func download(ctx context.Context, cgwURL string, cliName string) (string, error) { + cgwName := contentGatewayName(cliName) + archiveName := fmt.Sprintf("%s_%s_%s.tar.gz", cgwName, runtime.GOOS, runtime.GOARCH) + link := fmt.Sprintf("%s/%s", strings.TrimRight(cgwURL, "/"), archiveName) + + logrus.Info("Getting binary '", cliName, "' from content gateway: ", link) + + tmp, err := os.MkdirTemp("", cliName) + if err != nil { + return "", err + } + + if err = support.DownloadAndUntarArchive(ctx, link, tmp); err != nil { + return "", err + } + + candidates := []string{ + cliName, + fmt.Sprintf("%s_%s_%s", cgwName, runtime.GOOS, runtime.GOARCH), + fmt.Sprintf("%s-%s-%s", cliName, runtime.GOOS, runtime.GOARCH), + } + if runtime.GOOS == "windows" { + for i, name := range candidates { + candidates[i] = name + ".exe" + } + } + + for _, name := range candidates { + path := filepath.Join(tmp, name) + if _, err = os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("binary for '%s' not found in extracted archive from %s (tried %v)", cliName, link, candidates) +} diff --git a/pkg/strategy/cgw/cgw_test.go b/pkg/strategy/cgw/cgw_test.go new file mode 100644 index 0000000..684d566 --- /dev/null +++ b/pkg/strategy/cgw/cgw_test.go @@ -0,0 +1,96 @@ +package cgw + +import ( + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/strategy/testutil" +) + +func TestContentGatewayName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"cosign", "cosign"}, + {"gitsign", "gitsign_cli"}, + {"rekor-cli", "rekor_cli"}, + {"ec", "ec"}, + {"tuftool", "tuftool"}, + {"createtree", "createtree"}, + {"some-other-tool", "some_other_tool"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := contentGatewayName(tt.input) + if got != tt.expected { + t.Errorf("contentGatewayName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestRegistered(t *testing.T) { + if !strategy.Has("cgw") { + t.Fatal("cgw strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho cosign\n") + archiveName := fmt.Sprintf("%s_%s_%s.tar.gz", contentGatewayName("cosign"), runtime.GOOS, runtime.GOARCH) + + archive := testutil.BuildTarGz(t, map[string][]byte{ + "cosign": binaryContent, + }) + + srv := testutil.ServeBinary(t, "/"+archiveName, archive) + + path, err := download(t.Context(), srv.URL, "cosign") + if err != nil { + t.Fatalf("download failed: %v", err) + } + + testutil.VerifyBinary(t, path, binaryContent) + t.Logf("OK: cosign -> %s", path) +} + +func TestStrategyNameOverride(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho gitsign\n") + cgwName := contentGatewayName("gitsign") + archiveName := fmt.Sprintf("%s_%s_%s.tar.gz", cgwName, runtime.GOOS, runtime.GOARCH) + binaryName := fmt.Sprintf("%s_%s_%s", cgwName, runtime.GOOS, runtime.GOARCH) + + archive := testutil.BuildTarGz(t, map[string][]byte{ + binaryName: binaryContent, + }) + + srv := testutil.ServeBinary(t, "/"+archiveName, archive) + + path, err := download(t.Context(), srv.URL, "gitsign") + if err != nil { + t.Fatalf("download failed: %v", err) + } + + testutil.VerifyBinary(t, path, binaryContent) +} + +func TestStrategyError(t *testing.T) { + archive := testutil.BuildTarGz(t, map[string][]byte{ + "wrong-name": []byte("data"), + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + })) + t.Cleanup(srv.Close) + + _, err := download(t.Context(), srv.URL, "cosign") + if err == nil { + t.Fatal("expected error when binary not found in archive") + } +} diff --git a/pkg/strategy/cliserver/cliserver.go b/pkg/strategy/cliserver/cliserver.go new file mode 100644 index 0000000..307bf31 --- /dev/null +++ b/pkg/strategy/cliserver/cliserver.go @@ -0,0 +1,29 @@ +package cliserver + +import ( + "context" + "fmt" + "runtime" + + "github.com/securesign/sigstore-e2e/pkg/api" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/sirupsen/logrus" +) + +func init() { + strategy.Register("cli_server", func() strategy.Strategy { + server := api.GetValueFor(api.CLIServerURL) + if server == "" { + panic("CLI server URL not specified") + } + return func(ctx context.Context, cliName string) (string, error) { + return download(ctx, server, cliName) + } + }) +} + +func download(ctx context.Context, server string, cliName string) (string, error) { + logrus.Info("Getting binary '", cliName, "' from CLI server ", server) + link := fmt.Sprintf("%s/clients/%s/%s-%s.gz", server, runtime.GOOS, cliName, runtime.GOARCH) + return strategy.DownloadFromLink(ctx, cliName, link) +} diff --git a/pkg/strategy/cliserver/cliserver_test.go b/pkg/strategy/cliserver/cliserver_test.go new file mode 100644 index 0000000..4e23998 --- /dev/null +++ b/pkg/strategy/cliserver/cliserver_test.go @@ -0,0 +1,41 @@ +package cliserver + +import ( + "runtime" + "testing" + + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/strategy/testutil" +) + +func TestRegistered(t *testing.T) { + if !strategy.Has("cli_server") { + t.Fatal("cli_server strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello\n") + gzipped := testutil.GzipBytes(t, binaryContent) + expectedPath := "/clients/" + runtime.GOOS + "/testcli-" + runtime.GOARCH + ".gz" + + srv := testutil.ServeBinary(t, expectedPath, gzipped) + + path, err := download(t.Context(), srv.URL, "testcli") + if err != nil { + t.Fatalf("download failed: %v", err) + } + + testutil.VerifyBinary(t, path, binaryContent) + t.Logf("OK: testcli -> %s", path) +} + +func TestStrategyError(t *testing.T) { + gzipped := []byte("not valid gzip data") + srv := testutil.ServeBinary(t, "/clients/"+runtime.GOOS+"/nonexistent-"+runtime.GOARCH+".gz", gzipped) + + _, err := download(t.Context(), srv.URL, "nonexistent") + if err == nil { + t.Fatal("expected error for invalid gzip response") + } +} diff --git a/pkg/strategy/container/container.go b/pkg/strategy/container/container.go new file mode 100644 index 0000000..26fffa3 --- /dev/null +++ b/pkg/strategy/container/container.go @@ -0,0 +1,109 @@ +package container + +import ( + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/docker/docker/api/types/container" + imageDocker "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/google/uuid" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/securesign/sigstore-e2e/pkg/api" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/support" + "github.com/sirupsen/logrus" +) + +type dockerAPI interface { + ImagePull(ctx context.Context, ref string, options imageDocker.PullOptions) (io.ReadCloser, error) + ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) + CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, container.PathStat, error) +} + +func init() { + strategy.Register("container", func() strategy.Strategy { + image := api.GetValueFor(api.ContainerImage) + if image == "" { + panic("Container image (CONTAINER_IMAGE) not specified") + } + path := api.GetValueFor(api.ContainerPath) + if path == "" { + panic("Container path (CONTAINER_PATH) not specified") + } + return func(ctx context.Context, cliName string) (string, error) { + return download(ctx, image, path) + } + }) +} + +func download(ctx context.Context, image string, path string) (string, error) { + dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return "", err + } + + registryAuth, err := support.DockerAuth() + if err != nil { + return "", err + } + return extractWithClient(ctx, dockerCli, image, path, imageDocker.PullOptions{RegistryAuth: registryAuth}) +} + +func extractWithClient(ctx context.Context, dockerCli dockerAPI, image string, path string, pullOpts imageDocker.PullOptions) (string, error) { + pull, err := dockerCli.ImagePull(ctx, image, pullOpts) + if err != nil { + return "", err + } + defer pull.Close() //nolint:errcheck + out := logrus.NewEntry(logrus.StandardLogger()).WithField("app", "docker").WriterLevel(logrus.DebugLevel) + _, _ = io.Copy(out, pull) + + var cont container.CreateResponse + if cont, err = dockerCli.ContainerCreate(ctx, &container.Config{Image: image}, + nil, + nil, + &v1.Platform{OS: runtime.GOOS}, + uuid.New().String()); err != nil { + return "", err + } + + var tarOut io.ReadCloser + if tarOut, _, err = dockerCli.CopyFromContainer(ctx, cont.ID, path); err != nil { + return "", err + } + + defer tarOut.Close() //nolint:errcheck + + binName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + tmp, err := os.MkdirTemp("", binName) + if err != nil { + return "", err + } + fileName := tmp + string(os.PathSeparator) + binName + file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0711) //nolint:mnd,gosec + if err != nil { + return "", err + } + defer file.Close() //nolint:errcheck + + r, w := io.Pipe() + defer r.Close() //nolint:errcheck + + go func() { + defer w.Close() //nolint:errcheck + if err := support.UntarFile(tarOut, w); err != nil { + panic(err) + } + }() + + if err = support.Gunzip(r, file); err != nil { + return "", err + } + return file.Name(), err +} diff --git a/pkg/strategy/container/container_test.go b/pkg/strategy/container/container_test.go new file mode 100644 index 0000000..7103ca1 --- /dev/null +++ b/pkg/strategy/container/container_test.go @@ -0,0 +1,89 @@ +package container + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/docker/docker/api/types/container" + imageDocker "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + v1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/strategy/testutil" +) + +type mockDocker struct { + pullFn func(ctx context.Context, ref string, options imageDocker.PullOptions) (io.ReadCloser, error) + createFn func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) + copyFn func(ctx context.Context, containerID, srcPath string) (io.ReadCloser, container.PathStat, error) +} + +func (m *mockDocker) ImagePull(ctx context.Context, ref string, options imageDocker.PullOptions) (io.ReadCloser, error) { + return m.pullFn(ctx, ref, options) +} + +func (m *mockDocker) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) { + return m.createFn(ctx, config, hostConfig, networkingConfig, platform, containerName) +} + +func (m *mockDocker) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, container.PathStat, error) { + return m.copyFn(ctx, containerID, srcPath) +} + +func newMock(tarred []byte) *mockDocker { + return &mockDocker{ + pullFn: func(_ context.Context, _ string, _ imageDocker.PullOptions) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(nil)), nil + }, + createFn: func(_ context.Context, _ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { + return container.CreateResponse{ID: "test-container-123"}, nil + }, + copyFn: func(_ context.Context, _, _ string) (io.ReadCloser, container.PathStat, error) { + return io.NopCloser(bytes.NewReader(tarred)), container.PathStat{}, nil + }, + } +} + +func TestRegistered(t *testing.T) { + if !strategy.Has("container") { + t.Fatal("container strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho hello\n") + gzipped := testutil.GzipBytes(t, binaryContent) + tarred := testutil.TarBytes(t, "tool.gz", gzipped) + + mock := newMock(tarred) + + path, err := extractWithClient(t.Context(), mock, "registry.example.com/image:latest", "/usr/bin/tool.gz", imageDocker.PullOptions{}) + if err != nil { + t.Fatalf("extractWithClient failed: %v", err) + } + + testutil.VerifyBinary(t, path, binaryContent) + t.Logf("OK: tool -> %s", path) +} + +func TestStrategyError(t *testing.T) { + mock := &mockDocker{ + pullFn: func(_ context.Context, _ string, _ imageDocker.PullOptions) (io.ReadCloser, error) { + return nil, errors.New("pull failed: image not found") + }, + createFn: func(_ context.Context, _ *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { + return container.CreateResponse{}, nil + }, + copyFn: func(_ context.Context, _, _ string) (io.ReadCloser, container.PathStat, error) { + return nil, container.PathStat{}, nil + }, + } + + _, err := extractWithClient(t.Context(), mock, "registry.example.com/bad:latest", "/usr/bin/tool.gz", imageDocker.PullOptions{}) + if err == nil { + t.Fatal("expected error when image pull fails") + } +} diff --git a/pkg/strategy/git/git.go b/pkg/strategy/git/git.go new file mode 100644 index 0000000..f386954 --- /dev/null +++ b/pkg/strategy/git/git.go @@ -0,0 +1,45 @@ +package git + +import ( + "context" + "os/exec" + + "github.com/securesign/sigstore-e2e/pkg/api" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/support" + "github.com/sirupsen/logrus" +) + +func init() { + strategy.Register("git", func() strategy.Strategy { + url := api.GetValueFor(api.GitURL) + if url == "" { + panic("Git URL (GIT_URL) not specified") + } + branch := api.GetValueFor(api.GitBranch) + if branch == "" { + panic("Git branch (GIT_BRANCH) not specified") + } + buildDir := api.GetValueFor(api.GitBuildDir) + if buildDir == "" { + panic("Git build directory (GIT_BUILD_DIR) not specified") + } + return func(ctx context.Context, cliName string) (string, error) { + return cloneAndBuild(ctx, url, branch, buildDir, cliName) + } + }) +} + +func cloneAndBuild(ctx context.Context, url string, branch string, buildDir string, cliName string) (string, error) { + logrus.Info("Building '", cliName, "' from git: ", url, ", branch ", branch) + dir, _, err := support.GitClone(url, branch) + if err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, "go", "build", "-C", dir, "-o", cliName, buildDir) //nolint:gosec + cmd.Stdout = logrus.NewEntry(logrus.StandardLogger()).WithField("app", cliName).WriterLevel(logrus.InfoLevel) + cmd.Stderr = logrus.NewEntry(logrus.StandardLogger()).WithField("app", cliName).WriterLevel(logrus.ErrorLevel) + err = cmd.Run() + + return dir + "/" + cliName, err +} diff --git a/pkg/strategy/git/git_test.go b/pkg/strategy/git/git_test.go new file mode 100644 index 0000000..1adc565 --- /dev/null +++ b/pkg/strategy/git/git_test.go @@ -0,0 +1,79 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/securesign/sigstore-e2e/pkg/strategy" +) + +func TestRegistered(t *testing.T) { + if !strategy.Has("git") { + t.Fatal("git strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not on PATH") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + + repoDir := t.TempDir() + + run := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) //nolint:gosec + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("%v failed: %v", args, err) + } + } + + run(repoDir, "git", "init", "-b", "main") + run(repoDir, "git", "config", "user.email", "test@test.com") + run(repoDir, "git", "config", "user.name", "Test") + + goMod := "module example.com/testcli\n\ngo 1.21\n" + if err := os.WriteFile(filepath.Join(repoDir, "go.mod"), []byte(goMod), 0600); err != nil { + t.Fatal(err) + } + + mainGo := "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hello\") }\n" + if err := os.WriteFile(filepath.Join(repoDir, "main.go"), []byte(mainGo), 0600); err != nil { + t.Fatal(err) + } + + run(repoDir, "git", "add", ".") + run(repoDir, "git", "commit", "-m", "init") + + path, err := cloneAndBuild(t.Context(), "file://"+repoDir, "main", ".", "testcli") + if err != nil { + t.Fatalf("cloneAndBuild failed: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("binary not found at %s: %v", path, err) + } + if info.Size() == 0 { + t.Fatalf("binary at %s is empty", path) + } + if info.Mode()&0111 == 0 { + t.Fatalf("binary at %s is not executable (mode: %s)", path, info.Mode()) + } + t.Logf("OK: testcli -> %s (%d bytes)", path, info.Size()) +} + +func TestStrategyError(t *testing.T) { + _, err := cloneAndBuild(t.Context(), "file:///nonexistent-repo-path-e2e-test", "main", ".", "testcli") + if err == nil { + t.Fatal("expected error for nonexistent git repo") + } +} diff --git a/pkg/strategy/local/local.go b/pkg/strategy/local/local.go new file mode 100644 index 0000000..6f47f0e --- /dev/null +++ b/pkg/strategy/local/local.go @@ -0,0 +1,20 @@ +package local + +import ( + "context" + "os/exec" + + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/sirupsen/logrus" +) + +func init() { + strategy.Register("local", func() strategy.Strategy { + return download + }) +} + +func download(_ context.Context, cliName string) (string, error) { + logrus.Info("Checking local binary '", cliName, "'") + return exec.LookPath(cliName) +} diff --git a/pkg/strategy/local/local_test.go b/pkg/strategy/local/local_test.go new file mode 100644 index 0000000..1afb4bd --- /dev/null +++ b/pkg/strategy/local/local_test.go @@ -0,0 +1,31 @@ +package local + +import ( + "testing" + + "github.com/securesign/sigstore-e2e/pkg/strategy" +) + +func TestRegistered(t *testing.T) { + if !strategy.Has("local") { + t.Fatal("local strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + path, err := download(t.Context(), "go") + if err != nil { + t.Fatalf("expected to find 'go' on PATH: %v", err) + } + if path == "" { + t.Fatal("expected non-empty path") + } + t.Logf("found go at %s", path) +} + +func TestStrategyError(t *testing.T) { + _, err := download(t.Context(), "this-binary-does-not-exist-e2e-test") + if err == nil { + t.Fatal("expected error for nonexistent binary") + } +} diff --git a/pkg/strategy/openshift/openshift.go b/pkg/strategy/openshift/openshift.go new file mode 100644 index 0000000..8194ad9 --- /dev/null +++ b/pkg/strategy/openshift/openshift.go @@ -0,0 +1,28 @@ +package openshift + +import ( + "context" + "runtime" + + "github.com/securesign/sigstore-e2e/pkg/kubernetes" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/sirupsen/logrus" + controller "sigs.k8s.io/controller-runtime/pkg/client" +) + +func init() { + strategy.Register("openshift", func() strategy.Strategy { + return func(ctx context.Context, cliName string) (string, error) { + return download(ctx, kubernetes.GetClient(), cliName) + } + }) +} + +func download(ctx context.Context, client controller.Reader, cliName string) (string, error) { + logrus.Info("Getting binary '", cliName, "' from Openshift") + link, err := kubernetes.ConsoleCLIDownload(ctx, client, cliName, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err + } + return strategy.DownloadFromLink(ctx, cliName, link) +} diff --git a/pkg/strategy/openshift/openshift_test.go b/pkg/strategy/openshift/openshift_test.go new file mode 100644 index 0000000..852796c --- /dev/null +++ b/pkg/strategy/openshift/openshift_test.go @@ -0,0 +1,74 @@ +package openshift + +import ( + "runtime" + "testing" + + consoleV1 "github.com/openshift/api/console/v1" + "github.com/securesign/sigstore-e2e/pkg/strategy" + "github.com/securesign/sigstore-e2e/pkg/strategy/testutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newFakeClient(t *testing.T, objects ...consoleV1.ConsoleCLIDownload) *fake.ClientBuilder { + t.Helper() + scheme := k8sruntime.NewScheme() + if err := consoleV1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + builder := fake.NewClientBuilder().WithScheme(scheme) + for i := range objects { + builder = builder.WithObjects(&objects[i]) + } + return builder +} + +func TestRegistered(t *testing.T) { + if !strategy.Has("openshift") { + t.Fatal("openshift strategy not registered") + } +} + +func TestStrategy(t *testing.T) { + binaryContent := []byte("#!/bin/sh\necho testcli\n") + gzipped := testutil.GzipBytes(t, binaryContent) + expectedPath := "/clients/" + runtime.GOOS + "/testcli-" + runtime.GOARCH + ".gz" + + srv := testutil.ServeBinary(t, expectedPath, gzipped) + + cliDownload := consoleV1.ConsoleCLIDownload{ + ObjectMeta: metav1.ObjectMeta{Name: "testcli"}, + Spec: consoleV1.ConsoleCLIDownloadSpec{ + DisplayName: "Test CLI", + Description: "test binary", + Links: []consoleV1.CLIDownloadLink{ + {Text: "Linux AMD64", Href: srv.URL + "/clients/linux/testcli-amd64.gz"}, + {Text: "Linux ARM64", Href: srv.URL + "/clients/linux/testcli-arm64.gz"}, + {Text: "macOS AMD64", Href: srv.URL + "/clients/darwin/testcli-amd64.gz"}, + {Text: "macOS ARM64", Href: srv.URL + "/clients/darwin/testcli-arm64.gz"}, + {Text: "Windows AMD64", Href: srv.URL + "/clients/windows/testcli-amd64.gz"}, + }, + }, + } + + fakeClient := newFakeClient(t, cliDownload).Build() + + path, err := download(t.Context(), fakeClient, "testcli") + if err != nil { + t.Fatalf("download failed: %v", err) + } + + testutil.VerifyBinary(t, path, binaryContent) + t.Logf("OK: testcli -> %s", path) +} + +func TestStrategyError(t *testing.T) { + fakeClient := newFakeClient(t).Build() + + _, err := download(t.Context(), fakeClient, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent CLI download") + } +} diff --git a/pkg/strategy/strategy.go b/pkg/strategy/strategy.go new file mode 100644 index 0000000..72cdf2d --- /dev/null +++ b/pkg/strategy/strategy.go @@ -0,0 +1,79 @@ +package strategy + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + + "github.com/securesign/sigstore-e2e/pkg/support" + "github.com/sirupsen/logrus" +) + +// Strategy resolves a CLI binary by name and returns its executable path. +type Strategy func(ctx context.Context, cliName string) (string, error) + +// Factory creates a Strategy instance, reading its own configuration (env vars, etc.). +type Factory func() Strategy + +var ( + mu sync.Mutex + registry = map[string]Factory{} +) + +func Register(name string, f Factory) { + mu.Lock() + defer mu.Unlock() + if _, dup := registry[name]; dup { + panic(fmt.Sprintf("strategy %q already registered", name)) + } + registry[name] = f +} + +func Get(name string) (Strategy, bool) { + mu.Lock() + f, ok := registry[name] + mu.Unlock() + if !ok { + return nil, false + } + return f(), true +} + +func Has(name string) bool { + mu.Lock() + _, ok := registry[name] + mu.Unlock() + return ok +} + +// DownloadFromLink downloads a .gz file from link, gunzips it, and writes the +// result as an executable named cliName into a temp directory. +func DownloadFromLink(ctx context.Context, cliName string, link string) (string, error) { + tmp, err := os.MkdirTemp("", cliName) + if err != nil { + return "", err + } + + logrus.Info("Downloading ", cliName, " from ", link) + + var fileName string + if runtime.GOOS == "windows" { + fileName = filepath.Join(tmp, cliName+".exe") + } else { + fileName = filepath.Join(tmp, cliName) + } + file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0711) //nolint:mnd,gosec + if err != nil { + return "", err + } + defer file.Close() //nolint:errcheck + + if err = support.DownloadAndUnzip(ctx, link, file); err != nil { + return "", err + } + + return file.Name(), err +} diff --git a/pkg/strategy/strategy_test.go b/pkg/strategy/strategy_test.go new file mode 100644 index 0000000..d57e523 --- /dev/null +++ b/pkg/strategy/strategy_test.go @@ -0,0 +1,60 @@ +package strategy + +import ( + "context" + "testing" +) + +func TestRegisterAndGet(t *testing.T) { + oldRegistry := registry + registry = map[string]Factory{} + defer func() { registry = oldRegistry }() + + Register("test_strategy", func() Strategy { + return func(_ context.Context, cliName string) (string, error) { + return "/tmp/" + cliName, nil + } + }) + + s, ok := Get("test_strategy") + if !ok { + t.Fatal("expected strategy to be registered") + } + + path, err := s(t.Context(), "mytool") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/tmp/mytool" { + t.Fatalf("expected /tmp/mytool, got %s", path) + } +} + +func TestGetUnknown(t *testing.T) { + oldRegistry := registry + registry = map[string]Factory{} + defer func() { registry = oldRegistry }() + + _, ok := Get("nonexistent") + if ok { + t.Fatal("expected ok=false for unregistered strategy") + } +} + +func TestRegisterDuplicatePanics(t *testing.T) { + oldRegistry := registry + registry = map[string]Factory{} + defer func() { registry = oldRegistry }() + + factory := func() Strategy { + return func(_ context.Context, _ string) (string, error) { return "", nil } + } + Register("dup", factory) + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on duplicate registration") + } + }() + Register("dup", factory) +} diff --git a/pkg/strategy/testutil/testutil.go b/pkg/strategy/testutil/testutil.go new file mode 100644 index 0000000..f3733ed --- /dev/null +++ b/pkg/strategy/testutil/testutil.go @@ -0,0 +1,103 @@ +package testutil + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func GzipBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(data); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TarBytes(t *testing.T, name string, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(data)), + Mode: 0755, + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func BuildTarGz(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0755, + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func ServeBinary(t *testing.T, expectedPath string, content []byte) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + t.Errorf("unexpected request path: %s (want %s)", r.URL.Path, expectedPath) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(content) + })) + t.Cleanup(srv.Close) + return srv +} + +func VerifyBinary(t *testing.T, path string, wantContent []byte) { + t.Helper() + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + t.Fatalf("cannot read binary at %s: %v", path, err) + } + if !bytes.Equal(data, wantContent) { + t.Fatalf("content mismatch: got %q, want %q", data, wantContent) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0111 == 0 { + t.Fatalf("binary at %s is not executable (mode: %s)", path, info.Mode()) + } +} diff --git a/pkg/support/testSupport.go b/pkg/support/testSupport.go index 554bdcb..0873b7d 100644 --- a/pkg/support/testSupport.go +++ b/pkg/support/testSupport.go @@ -74,6 +74,18 @@ func DownloadAndUnzip(ctx context.Context, link string, writer io.Writer) error return Gunzip(pr, writer) } +func DownloadAndUntarArchive(ctx context.Context, link string, dst string) error { + pr, pw := io.Pipe() + + go func() { + defer pw.Close() + if _, err := Download(ctx, link, pw); err != nil { + panic(err) + } + }() + return UntarArchive(dst, pr) +} + func Download(ctx context.Context, link string, writer io.Writer) (int64, error) { client := &http.Client{Timeout: 2 * time.Minute} //nolint:mnd req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)