Completes R-351 and ships R-352's visibility half. Gates 11/11 OK, suite 28 packages ok,
go vet clean, -race clean on the changed package - all run and read BEFORE this commit.
PART 2 SCENARIO A - the deploy page prefills the address and data folder from the app's OWN
backup. backup.RecordedUnitForStack scans every readable namespace root (the app is NOT
installed in this case, so there is no own drive to ask) and reads manifest.json plus the
captured compose/app.yaml. Local file reads only: no network, no restic, no restore.
RecordedAddress.Known() requires BOTH halves on purpose - an absent SUBDOMAIN makes the live
deploy path substitute the CATALOG default (stacks/deploy.go:88-90), and offering that back as
"what your backup says" would be a fabricated fact. The prefill is labelled as coming from the
backup and stays editable: a memory, not a lock.
PART 1 VISIBILITY (R-352) - the deploy page now states where the app's data will live before
the button is pressed. Measured 2026-08-21: 13 of 53 catalogue templates declare a storage
field; the other 40 have none and their data goes to the system drive, which no screen said.
Metadata.HasDeployField answers "does this app have somewhere to PUT a recorded value?" - for
the 40-class a recorded placement is a fact to state, never a value written into a field that
does not exist. NO PLACEMENT CHANGED. NOTHING MIGRATED. The rest is a filed specification.
PART 4 - measured before theorising, on the live off-site target:
snapshots --json 2605 ms once; stats 2697 ms PER APP, sequential, 5 app tags
=> 2605 + 5*2697 = ~16.1 s, matching the reported ten-to-fifteen seconds.
The cause is the shape already on file, so the per-app size calls now run concurrently,
BOUNDED TO 4. The bound is the safety property, not the speed one: the repository is a Hetzner
Storage Box with a session cap, and a refused size call returns SizeBytes 0 - a silent
UNDER-REPORT of the customer's data rather than a visible failure. Peak-in-flight is asserted.
OffsiteInventoryList had no test at all before this.
TEMPLATE SAFETY - every Restore* key is set UNCONDITIONALLY in the deploy handler, because a
template doing index/eq against an undefined key errors at RENDER time: green build, green vet,
green suite, 500 on the page. Four render tests, one per branch, because the existing deploy
render test only renders AutoFields and never reaches these blocks.
RED-PROOFS, mutation asserted applied then reverted to 0:
A three template guards dropped (count asserted 3) -> the blank form returned
P4 inventorySizeConcurrency = 1 -> "peak in flight was 1", elapsed 282ms = sequential
DOCS: CHANGELOG v0.217.0 (MinAgent 0.129.0 unchanged), CONTEXT (the restore's own memory +
what is next), controller/README.md (Backup System), REUSE.md (4 new rows), REPORT.md
overwritten - the previous REPORT preserved to audits/REPORT-v0.216.0-2026-08-14.md first.
NOT fixed here, filed as R-353 and named the next session's first item: a restore whose unit
carries no db_dumps and no volume_dumps still reports a bare completion.
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
New /launcher page: a grid of large tappable tiles, one per openable deployed
app (subdomain presence is the single openability criterion, shared with the
Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles
(deterministic slug color or .felhom.yml brand_color), white glyph/monogram
fallback, target=_blank links for operational apps, greyed unclickable tiles for
stopped ones. First sidebar item; / stays the Vezérlőpult.
Universal app placeholder: new AppPlaceholderSVG served at
/static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a
logo-less app shows a placeholder instead of visibility:hidden. Brand mark is
never an app placeholder.
New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions +
4 red-proofs. No agent coupling; MinAgent unchanged.
Shipped in v0.158.0, caught live within the hour. /apps/<slug> returned 500
for EVERY app: html/template cannot call a pointer-receiver method on a
non-addressable value, and appDetailHandler passes Meta as a VALUE inside a
map[string]interface{}.
It compiled and every test passed because nothing rendered app_info - the
catalog tests used the funcmap route, which takes a value and works either
way. A template method call is only checked when the template runs.
Adds TestAppInfoRendersForEveryLifecycle with the handler's exact data shape.
The previous commit landed only the new test/badge files: a 'git stash' used
to compare REUSE.md ref-check output silently dropped the staged index, so
every modification to an existing file was left behind and that commit does
not build. This adds the metadata field, the predicates, the fail-closed
deploy gate, the catalog filter, the funcmap entries, the template edits and
the docs that those tests exercise.
ClassifiedBinds("samba") resolves from the shares registry instead of catalog
metadata (samba has no .felhom.yml; its binds are absolute share paths). [R4]
Offsite ON -> mandatory (offsite + tier-2); OFF -> optional (tier-2 only);
smb.conf/passdb never classified. Verified through the REAL ComputeCaptureSet
tier filter incl. the negative (optional NOT in offsite). Zero engine edits.
Part-4 Step-1 finding: tier-2 (RunTier2) short-circuits on os.Stat(unitDir)
BEFORE GetStackClassifiedBinds, and the offsite runner enumerates
settings.GetOffboxApps() — both are recovery-unit shaped, which a share-only
infra stack has not. Wiring share data into a live run is therefore more than an
enumeration tweak; reported as a design fork per the STOP clause, not improvised.
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.
- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
(delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
now so Task 3 consumes a tested seam.
INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
Gap 1: third shim listener :2526, plaintext, does NOT advertise STARTTLS (TLSConfig
nil) — for opportunistic-STARTTLS clients with no cert-skip (cal.com, nextcloud).
Gap 2: SMTPMapping tls_mode (picks port 2525/2526/2465) + from_domain_var (split
local-part + domain for nextcloud's MAIL_FROM_ADDRESS/MAIL_DOMAIN). Default keeps
existing apps on 2525. Hub untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add structured operational logging at INFO, WARN, and ERROR levels to
every controller module. Standardize custom prefixes ([GEO], [SCHED],
[SYNC]) to use [INFO/WARN/ERROR] [module] format. Fix misleveled logs
(WARN->ERROR for data loss scenarios, WARN->INFO for routine operations).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Generic integration system for connecting deployed apps via toggle UI.
First handlers: OnlyOffice→FileBrowser (config.yaml patch) and
OnlyOffice→Nextcloud (occ CLI). Lifecycle hooks auto-suspend on
stop and re-apply on start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add network-level health probing from the controller to deployed apps.
The controller probes containers over the shared Docker network and
overrides stack state to "unhealthy" if the service isn't responding.
Three probe types: http (any response = alive), api (validates status
code and body content), tcp (port reachability). Configured per-app
via healthcheck: section in .felhom.yml. Runs every minute, per-app
interval defaults to 5 minutes.
This replaces Docker-level healthchecks for distroless images (e.g.
Vikunja) that lack shell utilities, and complements existing Docker
healthchecks for other apps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Users can now customize the subdomain for each app during deployment
instead of using a fixed value. The deploy page shows an editable text
input with the default pre-filled and the base domain as a suffix.
New "subdomain" deploy field type with DNS-safe format validation,
reserved name blocklist, and uniqueness check across deployed stacks.
Locked after deploy — changing requires Remove + Redeploy.
Backward compatible: InjectMissingFields() auto-fills SUBDOMAIN from
.felhom.yml defaults for existing deployed apps on next sync/restart.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Log YAML parse errors in LoadMetadata instead of silently swallowing
- Add debug log for successful metadata loads (tagline, useCases, optConfig counts)
- Fix CLAUDE.md deploy command: use sudo, target only image: line in sed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New route: GET /apps/{slug} renders info page with use cases, setup guide, prerequisites
- New API: POST /api/stacks/{name}/optional-config for updating optional env vars
- New structs: AppInfo, OptionalConfigGroup, OptionalConfigField in metadata.go
- UpdateOptionalConfig saves to app.yaml and restarts deployed stacks with new env vars
- Info page template with hero section, screenshots, info cards, optional config form
- Navigation: stack cards now link to /apps/{slug}, deploy page has "Részletek" link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>