f3146ac7bf
DR recipe slice (controller half), grounded in SPIKE-dr-recipe-2026-06-16. The
controller emitter is the BOUNDARY enforcement point: v1 ships an explicit
allowlist {catalog_ref, enabled, storage_bindings} and reads NOTHING from
AppConfig.Env, so no ENC:/token/password can leak. storage_bindings parsed from
the compose (${HDD_PATH}/${USERDATA_PATH} volume binds -> {container_path,
drive, subpath}).
Load-bearing tests: TestBuildAppRecipe_NoSecrets (synthetic-secret app -> none
leak) + TestBuildAppRecipe_AllowlistIsLoadBearing (red-proof companion) +
TestAppStorageBindings + TestBuildDRRecipeAppHalf. Red-proofed live: forcing the
emitter to dump Env makes the boundary test fail. recipe_version=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
8.5 KiB
Go
298 lines
8.5 KiB
Go
package report
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// BuildReport collects current state from all subsystems and returns a Report.
|
|
func BuildReport(
|
|
cfg *config.Config,
|
|
configPath string,
|
|
stackMgr *stacks.Manager,
|
|
backupMgr *backup.Manager,
|
|
cpuCollector *system.CPUCollector,
|
|
metricsStore *metrics.MetricsStore,
|
|
version string,
|
|
storagePaths []settings.StoragePath,
|
|
geoRestriction *settings.GeoRestriction,
|
|
logger *log.Logger,
|
|
) *Report {
|
|
debug := cfg.Logging.Level == "debug"
|
|
if logger != nil {
|
|
logger.Printf("[INFO] [report] Building system report")
|
|
}
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [report] BuildReport: starting — version=%s, storagePaths=%d", version, len(storagePaths))
|
|
}
|
|
|
|
r := &Report{
|
|
Version: 1,
|
|
CustomerID: cfg.Customer.ID,
|
|
CustomerName: cfg.Customer.Name,
|
|
ControllerVersion: version,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
// Controller URL for hub callbacks (self-update trigger, etc.)
|
|
if cfg.Customer.Domain != "" {
|
|
r.ControllerURL = fmt.Sprintf("https://felhom.%s", cfg.Customer.Domain)
|
|
}
|
|
|
|
// Config hash for Hub comparison
|
|
if configPath != "" {
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
h := sha256.Sum256(data)
|
|
r.ConfigHash = hex.EncodeToString(h[:])
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [report] BuildReport: configHash=%s (%d bytes)", r.ConfigHash[:12]+"...", len(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
// System info
|
|
staticInfo := metrics.GetStaticInfo()
|
|
hddPath := cfg.Paths.HDDPath
|
|
if len(storagePaths) > 0 {
|
|
hddPath = storagePaths[0].Path
|
|
}
|
|
sysInfo := system.GetInfo(hddPath, cpuCollector)
|
|
|
|
r.System = SystemReport{
|
|
Hostname: staticInfo.Hostname,
|
|
OS: staticInfo.OS,
|
|
Kernel: staticInfo.Kernel,
|
|
CPUModel: staticInfo.CPUModel,
|
|
CPUCores: staticInfo.CPUCores,
|
|
UptimeSeconds: staticInfo.UptimeSeconds,
|
|
CPUPercent: sysInfo.CPUPercent,
|
|
MemoryTotalMB: sysInfo.TotalMemMB,
|
|
MemoryUsedMB: sysInfo.UsedMemMB,
|
|
MemoryPercent: sysInfo.MemPercent,
|
|
TemperatureCelsius: sysInfo.TemperatureCelsius,
|
|
LoadAvg1: sysInfo.LoadAvg1,
|
|
LoadAvg5: sysInfo.LoadAvg5,
|
|
LoadAvg15: sysInfo.LoadAvg15,
|
|
}
|
|
|
|
// Storage — root filesystem + all registered storage paths
|
|
r.Storage = []StorageReport{
|
|
{Mount: "/", Label: "SSD", TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent},
|
|
}
|
|
for _, sp := range storagePaths {
|
|
if sp.Decommissioned {
|
|
r.Storage = append(r.Storage, StorageReport{
|
|
Mount: sp.Path,
|
|
Label: sp.Label,
|
|
Decommissioned: true,
|
|
MigratedTo: sp.MigratedTo,
|
|
})
|
|
continue
|
|
}
|
|
if sp.Disconnected {
|
|
r.Storage = append(r.Storage, StorageReport{
|
|
Mount: sp.Path,
|
|
Label: sp.Label,
|
|
Disconnected: true,
|
|
})
|
|
continue
|
|
}
|
|
di := system.GetDiskUsage(sp.Path)
|
|
if di == nil {
|
|
continue
|
|
}
|
|
r.Storage = append(r.Storage, StorageReport{
|
|
Mount: sp.Path,
|
|
Label: sp.Label,
|
|
TotalGB: di.TotalGB,
|
|
UsedGB: di.UsedGB,
|
|
Percent: di.UsedPercent,
|
|
})
|
|
}
|
|
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [report] BuildReport: system info collected — cpu=%.1f%%, mem=%d/%dMB, temp=%.1fC",
|
|
sysInfo.CPUPercent, sysInfo.UsedMemMB, sysInfo.TotalMemMB, sysInfo.TemperatureCelsius)
|
|
logger.Printf("[DEBUG] [report] BuildReport: storage entries=%d", len(r.Storage))
|
|
}
|
|
|
|
// Containers
|
|
r.Containers = buildContainerReport(stackMgr, metricsStore)
|
|
|
|
// Backup
|
|
r.Backup = buildBackupReport(cfg, backupMgr)
|
|
|
|
// Health
|
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, logger)
|
|
r.Health = HealthReport{
|
|
Status: healthReport.Status,
|
|
Issues: healthReport.Issues,
|
|
Warnings: healthReport.Warnings,
|
|
}
|
|
if r.Health.Issues == nil {
|
|
r.Health.Issues = []string{}
|
|
}
|
|
if r.Health.Warnings == nil {
|
|
r.Health.Warnings = []string{}
|
|
}
|
|
|
|
// Stacks
|
|
r.Stacks = buildStacksReport(stackMgr)
|
|
|
|
// App telemetry (metrics + log scan)
|
|
r.AppTelemetry = buildAppTelemetrySection(stackMgr, metricsStore, logger)
|
|
|
|
// Geo-restriction status — ALWAYS present (even when never configured) so the hub
|
|
// always renders the section. A nil pointer (omitempty) made the hub hide the whole
|
|
// section for a never-configured controller; a present-but-disabled report renders
|
|
// "Inaktív" hub-side.
|
|
r.GeoRestriction = buildGeoRestrictionReport(geoRestriction)
|
|
|
|
// DR recipe app-half — customer identity + per-app {catalog_ref, enabled, storage_bindings}.
|
|
// Allowlist-only (the boundary): NO env/secret fields. The hub assembles it with the agent half.
|
|
r.DRRecipe = BuildDRRecipeAppHalf(cfg.Customer.ID, cfg.Customer.Name, cfg.Customer.Domain,
|
|
stackMgr.GetStacks(), readComposeFile)
|
|
|
|
if debug && logger != nil {
|
|
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
|
|
r.Containers.Total, r.Health.Status, len(r.Stacks.Deployed), len(r.Stacks.Available), len(r.AppTelemetry))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// buildGeoRestrictionReport always returns a non-nil report so the hub always renders the
|
|
// geo-restriction section. A nil or never-configured input yields Enabled=false with an
|
|
// empty (non-nil) country list — the hub renders that as "Inaktív".
|
|
func buildGeoRestrictionReport(geo *settings.GeoRestriction) *GeoRestrictionReport {
|
|
gr := &GeoRestrictionReport{Enabled: false, AllowedCountries: []string{}}
|
|
if geo == nil {
|
|
return gr
|
|
}
|
|
gr.Enabled = geo.Enabled
|
|
if geo.AllowedCountries != nil {
|
|
gr.AllowedCountries = geo.AllowedCountries
|
|
}
|
|
gr.LastSync = geo.LastSync
|
|
gr.LastSyncError = geo.LastSyncError
|
|
if len(geo.AppOverrides) > 0 {
|
|
gr.AppOverrides = make(map[string]GeoAppOverrideReport, len(geo.AppOverrides))
|
|
for k, v := range geo.AppOverrides {
|
|
gr.AppOverrides[k] = GeoAppOverrideReport{AllowedCountries: v.AllowedCountries}
|
|
}
|
|
}
|
|
return gr
|
|
}
|
|
|
|
func buildContainerReport(stackMgr *stacks.Manager, metricsStore *metrics.MetricsStore) ContainerReport {
|
|
cr := ContainerReport{}
|
|
|
|
allStacks := stackMgr.GetStacks()
|
|
|
|
// Build a map of container stats from metrics store
|
|
statsMap := make(map[string]metrics.ContainerCurrentStats)
|
|
if metricsStore != nil {
|
|
if stats, err := metricsStore.QueryContainerSummary(); err == nil {
|
|
for _, s := range stats {
|
|
statsMap[s.ContainerName] = s
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, s := range allStacks {
|
|
if !s.Deployed {
|
|
continue
|
|
}
|
|
for _, c := range s.Containers {
|
|
cr.Total++
|
|
switch c.State {
|
|
case stacks.StateRunning, stacks.StateStarting:
|
|
cr.Running++
|
|
case stacks.StateUnhealthy:
|
|
cr.Unhealthy++
|
|
cr.Running++ // unhealthy containers are still running
|
|
default:
|
|
cr.Stopped++
|
|
}
|
|
|
|
detail := ContainerDetailReport{
|
|
Name: c.Name,
|
|
State: string(c.State),
|
|
}
|
|
if cs, ok := statsMap[c.Name]; ok {
|
|
detail.CPUPercent = cs.CPUPercent
|
|
detail.MemoryMB = cs.MemUsageMB
|
|
}
|
|
cr.List = append(cr.List, detail)
|
|
}
|
|
}
|
|
|
|
if cr.List == nil {
|
|
cr.List = []ContainerDetailReport{}
|
|
}
|
|
|
|
return cr
|
|
}
|
|
|
|
func buildBackupReport(cfg *config.Config, backupMgr *backup.Manager) BackupReport {
|
|
br := BackupReport{
|
|
Enabled: cfg.Backup.Enabled,
|
|
}
|
|
|
|
if backupMgr == nil {
|
|
return br
|
|
}
|
|
|
|
// Disk-tier backup (restic snapshots, integrity check, repo stats) has moved to
|
|
// the host agent (slice 8C). The controller report now covers only app-data backup
|
|
// (database dumps); restic/snapshot/integrity fields are left zero.
|
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
|
status := backupMgr.GetFullStatus(nextDBDump)
|
|
|
|
if status.LastDBDump != nil {
|
|
t := status.LastDBDump.LastRun
|
|
br.LastDBDump = &t
|
|
}
|
|
|
|
return br
|
|
}
|
|
|
|
func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
|
|
sr := StacksReport{}
|
|
allStacks := stackMgr.GetStacks()
|
|
|
|
for _, s := range allStacks {
|
|
if s.Protected {
|
|
continue
|
|
}
|
|
if s.Deployed {
|
|
sr.Deployed = append(sr.Deployed, s.Name)
|
|
} else {
|
|
sr.Available = append(sr.Available, s.Name)
|
|
}
|
|
}
|
|
|
|
if sr.Deployed == nil {
|
|
sr.Deployed = []string{}
|
|
}
|
|
if sr.Available == nil {
|
|
sr.Available = []string{}
|
|
}
|
|
|
|
return sr
|
|
}
|
|
|