diff --git a/README.md b/README.md index 77a4a2af6..31960c6f1 100644 --- a/README.md +++ b/README.md @@ -520,18 +520,18 @@ Any variables that Kilnfile needs for the kiln re-bake command should be set in ### `test` -The `test` command exercises to ginkgo tests under the `//test/manifest` and `//migrations` paths of the `pivotal/tas` repos (where `` is tas, ist, or tasw). +The `test` command exercises the Ginkgo tests under the `//test/manifest` and `//migrations` paths of the `pivotal/tas` repos (where `` is tas, ist, or tasw). -Running these tests requires a docker daemon. It also requires the user to -provide Artifactory credentials via the ARTIFACTORY_USERNAME and -ARTIFACTORY_PASSWORD environment variables to allow the ops-manifest gem to -be installed. The credentials must have access to the `tas-rel-eng-gem-dev-local` -repository within Broadcom's Artifactory. +Running these tests requires a Docker daemon (or Podman API-compatible socket). You must provide **ARTIFACTORY_USERNAME** and **ARTIFACTORY_PASSWORD** using **`-e`** and/or **exported** environment variables. They are used for the **ops-manifest** gem (`tas-rel-eng-gem-dev-local`), for **Go module** downloads during **`go install ginkgo`** (via **`GOPROXY`** / **`GOSUMDB=off`** in the embedded Dockerfile), and Kiln sends the same credentials to the daemon as **registry auth** so base images can be pulled from **docker-virtual** (`tas-rel-eng-docker-virtual.usw1.packages.broadcom.com`) **without a separate `docker login`** for `kiln test`. + +The embedded Dockerfile pins **`FROM`** paths on that registry. The registry hostname in Kiln’s **`AuthConfigs`** must stay aligned with those **`FROM`** lines (see **`DockerVirtualRegistryHost`** in `internal/test/container.go`). Passwords with characters that are special in URLs may not behave the same as URL-encoded credentials when interpolated into **`GOPROXY`** inside the Dockerfile. + +If either credential is missing, `kiln test` exits with an error before talking to Docker. If you run into this docker error `could not execute "test": failed to connect to Docker daemon: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running`, then create a symlink `sudo ln -s ~/.docker/run/docker.sock /var/run/docker.sock` -Here are command line examples: +Examples: ``` $ cd ~/workspace/tas/ist @@ -539,37 +539,46 @@ $ kiln test -e ARTIFACTORY_USERNAME=myuser -e ARTIFACTORY_PASSWORD=secretpasswor ``` ``` -cd ~ -$ kiln test --verbose -tp ~/workspace/tas/ist --ginkgo-manifest-flags "-p -nodes 8 -v" +$ export ARTIFACTORY_USERNAME=myuser +$ export ARTIFACTORY_PASSWORD=secretpassword +$ kiln test --verbose -tp ~/workspace/tas/ist --ginkgo-flags "-p -nodes 8 -v" ```
Additional test options -##### `--ginkgo-manifest-flags` +##### `--ginkgo-flags` + +The `--ginkgo-flags` flag can be used to pass through Ginkgo test flags. The defaults being passed through are `-r -p -slowSpecThreshold 15`. Pass `help` as a flag to retrieve the available options for the embedded version of ginkgo. -The `--ginkgo-manifest-flags` flag can be used to pass through Ginkgo test flags. The defaults being passed through are `-r -p -slowSpecThreshold 15`. Pass `help` as a flag to retrieve the available options for the embeded version of ginkgo. +#### `--manifest` -#### `--manifest-only` +The `--manifest` flag can be used to run only Manifest tests. -The `--manifest-only` flag can be used to run only Manifest tests. If not passed, `kiln test` will run both Manifest and Migration tests by default. +#### `--migrations` -#### `--migrations-only` +The `--migrations` flag can be used to run only Migration tests. -The `--migrations-only` flag can be used to run only Migration tests. If not passed, `kiln test` will run both Manifest and Migration tests by default. +#### `--stability` + +The `--stability` flag can be used to run only Stability tests. ##### `--tile-path` -The `--tile-path` (`-tp`) flag can be set the path the directory you wish to test. It defaults to the current working directory. For example +The `--tile-path` (`-tp`) flag can be set to the directory you wish to test. It defaults to the current working directory. For example: ``` -$ kiln test -tp ~/workspace/tas/ist +$ kiln test -e ARTIFACTORY_USERNAME=myuser -e ARTIFACTORY_PASSWORD=secret -tp ~/workspace/tas/ist ``` ##### `--verbose` The `--verbose` (`-v`) flag will log additional debugging info. +##### `--silent` + +The `--silent` (`-s`) flag hides Kiln info lines (not Ginkgo output). +
### `fetch` diff --git a/internal/commands/test_tile.go b/internal/commands/test_tile.go index 2acbe3289..ae835bd6c 100644 --- a/internal/commands/test_tile.go +++ b/internal/commands/test_tile.go @@ -2,7 +2,6 @@ package commands import ( "context" - _ "embed" "fmt" "io" "os" @@ -17,15 +16,15 @@ type TileTestFunction func(ctx context.Context, w io.Writer, configuration test. type TileTest struct { Options struct { - TilePath string ` long:"tile-path" default:"." description:"Path to the Tile directory (e.g., ~/workspace/tas/ist)."` - Verbose bool `short:"v" long:"verbose" default:"true" description:"Print info lines. This doesn't affect Ginkgo output."` + TilePath string ` long:"tile-path" default:"." description:"Path to the Tile directory (e.g. ~/workspace/tas/ist)."` + Verbose bool `short:"v" long:"verbose" default:"false" description:"Print container ID, per-suite start/end timestamps, and timestamped pass/fail summary. Also enables npm install output. Does not affect Ginkgo or npm test output."` Silent bool `short:"s" long:"silent" default:"false" description:"Hide info lines. This doesn't affect Ginkgo output."` Manifest bool ` long:"manifest" default:"false" description:"Focus the Manifest tests."` Migrations bool ` long:"migrations" default:"false" description:"Focus the Migration tests."` Stability bool ` long:"stability" default:"false" description:"Focus the Stability tests."` - EnvironmentVars []string `short:"e" long:"environment-variable" description:"Pass environment variable to the test suites. For example --stability -e 'PRODUCT=srt'."` - GingkoFlags string ` long:"ginkgo-flags" default:"-r -p -slowSpecThreshold 15" description:"Flags to pass to the Ginkgo Manifest and Stability test suites."` + EnvironmentVars []string `short:"e" long:"environment-variable" description:"Pass environment variables to the test suites (e.g. -e 'PRODUCT=srt'). Include -e ARTIFACTORY_USERNAME=... and -e ARTIFACTORY_PASSWORD=... unless they are exported."` + GinkgoFlags string ` long:"ginkgo-flags" default:"-r -p -slowSpecThreshold 15" description:"Flags to pass to the Ginkgo Manifest and Stability test suites."` } function TileTestFunction output io.Writer @@ -64,22 +63,27 @@ func (cmd TileTest) configuration() (test.Configuration, error) { if _, err := os.Stat(absPath); err != nil { return test.Configuration{}, fmt.Errorf("failed to get information about --tile-path: %w", err) } - return test.Configuration{ + if _, _, err := test.RequiredArtifactoryCredentials(cmd.Options.EnvironmentVars); err != nil { + return test.Configuration{}, err + } + cfg := test.Configuration{ AbsoluteTileDirectory: absPath, RunAll: !cmd.Options.Migrations && !cmd.Options.Manifest && !cmd.Options.Stability, RunManifest: cmd.Options.Manifest, - RunMetadata: cmd.Options.Stability, + RunStability: cmd.Options.Stability, RunMigrations: cmd.Options.Migrations, - GinkgoFlags: cmd.Options.GingkoFlags, + GinkgoFlags: cmd.Options.GinkgoFlags, Environment: cmd.Options.EnvironmentVars, - }, absErr + Verbose: cmd.Options.Verbose, + } + return cfg, absErr } func (cmd TileTest) Usage() jhanda.Usage { return jhanda.Usage{ - Description: "Run the Manifest, Migrations, and Stability tests for a Tile in a Docker container. Requires a Docker daemon to be running and Artifactory credentials to be provided via the ARTIFACTORY_USERNAME and ARTIFACTORY_PASSWORD environment variables to install the ops-manifest gem.", + Description: "Run the Manifest, Migrations, and Stability tests for a Tile in a Docker container. Requires a Docker daemon. Requires ARTIFACTORY_USERNAME and ARTIFACTORY_PASSWORD (via -e or your environment) for the test image build and ops-manifest gem. Kiln passes the same credentials to the Docker daemon for pulling base images from docker-virtual (no separate docker login needed for kiln test).", ShortDescription: "Runs unit tests for a Tile.", Flags: cmd.Options, } diff --git a/internal/commands/test_tile_artifactory_test.go b/internal/commands/test_tile_artifactory_test.go new file mode 100644 index 000000000..d763be1fb --- /dev/null +++ b/internal/commands/test_tile_artifactory_test.go @@ -0,0 +1,58 @@ +package commands + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pivotal-cf/kiln/internal/test" +) + +func TestTileTest_RequiresArtifactoryCredentials(t *testing.T) { + t.Setenv("ARTIFACTORY_USERNAME", "") + t.Setenv("ARTIFACTORY_PASSWORD", "") + + err := NewTileTest().Execute([]string{}) + require.Error(t, err) + require.ErrorContains(t, err, "ARTIFACTORY_USERNAME") + require.ErrorContains(t, err, "kiln test") +} + +func TestTileTest_RequiresArtifactoryPassword(t *testing.T) { + t.Setenv("ARTIFACTORY_PASSWORD", "") + + err := NewTileTest().Execute([]string{"-e", "ARTIFACTORY_USERNAME=onlyuser"}) + require.Error(t, err) + require.ErrorContains(t, err, "ARTIFACTORY_PASSWORD") +} + +func TestTileTest_PassesArtifactoryViaEnvironmentToConfiguration(t *testing.T) { + var captured test.Configuration + stub := func(_ context.Context, _ io.Writer, c test.Configuration) error { + captured = c + return nil + } + err := NewTileTestWithCollaborators(io.Discard, stub).Execute([]string{ + "-e", "ARTIFACTORY_USERNAME=u", + "-e", "ARTIFACTORY_PASSWORD=p", + }) + require.NoError(t, err) + require.Contains(t, captured.Environment, "ARTIFACTORY_USERNAME=u") + require.Contains(t, captured.Environment, "ARTIFACTORY_PASSWORD=p") +} + +func TestTileTest_UsesProcessEnvArtifactoryCredentials(t *testing.T) { + t.Setenv("ARTIFACTORY_USERNAME", "fromenv") + t.Setenv("ARTIFACTORY_PASSWORD", "frompass") + + var captured test.Configuration + stub := func(_ context.Context, _ io.Writer, c test.Configuration) error { + captured = c + return nil + } + err := NewTileTestWithCollaborators(io.Discard, stub).Execute([]string{}) + require.NoError(t, err) + require.Empty(t, captured.Environment) +} diff --git a/internal/commands/test_tile_test.go b/internal/commands/test_tile_test.go index f04a93472..45b16fc91 100644 --- a/internal/commands/test_tile_test.go +++ b/internal/commands/test_tile_test.go @@ -34,6 +34,12 @@ func init() { var _ = Describe("kiln test", func() { var output bytes.Buffer + BeforeEach(func() { + t := GinkgoT() + t.Setenv("ARTIFACTORY_USERNAME", "ginkgo-test-user") + t.Setenv("ARTIFACTORY_PASSWORD", "ginkgo-test-pass") + }) + AfterEach(func() { output.Reset() }) @@ -161,7 +167,7 @@ var _ = Describe("kiln test", func() { Expect(w).NotTo(BeNil()) Expect(configuration.RunManifest).To(BeTrue()) - Expect(configuration.RunMetadata).To(BeFalse()) + Expect(configuration.RunStability).To(BeFalse()) Expect(configuration.RunMigrations).To(BeFalse()) }) }) @@ -183,13 +189,13 @@ var _ = Describe("kiln test", func() { Expect(w).NotTo(BeNil()) Expect(configuration.RunManifest).To(BeFalse()) - Expect(configuration.RunMetadata).To(BeFalse()) + Expect(configuration.RunStability).To(BeFalse()) Expect(configuration.RunMigrations).To(BeTrue()) }) }) When("when the stability test is enabled", func() { - It("it sets the RunMetadata configuration flag", func() { + It("it sets the RunStability configuration flag", func() { args := []string{"--stability"} fakeTestFunc := fakes.TestTileFunction{} @@ -205,14 +211,14 @@ var _ = Describe("kiln test", func() { Expect(w).NotTo(BeNil()) Expect(configuration.RunManifest).To(BeFalse()) - Expect(configuration.RunMetadata).To(BeTrue()) + Expect(configuration.RunStability).To(BeTrue()) Expect(configuration.RunMigrations).To(BeFalse()) }) }) - When("when the stability test is enabled", func() { - It("it sets the RunMetadata configuration flag", func() { - args := []string{"--stability"} + When("when ginkgo/v2 flag arguments are passed", func() { + It("it sets the GinkgoFlags configuration", func() { + args := []string{"--ginkgo-flags=peach pair"} fakeTestFunc := fakes.TestTileFunction{} fakeTestFunc.Returns(nil) @@ -226,15 +232,16 @@ var _ = Describe("kiln test", func() { Expect(ctx).NotTo(BeNil()) Expect(w).NotTo(BeNil()) - Expect(configuration.RunManifest).To(BeFalse()) - Expect(configuration.RunMetadata).To(BeTrue()) - Expect(configuration.RunMigrations).To(BeFalse()) + Expect(configuration.GinkgoFlags).To(Equal("peach pair")) }) }) - When("when ginkgo/v2 flag arguments are passed", func() { - It("it sets the GinkgoFlags configuration", func() { - args := []string{"--ginkgo-flags=peach pair"} + When("when Artifactory credentials are provided via -e", func() { + It("invokes the test function with those variables in Environment", func() { + args := []string{ + "-e", "ARTIFACTORY_USERNAME=u", + "-e", "ARTIFACTORY_PASSWORD=p", + } fakeTestFunc := fakes.TestTileFunction{} fakeTestFunc.Returns(nil) @@ -242,13 +249,25 @@ var _ = Describe("kiln test", func() { err := commands.NewTileTestWithCollaborators(&output, fakeTestFunc.Spy).Execute(args) Expect(err).NotTo(HaveOccurred()) - Expect(fakeTestFunc.CallCount()).To(Equal(1)) + _, _, configuration := fakeTestFunc.ArgsForCall(0) + Expect(configuration.Environment).To(ContainElement("ARTIFACTORY_USERNAME=u")) + Expect(configuration.Environment).To(ContainElement("ARTIFACTORY_PASSWORD=p")) + }) + }) - ctx, w, configuration := fakeTestFunc.ArgsForCall(0) - Expect(ctx).NotTo(BeNil()) - Expect(w).NotTo(BeNil()) + When("when Artifactory credentials are missing", func() { + It("returns an error before invoking the test function", func() { + t := GinkgoT() + t.Setenv("ARTIFACTORY_USERNAME", "") + t.Setenv("ARTIFACTORY_PASSWORD", "") - Expect(configuration.GinkgoFlags).To(Equal("peach pair")) + fakeTestFunc := fakes.TestTileFunction{} + fakeTestFunc.Returns(nil) + + err := commands.NewTileTestWithCollaborators(&output, fakeTestFunc.Spy).Execute([]string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ARTIFACTORY_USERNAME")) + Expect(fakeTestFunc.CallCount()).To(Equal(0)) }) }) diff --git a/internal/test/Dockerfile b/internal/test/Dockerfile index e466046d1..16d47a6df 100644 --- a/internal/test/Dockerfile +++ b/internal/test/Dockerfile @@ -1,32 +1,44 @@ -FROM golang AS go-image -FROM docker.io/pivotalcfreleng/kiln:v0.110.0-rc2 AS kiln +# Base images: host must match DockerVirtualRegistryHost in internal/test/container.go (AuthConfigs). +FROM tas-rel-eng-docker-virtual.usw1.packages.broadcom.com/golang AS go-image +FROM tas-rel-eng-docker-virtual.usw1.packages.broadcom.com/pivotalcfreleng/kiln:v0.110.0-rc2 AS kiln -FROM ruby:4.0.3 AS builder +FROM tas-rel-eng-docker-virtual.usw1.packages.broadcom.com/ruby:4.0.3 AS builder RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts -FROM ruby:4.0.3 +FROM tas-rel-eng-docker-virtual.usw1.packages.broadcom.com/ruby:4.0.3 -# Install Go +# ── Stable tools — no credentials; these layers cache across credential rotation ── + +# Go runtime COPY --from=go-image /usr/local/go/ /usr/local/go/ ENV GOROOT=/usr/local/go/ ENV PATH="$GOROOT/bin:/root/go/bin:$PATH" -# Install Kiln +# Kiln binary (used by ops-manifest during manifest tests) COPY --from=kiln /kiln /usr/local/bin/kiln -# Install JQ -RUN apt-get update && apt-get install jq -y +# System packages (consolidated to one layer; before credentials so they stay cached) +RUN apt-get update \ + && apt-get install --no-install-recommends -y jq nodejs npm \ + && rm -rf /var/lib/apt/lists/* -# Install Ginkgo -RUN go install github.com/onsi/ginkgo/ginkgo@latest +# Go toolchain settings — must appear before any `go install` +# CGO_ENABLED=0: pure-Go build avoids gcc issues on arm64 (e.g. Podman on Apple Silicon). +ENV GOTOOLCHAIN=local +ENV CGO_ENABLED=0 -# Install NodeJS -RUN apt-get update && apt-get install nodejs npm -y +# ── Ginkgo — credentials scoped to this RUN only so layers above stay cached ── +# Pinned to v1.16.5 for reproducibility (last stable v1; tiles using ginkgo v2 use their own binary). -# Install OpsManifest from Artifactory ARG ARTIFACTORY_USERNAME ARG ARTIFACTORY_PASSWORD +RUN GOPROXY=https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_PASSWORD}@usw1.packages.broadcom.com/artifactory/api/go/tas-rel-eng-go-virtual \ + GOSUMDB=off \ + go install github.com/onsi/ginkgo/ginkgo@v1.16.5 + +# ── ops-manifest gem — credentials exported for gem source registration at build time ── + ENV ARTIFACTORY_USERNAME=${ARTIFACTORY_USERNAME} ENV ARTIFACTORY_PASSWORD=${ARTIFACTORY_PASSWORD} @@ -34,3 +46,7 @@ RUN gem source -a https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_PASSWORD}@usw1.p RUN gem install --verbose ops-manifest -v 0.0.4.pre RUN which ops-manifest + +RUN printf '%s\n%s\n' \ + 'registry=https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_PASSWORD}@usw1.packages.broadcom.com/artifactory/api/npm/tis-npm-virtual/' \ + 'always-auth=true' > /root/.npmrc diff --git a/internal/test/container.go b/internal/test/container.go index 5b3120ed1..4af0f1a12 100644 --- a/internal/test/container.go +++ b/internal/test/container.go @@ -9,11 +9,11 @@ import ( "errors" "fmt" "io" - "log" "os" "os/signal" - "path" "path/filepath" + "runtime" + "strconv" "strings" cerrdefs "github.com/containerd/errdefs" @@ -22,6 +22,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/registry" "github.com/docker/docker/client" specV1 "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/errgroup" @@ -32,17 +33,19 @@ const ( // If the integration tests pass on your machine with an older version, feel free to PR a less conservative value. MinimumDockerServerVersion = "> 24.0.0" MinimumPodmanServerVersion = "> 5.3.0" + + // DockerVirtualRegistryHost is the docker-virtual registry used in Dockerfile FROM lines + // and in ImageBuild AuthConfigs. Keep in sync with internal/test/Dockerfile. + DockerVirtualRegistryHost = "tas-rel-eng-docker-virtual.usw1.packages.broadcom.com" ) func Run(ctx context.Context, w io.Writer, configuration Configuration) error { - logger := log.New(w, "kiln test: ", log.Default().Flags()) - dockerDaemon, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return err } - return runTest(ctx, logger, w, dockerDaemon, configuration) + return runTest(ctx, w, dockerDaemon, configuration) } type Configuration struct { @@ -53,37 +56,146 @@ type Configuration struct { RunAll, RunMigrations, RunManifest, - RunMetadata bool + RunStability bool GinkgoFlags string Environment []string + Verbose bool +} + +// testPlan holds the complete set of shell work for a kiln test run: global +// setup that must succeed before any suite, and an ordered list of named +// suites each of which is run as an independent unit. +type testPlan struct { + setup []string // fail-fast preamble (git config, etc.) + suites []suiteStep +} + +// suiteStep is one test suite — migrations, stability, or manifest. +// cmds are chained with && inside a subshell so their exit code is captured +// as a single unit. +type suiteStep struct { + name string // human label used in header and summary + cmds []string // shell commands; first entry is typically the header printf } -func (configuration Configuration) commands() ([]string, error) { +// suiteHeader builds the "Running Suite: …\n…\n" printf command for a suite. +func suiteHeader(title string) string { + label := "Running Suite: " + title + return fmt.Sprintf(`printf '\n%s\n%s\n'`, label, strings.Repeat("=", len(label))) +} + +func (configuration Configuration) commands() (testPlan, error) { if !filepath.IsAbs(configuration.AbsoluteTileDirectory) { - return nil, fmt.Errorf("tile path must be absolute") + return testPlan{}, fmt.Errorf("tile path must be absolute") } tileDirName := filepath.Base(configuration.AbsoluteTileDirectory) - commands := []string{"git config --global --add safe.directory '*'"} + plan := testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + } + if configuration.RunMigrations || configuration.RunAll { - commands = append(commands, fmt.Sprintf("cd /tas/%s/migrations", tileDirName)) - commands = append(commands, "npm install") - commands = append(commands, "npm test") + plan.suites = append(plan.suites, suiteStep{ + name: "Migration Tests", + cmds: []string{ + fmt.Sprintf("cd /tas/%s/migrations", tileDirName), + npmInstallCommand(configuration.AbsoluteTileDirectory, configuration.Verbose), + suiteHeader("Migration Tests"), + "npm test", + }, + }) } - var ginkgo []string - if configuration.RunMetadata || configuration.RunAll { - ginkgo = append(ginkgo, fmt.Sprintf("/tas/%s/test/stability", tileDirName)) + + // Each ginkgo suite gets its own invocation so output is never interleaved. + // Stability tests use Go's standard testing package (no ginkgo bootstrap), so + // ginkgo does not print "Running Suite: ..."; we add the header ourselves. + // Manifest suites use ginkgo specs and print their own header — we only add a + // blank line. Note: not compatible with tiles that use ginkgo v2. + if configuration.RunStability || configuration.RunAll { + stabilityPath := fmt.Sprintf("/tas/%s/test/stability", tileDirName) + plan.suites = append(plan.suites, suiteStep{ + name: "Stability Tests", + cmds: []string{ + suiteHeader("Stability Tests"), + fmt.Sprintf("cd /tas/%s && ginkgo %s %s", tileDirName, configuration.GinkgoFlags, stabilityPath), + }, + }) } + if configuration.RunManifest || configuration.RunAll { - ginkgo = append(ginkgo, fmt.Sprintf("/tas/%s/test/manifest", tileDirName)) + manifestPath := fmt.Sprintf("/tas/%s/test/manifest", tileDirName) + plan.suites = append(plan.suites, suiteStep{ + name: "Manifest Tests", + cmds: []string{ + `printf '\n'`, + fmt.Sprintf("cd /tas/%s && ginkgo %s %s", tileDirName, configuration.GinkgoFlags, manifestPath), + }, + }) + } + + return plan, nil +} + +// script produces the complete bash command for the test container. +// +// Each suite runs in a subshell so cd calls don't leak between suites. Exit +// codes are captured individually. When more than one suite is selected a +// colored pass/fail summary is printed at the end. The script always exits +// non-zero if any suite failed. When verbose is true, start/end timestamps +// are echoed before and after each suite. +func (p testPlan) script(verbose bool) string { + var b strings.Builder + + // Global setup — fail fast on any error. + if len(p.setup) > 0 { + b.WriteString(strings.Join(p.setup, " && ")) + b.WriteString("\n") } - // Note: this isn't compatible with tiles that use ginkgo v2 for their manifest tests - if configuration.RunMetadata || configuration.RunManifest || configuration.RunAll { - ginkgoCommand := fmt.Sprintf("cd /tas/%s && ginkgo %s %s", tileDirName, configuration.GinkgoFlags, strings.Join(ginkgo, " ")) - commands = append(commands, ginkgoCommand) + + if len(p.suites) == 0 { + return b.String() + } + + // One subshell per suite; exit code in _exitN. End-time (_timeN) only + // captured when verbose — it is only used in the verbose summary format. + for i, s := range p.suites { + if verbose { + fmt.Fprintf(&b, "\necho \"[$(date '+%%H:%%M:%%S')] Starting: %s\"\n", s.name) + } + fmt.Fprintf(&b, "\n(%s); _exit%d=$?\n", strings.Join(s.cmds, " && "), i) + if verbose { + fmt.Fprintf(&b, "_time%d=$(date '+%%H:%%M:%%S')\n", i) + fmt.Fprintf(&b, "echo \"[$_time%d] Completed: %s\"\n", i, s.name) + } + } + + // Summary — only when running more than one suite. + if len(p.suites) > 1 { + b.WriteString("\nprintf '\\n'\n") + for i, s := range p.suites { + if verbose { + fmt.Fprintf(&b, + "[ $_exit%d -eq 0 ] && printf '[%%s] \\033[32m✓\\033[0m %s Passed\\n' \"$_time%d\" || printf '[%%s] \\033[31m✗\\033[0m %s Failed\\n' \"$_time%d\"\n", + i, s.name, i, s.name, i, + ) + } else { + fmt.Fprintf(&b, + "[ $_exit%d -eq 0 ] && printf '\\033[32m✓\\033[0m %s Passed\\n' || printf '\\033[31m✗\\033[0m %s Failed\\n'\n", + i, s.name, s.name, + ) + } + } + } + + // Overall exit — non-zero if any suite failed. + b.WriteString("\n_overall=0\n") + for i := range p.suites { + fmt.Fprintf(&b, "[ $_exit%d -ne 0 ] && _overall=1\n", i) } - return commands, nil + b.WriteString("exit $_overall\n") + + return b.String() } //counterfeiter:generate -o ./fakes/moby_client.go --fake-name MobyClient . mobyClient @@ -97,59 +209,77 @@ type mobyClient interface { ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error } -func runTest(ctx context.Context, logger *log.Logger, w io.Writer, dockerDaemon mobyClient, configuration Configuration) error { - logger.Printf("pinging docker daemon") +func runTest(ctx context.Context, w io.Writer, dockerDaemon mobyClient, configuration Configuration) error { _, err := dockerDaemon.Ping(ctx) if err != nil { return fmt.Errorf("failed to connect to Docker daemon: %w", err) } - commands, err := configuration.commands() + plan, err := configuration.commands() if err != nil { return err } - var dockerfileTarball bytes.Buffer - if err := createDockerfileTarball(tar.NewWriter(&dockerfileTarball), dockerfile); err != nil { - return err - } - envMap, err := decodeEnvironment(configuration.Environment) if err != nil { return fmt.Errorf("failed to parse environment: %w", err) } - artifactoryUsername := envMap["ARTIFACTORY_USERNAME"] - artifactoryPassword := envMap["ARTIFACTORY_PASSWORD"] + username, password, err := requiredArtifactoryCredentialsFromMap(envMap) + if err != nil { + return err + } + envMap["ARTIFACTORY_USERNAME"] = username + envMap["ARTIFACTORY_PASSWORD"] = password + + if err := buildTestImage(ctx, w, dockerDaemon, username, password, envMap, configuration.Verbose); err != nil { + return err + } + + parentDir := filepath.Dir(configuration.AbsoluteTileDirectory) + tileDir := filepath.Base(configuration.AbsoluteTileDirectory) + envVars := getTileTestEnvVars(configuration.AbsoluteTileDirectory, tileDir, envMap) + + return startAndWaitContainer(ctx, w, dockerDaemon, plan.script(configuration.Verbose), envVars, parentDir, configuration.Verbose) +} + +// buildTestImage builds the kiln test Docker image, forwarding Artifactory +// credentials as build args and registry auth for pulling base images. +func buildTestImage(ctx context.Context, w io.Writer, dockerDaemon mobyClient, username, password string, envMap environmentVars, verbose bool) error { + var dockerfileTarball bytes.Buffer + if err := createDockerfileTarball(tar.NewWriter(&dockerfileTarball), dockerfile); err != nil { + return err + } - logger.Println("creating test image") + _, _ = fmt.Fprintln(w, "Preparing test image...") + var logOutput io.Writer + if verbose { + logOutput = w + } resp, err := dockerDaemon.ImageBuild(ctx, &dockerfileTarball, build.ImageBuildOptions{ Tags: []string{"kiln_test_dependencies:vmware"}, BuildArgs: map[string]*string{ - "ARTIFACTORY_USERNAME": &artifactoryUsername, - "ARTIFACTORY_PASSWORD": &artifactoryPassword, + "ARTIFACTORY_USERNAME": &username, + "ARTIFACTORY_PASSWORD": &password, }, + AuthConfigs: registryAuthForDockerVirtual(envMap), + SuppressOutput: !verbose, }) - if err != nil { return fmt.Errorf("failed to build image: %w", err) } - - logger.Println("reading image build response") - if err := checkImageBuildResponse(resp.Body); err != nil { + if err := checkImageBuildResponse(resp.Body, logOutput); err != nil { return fmt.Errorf("image build failed: %w", err) } + return nil +} - parentDir := path.Dir(configuration.AbsoluteTileDirectory) - tileDir := path.Base(configuration.AbsoluteTileDirectory) - - dockerCmd := strings.Join(commands, " && ") - - envVars := getTileTestEnvVars(configuration.AbsoluteTileDirectory, tileDir, envMap) - logger.Println("creating test container") +// startAndWaitContainer creates, starts, and waits for the test container to +// exit, streaming its logs to w. It stops the container on SIGINT. +func startAndWaitContainer(ctx context.Context, w io.Writer, dockerDaemon mobyClient, script string, envVars environmentVars, parentDir string, verbose bool) error { testContainer, err := dockerDaemon.ContainerCreate(ctx, &container.Config{ Image: "kiln_test_dependencies:vmware", - Cmd: []string{"/bin/bash", "-c", dockerCmd}, + Cmd: []string{"/bin/bash", "-c", script}, Env: encodeEnvironment(envVars), Tty: true, }, &container.HostConfig{ @@ -170,7 +300,9 @@ func runTest(ctx context.Context, logger *log.Logger, w io.Writer, dockerDaemon if err != nil { return fmt.Errorf("failed to create container: %w", err) } - logger.Printf("created test container with id %s", testContainer.ID) + if verbose { + _, _ = fmt.Fprintf(w, "Container: %s\n", testContainer.ID) + } errG := errgroup.Group{} @@ -197,18 +329,22 @@ func runTest(ctx context.Context, logger *log.Logger, w io.Writer, dockerDaemon return fmt.Errorf("failed to start test container: %w", err) } + // Subscribe for exit before draining logs. With AutoRemove, the engine may delete the + // container as soon as it stops; waiting for removal after io.Copy can race and + // return "no such container" (often under Podman). next-exit records the exit while + // the ID still exists. + statusCh, containerWaitError := dockerDaemon.ContainerWait(ctx, testContainer.ID, container.WaitConditionNextExit) + out, err := dockerDaemon.ContainerLogs(ctx, testContainer.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true}) if err != nil { return fmt.Errorf("container log request failure: %w", err) } + _, _ = fmt.Fprintln(w, "") if _, err := io.Copy(w, out); err != nil { return err } - //Although the fan-in loop pattern seems like the right solution here, ContainerWait - //does not properly close channels, so it won't work. var resultErr error - statusCh, containerWaitError := dockerDaemon.ContainerWait(ctx, testContainer.ID, container.WaitConditionRemoved) select { case err := <-containerWaitError: resultErr = err @@ -237,6 +373,54 @@ func encodeEnvironment(m environmentVars) []string { return result } +// RequiredArtifactoryCredentials resolves ARTIFACTORY_USERNAME and ARTIFACTORY_PASSWORD +// from -e flags and os.Getenv for kiln test. +func RequiredArtifactoryCredentials(envVarArgs []string) (username, password string, err error) { + m, err := decodeEnvironment(envVarArgs) + if err != nil { + return "", "", err + } + return requiredArtifactoryCredentialsFromMap(m) +} + +// requiredArtifactoryCredentialsFromMap resolves credentials from an already-decoded +// environment map, falling back to os.Getenv when a value is absent. +func requiredArtifactoryCredentialsFromMap(m environmentVars) (username, password string, err error) { + user := strings.TrimSpace(m["ARTIFACTORY_USERNAME"]) + if user == "" { + user = strings.TrimSpace(os.Getenv("ARTIFACTORY_USERNAME")) + } + pass := strings.TrimSpace(m["ARTIFACTORY_PASSWORD"]) + if pass == "" { + pass = strings.TrimSpace(os.Getenv("ARTIFACTORY_PASSWORD")) + } + if user == "" { + return "", "", fmt.Errorf("kiln test requires ARTIFACTORY_USERNAME: set it using -e or export it in your environment") + } + if pass == "" { + return "", "", fmt.Errorf("kiln test requires ARTIFACTORY_PASSWORD: set it using -e or export it in your environment") + } + return user, pass, nil +} + +// registryAuthForDockerVirtual supplies credentials for pulling FROM images on +// DockerVirtualRegistryHost during docker build (X-Registry-Config). +func registryAuthForDockerVirtual(env environmentVars) map[string]registry.AuthConfig { + user := strings.TrimSpace(env["ARTIFACTORY_USERNAME"]) + pass := strings.TrimSpace(env["ARTIFACTORY_PASSWORD"]) + if user == "" || pass == "" { + return nil + } + host := DockerVirtualRegistryHost + return map[string]registry.AuthConfig{ + host: { + Username: user, + Password: pass, + ServerAddress: host, + }, + } +} + func decodeEnvironment(environmentVarArgs []string) (environmentVars, error) { envMap := make(environmentVars) for _, envVar := range environmentVarArgs { @@ -260,6 +444,24 @@ func toProduct(dir string) string { } } +// npmInstallCommand returns the appropriate npm install command. +// When not verbose, --silent suppresses all progress output; errors still cause +// a non-zero exit. When verbose, output is unrestricted so the user can see +// what npm is doing. +func npmInstallCommand(absoluteTileDir string, verbose bool) string { + lockFile := filepath.Join(absoluteTileDir, "migrations", "package-lock.json") + if _, err := os.Stat(lockFile); err == nil { + if verbose { + return "npm ci" + } + return "npm ci --silent" + } + if verbose { + return "npm install --no-audit --no-fund" + } + return "npm install --no-audit --no-fund --silent" +} + func getTileTestEnvVars(dir, productDir string, envMap environmentVars) environmentVars { const fixturesFormat = "%s/test/manifest/fixtures" metadataPath := fmt.Sprintf(fixturesFormat+"/tas_metadata.yml", dir) @@ -279,6 +481,7 @@ func getTileTestEnvVars(dir, productDir string, envMap environmentVars) environm envVarsMap["PRODUCT"] = toProduct(productDir) } envVarsMap["RENDERER"] = "ops-manifest" + envVarsMap["GOMAXPROCS"] = strconv.Itoa(runtime.NumCPU()) // overwrite with / include optional env vars for k, v := range envMap { @@ -296,13 +499,17 @@ type tarWriter interface { } type imageBuildMessage struct { + Stream string `json:"stream"` Error string `json:"error"` ErrorDetail struct { Message string `json:"message"` } `json:"errorDetail"` } -func checkImageBuildResponse(body io.ReadCloser) error { +// checkImageBuildResponse reads the Docker/Podman image-build JSON stream. If +// logOutput is non-nil, "stream" lines are written there. Build errors are +// always returned. +func checkImageBuildResponse(body io.ReadCloser, logOutput io.Writer) error { defer func() { _ = body.Close() }() @@ -315,8 +522,15 @@ func checkImageBuildResponse(body io.ReadCloser) error { } return fmt.Errorf("failed to read image build response: %w", err) } + if logOutput != nil && msg.Stream != "" { + _, _ = io.WriteString(logOutput, msg.Stream) + } if msg.Error != "" { - return fmt.Errorf("%s", msg.Error) + detail := msg.Error + if msg.ErrorDetail.Message != "" { + detail = msg.ErrorDetail.Message + } + return errors.New(detail) } } return nil diff --git a/internal/test/container_test.go b/internal/test/container_test.go index 39172e7b6..fcbbf5fa6 100644 --- a/internal/test/container_test.go +++ b/internal/test/container_test.go @@ -1,8 +1,10 @@ package test import ( + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -17,7 +19,7 @@ func TestConfiguration_commands(t *testing.T) { for _, tt := range []struct { Name string Configuration Configuration - Result []string + ExpPlan testPlan ExpErrSubstring string }{ { @@ -32,7 +34,9 @@ func TestConfiguration_commands(t *testing.T) { Configuration: Configuration{ AbsoluteTileDirectory: absoluteTileDirectory, }, - Result: []string{"git config --global --add safe.directory '*'"}, + ExpPlan: testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + }, }, { Name: "when running migrations tests", @@ -40,7 +44,20 @@ func TestConfiguration_commands(t *testing.T) { AbsoluteTileDirectory: absoluteTileDirectory, RunMigrations: true, }, - Result: []string{"git config --global --add safe.directory '*'", "cd /tas/test/migrations", "npm install", "npm test"}, + ExpPlan: testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + suites: []suiteStep{ + { + name: "Migration Tests", + cmds: []string{ + "cd /tas/test/migrations", + "npm install --no-audit --no-fund --silent", + `printf '\nRunning Suite: Migration Tests\n==============================\n'`, + "npm test", + }, + }, + }, + }, }, { Name: "when running manifest tests", @@ -48,15 +65,37 @@ func TestConfiguration_commands(t *testing.T) { AbsoluteTileDirectory: absoluteTileDirectory, RunManifest: true, }, - Result: []string{"git config --global --add safe.directory '*'", "cd /tas/test && ginkgo /tas/test/test/manifest"}, + ExpPlan: testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + suites: []suiteStep{ + { + name: "Manifest Tests", + cmds: []string{ + `printf '\n'`, + "cd /tas/test && ginkgo /tas/test/test/manifest", + }, + }, + }, + }, }, { Name: "when running metadata tests", Configuration: Configuration{ AbsoluteTileDirectory: absoluteTileDirectory, - RunMetadata: true, + RunStability: true, + }, + ExpPlan: testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + suites: []suiteStep{ + { + name: "Stability Tests", + cmds: []string{ + `printf '\nRunning Suite: Stability Tests\n==============================\n'`, + "cd /tas/test && ginkgo /tas/test/test/stability", + }, + }, + }, }, - Result: []string{"git config --global --add safe.directory '*'", "cd /tas/test && ginkgo /tas/test/test/stability"}, }, { Name: "when running all tests", @@ -64,21 +103,292 @@ func TestConfiguration_commands(t *testing.T) { AbsoluteTileDirectory: absoluteTileDirectory, RunAll: true, }, - Result: []string{"git config --global --add safe.directory '*'", "cd /tas/test/migrations", "npm install", "npm test", "cd /tas/test && ginkgo /tas/test/test/stability /tas/test/test/manifest"}, + ExpPlan: testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + suites: []suiteStep{ + { + name: "Migration Tests", + cmds: []string{ + "cd /tas/test/migrations", + "npm install --no-audit --no-fund --silent", + `printf '\nRunning Suite: Migration Tests\n==============================\n'`, + "npm test", + }, + }, + { + name: "Stability Tests", + cmds: []string{ + `printf '\nRunning Suite: Stability Tests\n==============================\n'`, + "cd /tas/test && ginkgo /tas/test/test/stability", + }, + }, + { + name: "Manifest Tests", + cmds: []string{ + `printf '\n'`, + "cd /tas/test && ginkgo /tas/test/test/manifest", + }, + }, + }, + }, }, } { t.Run(tt.Name, func(t *testing.T) { - result, err := tt.Configuration.commands() + plan, err := tt.Configuration.commands() if tt.ExpErrSubstring != "" { require.ErrorContains(t, err, tt.ExpErrSubstring) - } else { - require.NoError(t, err) - require.Equal(t, tt.Result, result) + return + } + require.NoError(t, err) + require.Equal(t, tt.ExpPlan.setup, plan.setup) + require.Len(t, plan.suites, len(tt.ExpPlan.suites)) + for i, expSuite := range tt.ExpPlan.suites { + require.Equal(t, expSuite.name, plan.suites[i].name) + require.Equal(t, expSuite.cmds, plan.suites[i].cmds) } }) } } +func TestTestPlan_script_includesSummaryForMultipleSuites(t *testing.T) { + plan := testPlan{ + setup: []string{"setup cmd"}, + suites: []suiteStep{ + {name: "Migration Tests", cmds: []string{"npm test"}}, + {name: "Stability Tests", cmds: []string{"ginkgo stability"}}, + }, + } + + script := plan.script(false) + + // Each suite runs in a subshell with captured exit code. + require.Contains(t, script, "); _exit0=$?") + require.Contains(t, script, "); _exit1=$?") + + // End time NOT captured without verbose — not needed for plain summary. + require.NotContains(t, script, "_time0=$(date") + require.NotContains(t, script, "_time1=$(date") + + // Summary lines present for both suites without timestamps. + require.Contains(t, script, "Migration Tests Passed") + require.Contains(t, script, "Migration Tests Failed") + require.Contains(t, script, "Stability Tests Passed") + require.Contains(t, script, "Stability Tests Failed") + require.NotContains(t, script, "$_time0") + require.NotContains(t, script, "$_time1") + + // ANSI green and red codes present. + require.Contains(t, script, "\\033[32m") + require.Contains(t, script, "\\033[31m") + + // Pass/fail symbols present. + require.Contains(t, script, "✓") + require.Contains(t, script, "✗") + + // Overall exit present. + require.Contains(t, script, "_overall") + require.Contains(t, script, "exit $_overall") +} + +func TestTestPlan_script_omitsSummaryForSingleSuite(t *testing.T) { + plan := testPlan{ + setup: []string{"setup cmd"}, + suites: []suiteStep{ + {name: "Manifest Tests", cmds: []string{"ginkgo manifest"}}, + }, + } + + script := plan.script(false) + + // No summary text for single suite. + require.NotContains(t, script, "Passed") + require.NotContains(t, script, "Failed") + + // Still exits with the suite's exit code. + require.Contains(t, script, "exit $_overall") +} + +func TestTestPlan_script_emptyWithNoSuites(t *testing.T) { + plan := testPlan{ + setup: []string{"git config --global --add safe.directory '*'"}, + } + script := plan.script(false) + require.Contains(t, script, "git config") + require.NotContains(t, script, "_exit0") + require.NotContains(t, script, "_overall") +} + +func Test_checkImageBuildResponse(t *testing.T) { + t.Run("returns error from daemon error message", func(t *testing.T) { + body := io.NopCloser(strings.NewReader( + `{"stream":"Step 1\n"}` + "\n" + + `{"stream":"go: downloading\n"}` + "\n" + + `{"error":"failed","errorDetail":{"message":"go install: nope"}}` + "\n", + )) + err := checkImageBuildResponse(body, nil) + require.ErrorContains(t, err, "go install: nope") + }) +} + +func TestEmbeddedDockerfile_structure(t *testing.T) { + // Base image FROM lines must use the internal docker-virtual registry. + require.Contains(t, dockerfile, "FROM "+DockerVirtualRegistryHost+"/golang") + require.Contains(t, dockerfile, "FROM "+DockerVirtualRegistryHost+"/ruby:4.0.3") + require.NotContains(t, dockerfile, "REGISTRY_PREFIX") + + // ginkgo must be pinned to a specific version (not @latest) so builds are reproducible + // and the cache layer is stable. + require.Contains(t, dockerfile, "go install github.com/onsi/ginkgo/ginkgo@v1.16.5") + require.NotContains(t, dockerfile, "ginkgo@latest") + + // GOPROXY credentials must be scoped to the ginkgo RUN step only — not exported + // as an ENV layer — so the ginkgo install layer is not busted by credential rotation. + require.NotContains(t, dockerfile, "ENV GOPROXY=https://${ARTIFACTORY_USERNAME}") + + // Credentials ARG declaration must come AFTER stable system package installs + // (jq, nodejs, npm) so those layers stay cached when credentials rotate. + argIdx := strings.Index(dockerfile, "ARG ARTIFACTORY_USERNAME") + jqIdx := strings.Index(dockerfile, "apt-get") + require.Greater(t, argIdx, jqIdx, "ARTIFACTORY_USERNAME ARG should appear after apt-get installs") + + // Credentials must be exported to ENV for ops-manifest gem at container runtime. + require.Contains(t, dockerfile, "ENV ARTIFACTORY_USERNAME=${ARTIFACTORY_USERNAME}") + require.Contains(t, dockerfile, "ENV ARTIFACTORY_PASSWORD=${ARTIFACTORY_PASSWORD}") +} + +func TestConfiguration_commands_usesNpmCiWhenLockfilePresent(t *testing.T) { + tileDir := filepath.Join(t.TempDir(), "ist") + require.NoError(t, os.MkdirAll(filepath.Join(tileDir, "migrations"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(tileDir, "migrations", "package-lock.json"), []byte("{}"), 0o600)) + + plan, err := Configuration{AbsoluteTileDirectory: tileDir, RunMigrations: true}.commands() + require.NoError(t, err) + require.Len(t, plan.suites, 1) + // verbose=false (default): npm output silenced + require.Contains(t, plan.suites[0].cmds, "npm ci --silent") +} + +func TestConfiguration_commands_verboseUsesNpmCiWithoutSilent(t *testing.T) { + tileDir := filepath.Join(t.TempDir(), "ist") + require.NoError(t, os.MkdirAll(filepath.Join(tileDir, "migrations"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(tileDir, "migrations", "package-lock.json"), []byte("{}"), 0o600)) + + plan, err := Configuration{AbsoluteTileDirectory: tileDir, RunMigrations: true, Verbose: true}.commands() + require.NoError(t, err) + require.Contains(t, plan.suites[0].cmds, "npm ci") + require.NotContains(t, plan.suites[0].cmds, "npm ci --silent") +} + +func TestConfiguration_commands_usesNpmInstallWithoutLockfile(t *testing.T) { + tileDir := filepath.Join(t.TempDir(), "ist") + require.NoError(t, os.MkdirAll(filepath.Join(tileDir, "migrations"), 0o700)) + + plan, err := Configuration{AbsoluteTileDirectory: tileDir, RunMigrations: true}.commands() + require.NoError(t, err) + require.Contains(t, plan.suites[0].cmds, "npm install --no-audit --no-fund --silent") +} + +func TestTestPlan_script_verbose_addsTimestampsAndUsesThemInSummary(t *testing.T) { + plan := testPlan{ + setup: []string{"setup cmd"}, + suites: []suiteStep{ + {name: "Migration Tests", cmds: []string{"npm test"}}, + {name: "Stability Tests", cmds: []string{"ginkgo stability"}}, + }, + } + + script := plan.script(true) + + // Start and end echo lines present for each suite. + require.Contains(t, script, "Starting: Migration Tests") + require.Contains(t, script, "Completed: Migration Tests") + require.Contains(t, script, "Starting: Stability Tests") + require.Contains(t, script, "Completed: Stability Tests") + + // End time captured and used in summary with timestamp prefix. + require.Contains(t, script, "_time0=$(date") + require.Contains(t, script, "_time1=$(date") + require.Contains(t, script, "$_time0") + require.Contains(t, script, "$_time1") +} + +func TestTestPlan_script_noStartEndEchoWhenNotVerbose(t *testing.T) { + plan := testPlan{ + setup: []string{"setup cmd"}, + suites: []suiteStep{{name: "Migration Tests", cmds: []string{"npm test"}}}, + } + + script := plan.script(false) + + // No verbose echo lines and no timestamp capture without verbose. + require.NotContains(t, script, "Starting:") + require.NotContains(t, script, "Completed:") + require.NotContains(t, script, "_time0=$(date") +} + +func TestGetTileTestEnvVars_setsGOMAXPROCS(t *testing.T) { + tileDir := filepath.Join(t.TempDir(), "ist") + envVars := getTileTestEnvVars(tileDir, "ist", environmentVars{}) + gomaxprocs, ok := envVars["GOMAXPROCS"] + require.True(t, ok, "GOMAXPROCS should be set in container env") + require.NotEmpty(t, gomaxprocs) +} + +func Test_registryAuthForDockerVirtual(t *testing.T) { + t.Run("nil when username missing", func(t *testing.T) { + require.Nil(t, registryAuthForDockerVirtual(environmentVars{"ARTIFACTORY_PASSWORD": "p"})) + }) + t.Run("nil when password missing", func(t *testing.T) { + require.Nil(t, registryAuthForDockerVirtual(environmentVars{"ARTIFACTORY_USERNAME": "u"})) + }) + t.Run("returns auth for docker virtual host", func(t *testing.T) { + got := registryAuthForDockerVirtual(environmentVars{ + "ARTIFACTORY_USERNAME": "alice", + "ARTIFACTORY_PASSWORD": "secret", + }) + require.Len(t, got, 1) + cfg := got[DockerVirtualRegistryHost] + require.Equal(t, "alice", cfg.Username) + require.Equal(t, "secret", cfg.Password) + require.Equal(t, DockerVirtualRegistryHost, cfg.ServerAddress) + }) +} + +func Test_RequiredArtifactoryCredentials(t *testing.T) { + t.Run("from -e only", func(t *testing.T) { + t.Setenv("ARTIFACTORY_USERNAME", "") + t.Setenv("ARTIFACTORY_PASSWORD", "") + u, p, err := RequiredArtifactoryCredentials([]string{"ARTIFACTORY_USERNAME=a", "ARTIFACTORY_PASSWORD=b"}) + require.NoError(t, err) + require.Equal(t, "a", u) + require.Equal(t, "b", p) + }) + t.Run("-e overrides process env", func(t *testing.T) { + t.Setenv("ARTIFACTORY_USERNAME", "envuser") + t.Setenv("ARTIFACTORY_PASSWORD", "envpass") + u, p, err := RequiredArtifactoryCredentials([]string{"ARTIFACTORY_USERNAME=fromflag", "ARTIFACTORY_PASSWORD=frompass"}) + require.NoError(t, err) + require.Equal(t, "fromflag", u) + require.Equal(t, "frompass", p) + }) + t.Run("missing username", func(t *testing.T) { + t.Setenv("ARTIFACTORY_USERNAME", "") + _, _, err := RequiredArtifactoryCredentials([]string{"ARTIFACTORY_PASSWORD=only"}) + require.ErrorContains(t, err, "ARTIFACTORY_USERNAME") + require.ErrorContains(t, err, "kiln test") + }) + t.Run("missing password", func(t *testing.T) { + t.Setenv("ARTIFACTORY_PASSWORD", "") + _, _, err := RequiredArtifactoryCredentials([]string{"ARTIFACTORY_USERNAME=only"}) + require.ErrorContains(t, err, "ARTIFACTORY_PASSWORD") + require.ErrorContains(t, err, "kiln test") + }) + t.Run("invalid env pair", func(t *testing.T) { + _, _, err := RequiredArtifactoryCredentials([]string{"notakeyval"}) + require.Error(t, err) + }) +} + func Test_decodeEnvironment(t *testing.T) { for _, tt := range []struct { Name string diff --git a/internal/test/integration_test.go b/internal/test/integration_test.go index 652f904dc..bc379f279 100644 --- a/internal/test/integration_test.go +++ b/internal/test/integration_test.go @@ -49,6 +49,7 @@ func TestDockerIntegration(t *testing.T) { AbsoluteTileDirectory: tmpDir, RunAll: true, Environment: []string{"ARTIFACTORY_USERNAME=" + artifactoryUsername, "ARTIFACTORY_PASSWORD=" + artifactoryPassword}, + Verbose: testing.Verbose(), } out := io.Discard if testing.Verbose() { @@ -129,14 +130,10 @@ func setupTestRepo(t *testing.T) string { tmpDir := t.TempDir() happyTilePath := filepath.Join(wd, "testdata", "happy-tile") tar, err := archive.Tar(happyTilePath, compression.None) - assert.NoError(t, err) + require.NoError(t, err) err = archive.Untar(tar, tmpDir, nil) - assert.NoError(t, err) - t.Cleanup(func() { - err = os.RemoveAll(tmpDir) - assert.NoError(t, err) - _ = tar.Close() - }) + require.NoError(t, err) + _ = tar.Close() cmds := [][]string{ {"git", "init"},