Compare commits
1 Commits
739de1e101
...
d257f8f658
| Author | SHA1 | Date | |
|---|---|---|---|
| d257f8f658 |
@@ -36,23 +36,7 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# `golangci-lint config verify` is deliberately NOT run here.
|
# Force the check layers to execute on every invocation.
|
||||||
#
|
|
||||||
# It validates .golangci.yml against a JSON schema that it fetches over
|
|
||||||
# live HTTPS from an unpinned URL at run time. Running it would make the
|
|
||||||
# lint gate depend on a remote resource that is outside this repo's
|
|
||||||
# hash-pinning discipline, and would turn an upstream outage or an
|
|
||||||
# egress-less runner into a red that is not a lint verdict -- the exact
|
|
||||||
# false-red class this repo has spent several issues eliminating. The
|
|
||||||
# config is not unvalidated in practice either: `golangci-lint run`
|
|
||||||
# below rejects an unparseable or unknown-key config itself, at the
|
|
||||||
# version that is actually gating.
|
|
||||||
#
|
|
||||||
# If it is ever added, it must first be demonstrated to work with the
|
|
||||||
# network genuinely off (`docker run --network none`) at the pinned
|
|
||||||
# version, with that output recorded.
|
|
||||||
|
|
||||||
# Force the lint layer to execute on every invocation.
|
|
||||||
#
|
#
|
||||||
# CHECK_EPOCH must stay immediately above the RUNs below. Those layers
|
# CHECK_EPOCH must stay immediately above the RUNs below. Those layers
|
||||||
# are keyed on its value, so they are cache-eligible only for a value
|
# are keyed on its value, so they are cache-eligible only for a value
|
||||||
@@ -62,8 +46,8 @@ COPY . .
|
|||||||
# Dockerfile.lint .` on an unchanged tree exits 0 in well under a second
|
# Dockerfile.lint .` on an unchanged tree exits 0 in well under a second
|
||||||
# having linted nothing.
|
# having linted nothing.
|
||||||
#
|
#
|
||||||
# The value is expanded into the lint command itself rather than left to
|
# The value is expanded into each check command itself rather than left
|
||||||
# a bare declaration, so the cache miss does not depend on BuildKit's
|
# to a bare declaration, so the cache miss does not depend on BuildKit's
|
||||||
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
# unreferenced-ARG handling staying as it is. It also puts the epoch in
|
||||||
# the build log, where a reader can see the layer was keyed fresh.
|
# the build log, where a reader can see the layer was keyed fresh.
|
||||||
#
|
#
|
||||||
@@ -77,5 +61,44 @@ COPY . .
|
|||||||
# constant and restore the hole.
|
# constant and restore the hole.
|
||||||
ARG CHECK_EPOCH
|
ARG CHECK_EPOCH
|
||||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
||||||
|
|
||||||
|
# Validate .golangci.yml before linting with it.
|
||||||
|
#
|
||||||
|
# This is not belt-and-braces; it closes a hole that `golangci-lint run`
|
||||||
|
# leaves wide open. `run` rejects YAML it cannot PARSE, but it silently
|
||||||
|
# IGNORES an unknown top-level KEY. Renaming `linters:` to `linterz:` --
|
||||||
|
# one character -- discards `default: all`, the whole disable list and
|
||||||
|
# every threshold, leaves only golangci-lint's small default linter set
|
||||||
|
# running, and exits 0 reporting `0 issues.` on a tree the real config
|
||||||
|
# fails. Demonstrated on this repo at this pin, recorded on
|
||||||
|
# https://git.eeqj.de/sneak/vaultik/pulls/114: with a planted
|
||||||
|
# over-length line, `script/lint` exits 1 naming the `lll` finding with
|
||||||
|
# `linters:` and exits 0 with `linterz:`. A set-but-ineffective config
|
||||||
|
# quietly falling back to defaults is precisely the false-green class
|
||||||
|
# this gate exists to eliminate, so it must not sit in the gate's own
|
||||||
|
# configuration.
|
||||||
|
#
|
||||||
|
# `config verify` catches it, and it does so OFFLINE at this pinned
|
||||||
|
# version -- verified, not assumed. Under `docker run --network none`
|
||||||
|
# against the pinned digest it exits 0 on this repo's config and exits 3
|
||||||
|
# on the `linterz:` variant with `additional properties 'linterz' not
|
||||||
|
# allowed`. An earlier revision of this file asserted the opposite, that
|
||||||
|
# the schema is fetched over live HTTPS from an unpinned URL, and used
|
||||||
|
# that to justify omitting this line. That claim was false at v2.12.2;
|
||||||
|
# the schema is embedded. If a future bump reintroduces a network fetch
|
||||||
|
# the failure is loud and this comment is where to record it.
|
||||||
|
#
|
||||||
|
# It is keyed on CHECK_EPOCH, like the lint run below, so it executes on
|
||||||
|
# every invocation. Content-addressing alone would arguably be enough --
|
||||||
|
# .golangci.yml arrives through `COPY . .`, so a cache hit here implies
|
||||||
|
# a byte-identical config was validated when the layer really ran. That
|
||||||
|
# argument is exactly the one that would also excuse caching the lint
|
||||||
|
# layer, and this repo has ruled it insufficient: a cached check layer
|
||||||
|
# checks nothing, and the cost of being wrong is silent. Forcing it costs
|
||||||
|
# milliseconds and puts the epoch in the log, where a reader can see that
|
||||||
|
# this validation ran rather than being replayed.
|
||||||
|
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
||||||
|
golangci-lint config verify --config .golangci.yml
|
||||||
|
|
||||||
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
RUN echo "check epoch: ${CHECK_EPOCH}" && \
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|||||||
23
TODO.md
23
TODO.md
@@ -50,10 +50,13 @@ release" is exactly the contradiction
|
|||||||
product `Dockerfile` already used is what makes the green mean
|
product `Dockerfile` already used is what makes the green mean
|
||||||
something: `ARG CHECK_EPOCH` with no default below the module layers,
|
something: `ARG CHECK_EPOCH` with no default below the module layers,
|
||||||
a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard, the value expanded
|
a `RUN [ -n "$CHECK_EPOCH" ] || exit 1` guard, the value expanded
|
||||||
into the lint command, and a fresh `$(date +%s%N)$$` per invocation
|
into each check command, and a fresh `$(date +%s%N)$$` per invocation
|
||||||
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
computed as a bare assignment. `cmd/vaultik/lintdocker_test.go`
|
||||||
parses both Dockerfiles and both scripts and fails if any part of
|
parses both Dockerfiles and both scripts and fails if any part of
|
||||||
that is dropped, because every way of losing it is silent.
|
that is dropped, because every way of losing it is silent. Its
|
||||||
|
host-lint assertion is structural — no script runs `golangci-lint`
|
||||||
|
except through `docker` — rather than a search for the one retired
|
||||||
|
variable name, which nothing could ever reintroduce.
|
||||||
|
|
||||||
The product `Dockerfile` lost its lint stage rather than gaining a
|
The product `Dockerfile` lost its lint stage rather than gaining a
|
||||||
second linter pin: `make lint` is now `docker build`, so the stage
|
second linter pin: `make lint` is now `docker build`, so the stage
|
||||||
@@ -66,11 +69,17 @@ release" is exactly the contradiction
|
|||||||
to be found: `script/docker` builds the product image only and no
|
to be found: `script/docker` builds the product image only and no
|
||||||
longer lints; the gates are `script/check` and `script/cibuild`.
|
longer lints; the gates are `script/check` and `script/cibuild`.
|
||||||
|
|
||||||
Two deliberate decisions, both documented where they apply.
|
`golangci-lint config verify` runs as its own epoch-keyed layer,
|
||||||
`golangci-lint config verify` is omitted: it fetches its JSON schema
|
above the lint. `golangci-lint run` rejects a config it cannot parse
|
||||||
over an unpinned live HTTPS call, which would make the gate
|
but silently ignores an unknown top-level *key*: renaming `linters:`
|
||||||
network-dependent and turn an upstream outage into a red that is not
|
to `linterz:` discarded `default: all` and every threshold and still
|
||||||
a lint verdict. `script/lint-fix` is kept, reimplemented as a
|
exited 0 on a tree the real config fails. `config verify` catches
|
||||||
|
that, and it does so with the network off at this pin — checked under
|
||||||
|
`docker run --network none`, not assumed. An earlier revision omitted
|
||||||
|
it on the claim that it fetches its schema over live HTTPS; that
|
||||||
|
claim was false at v2.12.2.
|
||||||
|
|
||||||
|
`script/lint-fix` is kept, reimplemented as a
|
||||||
bind-mounted `docker run` against the image parsed out of
|
bind-mounted `docker run` against the image parsed out of
|
||||||
`Dockerfile.lint` — it cannot be a build step, because fixes have to
|
`Dockerfile.lint` — it cannot be a build step, because fixes have to
|
||||||
land in the worktree — and marked in its header as a developer
|
land in the worktree — and marked in its header as a developer
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const (
|
|||||||
cibuildScript = "script/cibuild"
|
cibuildScript = "script/cibuild"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// linterBinary is the linter's command name. Every occurrence of it in
|
||||||
|
// executable shell in this repo must be inside a docker invocation; see
|
||||||
|
// TestNoHostLintPathRemains.
|
||||||
|
const linterBinary = "golangci-lint"
|
||||||
|
|
||||||
// checkEpochARG is the declaration, with no default value. A default
|
// checkEpochARG is the declaration, with no default value. A default
|
||||||
// would satisfy the non-empty guard with a constant, and a constant is
|
// would satisfy the non-empty guard with a constant, and a constant is
|
||||||
// a stable cache key: the checks would be replayed from cache forever
|
// a stable cache key: the checks would be replayed from cache forever
|
||||||
@@ -111,6 +116,38 @@ func TestLintDockerfileCannotBeCachedGreen(t *testing.T) {
|
|||||||
" still cache", checkEpochARG)
|
" still cache", checkEpochARG)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLintDockerfileVerifiesTheLinterConfig guards the validation of
|
||||||
|
// .golangci.yml itself. `golangci-lint run` rejects a config it cannot
|
||||||
|
// parse but silently IGNORES an unknown top-level key, so renaming
|
||||||
|
// `linters:` to `linterz:` discards `default: all` and every threshold
|
||||||
|
// and still exits 0 reporting no issues. `config verify` is what turns
|
||||||
|
// that into a failure, and it has to run BEFORE the lint, or the lint
|
||||||
|
// spends a minute reporting a verdict from a config already known to be
|
||||||
|
// wrong.
|
||||||
|
func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
found := instructions(t, lintDockerfile)
|
||||||
|
verify := linterBinary + " config verify"
|
||||||
|
|
||||||
|
verifyAt := indexContaining(found, verify)
|
||||||
|
require.GreaterOrEqual(t, verifyAt, 0,
|
||||||
|
"%s must run `%s --config .golangci.yml`: without it a typo'd"+
|
||||||
|
" top-level key in .golangci.yml is silently ignored and the"+
|
||||||
|
" gate passes with only the default linter set", lintDockerfile,
|
||||||
|
verify)
|
||||||
|
|
||||||
|
runAt := indexContaining(found, linterBinary+" run")
|
||||||
|
require.GreaterOrEqual(t, runAt, 0, "%s must lint", lintDockerfile)
|
||||||
|
assert.Less(t, verifyAt, runAt,
|
||||||
|
"%s must verify the config before linting with it", lintDockerfile)
|
||||||
|
|
||||||
|
// Keyed on the epoch like every other check layer, so it executes
|
||||||
|
// per invocation rather than being replayed. A cached validation
|
||||||
|
// validates nothing.
|
||||||
|
assertEpochExpandedInto(t, found, verify)
|
||||||
|
}
|
||||||
|
|
||||||
// TestProductDockerfileCannotBeCachedGreen holds the same line for the
|
// TestProductDockerfileCannotBeCachedGreen holds the same line for the
|
||||||
// checks that remain in the product image build.
|
// checks that remain in the product image build.
|
||||||
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
|
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
|
||||||
@@ -187,6 +224,19 @@ func TestCibuildBuildsBothDockerfilesWithFreshEpochs(t *testing.T) {
|
|||||||
// a container; a PATH binary that happens to match the pinned version
|
// a container; a PATH binary that happens to match the pinned version
|
||||||
// is a different build reached by a different code path, and admitting
|
// is a different build reached by a different code path, and admitting
|
||||||
// it is what lets a local pass disagree with CI.
|
// it is what lets a local pass disagree with CI.
|
||||||
|
//
|
||||||
|
// This asserts the PROPERTY -- no script invokes the linter except
|
||||||
|
// through docker -- rather than the absence of any particular variable
|
||||||
|
// name. An earlier version of this test looked only for the literal
|
||||||
|
// VAULTIK_LINT_IN_CONTAINER, the name of the hatch that was removed
|
||||||
|
// alongside it, so nothing could ever trip it again: a hatch under any
|
||||||
|
// other name left it passing. A structural test that passes on a broken
|
||||||
|
// tree is worse than no test, because it is what a later reader trusts
|
||||||
|
// instead of re-deriving the invariant.
|
||||||
|
//
|
||||||
|
// script/lint-fix is not exempted. It is the one script that runs the
|
||||||
|
// linter as a container rather than as a build step, but it still runs
|
||||||
|
// it in one, so the same property holds of it.
|
||||||
func TestNoHostLintPathRemains(t *testing.T) {
|
func TestNoHostLintPathRemains(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -194,21 +244,63 @@ func TestNoHostLintPathRemains(t *testing.T) {
|
|||||||
|
|
||||||
entries, err := os.ReadDir(filepath.Join(root, "script"))
|
entries, err := os.ReadDir(filepath.Join(root, "script"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, entries, "no scripts found to scan")
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
contents := readRepoFile(t, filepath.Join("script", entry.Name()))
|
name := filepath.Join("script", entry.Name())
|
||||||
assert.NotContains(t, contents, "VAULTIK_LINT_IN_CONTAINER",
|
for _, line := range shellCode(readRepoFile(t, name)) {
|
||||||
"script/%s revives the in-container escape hatch",
|
assertLinterIsContainerised(t, name, line)
|
||||||
entry.Name())
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLinterIsContainerised fails if the line runs the linter without
|
||||||
|
// handing it to docker first. Position matters: docker has to come
|
||||||
|
// before the binary, or the line is running the host linter and merely
|
||||||
|
// mentioning docker afterwards.
|
||||||
|
func assertLinterIsContainerised(t *testing.T, name, line string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
at := strings.Index(line, linterBinary)
|
||||||
|
if at < 0 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NotContains(t, readRepoFile(t, lintDockerfile),
|
docker := strings.Index(line, "docker")
|
||||||
"VAULTIK_LINT_IN_CONTAINER",
|
|
||||||
"%s revives the in-container escape hatch", lintDockerfile)
|
assert.True(t, docker >= 0 && docker < at,
|
||||||
|
"%s runs %s on the host; every lint run happens in a container"+
|
||||||
|
" (line: %s)", name, linterBinary, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShellCodeSeesCodeAndNotProse keeps the scanner above honest. It
|
||||||
|
// has to ignore comments and here-document bodies, because script/lint
|
||||||
|
// and script/bootstrap both NAME golangci-lint in prose -- in comments,
|
||||||
|
// and in the error text they print -- precisely to say that the host
|
||||||
|
// binary is never used. A scanner that went blind, by over-eager
|
||||||
|
// stripping or by failing to join continuation lines, would make
|
||||||
|
// TestNoHostLintPathRemains pass on everything.
|
||||||
|
func TestShellCodeSeesCodeAndNotProse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
script := strings.Join([]string{
|
||||||
|
"#!/bin/sh",
|
||||||
|
"# a comment naming golangci-lint",
|
||||||
|
"cat >&2 <<EOF",
|
||||||
|
"prose naming golangci-lint, printed not executed",
|
||||||
|
"EOF",
|
||||||
|
"docker run --rm \\",
|
||||||
|
" \"$image\" \\",
|
||||||
|
" golangci-lint run ./...",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"cat >&2 <<EOF", `docker run --rm "$image" golangci-lint run ./...`},
|
||||||
|
shellCode(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertEpochExpandedInto fails unless some instruction runs the named
|
// assertEpochExpandedInto fails unless some instruction runs the named
|
||||||
@@ -303,6 +395,82 @@ func indexOf(found []string, want string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellCode returns a POSIX shell script's executable lines: comments
|
||||||
|
// dropped, here-document bodies dropped, and backslash continuations
|
||||||
|
// joined so a multi-line command is a single string. Whitespace is
|
||||||
|
// collapsed, as it is for Dockerfile instructions.
|
||||||
|
//
|
||||||
|
// Both exclusions are load-bearing rather than tidiness. The scripts
|
||||||
|
// name golangci-lint in prose to state that the host binary is never
|
||||||
|
// used, and joining continuations is what lets the one legitimate
|
||||||
|
// container invocation -- script/lint-fix's `docker run`, whose linter
|
||||||
|
// command sits several lines below the word `docker` -- be recognised
|
||||||
|
// as containerised.
|
||||||
|
func shellCode(contents string) []string {
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
joined string
|
||||||
|
terminate string
|
||||||
|
)
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(contents, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if terminate != "" {
|
||||||
|
if trimmed == terminate {
|
||||||
|
terminate = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined == "" && (trimmed == "" || strings.HasPrefix(trimmed, "#")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
joined += strings.TrimSuffix(trimmed, `\`) + " "
|
||||||
|
if strings.HasSuffix(trimmed, `\`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
joined = strings.Join(strings.Fields(joined), " ")
|
||||||
|
terminate = heredocTerminator(joined)
|
||||||
|
|
||||||
|
out = append(out, joined)
|
||||||
|
joined = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// heredocTerminator returns the terminator of the here-document a
|
||||||
|
// command opens, or "" if it opens none. Only the first on a line is
|
||||||
|
// recognised; nothing in script/ opens two.
|
||||||
|
func heredocTerminator(line string) string {
|
||||||
|
_, after, opens := strings.Cut(line, "<<")
|
||||||
|
if !opens {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<<-` strips leading tabs from the body; the terminator word is
|
||||||
|
// the same either way, and callers compare against trimmed lines.
|
||||||
|
word, _, _ := strings.Cut(strings.TrimPrefix(after, "-"), " ")
|
||||||
|
|
||||||
|
return strings.Trim(word, `'"`)
|
||||||
|
}
|
||||||
|
|
||||||
// readRepoFile reads a file by its path relative to the repository
|
// readRepoFile reads a file by its path relative to the repository
|
||||||
// root.
|
// root.
|
||||||
func readRepoFile(t *testing.T, name string) string {
|
func readRepoFile(t *testing.T, name string) string {
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
#
|
#
|
||||||
# BUILDKIT_PROGRESS=plain script/lint
|
# BUILDKIT_PROGRESS=plain script/lint
|
||||||
#
|
#
|
||||||
# The lint layer must appear as executing rather than CACHED on every
|
# The check layers -- `golangci-lint config verify` and then
|
||||||
# run; see the CHECK_EPOCH comment in Dockerfile.lint.
|
# `golangci-lint run` -- must appear as executing rather than CACHED on
|
||||||
|
# every run; see the CHECK_EPOCH comment in Dockerfile.lint.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -73,7 +74,7 @@ main() {
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
require_docker
|
require_docker
|
||||||
|
|
||||||
# A fresh epoch per invocation is what forces the lint layer to
|
# A fresh epoch per invocation is what forces the check layers to
|
||||||
# execute; the layers above the ARG in Dockerfile.lint still cache,
|
# execute; the layers above the ARG in Dockerfile.lint still cache,
|
||||||
# so a run is not cold. The value must be unique per invocation, not
|
# so a run is not cold. The value must be unique per invocation, not
|
||||||
# per second: `date +%s` is second-granular, so two concurrent
|
# per second: `date +%s` is second-granular, so two concurrent
|
||||||
|
|||||||
Reference in New Issue
Block a user