v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx -> paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063: finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409 come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z. Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
@@ -1,3 +1,45 @@
|
||||
## v0.329.0 — each app reaches only its own data folder; homepage loses the Docker socket; a refused drive retirement changes nothing; a plain message for a virtual disk without a serial; `:latest` only for a release (R-1142, R-981, R-1063, R-1069, R-1143) (2026-10-18)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls a new agent route. No golden (`09` §3 decision 250).
|
||||
Task: the night brief of 2026-10-17 (felhom.eu `REPORT-night-2026-10-18.md`). **CATALOG ORDER:** the catalog commit that
|
||||
drops homepage's socket and carries the new list copy is pushed FIRST — this controller refuses the old homepage
|
||||
template (the socket has no exception any more); an older controller accepts the new template.
|
||||
|
||||
- **R-1142 — the data root under `${HDD_PATH}` is the app's OWN folder.** `privpolicy` judges a bind under
|
||||
`${HDD_PATH}/appdata/<folder>/` only, `<folder>` being the app's name or its entry in the list's new
|
||||
`appdata_folders` map (one entry: `paperless-ngx` → `paperless`; the survey of every template, the drill catalog and
|
||||
the three test boxes found no other slug ≠ folder — felhom.eu `audits/night-2026-10-18/P1/`). The map is compiled in
|
||||
with the list (`allowlist.json` v2), because the catalog is the threat and must not name the folder. An app with no
|
||||
usable name gets no `HDD_PATH` root (fail closed). `Check` now takes the app. Tests:
|
||||
`TestAdmit_AnotherAppsAppdataFolderIsRefused` (app A binding app B's folder, the whole `appdata/`, a look-alike
|
||||
name, the slug where a folder is listed, a literal path to another app → refused; own folder, sub-folder, listed
|
||||
folder → admitted), `TestAllowList_AppdataFolderMustBeOneName`. Red-proof: root back at `${HDD_PATH}/appdata` → six
|
||||
cases admitted.
|
||||
- **R-981 — the allow-list is EMPTY.** Homepage's read-only Docker socket served no Felhom feature: on demo-hp its
|
||||
rendered `docker.yaml` is empty and no container carries `homepage.*` labels (`audits/night-2026-10-18/P1/
|
||||
r981-homepage-before.txt`). The catalog template drops the mount; the list drops the entry, so the socket is refused
|
||||
for every app. The entry-binding rules (image, start, healthcheck) stay pinned on a test list
|
||||
(`withTestAllowList`), so a future entry is held to them. `TestAllowList_HasNoExceptionAndRefusesHomepagesSocket`;
|
||||
red-proof: the old one-entry list compiled in → fails.
|
||||
- **R-1063 — „Végleges leszerelés" asks the agent FIRST.** `finalizeDecommissionWith` marks the drive decommissioned and
|
||||
moves the default only after the agent's yes. The agent's 403 (protected role) and 409 (the drive holds the system
|
||||
backup) come back typed (`agentapi.DecommissionRefusedError`) and reach the household as a 409 with a plain sentence
|
||||
(`err.web.decommission_protected`, `err.web.decommission_backup_target`, HU + EN): nothing changed, and what to do.
|
||||
`TestFinalizeDecommission_AgentRefusalChangesNothing` (403, 409, a transport failure), `TestDecommission_
|
||||
PolicyRefusalsAreTyped` (a fake agent over TLS). Red-proof: the old order → the drive reads decommissioned, the
|
||||
default moved.
|
||||
- **R-1069 — a virtual disk without a serial gets a plain refusal.** The agent's anti-retarget refusal of a blank
|
||||
format („device has no durable id …", 409) is `agentapi.ErrNoDurableID`; the format endpoint, the drive init and the
|
||||
wipe say `disk.err.no_durable_id` (HU + EN: the dashboard cannot format it, the disk did not change, the operator or
|
||||
whoever set up the virtual machine can give it a serial number or a SATA bus). The agent's rule is unchanged.
|
||||
`TestR1069_NoDurableIDIsAPlainRefusal`, `TestFormatDisk_NoDurableIDIsTyped`. No page changed: the text travels in
|
||||
the JSON answer, so no new fixture.
|
||||
- **R-1143 — `build.sh` moves `:latest` only for a release tag (x.y.z).** One `TAGS` array feeds the build, the push and
|
||||
the multiarch leg; `--print-tags` prints it and stops. `scripts/test_build_push_once.sh` (16 cases): rc/dev/`0.999`
|
||||
carry no `:latest`, `0.999.0` does; the test now runs a COPY of `build.sh` (in place, its workspace was inside the
|
||||
source it copies — the „local build" case failed on HEAD too). Red-proof: every build adding `:latest` → 4 cases fail.
|
||||
- i18n: three keys born as keys (`scripts/i18n_go_keys.json`); every page fixture byte-identical.
|
||||
|
||||
## v0.328.0 — the catalog allow-list: the box refuses container rights a template asks for and the box does not give (R-971, operator decision D2 = A) (2026-10-17)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls a new agent route. No golden (`09` §3 decision 250).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# CONTEXT.md — Project Memory
|
||||
|
||||
|
||||
> **2026-10-18 (night) — v0.329.0: each app reaches only its own `appdata/<folder>` (R-1142, compiled-in folder map:
|
||||
> paperless-ngx → paperless); the allow-list is EMPTY (homepage's socket removed, R-981); a drive retirement asks the agent
|
||||
> first and a refusal changes nothing (R-1063); a plain message for a virtio disk without a serial (R-1069); `:latest`
|
||||
> only for a release tag (R-1143).** MinAgent 0.158.1.
|
||||
> **2026-10-17 (late) — v0.328.0 is the floor (MinAgent 0.158.1): the catalog allow-list (R-971, operator decision D2 = A).**
|
||||
> `internal/privpolicy` refuses container rights not on the compiled list before ANY compose write; refusal = nothing
|
||||
> written + `app_definition_refused` (hub ≥ 0.157.0) + an app-page line. Three security-review rounds fixed before the
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
# REPORT — v0.328.0: the catalog allow-list (R-971, operator decision D2 = A) (2026-10-17)
|
||||
# REPORT — controller v0.329.0 (night 2026-10-17 → 18)
|
||||
|
||||
Written in ASD-STE100 Simplified Technical English. Session report: felhom.eu `REPORT-agent-allowlist-rotation-2026-10-17.md`.
|
||||
Evidence: felhom.eu `documentation/audits/agent-allowlist-rotation-2026-10-17/P2/`, `P5/`.
|
||||
**In plain words.** Each app can now reach only its own data folder on the drive. Homepage no longer gets control of
|
||||
Docker, so no app has a special right any more. When the host says „no" to retiring a drive, nothing changes on the
|
||||
dashboard and the household reads why. A virtual disk that cannot be formatted gets a plain sentence instead of an
|
||||
English error. Test builds no longer move the `:latest` tag. The full night report: felhom.eu
|
||||
`REPORT-night-2026-10-18.md`.
|
||||
|
||||
- **What:** `internal/privpolicy` checks the RENDERED compose before every compose write and env decision (sync,
|
||||
update/ladder step, install, two settings changes, two restores — before the stop) against `allowlist.json`
|
||||
(go:embed; one entry: homepage's read-only socket, bound to its image, start and healthcheck). Refusal: nothing
|
||||
written, operator event `app_definition_refused` (hub ≥ v0.157.0), app-page line, plain message. Sync never copies a
|
||||
template named like an infra stack.
|
||||
- **Tests:** `privpolicy_test.go` (every class, positive controls, the three review rounds, the live root finding),
|
||||
`TestAllowList_IsWiredIntoEveryComposeWriter` (AST), `sync.TestR971_*`, `stacks.TestAllowList_*`,
|
||||
`TestR971_AppPage_*`; full suite green on `99f9117`. Red-proofs in P2.
|
||||
- **i18n:** new fixture `app_settings_definition_refused`; every other fixture byte-identical. Keys
|
||||
`err.stacks.definition_refused`, `app_info.definition_refused` in both bundles.
|
||||
- **Release:** rc.1–rc.5 on demo-hp 9201; 0.328.0 to Tester 1, demo-hp, demo-felhom by per-box floors (all three in
|
||||
< 1 min), box walk green (0 refusals, all containers healthy, login and files 200), https-only green; global floor
|
||||
0.328.0, MinAgent 0.158.1; per-box floors cleared; read back from the hub DB with its WAL.
|
||||
- **9201 proof:** drill catalog with `privileged: true` on privatebin → refused; compose sha and container unchanged;
|
||||
event at the hub + operator mail; page line on privatebin only; live catalog restored byte-identical.
|
||||
| Row | What changed | Pinned by | Red-proof |
|
||||
|---|---|---|---|
|
||||
| R-1142 | `${HDD_PATH}` root = `appdata/<own folder>`; compiled-in map `paperless-ngx → paperless` | `TestAdmit_AnotherAppsAppdataFolderIsRefused`, `TestAllowList_AppdataFolderMustBeOneName` | root back at `appdata/` → 6 cases admitted |
|
||||
| R-981 | allow-list EMPTY; homepage template has no socket (catalog) | `TestAllowList_HasNoExceptionAndRefusesHomepagesSocket` | old one-entry list → „1 entries" |
|
||||
| R-1063 | agent asked first; 403/409 → plain 409, nothing changed | `TestFinalizeDecommission_AgentRefusalChangesNothing`, `TestDecommission_PolicyRefusalsAreTyped` | old order → drive marked, default moved |
|
||||
| R-1069 | agent's no-durable-id refusal → `disk.err.no_durable_id` | `TestR1069_NoDurableIDIsAPlainRefusal`, `TestFormatDisk_NoDurableIDIsTyped` | branch removed → raw text reaches the household |
|
||||
| R-1143 | `:latest` only for x.y.z | `scripts/test_build_push_once.sh` (16 cases) | every build adds `:latest` → 4 cases fail |
|
||||
|
||||
## Observations
|
||||
|
||||
1. rc.4 refused every not-installed app on the box (placeholder roots do not exist there). NOT-A-FINDING: found by this session's own live proof and fixed in rc.5 before the release, red-proved.
|
||||
2. One app can still bind another app's `appdata/<name>`. FILED: R-1142.
|
||||
3. `build.sh` pushes `:latest` with every build, rc included. FILED: R-1143.
|
||||
Red-proof records: felhom.eu `documentation/audits/night-2026-10-18/P1/redproof-*.txt`. Green gate rc=0;
|
||||
`controller_gates.py --fast` rc=0 (golden-notice advisory). i18n: three keys born as keys; every page fixture
|
||||
byte-identical. MinAgent 0.158.1. Release path and box walk: the night report.
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting) and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
|
||||
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting; EMPTY since v0.329.0) plus `appdata_folders` (an app's `${HDD_PATH}/appdata/<folder>` when the folder is not its name — `privpolicy.AppdataFolder`, R-1142), and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
|
||||
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
|
||||
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
|
||||
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
|
||||
@@ -241,7 +241,7 @@
|
||||
| `agentapi.New` | controller/internal/agentapi/client.go | `(endpoint, token, fingerprintHex) (*Client, error)` | Pinned-TLS client to felhom-agent | Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix) |
|
||||
| `Server.agentClient` | controller/internal/web/agent_disk_handlers.go | `() (*agentapi.Client, error)` | THE memoized client accessor | Always use this, never a fresh `agentapi.New` per request (§3) |
|
||||
| `Server.ProbeAgentChannel` | controller/internal/web/agent_disk_handlers.go | `(ctx) (constructionErr bool, err error)` | Channel health probe | Probes via the PRODUCTION client on purpose (self-heals, mirrors UI) |
|
||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only). Agent re-checks role server-side |
|
||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only) / `ErrNoDurableID` (v0.329.0, R-1069: a blank disk with no durable id, e.g. virtio without a serial — show `disk.err.no_durable_id`). Agent re-checks role server-side. `Client.Decommission` returns `*DecommissionRefusedError{Status 403|409}` for the agent's two policy refusals (R-1063) — ask the agent BEFORE marking anything |
|
||||
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
|
||||
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
||||
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
||||
|
||||
@@ -1191,9 +1191,11 @@ correction, an update or ladder step, a unit or off-site restore — first rende
|
||||
(`network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`), `security_opt` other than `no-new-privileges`, `sysctls`,
|
||||
`volumes_from`, `build`/`include`/`extends` files, an `env_file` or config file outside the stack directory, a named
|
||||
volume with a driver/`driver_opts`/`external`/`name`, a `${VAR}` inside any of those, and any bind outside the data
|
||||
roots the box itself gives (`${HDD_PATH}`, `${USERDATA_PATH}`, `${IMPORT_PATH}`) or of the Docker socket. Such a setting
|
||||
is refused unless this app's service has EXACTLY that setting on the list compiled into the controller
|
||||
(`internal/privpolicy/allowlist.json` — today one entry: homepage's read-only Docker socket). A refusal writes nothing:
|
||||
roots the box itself gives (`${HDD_PATH}/appdata/<the app's own folder>`, `${USERDATA_PATH}`, `${IMPORT_PATH}`) or of the
|
||||
Docker socket. The folder is the app's name unless the list's `appdata_folders` names it (paperless-ngx → `paperless`;
|
||||
v0.329.0, R-1142), so one app cannot bind another app's files. Such a setting is refused unless this app's service has
|
||||
EXACTLY that setting on the list compiled into the controller (`internal/privpolicy/allowlist.json` — EMPTY since
|
||||
v0.329.0: homepage's Docker socket served no Felhom feature and is gone, R-981). A refusal writes nothing:
|
||||
a refused correction or update leaves the app running on its last good compose, raises the operator-only hub event
|
||||
`app_definition_refused` once per refused definition, and puts one line on the app page; a refused install, settings
|
||||
change or restore is refused with a plain message („Semmi nem változott"). A catalog template named like a controller
|
||||
|
||||
+22
-12
@@ -12,6 +12,9 @@
|
||||
# ./build.sh 0.1.0 # Build with version tag
|
||||
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
|
||||
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
|
||||
# ./build.sh 0.1.0 --print-tags # Print the tags a build would carry, and stop (the R-1143 test)
|
||||
#
|
||||
# :latest is carried ONLY by a release tag (x.y.z). An -rc, dev or any other tag never moves it (R-1143).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -41,6 +44,21 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
|
||||
# --- The tags this build carries (R-1143, 2026-10-18) ---
|
||||
# Until v0.328.0 every build moved :latest, release candidates included (rc.1–rc.5 each did on 2026-10-17). Nothing
|
||||
# current pulls :latest — the update button takes the highest x.y.z tag — but a legacy-shaped box following it would
|
||||
# have taken an rc. So :latest moves with a RELEASE only. Pinned by scripts/test_build_push_once.sh.
|
||||
TAGS=("${IMAGE}:${VERSION}")
|
||||
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TAGS+=("${IMAGE}:latest")
|
||||
fi
|
||||
TAG_ARGS=()
|
||||
for t in "${TAGS[@]}"; do TAG_ARGS+=(-t "$t"); done
|
||||
if [[ "${ACTION}" == "--print-tags" ]]; then
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
|
||||
error "Controller source not found: ${CONTROLLER_SRC}"
|
||||
@@ -243,14 +261,10 @@ BUILD_ARGS=(
|
||||
case "${ACTION}" in
|
||||
--push)
|
||||
info "Building for current platform + pushing..."
|
||||
docker build "${BUILD_ARGS[@]}" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
.
|
||||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||
|
||||
info "Pushing..."
|
||||
docker push "${IMAGE}:${VERSION}"
|
||||
docker push "${IMAGE}:latest"
|
||||
for t in "${TAGS[@]}"; do docker push "$t"; done
|
||||
;;
|
||||
|
||||
--multiarch)
|
||||
@@ -266,18 +280,14 @@ case "${ACTION}" in
|
||||
|
||||
docker buildx build "${BUILD_ARGS[@]}" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${TAG_ARGS[@]}" \
|
||||
--push \
|
||||
.
|
||||
;;
|
||||
|
||||
*)
|
||||
info "Building for current platform (local only)..."
|
||||
docker build "${BUILD_ARGS[@]}" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
.
|
||||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -429,6 +429,13 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||
|
||||
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
|
||||
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
|
||||
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
|
||||
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
|
||||
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
|
||||
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
|
||||
|
||||
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||
@@ -779,6 +786,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, err
|
||||
}
|
||||
if err := refusalError("/disks/decommission", status, env); err != nil {
|
||||
if status == http.StatusForbidden || status == http.StatusConflict {
|
||||
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
|
||||
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
|
||||
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
@@ -787,6 +799,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
|
||||
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
|
||||
type DecommissionRefusedError struct {
|
||||
Status int
|
||||
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
|
||||
}
|
||||
|
||||
func (e *DecommissionRefusedError) Error() string {
|
||||
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
|
||||
}
|
||||
|
||||
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
||||
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
||||
func refusalError(path string, status int, env apiResponse) error {
|
||||
@@ -794,16 +817,21 @@ func refusalError(path string, status int, env apiResponse) error {
|
||||
if accepted && env.OK {
|
||||
return nil
|
||||
}
|
||||
reason := truncateErr(env.Error, 300)
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
reason := refusalReason(env)
|
||||
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
||||
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
||||
}
|
||||
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
||||
}
|
||||
|
||||
// refusalReason is the agent's refusal text as refusalError carries it.
|
||||
func refusalReason(env apiResponse) string {
|
||||
if r := truncateErr(env.Error, 300); r != "" {
|
||||
return r
|
||||
}
|
||||
return "(no reason in agent response)"
|
||||
}
|
||||
|
||||
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
||||
func truncateErr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
@@ -850,6 +878,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
||||
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
|
||||
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
|
||||
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
|
||||
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
|
||||
return out, ErrNoDurableID
|
||||
}
|
||||
if status < 200 || status >= 300 || !env.OK {
|
||||
msg := strings.TrimSpace(env.Error)
|
||||
if msg == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,3 +36,52 @@ func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
|
||||
// target), with the same text as before; any other failure is not that type.
|
||||
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
code int
|
||||
typed bool
|
||||
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(c.code)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
|
||||
s.Close()
|
||||
var dr *DecommissionRefusedError
|
||||
if got := errors.As(err, &dr); got != c.typed {
|
||||
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
|
||||
}
|
||||
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
|
||||
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
|
||||
// another 409 does not.
|
||||
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
body string
|
||||
typed bool
|
||||
}{
|
||||
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
|
||||
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
|
||||
} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
|
||||
s.Close()
|
||||
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
|
||||
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1235,6 +1235,7 @@
|
||||
"disk.err.device_required": "a device is required",
|
||||
"disk.err.init_in_progress": "a drive is already being initialised",
|
||||
"disk.err.name_mismatch": "the name you typed does not match the mount name",
|
||||
"disk.err.no_durable_id": "This disk cannot be formatted from the dashboard: the virtual machine gives it no fixed name (serial number), so Felhom cannot identify it for certain. The disk was not changed. The operator, or the person who set up the virtual machine, can give the disk a serial number (or attach it on a SATA bus); then try again.",
|
||||
"disk.err.protected": "this drive is protected (system / backup) — erasing it needs an operator signature",
|
||||
"disk.err.target_required": "a target storage is required for the move",
|
||||
"disk.err.unknown_mode": "unknown mode (migrate or anyway)",
|
||||
@@ -1427,6 +1428,8 @@
|
||||
"err.web.a_formazas_nem_indult_el_az": "the format did not start on the device (%s)",
|
||||
"err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "no file-system id was found for the drive you picked (only a drive that already has a file system can be attached)",
|
||||
"err.web.a_meghajto_leszerelese_sikertelen": "decommissioning the drive failed: %s",
|
||||
"err.web.decommission_backup_target": "This drive holds the system backup, so it cannot be retired now. First choose another drive as the place for the system backup, then try again. Nothing was changed.",
|
||||
"err.web.decommission_protected": "The system protects this drive (it is a system or backup disk), so it cannot be retired from here. Nothing was changed.",
|
||||
"err.web.a_meghajto_nem_talalhato": "the drive was not found",
|
||||
"err.web.a_meghajtok_listaja_nem_olvashato": "the list of drives cannot be read",
|
||||
"err.web.csatlakoztatas_sikertelen": "mounting failed: %s",
|
||||
|
||||
@@ -1230,6 +1230,7 @@
|
||||
"disk.err.device_required": "eszköz kötelező",
|
||||
"disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás",
|
||||
"disk.err.name_mismatch": "a beírt név nem egyezik a csatlakoztatási névvel",
|
||||
"disk.err.no_durable_id": "Ezt a lemezt a vezérlőpultról nem lehet formázni: a virtuális gép nem ad neki állandó nevet (sorozatszámot), ezért a Felhom nem tudja biztosan azonosítani. A lemez nem változott. Az üzemeltető vagy az, aki a virtuális gépet beállította, adhat a lemeznek sorozatszámot (vagy SATA-buszon csatolhatja), utána próbáld újra.",
|
||||
"disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással",
|
||||
"disk.err.target_required": "céltároló kötelező az áthelyezéshez",
|
||||
"disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)",
|
||||
@@ -1418,6 +1419,8 @@
|
||||
"err.web.a_formazas_nem_indult_el_az": "a formázás nem indult el az eszközön (%s)",
|
||||
"err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)",
|
||||
"err.web.a_meghajto_leszerelese_sikertelen": "a meghajtó leszerelése sikertelen: %s",
|
||||
"err.web.decommission_backup_target": "Ezen a meghajtón van a rendszermentés, ezért most nem szerelhető le. Előbb jelölj ki egy másik meghajtót a rendszermentés helyéül, utána próbáld újra. Semmi nem változott.",
|
||||
"err.web.decommission_protected": "Ezt a meghajtót a rendszer védi (rendszer- vagy mentési lemez), ezért innen nem szerelhető le. Semmi nem változott.",
|
||||
"err.web.a_meghajto_nem_talalhato": "a meghajtó nem található",
|
||||
"err.web.a_meghajtok_listaja_nem_olvashato": "a meghajtók listája nem olvasható",
|
||||
"err.web.csatlakoztatas_sikertelen": "csatlakoztatás sikertelen: %s",
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
|
||||
"entries": [
|
||||
{
|
||||
"app": "homepage",
|
||||
"service": "homepage",
|
||||
"setting": "bind /var/run/docker.sock -> /var/run/docker.sock ro",
|
||||
"image": "ghcr.io/gethomepage/homepage",
|
||||
"healthcheck": "[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]",
|
||||
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
|
||||
"doc": "https://gethomepage.dev/configs/docker/"
|
||||
}
|
||||
]
|
||||
"version": 2,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. EMPTY since R-981 (2026-10-18): homepage's Docker socket was used by no Felhom feature and is gone, so no app has an exception. appdata_folders (R-1142): an app's bind root under ${HDD_PATH} is appdata/<its own folder>/, the folder being its name unless listed here. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md and audits/night-2026-10-18/P1/",
|
||||
"entries": [],
|
||||
"appdata_folders": {
|
||||
"paperless-ngx": "paperless"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
//
|
||||
// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each
|
||||
// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables,
|
||||
// and only as its leading token.
|
||||
// and only as its leading token. Under HDD_PATH the root is the app's OWN folder, ${HDD_PATH}/appdata/<folder>/ (R-1142):
|
||||
// <folder> is the app's name, or its entry in the list's appdata_folders (paperless-ngx keeps appdata/paperless) — so a
|
||||
// catalog push cannot hand one app another app's files.
|
||||
//
|
||||
// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate,
|
||||
// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope.
|
||||
@@ -69,11 +71,14 @@ type Entry struct {
|
||||
type allowList struct {
|
||||
Version int `json:"version"`
|
||||
Entries []Entry `json:"entries"`
|
||||
// AppdataFolders names an app's folder under ${HDD_PATH}/appdata/ when it is not the app's own name (R-1142). It is
|
||||
// compiled in with the entries for the same reason: the catalog — the threat — must not be able to name the folder.
|
||||
AppdataFolders map[string]string `json:"appdata_folders"`
|
||||
}
|
||||
|
||||
var allowed = mustLoad(allowListJSON)
|
||||
var allowed, appdataFolders = mustLoad(allowListJSON)
|
||||
|
||||
func mustLoad(b []byte) map[string]Entry {
|
||||
func mustLoad(b []byte) (map[string]Entry, map[string]string) {
|
||||
var l allowList
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
panic("privpolicy: allowlist.json does not parse: " + err.Error())
|
||||
@@ -85,7 +90,29 @@ func mustLoad(b []byte) map[string]Entry {
|
||||
}
|
||||
m[key(e.App, e.Service, e.Setting)] = e
|
||||
}
|
||||
return m
|
||||
for app, f := range l.AppdataFolders {
|
||||
if !oneSegment(f) {
|
||||
panic("privpolicy: allowlist.json appdata folder of " + app + " is not one folder name: " + f)
|
||||
}
|
||||
}
|
||||
return m, l.AppdataFolders
|
||||
}
|
||||
|
||||
// oneSegment: a single folder name — no separator, not "." or "..", not empty.
|
||||
func oneSegment(s string) bool {
|
||||
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\$")
|
||||
}
|
||||
|
||||
// AppdataFolder is the app's own folder under ${HDD_PATH}/appdata/ (R-1142): its appdata_folders entry, else its name.
|
||||
// "" when the app has no usable name — then HDD_PATH gives it no root at all (fail closed).
|
||||
func AppdataFolder(app string) string {
|
||||
if f, ok := appdataFolders[app]; ok {
|
||||
return f
|
||||
}
|
||||
if !oneSegment(app) {
|
||||
return ""
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
|
||||
@@ -156,7 +183,7 @@ func PlaceholderEnv() map[string]string {
|
||||
// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a
|
||||
// definition it has checked).
|
||||
func Admit(app string, compose []byte, env map[string]string, p Policy) error {
|
||||
vs, err := Check(compose, env, p)
|
||||
vs, err := Check(app, compose, env, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
|
||||
}
|
||||
@@ -230,13 +257,13 @@ func imageRepo(ref string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// Check lists every setting in a class, allowed or not, sorted.
|
||||
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
// Check lists every setting in a class, allowed or not, sorted. app decides the HDD_PATH root (R-1142).
|
||||
func Check(app string, compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(compose, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &checker{env: env, p: p}
|
||||
c := &checker{env: env, p: p, app: app}
|
||||
c.roots = c.validRoots()
|
||||
if _, ok := doc["include"]; ok {
|
||||
c.add("", "include")
|
||||
@@ -276,6 +303,7 @@ func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error)
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
app string
|
||||
env map[string]string
|
||||
p Policy
|
||||
roots []string
|
||||
@@ -516,7 +544,12 @@ func (c *checker) validRoots() []string {
|
||||
// round 3: HDD_PATH is the drive's whole namespace — its backups/ (recovery units carry app secrets)
|
||||
// and every other top-level folder are siblings an app has no business binding. Only appdata/ is a
|
||||
// root; the household's folders come through USERDATA_PATH.
|
||||
v = filepath.Join(v, "appdata")
|
||||
// R-1142: and only the app's OWN folder in it — appdata/<other app>/ is another app's files.
|
||||
f := AppdataFolder(c.app)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
v = filepath.Join(v, "appdata", f)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
@@ -176,6 +176,21 @@ func TestAdmit_TopLevelAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testAllowList is the shape the box had until R-981 (one entry, homepage's read-only socket). The compiled list is
|
||||
// empty since then; the entry-binding rules are still pinned on this test list, so a future entry is held to them.
|
||||
const testAllowList = `{"version":2,"entries":[{"app":"homepage","service":"homepage",
|
||||
"setting":"bind /var/run/docker.sock -> /var/run/docker.sock ro","image":"ghcr.io/gethomepage/homepage",
|
||||
"healthcheck":"[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]"}],
|
||||
"appdata_folders":{"paperless-ngx":"paperless"}}`
|
||||
|
||||
// withTestAllowList swaps the compiled list for testAllowList for one test.
|
||||
func withTestAllowList(t *testing.T) {
|
||||
t.Helper()
|
||||
oldA, oldF := allowed, appdataFolders
|
||||
allowed, appdataFolders = mustLoad([]byte(testAllowList))
|
||||
t.Cleanup(func() { allowed, appdataFolders = oldA, oldF })
|
||||
}
|
||||
|
||||
const homepage = `services:
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:v1.13.2
|
||||
@@ -191,6 +206,7 @@ volumes:
|
||||
// The allow-list is EXACT: homepage's read-only socket is allowed for homepage's service only; the same socket
|
||||
// read-write, under another service, or in another app is refused.
|
||||
func TestAdmit_TheAllowListIsExact(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
if err := admit(t, "homepage", homepage, env()); err != nil {
|
||||
t.Fatalf("the allowed homepage socket was refused: %v", err)
|
||||
}
|
||||
@@ -273,6 +289,7 @@ func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
|
||||
// Security review 2026-10-17, finding 1: an allow-list entry holds only for its image and the image's own start. The
|
||||
// same allowed socket under a swapped image, or with an entrypoint or command added, is refused.
|
||||
func TestAdmit_TheAllowedSettingIsBoundToItsImageAndStart(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
for name, compose := range map[string]string{
|
||||
"another image": strings.Replace(homepage, "ghcr.io/gethomepage/homepage:v1.13.2", "docker.io/evil/homepage:v1.13.2", 1),
|
||||
"added entrypoint": strings.Replace(homepage, " volumes:", " entrypoint: [\"sh\", \"-c\", \"id\"]\n volumes:", 1),
|
||||
@@ -353,6 +370,7 @@ func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) {
|
||||
|
||||
// Round 2 of the security review: the gaps found in the fixes.
|
||||
func TestAdmit_Round2(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
refuse := map[string]struct{ app, compose string }{
|
||||
"a second service extends the allowed one": {"homepage", strings.Replace(homepage, "volumes:\n homepage_config:", " evil:\n image: evil/x:1\n extends:\n service: homepage\nvolumes:\n homepage_config:", 1)},
|
||||
"the allowed service's healthcheck runs a shell": {"homepage", strings.Replace(homepage, `["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]`, `["CMD-SHELL", "id"]`, 1)},
|
||||
@@ -438,3 +456,81 @@ func TestAdmit_RootsThatDoNotExistYet(t *testing.T) {
|
||||
t.Fatalf("binds under roots that do not exist yet were refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-981 (2026-10-18): homepage's Docker socket served no Felhom feature (its rendered docker.yaml is empty —
|
||||
// audits/night-2026-10-18/P1/r981-homepage-before.txt), so the compiled list has NO exception: the socket is refused
|
||||
// for homepage too, read-only or not. The catalog template no longer mounts it (TheWholeCatalogPasses).
|
||||
// RED-PROOF: with the old one-entry allowlist.json compiled in, this test fails („homepage's socket was admitted").
|
||||
func TestAllowList_HasNoExceptionAndRefusesHomepagesSocket(t *testing.T) {
|
||||
if n := len(Entries()); n != 0 {
|
||||
t.Fatalf("the compiled allow-list has %d entries; R-981 left it empty", n)
|
||||
}
|
||||
if err := admit(t, "homepage", homepage, env()); err == nil {
|
||||
t.Fatal("homepage's socket was admitted")
|
||||
}
|
||||
without := strings.Replace(homepage, " - /var/run/docker.sock:/var/run/docker.sock:ro\n", "", 1)
|
||||
if err := admit(t, "homepage", without, env()); err != nil {
|
||||
t.Fatalf("homepage without the socket was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1142: under ${HDD_PATH} an app reaches its OWN folder only — appdata/<its name>/, or the folder the list names
|
||||
// (paperless-ngx keeps appdata/paperless). Another app's folder, a name that only starts like its own, and a bind
|
||||
// for an app with no usable name are refused.
|
||||
// RED-PROOF: with validRoots' root back at ${HDD_PATH}/appdata (the v0.328.0 shape), the three „another app" cases
|
||||
// are admitted and the test fails.
|
||||
func TestAdmit_AnotherAppsAppdataFolderIsRefused(t *testing.T) {
|
||||
bind := func(line string) string {
|
||||
return "services:\n app:\n image: x/y:1\n volumes:\n - " + line + "\n"
|
||||
}
|
||||
refuse := map[string]struct{ app, line string }{
|
||||
"another app's folder": {"immich", "${HDD_PATH}/appdata/nextcloud:/data"},
|
||||
"another app's sub-folder": {"romm", "${HDD_PATH}/appdata/paperless/media:/m"},
|
||||
"the whole appdata": {"immich", "${HDD_PATH}/appdata:/all"},
|
||||
"a name that starts like its own": {"immich", "${HDD_PATH}/appdata/immich-other:/x"},
|
||||
"the slug where a folder is set": {"paperless-ngx", "${HDD_PATH}/appdata/paperless-ngx:/x"},
|
||||
"a literal path to another app": {"immich", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/x"},
|
||||
"no app name": {"", "${HDD_PATH}/appdata/x:/x"},
|
||||
"an app name with a slash": {"a/b", "${HDD_PATH}/appdata/a/b:/x"},
|
||||
}
|
||||
for name, c := range refuse {
|
||||
t.Run("refused/"+name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, bind(c.line), env()); err == nil {
|
||||
t.Fatalf("%s binding %s was admitted", c.app, c.line)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, c := range map[string]struct{ app, line string }{
|
||||
"its own folder": {"immich", "${HDD_PATH}/appdata/immich:/usr/src/app/upload"},
|
||||
"its own sub-folder": {"romm", "${HDD_PATH}/appdata/romm/resources:/romm/resources"},
|
||||
"the listed folder": {"paperless-ngx", "${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media"},
|
||||
"a literal path to its own": {"nextcloud", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/d"},
|
||||
"USERDATA is still shared": {"immich", "${USERDATA_PATH}/media/photos:/external/photos:ro"},
|
||||
} {
|
||||
t.Run("admitted/"+name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, bind(c.line), env()); err != nil {
|
||||
t.Fatalf("%s binding %s was refused: %v", c.app, c.line, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The folder map loads only single folder names (a list entry naming a path would hand out a wider root).
|
||||
func TestAllowList_AppdataFolderMustBeOneName(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "a/b", "../x", "${X}"} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("appdata folder %q was loaded", bad)
|
||||
}
|
||||
}()
|
||||
mustLoad([]byte(`{"version":2,"entries":[],"appdata_folders":{"app":"` + bad + `"}}`))
|
||||
}()
|
||||
}
|
||||
if got := AppdataFolder("paperless-ngx"); got != "paperless" {
|
||||
t.Errorf("AppdataFolder(paperless-ngx) = %q", got)
|
||||
}
|
||||
if got := AppdataFolder("immich"); got != "immich" {
|
||||
t.Errorf("AppdataFolder(immich) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,11 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrNoDurableID) {
|
||||
s.logger.Printf("[INFO] [web] disk format refused by agent: %s has no durable id (a virtual disk without a serial, R-1069)", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||
s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
||||
|
||||
@@ -2,8 +2,15 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
@@ -115,3 +122,57 @@ func TestMissingStorageLabel(t *testing.T) {
|
||||
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent is asked FIRST. When it refuses — 403 (a protected role) or 409 (the whole-guest backup target) —
|
||||
// nothing changes on the dashboard: the drive is not marked decommissioned, the default does not move, and the
|
||||
// household reads a plain reason in its language (a 409 to the browser, like the M1 refusal).
|
||||
// RED-PROOF: with SetDecommissioned + the default move back BEFORE agent.Decommission (the v0.328.0 order), the
|
||||
// drive reads decommissioned and the default moved, and this test fails.
|
||||
func TestFinalizeDecommission_AgentRefusalChangesNothing(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
status int
|
||||
want error
|
||||
huFrag, enFrag string
|
||||
}{
|
||||
{http.StatusForbidden, errDecommissionProtected, "rendszer v", "system protects this drive"},
|
||||
{http.StatusConflict, errDecommissionBackupTarget, "rendszermentés helyéül", "place for the system backup"},
|
||||
} {
|
||||
t.Run(strconv.Itoa(c.status), func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: c.status, Reason: "refused"}}
|
||||
|
||||
err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "")
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("err = %v, want the plain refusal %v", err, c.want)
|
||||
}
|
||||
if len(agent.decommissionCalls) != 1 {
|
||||
t.Fatalf("agent asked %d times, want 1", len(agent.decommissionCalls))
|
||||
}
|
||||
if s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Error("the drive reads decommissioned although the agent refused")
|
||||
}
|
||||
if got := s.settings.GetDefaultStoragePath(); got != "/mnt/old" {
|
||||
t.Errorf("default moved to %q although the agent refused", got)
|
||||
}
|
||||
if !isDecommissionPolicyRefusal(err) {
|
||||
t.Error("the refusal is not mapped to 409 (the CF edge would swallow a 502's body)")
|
||||
}
|
||||
if hu := util.ErrText("hu", err); !strings.Contains(hu, c.huFrag) || !strings.Contains(hu, "Semmi nem v") {
|
||||
t.Errorf("HU text = %q", hu)
|
||||
}
|
||||
if en := util.ErrText("en", err); !strings.Contains(en, c.enFrag) || !strings.Contains(en, "Nothing was changed") {
|
||||
t.Errorf("EN text = %q", en)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Any other agent failure is still the old „failed" message, and changes nothing either.
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
err := s.finalizeDecommissionWith(context.Background(), &mockAgent{decommissionErr: errors.New("agentapi: dial: refused")}, "/mnt/old", "")
|
||||
if err == nil || isDecommissionPolicyRefusal(err) || s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Fatalf("a transport failure: err=%v policy=%v marked=%v", err, isDecommissionPolicyRefusal(err), s.settings.IsDecommissioned("/mnt/old"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-1069: a disk the agent cannot bind a format to (a virtio disk without a serial) is refused with a plain
|
||||
// sentence in the household's language — what happened, that nothing changed, who can fix it — and the init stops
|
||||
// before any mount or registration. The agent's refusal itself is unchanged (a safety rule).
|
||||
// RED-PROOF: with the ErrNoDurableID branch removed from runStorageInit, the error is „formázás sikertelen:
|
||||
// agentapi: format refused — …" and the HU/EN assertions fail.
|
||||
func TestR1069_NoDurableIDIsAPlainRefusal(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{formatErr: agentapi.ErrNoDurableID}
|
||||
_, err := s.runStorageInit(context.Background(), agent, "/dev/vdb", "ext4", "/mnt/felhom-drives/adat", "Adat", false, false, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("the init went on although the agent refused the format")
|
||||
}
|
||||
if len(agent.assignCalls) != 0 {
|
||||
t.Fatalf("a mount was attempted after the refusal: %v", agent.assignCalls)
|
||||
}
|
||||
hu, en := util.ErrText("hu", err), util.ErrText("en", err)
|
||||
for _, frag := range []string{"virtu", "sorozatsz", "nem v", "zemeltet"} {
|
||||
if !strings.Contains(hu, frag) {
|
||||
t.Errorf("HU text lacks %q: %q", frag, hu)
|
||||
}
|
||||
}
|
||||
for _, frag := range []string{"virtual machine", "serial number", "was not changed", "operator"} {
|
||||
if !strings.Contains(en, frag) {
|
||||
t.Errorf("EN text lacks %q: %q", frag, en)
|
||||
}
|
||||
}
|
||||
if strings.Contains(hu, "agentapi") || strings.Contains(en, "agentapi") {
|
||||
t.Errorf("the raw agent text reached the household: %q / %q", hu, en)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,9 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
||||
}
|
||||
return res, nil // STOP — no bypass; the UI surfaces Opsign.
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrNoDurableID) {
|
||||
return storageInitResult{}, util.MsgError("disk.err.no_durable_id") // R-1069: plain words, nothing changed
|
||||
}
|
||||
if err != nil && !isAgentTimeout(err) {
|
||||
return storageInitResult{}, util.MsgError("err.web.formazas_sikertelen", err)
|
||||
}
|
||||
@@ -562,7 +565,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// The M1 policy refusal is a 409 (CONFLICT with current state), not a gateway
|
||||
// failure — and 409 passes the CF edge with its JSON body intact.
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, errLastUsableDrive) {
|
||||
if isDecommissionPolicyRefusal(err) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeDiskJSON(w, status, false, s.errText(r, err), nil)
|
||||
@@ -580,6 +583,19 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// browser intact (a 502 would be swallowed by the Cloudflare edge error page).
|
||||
var errLastUsableDrive = util.MsgError("err.web.ez_az_egyetlen_hasznalhato_tarhely_a")
|
||||
|
||||
// errDecommissionProtected / errDecommissionBackupTarget are the agent's two policy refusals (R-1063), in plain
|
||||
// words. Both are 409s like the M1 refusal, and in both NOTHING changed: the agent is asked first.
|
||||
var (
|
||||
errDecommissionProtected = util.MsgError("err.web.decommission_protected")
|
||||
errDecommissionBackupTarget = util.MsgError("err.web.decommission_backup_target")
|
||||
)
|
||||
|
||||
// isDecommissionPolicyRefusal: a refusal the household can act on (409 with its JSON), not a gateway failure.
|
||||
func isDecommissionPolicyRefusal(err error) bool {
|
||||
return errors.Is(err, errLastUsableDrive) || errors.Is(err, errDecommissionProtected) ||
|
||||
errors.Is(err, errDecommissionBackupTarget)
|
||||
}
|
||||
|
||||
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
|
||||
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
|
||||
agent, err := s.agentClient()
|
||||
@@ -589,10 +605,15 @@ func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo str
|
||||
return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
|
||||
}
|
||||
|
||||
// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
|
||||
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
|
||||
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
|
||||
// the orchestration is testable without a live agent.
|
||||
// finalizeDecommissionWith tells the agent to decommission the drive (intent + unmount; never formats) and, only
|
||||
// once the agent said yes, soft-marks the registry path (keeping the entry — blocks A1 resurrection) and moves the
|
||||
// default. migratedTo is the target path for a migrate-then-decommission, or "" for decommission-anyway. The agent
|
||||
// is injected so the orchestration is testable without a live agent.
|
||||
//
|
||||
// R-1063 (2026-10-18): the AGENT IS ASKED FIRST. Until v0.328.0 the mark and the default moved before the agent was
|
||||
// asked, and its 403 (protected role) or 409 (whole-guest backup target) left the dashboard showing a retired drive
|
||||
// the agent kept serving. On a refusal nothing changes now, and the household reads which refusal it was.
|
||||
// Pinned by TestFinalizeDecommission_AgentRefusalChangesNothing.
|
||||
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
|
||||
// M1: never leave zero default. If `where` is the default, promote another usable drive; if none
|
||||
// exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which
|
||||
@@ -601,7 +622,22 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
if mustBlock {
|
||||
return errLastUsableDrive
|
||||
}
|
||||
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
||||
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
||||
var refused *agentapi.DecommissionRefusedError
|
||||
if errors.As(err, &refused) {
|
||||
s.logger.Printf("[INFO] [web] decommission of %s refused by the agent (HTTP %d) — nothing changed", where, refused.Status)
|
||||
if refused.Status == http.StatusConflict {
|
||||
return errDecommissionBackupTarget
|
||||
}
|
||||
return errDecommissionProtected
|
||||
}
|
||||
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
|
||||
}
|
||||
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
|
||||
// The agent has let go of the drive; only the dashboard's record failed. Said loudly — the next
|
||||
// decommission press is idempotent on the agent side and writes the mark.
|
||||
s.logger.Printf("[ERROR] [web] decommission of %s: the agent decommissioned it but the registry mark failed: %v", where, err)
|
||||
return util.MsgError("err.web.nyilvantartas_frissitese_sikertelen", err)
|
||||
}
|
||||
if promote != "" {
|
||||
@@ -611,10 +647,6 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote)
|
||||
}
|
||||
}
|
||||
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
||||
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
||||
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
|
||||
}
|
||||
if s.stackMgr != nil {
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
@@ -818,6 +850,10 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.protected"), probe)
|
||||
return
|
||||
}
|
||||
if errors.Is(perr, agentapi.ErrNoDurableID) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
|
||||
return
|
||||
}
|
||||
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
|
||||
if perr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", s.errText(r, perr)), nil)
|
||||
|
||||
@@ -57,6 +57,7 @@ type mockAgent struct {
|
||||
formatStatusCalls int
|
||||
guestAttachCalls []string
|
||||
decommissionCalls []string
|
||||
decommissionErr error
|
||||
guestRebootCalls int
|
||||
guestRebootErr error
|
||||
candidates agentapi.CandidatesResult
|
||||
@@ -99,6 +100,9 @@ func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectRe
|
||||
}
|
||||
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
|
||||
m.decommissionCalls = append(m.decommissionCalls, where)
|
||||
if m.decommissionErr != nil {
|
||||
return agentapi.DecommissionResult{}, m.decommissionErr
|
||||
}
|
||||
return agentapi.DecommissionResult{Decommissioned: where}, nil
|
||||
}
|
||||
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
||||
|
||||
@@ -274,7 +274,10 @@
|
||||
"filebrowser.reset_not_operator": "BORN AS A KEY (v0.320.0, 2026-10-16) -- the new-password press refused because the box already knows the file manager's password. Pinned by web TestFileBrowserResetEndpoint_Refusals.",
|
||||
"filebrowser.reset_failed": "BORN AS A KEY (v0.320.0, 2026-10-16) -- the new-password press failed (apply or verify); the state stays operator. Pinned by stacks TestFileBrowserReset_FailedVerifyKeepsOperator.",
|
||||
"note.tier2.no_space_ssd": "REWORDED (v0.321.0, 2026-10-16) -- plain words on the app page's Data tab: no 'off-drive', no 'DB/konfiguracio'. Pinned by web TestAppData_OneBackupSection.",
|
||||
"note.tier2.reason_no_second": "REWORDED (v0.321.0, 2026-10-16) -- plain words on the app page's Data tab: no 'off-drive', no 'DB/konfiguracio'. Pinned by web TestAppData_OneBackupSection."
|
||||
"note.tier2.reason_no_second": "REWORDED (v0.321.0, 2026-10-16) -- plain words on the app page's Data tab: no 'off-drive', no 'DB/konfiguracio'. Pinned by web TestAppData_OneBackupSection.",
|
||||
"disk.err.no_durable_id": "BORN AS A KEY (R-1069, 2026-10-18) -- the plain refusal for a disk the agent cannot bind a format to (a virtio disk without a serial); replaces the agent's raw English text. Pinned by internal/web TestR1069_NoDurableIDIsAPlainRefusal.",
|
||||
"err.web.decommission_backup_target": "BORN AS A KEY (R-1063, 2026-10-18) -- the agent's 409 to a drive retirement (the drive holds the system backup), in plain words; nothing changed. Pinned by internal/web TestFinalizeDecommission_AgentRefusalChangesNothing.",
|
||||
"err.web.decommission_protected": "BORN AS A KEY (R-1063, 2026-10-18) -- the agent's 403 to a drive retirement (a protected system/backup disk), in plain words; nothing changed. Pinned by internal/web TestFinalizeDecommission_AgentRefusalChangesNothing."
|
||||
},
|
||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
|
||||
@@ -47,7 +47,10 @@ run_build() {
|
||||
local mode="$1"; shift
|
||||
local tmp; tmp="$(mktemp -d)"
|
||||
make_fake_docker "$tmp/bin"
|
||||
FAKE_MODE="$mode" PATH="$tmp/bin:$PATH" bash "$BUILD_SH" "$@" >"$tmp/out" 2>&1
|
||||
# build.sh is run from a COPY in the throwaway dir, as the build dir runs it (through its symlink): its workspace
|
||||
# is <its own dir>/workspace, and run in place that is inside the source it copies (cp: into itself, 2026-10-18).
|
||||
cp "$BUILD_SH" "$tmp/build.sh"
|
||||
FAKE_MODE="$mode" PATH="$tmp/bin:$PATH" bash "$tmp/build.sh" "$@" >"$tmp/out" 2>&1
|
||||
rc=$?
|
||||
out="$(cat "$tmp/out")"
|
||||
rm -rf "$tmp"
|
||||
@@ -118,7 +121,8 @@ run_build_in() { # $1 = mode, $2 = repo dir, rest = args
|
||||
local mode="$1" repo="$2"; shift 2
|
||||
local tmp; tmp="$(mktemp -d)"
|
||||
make_fake_docker "$tmp/bin"
|
||||
FAKE_MODE="$mode" FELHOM_REPO_DIR="$repo" PATH="$tmp/bin:$PATH" bash "$BUILD_SH" "$@" >"$tmp/out" 2>&1
|
||||
cp "$BUILD_SH" "$tmp/build.sh"
|
||||
FAKE_MODE="$mode" FELHOM_REPO_DIR="$repo" PATH="$tmp/bin:$PATH" bash "$tmp/build.sh" "$@" >"$tmp/out" 2>&1
|
||||
rc=$?; out="$(cat "$tmp/out")"; rm -rf "$tmp"; return $rc
|
||||
}
|
||||
|
||||
@@ -142,6 +146,18 @@ run_build_in absent "$NOT_A_REPO" 0.999.0 --push
|
||||
check "not a git checkout -> refuses" 1 "not a git checkout" "FAKE-DOCKER-REACHED"
|
||||
rm -rf "$NOT_A_REPO"
|
||||
|
||||
# ── :latest MOVES WITH A RELEASE ONLY (R-1143, 2026-10-18) ──────────────────────────────────────────
|
||||
# rc.1–rc.5 of 0.328.0 each moved :latest. --print-tags prints the very TAGS array the push and multiarch legs use.
|
||||
# RED-PROOF: with the release-tag condition removed (every build adds :latest), the two rc/dev cases fail.
|
||||
for v in 0.999.0-rc.1 0.999.0-rc.12 dev 0.999; do
|
||||
run_build absent "$v" --print-tags
|
||||
check "--print-tags $v -> no :latest" 0 "felhom-controller:$v" "felhom-controller:latest"
|
||||
done
|
||||
run_build absent 0.999.0 --print-tags
|
||||
check "--print-tags 0.999.0 (a release) -> :latest too" 0 "felhom-controller:latest" ""
|
||||
grep -q 'docker push "${IMAGE}:latest"' "$BUILD_SH" && { fails=$((fails+1)); echo " FAIL a push of :latest outside the TAGS array"; }
|
||||
ran=$((ran+1))
|
||||
|
||||
echo
|
||||
if [[ $fails -eq 0 ]]; then
|
||||
echo "all $ran cases behaved — a pushed version cannot be overwritten"
|
||||
|
||||
Reference in New Issue
Block a user