v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (felhom.eu architecture/16, build A)
gates / gates (push) Successful in 46s

Tier-2 and the one-copy notice ask the host agent (POST /disks/same-disk, agent 0.157.0): different +
proven -> made and counted; same disk -> not made (D1); unknown (VM, hidden USB serial) -> made, not
counted, the row says why (D2); a NAS never counts and no longer hides the notice (R-1075). An older
agent keeps the st_dev rule, logged once. D4: a data-folder or media app on a box with no drive is
refused in words with a link to the drives page (R-1068's raw-error half). MinAgent 0.157.0.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-15 21:21:54 +02:00
parent c6a567122e
commit 0626b9fc8a
34 changed files with 665 additions and 200 deletions
+21
View File
@@ -1,3 +1,24 @@
## v0.317.0 — a second copy counts only on a proven different disk; the no-drive install speaks in words (2026-10-15)
**MinAgent: 0.157.0** — the same-disk verdict needs agent 0.157.0 (`FeatureSameDisk`, `POST /disks/same-disk`). Below it the
box keeps exactly today's behaviour (the st_dev rule), logged once — so the image is safe on every box, but the global floor
waits for the operator's vouch of agent 0.157.0. No golden (`09` §3 decision 250). Design: felhom.eu
`architecture/16-storage-physical-identity.md` §4, §5 (D4 only), §8, §10 — operator rulings D1–D6, 2026-10-15.
- **Tier-2 asks the agent** (`backup.Manager.diskRelation`): different + proven → made and counted; **same disk → not made**
(`16` D1); **unknown** (a VM, a USB case that hides the serial) → **made, not counted** (`16` D2) — the app's row says „A Felhom
nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e…"; a NAS → never a target. A proven different drive beats an
unknown one. The verdict is asked again right before each run.
- **The one-copy notice** (`/backups`) follows the same rule and moved to i18n, informal voice (R-1072's half): „Csak egy helyi
másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok…" or, for an unknown second drive, „A Felhom
nem tudja ellenőrizni…". **A NAS no longer hides it** (R-1075).
- **D4 (R-1068's raw-error half):** a data-folder or media app on a box with no drive is refused with „Ehhez az alkalmazáshoz egy
adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." and a link (API 400, `code: needs_drive`) — never the field name.
- Tests: the four `16` §4 rows, an older agent, the recorded mark, the D4 sentence (both an app with and without the media marker),
the wiring in main.go — each red-proofed. Ten i18n parity pages refreshed: the only change is the new lines.
- **Not changed, reported instead:** the drives page still offers `/mnt/sys_drive` for attaching (R-280, the reinstall-restore
way out); the design rejects it for media (`16` §11). The two disagree — filed for the operator as R-1082, not decided here.
## v0.316.4 — the clean-tree gate is a mechanism, not a sentence (2026-10-15)
**MinAgent: 0.155.0** (unchanged). **Do not deploy `0.316.3`** — see below.
+37 -163
View File
@@ -1,177 +1,51 @@
# REPORT — v0.316.x: the app page as one column; one image per version number (2026-10-15)
# REPORT — v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (2026-10-15)
Written in ASD-STE100 Simplified Technical English. Design read first: felhom.eu `architecture/16-storage-physical-identity.md`
(§3, §4, §5 D4, §8, §10, §11), `07-backup-architecture.md` §6, `09-update-architecture.md` (MinAgent), `03-host-agent.md`.
Task: storage identity build A (operator rulings D1–D6, 2026-10-15). Agent counterpart: felhom-agent v0.157.0.
## 1. Baselines
| Repo | `main` at start | Version | → |
|---|---|---|---|
| felhom-controller | `8339206` | v0.315.0 | **v0.316.0 → .1 → .2 built; .3 committed, NOT built (§6)** |
| felhom.eu | `340804f9` (docs only) | — | — |
Architecture read first: `02-controller-module-map.md` „The app page", `09-update-architecture.md`,
the `felhom-ui-design` skill, and the controller's `CLAUDE.md` + `.claude/rules/*`.
Built and tested on DooPlex in an isolated scratch clone; edited on the Windows workstation. The
browser work is on the workstation, which is the reason CC runs here.
## 2. PART 0 — the diagnosis, before any fix
**Verdict: (a) a cache.** Full evidence: `felhom.eu/documentation/audits/app-page-2026-10-15b/P0/VERDICT.md`.
**(b) is ruled out.** Exactly one guest ran 0.315.0 — demo-hp 9201 — on digest
`sha256:8887343011d6…`, identical to the registry's 0.315.0 at the time of measuring. The **global
floor is 0.314.0** (read from the hub's `/configuration`), so no box could have taken 0.315.0 by
itself. demo-hp 9202 and felhom-pve (N100) 9201 are both on 0.314.0; Tester 1 (`c11-appliance`) was
unreachable and, by the floor, cannot be on 0.315.0.
**The mechanism, in four measured facts:**
1. `layout.html` asked for `/static/style.css?v={{.Version}}` → `?v=0.315.0` for **all three**
different 0.315.0 builds (`e66d466`, `fe0228c`, `db52f5a`); the first had no `.app-tabs` rules.
2. The origin sends **no validator**: measured on the box, `ETag present = 0`,
`Last-Modified present = 0`. A cached copy cannot be revalidated — only expire.
3. Cloudflare caches that URL (`cf-cache-status: HIT`, `age: 99 → 100` over three requests) and
**rewrites the browser TTL from the origin's `max-age=3600` to `max-age=14400`** — four hours.
4. The session's first request returned **`cf-cache-status: EXPIRED`**, which only happens when the
edge holds an entry that has aged past its TTL.
**What is true now, and the caveat stated rather than glossed:** origin, edge and box agree
byte-for-byte (`sha256 c1774f63…` both sides) and all carry the rules; the page itself is never
cached (`DYNAMIC`). The specific stale object is **no longer observable** — the first probe forced
its revalidation, which is what `EXPIRED` records — so I cannot show a `HIT` whose body lacks
`.app-tabs`. What is shown is every precondition, the proof an aged entry existed, and the
elimination of the only other candidate. No cache was purged by hand.
## 3. Boxes on 0.315.0, with digests
| Box | tag | digest | now |
|---|---|---|---|
| demo-hp 9201 | 0.315.0 | `8887343011d6` | **0.316.2** |
| demo-hp 9202 | 0.314.0 | — | unchanged |
| felhom-pve (N100) 9201 | 0.314.0 | — | unchanged |
| Tester 1 (`c11-appliance`) | unreachable | — | cannot be on 0.315.0 (floor 0.314.0) |
## 4. Files changed, commits on `main`
| Commit | What |
|---|---|
| `9cc7ffc` | build.sh refuses a repeated push; `assetTag` per build; every shell uses it; harness normalises it |
| `7f3e58f` | the one-column layout (the approved mockup) |
| `48d8674` | v0.316.0 CHANGELOG + the push-once fact in the rule file |
| `006ceab` | **v0.316.1** — the asset tag reaches the dashboard pages |
| `f908eee` | the settings tab loses the install page's boxes |
| `9e68e52` | two more data-tab leftovers |
| `8c7aa10` | **v0.316.3** CHANGELOG |
| felhom.eu `b6638ac4`, `22cd7c51` | the design docs, the audit, R-1080 closed, R-1081 filed |
Main files: `controller/build.sh`, `scripts/test_build_push_once.sh`, `internal/web/server.go`,
`asset_tag_test.go`, `app_layout_test.go`, `templates/{style.css,app_shell,app_settings,app_data,app_guide,app_logs,app_fields,deploy}.html`,
both locale files, `i18n_parity_test.go`, `i18n_wiring_test.go`.
## 5. Tests and red-proofs
| # | Break | Result |
| Repo | `main` at start | at commit |
|---|---|---|
| B1 | the whole registry check deleted | 5 of 6 cases fail; a present tag builds AND pushes |
| B2 | an unreachable registry treated as absent | the two unanswered-registry cases fail |
| C1 | `assetTag` from the version only | `TestAssetTag_ChangesPerBuild` fails on both same-version cases |
| C2 | one shell back on `?v={{.Version}}` | `TestAssetTag_EveryShellUsesIt` fails naming layout.html |
| C3 | `addShellAssetData` removed from `executeTemplate` | `TestAssetTag_ReachesTheRenderedPage` fails: empty buster on four pages |
| D1 | the auto-update success banner restored | `TestAppPage_AutoUpdateSuccessIsMetaNotBanner` fails |
| D2 | one tab loses its column | `TestAppPage_OneColumnLayout` fails naming the tab |
| D3 | a section drawn as a card again | same test fails on two tabs |
| E | an informational section drawn as a blue callout | `TestAppPage_CalloutsOnlyForAskAndWarn` fails on both counts |
| felhom-controller | `9cc7ffcf3b` (brief) → `48d8674876` read | rebased onto `c6a5671` (v0.316.4) |
| felhom-agent | `d2053e7d47` | `52ed52c` (v0.157.0 released) |
New: `TestBuildScript_RefusesExistingTag` (six cases, real script, fake docker),
`TestAssetTag_{ChangesPerBuild,EveryShellUsesIt,ReachesTheRenderedPage}`,
`TestAppPage_{OneColumnLayout,AutoUpdateSuccessIsMetaNotBanner,CalloutsOnlyForAskAndWarn}`.
## 2. What changed
Full gate: `go build ./... && go vet ./... && go test ./...` → **exit 0, 37 packages ok, zero
failures**, measured with `go test`'s own exit code (not a pipeline's). All controller gates OK; 53
decoys behaved.
- `internal/backup/backup.go`: `diskRelation` — the one „same disk?" decision; asks the agent through `SetDiskJudge`; an older
agent or an `unmapped` path keeps the st_dev rule (logged once). `offDriveCopyState` / `singleCopyWarning`: the notice by `16` §4,
skips a NAS (R-1075), i18n.
- `internal/backup/tier2.go`, `tier2_shares.go`: target choice — same disk never; unknown made, marked `Unproven`; proven beats unknown;
the guard re-asks right before the run. `settings.CrossDriveBackup.Unproven` records it; `handlers.go` + `backups_apps.html` show it.
- `internal/agentapi`: `FeatureSameDisk` (MinAgent 0.157.0), `Client.SameDisk`, the identity fields on `DiskInfo`.
- `internal/stacks/deploy.go`, `deploy_errors.go`, `internal/api/router.go`, `deploy.html`: D4 — `ErrNeedsDrive`, 400, `code: needs_drive`,
the sentence and a link to the drives page.
- `cmd/controller/main.go`: the judge wired. Locales: four new keys in both files; `scripts/i18n_go_keys.json` lists them.
## 6. Deployed — and what is NOT
## 3. Tests and red-proofs
**demo-hp guest 9201 runs `0.316.2`, healthy.** Tester 1 and demo-felhom untouched. The **floor stays
0.314.0** — raising it needs all three test boxes and a green box walk.
| Test | Red-proof (seen failing) |
|---|---|
| `TestDisk16_ProvenDifferent_CountedNoNotice` | — (positive control for the others) |
| `TestDisk16_SameDisk_NotMadeAndNoticeShown` | the same-disk refusal turned off → fails |
| `TestDisk16_Unknown_MadeNotCountedCannotCheck` | the unproven mark set false → fails |
| `TestDisk16_NetworkShare_NeverHidesTheNotice` | the NAS skip removed → fails (R-1075) |
| `TestDisk16_OldAgent_KeepsTheStDevRule` | fails with the same-disk refusal off |
| `TestDisk16_UnprovenCopyIsRecorded` | — |
| `TestD4_NoDriveRefusalSpeaksInWords` | the D4 branch off → fails with the OMV run's raw sentence |
| `TestD4_NeedsDriveIs400`, `TestD4_FilledPathKeepsItsOwnRefusal` | — |
| `TestDiskJudge_IsWiredAtStartup` | the judge made to never call the agent → fails |
**v0.316.3 is committed and pushed to git but NOT built.** Between the 0.316.2 build and this one,
another session's work in progress appeared in the shared DooPlex build clone (15 modified files,
the physical-disk storage work, R-1074..R-1079). The clean-tree gate forbids building over it and I
will not stash or revert another session's work. Every build I did make printed `porcelain=0` first.
`go test ./...` green; `controller_gates.py --fast` green except `observations` (this section fixes it). Ten i18n parity pages
refreshed with `-update-i18n-golden` after moving the old ones aside; the diff of each is ONLY the new lines (the stylesheet line
kept as stored).
What 0.316.3 adds, and therefore what the operator will NOT see tonight: on the data tab,
„Biztonsági mentés" is still drawn as a card, and the 2nd-copy heading still reads „2. mentés
beállítása — AdventureLog". Both are cosmetic; everything else is live.
## 4. Live proof
## 7. The browser check, through the PUBLIC address
Chrome on the workstation, `https://felhom.enkisfelhom.hu`, **normal loads — no hard refresh**:
- The stylesheet URL is `?v=20540d81` (an 8-hex per-build tag, not a version, not empty), and the
loaded sheet carries the `.app-tabs` rules. **This is the fix working:** before it, the same page
needed a hand-forced refetch.
- `.app-tab-active` computes `border-bottom: 2px rgb(46,168,245)` = `--blue-bright`; the column is
`max-width: 820px`; the hero has `border-top-width: 0px`.
- Across settings / data / guide / logs / plex / grocy / install: **1 column each, 1 `aria-current`
on an installed app and 0 on a not-installed one, 0 `.app-info-card`, 0 success banners.**
- Data tab: exactly **1 amber callout** (the same-disk warning). Settings, guide, grocy: **0 blue,
0 amber** — correct, nothing is asked and nothing is risky.
- **Console clean on a fresh load.**
Screenshots: `felhom.eu/documentation/audits/app-page-2026-10-15b/browser/`.
**Scenario E's blue callout was NOT exercised live.** Plex's setup gate is open (the operator's real
claim, R-1060), so `Needed()` is false and the card is correctly hidden. Showing it live would mean
un-claiming the operator's real Plex server; not done. The branch is covered by
`TestAppPage_CalloutsOnlyForAskAndWarn` against a harness Plex with a closed gate.
## 8. Fixtures replaced, by name
Recaptured (the pages this task restyles, per the standing exception): `app_settings_*` ×21,
`app_data_*` ×9, `app_guide_*` ×4, `app_logs`, `deploy_new{,_blocked,_memory_blocked}`. **All other
fixtures are byte-identical** — and now permanently so against build identity: the harness
normalises the asset tag on both sides of the comparison, as it already did for the per-session CSRF
token, so a build can no longer churn 148 files.
## 9. NOT yet validated
1. **The operator's look** at AdventureLog and Plex on demo-hp through the public address — asked
for in STATUS. The layout judgement is theirs.
2. **v0.316.3's two cosmetic fixes** (§6) — committed, unbuilt, so unseen.
3. **A blue callout in a real browser** (§7).
## 10. Teardown — three layers
- **Machine:** nothing provisioned, no app installed, no throwaway Plex. Probe scripts and fetched
pages removed from DooPlex `/tmp`.
- **Host:** the scratch clone and the `felhom.eu` sibling symlink are deleted. The shared DooPlex
checkout was never written to by me — which is how I could tell the other session's work from
mine. `build.sh` in the build directory is now a symlink to the repo's (deliberate, §11), with the
stale copy kept beside it.
- **Hub:** nothing created; no floor change.
Pending at this commit — the next REPORT revision carries it (Tester 1, demo-hp, the N100, a NAS on a scratch box).
## 11. Observations
- **FILED: R-1081** — the registry's `0.315.0` no longer holds the 0.315.0 release. **I did this**,
and the cause is worth stating plainly: I put the guard in `controller/build.sh` in the repo, but
the documented release command runs `$FELHOM_ROOT/build/felhom-controller/build.sh`, which was a
**separate copy taken 2026-07-18**. The very first thing I did with the new refusal — run it
against a real existing tag to prove it fires — therefore rebuilt and re-pushed 0.315.0 a fourth
time, with v0.316.0's code under it. A guard added to a script the runbook does not execute is the
seam-built-but-never-wired class, and I shipped one while fixing another. Root cause fixed in the
session (symlink), then the refusal proven from the documented path. No box is affected.
- **NOT-A-FINDING (fixed in session):** the asset tag shipped dead in 0.316.0 — every shell asked for
it, the template test passed, and the dashboard path never set it, so the first live load rendered
`?v=` (empty, i.e. a constant). Found in a browser; `TestAssetTag_ReachesTheRenderedPage` now
asserts the rendered value, not the markup.
- **NOT-A-FINDING (fixed in session):** `app_row_dedup_gate` convicted my `.app-row`, correctly —
that class already means „a row in the app list". Renamed `.app-field-row`; an over-broad rename
briefly took the list's own rule with it and was restored.
- **NOT-A-FINDING:** `page.title.tier2_config`-style leftovers — the 2nd-copy heading's locale key
carries „— {{.DisplayName}}" because it was a page title. New key `app_page.second_backup`.
- **Register: 169 before → 170 after; 1 opened (R-1081), 1 opened-and-closed (R-1080).**
- **System poster:** no fact in `felhom-system-poster.facts.md` changed. Neither file edited;
nothing added to STATUS about it.
- **Instruction files:** `.claude/rules/unprompted-work.md` item 6 in **all four** versioned copies
gains „a version number is pushed once", stated as a fact about what the build now enforces, not
as a new rule. The workspace-root copy is unversioned and not on this machine.
1. FILED: R-1082 — the drives page offers `/mnt/sys_drive` for attaching (R-280, the reinstall-restore way out) while `16` §11 rejects
it for household data, and the D4 sentence links to that page. Not changed here: source and design disagree, so it is reported.
+1
View File
@@ -214,6 +214,7 @@
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
@@ -0,0 +1,41 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
// "never asks the agent".
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var judge *ast.CallExpr
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
judge = c
}
}
return true
})
if judge == nil {
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
}
asks := false
ast.Inspect(judge, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
asks = true
}
return true
})
if !asks {
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
}
}
+23
View File
@@ -583,6 +583,29 @@ func main() {
// re-rendered or the restored shares exist on paper but are not exported. A seam rather than a
// direct call — the backup package must not depend on the stacks package.
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
// v0.317.0 (felhom.eu architecture/16-storage-physical-identity.md §4): every Tier-2 and one-copy
// decision asks the HOST AGENT whether two paths share a physical disk. An agent older than 0.157.0
// (FeatureSameDisk) or a failed call → supported=false → the backup side keeps the old st_dev rule,
// logged once. Pinned by TestDiskJudge_IsWiredAtStartup.
if cfg.LocalAPI.Endpoint != "" && cfg.LocalAPI.Token != "" {
if ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint); err != nil {
logger.Printf("[WARN] [backup] same-disk: agent client init failed (%v) — the st_dev rule stays", err)
} else {
backupMgr.SetDiskJudge(func(a, b string) (backup.DiskVerdict, bool) {
jctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if ac.Supports(jctx, agentapi.FeatureSameDisk) == agentapi.SupportNo {
return backup.DiskVerdict{}, false
}
r, err := ac.SameDisk(jctx, a, b)
if err != nil {
logger.Printf("[WARN] [backup] same-disk: agent call failed for %s / %s: %v", a, b, err)
return backup.DiskVerdict{}, false
}
return backup.DiskVerdict{Different: r.Different, Proven: r.Proven, Reason: r.Reason}, true
})
}
}
}
// --- Slice 4: the guarded update's backup side ---
+29
View File
@@ -355,6 +355,12 @@ type DiskInfo struct {
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
Smart *SmartSummary `json:"smart,omitempty"`
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
// proven|unknown + reason. Empty from an older agent or for an absent drive.
Kind string `json:"kind,omitempty"`
PhysicalDisks []string `json:"physical_disks,omitempty"`
Identity string `json:"identity,omitempty"`
IdentityReason string `json:"identity_reason,omitempty"`
}
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
@@ -419,6 +425,29 @@ 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")
// 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).
type SameDiskResult struct {
Different bool `json:"different"`
Proven bool `json:"proven"`
Reason string `json:"reason,omitempty"`
}
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
// Supports(FeatureSameDisk) — an older agent answers 404.
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
var out SameDiskResult
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
if err != nil {
return out, err
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
}
return out, nil
}
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
var out DisksResponse
+10
View File
@@ -88,6 +88,11 @@ const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
const FeatureHostPowerOff Feature = "host_poweroff"
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
// rule (backup.Manager.diskRelation), logged once.
const FeatureSameDisk Feature = "same_disk"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -168,6 +173,9 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe // not probeable: decided by the version header alone
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -204,6 +212,8 @@ var featureMinAgent = map[Feature]string{
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
FeatureHostPowerOff: "0.155.0",
FeatureSameDisk: "0.157.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
@@ -0,0 +1,17 @@
package api
import (
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
func TestD4_NeedsDriveIs400(t *testing.T) {
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
if got := deployStatusFor(err); got != http.StatusBadRequest {
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
}
}
+7 -2
View File
@@ -437,7 +437,7 @@ func deployStatusFor(err error) int {
switch {
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
return http.StatusConflict
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing),
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
errors.Is(err, stacks.ErrNotEnoughMemory):
return http.StatusBadRequest
}
@@ -535,7 +535,12 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if err != nil {
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
writeJSON(w, deployStatusFor(err), apiResponse{OK: false, Error: r.errText(req, err)})
resp := apiResponse{OK: false, Error: r.errText(req, err)}
if errors.Is(err, stacks.ErrNeedsDrive) {
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
}
writeJSON(w, deployStatusFor(err), resp)
return
}
+88 -16
View File
@@ -286,6 +286,11 @@ type Manager struct {
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
// Nil → the real system.SamePhysicalDevice (production always takes this path).
samePhysicalDevice func(a, b string) bool
// diskJudge (v0.317.0, felhom.eu architecture/16-storage-physical-identity.md §4) asks the HOST AGENT
// whether two guest paths share a physical disk. supported=false (agent older than 0.157.0, or the
// call failed) → diskRelation keeps the old st_dev rule, logged once. nil → the old rule (tests).
diskJudge func(a, b string) (v DiskVerdict, supported bool)
diskLegacyOnce sync.Once
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
@@ -356,10 +361,6 @@ type FullBackupStatus struct {
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
const systemDriveLabel = "Belső SSD (rendszer)"
// singleCopyNotice is the honest single-drive signal (F6): shown when no off-drive tier-2 target
// exists, instead of silently implying a 3-2-1 guarantee the box cannot provide.
const singleCopyNotice = "Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót."
// DBDumpStatus holds the last DB dump result.
type DBDumpStatus struct {
LastRun time.Time
@@ -1363,6 +1364,59 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
return status
}
// DiskVerdict is the agent's answer to "do these two paths share a physical disk?" (`16` §4).
type DiskVerdict struct {
Different bool
Proven bool
Reason string // "" | same_disk | vm | network | usb_bridge | weak_wwn | no_serial | unmapped
}
// DiskRelation is what a Tier-2 decision acts on.
type DiskRelation int
const (
// DiskDifferent — a different physical disk, proven by the agent (or the old st_dev rule said so):
// the copy is made and COUNTS as a second copy.
DiskDifferent DiskRelation = iota
// DiskSame — the same physical disk: no second copy is made (`16` D1).
DiskSame
// DiskUnknown — the agent cannot prove it (a VM, a USB bridge that hides the serial): the copy is made
// but does NOT count, and the page says why (`16` D2).
DiskUnknown
)
// SetDiskJudge wires the agent's same-disk call (main: agentapi FeatureSameDisk).
func (m *Manager) SetDiskJudge(fn func(a, b string) (DiskVerdict, bool)) { m.diskJudge = fn }
// diskRelation is the ONE place a Tier-2 or one-copy decision asks "same disk?" (`16` §4). The agent's
// verdict wins when it can give one; "unmapped" (a path the agent did not make, e.g. an absent drive) and
// an older agent fall back to today's st_dev rule — never a silent change, never a crash. Pinned by
// diskrelation_test.go.
func (m *Manager) diskRelation(a, b string) (DiskRelation, string) {
if m.diskJudge != nil {
v, ok := m.diskJudge(a, b)
if ok && v.Reason != "unmapped" {
switch {
case v.Proven && v.Different:
return DiskDifferent, ""
case v.Proven:
return DiskSame, v.Reason
default:
return DiskUnknown, v.Reason
}
}
if !ok {
m.diskLegacyOnce.Do(func() {
m.logger.Printf("[INFO] [backup] same-disk: the host agent cannot answer (older than 0.157.0, or the call failed) — using the filesystem-device rule")
})
}
}
if m.sameDevice(a, b) {
return DiskSame, "st_dev"
}
return DiskDifferent, "st_dev"
}
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
func (m *Manager) sameDevice(a, b string) bool {
@@ -1384,29 +1438,47 @@ func (m *Manager) sameDevice(a, b string) bool {
return system.SamePhysicalDevice(a, b)
}
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
func (m *Manager) hasOffDriveTarget() bool {
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
if m.settings == nil || m.systemDataPath == "" {
return false
return false, false
}
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == m.systemDataPath || m.sameDevice(m.systemDataPath, sp.Path) {
if sp.Path == m.systemDataPath || sp.IsNetwork() {
continue
}
return true
switch rel, _ := m.diskRelation(m.systemDataPath, sp.Path); rel {
case DiskDifferent:
return true, false
case DiskUnknown:
unknown = true
}
}
return false
return false, unknown
}
// singleCopyWarning returns the honest single-drive notice, or "" when an off-drive target exists.
// hasOffDriveTarget reports whether a COUNTED off-drive (tier-2) copy is possible at all.
func (m *Manager) hasOffDriveTarget() bool {
counted, _ := m.offDriveCopyState()
return counted
}
// singleCopyWarning returns the honest notice (`16` §4), or "" when a counted off-drive target exists:
// "only one copy" when there is no second disk (or it is the same disk); "cannot check" when a second
// drive exists whose disk the agent cannot prove. Both sentences are i18n (informal voice — R-1072).
func (m *Manager) singleCopyWarning() string {
if m.hasOffDriveTarget() {
counted, unknown := m.offDriveCopyState()
switch {
case counted:
return ""
case unknown:
return m.note("note.backup.single_copy_unproven")
default:
return m.note("note.backup.single_copy")
}
return singleCopyNotice
}
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
@@ -0,0 +1,137 @@
package backup
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// felhom.eu architecture/16-storage-physical-identity.md §4 — the agent's verdict decides Tier-2 and the
// one-copy notice. Each test asserts the CONSEQUENCE (is a target picked, does it count, what does the page
// say), not the mechanism.
// judgeBy answers per target path; paths not in the map are "unmapped" (→ the old st_dev rule).
func judgeBy(m map[string]DiskVerdict) func(a, b string) (DiskVerdict, bool) {
return func(a, b string) (DiskVerdict, bool) {
if v, ok := m[b]; ok {
return v, true
}
return DiskVerdict{Reason: "unmapped"}, true
}
}
var (
provenDifferent = DiskVerdict{Different: true, Proven: true}
provenSame = DiskVerdict{Different: false, Proven: true, Reason: "same_disk"}
unknownVM = DiskVerdict{Different: true, Proven: false, Reason: "vm"}
)
func addDrive(t *testing.T, sett *settings.Settings, sp settings.StoragePath) {
t.Helper()
sp.Schedulable = true
if err := sett.AddStoragePath(sp); err != nil {
t.Fatal(err)
}
}
// Row 1: different + proven → made, counted, notice hidden.
func TestDisk16_ProvenDifferent_CountedNoNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/b": provenDifferent}))
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.Unproven || tg.NamespaceRoot != "/mnt/b" {
t.Fatalf("want /mnt/b counted, got %+v err=%v", tg, err)
}
if w := m.singleCopyWarning(); w != "" {
t.Fatalf("a proven second disk must hide the notice, got %q", w)
}
}
// Row 2: same disk (two partitions) → NOT made (D1), notice shown — even though st_dev would say "different".
func TestDisk16_SameDisk_NotMadeAndNoticeShown(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/part2", Label: "P2"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return provenSame, true })
if tg, err := m.selectTier2TargetFrom("app", "/mnt/part1", 1, 1); err == nil {
t.Fatalf("a same-disk partition must not be a Tier-2 target, got %+v", tg)
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("same disk: want the one-copy notice, got %q", w)
}
}
// Row 3: unknown (a VM) → made, NOT counted (D2), notice shown with the "cannot check" sentence.
func TestDisk16_Unknown_MadeNotCountedCannotCheck(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/media_a", Label: "A"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return unknownVM, true })
tg, err := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1)
if err != nil || !tg.Unproven || tg.UnprovenReason != "vm" {
t.Fatalf("unknown: want the copy made and marked unproven, got %+v err=%v", tg, err)
}
w := m.singleCopyWarning()
if w != m.note("note.backup.single_copy_unproven") || !strings.Contains(w, "nem tudja ellenőrizni") {
t.Fatalf("unknown: want the cannot-check notice, got %q", w)
}
// A proven-different drive beats an unknown one.
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/real", Label: "R"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/felhom-drives/media_a": unknownVM, "/mnt/felhom-drives/real": provenDifferent}))
if tg, _ := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1); tg == nil || tg.NamespaceRoot != "/mnt/felhom-drives/real" || tg.Unproven {
t.Fatalf("a proven drive must win over an unknown one, got %+v", tg)
}
}
// Row 4: a network share → never a Tier-2 target and never hides the notice (R-1075).
func TestDisk16_NetworkShare_NeverHidesTheNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/nas", Label: "NAS", Kind: settings.StorageKindNetwork})
// No judge at all — the OLD st_dev rule calls the NAS "different", which is exactly R-1075.
if m.hasOffDriveTarget() {
t.Fatal("R-1075: a NAS made the box claim an off-drive copy")
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("a NAS must not hide the one-copy notice, got %q", w)
}
if tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1); err == nil && !tg.IsSystemDrive {
t.Fatalf("a NAS must never be a Tier-2 target, got %+v", tg)
}
}
// An older agent (supported=false) keeps today's behaviour exactly: the st_dev seam decides.
func TestDisk16_OldAgent_KeepsTheStDevRule(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
calls := 0
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { calls++; return DiskVerdict{}, false })
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.NamespaceRoot != "/mnt/b" || tg.Unproven {
t.Fatalf("old agent: want today's pick (/mnt/b, counted), got %+v err=%v", tg, err)
}
if calls == 0 {
t.Fatal("the judge was never asked")
}
if m.singleCopyWarning() != "" {
t.Fatal("old agent: a second drive by st_dev must still hide the notice (today's behaviour)")
}
// And the st_dev rule's own same-disk answer still stands.
m.samePhysicalDevice = func(a, b string) bool { return true }
if _, err := m.selectTier2TargetFrom("app", "/srv/x", 1, 1); err == nil {
t.Fatal("old agent: st_dev 'same' must still refuse the target")
}
}
// The success record carries the mark the app row renders.
func TestDisk16_UnprovenCopyIsRecorded(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a", Unproven: true, UnprovenReason: "vm"}, 1, "", 0)
cd := sett.GetCrossDriveConfig("app")
if cd == nil || !cd.Unproven || cd.UnprovenReason != "vm" {
t.Fatalf("want the unproven mark recorded, got %+v", cd)
}
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a"}, 1, "", 0)
if cd := sett.GetCrossDriveConfig("app"); cd.Unproven {
t.Fatal("a later proven copy must clear the mark")
}
}
@@ -43,7 +43,7 @@ func TestRunAllTier2_IncludesVolumeOnlyApps(t *testing.T) {
func TestSingleCopyWarning_HonestOnSingleDrive(t *testing.T) {
// Single-drive: only the system drive, no schedulable off-drive paths.
m1, _ := newTestManager(t, "/srv/sys")
if got := m1.singleCopyWarning(); got != singleCopyNotice {
if got := m1.singleCopyWarning(); got != m1.note("note.backup.single_copy") {
t.Errorf("single-drive box must surface the honest notice, got %q", got)
}
+67 -16
View File
@@ -67,6 +67,11 @@ type Tier2Target struct {
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
StateOnly bool // §2.2: this target carries unit + MANDATORY only (optional legs skipped) — the SSD tier
Reason string // why this target (Hungarian, for UI/logs)
// Unproven (`16` D2): the agent cannot prove this target is another physical disk (a VM, a USB bridge
// that hides the serial). The copy is still made; it does not count as a second copy, and the app's
// row says why. UnprovenReason is the agent's reason code.
Unproven bool
UnprovenReason string
}
// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a
@@ -116,47 +121,75 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
sawNetworkCandidate = true
break
}
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
break // pinned target is on the same physical disk — not off-drive; fall through
if sp.Path == sourceDrive {
break
}
rel, why := m.diskRelation(sourceDrive, sp.Path)
if rel == DiskSame {
break // pinned target is on the same physical disk — not off-drive; fall through (`16` D1)
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: m.note("note.tier2.reason_manual"),
Unproven: rel == DiskUnknown,
UnprovenReason: unprovenWhy(rel, why),
}, nil
}
}
}
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network.
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network. A drive the
// agent PROVES is another disk wins; failing that, the first drive it cannot judge (`16` D2: the copy
// is made, not counted). A drive on the SAME disk is never a target (`16` D1).
if m.settings != nil {
var firstUnknown *Tier2Target
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == sourceDrive || m.sameDevice(sourceDrive, sp.Path) {
if sp.Path == sourceDrive {
continue
}
if sp.IsNetwork() {
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
continue
}
rel, why := m.diskRelation(sourceDrive, sp.Path)
if rel == DiskSame {
continue
}
label := sp.Label
if label == "" {
label = filepath.Base(sp.Path)
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
}, nil
t := &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: m.note("note.tier2.reason_other_drive"),
Unproven: rel == DiskUnknown,
UnprovenReason: unprovenWhy(rel, why),
}
if rel == DiskDifferent {
return t, nil
}
if firstUnknown == nil {
firstUnknown = t
}
}
if firstUnknown != nil {
return firstUnknown, nil
}
}
// 2. Fall back to the internal SSD (system data path) — STATE-ONLY set only.
sys := m.systemDataPath
if sys == "" || m.sameDevice(sourceDrive, sys) {
sysRel, sysWhy := DiskSame, ""
if sys != "" {
sysRel, sysWhy = m.diskRelation(sourceDrive, sys)
}
if sys == "" || sysRel == DiskSame {
if sawNetworkCandidate {
return nil, errTier2NetworkOnly // the only off-disk candidate was a NAS
}
@@ -175,9 +208,20 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
IsSystemDrive: true,
StateOnly: true,
Reason: m.note("note.tier2.reason_no_second"),
// `16` D2: an app on a drive the agent cannot judge against the system disk — made, not counted.
Unproven: sysRel == DiskUnknown,
UnprovenReason: unprovenWhy(sysRel, sysWhy),
}, nil
}
// unprovenWhy keeps the agent's reason only for an unknown verdict.
func unprovenWhy(rel DiskRelation, why string) string {
if rel == DiskUnknown {
return why
}
return ""
}
// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target.
func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
di := system.GetDiskUsage(sys)
@@ -326,8 +370,9 @@ func (m *Manager) RunTier2(stackName string) error {
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
// Defense-in-depth off-drive guard (selection already enforced it) — asked again right before the run,
// so a disk swapped in the same bay is re-judged (`16` §3 "before each Tier-2 run").
if rel, _ := m.diskRelation(sourceDrive, target.NamespaceRoot); rel == DiskSame {
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
return nil
}
@@ -527,7 +572,10 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
}
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == source || m.sameDevice(source, sp.Path) {
if sp.Path == source {
continue
}
if rel, _ := m.diskRelation(source, sp.Path); rel == DiskSame {
continue
}
label := sp.Label
@@ -636,6 +684,9 @@ func (m *Manager) recordTier2SuccessWithUnit(stackName string, target *Tier2Targ
c.LastDuration = dur.Round(time.Second).String()
c.LastSizeHuman = humanizeBytes(sizeBytes)
c.LastError = "" // a success invalidates the previous error
// `16` D2: a copy on a disk the agent cannot prove is another one — kept, not counted.
c.Unproven = target.Unproven
c.UnprovenReason = target.UnprovenReason
})
}
+1 -1
View File
@@ -177,7 +177,7 @@ func (m *Manager) RunSharesTier2() error {
}
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
if m.sameDevice(g.sourceDrive, target.NamespaceRoot) {
if rel, _ := m.diskRelation(g.sourceDrive, target.NamespaceRoot); rel == DiskSame {
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
continue
}
+5
View File
@@ -205,6 +205,7 @@
"backup.tier2.badge_running": "Running...",
"backup.tier2.dest_ssd": "internal SSD (database and settings only)",
"backup.tier2.no_drive": "No second drive",
"backup.tier2.unproven": "Felhom cannot check that this drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count this copy as a second copy.",
"backup.tier2.schedule_daily": "Daily",
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
"backups.a_meghajto_kijelolve": "The drive is assigned.",
@@ -1192,6 +1193,7 @@
"deploy.telepites": "Install",
"deploy.telepites_folyamatban": "Installing...",
"deploy.telepites_inditasa": "Start install",
"deploy.meghajtok_megnyitasa": "Open Drives",
"deploy.telepites_sikeres": "Install succeeded!",
"deploy.telepites_sikertelen": "Install failed",
"deploy.telepites_utan_nem_modosithato": "cannot be changed after install",
@@ -1359,6 +1361,7 @@
"err.stacks.ellenorzes_sikertelen_appdata": "the check failed (%s app data): %s",
"err.stacks.ellenorzes_sikertelen_mentes": "the check failed (%s backup): %s",
"err.stacks.field_required": "the %q (%s) field has to be filled in",
"err.stacks.needs_drive": "This app needs a storage drive. You can add one on the Storage → Drives page.",
"err.stacks.field_required_password": "the %q field has to be filled in — use the Generate button, or type a password",
"err.stacks.forras_nevter_olvasasa_sikertelen": "reading the source folder failed: %s",
"err.stacks.forras_torlese_sikertelen": "removing the source failed (%s): %s",
@@ -1827,6 +1830,8 @@
"note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive",
"note.tier2.reason_other_drive": "another data drive",
"note.tier2.same_disk": "the target you picked is on the same physical disk",
"note.backup.single_copy_unproven": "Felhom cannot check that the second drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count it as a second copy.",
"note.backup.single_copy": "Only one local copy is made: there is no second disk, or it is on the same disk as the data. This protects against mistakes, not against a disk failure. A different disk or the off-site copy protects against a disk failure.",
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
+5
View File
@@ -201,6 +201,7 @@
"backup.tier2.badge_running": "Fut...",
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
"backup.tier2.no_drive": "Nincs 2. meghajtó",
"backup.tier2.unproven": "A Felhom nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért ezt a másolatot nem számolja második másolatnak.",
"backup.tier2.schedule_daily": "Naponta",
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
"backups.a_meghajto_kijelolve": "A meghajtó kijelölve.",
@@ -1187,6 +1188,7 @@
"deploy.telepites": "Telepítés",
"deploy.telepites_folyamatban": "Telepítés folyamatban...",
"deploy.telepites_inditasa": "Telepítés indítása",
"deploy.meghajtok_megnyitasa": "Meghajtók megnyitása",
"deploy.telepites_sikeres": "Telepítés sikeres!",
"deploy.telepites_sikertelen": "Telepítés sikertelen",
"deploy.telepites_utan_nem_modosithato": "telepítés után nem módosítható",
@@ -1352,6 +1354,7 @@
"err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s",
"err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s",
"err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező",
"err.stacks.needs_drive": "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet.",
"err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használd a Generálás gombot vagy írj be egy jelszót",
"err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s",
"err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s",
@@ -1815,6 +1818,8 @@
"note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
"note.tier2.reason_other_drive": "másik adatmeghajtó",
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
"note.backup.single_copy_unproven": "A Felhom nem tudja ellenőrizni, hogy a második meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem számolja második másolatnak.",
"note.backup.single_copy": "Csak egy helyi másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok. Ez a véletlen törlés ellen véd, lemezhiba ellen nem. Lemezhiba ellen egy másik lemez vagy a távoli mentés véd.",
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
+4
View File
@@ -550,6 +550,10 @@ type CrossDriveBackup struct {
// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
UserDisabled bool `json:"user_disabled,omitempty"`
PreferredTarget string `json:"preferred_target,omitempty"`
// Unproven (v0.317.0, `16` D2): the last copy sits on a drive the host agent cannot prove is another
// physical disk (a VM, a hidden USB serial). It was made; it does not count as a second copy.
Unproven bool `json:"unproven,omitempty"`
UnprovenReason string `json:"unproven_reason,omitempty"`
}
// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
@@ -0,0 +1,47 @@
package stacks
import (
"errors"
"strings"
"testing"
)
// D4 (felhom.eu architecture/16-storage-physical-identity.md §5, R-1068): a data-folder or media app on a
// box with NO drive is refused in words with the way forward — never the field name, never sys_drive.
// COMPANION RED-PROOF: delete the D4 branch in deploy.go → the plain required-field refusal comes back
// („a(z) "Médiatár útvonal" (HDD_PATH) mező kitöltése kötelező", the OMV test's sentence) and this fails.
func TestD4_NoDriveRefusalSpeaksInWords(t *testing.T) {
for name, yml := range map[string]string{
"media app": "display_name: Jellyfin\nmedia_library:\n folder: films\ndeploy_fields:\n - env_var: HDD_PATH\n label: Médiatár útvonal\n type: path\n required: true\n",
"data-folder app": "display_name: Paperless\ndeploy_fields:\n - env_var: HDD_PATH\n label: Adattárolási útvonal\n type: path\n required: true\n",
} {
t.Run(name, func(t *testing.T) {
m := r553Manager(t, yml)
_, err := m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{}})
if err == nil {
t.Fatal("deploy was admitted on a box with no drive")
}
if !errors.Is(err, ErrNeedsDrive) {
t.Fatalf("want ErrNeedsDrive, got %v", err)
}
msg := err.Error()
if msg != "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet." {
t.Errorf("the D4 sentence moved: %q", msg)
}
for _, bad := range []string{"HDD_PATH", "útvonal", "sys_drive", "kötelező"} {
if strings.Contains(msg, bad) {
t.Errorf("the refusal names %q — it must speak in words, not fields: %q", bad, msg)
}
}
})
}
}
// A path field the household FILLED is not a D4 case: a missing path keeps its own refusal.
func TestD4_FilledPathKeepsItsOwnRefusal(t *testing.T) {
m := r553Manager(t, "display_name: X\ndeploy_fields:\n - env_var: HDD_PATH\n label: Adat\n type: path\n required: true\n")
_, err := m.DeployStack(DeployRequest{StackName: "r553app", Values: map[string]string{"HDD_PATH": "/definitely/not/here/d4"}})
if !errors.Is(err, ErrPathMissing) {
t.Fatalf("want ErrPathMissing for a filled, missing path, got %v", err)
}
}
+8
View File
@@ -390,6 +390,14 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
}
}
// D4 (`16` §5, R-1068): a path field left empty on a box with NO drive at all is not a form mistake —
// the household has nowhere to put the data. Say so in words with the way forward; never the field
// name. (sys_drive is never suggested: it rides in every whole-guest copy, `16` §11.)
if field.Required && value == "" && !field.AfterStart && field.Type == "path" && m.defaultMediaDrive() == "" {
clearDeploying()
m.logger.Printf("[INFO] [stacks] Deploy %s: refused — the box has no drive for %s (D4)", req.StackName, field.EnvVar)
return "", util.MsgErrorf(ErrNeedsDrive, "err.stacks.needs_drive")
}
// Validate required fields (an after_start field is asked after the start — R-1028)
if field.Required && value == "" && !field.AfterStart {
clearDeploying()
@@ -28,6 +28,10 @@ var (
// deploy's own `up` makes BOTH fail, and the deploy then records „not deployed" over whatever
// containers the race left behind. Pinned by TestR634_StopAndStartRefuseADeployingStack.
ErrStackDeploying = errors.New("stack is being deployed")
// ErrNeedsDrive — a data-folder or media app on a box with NO drive (felhom.eu architecture/
// 16-storage-physical-identity.md §5, D4; R-1068). API: 400 with code "needs_drive" and a link to the
// drives page; the sentence never names the field. Pinned by TestD4_NoDriveRefusalSpeaksInWords.
ErrNeedsDrive = errors.New("a storage drive is needed")
)
// KeyMemoryOvercommitWarning is the soft overcommit warning's bundle key. It travels as a KEY from
+6
View File
@@ -1422,6 +1422,9 @@ type AppBackupRow struct {
// Tier2UnitStaleNotice (R-403) — non-empty when the newest run PRESERVED this copy's package
// instead of refreshing it, so the card cannot render a preserved package as a fresh one.
Tier2UnitStaleNotice string
// Tier2UnprovenNotice (v0.317.0, `16` D2) — non-empty when the copy sits on a drive the host agent cannot
// prove is another physical disk: the copy exists, it does not count as a second copy.
Tier2UnprovenNotice string
// Drive disconnected — app's home drive is currently disconnected
DriveDisconnected bool
@@ -1663,6 +1666,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastSuccess = cd.LastSuccess
row.Tier2SuccessTracked = cd.SuccessTracked
if cd.Unproven {
row.Tier2UnprovenNotice = s.msgLang(lang, "backup.tier2.unproven")
}
// Disclose, do not hide: an older good copy AND the fact that the newest attempt failed.
// Suppressing the failure to keep the surface calm is a quieter version of the same lie.
row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error"
@@ -283,6 +283,7 @@
not render as a plain fresh copy. The status line above is about the RUN;
this one is about the PACKAGE, and after a preserved leg they differ. */}}
{{if .Tier2UnitStaleNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnitStaleNotice}}</span>{{end}}
{{if .Tier2UnprovenNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnprovenNotice}}</span>{{end}}
<span class="tier-contents">{{.Tier23Contents}}</span>
<span class="tier-browsable" title="{{T "backups_apps.a_mentes_bongeszheto_fajlrendszerben"}}"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -431,6 +431,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
return;
}
// D4 (`16` §5): no drive on the box — the sentence, and a way to the drives page.
if (!data.ok && data.data && data.data.code === 'needs_drive') {
var nd = window.needsDriveNote;
if (!nd) {
nd = document.createElement('div');
nd.className = 'alert alert-warning';
btn.parentNode.insertBefore(nd, btn);
window.needsDriveNote = nd;
}
nd.textContent = data.error + ' ';
var a = document.createElement('a');
a.href = data.data.link || '/storage';
a.textContent = '{{T "deploy.meghajtok_megnyitasa"}}';
nd.appendChild(a);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
@@ -819,6 +819,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -902,6 +903,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -978,6 +980,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1047,6 +1050,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -850,6 +850,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -933,6 +934,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1009,6 +1011,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1078,6 +1081,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -854,6 +854,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -937,6 +938,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1013,6 +1015,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1082,6 +1085,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -848,6 +848,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -931,6 +932,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1007,6 +1009,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1076,6 +1079,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -762,6 +762,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -845,6 +846,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -921,6 +923,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -990,6 +993,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -762,6 +762,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -845,6 +846,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -921,6 +923,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -990,6 +993,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -848,6 +848,7 @@
<span class="layer-reason" style="color:var(--warn);opacity:.9">Egy figyelmeztetés a másolatról</span>
<span class="layer-reason" style="color:var(--warn);opacity:.9">A másolat régebbi a mentésnél</span>
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -931,6 +932,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1007,6 +1009,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -1076,6 +1079,7 @@
<span class="tier-contents">Teljes helyreállítási egység</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
@@ -814,6 +814,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
return;
}
if (!data.ok && data.data && data.data.code === 'needs_drive') {
var nd = window.needsDriveNote;
if (!nd) {
nd = document.createElement('div');
nd.className = 'alert alert-warning';
btn.parentNode.insertBefore(nd, btn);
window.needsDriveNote = nd;
}
nd.textContent = data.error + ' ';
var a = document.createElement('a');
a.href = data.data.link || '/storage';
a.textContent = 'Meghajtók megnyitása';
nd.appendChild(a);
btn.textContent = 'Telepítés indítása';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
@@ -768,6 +768,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
return;
}
if (!data.ok && data.data && data.data.code === 'needs_drive') {
var nd = window.needsDriveNote;
if (!nd) {
nd = document.createElement('div');
nd.className = 'alert alert-warning';
btn.parentNode.insertBefore(nd, btn);
window.needsDriveNote = nd;
}
nd.textContent = data.error + ' ';
var a = document.createElement('a');
a.href = data.data.link || '/storage';
a.textContent = 'Meghajtók megnyitása';
nd.appendChild(a);
btn.textContent = 'Telepítés indítása';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
@@ -793,6 +793,24 @@ document.getElementById('deploy-form').addEventListener('submit', async function
btn.disabled = false;
return;
}
if (!data.ok && data.data && data.data.code === 'needs_drive') {
var nd = window.needsDriveNote;
if (!nd) {
nd = document.createElement('div');
nd.className = 'alert alert-warning';
btn.parentNode.insertBefore(nd, btn);
window.needsDriveNote = nd;
}
nd.textContent = data.error + ' ';
var a = document.createElement('a');
a.href = data.data.link || '/storage';
a.textContent = 'Meghajtók megnyitása';
nd.appendChild(a);
btn.textContent = 'Telepítés indítása';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
+5 -1
View File
@@ -250,7 +250,11 @@
"app_page.tab_logs": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the „Naplo\" tab's label. The logs PAGE said „Naplok\" (plural); the tab is one app's log, so the word is new, not moved.",
"err.after_start.failed": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the generic refusal for an after-start action that fails without naming its own sentence. Plex's three messages (err.plex_claim.*) are unchanged; this is the fallback a future app gets for free.",
"page.title.app_data": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the data tab's page title. It replaces page.title.tier2_config, whose page no longer exists; the old key was removed in the same commit.",
"page.title.app_guide": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the guide tab's page title. New: the details page it replaces had no title key of its own, it used the app's display name."
"page.title.app_guide": "BORN AS A KEY (v0.315.0, 2026-10-15) -- the guide tab's page title. New: the details page it replaces had no title key of its own, it used the app's display name.",
"note.backup.single_copy": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the one-copy notice on the backup page (felhom.eu architecture/16-storage-physical-identity.md §4). It replaces the Go constant singleCopyNotice, removed in the same commit; the wording changed ON PURPOSE (the informal voice, R-1072; the honest same-disk sentence, `16` D1).",
"note.backup.single_copy_unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the notice when a second drive exists whose disk the host agent cannot prove (a VM, a USB bridge that hides the serial; `16` D2). New.",
"backup.tier2.unproven": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the app row's line under a Tier-2 copy on a drive the agent cannot prove is another disk (`16` D2). New.",
"err.stacks.needs_drive": "BORN AS A KEY (v0.317.0, 2026-10-15) -- the install refusal on a box with no drive (`16` §5, D4; R-1068). New; it never names the field."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",