Compare commits

...

8 Commits

Author SHA1 Message Date
admin 034a2b7363 CHANGELOG: unreleased — burn-down night ctrl-c lane (R-578, R-569, R-492, R-576, R-574, R-488, R-325)
gates / gates (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:30:45 +02:00
admin daeed27223 R-325 (controller half): retrieval_promise_gate imports the shared stem list
STEMS now comes from felhom.eu/scripts/customer_copy_vocab.py (RETRIEVAL_STEMS), read the
way controller_gates.py reads the other shared checkers (CI clones felhom.eu beside this repo);
an absent or empty vocabulary is INCONCLUSIVE (exit 2), never a pass. Decoys: vocabulary
absent -> rc 2; an invented stem in a doctored shared list convicts a template using it.
hub_copy_gate.check_drift on this file now reads 'ok — single source'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 8ce496f124 R-488: backup tests read ONE health-wait clock; package 444 s -> ~2 s
waitForHealthy's 3 s settle / 5 s poll / 90 s timeout become package vars (production values
unchanged, pinned by TestR488_HealthWaitDefaultsAreProduction); TestMain shortens them once.
Measured before: 51 of 599 tests held 442 of 444 s, two not-running fixtures 279 s alone.
newOffboxManager now installs a failing SSH fake: TestOffbox_ConfirmedReset was spawning a
real 'ssh felhom@nas.local' and waiting out its 10 s ConnectTimeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 391967bcf1 R-574: handler_debug.go literals classified; the 38 page-copy ones move to the bundle
39 Hungarian literals: 38 are debug-page copy (every writeDebugJSON message/error, rendered
into the result line by debug.html, plus the pre-0.83 agent notice rendered in the log
viewer) -> 30 debug.api.* keys (hu byte-equal, parity gate green; en added). One is PAYLOAD:
the 'Teszt esemény' text sent to the hub as the test event's message stays a literal. The
diagnostic dump carries no Hungarian. TestR574_DebugPageCopyFollowsTheLanguage pins hu bytes
and en rendering.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 074ddda352 R-576: go-parity gate also judges the CALL — arity vs printf verbs, and no key glued with +
Checks 4 (ARITY) and 5 (NO-CONCAT) in i18n_go_parity.py: at every message-helper call
(MsgError/MsgErrorf/Text/Msgf/msg/msgLang/msgHU/note) whose key is a literal hu.json knows,
the argument count must equal the Hungarian value's printf verbs; a known key literal may
never be an operand of +. 485 calls judged, 0 defects today. Decoys: a dropped argument and
a key+suffix concatenation on a real producer convict; a nested-paren argument is accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 7b8216abac R-492: delete the always-empty cfg.Paths.HDDPath (field, env binding, every fallback)
Readers (report builder, health check, /api/system, web primaryHDDPath, metrics collector,
AutoDiscoverStoragePaths' fallback parameter) now use the storage registry only. An old
controller.yaml still carrying paths.hdd_path keeps loading (non-strict YAML), pinned by
TestR492_OldConfigWithHDDPathStillLoads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin d15ad105be R-569: stack-lifecycle handlers pick their status by error KIND, not English words
stop/start/restart/update, remove and delete matched "protected", "not found", "not deployed",
"still running", "not orphaned" in err.Error(). New sentinels in internal/stacks
(stack_errors.go) carried by the producers in manager.go/delete.go via util.KindErrorf (message
bytes unchanged); api.stackOpStatusFor maps them. Tests: reworded-message table per family,
wiring check over all three handlers, producers keep kind + words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin 9271f33359 R-578: settings-callback deadlock gate over every controller package
TestR578NothingTakesTheSettingsLockInsideASettingsCallback (internal/settings) derives from
source the *Settings methods that take s.mu, the callback runners among them, and per package
every helper that transitively reaches one; a call to any of those inside a func literal
passed to a runner (or a non-literal callback) fails with file:line. Decoy:
TestR578GateConvictsAHelperChain. REUSE.md lookalike row added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
33 changed files with 1133 additions and 144 deletions
+7
View File
@@ -8,6 +8,13 @@
- R-607: a catalog sync that moves the catalog but no installed app's files now refreshes the update badge's input at once and no longer says „nincs változás".
- R-615: changing the catalog address (git.repo_url) now takes effect — the box re-clones from the new repository instead of fetching the old one for ever; a changed token only updates the stored address.
- R-25 (controller half): drive set-up mounts the new filesystem by the UUID the agent verified it made, not by re-reading the device path; an older agent still works through the old path, logged as a warning.
- R-578: the settings-callback deadlock guard now covers every controller package — `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` derives the lock-taking `*Settings` methods and every helper that reaches one (transitively, per package) from source, and convicts any such call inside a settings `Update*` callback with file:line; decoy test included; REUSE.md lookalike row. Test-only, no binary change.
- R-569: the stop/start/restart/update, remove and delete API handlers pick their status code by the refusal's KIND (`stacks.ErrStackNotFound`/`ErrProtectedStack`/`ErrNotDeployed`/`ErrStillRunning`/`ErrNotOrphaned`, via `util.KindErrorf`, message bytes unchanged) through `api.stackOpStatusFor`, no longer by matching English words in `err.Error()`. Side effect, stated: an unrelated wrapped error that merely contained "not found"/"protected" (e.g. docker output) now answers 500 instead of 404/403. The two "currently being deployed" refusals still answer 500 as before.
- R-492: removed the always-empty global `paths.hdd_path` (`cfg.Paths.HDDPath`, env `FELHOM_PATHS_HDD_PATH`) and every reader's fallback to it (report builder, health check, `/api/system`, web `primaryHDDPath`, metrics collector, and the `fallbackHDDPath` parameter of `AutoDiscoverStoragePaths`); the storage registry is the only source. An old `controller.yaml` that still names `hdd_path` keeps loading (pinned by `TestR492_OldConfigWithHDDPathStillLoads`).
- R-576: `scripts/i18n_go_parity.py` learns two questions about the CALL, not only the text — ARITY (at every message-helper call with a literal key, the argument count equals the printf verbs in the key's Hungarian value; spread `args...` calls are skipped) and NO-CONCAT (a bundle-key literal is never an operand of `+`). 485 calls judged, none convicted today. Three new decoys in `test_gate_decoys.py`. Tooling only.
- R-574: `web/handler_debug.go`'s 39 Hungarian literals classified — 38 are debug-page copy (every `writeDebugJSON` message/error plus the pre-0.83 agent notice) and now come from the bundle (`debug.api.*`, 30 keys, hu byte-identical, en added); one is payload (the test event's message sent to the hub) and stays a literal. The diagnostic dump holds no Hungarian. Note: one existing hu value keeps the formal „írja be” (bytes frozen by the parity rule; the page is operator-facing).
- R-488: `go test ./internal/backup` 444 s → ~2 s. `waitForHealthy`'s clock (3 s settle / 5 s poll / 90 s timeout) is three package vars, production values unchanged and pinned by `TestR488_HealthWaitDefaultsAreProduction`, shortened once in the package's new `TestMain`. Found on the way: `TestOffbox_ConfirmedReset` spawned a real `ssh felhom@nas.local` (10 s ConnectTimeout) — `newOffboxManager` now installs a failing SSH fake. No binary behaviour change.
- R-325 (controller half): `scripts/retrieval_promise_gate.py` imports `RETRIEVAL_STEMS` from `felhom.eu/scripts/customer_copy_vocab.py` instead of carrying its own `STEMS` literal; an absent/empty shared vocabulary is INCONCLUSIVE (exit 2), never a pass. Two decoys. Tooling only.
- Decision (`09` §3 decision 133, decided by CC unattended — operator may reverse): removing an own off-site target keeps the repository password whenever anything could depend on it.
## v0.297.0 — small fixes from the burn-down: plain refusals that name a route, honest countdowns, a locked off-site store named, no shared metadata, gofmt gated (24 rows) (2026-10-05)
+4 -1
View File
@@ -59,6 +59,7 @@
|---|---|---|---|---|
| `util.KindErrorf` / `util.KindError` | controller/internal/util/errkind.go | `(kind error, format string, a ...interface{}) error` | ANY refusal a caller must tell apart: build the message exactly as `fmt.Errorf` would AND carry a sentinel for `errors.Is` | The message bytes are unchanged (pinned by tests); never `fmt.Errorf("%w: …")`, which would prepend the sentinel's own text to the customer's sentence |
| `stacks.ErrAlreadyDeployed` / `ErrRequiredField` / `ErrPathMissing` / `ErrNotEnoughMemory` | controller/internal/stacks/deploy_errors.go | sentinels | the API's deploy status code (`api.deployStatusFor`) | 409 / 400 / 400 / 400. Do NOT add a text signature beside them |
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the text fallback in `offboxWarningDisplay` is LEGACY only (kind == "") and is removed when R-570 closes |
@@ -148,7 +149,7 @@
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never `cfg.Paths.HDDPath`; a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/`UpdateStack`/`runComposeDeploy`. **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
@@ -311,6 +312,7 @@
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
@@ -320,6 +322,7 @@
|---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
+2 -5
View File
@@ -355,7 +355,7 @@ func main() {
// --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, logger)
// --- Load or create encryption key ---
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
@@ -523,10 +523,7 @@ func main() {
if metricsStore != nil {
defer metricsStore.Close()
metricsHDDPath := cfg.Paths.HDDPath
if p := sett.GetDefaultStoragePath(); p != "" {
metricsHDDPath = p
}
metricsHDDPath := sett.GetDefaultStoragePath() // R-492: no global fallback any more
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger)
metricsCollector.Start(ctx)
defer metricsCollector.Stop()
@@ -0,0 +1,80 @@
package api
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-569 — the stop/start/restart/update, remove and delete handlers pick their status by the
// refusal's KIND. Every row passes a REWORDED message: the words the handlers used to match
// ("protected", "not found", "not deployed", "still running", "not orphaned") are gone, only the
// kind remains. RED-PROOF (REPORT): restore any strings.Contains chain and its family's reworded rows
// answer 500.
func TestR569_StackOpStatus_SurvivesRewording(t *testing.T) {
cases := []struct {
family, name string
err error
want int
}{
// action family (start/stop/restart/update)
{"action", "unknown app, reworded", util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x"), http.StatusNotFound},
{"action", "unknown app, wrapped by the caller", fmt.Errorf("restarting: %w",
util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x")), http.StatusNotFound},
{"action", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "%q is infrastructure", "traefik"), http.StatusForbidden},
{"action", "update refusal", &stacks.UpdateRefusal{Reason: "busy", Message: "foglalt"}, http.StatusConflict},
{"action", "update refusal: unknown app", &stacks.UpdateRefusal{Reason: "not_found", Message: "nincs ilyen"}, http.StatusNotFound},
// remove family
{"remove", "not deployed, reworded", util.KindErrorf(stacks.ErrNotDeployed, "%q has nothing installed", "x"), http.StatusConflict},
{"remove", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up — stop it first", "x"), http.StatusConflict},
{"remove", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "cannot take %q away", "traefik"), http.StatusForbidden},
// delete family
{"delete", "not orphaned, reworded", util.KindErrorf(stacks.ErrNotOrphaned, "%q still belongs to the catalog", "x"), http.StatusConflict},
{"delete", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up", "x"), http.StatusConflict},
{"delete", "unknown, reworded", util.KindErrorf(stacks.ErrStackNotFound, "nothing named %q", "x"), http.StatusNotFound},
// the negative half: the old WORDS without a kind are not a refusal
{"any", "text that merely contains the old words", errors.New("docker: image not found; protected; still running"), http.StatusInternalServerError},
{"any", "anything else", errors.New("compose exploded"), http.StatusInternalServerError},
}
for _, c := range cases {
if got := stackOpStatusFor(c.err); got != c.want {
t.Errorf("%s / %s: stackOpStatusFor(%q) = %d, want %d", c.family, c.name, c.err.Error(), got, c.want)
}
}
}
// The seam must be WIRED in all three handlers, and none may decide by reading the error text again.
func TestR569_HandlersUseTheKind(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
for _, fn := range []string{"func (r *Router) actionStack(", "func (r *Router) removeStack(", "func (r *Router) deleteStack("} {
i := strings.Index(body, fn)
if i < 0 {
t.Fatalf("%s not found — this test no longer reads what it thinks it reads", fn)
}
h := body[i:]
if j := strings.Index(h[10:], "\nfunc "); j > 0 {
h = h[:j+10]
}
if !strings.Contains(h, "stackOpStatusFor(err)") {
t.Errorf("%s no longer asks stackOpStatusFor for the status code", fn)
}
for _, line := range strings.Split(h, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
if strings.Contains(line, "strings.Contains(err.Error()") {
t.Errorf("%s decides by reading the error text again (R-569): %s", fn, strings.TrimSpace(line))
}
}
}
}
+36 -34
View File
@@ -623,6 +623,36 @@ func desiredStateForAction(action string) (string, bool) {
}
}
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
//
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
// come from internal/stacks (stack_errors.go); the messages are unchanged.
//
// One function serves the three families because their kinds are disjoint: an action never returns
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
func stackOpStatusFor(err error) int {
var ref *stacks.UpdateRefusal
switch {
case errors.Is(err, stacks.ErrStackNotFound):
return http.StatusNotFound
case errors.As(err, &ref):
if ref.Reason == "not_found" {
return http.StatusNotFound
}
return http.StatusConflict
case errors.Is(err, stacks.ErrProtectedStack):
return http.StatusForbidden
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
errors.Is(err, stacks.ErrNotOrphaned):
return http.StatusConflict
}
return http.StatusInternalServerError
}
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
@@ -760,17 +790,7 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
if err != nil {
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
status := http.StatusInternalServerError
var ref *stacks.UpdateRefusal
if errors.As(err, &ref) {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1052,16 +1072,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1137,16 +1148,7 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1169,9 +1171,9 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
// R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box.
hddPath := r.cfg.Paths.HDDPath
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
hddPath := ""
if r.sett != nil {
if p := r.sett.GetDefaultStoragePath(); p != "" {
hddPath = p
+50
View File
@@ -0,0 +1,50 @@
package backup
import (
"os"
"testing"
"time"
)
// R-488 — the package's ONE test clock for the post-restore health wait.
//
// Before this, every restore fixture slept waitForHealthy's 3 s settle for real, and every fixture
// whose fake provider never reports "running" waited out the full 90 s timeout: measured 2026-10-05,
// 51 of 599 tests took ≥ 1 s and together held 442 of the package's 444 s, two of them alone 279 s.
// The production values are captured here BEFORE they are shortened, so the pin below reads what a
// box runs, not what the tests run.
var prodHealthSettle, prodHealthInterval, prodHealthTimeout = healthSettle, healthInterval, healthTimeout
func TestMain(m *testing.M) {
healthSettle = 0
healthInterval = 2 * time.Millisecond
healthTimeout = 50 * time.Millisecond
os.Exit(m.Run())
}
// The shortening is a TEST seam only: a box must still give a restored stack 3 s to settle, poll
// every 5 s, and wait 90 s before calling the restore unhealthy.
func TestR488_HealthWaitDefaultsAreProduction(t *testing.T) {
if prodHealthSettle != 3*time.Second || prodHealthInterval != 5*time.Second || prodHealthTimeout != 90*time.Second {
t.Fatalf("production health wait changed: settle=%v interval=%v timeout=%v (want 3s/5s/1m30s) — "+
"a restored app now gets a different grace on every box", prodHealthSettle, prodHealthInterval, prodHealthTimeout)
}
}
// The consequence the seam must not break: a stack that never comes up is still reported as a
// failure (only sooner), and one that is up is accepted.
func TestR488_WaitForHealthyStillJudges(t *testing.T) {
m := &Manager{}
m.stackProvider = &fakeRecoveryProvider{running: false}
start := time.Now()
if err := m.waitForHealthy("app", healthTimeout); err == nil {
t.Fatal("a stack that never reaches running must fail the health wait")
}
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("the shortened clock is not in force: the wait took %v", d)
}
m.stackProvider = &fakeRecoveryProvider{running: true}
if err := m.waitForHealthy("app", healthTimeout); err != nil {
t.Fatalf("a running stack must pass: %v", err)
}
}
@@ -924,7 +924,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// so without this line a successful off-site restore would leave the app refusing its next start.
// Pinned by TestR475_OffsiteRestoreClearsAnUpdateHold.
m.clearUpdateHoldAfterRestore(stack)
if err := m.waitForHealthy(stack, 90*time.Second); err != nil {
if err := m.waitForHealthy(stack, healthTimeout); err != nil {
m.logger.Printf("[WARN] [offbox] %s reconstituted but health check failed: %v", stack, err)
}
@@ -40,6 +40,13 @@ func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) {
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
// R-488: no test reaches a real ssh. Until 2026-10-05 the default runner was left in place, so a
// run that reached the set-aside path (TestOffbox_ConfirmedReset's first-night run) spawned a real
// `ssh felhom@nas.local` from DooPlex and waited out its 10 s ConnectTimeout. This fake fails the
// way an unreachable host does — immediately — and a test that needs ssh to work installs its own.
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
return []byte("ssh: test harness: no network"), errors.New("exit status 255")
})
return m, sett
}
@@ -203,6 +203,9 @@ func TestR570SentenceStaysHungarian(t *testing.T) {
//
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
// names the file and the line, in a second, instead of the suite hanging for 25 minutes.
//
// R-578: the controller-wide successor is TestR578NothingTakesTheSettingsLockInsideASettingsCallback
// (internal/settings) — it derives the lockers and helpers from source in every package.
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
callback := regexp.MustCompile(`\.Update\w*\(func\(`)
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
+13 -3
View File
@@ -87,7 +87,7 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
}
// Verify app started successfully
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil {
if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
m.logger.Printf("[WARN] [backup] Restore completed but app health check failed: %v", err)
}
@@ -203,13 +203,23 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
return restored, nil
}
// waitForHealthy's clock (R-488). These are the PRODUCTION values; they are variables only so the
// package's tests can shorten them in ONE place (main_test.go TestMain) instead of every restore
// test sleeping 3 s of settle and every not-running fixture waiting out the full 90 s — that was
// ~280 s of the package's ~440 s. TestR488_HealthWaitDefaultsAreProduction pins the values.
var (
healthSettle = 3 * time.Second // initial settling time before the first poll
healthInterval = 5 * time.Second // between polls
healthTimeout = 90 * time.Second // how long a restored stack gets to reach running
)
// waitForHealthy waits for a stack to reach running state after restore.
// Forces a docker ps refresh on each poll to avoid stale state.
func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
interval := 5 * time.Second
interval := healthInterval
time.Sleep(3 * time.Second) // initial settling time
time.Sleep(healthSettle) // initial settling time
for time.Now().Before(deadline) {
if m.stackProvider == nil {
+1 -1
View File
@@ -468,7 +468,7 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
if err := m.stackProvider.StartStack(stackName); err != nil {
return res, fmt.Errorf("starting %s after restore from unit: %w", stackName, err)
}
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil {
if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
}
+1 -1
View File
@@ -440,7 +440,7 @@ func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err er
if startErr != nil {
m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr)
}
if healthErr := m.waitForHealthy(stackName, 90*time.Second); healthErr != nil {
if healthErr := m.waitForHealthy(stackName, healthTimeout); healthErr != nil {
m.logger.Printf("[WARN] [backup] %s Tier-2 file restore done but health check failed: %v", stackName, healthErr)
}
-2
View File
@@ -164,7 +164,6 @@ type PathsConfig struct {
StacksDir string `yaml:"stacks_dir"`
DataDir string `yaml:"data_dir"`
SystemDataPath string `yaml:"system_data_path"`
HDDPath string `yaml:"hdd_path"`
}
type WebConfig struct {
@@ -450,7 +449,6 @@ func applyEnvOverrides(cfg *Config) {
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
envStr("FELHOM_PATHS_HDD_PATH", &cfg.Paths.HDDPath)
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval)
}
@@ -0,0 +1,27 @@
package config
import (
"path/filepath"
"testing"
)
// R-492 — the global paths.hdd_path was deleted (it was empty on every box and every reader already
// fell back to the storage registry). The consequence that matters is the OLD box: a controller.yaml
// written before the deletion that still carries `hdd_path` (empty or not) must keep loading, with
// every other field intact — a config that fails to parse is a controller that does not start.
// RED-PROOF (REPORT): switch loadAndParse to a strict decoder (yaml KnownFields) and this fails.
func TestR492_OldConfigWithHDDPathStillLoads(t *testing.T) {
for _, v := range []string{`""`, `/mnt/legacy_hdd`} {
y := minimalYAML(bcryptHash) + "paths:\n stacks_dir: /opt/stacks\n hdd_path: " + v + "\n"
cfg, err := LoadFromBytes([]byte(y))
if err != nil {
t.Fatalf("hdd_path=%s: an old config no longer loads: %v", v, err)
}
if cfg.Paths.StacksDir != filepath.FromSlash("/opt/stacks") && cfg.Paths.StacksDir != "/opt/stacks" {
t.Errorf("hdd_path=%s: neighbouring field lost: stacks_dir=%q", v, cfg.Paths.StacksDir)
}
if cfg.Customer.ID != "demo" {
t.Errorf("hdd_path=%s: customer.id lost: %q", v, cfg.Customer.ID)
}
}
}
+30
View File
@@ -1035,6 +1035,36 @@
"debug.verzio": "Version",
"debug.veszhelyzet_szimulacio": "Emergency simulation",
"debug.vezerlo": "Controller",
"debug.api.invalid_request": "Invalid request",
"debug.api.notifier_unconfigured": "Notifier is not configured",
"debug.api.event_sent": "Event sent (HTTP %d)",
"debug.api.backup_unconfigured": "Backup manager is not configured",
"debug.api.db_dump_started": "DB dump started",
"debug.api.crossdrive_none": "No installed app has a second-drive backup that could run",
"debug.api.crossdrive_started": "Cross-drive backup started; apps: %d",
"debug.api.not_wired": "Not wired",
"debug.api.proof_none_due": "No app is due — every latest backup is already checked",
"debug.api.proof_not_run": "The restore did not run — nothing was learned about the backup",
"debug.api.proof_ok": "The backup contains the app’s data",
"debug.api.proof_unknown": "Cannot be judged — the backup does not carry enough information",
"debug.api.proof_missing": "The backup is readable but does not contain the app’s data",
"debug.api.integrity_unreachable": "The repository cannot be reached — the check did not run",
"debug.api.integrity_ok": "The check completed without problems",
"debug.api.integrity_failed": "The check found an error in the repository",
"debug.api.hub_report_sent": "Hub report sent",
"debug.api.hub_reachable": "Hub reachable (HTTP %d, %dms)",
"debug.api.prefs_synced": "Preferences synchronised",
"debug.api.gitea_reachable": "Gitea reachable (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetry collected: apps %d, errors %d, warnings %d (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update is not configured",
"debug.api.reset_confirm_invalid": "Invalid confirmation — type: RESET",
"debug.api.marker_write_failed": "Marker file write error: %v",
"debug.api.restarting_to_setup": "Restarting the controller into setup mode...",
"debug.api.agent_unconfigured": "The agent is not configured on this system.",
"debug.api.agent_logs_unsupported": "The agent’s log view becomes available after the agent’s next update.",
"debug.api.bundles_found": "Bundles found: %d",
"debug.api.no_temp_files": "No temp file to remove",
"debug.api.temp_files_removed": "Temp files removed: %d/%d",
"deploy.a_celalkalmazas_nem_fut": "The target app is not running",
"deploy.a_celalkalmazas_nincs_telepitve": "The target app is not installed",
"deploy.a_ket_jelszo_nem_egyezik": "The two passwords do not match:",
+30
View File
@@ -1030,6 +1030,36 @@
"debug.verzio": "Verzió",
"debug.veszhelyzet_szimulacio": "Vészhelyzet szimuláció",
"debug.vezerlo": "Vezérlő",
"debug.api.invalid_request": "Érvénytelen kérés",
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
"debug.api.event_sent": "Esemény elküldve (HTTP %d)",
"debug.api.backup_unconfigured": "Backup manager nincs konfigurálva",
"debug.api.db_dump_started": "DB dump elindítva",
"debug.api.crossdrive_none": "Nincs olyan telepített alkalmazás, amelynek 2. mentése futtatható lenne",
"debug.api.crossdrive_started": "Cross-drive mentés elindítva %d alkalmazásra",
"debug.api.not_wired": "Nem bekötött",
"debug.api.proof_none_due": "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve",
"debug.api.proof_not_run": "A visszaállítás nem futott le — a mentésről semmi nem derült ki",
"debug.api.proof_ok": "A mentés tartalmazza az alkalmazás adatait",
"debug.api.proof_unknown": "Nem megítélhető — a mentés nem hordoz elég információt",
"debug.api.proof_missing": "A mentés olvasható, de nem tartalmazza az alkalmazás adatait",
"debug.api.integrity_unreachable": "A tároló nem érhető el — az ellenőrzés nem futott le",
"debug.api.integrity_ok": "Az ellenőrzés rendben lezajlott",
"debug.api.integrity_failed": "Az ellenőrzés hibát talált a tárolóban",
"debug.api.hub_report_sent": "Hub jelentés elküldve",
"debug.api.hub_reachable": "Hub elérhető (HTTP %d, %dms)",
"debug.api.prefs_synced": "Preferenciák szinkronizálva",
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írja be: RESET",
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"debug.api.bundles_found": "%d csomag található",
"debug.api.no_temp_files": "Nincs eltávolítandó temp fájl",
"debug.api.temp_files_removed": "%d/%d temp fájl eltávolítva",
"deploy.a_celalkalmazas_nem_fut": "A célalkalmazás nem fut",
"deploy.a_celalkalmazas_nincs_telepitve": "A célalkalmazás nincs telepítve",
"deploy.a_ket_jelszo_nem_egyezik": "A két jelszó nem egyezik:",
+1 -1
View File
@@ -76,7 +76,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
debug := cfg.Logging.Level == "debug" && logger != nil
hddPath := cfg.Paths.HDDPath
hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path
}
+1 -1
View File
@@ -66,7 +66,7 @@ func BuildReport(
// System info
staticInfo := metrics.GetStaticInfo()
hddPath := cfg.Paths.HDDPath
hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path
}
@@ -0,0 +1,355 @@
package settings
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// R-578 — the settings-callback deadlock, as a gate over EVERY package of the controller.
//
// `UpdateOffboxStatus` (and every other *Settings method that takes a func and the settings lock)
// runs its callback while holding the settings WRITE lock. sync.RWMutex is not reentrant: anything
// inside that callback that takes the settings lock again — a getter, a setter, or a package helper
// that ends in one (`m.note` → `m.boxLang` → `GetLanguage`) — DEADLOCKS while holding the lock, and
// wedges everything else on the box that touches settings.json. Release C of localisation slice 2
// shipped exactly that; the only symptom was a 25-minute test timeout. The backup-only predecessor,
// TestNoteHelpersAreNotCalledUnderTheSettingsLock, knew three helper names in one package.
//
// This test derives everything from source instead of a list:
// - lockers: *Settings methods that take s.mu (directly or through another *Settings method);
// - takers: lockers that have a func-typed parameter (the callback runners);
// - tainted: per package, every func/method whose body calls an exported locker or another
// tainted name of the same package (fixpoint) — so a new helper is covered the day it is written;
// - violation: a call to a locker or a tainted name inside a func literal passed to a taker, OR a
// taker given something that is not a literal (the gate could not see inside it).
//
// The matching is by NAME (no type checker — this must stay fast inside `go test ./...`). A false
// positive is possible if an unrelated type has a method named like a settings locker and is called
// inside a callback; rename it or hoist the call — both are cheap, a hang is not.
//
// RED-PROOF (R-578): put `lang := m.note("x")` inside the final UpdateOffboxStatus callback in
// internal/backup/offbox.go → this test names internal/backup/offbox.go:<line> and the call chain.
func TestR578NothingTakesTheSettingsLockInsideASettingsCallback(t *testing.T) {
root := filepath.Join("..", "..") // controller/
pkgs := parseControllerPackages(t, root)
own, ok := pkgs[filepath.Join(root, "internal", "settings")]
if !ok {
t.Fatal("internal/settings was not parsed — the walk no longer finds this package")
}
lockers, takers := settingsLockers(own)
if len(lockers) < 20 || len(takers) == 0 {
t.Fatalf("found %d settings lockers and %d callback runners — the pattern no longer matches the code", len(lockers), len(takers))
}
exported := map[string]bool{}
for name := range lockers {
if ast.IsExported(name) {
exported[name] = true
}
}
var bad []string
callbacks := 0
backupTaint := map[string]bool{}
for dir, files := range pkgs {
tainted := taintedFuncs(files, exported)
if dir == filepath.Join(root, "internal", "backup") {
backupTaint = tainted
}
for _, pf := range files {
ast.Inspect(pf.file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
idx, isTaker := takers[sel.Sel.Name]
if !isTaker {
return true
}
for _, i := range idx {
if i >= len(call.Args) {
continue
}
arg := call.Args[i]
if lit, ok := arg.(*ast.FuncLit); ok {
callbacks++
for _, hit := range lockingCalls(lit.Body, exported, tainted) {
bad = append(bad, pf.pos(hit.pos)+" "+hit.name+"() inside "+sel.Sel.Name+"(func…)")
}
continue
}
bad = append(bad, pf.pos(arg.Pos())+" "+sel.Sel.Name+" is passed a non-literal callback — the gate cannot see inside it; pass a func literal")
}
return true
})
}
}
// Positive controls: the instrument is looking at something, and the transitive taint reaches
// the helpers that actually caused the 2026-09-18 deadlock.
if callbacks < 10 {
t.Fatalf("found only %d settings callbacks across the controller — the walk no longer matches the code", callbacks)
}
for _, h := range []string{"boxLang", "note", "noteErr"} {
if !backupTaint[h] {
t.Errorf("internal/backup %s() is not seen as taking the settings lock — the taint analysis lost the chain that deadlocked release C", h)
}
}
if len(bad) > 0 {
sort.Strings(bad)
t.Errorf("a settings callback (which holds the settings WRITE lock) calls something that takes "+
"the settings lock again — sync.RWMutex is not reentrant, so this DEADLOCKS and wedges "+
"settings.json for the whole box. Hoist the call before the callback (%d):\n %s",
len(bad), strings.Join(bad, "\n "))
}
t.Logf("%d packages, %d settings lockers, %d callback runners, %d callbacks examined", len(pkgs), len(lockers), len(takers), callbacks)
}
// TestR578GateConvictsAHelperChain is the decoy: a synthetic package whose callback reaches the
// settings lock only through two local helpers. If the taint fixpoint stops following calls, this
// fails before the real gate quietly goes blind.
func TestR578GateConvictsAHelperChain(t *testing.T) {
src := `package decoy
type M struct{ settings *S }
func (m *M) lang() string { return m.settings.GetLanguage() }
func (m *M) msg() string { return "x" + m.lang() }
func (m *M) clean() string { return "y" }
func (m *M) run() {
_ = m.settings.UpdateOffboxStatus(func(o *T) { o.A = m.msg(); o.B = m.clean() })
}`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "decoy.go", src, 0)
if err != nil {
t.Fatal(err)
}
files := []parsedFile{{fset: fset, file: f, path: "decoy.go"}}
exported := map[string]bool{"GetLanguage": true}
tainted := taintedFuncs(files, exported)
if !tainted["msg"] || !tainted["lang"] || tainted["clean"] {
t.Fatalf("taint = %v; want lang+msg tainted, clean not", tainted)
}
var lit *ast.FuncLit
ast.Inspect(f, func(n ast.Node) bool {
if l, ok := n.(*ast.FuncLit); ok {
lit = l
}
return true
})
hits := lockingCalls(lit.Body, exported, tainted)
if len(hits) != 1 || hits[0].name != "msg" {
t.Fatalf("hits = %+v; want exactly msg()", hits)
}
}
type parsedFile struct {
fset *token.FileSet
file *ast.File
path string
}
func (p parsedFile) pos(pos token.Pos) string {
ps := p.fset.Position(pos)
return filepath.ToSlash(p.path) + ":" + strconv.Itoa(ps.Line)
}
// parseControllerPackages parses every non-test .go file under controller/, grouped by directory.
func parseControllerPackages(t *testing.T, root string) map[string][]parsedFile {
t.Helper()
fset := token.NewFileSet()
out := map[string][]parsedFile{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := info.Name()
if path != root && (strings.HasPrefix(base, ".") || base == "testdata" || base == "vendor") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return perr
}
dir := filepath.Dir(path)
rel, _ := filepath.Rel(root, path)
out[dir] = append(out[dir], parsedFile{fset: fset, file: f, path: rel})
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// settingsLockers returns the *Settings methods that take s.mu (directly, or by calling another
// *Settings method that does), and the subset that also take a func-typed parameter (with the
// argument positions of those parameters).
func settingsLockers(files []parsedFile) (lockers map[string]bool, takers map[string][]int) {
type method struct {
recv string
decl *ast.FuncDecl
}
var methods []method
for _, pf := range files {
for _, d := range pf.file.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Recv == nil || len(fd.Recv.List) != 1 || fd.Body == nil {
continue
}
star, ok := fd.Recv.List[0].Type.(*ast.StarExpr)
if !ok {
continue
}
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "Settings" {
continue
}
recv := "_"
if len(fd.Recv.List[0].Names) == 1 {
recv = fd.Recv.List[0].Names[0].Name
}
methods = append(methods, method{recv, fd})
}
}
lockers = map[string]bool{}
for changed := true; changed; {
changed = false
for _, m := range methods {
if lockers[m.decl.Name.Name] {
continue
}
found := false
ast.Inspect(m.decl.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || found {
return !found
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// s.mu.Lock() / s.mu.RLock()
if (sel.Sel.Name == "Lock" || sel.Sel.Name == "RLock") && isRecvField(sel.X, m.recv, "mu") {
found = true
}
// s.OtherLockingMethod()
if id, ok := sel.X.(*ast.Ident); ok && id.Name == m.recv && lockers[sel.Sel.Name] {
found = true
}
return !found
})
if found {
lockers[m.decl.Name.Name] = true
changed = true
}
}
}
// takers: locker -> the argument positions that are func-typed.
takers = map[string][]int{}
for _, m := range methods {
if !lockers[m.decl.Name.Name] {
continue
}
pos := 0
for _, p := range m.decl.Type.Params.List {
n := len(p.Names)
if n == 0 {
n = 1
}
if _, ok := p.Type.(*ast.FuncType); ok {
for k := 0; k < n; k++ {
takers[m.decl.Name.Name] = append(takers[m.decl.Name.Name], pos+k)
}
}
pos += n
}
}
return lockers, takers
}
func isRecvField(x ast.Expr, recv, field string) bool {
sel, ok := x.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != field {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == recv
}
// calledName is the name a call expression invokes: `f(…)` → f, `x.y.f(…)` → f.
func calledName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
return fn.Sel.Name
}
return ""
}
// taintedFuncs returns, for one package, the names of funcs/methods whose body (transitively, within
// the package) calls an exported settings locker.
func taintedFuncs(files []parsedFile, exported map[string]bool) map[string]bool {
bodies := map[string][]*ast.BlockStmt{}
for _, pf := range files {
for _, d := range pf.file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Body != nil {
bodies[fd.Name.Name] = append(bodies[fd.Name.Name], fd.Body)
}
}
}
tainted := map[string]bool{}
for changed := true; changed; {
changed = false
for name, bs := range bodies {
if tainted[name] {
continue
}
for _, b := range bs {
if len(lockingCalls(b, exported, tainted)) > 0 {
tainted[name] = true
changed = true
break
}
}
}
}
return tainted
}
type lockHit struct {
name string
pos token.Pos
}
func lockingCalls(body ast.Node, exported, tainted map[string]bool) []lockHit {
var hits []lockHit
ast.Inspect(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
name := calledName(call)
_, isSel := call.Fun.(*ast.SelectorExpr)
if (isSel && exported[name]) || tainted[name] {
hits = append(hits, lockHit{name, call.Pos()})
}
return true
})
return hits
}
+3 -11
View File
@@ -1738,7 +1738,6 @@ func (s *Settings) SetStorageLabel(path, label string) error {
// already in the registry. It is ADDITIVE: pre-existing entries are never removed,
// modified, or reactivated.
// - discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
// - fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
//
// Invariants:
// - A path already present in the registry IN ANY STATE (including a Decommissioned
@@ -1747,12 +1746,12 @@ func (s *Settings) SetStorageLabel(path, label string) error {
// - IsDefault is never flipped on an existing entry. A newly-discovered path becomes
// default ONLY if the registry currently has no default at all (and then only the
// first such new path).
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, logger *log.Logger) {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v existing=%d", discoveredPaths, len(s.StoragePaths))
}
// Index existing paths (in ANY state) and whether a default already exists.
@@ -1765,7 +1764,7 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
}
}
// Build the de-duplicated, cleaned candidate list (discovered first, then fallback).
// Build the de-duplicated, cleaned candidate list.
seen := make(map[string]bool)
var ordered []string
for _, p := range discoveredPaths {
@@ -1775,13 +1774,6 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
ordered = append(ordered, cleaned)
}
}
if fallbackHDDPath != "" {
cleaned := filepath.Clean(fallbackHDDPath)
if cleaned != "" && cleaned != "." && !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
added := 0
for _, path := range ordered {
@@ -30,7 +30,6 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
name string
existing []StoragePath
discovered []string
fallback string
wantPaths []string // expected Path set after discovery (order-insensitive)
wantNewPath string // a path expected to be newly added (may be "")
// assertions run against the resulting registry
@@ -101,17 +100,6 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
}
},
},
{
name: "fallback path registered when missing",
existing: []StoragePath{
{Path: "/mnt/felhom-usb", Label: "x", IsDefault: true, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"},
},
discovered: nil,
fallback: "/mnt/legacy_hdd",
wantPaths: []string{"/mnt/felhom-usb", "/mnt/legacy_hdd"},
wantNewPath: "/mnt/legacy_hdd",
check: nil,
},
}
for _, tc := range tests {
@@ -119,7 +107,7 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
s := newTestSettings(t, cloneStoragePaths(tc.existing))
before := cloneStoragePaths(tc.existing)
s.AutoDiscoverStoragePaths(tc.discovered, tc.fallback, logger)
s.AutoDiscoverStoragePaths(tc.discovered, logger)
// Normalize with filepath.Clean so comparisons hold on both Linux (the deploy
// target) and Windows (the dev machine), where Clean uses backslashes.
@@ -158,7 +146,7 @@ func TestAutoDiscoverStoragePaths_DecommissionedNotReactivated(t *testing.T) {
before := cloneStoragePaths(existing)
// A deployed app still points at the decommissioned drive.
s.AutoDiscoverStoragePaths([]string{"/mnt/old_hdd", "/mnt/felhom-usb"}, "", logger)
s.AutoDiscoverStoragePaths([]string{"/mnt/old_hdd", "/mnt/felhom-usb"}, logger)
if len(s.StoragePaths) != 2 {
t.Fatalf("path count changed: got %d want 2 (%v)", len(s.StoragePaths), pathList(s))
+10 -10
View File
@@ -269,12 +269,12 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
// Safety: never delete protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name)
return nil, util.KindErrorf(ErrProtectedStack, "stack %q is protected and cannot be deleted", name)
}
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
return nil, util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if m.isDebug() {
@@ -284,7 +284,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
// Must be orphaned
if !stack.Orphaned {
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name)
return nil, util.KindErrorf(ErrNotOrphaned, "stack %q is not orphaned — only orphaned stacks can be deleted", name)
}
// Must not be deploying (H2 fix)
@@ -296,7 +296,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
// StateDegraded (R-51) counts as running here: a degraded stack still has LIVE containers, and
// deleting its directory out from under them would leave orphans behind.
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting || stack.State == StateDegraded {
return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name)
return nil, util.KindErrorf(ErrStillRunning, "stack %q is still running — stop it first before deleting", name)
}
stackDir := filepath.Dir(stack.ComposePath)
@@ -417,7 +417,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
return nil, util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
// R-442: the app's own recorded HDD_PATH, not the global config (which no box sets).
@@ -577,12 +577,12 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
// Safety: never remove protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be removed", name)
return nil, util.KindErrorf(ErrProtectedStack, "stack %q is protected and cannot be removed", name)
}
stack, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
return nil, util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if m.isDebug() {
@@ -601,7 +601,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
if !stack.Deployed {
half, why := m.halfStateEvidence(name, stack)
if !half {
return nil, fmt.Errorf("stack %q is not deployed", name)
return nil, util.KindErrorf(ErrNotDeployed, "stack %q is not deployed", name)
}
m.logger.Printf("[WARN] [stacks] RemoveStack %s: deployed=false but %s — removing what exists (R-634)", name, why)
}
@@ -632,7 +632,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
// StateDegraded (R-51) counts as running here: a degraded stack still has LIVE containers, and
// deleting its directory out from under them would leave orphans behind.
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting || stack.State == StateDegraded {
return nil, fmt.Errorf("stack %q is still running — stop it first before removing", name)
return nil, util.KindErrorf(ErrStillRunning, "stack %q is still running — stop it first before removing", name)
}
stackDir := filepath.Dir(stack.ComposePath)
@@ -830,7 +830,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
func (m *Manager) GetStackBackupData(name string, drivePath string, mirrorDirs []string) (*BackupDataResponse, error) {
_, ok := m.GetStack(name)
if !ok {
return nil, fmt.Errorf("stack %q not found", name)
return nil, util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
resp := &BackupDataResponse{
@@ -399,15 +399,13 @@ func TestDeleteStack_R442_RemovesDeclaredDriveData(t *testing.T) {
}
// GetStackHDDData (the modal's source) reads the per-app record too: with HDD_PATH recorded it
// lists the folder; the global config stays empty throughout.
// lists the folder; there is no global config value to fall back to (deleted in R-492).
func TestGetStackHDDData_R442_ReadsPerAppRecord(t *testing.T) {
drive := t.TempDir()
m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive)
data := filepath.Join(drive, "appdata", "app")
plantFile(t, filepath.Join(data, "one.bin"))
if m.cfg.Paths.HDDPath != "" {
t.Fatal("fixture error: the global must stay empty to prove the per-app read")
}
// (R-492: the global paths.hdd_path no longer exists, so the per-app record is the only source.)
resp, err := m.GetStackHDDData("app")
if err != nil {
t.Fatal(err)
+7 -6
View File
@@ -21,6 +21,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ContainerState represents the current state of a container.
@@ -1275,7 +1276,7 @@ func deepCopyStack(s *Stack) Stack {
func (m *Manager) StartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
return util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if stack.Deploying {
// R-634: a second `compose up -d` beside the deploy's own is the race that left apps running
@@ -1338,7 +1339,7 @@ func (m *Manager) StartStackServices(name string, services []string) error {
}
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
return util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
m.logger.Printf("[INFO] [stacks] Starting stack %s services only: %v", name, services)
@@ -1358,12 +1359,12 @@ func (m *Manager) StartStackServices(name string, services []string) error {
func (m *Manager) StopStack(name string) error {
if m.cfg.IsProtectedStack(name) {
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
return util.KindErrorf(ErrProtectedStack, "stack %q is protected and cannot be stopped", name)
}
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
return util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if stack.Deploying {
// R-634, the backstop for EVERY caller (backup, quiesce, restore, export, storage, the Stop
@@ -1393,7 +1394,7 @@ func (m *Manager) StopStack(name string) error {
func (m *Manager) RestartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
return util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if m.isDebug() {
@@ -1437,7 +1438,7 @@ func (m *Manager) RestartStack(name string) error {
func (m *Manager) GetLogs(name string, lines int) (string, error) {
stack, ok := m.GetStack(name)
if !ok {
return "", fmt.Errorf("stack %q not found", name)
return "", util.KindErrorf(ErrStackNotFound, "stack %q not found", name)
}
if lines <= 0 {
@@ -0,0 +1,51 @@
package stacks
import (
"errors"
"testing"
)
// R-569 — the stack-lifecycle refusals the API maps to 404/403/409 carry their KIND, and their
// messages are byte-for-byte what they were. Every case below refuses BEFORE anything is executed
// (no compose, no docker): unknown name, protected name, or a scanned app that is not orphaned.
//
// RED-PROOF (REPORT): put any one producer back to plain fmt.Errorf and its errors.Is row fails while
// its message row still passes — the silent state the API's old text match lived in.
func TestR569_StackProducersCarryKindAndKeepTheirWords(t *testing.T) {
m := r553Manager(t, "display_name: R569\n")
cases := []struct {
name string
call func() error
kind error
wantMsg string
}{
{"start unknown", func() error { return m.StartStack("nope") }, ErrStackNotFound, `stack "nope" not found`},
{"stop unknown", func() error { return m.StopStack("nope") }, ErrStackNotFound, `stack "nope" not found`},
{"restart unknown", func() error { return m.RestartStack("nope") }, ErrStackNotFound, `stack "nope" not found`},
{"stop protected", func() error { return m.StopStack("samba") }, ErrProtectedStack,
`stack "samba" is protected and cannot be stopped`},
{"remove unknown", func() error { _, err := m.RemoveStack("nope", false, nil); return err }, ErrStackNotFound,
`stack "nope" not found`},
{"remove protected", func() error { _, err := m.RemoveStack("samba", false, nil); return err }, ErrProtectedStack,
`stack "samba" is protected and cannot be removed`},
{"delete unknown", func() error { _, err := m.DeleteStack("nope", false); return err }, ErrStackNotFound,
`stack "nope" not found`},
{"delete protected", func() error { _, err := m.DeleteStack("samba", false); return err }, ErrProtectedStack,
`stack "samba" is protected and cannot be deleted`},
{"delete not orphaned", func() error { _, err := m.DeleteStack("r553app", false); return err }, ErrNotOrphaned,
`stack "r553app" is not orphaned — only orphaned stacks can be deleted`},
}
for _, c := range cases {
err := c.call()
if err == nil {
t.Errorf("%s: no refusal", c.name)
continue
}
if !errors.Is(err, c.kind) {
t.Errorf("%s: errors.Is(err, %v) = false — the API would answer 500 (err=%q)", c.name, c.kind, err.Error())
}
if err.Error() != c.wantMsg {
t.Errorf("%s: message moved:\n got %q\n want %q", c.name, err.Error(), c.wantMsg)
}
}
}
@@ -0,0 +1,21 @@
package stacks
import "errors"
// R-569 — the stack-lifecycle refusals carry a KIND, so the API's stop/start/restart/update, remove
// and delete handlers pick their status code with errors.Is instead of matching "protected",
// "not found", "not deployed", "still running" or "not orphaned" in the error text. The messages are
// unchanged byte for byte (util.KindErrorf); a reworded message no longer moves a status code.
// Pinned by TestR569_StackOpStatus_SurvivesRewording (internal/api).
var (
// ErrStackNotFound — no stack by that name (API: 404).
ErrStackNotFound = errors.New("stack not found")
// ErrProtectedStack — an infrastructure stack the household may not stop/remove/delete (API: 403).
ErrProtectedStack = errors.New("stack is protected")
// ErrNotDeployed — remove asked of a stack with nothing deployed (API: 409).
ErrNotDeployed = errors.New("stack is not deployed")
// ErrStillRunning — remove/delete asked of a running stack (API: 409).
ErrStillRunning = errors.New("stack is still running")
// ErrNotOrphaned — delete asked of a stack that is not orphaned (API: 409).
ErrNotOrphaned = errors.New("stack is not orphaned")
)
+41 -38
View File
@@ -370,7 +370,7 @@ func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) {
Severity string `json:"severity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.invalid_request"), nil)
return
}
if req.EventType == "" {
@@ -381,10 +381,13 @@ func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) {
}
if s.notifier == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.notifier_unconfigured"), nil)
return
}
// R-574: PAYLOAD, not page copy — this text travels to the hub as the test event's own message,
// so it stays a literal (every other Hungarian string in this file is debug-page copy and comes
// from the bundle, debug.api.*). The classification is written in TestR574_DebugPageCopyFollowsTheLanguage.
statusCode, err := s.notifier.PushTestEventSync(req.EventType, req.Severity,
fmt.Sprintf("Teszt esemény: %s (%s)", req.EventType, req.Severity))
if err != nil {
@@ -393,7 +396,7 @@ func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) {
})
return
}
writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Esemény elküldve (HTTP %d)", statusCode),
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.event_sent", statusCode),
map[string]interface{}{"hub_status": statusCode})
}
@@ -410,7 +413,7 @@ func (s *Server) debugEventHistory(w http.ResponseWriter, r *http.Request) {
func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.backup_unconfigured"), nil)
return
}
s.backupMgr.MarkManualRun() // R-182: a person pressed this, so its digest must not be collapsed
@@ -419,7 +422,7 @@ func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] Debug DB dump failed: %v", err)
}
}()
writeDebugJSON(w, http.StatusOK, true, "DB dump elindítva", nil)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.db_dump_started"), nil)
}
// debugRunCrossDrive runs the Tier-2 (cross-drive) copy for every deployed HDD app (R-400).
@@ -435,14 +438,14 @@ func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) {
// how a button reads as working while doing nothing — the class this whole row exists to close.
func (s *Server) debugRunCrossDrive(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.backup_unconfigured"), nil)
return
}
names := crossDriveTargets(s.stackMgr.GetStacks(), func(name string) bool {
return s.backupMgr.Tier2Info(name).IsHDDApp
})
if len(names) == 0 {
writeDebugJSON(w, http.StatusOK, true, "Nincs olyan telepített alkalmazás, amelynek 2. mentése futtatható lenne",
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.crossdrive_none"),
map[string]interface{}{"apps": names, "count": 0})
return
}
@@ -456,7 +459,7 @@ func (s *Server) debugRunCrossDrive(w http.ResponseWriter, r *http.Request) {
}
}(names)
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("Cross-drive mentés elindítva %d alkalmazásra", len(names)),
s.msg(r, "debug.api.crossdrive_started", len(names)),
map[string]interface{}{"apps": names, "count": len(names)})
}
@@ -498,7 +501,7 @@ func crossDriveTargets(all []stacks.Stack, isHDDApp func(name string) bool) []st
// that would reintroduce the hazard.
func (s *Server) debugRunOffsiteProof(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.RunOffsiteProof == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
res := s.debugCallbacks.RunOffsiteProof()
@@ -517,17 +520,17 @@ func (s *Server) debugRunOffsiteProof(w http.ResponseWriter, r *http.Request) {
case res.Skipped:
writeDebugJSON(w, http.StatusOK, true, "Kihagyva: "+res.SkipReason, data)
case res.NoSnapshot:
writeDebugJSON(w, http.StatusOK, true, "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.proof_none_due"), data)
case res.Err != nil:
// NOT a verdict about the backup: the restore did not finish, so nothing was concluded.
data["error"] = res.Err.Error()
writeDebugJSON(w, http.StatusOK, true, "A visszaállítás nem futott le — a mentésről semmi nem derült ki", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.proof_not_run"), data)
case res.Judgement.Verdict == backup.UnitProofPass:
writeDebugJSON(w, http.StatusOK, true, "A mentés tartalmazza az alkalmazás adatait", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.proof_ok"), data)
case res.Judgement.Verdict == backup.UnitProofCannotJudge:
writeDebugJSON(w, http.StatusOK, true, "Nem megítélhető — a mentés nem hordoz elég információt", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.proof_unknown"), data)
default:
writeDebugJSON(w, http.StatusOK, false, "A mentés olvasható, de nem tartalmazza az alkalmazás adatait", data)
writeDebugJSON(w, http.StatusOK, false, s.msg(r, "debug.api.proof_missing"), data)
}
}
@@ -543,7 +546,7 @@ func (s *Server) debugRunOffsiteProof(w http.ResponseWriter, r *http.Request) {
// for it" is precisely the reasoning that would reintroduce the hazard.
func (s *Server) debugRunIntegrityCheck(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.RunIntegrityCheck == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
res := s.debugCallbacks.RunIntegrityCheck(true)
@@ -563,11 +566,11 @@ func (s *Server) debugRunIntegrityCheck(w http.ResponseWriter, r *http.Request)
writeDebugJSON(w, http.StatusOK, true, "Kihagyva: "+res.SkipReason, data)
case res.Unreachable:
// NOT reported as a failure: the store could not be opened, so nothing was concluded about it.
writeDebugJSON(w, http.StatusOK, true, "A tároló nem érhető el — az ellenőrzés nem futott le", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.integrity_unreachable"), data)
case res.OK:
writeDebugJSON(w, http.StatusOK, true, "Az ellenőrzés rendben lezajlott", data)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.integrity_ok"), data)
default:
writeDebugJSON(w, http.StatusOK, false, "Az ellenőrzés hibát talált a tárolóban", data)
writeDebugJSON(w, http.StatusOK, false, s.msg(r, "debug.api.integrity_failed"), data)
}
}
@@ -575,7 +578,7 @@ func (s *Server) debugRunIntegrityCheck(w http.ResponseWriter, r *http.Request)
func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.TriggerHubReportPush == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
start := time.Now()
@@ -585,13 +588,13 @@ func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) {
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency})
return
}
writeDebugJSON(w, http.StatusOK, true, "Hub jelentés elküldve",
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.hub_report_sent"),
map[string]interface{}{"latency_ms": latency})
}
func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.HubConnectivityTest == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
statusCode, latency, err := s.debugCallbacks.HubConnectivityTest()
@@ -604,12 +607,12 @@ func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) {
return
}
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("Hub elérhető (HTTP %d, %dms)", statusCode, latency), data)
s.msg(r, "debug.api.hub_reachable", statusCode, latency), data)
}
func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
if s.notifier == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.notifier_unconfigured"), nil)
return
}
prefs := s.settings.GetNotificationPrefs()
@@ -617,12 +620,12 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
return
}
writeDebugJSON(w, http.StatusOK, true, "Preferenciák szinkronizálva", nil)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.prefs_synced"), nil)
}
func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.GiteaConnectivityTest == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
statusCode, latency, err := s.debugCallbacks.GiteaConnectivityTest()
@@ -635,14 +638,14 @@ func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request)
return
}
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("Gitea elérhető (HTTP %d, %dms)", statusCode, latency), data)
s.msg(r, "debug.api.gitea_reachable", statusCode, latency), data)
}
// ── Section: Telemetry testing ───────────────────────────────────────
func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) {
if s.debugCallbacks == nil || s.debugCallbacks.GetTelemetryPreview == nil {
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
writeDebugJSON(w, http.StatusNotImplemented, false, s.msg(r, "debug.api.not_wired"), nil)
return
}
start := time.Now()
@@ -661,7 +664,7 @@ func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) {
}
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
s.msg(r, "debug.api.telemetry_collected",
len(telemetry), totalErrors, totalWarnings, latency),
map[string]interface{}{
"latency_ms": latency,
@@ -676,7 +679,7 @@ func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) {
func (s *Server) debugSelfUpdateDryRun(w http.ResponseWriter, r *http.Request) {
if s.updater == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Self-update nincs konfigurálva", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.selfupdate_unconfigured"), nil)
return
}
result := s.updater.DryRun()
@@ -690,11 +693,11 @@ func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request)
Confirm string `json:"confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.invalid_request"), nil)
return
}
if req.Confirm != "RESET" {
writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen megerősítés — írja be: RESET", nil)
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.reset_confirm_invalid"), nil)
return
}
@@ -702,11 +705,11 @@ func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request)
markerPath := filepath.Join(s.cfg.Paths.DataDir, ".needs-setup")
if err := os.WriteFile(markerPath, []byte("debug-triggered\n"), 0644); err != nil {
writeDebugJSON(w, http.StatusInternalServerError, false,
fmt.Sprintf("Marker fájl írási hiba: %v", err), nil)
s.msg(r, "debug.api.marker_write_failed", err), nil)
return
}
writeDebugJSON(w, http.StatusOK, true, "Controller újraindítása setup módba...", nil)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.restarting_to_setup"), nil)
// Exit after response is sent so the container restarts into setup mode
go func() {
@@ -762,7 +765,7 @@ func (s *Server) debugAgentLogs(w http.ResponseWriter, r *http.Request) {
if fetch == nil {
client, err := s.agentClient()
if err != nil {
writeDebugJSON(w, http.StatusOK, false, "Az ügynök nincs konfigurálva ezen a rendszeren.", nil)
writeDebugJSON(w, http.StatusOK, false, s.msg(r, "debug.api.agent_unconfigured"), nil)
return
}
fetch = client.DebugLogs
@@ -775,7 +778,7 @@ func (s *Server) debugAgentLogs(w http.ResponseWriter, r *http.Request) {
if errors.As(err, &se) && se.Code == http.StatusNotFound {
writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{
"unsupported": true,
"notice": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"notice": s.msg(r, "debug.api.agent_logs_unsupported"),
})
return
}
@@ -843,7 +846,7 @@ func (s *Server) debugAppExportBundles(w http.ResponseWriter, r *http.Request) {
bundles := appexport.ScanForBundles(drives)
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("%d csomag található", len(bundles)),
s.msg(r, "debug.api.bundles_found", len(bundles)),
map[string]interface{}{"bundles": bundles})
}
@@ -857,7 +860,7 @@ func (s *Server) debugAppExportCleanup(w http.ResponseWriter, r *http.Request) {
staleFiles := appexport.ScanForStaleTempFiles(drives)
if len(staleFiles) == 0 {
writeDebugJSON(w, http.StatusOK, true, "Nincs eltávolítandó temp fájl", nil)
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.no_temp_files"), nil)
return
}
@@ -872,5 +875,5 @@ func (s *Server) debugAppExportCleanup(w http.ResponseWriter, r *http.Request) {
}
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("%d/%d temp fájl eltávolítva", removed, len(staleFiles)), nil)
s.msg(r, "debug.api.temp_files_removed", removed, len(staleFiles)), nil)
}
@@ -0,0 +1,69 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-574 — handler_debug.go's literals, classified once: 38 are PAGE COPY (every writeDebugJSON
// message/error, which debug.html prints into the result line, plus the pre-0.83 agent `notice`,
// which it prints into the log viewer) and were moved to the bundle (debug.api.*); ONE is PAYLOAD —
// the `Teszt esemény: %s (%s)` text handed to PushTestEventSync, which travels to the hub as the
// test event's own message — and stays a literal on purpose. The diagnostic dump carries no
// Hungarian.
//
// The consequence pinned here: the Hungarian page is byte-for-byte what it was (the parity gate
// proves the bundle text; this proves the handler reads it), and an English page is English.
// RED-PROOF (REPORT): put either producer back to its literal and the English rows fail.
func TestR574_DebugPageCopyFollowsTheLanguage(t *testing.T) {
type debugResp struct {
OK bool `json:"ok"`
Error string `json:"error"`
Data struct {
Notice string `json:"notice"`
} `json:"data"`
}
run := func(t *testing.T, lang string, call func(s *Server, w http.ResponseWriter, r *http.Request)) debugResp {
t.Helper()
s := testServer(t)
if s.settings == nil {
t.Fatal("testServer has no settings — the language cannot be set")
}
s.settings.SetConfigLanguage(lang)
w := httptest.NewRecorder()
call(s, w, httptest.NewRequest(http.MethodPost, "/api/debug/x", nil))
var resp debugResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v (%s)", err, w.Body.String())
}
return resp
}
dbDump := func(s *Server, w http.ResponseWriter, r *http.Request) {
s.backupMgr = nil
s.debugTriggerDBDump(w, r)
}
notice := func(s *Server, w http.ResponseWriter, r *http.Request) {
s.agentLogsFn = func(context.Context) (agentapi.AgentLogsResponse, error) {
return agentapi.AgentLogsResponse{}, &agentapi.StatusError{Path: "/debug/logs", Code: http.StatusNotFound}
}
s.debugAgentLogs(w, r)
}
if got := run(t, "hu", dbDump).Error; got != "Backup manager nincs konfigurálva" {
t.Errorf("hu error = %q — the Hungarian bytes moved", got)
}
if got := run(t, "en", dbDump).Error; got != "Backup manager is not configured" {
t.Errorf("en error = %q — the handler does not read the bundle", got)
}
if got := run(t, "hu", notice).Data.Notice; got != "Az ügynök naplónézete az ügynök következő frissítése után érhető el." {
t.Errorf("hu notice = %q — the Hungarian bytes moved", got)
}
if got := run(t, "en", notice).Data.Notice; got != "The agent’s log view becomes available after the agent’s next update." {
t.Errorf("en notice = %q — the handler does not read the bundle", got)
}
}
+3 -5
View File
@@ -982,12 +982,10 @@ func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
return nil, false
}
// primaryHDDPath returns the default storage path, or the legacy config value.
// primaryHDDPath returns the default storage path ("" when none is registered — R-492 deleted the
// always-empty legacy config value it used to fall back to).
func (s *Server) primaryHDDPath() string {
if p := s.settings.GetDefaultStoragePath(); p != "" {
return p
}
return s.cfg.Paths.HDDPath
return s.settings.GetDefaultStoragePath()
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
+30
View File
@@ -598,6 +598,36 @@
"api.geo.sync_started": "Szinkronizálás elindítva",
"api.geo.app_set": "Alkalmazás geo-korlátozás beállítva",
"api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva",
"debug.api.invalid_request": "Érvénytelen kérés",
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
"debug.api.event_sent": "Esemény elküldve (HTTP %d)",
"debug.api.backup_unconfigured": "Backup manager nincs konfigurálva",
"debug.api.db_dump_started": "DB dump elindítva",
"debug.api.crossdrive_none": "Nincs olyan telepített alkalmazás, amelynek 2. mentése futtatható lenne",
"debug.api.crossdrive_started": "Cross-drive mentés elindítva %d alkalmazásra",
"debug.api.not_wired": "Nem bekötött",
"debug.api.proof_none_due": "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve",
"debug.api.proof_not_run": "A visszaállítás nem futott le — a mentésről semmi nem derült ki",
"debug.api.proof_ok": "A mentés tartalmazza az alkalmazás adatait",
"debug.api.proof_unknown": "Nem megítélhető — a mentés nem hordoz elég információt",
"debug.api.proof_missing": "A mentés olvasható, de nem tartalmazza az alkalmazás adatait",
"debug.api.integrity_unreachable": "A tároló nem érhető el — az ellenőrzés nem futott le",
"debug.api.integrity_ok": "Az ellenőrzés rendben lezajlott",
"debug.api.integrity_failed": "Az ellenőrzés hibát talált a tárolóban",
"debug.api.hub_report_sent": "Hub jelentés elküldve",
"debug.api.hub_reachable": "Hub elérhető (HTTP %d, %dms)",
"debug.api.prefs_synced": "Preferenciák szinkronizálva",
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írja be: RESET",
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"debug.api.bundles_found": "%d csomag található",
"debug.api.no_temp_files": "Nincs eltávolítandó temp fájl",
"debug.api.temp_files_removed": "%d/%d temp fájl eltávolítva",
"alert.link.settings": "Beállítások",
"alert.link.monitoring": "Rendszermonitor",
"alert.link.update": "Frissítés",
+161 -3
View File
@@ -33,6 +33,12 @@ Checks:
3. BYTE-EQUAL. A single-literal key: hu.json[key] == from, exactly. A joined key: hu.json[key]
with its printf verbs removed == the `from` fragments concatenated in order, with their verbs
removed too. Plural keys compare their `.one`/`.other`-less base form.
4. ARITY (R-576). At a message-helper call whose key is a literal hu.json knows, the arguments
after the key are as many as the Hungarian value's printf verbs.
5. NO-CONCAT (R-576). A literal naming a bundle key is never an operand of `+`.
Checks 1-3 ask whether the TEXT is real; 4-5 ask whether the CALL kept all of it. A structural
gate over the text cannot see a defect in the call -- that is how 7 producers lost half their
sentence on 2026-09-18 with this gate green.
The Go literal walker is the inventory's (felhom.eu/scripts/i18n_inventory.py `go_literals`),
copied rather than imported: this gate runs from a controller clone that may not sit beside
@@ -233,6 +239,150 @@ def base_form(hu: dict, key: str):
return None
# ---- R-576: the CALL, not only the TEXT ---------------------------------------------------------
#
# Checks 1-3 ask "is the text a key carries real?". They cannot see a call site that LOST text: on
# 2026-09-18 the bulk converter turned `fmt.Errorf("a: "+ "b: %s", x)` into a call that kept only
# the key and dropped the continuation and its argument -- 7 producers, and this gate stayed GREEN,
# because every surviving fragment WAS a byte-equal base literal. Two more questions convict it:
#
# 4. ARITY. At a message-helper call whose key argument is a literal hu.json knows, the number of
# arguments after the key equals the number of printf verbs in the Hungarian value. A call that
# spreads a slice (`args...`) is not counted -- its arity is not visible in the source.
# 5. NO-CONCAT. A literal that names a bundle key is never an operand of `+`. A key is a whole
# name; a key glued to more text is either a half-converted concatenation or a key nobody can
# find.
#
# helper name -> index of its KEY argument. Matched by NAME (no type checker), so only a call whose
# key argument is a literal hu.json actually knows is ever judged.
MSG_HELPERS = {
"MsgError": 0, # util.MsgError(key, args...)
"MsgErrorf": 1, # util.MsgErrorf(kind, key, args...)
"Text": 1, # util.Text(lang, key, args...)
"Msgf": 1, # (*i18n.Bundle).Msgf(lang, key, args...)
"msg": 1, # (*Router|*Server).msg(req, key, args...)
"msgLang": 1, # (*Router|*Server).msgLang(lang, key, args...)
"msgHU": 0, # stacks.msgHU(key, args...)
"note": 0, # (*backup.Manager).note(key, args...)
}
CALL_RE = re.compile(r"\b(" + "|".join(sorted(MSG_HELPERS, key=len, reverse=True)) + r")\(")
def code_mask(src: str):
"""(mask, lits): mask is 1 where src[i] is CODE (outside comments, strings and runes); lits is
the (start, end) span of every interpreted string literal, quotes included."""
mask = bytearray(len(src))
lits = []
i, n = 0, len(src)
while i < n:
c = src[i]
if src.startswith("//", i):
j = src.find("\n", i)
i = n if j < 0 else j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
i = n if j < 0 else j + 2
elif c in "'\"":
j = i + 1
while j < n and src[j] != c and src[j] != "\n":
j += 2 if src[j] == "\\" else 1
if c == '"':
lits.append((i, j + 1))
i = j + 1
elif c == "`":
j = src.find("`", i + 1)
i = n if j < 0 else j + 1
else:
mask[i] = 1
i += 1
return mask, lits
def split_args(src: str, mask, open_paren: int):
"""The top-level argument source strings of the call whose '(' is at open_paren, or None."""
depth, start, args, i, n = 0, open_paren + 1, [], open_paren, len(src)
while i < n:
if mask[i]:
c = src[i]
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
if depth == 0:
tail = src[start:i].strip()
if tail:
args.append(tail)
return args
elif c == "," and depth == 1:
args.append(src[start:i].strip())
start = i + 1
i += 1
return None
def verb_arity(value: str) -> int:
"""How many arguments the printf verbs of value consume (explicit indexes respected)."""
s = value.replace("%%", "")
need, nxt = 0, 0
for m in VERB_RE.finditer(s):
idx = re.match(r"%\[(\d+)\]", m.group(0))
if idx:
nxt = int(idx.group(1))
else:
nxt += 1
need = max(need, nxt)
return need
STR_LIT_RE = re.compile(r'^"((?:[^"\\\n]|\\.)*)"$')
def call_site_defects(root: str, hu) -> tuple:
"""Checks 4 (ARITY) and 5 (NO-CONCAT) over every in-scope Go file -> (defects, calls judged)."""
bad, checked = [], 0
if hu is None:
return bad, checked
for p in go_files(root):
rel = os.path.relpath(p, root)
src = read(p)
mask, lits = code_mask(src)
# 5. NO-CONCAT: a known key literal with a `+` on either side.
for a, b in lits:
text = unescape(src[a + 1:b - 1])
if text not in hu or not KEY_SHAPE_RE.match(text):
continue
left = src[:a].rstrip(" \t")
right = src[b:].lstrip(" \t")
if left.endswith("+") or right.startswith("+"):
line = src.count("\n", 0, a) + 1
bad.append("CONCAT %s is glued to more text with + at %s:%d -- a key is a whole "
"name; this is a half-converted concatenation" % (text, rel, line))
# 4. ARITY.
for m in CALL_RE.finditer(src):
if not mask[m.start()]:
continue
args = split_args(src, mask, m.end() - 1)
k = MSG_HELPERS[m.group(1)]
if args is None or len(args) <= k:
continue
lit = STR_LIT_RE.match(args[k])
if not lit:
continue
key = unescape(lit.group(1))
if key not in hu or not KEY_SHAPE_RE.match(key):
continue # a plural key (.one/.other only) or not a key at all
rest = args[k + 1:]
if rest and rest[-1].endswith("..."):
continue # a spread slice: arity not visible
checked += 1
want = verb_arity(hu[key])
if len(rest) != want:
line = src.count("\n", 0, m.start()) + 1
bad.append("ARITY %s called with %d argument(s) at %s:%d, its Hungarian value "
"has %d printf verb(s): %r" % (key, len(rest), rel, line, want, hu[key]))
return bad, checked
def main(argv) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--capture", action="store_true",
@@ -334,16 +484,24 @@ def main(argv) -> int:
" base (verbs removed): %r"
% (key, len(froms), got, want))
# 4 + 5. ARITY and NO-CONCAT (R-576) -- the call, not the text.
call_bad, judged = call_site_defects(CTRL, hu)
bad.extend(call_bad)
if judged == 0:
bad.append("NO-CALLS no message-helper call with a literal key was found -- the call scanner "
"no longer matches the code, so checks 4-5 would be green by seeing nothing")
print("go-parity: base capture %s (%d literals), %d slice-2 keys listed, %d pre-existing, "
"%d named in Go"
"%d named in Go, %d helper calls judged for arity"
% (base.get("commit", "?")[:12] or "?", len(base_lits), len(keys), len(preexisting),
len(named)))
len(named), judged))
if bad:
print("\ngo-parity gate CONVICTS (%d):" % len(bad))
for b in bad:
print(" " + b)
return 1
print("go-parity gate OK: every Go-side key carries base-commit text, byte for byte.")
print("go-parity gate OK: every Go-side key carries base-commit text, byte for byte, and every "
"judged call passes as many arguments as its message has verbs.")
return 0
+18 -1
View File
@@ -51,7 +51,24 @@ GO_SOURCES = [
# R-311 adds `visszanyit`: the honest new message says a customer needs support's help „a régebbi
# előzményed visszanyitásához". That is the SAME claim in a fourth verb, and the docstring above
# records what happens when the guard chases words instead of claims — it misses the next one.
STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"]
#
# R-325: the list is NOT kept here. It lives ONCE in felhom.eu/scripts/customer_copy_vocab.py, which
# the hub's copy gate imports too — two copies of a word list is the R-299 defect exactly. The
# sibling clone is read the way controller_gates.py reads reuse_refs_check.py: CI clones felhom.eu
# beside this repo, and an ABSENT clone is INCONCLUSIVE (exit 2) — never a pass on an empty list.
# FELHOM_SHARED_SCRIPTS overrides the directory (the decoy suite uses it; nothing else should).
_SHARED_SCRIPTS = os.environ.get("FELHOM_SHARED_SCRIPTS") or os.path.normpath(
os.path.join(_HERE, "..", "..", "..", "felhom.eu", "scripts"))
if not os.path.isfile(os.path.join(_SHARED_SCRIPTS, "customer_copy_vocab.py")):
print("retrieval-promise gate INCONCLUSIVE: the shared vocabulary is missing (%s) — the felhom.eu "
"clone must sit beside this repo; without the stems nothing can be judged" %
os.path.join(_SHARED_SCRIPTS, "customer_copy_vocab.py"))
sys.exit(2)
sys.path.insert(0, _SHARED_SCRIPTS)
from customer_copy_vocab import RETRIEVAL_STEMS as STEMS # noqa: E402
if not STEMS:
print("retrieval-promise gate INCONCLUSIVE: customer_copy_vocab.RETRIEVAL_STEMS is empty")
sys.exit(2)
# R-564 — THE SPLIT VERB. Hungarian moves the particle after the verb when something is stressed:
# „csak akkor ÁLLÍTHATÓK VISSZA", „csak a hiányzó fájlokat HOZOD VISSZA". The joined stems above never
+66 -2
View File
@@ -39,14 +39,16 @@ COVERS = {
"app-row-dedup": "hand-rolled row markup in partials/, AND a commented-out partial call",
"template-id": "a JS reference to a missing id, in partials/",
"secret-markup": "a secret templated into markup, in partials/",
"retrieval-promise": "an unregistered retrieval promise, in partials/",
"retrieval-promise": "an unregistered retrieval promise, in partials/; R-325: the shared vocabulary "
"absent (INCONCLUSIVE) and an invented stem in it convicting",
"mojibake": "CONTROL: already walked; proves the planted file is really reachable",
"debug-routes": "a live dispatcher case commented out - the button survives, the handler dies",
"golden-notice": "R-410 in the other direction: an empty dir must not count as a bake",
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
"i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)",
"go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted "
"key left out of the map (v0.252.0, R-557)",
"key left out of the map (v0.252.0, R-557); a call that lost an argument, and a key glued "
"to more text with + (R-576)",
"gofmt": "R-454: a planted unformatted .go file in internal/ is convicted; the clean tree passes",
"offbox-rename": "R-425: NAS branding in a NEW backups*.html, and in a bundle value an offbox Go file "
"names; control: the same token in the network-storage feature's copy is accepted",
@@ -260,6 +262,68 @@ swapped("go-parity/invented", "i18n_go_parity.py", GO_KEYS,
swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS,
_one(_A_KEY + ",\n", ""))
# 4-5. R-576 -- the CALL lost text while every surviving fragment stayed real (2026-09-18, 7
# producers, this gate green). Built on a real producer: the update-already-running refusal.
UPDATE_GO = os.path.join(CTRL, "internal", "stacks", "update.go")
_R576 = 'util.MsgError("update.refusal.already", name), "lost the race'
# the argument dropped -- the key's text is still byte-equal, only the call is short.
swapped("go-parity/lost-argument", "i18n_go_parity.py", UPDATE_GO,
_one(_R576, 'util.MsgError("update.refusal.already"), "lost the race'))
# the key glued to a continuation with + -- the converter's exact shape.
swapped("go-parity/key-concat", "i18n_go_parity.py", UPDATE_GO,
_one(_R576, 'util.MsgError("update.refusal.already" + suffix, name), "lost the race'))
# CONTROL: one argument that itself holds parens and commas is still ONE argument.
swapped("go-parity/nested-arg-ok", "i18n_go_parity.py", UPDATE_GO,
_one(_R576, 'util.MsgError("update.refusal.already", fmt.Sprintf("%s,%s", f(a, b), c)), "lost the race'),
expect="accept")
# --- retrieval-promise, R-325: the stems are IMPORTED from felhom.eu/scripts/customer_copy_vocab.py. ---
# Two shapes. (1) The vocabulary absent must be INCONCLUSIVE, never a pass on an empty list.
# (2) declaration-for-reachability: a gate that still carried a private literal would ignore the
# shared file. A doctored vocabulary with one invented stem, and a template using only that stem,
# must convict — proof the shared list is the one in force.
import shutil # noqa: E402
import tempfile # noqa: E402
def _retrieval_with_vocab(vocab_src):
d = tempfile.mkdtemp(prefix="r325-")
try:
if vocab_src is not None:
io.open(os.path.join(d, "customer_copy_vocab.py"), "w", encoding="utf-8").write(vocab_src)
env = dict(os.environ, FELHOM_SHARED_SCRIPTS=d)
p = subprocess.run([sys.executable, os.path.join("scripts", "retrieval_promise_gate.py")],
cwd=CTRL, env=env, capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
finally:
shutil.rmtree(d, ignore_errors=True)
ran += 1
_rc, _out = _retrieval_with_vocab(None)
if _rc != 2 or "INCONCLUSIVE" not in _out:
fails.append("retrieval-promise/vocab-absent: want rc=2 INCONCLUSIVE, got rc=%d\n%s" % (_rc, _out[-300:]))
else:
print(" ok %-20s %s" % ("retrieval-promise/vocab-absent", "stays undetermined"))
ran += 1
_made = not os.path.isdir(SUB)
if _made:
os.makedirs(SUB)
_planted = os.path.join(SUB, "decoy-r325.html")
io.open(_planted, "w", encoding="utf-8").write(u"<p>Minden adatod felhomdecoyszohato marad.</p>\n")
try:
_rc, _out = _retrieval_with_vocab(u'RETRIEVAL_STEMS = ["visszaállíthat", "felhomdecoyszo"]\n')
finally:
os.remove(_planted)
if _made and os.path.isdir(SUB) and not os.listdir(SUB):
os.rmdir(SUB)
if _rc != 1 or "felhomdecoyszo" not in _out:
fails.append("retrieval-promise/vocab-is-live: an invented stem in the SHARED list did not convict — the "
"gate is not reading customer_copy_vocab.py (rc=%d)\n%s" % (_rc, _out[-300:]))
else:
print(" ok %-20s %s" % ("retrieval-promise/vocab-is-live", "decoy rejected"))
# --- golden-notice: R-410's decoy, in the other direction. It is ADVISORY, so rc is never the ---
# --- question — what it COUNTED is. ---
ran += 1