Compare commits
3
Commits
f3d4b559b5
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
070090124a | ||
|
|
584444b619 | ||
|
|
b30e79ee45 |
+62
-2
@@ -1,4 +1,65 @@
|
||||
.git
|
||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||
# `certs/server.key` still ship while this file reads as solved. Only
|
||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||
# into .gitignore, where `**/` is wrong.
|
||||
#
|
||||
# Matching is case-sensitive, so secrets use character ranges rather
|
||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||
#
|
||||
# Extend with this repo's own host-built artifacts, written anchored:
|
||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||
# deletes the package directory from the context.
|
||||
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
.git/config
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||
# `services/api/.claude/` and must add its own anchored entry.
|
||||
.claude
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Re-include a committed template with a negation if the
|
||||
# build needs one: `!docs/example.env`.
|
||||
**/*.[eE][nN][vV]
|
||||
**/.[eE][nN][vV].*
|
||||
**/.[eE][nN][vV][rR][cC]
|
||||
|
||||
# Private keys and the bundles carrying them. Public certificates
|
||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||
**/*.[pP][eE][mM]
|
||||
**/*.[kK][eE][yY]
|
||||
**/*.[pP]12
|
||||
**/*.[pP][fF][xX]
|
||||
**/[iI][dD]_[rR][sS][aA]
|
||||
**/[iI][dD]_[dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Editor state: never a build input, and it churns COPY.
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/*~
|
||||
**/*.bak
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# This repo's own entries.
|
||||
.gitea
|
||||
*.md
|
||||
LICENSE
|
||||
@@ -7,4 +68,3 @@ dist
|
||||
.tool
|
||||
coverage.out
|
||||
coverage.html
|
||||
.DS_Store
|
||||
|
||||
+1
-3
@@ -47,9 +47,7 @@ checksum:
|
||||
# A snapshot is not a release and must not name itself like one. The
|
||||
# previous `{{ incpatch .Version }}-next` derived a plausible-looking
|
||||
# release number from the last tag -- and with no tags in the repo at
|
||||
# all, from goreleaser's fabricated v0.0.0. This produces the same
|
||||
# string script/version produces for an untagged build, so a snapshot
|
||||
# binary and a `make vaultik` binary of the same clean commit agree.
|
||||
# all, from goreleaser's fabricated v0.0.0.
|
||||
snapshot:
|
||||
version_template: "dev-{{ slice .FullCommit 0 12 }}"
|
||||
|
||||
|
||||
+27
-31
@@ -20,10 +20,10 @@
|
||||
# golang:1.26.1-alpine, 2026-03-17
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder
|
||||
|
||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
||||
# CLI is required.
|
||||
RUN apk add --no-cache make build-base
|
||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo,
|
||||
# and git, which derives the version below. The sqlite driver is pure Go
|
||||
# (modernc.org/sqlite), so no sqlite library or CLI is required.
|
||||
RUN apk add --no-cache make build-base git
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -47,48 +47,44 @@ COPY . .
|
||||
# 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 guard is what makes a build that omits --build-arg fail instead of
|
||||
# lie. An unset ARG is an empty string, and an empty string is a
|
||||
# perfectly stable cache key: without the guard the first such build
|
||||
# runs the checks and every one after it on an unchanged tree replays
|
||||
# these layers from cache, executes nothing, and still exits 0. Failed
|
||||
# steps are never cached, so the guard fails on EVERY invocation rather
|
||||
# than once -- a bare `docker build .` is a loud error, not a quiet
|
||||
# green. Do not give CHECK_EPOCH a default value; a default would
|
||||
# satisfy the guard with a constant and restore the hole.
|
||||
# A build that passes no CHECK_EPOCH, such as a plain `docker build .`,
|
||||
# keys these layers on the empty string, so rebuilding an unchanged
|
||||
# checkout replays them from cache and runs nothing. Only the scripts'
|
||||
# builds mean the checks executed.
|
||||
#
|
||||
# Everything above this line (apk, go.mod, `go mod download`) is
|
||||
# deliberately outside the busted range and keeps caching.
|
||||
ARG CHECK_EPOCH
|
||||
RUN [ -n "$CHECK_EPOCH" ] || exit 1
|
||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
|
||||
RUN echo "check epoch: ${CHECK_EPOCH}" && make test
|
||||
|
||||
# Version, commit and build date are computed on the host by
|
||||
# script/docker and script/cibuild (where .git exists) and passed in as
|
||||
# build args. The build context excludes .git (see .dockerignore), so
|
||||
# the build cannot derive them itself: it used to try, with `git
|
||||
# rev-parse` inside this stage, and always got "unknown". VERSION comes
|
||||
# from script/version, the source of truth shared with the Makefile, so
|
||||
# it carries the same tag / dev-<sha> / -dirty rules and a Docker image
|
||||
# reports the same string a local build of the same tree would.
|
||||
#
|
||||
# The defaults are the fallback for a bare `docker build .` that passes
|
||||
# none of them: an unset arg would otherwise stamp an empty string and
|
||||
# produce an image that cannot report its own version, commit or date.
|
||||
# They match what an out-of-git build reports elsewhere.
|
||||
# Version, commit and build date: the build args when given (script/docker
|
||||
# and script/cibuild pass the ones they compute on the host), otherwise
|
||||
# derived from the .git in the build context. The version is then `git
|
||||
# describe --tags --always`: the tag on a tagged commit, tag-N-gHASH after
|
||||
# one, the short commit when no tag is reachable. A context that carries
|
||||
# .git and still yields no version fails the build; one without .git, as
|
||||
# from a source tarball, stamps "dev" and an "unknown" commit and date.
|
||||
#
|
||||
# These ARGs sit here, after the checks, rather than at the top of the
|
||||
# stage: every commit changes their values, and a value change
|
||||
# invalidates all layers below the ARG. Declared up top they would bust
|
||||
# `go mod download`; here they only rekey this build layer, which the
|
||||
# COPY of the sources above already rebuilds on any change anyway.
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
ARG COMMIT_DATE=unknown
|
||||
ARG VERSION
|
||||
ARG COMMIT
|
||||
ARG COMMIT_DATE
|
||||
|
||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${COMMIT}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${COMMIT_DATE}'" -o /vaultik ./cmd/vaultik
|
||||
RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
||||
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||
[ "$version" = unknown ]; }; then \
|
||||
echo "the build context carries .git but yields no version" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
commit="${COMMIT:-$(git rev-parse HEAD || echo unknown)}"; \
|
||||
commit_date="${COMMIT_DATE:-$(git show -s --format=%cs HEAD || echo unknown)}"; \
|
||||
CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${version}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=${commit}' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=${commit_date}'" -o /vaultik ./cmd/vaultik
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-25
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.PHONY: all bootstrap setup check test lint lint-fix fmt fmt-check build clean deps test-coverage local install release release-snapshot docker hooks
|
||||
|
||||
# Version number, derived from git by script/version -- the tag when
|
||||
# HEAD is on one, otherwise dev-<sha>. This used to be a hardcoded
|
||||
# Version number, derived from git by script/version (`git describe
|
||||
# --tags --always --dirty`). This used to be a hardcoded
|
||||
# constant, which meant every local build claimed to be a release that
|
||||
# had never been tagged.
|
||||
VERSION := $(shell script/version)
|
||||
|
||||
@@ -577,22 +577,18 @@ complete annotated example also lives in
|
||||
|
||||
## roadmap
|
||||
|
||||
Items still to do before / shortly after 1.0. Loosely ordered by
|
||||
priority.
|
||||
Work planned after 1.0. Loosely ordered by priority.
|
||||
|
||||
### correctness and operability
|
||||
|
||||
* **Security audit of the encryption implementation.** Pre-1.0
|
||||
blocker if we're advertising "secure" at the top of this README.
|
||||
age + zstd + content-defined chunking is mostly off-the-shelf
|
||||
pieces, but the seams (key handling, recipient parsing, manifest
|
||||
trust boundary, restore-time identity validation) need an outside
|
||||
read.
|
||||
* **Error-condition tests.** Today's coverage is the happy path
|
||||
plus a few specific regressions. Need fault-injection coverage:
|
||||
network failures mid-blob, disk-full during restore, corrupted /
|
||||
truncated / missing blobs, partial uploads, kill -9 between
|
||||
manifest and db.zst.age writes.
|
||||
* **Outside security audit.** Before 1.0 the encryption and
|
||||
blob-generation code was reviewed: every bug the review found was
|
||||
fixed, and the risks it accepted are listed in
|
||||
[Accepted Risks](docs/REPOSTRUCTURE.md#accepted-risks). No outside
|
||||
audit has been done. age + zstd + content-defined chunking
|
||||
is mostly off-the-shelf pieces, but the seams (key handling,
|
||||
recipient parsing, manifest trust boundary, restore-time identity
|
||||
validation) need an outside read.
|
||||
* **Verify restored content end-to-end in CI.** The current
|
||||
integration test does this for a small synthetic snapshot but
|
||||
not at scale. A nightly job against a multi-GB representative
|
||||
@@ -615,13 +611,15 @@ priority.
|
||||
doesn't resume from where it stopped or skip already-present
|
||||
files. A `--resume` mode that checks targets before fetching
|
||||
blobs would matter for very large restores.
|
||||
* **Daemon mode.** A long-running mode that watches for file
|
||||
changes so frequent backups, such as hourly, skip the full scan.
|
||||
It adds little for the usual runs from cron every 12 to 36 hours.
|
||||
See [issue #204](https://git.eeqj.de/sneak/vaultik/issues/204).
|
||||
|
||||
### usability
|
||||
|
||||
* **Man pages and richer `--help` examples.** Cobra generates
|
||||
basic help; man pages would be a separate target.
|
||||
* **`--bwlimit` style human-readable size flags** across the
|
||||
command surface where they're currently raw integers.
|
||||
* **`vaultik snapshot diff <a> <b>`** — show which files changed
|
||||
between two snapshots without restoring either.
|
||||
* **Status reporting hook for `--cron`.** When a backup fails
|
||||
@@ -772,8 +770,9 @@ them. We provide:
|
||||
* `script/projectname` — print the project name (used for the Docker
|
||||
image tag)
|
||||
* `script/version` — print the version string to bake into the binary.
|
||||
The `Makefile`'s `LDFLAGS` call this; it is the single source of truth
|
||||
for the version. See [releasing](#releasing) for the rules.
|
||||
The `Makefile`'s `LDFLAGS` call this, and `script/docker` and
|
||||
`script/cibuild` pass its output to the image build. See
|
||||
[releasing](#releasing) for the rules.
|
||||
* `script/install-goreleaser` — install the pinned `goreleaser` into
|
||||
`.tool/bin` from a sha256-verified release archive. Idempotent, and
|
||||
called by `script/bootstrap`; the release workflow calls it directly
|
||||
@@ -865,16 +864,18 @@ them. We provide:
|
||||
module layers sit above the `ARG` and still cache, so a build is not
|
||||
cold.
|
||||
|
||||
A build that supplies no `CHECK_EPOCH` — a bare `docker build .` or
|
||||
`docker build -f Dockerfile.lint .` — fails rather than lying. An
|
||||
unset `ARG` is an empty string and an empty string is a stable cache
|
||||
key, so without a guard such a build would serve every check layer
|
||||
from cache, execute nothing, and still exit 0. Each file therefore
|
||||
asserts the value is non-empty before running anything, and because
|
||||
failed steps are never cached that assertion fires on every
|
||||
invocation rather than once. Use `script/lint`, `script/docker` or
|
||||
`script/cibuild`, which pass the arg; a bare `docker build` is a loud
|
||||
error.
|
||||
A `docker build -f Dockerfile.lint .` that supplies no `CHECK_EPOCH`
|
||||
fails rather than lying. An unset `ARG` is an empty string and an
|
||||
empty string is a stable cache key, so without a guard such a build
|
||||
would serve the lint layer from cache, execute nothing, and still exit
|
||||
0. `Dockerfile.lint` therefore asserts the value is non-empty before
|
||||
running anything, and because failed steps are never cached that
|
||||
assertion fires on every invocation rather than once. The product
|
||||
`Dockerfile` has no such guard, because a plain `docker build .` must
|
||||
succeed: without `CHECK_EPOCH`, rebuilding an unchanged checkout
|
||||
replays its check layers from cache. Use `script/lint`,
|
||||
`script/docker` or `script/cibuild`, which pass the arg, when the
|
||||
checks must run.
|
||||
* `script/precommit` — pre-commit gate: `go mod tidy` + `go fmt` (must
|
||||
not change files), then `script/check`
|
||||
* `script/install-precommit` — install the git pre-commit hook that
|
||||
@@ -885,24 +886,33 @@ them. We provide:
|
||||
### version numbers
|
||||
|
||||
The version a binary reports comes from git, not from a constant in a
|
||||
file. `script/version` decides it, and everything that stamps a binary
|
||||
agrees with it:
|
||||
file. It is `git describe --tags --always --dirty`, which
|
||||
`script/version` runs for the `Makefile`, `script/docker` and
|
||||
`script/cibuild`:
|
||||
|
||||
* `HEAD` is exactly on a tag → that tag with a leading `v` stripped, so
|
||||
the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
|
||||
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser` strips the prefix the
|
||||
same way.
|
||||
* anything else → `dev-<12 chars of the commit sha>`.
|
||||
* either, with uncommitted changes to tracked files → a `-dirty`
|
||||
* `HEAD` is exactly on a tag → that tag, such as `v1.0.0`.
|
||||
* a commit after a tag → `<tag>-<N>-g<short sha>`.
|
||||
* no tag reachable → the short commit sha.
|
||||
* any of these, with uncommitted changes to tracked files → a `-dirty`
|
||||
suffix, because a modified checkout of a tag is not that tag.
|
||||
|
||||
A build that is not a release never names itself like one. `vaultik
|
||||
version` says so in as many words on a development build, and
|
||||
`goreleaser --snapshot` stamps the same `dev-<sha>` string rather than
|
||||
inventing the next patch number. If `script/version` cannot be run at
|
||||
all, `make` stops with an error instead of building an unversioned
|
||||
binary, and a binary that somehow carries an empty version string still
|
||||
reports itself as a development build.
|
||||
A `docker build .` of a clone, with no build arguments, runs the same
|
||||
`git describe` (without `--dirty`) on the `.git` in its build context,
|
||||
so it stamps the same value for a clean commit; the build fails if the
|
||||
context carries `.git` and no version comes out. A binary built without
|
||||
git metadata reports `dev`.
|
||||
|
||||
`goreleaser` stamps a release binary with the tag minus its leading
|
||||
`v`, so the tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive
|
||||
name `vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser --snapshot` stamps
|
||||
`dev-<12 chars of the commit sha>` rather than inventing the next patch
|
||||
number. `vaultik version` calls a build a development build when its
|
||||
version is `dev`, `dev-<sha>`, the short commit sha or
|
||||
`<tag>-<N>-g<short sha>`, with or without `-dirty`; only a plain tag,
|
||||
such as `v1.0.0` or `1.0.0`, is a release. If `script/version` cannot
|
||||
be run at all, `make` stops with an error instead of building an
|
||||
unversioned binary, and a binary that somehow carries an empty version
|
||||
string still reports itself as a development build.
|
||||
|
||||
### cutting a release
|
||||
|
||||
|
||||
@@ -14,14 +14,11 @@ pre-1.0
|
||||
|
||||
# Next Step
|
||||
|
||||
Define the remaining scope for the first tagged release under the 1.0.0
|
||||
milestone, then cut that tag. The mechanism to cut it now exists and is
|
||||
exercised; what is left is the scope decision, which is the owner's.
|
||||
This step deliberately names one version number: it previously said
|
||||
"cut v0.1.0" while the `Makefile` baked in `1.0.0-rc.1` and the issue
|
||||
milestone said 1.0.0, and three different answers to "what is the next
|
||||
release" is exactly the contradiction
|
||||
[issue #65](https://git.eeqj.de/sneak/vaultik/issues/65) was filed over.
|
||||
The 1.0 work is complete on `next`: the scope settled on
|
||||
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) was the work
|
||||
already planned for 1.0, and all of it has landed. The mechanism to cut
|
||||
the tag exists and is exercised; what is left is merging `next` to
|
||||
`main` and tagging, both the owner's.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
@@ -693,4 +690,5 @@ release" is exactly the contradiction
|
||||
|
||||
# Future Steps
|
||||
|
||||
None queued; the release-scoping item is now the Next Step.
|
||||
Work planned after 1.0 is listed in the README
|
||||
[roadmap](README.md#roadmap).
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
// 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", so an operator cannot tell which source produced a given
|
||||
// backup. .dockerignore excludes .git, so the build cannot derive the
|
||||
// commit itself; the values must be computed on the host and passed in.
|
||||
// unknown" or a version of "dev", so an operator cannot tell which
|
||||
// source produced a given backup. The build takes the values as build
|
||||
// args, which script/docker computes on the host, and otherwise derives
|
||||
// them from the .git in its context.
|
||||
//
|
||||
// These are parses of the committed files, for the same reason the lint
|
||||
// guards next door are: shelling out to docker would nest a build
|
||||
@@ -32,35 +33,41 @@ func versionArgs() []string {
|
||||
}
|
||||
|
||||
// TestProductDockerfileTakesVersionAsBuildArgs fails unless the build
|
||||
// declares each version arg and stamps it into the binary by ldflag
|
||||
// reference, rather than computing it in the container.
|
||||
// declares each version arg, with no default, and stamps it into the
|
||||
// binary whenever it is given, ahead of the value derived in the
|
||||
// container.
|
||||
func TestProductDockerfileTakesVersionAsBuildArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
found := instructions(t, productDockerfile)
|
||||
|
||||
for _, arg := range versionArgs() {
|
||||
require.GreaterOrEqual(t, indexOf(found, "ARG "+arg), 0,
|
||||
"%s must declare `ARG %s` so the host can pass it in",
|
||||
productDockerfile, arg)
|
||||
require.Contains(t, found, "ARG "+arg,
|
||||
"%s must declare `ARG %s`, with no default, so the host can"+
|
||||
" pass it in", productDockerfile, arg)
|
||||
|
||||
assertLdflagReferences(t, found, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProductDockerfileDoesNotDeriveVersionItself is the anti-regression
|
||||
// for the original defect: the container ran `git rev-parse`, but .git
|
||||
// is not in the build context, so it always resolved to "unknown". No
|
||||
// git command may reach into a build that cannot see the history.
|
||||
func TestProductDockerfileDoesNotDeriveVersionItself(t *testing.T) {
|
||||
// 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, and fails rather than
|
||||
// stamp "dev" when that .git yields no version.
|
||||
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := instructionText(readRepoFile(t, productDockerfile))
|
||||
found := instructions(t, productDockerfile)
|
||||
|
||||
assert.NotContains(t, text, "git ",
|
||||
"%s must not run git: .git is excluded from the build context, so"+
|
||||
" any value it derives is wrong. Pass version, commit and date"+
|
||||
" in as build args instead.", productDockerfile)
|
||||
buildAt := indexContaining(found, "go build")
|
||||
require.GreaterOrEqual(t, buildAt, 0, "%s must build", productDockerfile)
|
||||
|
||||
assert.Contains(t, found[buildAt], "git describe --tags --always",
|
||||
"%s must derive the version from git when no VERSION is given",
|
||||
productDockerfile)
|
||||
assert.Contains(t, found[buildAt], "[ -e .git ]",
|
||||
"%s must fail when the context carries .git but yields no version",
|
||||
productDockerfile)
|
||||
}
|
||||
|
||||
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
||||
@@ -78,25 +85,25 @@ func TestDockerScriptComputesVersionOnTheHost(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.Contains(t, script, "/version",
|
||||
"%s must take VERSION from script/version, the source of truth"+
|
||||
" shared with the Makefile", dockerScript)
|
||||
"%s must take VERSION from script/version, as the Makefile does",
|
||||
dockerScript)
|
||||
}
|
||||
|
||||
// assertLdflagReferences fails unless some build instruction stamps the
|
||||
// named variable from the ARG (a ${arg} reference), not from a value
|
||||
// computed inside the container.
|
||||
// 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+"}") {
|
||||
strings.Contains(instruction, "${"+arg+":-") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
assert.Fail(t, "version arg is declared but never stamped",
|
||||
"the go build in %s must reference ${%s} in its ldflags, or the"+
|
||||
" arg is passed and discarded", productDockerfile, arg)
|
||||
"the go build in %s must use ${%s:-...}, or the arg is passed and"+
|
||||
" discarded", productDockerfile, arg)
|
||||
}
|
||||
|
||||
@@ -152,20 +152,21 @@ func TestLintDockerfileVerifiesTheLinterConfig(t *testing.T) {
|
||||
assertEpochExpandedInto(t, found, verify)
|
||||
}
|
||||
|
||||
// TestProductDockerfileCannotBeCachedGreen holds the same line for the
|
||||
// checks that remain in the product image build.
|
||||
func TestProductDockerfileCannotBeCachedGreen(t *testing.T) {
|
||||
// TestProductDockerfileKeysChecksOnTheEpoch holds the same line for the
|
||||
// checks that remain in the product image build, but without the guard:
|
||||
// a plain `docker build .` with no build arguments must succeed.
|
||||
func TestProductDockerfileKeysChecksOnTheEpoch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
found := instructions(t, productDockerfile)
|
||||
|
||||
argAt := indexOf(found, checkEpochARG)
|
||||
require.GreaterOrEqual(t, argAt, 0,
|
||||
"%s must declare `%s` with no default value",
|
||||
productDockerfile, checkEpochARG)
|
||||
"%s must declare `%s`", productDockerfile, checkEpochARG)
|
||||
|
||||
assert.GreaterOrEqual(t, indexOf(found, checkEpochGuard), argAt,
|
||||
"%s must guard against an empty CHECK_EPOCH", productDockerfile)
|
||||
assert.Equal(t, -1, indexOf(found, checkEpochGuard),
|
||||
"%s must not refuse an empty CHECK_EPOCH: a plain `docker build .`"+
|
||||
" must succeed", productDockerfile)
|
||||
|
||||
assertEpochExpandedInto(t, found[argAt:], "make fmt-check")
|
||||
assertEpochExpandedInto(t, found[argAt:], "make test")
|
||||
|
||||
+18
-10
@@ -3,6 +3,7 @@
|
||||
package globals
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -10,12 +11,11 @@ import (
|
||||
// Appname is the application name, populated from main().
|
||||
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
|
||||
|
||||
// DevVersion is the version a binary reports when it was not built
|
||||
// from a tagged commit. script/version emits either this exact string
|
||||
// (outside a git checkout) or this string followed by "-" and the
|
||||
// commit it was built from, and goreleaser's snapshot template matches
|
||||
// that shape. It is deliberately not a number: a build that is not a
|
||||
// release must not name itself like one.
|
||||
// DevVersion is the version a binary reports when it was built without
|
||||
// git metadata: script/version emits it outside a git checkout, and an
|
||||
// unstamped `go build` keeps it. goreleaser's snapshot template stamps
|
||||
// it followed by "-" and the commit it was built from. It is
|
||||
// deliberately not a number.
|
||||
const DevVersion = "dev"
|
||||
|
||||
// Version is the application version, populated from main().
|
||||
@@ -60,9 +60,12 @@ func New() (*Globals, error) {
|
||||
}
|
||||
|
||||
// IsDevVersion reports whether v names a development build rather than
|
||||
// a release. Both "dev" and "dev-<sha>" (and its "-dirty" variant)
|
||||
// count: a caller that compares against "dev" exactly would treat every
|
||||
// commit-stamped development build as a release.
|
||||
// a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
|
||||
// does what `git describe --tags --always --dirty` gives a make or
|
||||
// docker build of an untagged commit: the bare short commit, or
|
||||
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty"
|
||||
// counts, a modified checkout of a tag ("v1.0.0-dirty") included.
|
||||
// A plain tag such as "v1.0.0" or "1.0.0" is a release.
|
||||
//
|
||||
// The empty string counts too. Nothing that knows its version reports
|
||||
// no version, so an empty Version means the stamping failed, and the
|
||||
@@ -71,7 +74,12 @@ func New() (*Globals, error) {
|
||||
// case; this is the second line of defence, for a binary linked by
|
||||
// something other than the Makefile.
|
||||
func IsDevVersion(v string) bool {
|
||||
return v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-")
|
||||
if v == "" || v == DevVersion || strings.HasPrefix(v, DevVersion+"-") ||
|
||||
strings.HasSuffix(v, "-dirty") {
|
||||
return true
|
||||
}
|
||||
|
||||
return regexp.MustCompile(`^(.+-[0-9]+-g)?[0-9a-f]+$`).MatchString(v)
|
||||
}
|
||||
|
||||
// shortCommitLen is the number of commit-hash characters ShortCommit keeps.
|
||||
|
||||
@@ -34,10 +34,11 @@ func TestGlobalsNew(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIsDevVersion covers the boundary that matters: everything
|
||||
// script/version and goreleaser's snapshot template can emit for an
|
||||
// untagged build must be recognised as a development build, and a real
|
||||
// tag must not be. A plain equality check against "dev" used to decide
|
||||
// this, which classified every commit-stamped dev build as a release.
|
||||
// script/version, a plain docker build and goreleaser's snapshot
|
||||
// template can emit for an untagged build must be recognised as a
|
||||
// development build, and a real tag must not be. A plain equality check
|
||||
// against "dev" used to decide this, which classified every
|
||||
// commit-stamped dev build as a release.
|
||||
func TestIsDevVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -49,8 +50,17 @@ func TestIsDevVersion(t *testing.T) {
|
||||
{"dev", true},
|
||||
{"dev-b6e4a218a39e", true},
|
||||
{"dev-b6e4a218a39e-dirty", true},
|
||||
// What a tagged build produces (script/version strips the
|
||||
// leading "v", matching goreleaser's .Version).
|
||||
// What `git describe --tags --always --dirty` produces with no
|
||||
// tag reachable, and on a commit after a tag.
|
||||
{"877eb2f", true},
|
||||
{"877eb2f-dirty", true},
|
||||
{"v1.0.0-3-g877eb2f", true},
|
||||
{"v1.0.0-3-g877eb2f-dirty", true},
|
||||
{"1.0.0-rc.1-12-g877eb2f", true},
|
||||
// A tagged commit with uncommitted changes is not that tag.
|
||||
{"v1.0.0-dirty", true},
|
||||
// What a tagged build produces (goreleaser's .Version strips
|
||||
// the leading "v"; script/version keeps it).
|
||||
{"1.0.0", false},
|
||||
{"0.1.0", false},
|
||||
{"1.0.0-rc.1", false},
|
||||
|
||||
@@ -680,18 +680,10 @@ func faultScannerFactory(
|
||||
|
||||
// Scenario 5: the restore target runs out of space mid-file. Restore
|
||||
// must fail with an out-of-space error, and must not leave a truncated
|
||||
// file at the target path presenting as a complete restore. Restore
|
||||
// today writes each file straight to its final path and does not remove
|
||||
// it when a write fails, so the truncated file survives; deleting it is
|
||||
// tracked by https://git.eeqj.de/sneak/vaultik/issues/163. Skipped until
|
||||
// that lands, so the destination assertion below is recorded rather than
|
||||
// dropped.
|
||||
// file at the target path presenting as a complete restore.
|
||||
//
|
||||
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||
func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
t.Skip("blocked on https://git.eeqj.de/sneak/vaultik/issues/163: " +
|
||||
"a disk-full write leaves a truncated file at the target path " +
|
||||
"instead of removing it")
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
osFS := afero.NewOsFs()
|
||||
@@ -717,10 +709,10 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
id := fullFaultBackup(ctx, t, osFS, inner, cfg, repos, dataDir, dbPath, "diskfull")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
// Restore onto a filesystem that allows only a few bytes of file
|
||||
// content: enough to create files, far too little to hold them.
|
||||
// Restore onto a target that allows only a few bytes of file content:
|
||||
// enough to create files, far too little to hold them.
|
||||
budget := int64(8)
|
||||
quota := "aFS{Fs: osFS, remaining: &budget}
|
||||
quota := "aFS{Fs: osFS, dir: restoreDir, remaining: &budget}
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, inner, nil, quota)
|
||||
err = v.Restore(&vaultik.RestoreOptions{SnapshotID: id, TargetDir: restoreDir})
|
||||
@@ -754,21 +746,26 @@ func assertRestoredTree(
|
||||
// budget is exhausted, mirroring a real ENOSPC.
|
||||
var errNoSpace = errors.New("no space left on device")
|
||||
|
||||
// quotaFS is an afero.Fs whose files may write only a fixed total number
|
||||
// of content bytes before failing, simulating a full restore target. It
|
||||
// wraps the interface so every method except Create delegates to the
|
||||
// real filesystem; only file writes are capped.
|
||||
// quotaFS is an afero.Fs on which files opened under dir may write only
|
||||
// a fixed total number of content bytes before failing, simulating a full
|
||||
// restore target. Every other method, and every file outside dir, goes
|
||||
// straight to the real filesystem: restore also writes the decrypted
|
||||
// metadata database under $TMPDIR through this filesystem, and capping
|
||||
// that would fail the restore before it wrote anything to the target.
|
||||
type quotaFS struct {
|
||||
afero.Fs
|
||||
|
||||
dir string
|
||||
remaining *int64
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.Fs.Create's signature requires returning afero.File.
|
||||
func (q *quotaFS) Create(name string) (afero.File, error) {
|
||||
f, err := q.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
//nolint:ireturn // afero.Fs.OpenFile's signature requires returning afero.File.
|
||||
func (q *quotaFS) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
f, err := q.Fs.OpenFile(name, flag, perm)
|
||||
if err != nil || !strings.HasPrefix(name, q.dir) {
|
||||
return f, err
|
||||
}
|
||||
|
||||
return "aFile{File: f, remaining: q.remaining}, nil
|
||||
|
||||
+9
-9
@@ -24,9 +24,11 @@ main() {
|
||||
# value is what forces those layers to re-run: without it an
|
||||
# unchanged tree replays them from cache, the checks never execute,
|
||||
# and the build still exits 0. Each ARG sits immediately above the
|
||||
# check RUNs, so dependency and module layers still cache. Both
|
||||
# Dockerfiles also refuse to build at all when CHECK_EPOCH is empty,
|
||||
# so a missing value fails loudly here rather than passing quietly.
|
||||
# check RUNs, so dependency and module layers still cache.
|
||||
# Dockerfile.lint also refuses to build at all when CHECK_EPOCH is
|
||||
# empty, so a missing value fails its build loudly rather than passing
|
||||
# quietly; the product Dockerfile does not, because a plain `docker
|
||||
# build .` must succeed.
|
||||
#
|
||||
# The value must be unique per invocation, not per second. `date +%s`
|
||||
# is second-granular, so two concurrent invocations in the same
|
||||
@@ -57,12 +59,10 @@ main() {
|
||||
--build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .
|
||||
|
||||
# Version, commit and build date are computed here on the host, the
|
||||
# same way script/docker does, and passed into the product build so
|
||||
# the CI-built image reports its real source. The build context
|
||||
# excludes .git (see .dockerignore), so the build cannot derive them
|
||||
# itself; without these it would stamp the Dockerfile's dev/unknown
|
||||
# fallbacks. VERSION comes from script/version, the source of truth
|
||||
# shared with the Makefile.
|
||||
# same way script/docker does, and passed into the product build,
|
||||
# where they take precedence over what the build would derive from
|
||||
# the .git in its context. VERSION comes from script/version, as in
|
||||
# the Makefile.
|
||||
version="$("$ROOT/script/version")"
|
||||
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
|
||||
|
||||
+10
-13
@@ -1,7 +1,8 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# Generic: needs no adaptation.
|
||||
# The tag comes from script/projectname. Unlike the canonical copy in
|
||||
# sneak/prompts, it passes a fresh CHECK_EPOCH instead of --no-cache, and
|
||||
# COMMIT and COMMIT_DATE as well as VERSION.
|
||||
#
|
||||
# This builds the PRODUCT image only, and the product Dockerfile has no
|
||||
# lint stage: linting lives in Dockerfile.lint and is run by
|
||||
@@ -21,19 +22,15 @@ main() {
|
||||
# comments there. This script is not the CI gate, but a local build
|
||||
# is almost always warm, so without this it would report a green the
|
||||
# tree had not earned and the two entrypoints would disagree about
|
||||
# whether the tree is clean. The Dockerfile now refuses to build
|
||||
# without a non-empty value, so this is required, not optional.
|
||||
# whether the tree is clean.
|
||||
epoch="$(date +%s%N)$$"
|
||||
|
||||
# Version, commit and build date are computed here on the host,
|
||||
# where .git exists, and passed into the build. The build context
|
||||
# excludes .git (see .dockerignore), so the container cannot derive
|
||||
# them itself -- it used to try and always got "unknown", giving
|
||||
# every image a "commit: unknown" it could not be traced from.
|
||||
# VERSION comes from script/version, the source of truth shared with
|
||||
# the Makefile, so a Docker build reports the same string (tag,
|
||||
# dev-<sha>, or a -dirty variant) that a local build of the same
|
||||
# tree would.
|
||||
# Version, commit and build date are computed here on the host and
|
||||
# passed into the build, where they take precedence over what the
|
||||
# build would derive from the .git in its context. VERSION comes
|
||||
# from script/version, as in the Makefile, so the image reports the
|
||||
# same string, -dirty included, that a local build of the same tree
|
||||
# would.
|
||||
version="$("$SCRIPT_DIR/version")"
|
||||
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
commit_date="$(git show -s --format=%cs HEAD 2>/dev/null || echo unknown)"
|
||||
|
||||
+16
-60
@@ -1,73 +1,29 @@
|
||||
#!/bin/sh
|
||||
# script/version: output the version string to bake into the binary.
|
||||
# Our own extension to scripts-to-rule-them-all, and the single source
|
||||
# of truth for the version: the Makefile's LDFLAGS call this rather
|
||||
# than carrying a hardcoded constant, which is what used to make every
|
||||
# local build claim to be 1.0.0-rc.1 regardless of git state.
|
||||
# Our own extension to scripts-to-rule-them-all. The Makefile's LDFLAGS
|
||||
# call this rather than carrying a hardcoded constant, which is what used
|
||||
# to make every local build claim to be 1.0.0-rc.1 regardless of git
|
||||
# state. script/docker and script/cibuild pass its output to the image
|
||||
# build; given no version, the image build runs `git describe --tags
|
||||
# --always` itself, without `--dirty`.
|
||||
#
|
||||
# The rules, in order:
|
||||
# The version is `git describe --tags --always --dirty`: the tag on a
|
||||
# tagged commit, tag-N-gHASH on a commit after one, the short commit
|
||||
# when no tag is reachable, each with a "-dirty" suffix when tracked
|
||||
# files have uncommitted changes. Untracked files are ignored: a stray
|
||||
# scratch file does not change what was compiled. Outside a git checkout
|
||||
# (release tarball, `go install`), or in one with no commits, it is
|
||||
# "dev".
|
||||
#
|
||||
# HEAD is exactly on an annotated or lightweight tag
|
||||
# -> that tag, with a leading "v" stripped
|
||||
# anything else
|
||||
# -> "dev-<12 chars of HEAD>"
|
||||
# not a git checkout at all (release tarball, `go install`)
|
||||
# -> "dev"
|
||||
#
|
||||
# Either of the first two gains a "-dirty" suffix when tracked files
|
||||
# have uncommitted changes, because a modified checkout of v1.0.0 is
|
||||
# not v1.0.0. Untracked files are ignored, matching `git describe
|
||||
# --dirty`: a stray scratch file does not change what was compiled.
|
||||
#
|
||||
# The "v" is stripped so that a `make` build and a goreleaser build of
|
||||
# the same tagged commit report the *same* string: goreleaser's
|
||||
# {{ .Version }} is the tag without the prefix, and the release archive
|
||||
# names are built from it. A tag named `v1.0.0` therefore produces
|
||||
# `vaultik 1.0.0`, matching `vaultik_1.0.0_linux_amd64.tar.gz`.
|
||||
#
|
||||
# Nothing here ever invents a version number. An untagged build says so
|
||||
# and names the commit it was built from; it does not round up to the
|
||||
# nearest plausible release.
|
||||
# Nothing here ever invents a version number.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Length of the commit prefix in a dev version. Matches
|
||||
# globals.ShortCommit, so `vaultik version` shows the same 12 chars in
|
||||
# its version line and its commit line.
|
||||
SHORT_LEN=12
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "dev"
|
||||
return 0
|
||||
fi
|
||||
|
||||
dirty=""
|
||||
if [ -n "$(git status --porcelain --untracked-files=no 2>/dev/null)" ]; then
|
||||
dirty="-dirty"
|
||||
fi
|
||||
|
||||
# --exact-match so a *descendant* of a tag is not reported as that
|
||||
# tag. Plain `git describe --tags` would call a commit 40 patches
|
||||
# past v1.0.0 "v1.0.0-40-gabc1234", and the leading token of that is
|
||||
# a released version the build is not.
|
||||
tag="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||
if [ -n "$tag" ]; then
|
||||
echo "${tag#v}${dirty}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sha="$(git rev-parse "--short=$SHORT_LEN" HEAD 2>/dev/null || true)"
|
||||
if [ -z "$sha" ]; then
|
||||
# A repo with no commits at all.
|
||||
echo "dev"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "dev-${sha}${dirty}"
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
echo "${version:-dev}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user