package main_test import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // This file guards the version stamping of the product image (issue // #75). The failure it protects against is silent: the image still // builds and runs, but `vaultik version` inside it reports "commit: // unknown" or a version of "dev", so an operator cannot tell which // source produced a given backup. The build takes the version as a // build arg, which script/docker computes on the host, and otherwise // derives it from the .git in its context; the commit and its date // always come from that .git. // // These are parses of the committed files, because shelling out to // docker would nest a build inside `make test`. That `vaultik version` // in the built image really prints the host's version is verified by // hand. // The files under guard, relative to the repository root. const ( productDockerfile = "Dockerfile" dockerScript = "script/docker" ) // TestProductDockerfileTakesVersionAsBuildArg fails unless the build // declares ARG VERSION, with no default, and stamps it into the binary // whenever it is given, ahead of the value derived in the container. func TestProductDockerfileTakesVersionAsBuildArg(t *testing.T) { t.Parallel() found := instructions(t, productDockerfile) require.Contains(t, found, "ARG VERSION", "%s must declare `ARG VERSION`, with no default, so the host can"+ " pass it in", productDockerfile) assertLdflagReferences(t, found, "VERSION") } // TestProductDockerfileDerivesVersionFromGit fails unless a build given // no VERSION, such as a plain `docker build .` of a clone, takes it from // `git describe` of the .git in its context, stamps "dev" when the // context has no .git, and fails rather than stamp "dev" when that .git // yields no version. The commit and its date come from the same .git, // and the build fails rather than stamp them "unknown" when it is // present. func TestProductDockerfileDerivesVersionFromGit(t *testing.T) { t.Parallel() found := instructions(t, productDockerfile) buildAt := indexContaining(found, "go build") require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile) assert.Contains(t, found[buildAt], "git describe --tags --always || echo dev", "%s must derive the version from git when no VERSION is given,"+ " and stamp dev when the context has no .git", productDockerfile) assert.Contains(t, found[buildAt], "[ -e .git ]", "%s must fail when the context carries .git but yields no version", productDockerfile) assert.Contains(t, found[buildAt], "git rev-parse HEAD", "%s must stamp the commit from git", productDockerfile) assert.Contains(t, found[buildAt], "git show -s --format=%cs HEAD", "%s must stamp the commit date from git", productDockerfile) assert.Contains(t, found[buildAt], `[ "$commit" = unknown ] || [ "$commit_date" = unknown ]`, "%s must fail when the context carries .git but yields no commit"+ " or date", productDockerfile) } // TestDockerScriptComputesVersionOnTheHost fails unless script/docker // passes the version it derives where .git exists as a build arg. func TestDockerScriptComputesVersionOnTheHost(t *testing.T) { t.Parallel() script := readRepoFile(t, dockerScript) assert.Contains(t, script, "--build-arg VERSION=", "%s must pass --build-arg VERSION to the build", dockerScript) } // assertLdflagReferences fails unless the build instruction uses the // named ARG whenever it is given (a ${arg:- reference), so a value // passed in is not overridden by one derived inside the container. func assertLdflagReferences(t *testing.T, found []string, arg string) { t.Helper() for _, instruction := range found { if strings.HasPrefix(instruction, "RUN ") && strings.Contains(instruction, "go build") && strings.Contains(instruction, "${"+arg+":-") { return } } assert.Fail(t, "version arg is declared but never stamped", "the go build in %s must use ${%s:-...}, or the arg is passed and"+ " discarded", productDockerfile, arg) } // instructions returns the Dockerfile's instructions, one per element, // with comments and blank lines dropped and continuation lines joined, // so a multi-line RUN is one string. func instructions(t *testing.T, name string) []string { t.Helper() var ( out []string continued string isContinued bool ) for line := range strings.SplitSeq(readRepoFile(t, name), "\n") { trimmed := strings.TrimSpace(line) if !isContinued && (trimmed == "" || strings.HasPrefix(trimmed, "#")) { continue } isContinued = strings.HasSuffix(trimmed, `\`) continued += strings.TrimSuffix(trimmed, `\`) if isContinued { continue } out = append(out, strings.Join(strings.Fields(continued), " ")) continued = "" } return out } // indexContaining returns the position of the first instruction // containing want; -1 if there is none. func indexContaining(found []string, want string) int { for i, instruction := range found { if strings.Contains(instruction, want) { return i } } return -1 }