Compare commits
8 Commits
f7c39c1700
...
034a2b7363
| Author | SHA1 | Date | |
|---|---|---|---|
| 034a2b7363 | |||
| daeed27223 | |||
| 8ce496f124 | |||
| 391967bcf1 | |||
| 074ddda352 | |||
| 7b8216abac | |||
| d15ad105be | |||
| 9271f33359 |
@@ -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)
|
||||
|
||||
@@ -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 `>=`) |
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)\(`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user