2958946517
${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.
140 lines
5.0 KiB
Go
140 lines
5.0 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// „Hova tegyem a fájlokat?" — the app-page folder card (R-75).
|
|
//
|
|
// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the
|
|
// skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed
|
|
// is the thing that would actually confuse someone).
|
|
|
|
// DataPathCard is one folder row on the app page.
|
|
type DataPathCard struct {
|
|
Label string // the catalog's Hungarian label
|
|
Link string // FileBrowser deep link
|
|
Consequence string // class-DRIVEN copy (never hand-written per app)
|
|
IsImport bool // drives the free-space line
|
|
FreeSpace string // system-drive headroom, import rows only ("" when unreadable)
|
|
}
|
|
|
|
// consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4).
|
|
//
|
|
// It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes
|
|
// can never drift from what the backup engines actually do. `excluded` means the tier filter drops it
|
|
// at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is
|
|
// temporary and unbacked. Saying anything softer would be a false promise about their files.
|
|
func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string {
|
|
switch class {
|
|
case appbackup.ClassExcluded:
|
|
if role == stacks.RoleImport {
|
|
return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " +
|
|
"ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
|
}
|
|
return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
|
case appbackup.ClassMandatory, appbackup.ClassOptional:
|
|
return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla."
|
|
default:
|
|
// No classification (legacy app, or a bind with no backup block). Say nothing rather than
|
|
// guess — an unverified backup promise is worse than no sentence at all.
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// buildDataPathCards turns an app's validated data_paths into rendered rows.
|
|
//
|
|
// classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence
|
|
// line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead.
|
|
func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard {
|
|
if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 {
|
|
return nil
|
|
}
|
|
domain := s.cfg.Customer.Domain
|
|
if domain == "" {
|
|
return nil
|
|
}
|
|
|
|
classOf := map[string]appbackup.BindClass{}
|
|
if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has {
|
|
for _, b := range binds {
|
|
classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class
|
|
}
|
|
}
|
|
|
|
var freeSpace string
|
|
if s.stackMgr != nil {
|
|
if root := s.stackMgr.GetImportRoot(); root != "" {
|
|
if du := system.GetDiskUsage(root); du != nil {
|
|
freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB)
|
|
}
|
|
}
|
|
}
|
|
|
|
cards := make([]DataPathCard, 0, len(st.Meta.DataPaths))
|
|
for _, dp := range st.Meta.DataPaths {
|
|
var link string
|
|
switch dp.Root {
|
|
case appbackup.RootImport:
|
|
link = importFolderLink(domain, dp.Path)
|
|
case appbackup.RootUserdata:
|
|
// A userdata folder lives on the app's OWN drive, so its FileBrowser source is that
|
|
// drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot.
|
|
src := s.fbSourceNameForApp(st.Name)
|
|
if src == "" {
|
|
continue
|
|
}
|
|
link = fileBrowserLink(domain, src, dp.Path)
|
|
default:
|
|
// hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it.
|
|
// Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate
|
|
// an hdd path with a customer-facing role.
|
|
continue
|
|
}
|
|
isImport := dp.Role == stacks.RoleImport
|
|
card := DataPathCard{
|
|
Label: dp.Label,
|
|
Link: link,
|
|
Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role),
|
|
IsImport: isImport,
|
|
}
|
|
if isImport {
|
|
// The system SSD filling is a different severity from a data drive filling — it can take
|
|
// the whole guest down — and the customer has no other signal that the drop-zone is not
|
|
// bottomless.
|
|
card.FreeSpace = freeSpace
|
|
}
|
|
cards = append(cards, card)
|
|
}
|
|
return cards
|
|
}
|
|
|
|
// fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed
|
|
// on: the storage path's label when it has one, else the mount basename — exactly what
|
|
// RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree.
|
|
func (s *Server) fbSourceNameForApp(stackName string) string {
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
|
if appCfg == nil {
|
|
return ""
|
|
}
|
|
hdd := appCfg.Env["HDD_PATH"]
|
|
if hdd == "" {
|
|
return ""
|
|
}
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path != hdd || sp.Decommissioned {
|
|
continue
|
|
}
|
|
if sp.Label != "" {
|
|
return sp.Label
|
|
}
|
|
return filepath.Base(sp.Path)
|
|
}
|
|
return ""
|
|
}
|