Debian 13 runtime with restic 0.18.1 pinned outside the OS; restic assumptions re-proven; felhom-samba 1.2.0 (R-1105, R-972)
gates / gates (push) Successful in 49s
gates / gates (push) Successful in 49s
This commit is contained in:
@@ -1,3 +1,31 @@
|
||||
## v0.323.0 — Debian 13 with restic pinned outside the OS, re-proven; felhom-samba 1.2.0 on Alpine 3.24.2 (2026-10-16)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task:
|
||||
„the controller on Debian 13 with a pinned restic, re-proven; the Samba image rebuilt" (felhom.eu
|
||||
`REPORT-debian13-restic-2026-10-16.md`).
|
||||
|
||||
- **R-1105 — restic is pinned by itself: the official 0.18.1 release binary, no longer Debian's package.** A new
|
||||
`restic` build stage downloads `restic_0.18.1_linux_<arch>.bz2`, checks restic's `SHA256SUMS` signature with the
|
||||
vendored release key (`third_party/restic/`, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no
|
||||
keyserver at build time), the published sum, and the sum pinned in the Dockerfile; any mismatch fails the build. The
|
||||
runtime copies only that binary. `TestR1105_ResticIsPinnedOutsideTheOS`, red-proved by four single mutations.
|
||||
- **Every restic assumption re-proven on 0.18.1** against real outputs of both binaries (`internal/backup/testdata/
|
||||
restic/`): the failure classes (wrong password, no repository, refused port, refused key, unreachable host, dead
|
||||
rclone ssh, both lock forms, a damaged pack), the init race, the snapshot/size/progress JSON — all unchanged.
|
||||
**One moved:** since restic 0.17 a `--keep-monthly` window with unused slots also keeps each group's OLDEST snapshot,
|
||||
so the clean-up plan removes a SUBSET of what 0.14.0 removed (41-day history: 60 vs 62; 230-day: identical). Only
|
||||
ever more kept, and old — the fake-snapshot guard's verdict holds. `TestR1105_*` (4), red-proved by six mutations.
|
||||
Comment corrected: a plain `restore` DOES write a lock on both versions; the nightly proof's `--no-lock` is what
|
||||
keeps it write-free.
|
||||
- **Runtime `debian:13.7-slim`**, pinned by digest; the Docker apt repo's codename now comes from `/etc/os-release`
|
||||
(it was hard-coded `bookworm`).
|
||||
- **Removed packages the binary never runs:** `postgresql-client`, `default-mysql-client` (every dump and import runs
|
||||
inside the app's database container), `sqlite3`, `fdisk`, `e2fsprogs`, `parted` (disk work is the agent's since
|
||||
v0.37.0), `sshpass` (its ssh-copy-id install was replaced by the hub's key registrar in v0.289.0).
|
||||
- **R-972 — felhom-samba 1.2.0:** Alpine 3.21 → 3.24.2 (digest-pinned), same packages (Samba 4.23.8). `SambaImage`
|
||||
bumped. `build-samba-image.sh` now refuses a tag already in the registry, a registry it cannot ask, and a dirty or
|
||||
unpushed tree — like `build.sh`; `scripts/test_samba_build_push_once.sh` (6 cases, 4 fail on the old script).
|
||||
|
||||
## v0.322.0 — the hub key opens no route on the box; Go 1.27.2; two Data tab leftovers; the install form's dead branches (2026-10-16)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task:
|
||||
|
||||
+54
-35
@@ -40,54 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-o /build/felhom-controller \
|
||||
./cmd/controller/
|
||||
|
||||
# --- Runtime stage ---
|
||||
# The runtime stays on Debian 12 (its current point release, 12.13, with every fixed package) ON PURPOSE: Debian 13
|
||||
# ships restic 0.18.0, and the off-site engine's measured behaviour is restic 0.14.0's (a missing source path is a
|
||||
# warning, the --json progress shape, the forget day-buckets, restore without a lock — offbox_*.go). Moving to
|
||||
# Debian 13 is its own change with that re-proof (R-1105). The binary itself is built on Go 1.27.2 above.
|
||||
FROM debian:12.13-slim@sha256:67b30a61dc87758f0caf819646104f29ecbda97d920aaf5edc834128ac8493d3
|
||||
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
|
||||
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
|
||||
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
|
||||
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
|
||||
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
|
||||
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
|
||||
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
|
||||
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
|
||||
ARG TARGETARCH
|
||||
ARG RESTIC_VERSION=0.18.1
|
||||
ARG RESTIC_SHA256_AMD64=680838f19d67151adba227e1570cdd8af12c19cf1735783ed1ba928bc41f363d
|
||||
ARG RESTIC_SHA256_ARM64=87f53fddde38764095e9c058a3b31834052c37e5826d2acf34e18923c006bd45
|
||||
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
|
||||
RUN set -eu; \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
|
||||
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
|
||||
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
|
||||
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
|
||||
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
|
||||
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
|
||||
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
|
||||
echo "${want} ${f}" | sha256sum -c -; \
|
||||
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
|
||||
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
|
||||
|
||||
# Install runtime dependencies:
|
||||
# - docker-cli: for "docker compose" commands
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
||||
# - postgresql-client: for pg_dump
|
||||
# - default-mysql-client: for mysqldump
|
||||
# - sqlite3: for SQLite backup
|
||||
# - git: for stack sync from Gitea
|
||||
# - curl: for health pings and debugging
|
||||
# - util-linux: lsblk, blkid, mount (storage init)
|
||||
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
|
||||
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
|
||||
# - rsync: for data migration between storage paths
|
||||
# - parted: partprobe (partition table re-read after sfdisk)
|
||||
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
|
||||
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
|
||||
# for the SLICE-2 offsite apply-bridge key install
|
||||
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
|
||||
# R-972: `apt-get upgrade` first — the Debian security fixes newer than the base image's point release (gnutls, pcre2,
|
||||
# libcap2 were fixable on 12.13 on 2026-10-16). Within Debian 12 only, so restic stays 0.14.0.
|
||||
# --- Runtime stage ---
|
||||
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
|
||||
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
|
||||
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
|
||||
|
||||
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
|
||||
# - docker-cli + compose plugin: every stack operation (below)
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
|
||||
# - curl: the image's HEALTHCHECK, and the docker repo key below
|
||||
# - gnupg: dearmors docker's repo key below
|
||||
# - git: catalog sync
|
||||
# - util-linux: findmnt (mount reads)
|
||||
# - rsync: data migration between storage paths
|
||||
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
|
||||
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
|
||||
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
|
||||
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
|
||||
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
|
||||
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
postgresql-client \
|
||||
default-mysql-client \
|
||||
sqlite3 \
|
||||
util-linux \
|
||||
fdisk \
|
||||
e2fsprogs \
|
||||
rsync \
|
||||
parted \
|
||||
restic \
|
||||
openssh-client \
|
||||
sshpass \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install docker-cli (without daemon)
|
||||
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
|
||||
|
||||
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
|
||||
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
|
||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
|
||||
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
|
||||
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
|
||||
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
|
||||
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
|
||||
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
|
||||
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
|
||||
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
|
||||
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
|
||||
joined := strings.ReplaceAll(src, "\\\n", " ")
|
||||
var stages []string
|
||||
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
|
||||
stages = append(stages, "FROM "+part)
|
||||
}
|
||||
if len(stages) < 3 {
|
||||
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
|
||||
}
|
||||
var fetch string
|
||||
for _, s := range stages {
|
||||
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
|
||||
fetch = s
|
||||
}
|
||||
}
|
||||
if fetch == "" {
|
||||
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
|
||||
}
|
||||
runtime := stages[len(stages)-1]
|
||||
|
||||
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
|
||||
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
|
||||
}
|
||||
for _, arch := range []string{"AMD64", "ARM64"} {
|
||||
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
|
||||
t.Errorf("no pinned sha256 for %s", arch)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COPY third_party/restic/restic-release-key.asc",
|
||||
"gpgv --keyring", // SHA256SUMS is restic's, signed
|
||||
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
|
||||
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
|
||||
} {
|
||||
if !strings.Contains(fetch, want) {
|
||||
t.Errorf("the restic stage no longer does %q", want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
|
||||
t.Errorf("the vendored release key is missing: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
|
||||
t.Error("the runtime does not take restic from the pinned stage")
|
||||
}
|
||||
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
|
||||
for _, inst := range aptRe.FindAllString(runtime, -1) {
|
||||
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
|
||||
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
|
||||
}
|
||||
}
|
||||
|
||||
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
|
||||
if !strings.Contains(from[1], "@sha256:") {
|
||||
t.Errorf("base image %s is not pinned by digest", from[1])
|
||||
}
|
||||
}
|
||||
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
|
||||
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
|
||||
}
|
||||
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
|
||||
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,9 @@
|
||||
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
|
||||
# question, not a shipped feature.
|
||||
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
# 1.2.0 (R-972): Alpine 3.24.2, pinned by digest — the same base as the hub. 3.21 carried 48 fixable high findings
|
||||
# (audits/security-review-2026-10-12/scanners/). Same packages; scanner before → after in the controller's v0.323.0 CHANGELOG.
|
||||
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# samba = smbd + nmbd + smbpasswd/testparm (meta-package proven installable in the spike);
|
||||
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
// TierOffsite capture set (recovery unit + MANDATORY userdata only) into the extra absolute paths
|
||||
// appended to the app's restic snapshot, plus the Hungarian customer warnings for LOUD capture gaps.
|
||||
//
|
||||
// SP-3.4 is law here: restic 0.14.0 does NOT error on a missing source path — it skips with a warning,
|
||||
// exits 0, and silently writes a partial snapshot. So a skipped/missing MANDATORY path is detected in
|
||||
// SP-3.4 is law here: restic does NOT error on a missing source path — it skips with a warning, exits 0,
|
||||
// and silently writes a partial snapshot (0.14.0, and re-measured on 0.18.1 for R-1105, 2026-10-16). So a skipped/missing MANDATORY path is detected in
|
||||
// THIS function (the structural-guard Skipped list + an os.Stat filter) and surfaced in BOTH the
|
||||
// English log and the Hungarian LastWarning. A restic exit code proves nothing about a missing path.
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ type OffboxProgress struct {
|
||||
// message_types (status, summary, error, verbose_status); anything that is not "status" is ignored
|
||||
// here rather than treated as garbage, because restic adds new types between versions and an unknown
|
||||
// type must never break the run. Schema captured from
|
||||
// restic 0.14.0 (the version in the controller image) via a live `backup --dry-run --json`:
|
||||
// restic 0.14.0 via a live `backup --dry-run --json` — and the same field names on 0.18.1, the version the image pins
|
||||
// since v0.323.0 (R-1105; testdata/restic/):
|
||||
//
|
||||
// {"message_type":"status","percent_done":0,"total_files":1,"total_bytes":112}
|
||||
// {"message_type":"status","percent_done":0.558,"total_files":173,"files_done":87,
|
||||
|
||||
@@ -51,9 +51,10 @@ import (
|
||||
// to write. The spike established what "read-only" actually costs here, by observation rather than by
|
||||
// reading the code:
|
||||
//
|
||||
// - `restic restore` in 0.14.0 takes NO repository lock (measured on demo-hp with a lock sampler
|
||||
// that was positively controlled first: it saw a lock appear and vanish across a real `check`, and
|
||||
// zero across two restores);
|
||||
// - `restic restore` WITHOUT `--no-lock` DOES write a lock — corrected 2026-10-16 (R-1105): against a
|
||||
// read-only repository both 0.14.0 and 0.18.1 fail on `Save(<lock/…>)`, and both restore with
|
||||
// `--no-lock`. (The 2026-08 sampler on demo-hp saw zero locks across two restores — a short-lived
|
||||
// lock between samples; the read-only repository cannot miss it.) The flag below is what does it;
|
||||
// - `restic check` DOES take one — so the comment claiming the check never writes is wrong (R-407);
|
||||
// - the PRODUCT's restore path writes anyway, because `unlockStale` runs `restic unlock` — a delete
|
||||
// verb against `locks/` — before every restore (`offbox_restore.go`).
|
||||
@@ -306,9 +307,8 @@ func (m *Manager) nextProofTarget(ctx context.Context) (stack, id, unitPath stri
|
||||
// repository.
|
||||
//
|
||||
// Three differences from `RestoreOffboxScratch`, each deliberate and each named:
|
||||
// - `--no-lock`, so restic does not create a lock file (it does not for `restore` in 0.14.0 anyway
|
||||
// — measured — but the flag makes that a guarantee rather than an observed behaviour of one
|
||||
// version);
|
||||
// - `--no-lock`, so restic does not create a lock file (a plain `restore` DOES, on 0.14.0 and 0.18.1 —
|
||||
// measured against a read-only repository, R-1105; with the flag both versions restore from it);
|
||||
// - no `unlockStale`, so no delete verb is issued against `locks/`;
|
||||
// - `m.runner()` rather than `resticStep`, so the `unlock --remove-all` escalation is unreachable
|
||||
// rather than merely unlikely.
|
||||
|
||||
@@ -82,7 +82,8 @@ var retentionPolicy = []string{"--group-by", "host,tags",
|
||||
"--keep-daily", strconv.Itoa(keepDaily), "--keep-weekly", strconv.Itoa(keepWeekly), "--keep-monthly", strconv.Itoa(keepMonthly)}
|
||||
|
||||
// calendarDaysBefore: how many calendar days s lies before now, both read in s's own zone — the zone
|
||||
// restic 0.14.0 buckets a snapshot's day in (the offset stored with the snapshot). Edge, recorded: in the
|
||||
// restic buckets a snapshot's day in (the offset stored with the snapshot; 0.14.0, and 0.18.1 gives the same plan on a
|
||||
// 502-snapshot, 230-day set made in UTC and Budapest time — R-1105). Edge, recorded: in the
|
||||
// hour after local midnight on a DST change, a box whose snapshots carry two different offsets can read one
|
||||
// day short; the guard then REFUSES (the safe direction) and the next week's window passes.
|
||||
func calendarDaysBefore(s, now time.Time) int {
|
||||
@@ -182,7 +183,9 @@ func (m *Manager) planRemovals(ctx context.Context, base, env []string) ([]guard
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forget --dry-run: %w: %s", err, truncate(out))
|
||||
}
|
||||
// restic 0.14.0 prints the JSON array on stdout; the runner may combine stderr — take the array.
|
||||
// restic prints the JSON array on stdout; the runner may combine stderr — take the array. (R-1105: on 0.18.1 the
|
||||
// plan can be SMALLER than 0.14.0's — while a --keep-monthly window has unused slots it also keeps each group's
|
||||
// oldest snapshot. Only ever more kept; pinned by TestR1105_TheForgetPlanOnlyEverKeepsMore.)
|
||||
js := string(out)
|
||||
if i := strings.Index(js, "["); i > 0 {
|
||||
js = js[i:]
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 ships 0.18.1. The fixtures in
|
||||
// testdata/restic/<version>/ are REAL outputs of both official binaries, captured 2026-10-16 on one shared scratch
|
||||
// repository (made by 0.14.0, so both versions read the same snapshot set; paths rewritten to /data/x). Each
|
||||
// assumption the code makes about restic's output is asserted for BOTH versions through the code's own functions —
|
||||
// so a future restic that rewords one of them fails here, by name, instead of moving a failure to `unknown` on a
|
||||
// customer's box (07-backup-architecture.md §6.7, §6.8).
|
||||
var r1105Versions = []string{"0.14.0", "0.18.1"}
|
||||
|
||||
func r1105Fixture(t *testing.T, version, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "restic", version, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fixtureRunner answers each restic subcommand with a captured output; err != nil when restic exited non-zero.
|
||||
func r1105Runner(t *testing.T, version string, answers map[string]string) offboxRunner {
|
||||
return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
joined := " " + strings.Join(args, " ") + " "
|
||||
for _, key := range sortedKeys(answers) {
|
||||
if strings.Contains(joined, " "+key+" ") {
|
||||
name := answers[key]
|
||||
var err error
|
||||
if strings.HasPrefix(name, "!") { // restic exited non-zero for this one
|
||||
name, err = name[1:], errors.New("exit status 1")
|
||||
}
|
||||
return r1105Fixture(t, version, name), err
|
||||
}
|
||||
}
|
||||
t.Fatalf("restic %s: no fixture for %q", version, joined)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
k := make([]string, 0, len(m))
|
||||
for s := range m {
|
||||
k = append(k, s)
|
||||
}
|
||||
sort.Slice(k, func(i, j int) bool { return len(k[i]) > len(k[j]) }) // the longest (most specific) match first
|
||||
return k
|
||||
}
|
||||
|
||||
// The probe and the failure classifier: each captured failure lands in the class it has always landed in.
|
||||
func TestR1105_EachResticFailureKeepsItsClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
fixture string
|
||||
probe string // classifyResticProbe on `cat config`
|
||||
runClass OffsiteFailureClass // ClassifyOffsiteFailure on the error ensureOffboxRepo returns ("" = not reached)
|
||||
}{
|
||||
{"wrong-password.txt", "orphaned", ""},
|
||||
{"no-repo.txt", "norepo", ""},
|
||||
{"sftp-refused-port.txt", "other", OffsiteFailTransport},
|
||||
{"sftp-refused-key.txt", "other", OffsiteFailTransport},
|
||||
{"sftp-unreachable.txt", "other", OffsiteFailTransport},
|
||||
{"rclone-dead-ssh.txt", "other", OffsiteFailTransport},
|
||||
}
|
||||
for _, v := range r1105Versions {
|
||||
for _, c := range cases {
|
||||
out := r1105Fixture(t, v, c.fixture)
|
||||
if got := classifyResticProbe(out, errors.New("exit status 1")); got != c.probe {
|
||||
t.Errorf("restic %s %s: probe class %q, want %q", v, c.fixture, got, c.probe)
|
||||
}
|
||||
if c.runClass == "" {
|
||||
continue
|
||||
}
|
||||
m, _ := newOffboxManager(t)
|
||||
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!" + c.fixture}))
|
||||
err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil)
|
||||
if got := ClassifyOffsiteFailure(err); got != c.runClass {
|
||||
t.Errorf("restic %s %s: run class %q, want %q (err: %v)", v, c.fixture, got, c.runClass, err)
|
||||
}
|
||||
}
|
||||
// A lock left by a crashed run on another hostname (the C2 shape), both forms.
|
||||
for _, f := range []string{"locked-shared.txt", "locked-exclusive.txt"} {
|
||||
out := r1105Fixture(t, v, f)
|
||||
if !offboxLockRe.Match(out) {
|
||||
t.Errorf("restic %s %s: the lock text is no longer recognised — resticStep would not self-heal", v, f)
|
||||
}
|
||||
if got := ClassifyOffsiteFailure(errors.New(string(out))); got != OffsiteFailLocked {
|
||||
t.Errorf("restic %s %s: class %q, want locked", v, f, got)
|
||||
}
|
||||
}
|
||||
// The integrity check: a damaged store reads as damage, a healthy one does not.
|
||||
if !looksLikeRepositoryDamage(r1105Fixture(t, v, "check-damaged.txt")) {
|
||||
t.Errorf("restic %s: a damaged pack is no longer recognised as damage", v)
|
||||
}
|
||||
if looksLikeRepositoryDamage(r1105Fixture(t, v, "check-ok.txt")) {
|
||||
t.Errorf("restic %s: a healthy check reads as damage", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A first run that races another init: „already exists" is still read as success, so the run proceeds.
|
||||
func TestR1105_InitRaceIsStillSuccess(t *testing.T) {
|
||||
for _, v := range r1105Versions {
|
||||
m, _ := newOffboxManager(t)
|
||||
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!no-repo.txt", "init": "!init-exists.txt"}))
|
||||
if err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil); err != nil {
|
||||
t.Errorf("restic %s: an init that finds the repository already made must be success, got %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The JSON the code parses: snapshot lists, the newest snapshot of a tag, the sizes, the progress lines.
|
||||
func TestR1105_TheJSONTheCodeReadsStillParses(t *testing.T) {
|
||||
counts := map[string]int{}
|
||||
for _, v := range r1105Versions {
|
||||
m, sett := newOffboxManager(t)
|
||||
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{
|
||||
"snapshots": "snapshots.json", "latest": "snapshots-latest-tag.json",
|
||||
"stats": "stats-id.json", "raw-data": "stats-raw.json",
|
||||
}))
|
||||
snaps, err := m.listGuardSnaps(context.Background(), nil, nil)
|
||||
if err != nil || len(snaps) == 0 {
|
||||
t.Fatalf("restic %s: snapshot list: %d, %v", v, len(snaps), err)
|
||||
}
|
||||
for _, s := range snaps {
|
||||
if s.ID == "" || s.ShortID == "" || s.Time.IsZero() || s.Hostname == "" || len(s.Tags) == 0 {
|
||||
t.Fatalf("restic %s: a snapshot lost a field the guard reads: %+v", v, s)
|
||||
}
|
||||
}
|
||||
counts[v] = len(snaps)
|
||||
id, paths, err := m.offboxLatestSnapshot(context.Background(), "a")
|
||||
if err != nil || id == "" || len(paths) == 0 {
|
||||
t.Errorf("restic %s: newest snapshot of a tag: %q %v %v", v, id, paths, err)
|
||||
}
|
||||
if size, err := m.offboxSnapshotSize(context.Background(), id); err != nil || size <= 0 {
|
||||
t.Errorf("restic %s: snapshot size: %d %v", v, size, err)
|
||||
}
|
||||
n, ok := m.offboxRecordStats(context.Background(), nil, nil)
|
||||
if !ok || n != counts[v] || sett.GetOffboxTarget().RepoSizeBytes <= 0 {
|
||||
t.Errorf("restic %s: repo stats: count %d ok %v size %d", v, n, ok, sett.GetOffboxTarget().RepoSizeBytes)
|
||||
}
|
||||
moved := false
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(r1105Fixture(t, v, "backup-status.jsonl"))), "\n") {
|
||||
p, ok := parseResticStatus(line)
|
||||
if !ok {
|
||||
t.Errorf("restic %s: a status line no longer parses: %s", v, line)
|
||||
}
|
||||
if p.TotalBytes > 0 && p.TotalFiles > 0 {
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if !moved {
|
||||
t.Errorf("restic %s: no status line carried the totals the progress bar shows", v)
|
||||
}
|
||||
}
|
||||
if counts["0.14.0"] != counts["0.18.1"] {
|
||||
t.Errorf("the two versions list a different number of snapshots of ONE repository: %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
// The clean-up plan — the ONE assumption that MOVED. On the same snapshot set, 0.18.1 removes a SUBSET of what 0.14.0
|
||||
// removes: since restic 0.17, while a --keep-monthly window has unused slots, restic also keeps the group's OLDEST
|
||||
// snapshot (reason „oldest monthly snapshot"). Measured: a 41-day history → 0.14.0 removes 62, 0.18.1 removes 60 (one
|
||||
// per group); a 230-day history (every monthly slot used) → identical plans (474 of 502, audits/debian13-restic-
|
||||
// 2026-10-16/). It only ever KEEPS more, never removes more, and what it keeps extra is old — so the fake-snapshot
|
||||
// guard (R-822/R-867), which refuses a plan that removes a YOUNG snapshot, passes the 0.18.1 plan whenever it passes
|
||||
// the 0.14.0 one. Pinned here: subset, the difference is exactly the „oldest" keeps, and the guard's verdict holds.
|
||||
func TestR1105_TheForgetPlanOnlyEverKeepsMore(t *testing.T) {
|
||||
plans := map[string]map[string]bool{}
|
||||
var all []guardSnap
|
||||
for _, v := range r1105Versions {
|
||||
m, _ := newOffboxManager(t)
|
||||
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"forget": "forget-dryrun.json", "snapshots": "snapshots.json"}))
|
||||
plan, err := m.planRemovals(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("restic %s: %v", v, err)
|
||||
}
|
||||
plans[v] = map[string]bool{}
|
||||
for _, s := range plan {
|
||||
plans[v][s.ID] = true
|
||||
}
|
||||
// Independent read of the same file: the parser drops nothing restic planned.
|
||||
var groups []struct {
|
||||
Remove []json.RawMessage `json:"remove"`
|
||||
}
|
||||
if err := json.Unmarshal(r1105Fixture(t, v, "forget-dryrun.json"), &groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 0
|
||||
for _, g := range groups {
|
||||
want += len(g.Remove)
|
||||
}
|
||||
if len(plan) != want || want == 0 {
|
||||
t.Errorf("restic %s: the parsed plan holds %d removals, restic planned %d", v, len(plan), want)
|
||||
}
|
||||
// The guard, at the minute the plans were captured: both plans pass it.
|
||||
if all == nil {
|
||||
if all, err = m.listGuardSnaps(context.Background(), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
captured := time.Date(2026, 10, 16, 18, 20, 0, 0, time.UTC)
|
||||
if _, refuse := offsiteGuard(all, plan, captured, time.Time{}, -1); refuse != "" {
|
||||
t.Errorf("restic %s: the guard refuses the honest plan: %s", v, refuse)
|
||||
}
|
||||
}
|
||||
for id := range plans["0.18.1"] {
|
||||
if !plans["0.14.0"][id] {
|
||||
t.Errorf("0.18.1 removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", id)
|
||||
}
|
||||
}
|
||||
// Every snapshot only 0.14.0 removes is one 0.18.1 keeps as the „oldest" of a window with unused slots.
|
||||
var groups []struct {
|
||||
Reasons []struct {
|
||||
Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"snapshot"`
|
||||
Matches []string `json:"matches"`
|
||||
} `json:"reasons"`
|
||||
}
|
||||
if err := json.Unmarshal(r1105Fixture(t, "0.18.1", "forget-dryrun.json"), &groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why := map[string]string{}
|
||||
for _, g := range groups {
|
||||
for _, r := range g.Reasons {
|
||||
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
|
||||
}
|
||||
}
|
||||
extra := 0
|
||||
for id := range plans["0.14.0"] {
|
||||
if plans["0.18.1"][id] {
|
||||
continue
|
||||
}
|
||||
extra++
|
||||
if !strings.Contains(why[id], "oldest") {
|
||||
t.Errorf("0.18.1 keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", id, why[id])
|
||||
}
|
||||
}
|
||||
if extra == 0 {
|
||||
t.Error("the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"message_type":"status","percent_done":0,"total_files":1,"total_bytes":41943040}
|
||||
{"message_type":"status","percent_done":0.6679942601232657,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":28017724,"current_files":["/data/x/u/a/big.tar"]}
|
||||
@@ -0,0 +1,14 @@
|
||||
using temporary cache in /data/x/cache/restic-check-cache-279810929
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
error for tree 49327794:
|
||||
decrypting blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156 failed: ciphertext verification failed
|
||||
[0:00] 100.00% 82 / 82 snapshots
|
||||
|
||||
read all data
|
||||
Pack ID does not match, want fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72, got 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948
|
||||
[0:00] 100.00% 123 / 123 packs
|
||||
|
||||
Fatal: repository contains errors
|
||||
@@ -0,0 +1,8 @@
|
||||
using temporary cache in /data/x/cache/restic-check-cache-1995998052
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
[0:00] 100.00% 82 / 82 snapshots
|
||||
|
||||
no errors were found
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
Fatal: create repository at /data/x/repo failed: config file already exists
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
unable to create lock in backend: repository is already locked exclusively by PID 2773051 on other-box by root (UID 0, GID 0)
|
||||
lock was created at 2026-10-16 18:20:13 (1.310283004s ago)
|
||||
storage ID 8cbd92f5
|
||||
the `unlock` command can be used to remove stale locks
|
||||
@@ -0,0 +1,4 @@
|
||||
unable to create lock in backend: repository is already locked by PID 2772696 on other-box by root (UID 0, GID 0)
|
||||
lock was created at 2026-10-16 18:20:11 (1.294783283s ago)
|
||||
storage ID a2cb4ce8
|
||||
the `unlock` command can be used to remove stale locks
|
||||
@@ -0,0 +1,3 @@
|
||||
Fatal: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
|
||||
Is there a repository at the following location?
|
||||
/data/x/nothing-here
|
||||
@@ -0,0 +1,2 @@
|
||||
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
|
||||
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: Get "http://localhost/file-5577006791947779410": unexpected EOF
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
|
||||
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: EOF
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
|
||||
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: EOF
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
|
||||
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: EOF
|
||||
@@ -0,0 +1 @@
|
||||
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"total_size":21,"total_file_count":12,"snapshots_count":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"total_size":217738,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
|
||||
@@ -0,0 +1 @@
|
||||
Fatal: wrong password or no key found
|
||||
@@ -0,0 +1,4 @@
|
||||
{"message_type":"status","percent_done":0.056467624048707366,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":2368425,"current_files":["/data/x/u/a/big.tar"]}
|
||||
{"message_type":"status","percent_done":0.5571281981541595,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23367662,"current_files":["/data/x/u/a/big.tar"]}
|
||||
{"message_type":"status","percent_done":1,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":41943061}
|
||||
{"message_type":"status","percent_done":1,"total_files":3,"files_done":3,"total_bytes":41943061,"bytes_done":41943061}
|
||||
@@ -0,0 +1,19 @@
|
||||
using temporary cache in /data/x/cache/restic-check-cache-3291579747
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
error for tree 49327794:
|
||||
decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed
|
||||
[0:00] 100.00% 82 / 82 snapshots
|
||||
read all data
|
||||
pack fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72 contains 2 errors: [blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156: decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed unexpected pack id 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948]
|
||||
[0:00] 100.00% 123 / 123 packs
|
||||
|
||||
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
|
||||
|
||||
restic repair packs fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72
|
||||
restic repair snapshots --forget
|
||||
|
||||
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
|
||||
Fatal: repository contains errors
|
||||
@@ -0,0 +1,7 @@
|
||||
using temporary cache in /data/x/cache/restic-check-cache-4211187088
|
||||
create exclusive lock for repository
|
||||
load indexes
|
||||
check all packs
|
||||
check snapshots, trees and blobs
|
||||
[0:00] 100.00% 82 / 82 snapshots
|
||||
no errors were found
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
Fatal: create repository at /data/x/repo failed: Fatal: unable to open repository at /data/x/repo: config file already exists
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
repo already locked, waiting up to 0s for the lock
|
||||
unable to create lock in backend: repository is already locked exclusively by PID 2775667 on other-box by root (UID 0, GID 0)
|
||||
lock was created at 2026-10-16 18:20:26 (1.312752495s ago)
|
||||
storage ID be8a3f47
|
||||
the `unlock` command can be used to remove stale locks
|
||||
@@ -0,0 +1,5 @@
|
||||
repo already locked, waiting up to 0s for the lock
|
||||
unable to create lock in backend: repository is already locked by PID 2775299 on other-box by root (UID 0, GID 0)
|
||||
lock was created at 2026-10-16 18:20:25 (1.301138628s ago)
|
||||
storage ID b41afa86
|
||||
the `unlock` command can be used to remove stale locks
|
||||
@@ -0,0 +1,3 @@
|
||||
Fatal: repository does not exist: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
|
||||
Is there a repository at the following location?
|
||||
/data/x/nothing-here
|
||||
@@ -0,0 +1,2 @@
|
||||
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
|
||||
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: exit status 255
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
|
||||
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
|
||||
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
|
||||
@@ -0,0 +1,2 @@
|
||||
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
|
||||
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
|
||||
@@ -0,0 +1 @@
|
||||
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"total_size":21,"total_file_count":12,"snapshots_count":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"total_size":217738,"total_uncompressed_size":345762,"compression_ratio":1.587972701136228,"compression_progress":100,"compression_space_saving":37.02662525089513,"total_blob_count":863,"snapshots_count":82}
|
||||
@@ -0,0 +1 @@
|
||||
Fatal: wrong password or no key found
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
FileBrowserKeptMount = "megorzott"
|
||||
// SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by
|
||||
// controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest.
|
||||
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0"
|
||||
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.2.0"
|
||||
)
|
||||
|
||||
// Images returns every controller-managed infra image, derived from the pins above so there is
|
||||
|
||||
@@ -18,6 +18,41 @@ ACTION="${2:-}"
|
||||
REGISTRY="gitea.dooplex.hu/admin"
|
||||
IMAGE="${REGISTRY}/felhom-samba"
|
||||
CTX="$(cd "$(dirname "$0")/../infra-images/samba" && pwd)"
|
||||
REPO_DIR="${FELHOM_REPO_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
||||
|
||||
# A VERSION NUMBER IS PUSHED ONCE (R-972, felhom-samba 1.2.0) — the same two checks build.sh makes for the
|
||||
# controller (v0.316.0/v0.316.4), because the box pulls an infra image by its tag and never again once it has it:
|
||||
# a second image under one number is a box that runs either and never learns which. Before any build:
|
||||
# 1. the tag must be ABSENT from the registry; a registry that cannot be asked is a refusal, not a pass;
|
||||
# 2. the tree must be clean and at origin/main — an image from an unpushed tree cannot be rebuilt.
|
||||
# Local builds (no --push) are untouched. Tested by scripts/test_samba_build_push_once.sh (a fake `docker`).
|
||||
if [[ "${ACTION}" == "--push" ]]; then
|
||||
probe_rc=0
|
||||
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
|
||||
if [[ ${probe_rc} -eq 0 ]]; then
|
||||
echo "[ERROR] ${IMAGE}:${VERSION} already exists in the registry — bump the version." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
|
||||
echo "[ERROR] Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build. The registry answered: ${probe_out}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[INFO] ${VERSION} is not present in the registry — proceeding."
|
||||
if ! git -C "${REPO_DIR}" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "[ERROR] ${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$(git -C "${REPO_DIR}" status --porcelain 2>/dev/null)" ]]; then
|
||||
echo "[ERROR] ${REPO_DIR} has uncommitted changes — refusing to push a build from it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(git -C "${REPO_DIR}" rev-parse HEAD)" != "$(git -C "${REPO_DIR}" rev-parse origin/main 2>/dev/null)" ]]; then
|
||||
echo "[ERROR] ${REPO_DIR} is not at origin/main — refusing to push." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[INFO] Source tree is clean and at origin/main."
|
||||
CTX="${REPO_DIR}/controller/infra-images/samba"
|
||||
fi
|
||||
|
||||
echo "[INFO] building ${IMAGE}:${VERSION}"
|
||||
echo "[INFO] context: ${CTX}"
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# A felhom-samba VERSION NUMBER IS PUSHED ONCE — the test for build-samba-image.sh's registry and tree checks
|
||||
# (R-972, 1.2.0). Same method as test_build_push_once.sh: a fake `docker` first on PATH drives the REAL script;
|
||||
# the fake answers `build`/`push` with a marker, so a run that got past a refusal is caught, and nothing is pushed.
|
||||
# Run from controller/: bash scripts/test_samba_build_push_once.sh
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SH="$HERE/build-samba-image.sh"
|
||||
fails=0; ran=0
|
||||
make_fake_docker() {
|
||||
mkdir -p "$1"
|
||||
cat > "$1/docker" <<'FAKE'
|
||||
#!/bin/bash
|
||||
case "$1 $2" in
|
||||
"manifest inspect")
|
||||
case "${FAKE_MODE}" in
|
||||
present) echo '{"schemaVersion":2}'; exit 0 ;;
|
||||
absent) echo "manifest unknown: manifest unknown" >&2; exit 1 ;;
|
||||
down) echo "dial tcp: connection refused" >&2; exit 1 ;;
|
||||
esac ;;
|
||||
"build "*|"push "*) echo "FAKE-DOCKER-REACHED: $*"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
FAKE
|
||||
chmod +x "$1/docker"
|
||||
}
|
||||
run() { # $1 mode, $2 repo dir ("" = default), rest = args
|
||||
local mode="$1" repo="$2"; shift 2
|
||||
local tmp; tmp="$(mktemp -d)"; make_fake_docker "$tmp/bin"
|
||||
if [[ -n "$repo" ]]; then FAKE_MODE="$mode" FELHOM_REPO_DIR="$repo" PATH="$tmp/bin:$PATH" bash "$SH" "$@" >"$tmp/out" 2>&1
|
||||
else FAKE_MODE="$mode" PATH="$tmp/bin:$PATH" bash "$SH" "$@" >"$tmp/out" 2>&1; fi
|
||||
rc=$?; out="$(cat "$tmp/out")"; rm -rf "$tmp"
|
||||
}
|
||||
check() { # name, want_rc, want_text, forbid_text
|
||||
ran=$((ran+1))
|
||||
if [[ "$rc" != "$2" ]] || { [[ -n "$3" ]] && ! grep -qi -- "$3" <<<"$out"; } || { [[ -n "${4:-}" ]] && grep -qi -- "$4" <<<"$out"; }; then
|
||||
fails=$((fails+1)); echo " FAIL $1 (exit $rc)"; echo "$out" | tail -4 | sed 's/^/ /'; return
|
||||
fi
|
||||
echo " ok $1"
|
||||
}
|
||||
scratch_repo() {
|
||||
local d; d="$(mktemp -d)"; git -C "$d" init -q; git -C "$d" config user.email t@t; git -C "$d" config user.name t
|
||||
mkdir -p "$d/controller/infra-images/samba"; echo x > "$d/controller/infra-images/samba/Dockerfile"
|
||||
git -C "$d" add -A >/dev/null; git -C "$d" commit -qm one >/dev/null; git -C "$d" update-ref refs/remotes/origin/main HEAD; echo "$d"
|
||||
}
|
||||
echo "build-samba-image.sh — a version number is pushed once, from a clean tree"
|
||||
R="$(scratch_repo)"
|
||||
run present "$R" 9.9.9 --push; check "tag present -> refuses" 1 "already exists in the registry" "FAKE-DOCKER-REACHED"
|
||||
run down "$R" 9.9.9 --push; check "registry unreachable -> refuses" 1 "Could not establish whether" "FAKE-DOCKER-REACHED"
|
||||
run absent "$R" 9.9.9 --push; check "tag absent, clean tree -> builds and pushes" 0 "FAKE-DOCKER-REACHED: push" "refusing"
|
||||
echo dirt > "$R/controller/infra-images/samba/extra"
|
||||
run absent "$R" 9.9.9 --push; check "dirty tree -> refuses" 1 "uncommitted changes" "FAKE-DOCKER-REACHED"
|
||||
rm -f "$R/controller/infra-images/samba/extra"; echo y > "$R/controller/f2"; git -C "$R" add -A >/dev/null; git -C "$R" commit -qm two >/dev/null
|
||||
run absent "$R" 9.9.9 --push; check "ahead of origin/main -> refuses" 1 "not at origin/main" "FAKE-DOCKER-REACHED"
|
||||
rm -rf "$R"
|
||||
run present "" 9.9.9; check "local build, tag present -> not refused" 0 "FAKE-DOCKER-REACHED: build" "already exists"
|
||||
echo
|
||||
[[ $fails -eq 0 ]] && { echo "all $ran cases behaved"; exit 0; }
|
||||
echo "$fails of $ran cases FAILED"; exit 1
|
||||
@@ -0,0 +1,63 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
JPO/wKqyIDcKxEZq978lxWDmCwGc
|
||||
=SIvp
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
Reference in New Issue
Block a user