Compare commits
4
Commits
next
..
8fceeca202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fceeca202 | ||
|
|
4900837068 | ||
|
|
294474a2cd | ||
|
|
04d9a6bf37 |
+22
-31
@@ -1,16 +1,12 @@
|
|||||||
# In every stage ARG CACHEBUST comes right after the steps that install
|
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||||
# system packages and download Go's modules, or right after FROM in a
|
# on every build, which reruns every RUN step after it. The steps that
|
||||||
# stage with neither, and every step that lints, tests or compiles
|
# compile mount Go's build cache, which survives from build to build.
|
||||||
# comes after it. script/ passes a new value on every build, which
|
|
||||||
# reruns every RUN step after it. Docker keeps the layers above it
|
|
||||||
# until the base image, their own lines or go.mod and go.sum change.
|
|
||||||
# The steps that compile mount Go's build cache, and golangci-lint run
|
|
||||||
# also mounts golangci-lint's; both survive from build to build.
|
|
||||||
|
|
||||||
# Lint phase. script/lint builds it alone. The linter is run directly:
|
# Lint phase. script/lint builds it alone. The linter is run directly:
|
||||||
# `make lint` and script/lint are themselves a docker build.
|
# `make lint` and script/lint are themselves a docker build.
|
||||||
# golangci/golangci-lint:v2.14.0, 2026-10-15
|
# golangci/golangci-lint:v2.14.0, 2026-10-15
|
||||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
# The linter compiles every package, and govips needs the libvips
|
# The linter compiles every package, and govips needs the libvips
|
||||||
# headers for that. REPO_POLICIES.md has the lint phase install them
|
# headers for that. REPO_POLICIES.md has the lint phase install them
|
||||||
@@ -22,29 +18,25 @@ RUN apt-get update \
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
ARG CACHEBUST
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/root/.cache/golangci-lint \
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase. script/test builds it alone. -race needs cgo and so a C
|
# Test phase. script/test builds it alone. -race needs cgo and so a C
|
||||||
# compiler, which the Debian Go image ships and the alpine one does not.
|
# compiler, which the Debian Go image ships and the alpine one does not.
|
||||||
# golang:1.25.4-trixie, 2026-10-15
|
# golang:1.25.4-trixie, 2026-10-15
|
||||||
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
|
FROM golang@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS test
|
||||||
|
ARG CACHEBUST
|
||||||
# govips compiles against the libvips and libheif headers, and the tests
|
|
||||||
# need libvips' JPEG XL support; libvips-dev brings all three. The tests
|
|
||||||
# also encode AVIF, which needs libheif's AVIF encoder,
|
|
||||||
# libheif-plugin-aomenc: Debian only recommends it, so it is named here.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends libvips-dev libheif-plugin-aomenc \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# script/bootstrap --cgo installs the build dependencies (the libvips
|
||||||
|
# and libheif headers, and libvips' JPEG XL support, which the tests
|
||||||
|
# need) and downloads the Go modules.
|
||||||
|
COPY script/ ./script/
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN script/bootstrap --cgo
|
||||||
ARG CACHEBUST
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Without -v first; on a failure, again with -v for the details, and
|
# Without -v first; on a failure, again with -v for the details, and
|
||||||
@@ -60,23 +52,22 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
|||||||
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -parallel 4 -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from the two phases above: these copies
|
# Build stage. Nothing is wanted from the two phases above: these copies
|
||||||
# make BuildKit build them first, so nothing after them runs unless lint
|
# make BuildKit build them first, so this stage runs only when lint and
|
||||||
# and test passed.
|
# test passed.
|
||||||
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too
|
||||||
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||||
|
|
||||||
# git for the version, and for govips a C compiler, pkg-config and the
|
|
||||||
# libvips and libheif headers
|
|
||||||
RUN apk add --no-cache git build-base pkgconfig vips-dev libheif-dev
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
ARG CACHEBUST
|
ARG CACHEBUST
|
||||||
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=test /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Build dependencies, git among them, and Go modules, as in the test
|
||||||
|
# phase
|
||||||
|
COPY script/ ./script/
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN script/bootstrap --cgo
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
RUN git config --system --add safe.directory /src
|
RUN git config --system --add safe.directory /src
|
||||||
|
|
||||||
@@ -111,6 +102,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
|||||||
# the libvips and musl it was built with.
|
# the libvips and musl it was built with.
|
||||||
# alpine:3.22, 2026-10-08
|
# alpine:3.22, 2026-10-08
|
||||||
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
||||||
|
ARG CACHEBUST
|
||||||
|
|
||||||
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL
|
||||||
# support, without which pixad does not start.
|
# support, without which pixad does not start.
|
||||||
@@ -121,7 +113,6 @@ RUN apk add --no-cache \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
tzdata \
|
tzdata \
|
||||||
su-exec
|
su-exec
|
||||||
ARG CACHEBUST
|
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
|
|||||||
support, which on Alpine is the `vips-jxl` package and which the nix and brew
|
support, which on Alpine is the `vips-jxl` package and which the nix and brew
|
||||||
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
|
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
|
||||||
on. Building pixa also needs the development files of libvips and libheif,
|
on. Building pixa also needs the development files of libvips and libheif,
|
||||||
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these;
|
`pkg-config` and a C compiler. `script/bootstrap --cgo` installs all of these,
|
||||||
the `Dockerfile` installs its own packages. Plain `script/bootstrap`, which
|
as the `Dockerfile` does where it compiles pixa. Plain `script/bootstrap`, which
|
||||||
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
|
`script/setup` and `script/cibuild` run, installs git, make and Go, and Node,
|
||||||
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
|
Yarn and the prettier pinned in `yarn.lock` for formatting the markdown, but
|
||||||
none of the C libraries: the checks compile pixa in Docker. Docker itself must
|
none of the C libraries: the checks compile pixa in Docker. Docker itself must
|
||||||
@@ -199,11 +199,7 @@ into place, so a crash leaves no file under its own name with part of its data.
|
|||||||
When pixa starts, and every five minutes after, it removes each cached source or
|
When pixa starts, and every five minutes after, it removes each cached source or
|
||||||
transformed image whose file is empty, or not the size the database records for
|
transformed image whose file is empty, or not the size the database records for
|
||||||
it, with a warning naming the file; the next request for it fetches the source
|
it, with a warning naming the file; the next request for it fetches the source
|
||||||
or converts the image again. Looking for those files reads the database once for
|
or converts the image again.
|
||||||
every cached file, so pixa skips each five-minute look that would start less
|
|
||||||
than ten times as long after the last look ended as that look took. After a look
|
|
||||||
that took one minute, the next one starts 10 to 15 minutes after that look
|
|
||||||
ended.
|
|
||||||
|
|
||||||
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
||||||
@@ -698,12 +694,7 @@ Key settings in more detail:
|
|||||||
and generator pages. Default: empty
|
and generator pages. Default: empty
|
||||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
|
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection, added
|
||||||
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
|
to the always-enforced built-in ranges (loopback, private, link-local, CGNAT,
|
||||||
benchmark, NAT64, and the like); an invalid CIDR aborts startup. An IPv6
|
benchmark, NAT64, and the like); an invalid CIDR aborts startup
|
||||||
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
|
|
||||||
address it holds, so an IPv6 range such as `::/0` refuses neither it nor a
|
|
||||||
plain IPv4 address. An entry written in that form is read as the IPv4 range it
|
|
||||||
covers, so `::ffff:10.0.0.0/104` refuses what `10.0.0.0/8` refuses; such an
|
|
||||||
entry shorter than `/96` covers more than IPv4 addresses and aborts startup
|
|
||||||
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
|
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
|
||||||
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
|
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
|
||||||
of these ranges; the logged and login-recorded client address is then the
|
of these ranges; the logged and login-recorded client address is then the
|
||||||
@@ -713,22 +704,16 @@ Key settings in more detail:
|
|||||||
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a proxy on a
|
||||||
private network; an explicitly empty list (`[]`) trusts no one, and an
|
private network; an explicitly empty list (`[]`) trusts no one, and an
|
||||||
explicit list replaces the default. An invalid CIDR aborts startup. An IPv6
|
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
||||||
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
|
to the address pixa sees for requests that come through your proxy, such as
|
||||||
address it holds, so an IPv6 range such as `::/0` trusts neither it nor a
|
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
||||||
plain IPv4 address. An entry written in that form is read as the IPv4 range it
|
(see the login limit and `image_requests_per_minute` under Routes). For a
|
||||||
covers, so `::ffff:10.0.0.0/104` trusts what `10.0.0.0/8` trusts; such an
|
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
||||||
entry shorter than `/96` covers more than IPv4 addresses and aborts startup.
|
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
||||||
Set this to the address pixa sees for requests that come through your proxy,
|
bridge), not the proxy's own address; a proxy that connects through another of
|
||||||
such as `172.17.0.1/32`, when the defaults do not cover it, or to trust
|
the host's addresses is seen with that address. To be sure which address it
|
||||||
nothing else (see the login limit and `image_requests_per_minute` under
|
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
||||||
Routes). For a proxy on the Docker host that connects to pixa over
|
through the proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
`127.0.0.1`, that address is the gateway of the container's Docker network
|
|
||||||
(`172.17.0.1` on the default bridge), not the proxy's own address; a proxy
|
|
||||||
that connects through another of the host's addresses is seen with that
|
|
||||||
address. To be sure which address it is, set this to `[]` (or
|
|
||||||
`PIXA_TRUSTED_PROXIES` to empty), send a request through the proxy, and read
|
|
||||||
`remoteIP` in pixa's request log line for it
|
|
||||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
as a duration such as `30s` (the default) or `2m`
|
as a duration such as `30s` (the default) or `2m`
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
@@ -837,25 +822,19 @@ provide:
|
|||||||
`script/precommit`
|
`script/precommit`
|
||||||
|
|
||||||
Every `docker build` in these scripts passes a new value of the `CACHEBUST`
|
Every `docker build` in these scripts passes a new value of the `CACHEBUST`
|
||||||
build argument, which every stage of the `Dockerfile` declares, so every `RUN`
|
build argument, which every stage of the `Dockerfile` declares first, so every
|
||||||
step after that line, the lint and test steps included, runs on every build
|
`RUN` step, the lint and test phases included, runs on every build instead of
|
||||||
instead of coming from the build cache. Above the line are the steps that
|
coming from the build cache. Go's build cache is kept from build to build: the
|
||||||
install system packages and download the Go modules; Docker keeps their layers
|
steps that compile mount it, so a package whose inputs are unchanged is not
|
||||||
until the base image, the step itself, `go.mod` or `go.sum` changes. Go's build
|
compiled again, and both `go test` runs pass `-count=1` so that no stored test
|
||||||
cache is kept from build to build: the steps that compile mount it, so a package
|
result is replayed. The `test` phase runs on the Debian Go image, with Debian's
|
||||||
whose inputs are unchanged is not compiled again, and both `go test` runs pass
|
libvips and libheif; the image pixa ships is built and runs on Alpine.
|
||||||
`-count=1` so that no stored test result is replayed. The lint step also mounts
|
`script/check`, `script/cibuild`, `script/docker`, `script/lint`, `script/test`,
|
||||||
golangci-lint's cache, which reuses a package's findings only when its files,
|
`script/setup` and `script/install-precommit` are the standard copies from
|
||||||
those of the packages it imports, `go.mod`, the linters and their settings in
|
`sneak/prompts`, kept identical to them. `script/fmt` and `script/fmt-check` are
|
||||||
`.golangci.yml`, and the golangci-lint version are all unchanged. The `test`
|
the standard copies with pixa's `gofmt` step kept before prettier. prettier
|
||||||
phase runs on the Debian Go image, with Debian's libvips and libheif; the image
|
formats the markdown only, not the HTML templates, as it cannot parse a Go
|
||||||
pixa ships is built and runs on Alpine. `script/check`, `script/cibuild`,
|
template action inside a tag.
|
||||||
`script/docker`, `script/lint`, `script/test`, `script/setup` and
|
|
||||||
`script/install-precommit` are the standard copies from `sneak/prompts`, kept
|
|
||||||
identical to them. `script/fmt` and `script/fmt-check` are the standard copies
|
|
||||||
with pixa's `gofmt` step kept before prettier. prettier formats the markdown
|
|
||||||
only, not the HTML templates, as it cannot parse a Go template action inside a
|
|
||||||
tag.
|
|
||||||
|
|
||||||
## Load Test
|
## Load Test
|
||||||
|
|
||||||
|
|||||||
+34
-67
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-18
|
last_modified: 2026-10-15
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -159,11 +159,8 @@ style conventions are in separate documents:
|
|||||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
org, in both directions. A container has its own `TMPDIR`, so the lock is
|
org, in both directions. A container has its own golangci-lint cache, its
|
||||||
out of reach. The lint phase does keep golangci-lint's cache between check
|
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
||||||
builds (below), but it lints every checkout at `/src`, and a finding is
|
|
||||||
taken from that cache only for the same files, settings and golangci-lint
|
|
||||||
version.
|
|
||||||
|
|
||||||
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||||
invalidates a `COPY` layer only when the copied content changes, so on an
|
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||||
@@ -173,21 +170,15 @@ style conventions are in separate documents:
|
|||||||
id, so no two builds share a value, not even the three in one
|
id, so no two builds share a value, not even the three in one
|
||||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||||
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
||||||
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||||
that install system packages and download Go's modules, or right after `FROM`
|
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
||||||
in a stage with neither, and every step that lints, tests or compiles comes
|
are compared by content and need nothing. A stage without the `ARG` line is
|
||||||
after it. A step that installs anything else as well, such as a
|
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
||||||
`script/bootstrap` that also installs node packages, comes after it too. A new
|
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
||||||
value reruns every `RUN` step after that line; `COPY` steps are compared by
|
it reruns every step too, but it also empties every cache mount, and Go's
|
||||||
content and need nothing. Docker keeps the layers above the line until the
|
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
||||||
base image, their own lines or the files they copy change. A stage without the
|
packages survive between check builds, would start empty every time. A bare
|
||||||
`ARG` line is served from cache on an unchanged tree, so a repository
|
|
||||||
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
|
|
||||||
not pass `--no-cache`: it reruns every step too, but it also builds the layers
|
|
||||||
above the line again and empties every cache mount, so Go's build cache and
|
|
||||||
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
|
|
||||||
survive between check builds, would start empty every time. A bare
|
|
||||||
`docker build .` is not evidence that anything ran: a sub-second build
|
`docker build .` is not evidence that anything ran: a sub-second build
|
||||||
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||||
`docker builder prune` and friends destroy a build cache shared with every
|
`docker builder prune` and friends destroy a build cache shared with every
|
||||||
@@ -203,25 +194,19 @@ style conventions are in separate documents:
|
|||||||
`Dockerfile`:
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# In every stage ARG CACHEBUST comes right after the steps that install
|
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||||
# system packages and download Go's modules, or right after FROM in a
|
# on every build, which reruns every RUN step after it. The steps that
|
||||||
# stage with neither, and every step that lints, tests or compiles
|
# compile mount Go's build cache, which survives from build to build.
|
||||||
# comes after it. script/ passes a new value on every build, which
|
|
||||||
# reruns every RUN step after it. Docker keeps the layers above it
|
|
||||||
# until the base image, their own lines or go.mod and go.sum change.
|
|
||||||
# The steps that compile mount Go's build cache, and golangci-lint run
|
|
||||||
# also mounts golangci-lint's; both survive from build to build.
|
|
||||||
|
|
||||||
# Lint phase
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
ARG CACHEBUST
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
ARG CACHEBUST
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/root/.cache/golangci-lint \
|
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
@@ -229,10 +214,10 @@ style conventions are in separate documents:
|
|||||||
# cache also holds test results, which go test would otherwise replay.
|
# cache also holds test results, which go test would otherwise replay.
|
||||||
# golang:1.x, YYYY-MM-DD
|
# golang:1.x, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS test
|
FROM golang@sha256:... AS test
|
||||||
|
ARG CACHEBUST
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
ARG CACHEBUST
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
@@ -240,19 +225,19 @@ style conventions are in separate documents:
|
|||||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
# are what make BuildKit build them first, so nothing after them can
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
# run unless lint and test passed.
|
# unless lint and test passed.
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
FROM golang@sha256:... AS builder
|
||||||
RUN apk add --no-cache git
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
ARG CACHEBUST
|
ARG CACHEBUST
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=test /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
RUN git config --system --add safe.directory /src
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
# The VERSION build arg when one is given, otherwise
|
||||||
@@ -289,26 +274,16 @@ style conventions are in separate documents:
|
|||||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
same two copies. A plain `docker build .` builds the last stage's chain
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
and nothing else.
|
and nothing else.
|
||||||
- Check builds keep these, and none of them can hand back a stale result:
|
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
||||||
- The layers above `ARG CACHEBUST`: the system packages and Go's
|
it for root), and only on the steps that compile: `golangci-lint run`,
|
||||||
modules. Docker installs the packages again when the base image or the
|
both `go test` runs and `go build`. It keeps compiled packages between
|
||||||
install line changes, and downloads the modules again when `go.mod` or
|
check builds, and Go reuses one only when all its inputs are unchanged, so
|
||||||
`go.sum` changes. Go checks every module against `go.sum` as it
|
it saves the compile without skipping a check. Nothing else is kept:
|
||||||
downloads it, and no later build can change a kept layer. The modules
|
golangci-lint's own cache would replay stored findings in place of a lint
|
||||||
are kept in a layer rather than a cache mount because a cache mount is
|
run, and Go builds from whatever files a kept module cache holds without
|
||||||
shared by every build on the host, and Go builds from whatever files
|
checking them again (only `go mod verify` does), so a file changed there
|
||||||
it holds without checking them again (only `go mod verify` does), so a
|
would go unnoticed. Go's build cache also holds test results, which is why
|
||||||
file changed there would go unnoticed.
|
both `go test` runs carry `-count=1`.
|
||||||
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
|
|
||||||
root), mounted on the steps that compile: `golangci-lint run`, both
|
|
||||||
`go test` runs and `go build`. Go reuses a compiled package only when
|
|
||||||
all its inputs are unchanged. The cache also holds test results, which
|
|
||||||
is why both `go test` runs carry `-count=1`.
|
|
||||||
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
|
|
||||||
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
|
|
||||||
a package's findings only when the package's files and those of every
|
|
||||||
package it imports, `go.mod`, the linters and their settings in
|
|
||||||
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
@@ -317,20 +292,12 @@ style conventions are in separate documents:
|
|||||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
has no `apk`, so install with `apt-get` under the Debian package name
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
lists in the same `RUN`, so the layer does not keep them. The test phase,
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
on the Debian Go image, installs the libraries its tests need the same
|
|
||||||
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
|
|
||||||
Docker keeps it until the base image or the install line changes:
|
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
ARG CACHEBUST
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
|||||||
@@ -30,32 +30,19 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-18 re-vendored from `sneak/prompts` commit `0653579` (closes #221):
|
|
||||||
each `Dockerfile` stage installs its system packages and downloads the Go
|
|
||||||
modules above `ARG CACHEBUST`, so check builds keep them, and the lint step
|
|
||||||
keeps golangci-lint's cache.
|
|
||||||
- 2026-10-19 a `trusted_proxies` entry in the IPv4-mapped form trusted nothing;
|
|
||||||
it is now read as the IPv4 range it covers by the same code that reads
|
|
||||||
`blocked_networks`, and such an entry shorter than `/96` aborts startup
|
|
||||||
(closes #303).
|
|
||||||
- 2026-10-18 the next reconciliation pass waits at least ten times as long as
|
|
||||||
the last one took, so passes on a large cache no longer run back to back
|
|
||||||
(closes #250).
|
|
||||||
- 2026-10-18 a `blocked_networks` entry in the IPv4-mapped form refused nothing;
|
|
||||||
`::ffff:10.0.0.0/104` is now read as `10.0.0.0/8`, and such an entry shorter
|
|
||||||
than `/96` aborts startup (closes #301).
|
|
||||||
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
|
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
|
||||||
addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it
|
addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it
|
||||||
refuses `64:ff9b::/96`, and refuses an IPv6 address in the IPv4-compatible
|
refuses `64:ff9b::/96`, and refuses an IPv6 address that holds an IPv4
|
||||||
form (`::/96`) when `blocked_networks` contains either the address as written
|
address, in the IPv4-compatible form (`::/96`) or the mapped form
|
||||||
or the IPv4 address it holds (closes #299). A source host such as
|
(`::ffff:a.b.c.d`), when `blocked_networks` contains either the address as
|
||||||
|
written or the IPv4 address it holds (closes #299). A source host such as
|
||||||
`[64:ff9b:1::a00:1]` was fetched, which on a network that runs NAT64 on that
|
`[64:ff9b:1::a00:1]` was fetched, which on a network that runs NAT64 on that
|
||||||
range reaches the IPv4 address it holds, here `10.0.0.1`; and with
|
range reaches the IPv4 address it holds, here `10.0.0.1`; with
|
||||||
`203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but
|
`203.0.113.0/24` in `blocked_networks`, `[::ffff:203.0.113.5]` was refused but
|
||||||
`[::203.0.113.5]` was fetched. Both now answer 403. The mapped form
|
`[::203.0.113.5]` was fetched; and with `::ffff:0:0/96` listed,
|
||||||
(`::ffff:a.b.c.d`) is still matched only as the IPv4 address it holds: Go
|
`[::ffff:203.0.113.5]` was fetched. All now answer 403. Go keeps a plain IPv4
|
||||||
keeps a plain IPv4 address in that form, and an IPv6 entry such as `::/0` must
|
address in the mapped form, so an IPv6 entry covering `::ffff:0:0/96`, such as
|
||||||
not refuse every IPv4 address.
|
`::/0`, now refuses every IPv4 address too.
|
||||||
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
|
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
|
||||||
addresses judges an IPv6 address in the IPv4-compatible form (`::/96`), such
|
addresses judges an IPv6 address in the IPv4-compatible form (`::/96`), such
|
||||||
as `::127.0.0.1` or `::10.0.0.1`, by the IPv4 address in its last four bytes
|
as `::127.0.0.1` or `::10.0.0.1`, by the IPv4 address in its last four bytes
|
||||||
|
|||||||
@@ -88,56 +88,3 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
|
|
||||||
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
|
|
||||||
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
|
|
||||||
// holds. /96, the shortest such entry, covers every IPv4 address.
|
|
||||||
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
yamlContent := signingKeyLine + `blocked_networks:
|
|
||||||
- "::ffff:10.0.0.0/104"
|
|
||||||
- "::ffff:0:0/96"
|
|
||||||
`
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, yamlContent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
|
|
||||||
if len(c.BlockedNetworks) != len(want) {
|
|
||||||
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, w := range want {
|
|
||||||
if got := c.BlockedNetworks[i].String(); got != w {
|
|
||||||
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
|
|
||||||
// entry in the IPv4-mapped form shorter than /96, which covers more than the
|
|
||||||
// IPv4 addresses, aborts startup with an error naming the key and the value.
|
|
||||||
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
runAbortCases(t, []abortCase{
|
|
||||||
{
|
|
||||||
name: "mapped-shorter-than-96",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- "::ffff:0.0.0.0/80"
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mapped-95",
|
|
||||||
yaml: signingKeyLine + `blocked_networks:
|
|
||||||
- "::ffff:0:0/95"
|
|
||||||
`,
|
|
||||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ var (
|
|||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
errNotAStringList = errors.New("not a list of strings")
|
errNotAStringList = errors.New("not a list of strings")
|
||||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
||||||
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
|
|
||||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||||
errEmptyEntry = errors.New("contains an empty entry")
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
@@ -1249,13 +1248,6 @@ func defaultTrustedProxies() []netip.Prefix {
|
|||||||
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
||||||
// aborts startup naming the key and the offending value; the default
|
// aborts startup naming the key and the offending value; the default
|
||||||
// (an empty list) applies only to an omitted key.
|
// (an empty list) applies only to an omitted key.
|
||||||
//
|
|
||||||
// An entry written in the IPv4-mapped form (::ffff:a.b.c.d) is kept as the
|
|
||||||
// IPv4 range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. Both
|
|
||||||
// blocked_networks and trusted_proxies are matched against an IPv4-mapped
|
|
||||||
// address as the IPv4 address it holds, so the entry as written would match
|
|
||||||
// nothing. Such an entry shorter than /96 covers more than the IPv4
|
|
||||||
// addresses and aborts startup.
|
|
||||||
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||||
raw, ok := lookupValue(sc, key)
|
raw, ok := lookupValue(sc, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1271,10 +1263,6 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
|
|
||||||
// the 32 bits of the IPv4 address it holds.
|
|
||||||
const mappedPrefixBits = 96
|
|
||||||
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(entries))
|
prefixes := make([]netip.Prefix, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -1284,16 +1272,6 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
|||||||
settingName(key), entry, errNotAValidCIDR)
|
settingName(key), entry, errNotAValidCIDR)
|
||||||
}
|
}
|
||||||
|
|
||||||
if prefix.Addr().Is4In6() && prefix.Bits() < mappedPrefixBits {
|
|
||||||
return nil, fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(key), entry, errMappedTooShort)
|
|
||||||
}
|
|
||||||
|
|
||||||
if prefix.Addr().Is4In6() {
|
|
||||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(),
|
|
||||||
prefix.Bits()-mappedPrefixBits)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix)
|
prefixes = append(prefixes, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,41 +83,3 @@ func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTrustedProxiesMappedEntryIsIPv4Range checks that an entry written in
|
|
||||||
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
|
|
||||||
// since the client address is matched as the IPv4 address it holds. /96, the
|
|
||||||
// shortest such entry, covers every IPv4 address.
|
|
||||||
func TestTrustedProxiesMappedEntryIsIPv4Range(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+
|
|
||||||
`trusted_proxies: ["::ffff:10.0.0.0/104", "::ffff:0:0/96"]`+"\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IPv4-mapped trusted_proxies entries should load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := make([]string, len(c.TrustedProxies))
|
|
||||||
for i, p := range c.TrustedProxies {
|
|
||||||
got[i] = p.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,0.0.0.0/0" {
|
|
||||||
t.Errorf("TrustedProxies = %v, want 10.0.0.0/8 and 0.0.0.0/0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTrustedProxiesMappedEntryShorterThan96AbortsStartup checks that an
|
|
||||||
// entry in the IPv4-mapped form shorter than /96, which covers more than the
|
|
||||||
// IPv4 addresses, aborts startup with an error naming the key and the value.
|
|
||||||
func TestTrustedProxiesMappedEntryShorterThan96AbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
runAbortCases(t, []abortCase{
|
|
||||||
{
|
|
||||||
name: "mapped-95",
|
|
||||||
yaml: signingKeyLine + `trusted_proxies: ["::ffff:0:0/95"]` + "\n",
|
|
||||||
wantErrSubstrings: []string{keyTrustedProxies, "::ffff:0:0/95"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -599,32 +599,39 @@ func isPrivateIP(ip net.IP) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isBlockedIP reports whether ip is refused, either by the built-in
|
// isBlockedIP reports whether ip is refused, either by the built-in
|
||||||
// internal-range check or by one of the operator-supplied prefixes. An
|
// internal-range check or by one of the operator-supplied prefixes. An IPv6
|
||||||
// IPv4-compatible address (in ::/96) is refused when a prefix contains the
|
// address that holds an IPv4 address is refused when a prefix contains the
|
||||||
// address as written or the IPv4 address it holds. An IPv4-mapped address (in
|
// address as written or the IPv4 address it holds.
|
||||||
// ::ffff:0:0/96) is matched only as the IPv4 address it holds: Go keeps a
|
|
||||||
// plain IPv4 address in that form too, and an IPv6 prefix such as ::/0 must
|
|
||||||
// not refuse every IPv4 address.
|
|
||||||
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
|
||||||
if isPrivateIP(ip) {
|
if isPrivateIP(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
addr, ok := netip.AddrFromSlice(ip)
|
asIPv6, asIPv4 := ipv6AndIPv4Forms(ip)
|
||||||
if !ok {
|
if !asIPv6.IsValid() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
addr = addr.Unmap()
|
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
||||||
|
return prefix.Contains(asIPv6) || prefix.Contains(asIPv4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6AndIPv4Forms returns ip as an IPv6 address and, when it holds an IPv4
|
||||||
|
// address in the IPv4-mapped form (::ffff:0:0/96) or the IPv4-compatible form
|
||||||
|
// (::/96), that IPv4 address. Otherwise the IPv4 form is the zero netip.Addr,
|
||||||
|
// which no prefix contains. Go keeps a plain IPv4 address in the mapped form,
|
||||||
|
// so the IPv6 form of 203.0.113.5 is ::ffff:203.0.113.5. An ip that is not 4
|
||||||
|
// or 16 bytes long gives two zero addresses.
|
||||||
|
func ipv6AndIPv4Forms(ip net.IP) (netip.Addr, netip.Addr) {
|
||||||
|
asIPv6, ok := netip.AddrFromSlice(ip.To16())
|
||||||
|
if !ok {
|
||||||
|
return netip.Addr{}, netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
// ip read as IPv4 where it is or holds an IPv4 address, otherwise the
|
|
||||||
// zero netip.Addr, which no prefix contains. Matching asIPv4 as well as
|
|
||||||
// addr refuses more only for an IPv4-compatible address.
|
|
||||||
asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
|
asIPv4, _ := netip.AddrFromSlice(ipv4CompatibleToIPv4(ip).To4())
|
||||||
|
|
||||||
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
|
return asIPv6, asIPv4
|
||||||
return prefix.Contains(addr) || prefix.Contains(asIPv4)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6
|
// ipv4CompatibleToIPv4 returns the IPv4 address that an IPv4-compatible IPv6
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ import (
|
|||||||
// write-pressure wakeups triggered by stores.
|
// write-pressure wakeups triggered by stores.
|
||||||
const DefaultEvictionInterval = 5 * time.Minute
|
const DefaultEvictionInterval = 5 * time.Minute
|
||||||
|
|
||||||
// reconciliationWaitFactor is how many times as long as a
|
|
||||||
// reconciliation pass took the eviction loop waits at least, after that
|
|
||||||
// pass ends, before it starts the next one. A pass reads a row and stats a
|
|
||||||
// file for every cached file, so its time grows with the cache; waiting
|
|
||||||
// ten times as long keeps reconciliation to at most a tenth of the time,
|
|
||||||
// however large the cache is.
|
|
||||||
const reconciliationWaitFactor = 10
|
|
||||||
|
|
||||||
// evictionBatchSize is how many LRU candidates of each class (variants
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
||||||
// and source blobs) one eviction pass fetches from the database.
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
const evictionBatchSize = 100
|
const evictionBatchSize = 100
|
||||||
@@ -446,12 +438,10 @@ func (c *Cache) notifyWritePressure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartEviction launches the background eviction goroutine, which
|
// StartEviction launches the background eviction goroutine, which
|
||||||
|
// reconciles the database accounting with the cache directories and
|
||||||
// removes the expired negative cache rows at startup and again on every
|
// removes the expired negative cache rows at startup and again on every
|
||||||
// periodic tick thereafter, reconciles the database accounting with the
|
// periodic tick thereafter, and evicts to the configured limit on the
|
||||||
// cache directories at startup and again on the first periodic tick
|
// given periodic interval and on write-pressure notifications. On a
|
||||||
// that comes at least ten times as long after a reconciliation pass
|
|
||||||
// ended as that pass took, and evicts to the configured limit on the given
|
|
||||||
// periodic interval and on write-pressure notifications. On a
|
|
||||||
// disabled cache it only removes the expired negative cache rows, as
|
// disabled cache it only removes the expired negative cache rows, as
|
||||||
// the negative cache is used either way. It is a no-op when already
|
// the negative cache is used either way. It is a no-op when already
|
||||||
// started. The goroutine outlives the caller, so it runs with its own
|
// started. The goroutine outlives the caller, so it runs with its own
|
||||||
@@ -492,7 +482,7 @@ func (c *Cache) StopEviction(ctx context.Context) error {
|
|||||||
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
||||||
defer close(c.evictionDone)
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
nextReconciliation := c.runReconciliationPass(ctx)
|
c.runReconciliationPass(ctx)
|
||||||
c.runCleanExpired(ctx)
|
c.runCleanExpired(ctx)
|
||||||
c.runEvictionPass(ctx)
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
@@ -504,20 +494,16 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Reconciliation walks the cache directories and reads a
|
// Reconciliation walks the cache directories, so it only
|
||||||
// row for every file, so it runs only on the periodic
|
// runs on the periodic ticker rather than on every
|
||||||
// ticker rather than on every write-pressure wakeup, and
|
// write-pressure wakeup, keeping it off the per-store hot
|
||||||
// only on the first tick at or after nextReconciliation,
|
// path. Reusing the eviction interval itself (rather than a
|
||||||
// which is ten times as long after the last pass ended as
|
// separate, longer one) is a deliberate choice: it is the
|
||||||
// that pass took. Passes then never run back to back,
|
// simplest option that still bounds how long a store's
|
||||||
// however long one takes. A file whose best-effort
|
// best-effort accounting insert can stay silently
|
||||||
// accounting insert failed in a store stays unaccounted for
|
// unaccounted for to one interval, on a process that is
|
||||||
// until the next pass, up to about one eviction interval
|
// already running this loop regardless.
|
||||||
// plus ten times the last pass's time.
|
c.runReconciliationPass(ctx)
|
||||||
if !time.Now().Before(nextReconciliation) {
|
|
||||||
nextReconciliation = c.runReconciliationPass(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.runCleanExpired(ctx)
|
c.runCleanExpired(ctx)
|
||||||
case <-c.evictionPressure:
|
case <-c.evictionPressure:
|
||||||
}
|
}
|
||||||
@@ -541,25 +527,17 @@ func (c *Cache) runEvictionPass(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runReconciliationPass runs one reconciliation pass, logging failures
|
// runReconciliationPass runs one reconciliation pass, logging failures
|
||||||
// instead of propagating them (the loop must keep running), and returns
|
// instead of propagating them (the loop must keep running). It does
|
||||||
// the earliest time the next pass may start: reconciliationWaitFactor
|
// nothing once ctx is cancelled.
|
||||||
// times as long after this pass ended as it took. It does nothing once
|
func (c *Cache) runReconciliationPass(ctx context.Context) {
|
||||||
// ctx is cancelled.
|
|
||||||
func (c *Cache) runReconciliationPass(ctx context.Context) time.Time {
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return time.Now()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
started := time.Now()
|
|
||||||
|
|
||||||
err := c.reconcileAccounting(ctx)
|
err := c.reconcileAccounting(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ended := time.Now()
|
|
||||||
|
|
||||||
return ended.Add(reconciliationWaitFactor * ended.Sub(started))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCleanExpired runs CleanExpired, logging a failure instead of
|
// runCleanExpired runs CleanExpired, logging a failure instead of
|
||||||
@@ -578,8 +556,7 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
|
|
||||||
// reconcileAccounting synchronizes the database size accounting with
|
// reconcileAccounting synchronizes the database size accounting with
|
||||||
// the actual contents of the cache directories. It runs at startup and
|
// the actual contents of the cache directories. It runs at startup and
|
||||||
// then on the first periodic eviction tick that comes at least ten times
|
// again on every periodic eviction tick thereafter, off the request
|
||||||
// as long after the last pass ended as that pass took, off the request
|
|
||||||
// hot path: it adopts variant files that predate the accounting table
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
@@ -589,10 +566,9 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
// files left behind by crashed writes, and last checks the total cache
|
// files left behind by crashed writes, and last checks the total cache
|
||||||
// usage against the tables. It reads the tables a page at a time.
|
// usage against the tables. It reads the tables a page at a time.
|
||||||
// Running it periodically, not just once, bounds how long such drift can
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
// accumulate unaccounted for on a long-running process to about one
|
// accumulate unaccounted for on a long-running process to one eviction
|
||||||
// eviction interval plus ten times the last pass's time.
|
// interval. Once ctx is cancelled, it stops at the next file or row and
|
||||||
// Once ctx is cancelled, it stops at the next file or row and returns
|
// returns ctx's error.
|
||||||
// ctx's error.
|
|
||||||
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -918,89 +918,6 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReconciliationPassesAreSpacedByTenTimesTheirTime makes the hook
|
|
||||||
// called after every read of the reconciliation pass sleep 10 ms, runs
|
|
||||||
// the eviction loop on a 10 ms interval, and records when each of those
|
|
||||||
// calls starts and ends. A pass starts before its first call and ends
|
|
||||||
// after its last, so the time from the first call's start to the last
|
|
||||||
// call's end is at most the pass's time. The next pass's first call must
|
|
||||||
// start at least ten times that long after the last call ended.
|
|
||||||
func TestReconciliationPassesAreSpacedByTenTimesTheirTime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
// A pass made here, before the loop starts, counts the reads one
|
|
||||||
// pass makes on this empty cache, so the reads recorded below can be
|
|
||||||
// split into passes.
|
|
||||||
readsPerPass := 0
|
|
||||||
cache.reconciliationReadTestHook = func(int) { readsPerPass++ }
|
|
||||||
|
|
||||||
err := cache.reconcileAccounting(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reconcileAccounting failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("one reconciliation pass makes %d reads", readsPerPass)
|
|
||||||
|
|
||||||
if readsPerPass == 0 {
|
|
||||||
t.Fatal("a reconciliation pass made no read")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pass at startup and two after it.
|
|
||||||
const passes = 3
|
|
||||||
|
|
||||||
type read struct{ start, end time.Time }
|
|
||||||
|
|
||||||
// The channel holds every read the test looks at, so none of those
|
|
||||||
// is dropped; reads after them are, and never block the loop.
|
|
||||||
reads := make(chan read, passes*readsPerPass)
|
|
||||||
|
|
||||||
cache.reconciliationReadTestHook = func(int) {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case reads <- read{start: start, end: time.Now()}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.StartEviction(10 * time.Millisecond)
|
|
||||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
|
||||||
|
|
||||||
recorded := make([]read, 0, passes*readsPerPass)
|
|
||||||
timeout := time.After(15 * time.Second)
|
|
||||||
|
|
||||||
for len(recorded) < passes*readsPerPass {
|
|
||||||
select {
|
|
||||||
case r := <-reads:
|
|
||||||
recorded = append(recorded, r)
|
|
||||||
case <-timeout:
|
|
||||||
t.Fatalf("saw %d reconciliation reads in 15 s, want %d (%d passes)",
|
|
||||||
len(recorded), passes*readsPerPass, passes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for pass := range passes - 1 {
|
|
||||||
first := recorded[pass*readsPerPass]
|
|
||||||
last := recorded[(pass+1)*readsPerPass-1]
|
|
||||||
next := recorded[(pass+1)*readsPerPass]
|
|
||||||
|
|
||||||
passTime := last.end.Sub(first.start)
|
|
||||||
wait := next.start.Sub(last.end)
|
|
||||||
|
|
||||||
t.Logf("pass %d took at least %v, and the next pass started %v after it",
|
|
||||||
pass+1, passTime, wait)
|
|
||||||
|
|
||||||
if wait < 10*passTime {
|
|
||||||
t.Errorf("pass %d took at least %v, and the next pass started %v "+
|
|
||||||
"after it, want at least %v", pass+1, passTime, wait, 10*passTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopEvictionInterruptsPassInProgress holds the test database's
|
// TestStopEvictionInterruptsPassInProgress holds the test database's
|
||||||
// only connection, so the startup reconciliation pass waits for it, and
|
// only connection, so the startup reconciliation pass waits for it, and
|
||||||
// checks that StopEviction stops that pass instead of waiting for the
|
// checks that StopEviction stops that pass instead of waiting for the
|
||||||
|
|||||||
@@ -589,18 +589,16 @@ func TestImageRoutesRefuseIPv6SourceHostHoldingInternalIPv4Address(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on
|
// TestImageRoutesCheckIPv4AddressInIPv6SourceHost requests ipv6SourcePath on
|
||||||
// source hosts that hold an IPv4 address, through a /v1/image/ URL with a
|
// IPv6 source hosts that hold an IPv4 address, through a /v1/image/ URL with a
|
||||||
// signature and through an encrypted URL, with the fetcher pixad uses.
|
// signature and through an encrypted URL, with the fetcher pixad uses.
|
||||||
// [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215),
|
// [64:ff9b:1::a00:1], in the local-use NAT64 range (64:ff9b:1::/48, RFC 8215),
|
||||||
// answers 403. [::203.0.113.5] holds the public address 203.0.113.5 in the
|
// answers 403. [::203.0.113.5] and [::ffff:203.0.113.5] hold a public IPv4
|
||||||
// IPv4-compatible form (::/96) and answers 403 when blocked_networks lists
|
// address in the IPv4-compatible form (::/96) and in the IPv4-mapped form
|
||||||
// 203.0.113.0/24 or ::/96. [::ffff:203.0.113.5] holds it in the IPv4-mapped
|
// (::ffff:0:0/96). Each answers 403 when blocked_networks lists
|
||||||
// form (::ffff:0:0/96) and answers 403 when blocked_networks lists
|
// 203.0.113.0/24, and when it lists the range of its own form. When
|
||||||
// 203.0.113.0/24. Go keeps the plain address 203.0.113.5 in the mapped form
|
// blocked_networks is empty [::203.0.113.5] is not refused: the fetcher tries
|
||||||
// too, and an IPv6 range must not refuse it: with ::/0 listed it is not
|
// to connect to it, finds nothing there, and the request fails with another
|
||||||
// refused. Neither is [::203.0.113.5] with blocked_networks empty. Where a
|
// status.
|
||||||
// host is not refused, the fetcher tries to connect to it, finds nothing
|
|
||||||
// there, and the request fails with another status.
|
|
||||||
func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
|
func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -625,9 +623,9 @@ func TestImageRoutesCheckIPv4AddressInIPv6SourceHost(t *testing.T) {
|
|||||||
{"IPv4-mapped form of an address in blocked_networks",
|
{"IPv4-mapped form of an address in blocked_networks",
|
||||||
ipv4MappedHost,
|
ipv4MappedHost,
|
||||||
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
|
[]netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, true},
|
||||||
{"plain IPv4 address with ::/0 in blocked_networks",
|
{"IPv4-mapped form with ::ffff:0:0/96 in blocked_networks",
|
||||||
"203.0.113.5",
|
ipv4MappedHost,
|
||||||
[]netip.Prefix{netip.MustParsePrefix("::/0")}, false},
|
[]netip.Prefix{netip.MustParsePrefix("::ffff:0:0/96")}, true},
|
||||||
{"IPv4-compatible form of an address with blocked_networks empty",
|
{"IPv4-compatible form of an address with blocked_networks empty",
|
||||||
ipv4CompatibleHost, nil, false},
|
ipv4CompatibleHost, nil, false},
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -19,8 +19,9 @@
|
|||||||
# CGO image libraries (pkg-config, vips
|
# CGO image libraries (pkg-config, vips
|
||||||
# with its JPEG XL support, libheif) for
|
# with its JPEG XL support, libheif) for
|
||||||
# the govips bindings instead of Node: to
|
# the govips bindings instead of Node: to
|
||||||
# compile pixa outside Docker, as
|
# compile pixa, in the Dockerfile's test
|
||||||
# `make build` does
|
# phase and build stage, which format
|
||||||
|
# nothing
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
+4
-5
@@ -2,12 +2,11 @@
|
|||||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||||
# the formatter on the host, which a pristine checkout cannot do.
|
# the formatter on the host, which a pristine checkout cannot do.
|
||||||
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||||
# line, since a cached gate phase is a check that did not run; unlike
|
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||||
# --no-cache, it keeps the layers above that line and the cache mounts.
|
|
||||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||||
# before every step that lints, tests or compiles; a stage without that
|
# first; a stage without that line is served from cache on an unchanged
|
||||||
# line is served from cache on an unchanged tree, with no warning.
|
# tree, with no warning.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+4
-5
@@ -1,12 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||||
# line, since a cached gate phase is a check that did not run; unlike
|
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||||
# --no-cache, it keeps the layers above that line and the cache mounts.
|
|
||||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||||
# before every step that lints, tests or compiles; a stage without that
|
# first; a stage without that line is served from cache on an unchanged
|
||||||
# line is served from cache on an unchanged tree, with no warning.
|
# tree, with no warning.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+5
-7
@@ -5,13 +5,11 @@
|
|||||||
# make its answer untrustworthy.
|
# make its answer untrustworthy.
|
||||||
#
|
#
|
||||||
# The phase is not the last stage in the file, so it is built only when
|
# The phase is not the last stage in the file, so it is built only when
|
||||||
# --target names it. A new CACHEBUST value reruns every RUN step after
|
# --target names it. A new CACHEBUST value reruns every RUN step, since
|
||||||
# the ARG CACHEBUST line, since a cached lint layer is a lint that did
|
# a cached lint layer is a lint that did not run; unlike --no-cache, it
|
||||||
# not run; unlike --no-cache, it keeps the layers above that line and
|
# keeps Go's build cache. This relies on every stage of the Dockerfile
|
||||||
# the cache mounts. This relies on every stage of the Dockerfile
|
# declaring ARG CACHEBUST first; a stage without that line is served
|
||||||
# declaring ARG CACHEBUST before every step that lints, tests or
|
# from cache on an unchanged tree, with no warning.
|
||||||
# compiles; a stage without that line is served from cache on an
|
|
||||||
# unchanged tree, with no warning.
|
|
||||||
# --output type=cacheonly writes no image, since nothing uses one.
|
# --output type=cacheonly writes no image, since nothing uses one.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -3,12 +3,11 @@
|
|||||||
# and this builds that phase alone, on the same terms as script/lint:
|
# and this builds that phase alone, on the same terms as script/lint:
|
||||||
# --target because a phase that is not the last stage is built only when
|
# --target because a phase that is not the last stage is built only when
|
||||||
# named, and a new CACHEBUST value because a cached test layer is a test
|
# named, and a new CACHEBUST value because a cached test layer is a test
|
||||||
# that did not run; unlike --no-cache, it keeps the layers above the ARG
|
# that did not run; unlike --no-cache, it keeps Go's build cache. This
|
||||||
# CACHEBUST line and the cache mounts. This relies on every stage of the
|
# relies on every stage of the Dockerfile declaring ARG CACHEBUST first;
|
||||||
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
|
# a stage without that line is served from cache on an unchanged tree,
|
||||||
# or compiles; a stage without that line is served from cache on an
|
# with no warning. --output type=cacheonly writes no image, since
|
||||||
# unchanged tree, with no warning. --output type=cacheonly writes no
|
# nothing uses one.
|
||||||
# image, since nothing uses one.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
Reference in New Issue
Block a user