7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1479 lines
57 KiB
Go
1479 lines
57 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// Router handles all /api/* requests.
|
|
type Router struct {
|
|
cfg *config.Config
|
|
configPath string
|
|
sett *settings.Settings
|
|
stackMgr *stacks.Manager
|
|
syncer *catalogsync.Syncer
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
metricsStore *metrics.MetricsStore
|
|
updater *selfupdate.Updater
|
|
notifier *notify.Notifier
|
|
logger *log.Logger
|
|
|
|
// restart triggers a graceful self-restart (config-apply + the manual restart button).
|
|
// Defaults to a real exit-after-flush so Docker's restart:unless-stopped brings the
|
|
// process back with fresh config; tests inject a recorder via SetRestarter.
|
|
restart func()
|
|
|
|
// classifyFSPath classifies a path's filesystem in this process's namespace (the deploy-time
|
|
// stub gate, RCA fix 2). Defaults to system.ClassifyPathFSTimeout; tests inject fake classes.
|
|
classifyFSPath func(path string) string
|
|
|
|
// triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a
|
|
// geo settings change so the hub reflects the new state immediately). Nil = no-op.
|
|
triggerReportPush func()
|
|
|
|
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
|
|
OnGeoRelevantChange func()
|
|
|
|
// Asset syncer for on-demand Hub asset sync
|
|
assetsSyncer *assets.Syncer
|
|
|
|
// Geo-restriction sync manager
|
|
geoSync *cf.GeoSyncManager
|
|
|
|
// App-to-app integration manager (nil if not configured)
|
|
integrationMgr *integrations.Manager
|
|
|
|
debug bool
|
|
}
|
|
|
|
// SetDebug enables or disables debug logging for API routing.
|
|
func (r *Router) SetDebug(on bool) {
|
|
r.debug = on
|
|
}
|
|
|
|
func (r *Router) dbg(format string, args ...interface{}) {
|
|
if r.debug {
|
|
r.logger.Printf("[DEBUG] [api] "+format, args...)
|
|
}
|
|
}
|
|
|
|
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
|
|
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
|
|
r.assetsSyncer = as
|
|
}
|
|
|
|
// SetGeoSync sets the geo-restriction sync manager.
|
|
func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) {
|
|
r.geoSync = gs
|
|
}
|
|
|
|
// SetIntegrationManager sets the app-to-app integration manager.
|
|
func (r *Router) SetIntegrationManager(im *integrations.Manager) {
|
|
r.integrationMgr = im
|
|
}
|
|
|
|
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
|
|
r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
|
|
r.restart = func() { gracefulSelfRestart(r.logger) }
|
|
r.classifyFSPath = system.ClassifyPathFSTimeout
|
|
return r
|
|
}
|
|
|
|
// SetRestarter overrides the graceful-restart action. Tests inject a recorder so the
|
|
// process is not actually killed.
|
|
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
|
|
|
|
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
|
|
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
|
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
|
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
|
|
// local (non-network) and empty paths keep today's behavior exactly.
|
|
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
|
if hdd == "" || r.sett == nil || !r.sett.IsNetworkStoragePath(hdd) {
|
|
return ""
|
|
}
|
|
if r.classifyFSPath(hdd) != system.FSClassStub {
|
|
return ""
|
|
}
|
|
return "api.deploy.netstorage_unreachable"
|
|
}
|
|
|
|
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
|
|
// The provided func MUST be non-blocking (it is called from request handlers).
|
|
func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn }
|
|
|
|
// reportPushNow fires the report-push trigger if wired. Called after a state change the
|
|
// hub should reflect immediately (geo settings/sync) instead of waiting for the next cycle.
|
|
func (r *Router) reportPushNow() {
|
|
if r.triggerReportPush != nil {
|
|
r.triggerReportPush()
|
|
}
|
|
}
|
|
|
|
type apiResponse struct {
|
|
OK bool `json:"ok"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// ServeHTTP routes /api/* requests.
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
path := strings.TrimPrefix(req.URL.Path, "/api")
|
|
path = strings.TrimSuffix(path, "/")
|
|
|
|
r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, path)
|
|
|
|
switch {
|
|
// GET /api/stacks
|
|
case path == "/stacks" && req.Method == http.MethodGet:
|
|
r.listStacks(w, req)
|
|
|
|
// POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks
|
|
case path == "/stacks/rescan" && req.Method == http.MethodPost:
|
|
r.rescanStacks(w, req)
|
|
|
|
// F4: /api/stacks/rescan with a non-POST method must be a clear 405, not fall through to the
|
|
// GET /stacks/{name} lookup below (which returned the misleading "stack not found: rescan").
|
|
case path == "/stacks/rescan":
|
|
w.Header().Set("Allow", http.MethodPost)
|
|
writeJSON(w, http.StatusMethodNotAllowed, apiResponse{OK: false, Error: "method not allowed: use POST /api/stacks/rescan"})
|
|
|
|
// GET /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
|
|
r.getStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// GET /api/selfupdate/status — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/status" && req.Method == http.MethodGet:
|
|
r.selfupdateStatus(w, req)
|
|
|
|
// POST /api/selfupdate/check — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/check" && req.Method == http.MethodPost:
|
|
r.selfupdateCheck(w, req)
|
|
|
|
// POST /api/selfupdate/update — must be before hasSuffix("/update") stack case
|
|
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
|
r.selfupdateTrigger(w, req)
|
|
|
|
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
|
case path == "/config/apply" && req.Method == http.MethodPost:
|
|
r.configApply(w, req)
|
|
|
|
// GET /api/config/hash — return current config file hash
|
|
case path == "/config/hash" && req.Method == http.MethodGet:
|
|
r.configHash(w, req)
|
|
|
|
// GET /api/config — return raw controller.yaml content
|
|
case path == "/config" && req.Method == http.MethodGet:
|
|
r.configContent(w, req)
|
|
|
|
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
|
|
case path == "/selfrestart" && req.Method == http.MethodPost:
|
|
r.selfRestart(w, req)
|
|
|
|
// --- Integration routes (must be before hasSuffix-based stack cases) ---
|
|
|
|
// GET /api/integrations/{provider} — list integrations for a provider
|
|
case strings.HasPrefix(path, "/integrations/") && !strings.Contains(strings.TrimPrefix(path, "/integrations/"), "/") && req.Method == http.MethodGet:
|
|
provider := strings.TrimPrefix(path, "/integrations/")
|
|
r.listIntegrations(w, provider)
|
|
|
|
// POST /api/integrations/{provider}/{target} — toggle integration
|
|
case strings.HasPrefix(path, "/integrations/") && req.Method == http.MethodPost:
|
|
rest := strings.TrimPrefix(path, "/integrations/")
|
|
parts := strings.SplitN(rest, "/", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid integration path"})
|
|
return
|
|
}
|
|
r.toggleIntegration(w, req, parts[0], parts[1])
|
|
|
|
// GET /api/stacks/{name}/deploy-fields
|
|
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
|
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
|
|
|
|
// POST /api/stacks/{name}/deploy
|
|
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
|
|
r.deployStack(w, req, extractName(path, "/deploy"))
|
|
|
|
// POST /api/stacks/{name}/start
|
|
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
|
r.actionStack(w, req, "start", extractName(path, "/start"))
|
|
|
|
// POST /api/stacks/{name}/stop
|
|
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
|
r.actionStack(w, req, "stop", extractName(path, "/stop"))
|
|
|
|
// POST /api/stacks/{name}/restart
|
|
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
|
r.actionStack(w, req, "restart", extractName(path, "/restart"))
|
|
|
|
// POST /api/stacks/{name}/update
|
|
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
|
r.actionStack(w, req, "update", extractName(path, "/update"))
|
|
|
|
// POST /api/stacks/{name}/optional-config
|
|
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
|
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
|
|
|
|
// GET /api/stacks/{name}/logs
|
|
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
|
|
r.getStackLogs(w, req, extractName(path, "/logs"))
|
|
|
|
// GET /api/stacks/{name}/hdd-data
|
|
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
|
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
|
|
|
// GET /api/stacks/{name}/backup-data
|
|
case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
|
|
r.getStackBackupData(w, req, extractName(path, "/backup-data"))
|
|
|
|
// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
|
|
case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
|
|
r.removeStack(w, req, extractName(path, "/remove"))
|
|
|
|
// DELETE /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
|
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// POST /api/sync — trigger immediate catalog sync
|
|
case path == "/sync" && req.Method == http.MethodPost:
|
|
r.triggerSync(w, req)
|
|
|
|
// GET /api/system/info
|
|
case path == "/system/info" && req.Method == http.MethodGet:
|
|
r.systemInfo(w, req)
|
|
|
|
// GET /api/backup/status
|
|
case path == "/backup/status" && req.Method == http.MethodGet:
|
|
r.backupStatus(w, req)
|
|
|
|
// GET /api/backup/restore-status — async restore progress for the backups-page banner (Part B).
|
|
// Distinct from /backup/status (which proxies the agent's PBS whole-guest status).
|
|
case path == "/backup/restore-status" && req.Method == http.MethodGet:
|
|
r.backupRestoreStatus(w, req)
|
|
|
|
// GET /api/backup/snapshots?stack=<name> — restorable keep-side backups for the restore panel
|
|
case path == "/backup/snapshots" && req.Method == http.MethodGet:
|
|
r.backupSnapshots(w, req)
|
|
|
|
// POST /api/backup/run
|
|
case path == "/backup/run" && req.Method == http.MethodPost:
|
|
r.triggerBackup(w, req)
|
|
|
|
// POST /api/backup/tier2 — run off-drive Tier 2 copies for all HDD apps
|
|
case path == "/backup/tier2" && req.Method == http.MethodPost:
|
|
r.triggerTier2(w, req)
|
|
|
|
// GET /api/metrics/system
|
|
case path == "/metrics/system" && req.Method == http.MethodGet:
|
|
r.metricsSystem(w, req)
|
|
|
|
// GET /api/metrics/containers/summary
|
|
case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
|
|
r.metricsContainerSummary(w, req)
|
|
|
|
// GET /api/metrics/containers/{name}
|
|
case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
|
|
name := strings.TrimPrefix(path, "/metrics/containers/")
|
|
r.metricsContainer(w, req, name)
|
|
|
|
// GET /api/metrics/sysinfo
|
|
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
|
|
r.metricsSysInfo(w, req)
|
|
|
|
// POST /api/assets/sync — trigger immediate asset sync from Hub
|
|
case path == "/assets/sync" && req.Method == http.MethodPost:
|
|
r.triggerAssetSync(w, req)
|
|
|
|
// GET /api/assets/status — get asset sync status
|
|
case path == "/assets/status" && req.Method == http.MethodGet:
|
|
r.assetSyncStatus(w, req)
|
|
|
|
// --- Geo-restriction endpoints ---
|
|
|
|
// GET /api/geo/status — current geo settings + sync state
|
|
case path == "/geo/status" && req.Method == http.MethodGet:
|
|
r.geoStatus(w, req)
|
|
|
|
// POST /api/geo/settings — update global geo settings
|
|
case path == "/geo/settings" && req.Method == http.MethodPost:
|
|
r.geoUpdateSettings(w, req)
|
|
|
|
// POST /api/geo/sync — trigger manual Cloudflare sync
|
|
case path == "/geo/sync" && req.Method == http.MethodPost:
|
|
r.geoTriggerSync(w, req)
|
|
|
|
// GET /api/geo/countries — full country list for search UI
|
|
case path == "/geo/countries" && req.Method == http.MethodGet:
|
|
r.geoCountries(w, req)
|
|
|
|
// POST /api/stacks/{name}/geo/override — set per-app geo override
|
|
case hasSuffix(path, "/geo/override") && req.Method == http.MethodPost:
|
|
r.geoSetAppOverride(w, req, extractName(path, "/geo/override"))
|
|
|
|
// DELETE /api/stacks/{name}/geo/override — remove per-app geo override
|
|
case hasSuffix(path, "/geo/override") && req.Method == http.MethodDelete:
|
|
r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
|
|
|
|
default:
|
|
r.dbg("no matching route: %s %s", req.Method, path)
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
|
}
|
|
}
|
|
|
|
// HealthHandler responds to /api/health (no auth required).
|
|
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
|
|
}
|
|
|
|
// --- Stack handlers ---
|
|
|
|
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
|
|
}
|
|
|
|
func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
|
|
r.logger.Printf("[INFO] [api] Manual stack rescan requested")
|
|
if err := r.stackMgr.ScanStacks(); err != nil {
|
|
r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
stackCount := len(r.stackMgr.GetStacks())
|
|
r.logger.Printf("[INFO] [api] Stack rescan completed: %d stacks found", stackCount)
|
|
writeJSON(w, http.StatusOK, apiResponse{
|
|
OK: true,
|
|
Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount),
|
|
})
|
|
}
|
|
|
|
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
|
stack, ok := r.stackMgr.GetStack(name)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
|
}
|
|
|
|
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
|
|
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"metadata": meta,
|
|
"app_config": appCfg,
|
|
"domain": r.cfg.Customer.Domain,
|
|
"logo_url": r.cfg.AppLogoURL(meta.Slug),
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
// deployStatusFor maps a deploy refusal to its HTTP status by the refusal's KIND (R-553).
|
|
//
|
|
// It used to read the customer's Hungarian sentence — `strings.Contains(err.Error(), "kötelező")`,
|
|
// „memória", plus the English "does not exist" / "already deployed". Localisation slice 2 translates
|
|
// exactly those words, and on that day every validation refusal would have become a 500: the customer
|
|
// sees a server error for a field they simply left empty, and the API contract changes silently.
|
|
// The sentences are untouched; the kinds come from internal/stacks (deploy_errors.go).
|
|
// Pinned by TestR553_Deploy_DecisionSurvivesWordingChange.
|
|
func deployStatusFor(err error) int {
|
|
switch {
|
|
case errors.Is(err, stacks.ErrAlreadyDeployed):
|
|
return http.StatusConflict
|
|
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing),
|
|
errors.Is(err, stacks.ErrNotEnoughMemory):
|
|
return http.StatusBadRequest
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
|
|
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Lifecycle gate: an app withdrawn from the catalog (`lifecycle: hidden` / `abandoned`) is not
|
|
// installable. FAIL-CLOSED and server-side on purpose — the catalog page already omits these, so
|
|
// anything reaching here is a stale link, a bookmarked deploy form, or a direct POST, and a gate
|
|
// that only hides the button is not a gate. Deliberately BEFORE every mutation.
|
|
//
|
|
// This does NOT touch an already-deployed instance: it is on the deploy path only, and the
|
|
// manager refuses a redeploy of an existing stack through its own "already deployed" check.
|
|
if st, ok := r.stackMgr.GetStack(name); ok && !st.Meta.CanInstall() {
|
|
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
|
name, st.Meta.EffectiveLifecycle())
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
|
Error: r.msg(req, "api.deploy.not_installable")})
|
|
return
|
|
}
|
|
|
|
// Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its
|
|
// reserved buffer, so customer apps can't fill the volume the infra containers (controller,
|
|
// traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is
|
|
// a safety net, not a security control, so a transient statfs failure must not block all deploys.
|
|
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
|
|
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
|
|
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
|
|
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
|
|
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
|
|
return
|
|
}
|
|
|
|
// RCA fix 2 (AUDIT-nas-cwa-rca-2026-07-11): a deploy targeting a registered NETWORK storage path
|
|
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
|
|
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
|
|
// send the app's data to the system drive.
|
|
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
|
|
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
|
|
return
|
|
}
|
|
|
|
// R-108: an app's data namespace may NOT live on network storage — its backups would land at
|
|
// `<share>/backups/primary/<stack>/`, inside the share-ROOT bind FileBrowser serves with
|
|
// download:true (and that bind cannot be narrowed — see settings.RefuseAsAppNamespace).
|
|
//
|
|
// THIS is the boundary, not the deploy dropdown. The dropdown is a UI list; this endpoint accepts
|
|
// whatever HDD_PATH a caller supplies and `DeployStack` validates only that it EXISTS on the
|
|
// filesystem (os.Stat, internal/stacks/deploy.go). A filter on the list alone would have left the
|
|
// surface wide open — the R-108 row's "no IsNetwork() filter on the dropdown" understates it.
|
|
if refuse, why := r.sett.RefuseAsAppNamespace(body.Values["HDD_PATH"]); refuse {
|
|
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH is not usable as an app namespace (R-108)", name)
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
|
|
return
|
|
}
|
|
|
|
deployReq := stacks.DeployRequest{
|
|
StackName: name,
|
|
Values: body.Values,
|
|
}
|
|
|
|
warning, err := r.stackMgr.DeployStack(deployReq)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
|
|
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
|
|
writeJSON(w, deployStatusFor(err), apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
// F6: the deploy runs asynchronously (compose pull/up + health happen after this returns; the UI
|
|
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
|
|
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
|
|
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
|
|
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
|
|
if warning != "" {
|
|
resp.Data = map[string]string{"warning": warning}
|
|
}
|
|
writeJSON(w, http.StatusAccepted, resp)
|
|
|
|
// Push app deployed event to Hub
|
|
if r.notifier != nil {
|
|
displayName := name
|
|
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
|
|
displayName = s.Meta.DisplayName
|
|
}
|
|
// R-536: this is the ACCEPTANCE, not the installation. `app_deployed` moved to the async
|
|
// path's own end (stacks.deployDoneHook → Router.OnDeployDone), because the deploy runs after
|
|
// this returns and an interrupted one used to leave a completed-install record behind.
|
|
r.notifier.NotifyAppDeployStarted(name, displayName)
|
|
}
|
|
|
|
// Re-sync geo rules (new hostname may need to be added)
|
|
if r.OnGeoRelevantChange != nil {
|
|
go r.OnGeoRelevantChange()
|
|
}
|
|
|
|
// v0.139.0: the hub sees the deploy in seconds (debounced trigger, not per-request)
|
|
r.reportPushNow()
|
|
|
|
// Re-apply integrations that target this newly deployed stack
|
|
if r.integrationMgr != nil {
|
|
go r.integrationMgr.OnStackStart(context.Background(), name)
|
|
}
|
|
}
|
|
|
|
// restoreHoldFor reports whether an app is held after a failed restore whose rollback also failed,
|
|
// and returns the CUSTOMER-facing Hungarian reason. Delegates to the backup manager so the sentence
|
|
// has one source; the drive gate's own reason is operator-English and stays that way.
|
|
func (r *Router) restoreHoldFor(name string) (bool, string) {
|
|
if r.backupMgr == nil {
|
|
return false, ""
|
|
}
|
|
return r.backupMgr.RestoreHoldFor(name)
|
|
}
|
|
|
|
// startGatedByMissingDrive reports whether starting `name` must be BLOCKED because the drive its
|
|
// HDD_PATH points at is currently disconnected or decommissioned. Returns the storage path for the
|
|
// message. SSD-resident apps (no HDD_PATH) are never gated.
|
|
func (r *Router) startGatedByMissingDrive(name string) (bool, string) {
|
|
cfg := r.stackMgr.LoadAppConfigByName(name)
|
|
if cfg == nil {
|
|
return false, ""
|
|
}
|
|
hdd := cfg.Env["HDD_PATH"]
|
|
if hdd == "" {
|
|
return false, ""
|
|
}
|
|
for _, sp := range r.sett.GetStoragePaths() {
|
|
if sp.Path == hdd && (sp.Disconnected || sp.Decommissioned) {
|
|
return true, hdd
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// desiredStateForAction maps a stack action to the customer intent it expresses, or (_, false) for
|
|
// an action that expresses none. Pure, so the §8.1/§1.3 mapping is testable without a Manager.
|
|
//
|
|
// `restart` and `update` both mean running: a customer who updates or restarts an app is asking for
|
|
// it to be up afterwards, and both end in `compose up -d`. Anything not listed here — an unknown
|
|
// action string — records nothing rather than guessing, so a future action cannot silently acquire
|
|
// an intent it was never meant to carry.
|
|
func desiredStateForAction(action string) (string, bool) {
|
|
switch action {
|
|
case "start", "restart", "update":
|
|
return stacks.DesiredStateRunning, true
|
|
case "stop":
|
|
return stacks.DesiredStateStopped, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// Protected stacks only allow restart — block all other actions
|
|
if r.cfg.IsProtectedStack(name) && action != "restart" {
|
|
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)})
|
|
return
|
|
}
|
|
|
|
// Drive-absent gate: refuse to start an app whose data drive is currently disconnected/decommissioned
|
|
// (the intermediary-mount gate). Starting it would let it write to the empty fail-closed stable path
|
|
// or just crash-loop; block with a clear message until the drive returns (then the gate auto-restarts).
|
|
// R-379/R-380: an app held after a failed restore + failed rollback must not start from the
|
|
// customer's button either. Checked BEFORE the drive gate because it applies to driveless apps,
|
|
// which is the class the hold exists for.
|
|
//
|
|
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
|
|
// updated — the one action most likely to make a held app's data worse. Pinned by
|
|
// TestR439_UpdateOfAHeldAppIsRefused.
|
|
if action == "start" || action == "restart" || action == "update" {
|
|
if held, why := r.restoreHoldFor(name); held {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
|
|
return
|
|
}
|
|
}
|
|
|
|
if action == "start" {
|
|
if gated, hdd := r.startGatedByMissingDrive(name); gated {
|
|
writeJSON(w, http.StatusConflict, apiResponse{
|
|
OK: false,
|
|
Error: r.msg(req, "api.start.drive_missing", hdd),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Memory check before starting a stopped app
|
|
if action == "start" {
|
|
stackMemMB := r.stackMgr.StackMemoryMB(name)
|
|
if stackMemMB > 0 {
|
|
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
|
reservedMB := r.cfg.System.ReservedMemoryMB
|
|
usableMB := totalMB - reservedMB
|
|
if usableMB < 0 {
|
|
usableMB = 0
|
|
}
|
|
afterMB := usedMB + stackMemMB
|
|
if afterMB > usableMB {
|
|
writeJSON(w, http.StatusConflict, apiResponse{
|
|
OK: false,
|
|
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slice 4: every cheap refusal of an update — busy, already updating, deploying, memory, disk, and
|
|
// "no backup to return to" — BEFORE the intent below is recorded, so an update that was never going
|
|
// to happen records nothing (§8.2). Each is a 409 with the Hungarian sentence.
|
|
if action == "update" {
|
|
if ref := r.stackMgr.UpdatePreflight(name); ref != nil {
|
|
status := http.StatusConflict
|
|
if ref.Reason == "not_found" {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: ref.Message})
|
|
return
|
|
}
|
|
}
|
|
|
|
// R-166: THE CUSTOMER-INTENT POINT. This switch is where a human's decision about whether their
|
|
// app should be running enters the system, and until v0.189.0 that decision was recorded nowhere
|
|
// — so the box had to infer it from container counts, and inferred wrong for a power cut and for
|
|
// an interrupted backup alike.
|
|
//
|
|
// Written BEFORE the act (§8.2) and a failed write REFUSES the act: performing a stop whose
|
|
// intent could not be recorded would recreate exactly the ambiguity this closes. Both gates that
|
|
// can legitimately refuse an action (protected-stack, drive-absent, memory) have already run
|
|
// above, so nothing is recorded for an action that was never going to happen.
|
|
if desired, ok := desiredStateForAction(action); ok {
|
|
if derr := r.stackMgr.SetDesiredState(name, desired); derr != nil {
|
|
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{
|
|
OK: false,
|
|
Error: r.msg(req, "api.action.state_not_saved"),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
var err error
|
|
switch action {
|
|
case "start":
|
|
err = r.stackMgr.StartStack(name)
|
|
case "stop":
|
|
err = r.stackMgr.StopStack(name)
|
|
case "restart":
|
|
err = r.stackMgr.RestartStack(name)
|
|
case "update":
|
|
// Slice 4: the GUARDED update. It returns as soon as the job has started; the result is only
|
|
// ever known from GET /api/stacks/{name} (updating / update_phase / update_error).
|
|
err = r.stackMgr.StartGuardedUpdate(name)
|
|
}
|
|
|
|
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
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
// R-443 (slice 4): an update is NEVER reported completed here. The spike measured this line saying
|
|
// "update completed" over an app that was already crash-looping. It answers 202 — accepted, not
|
|
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
|
|
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
|
|
if action == "update" {
|
|
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
|
|
Data: map[string]interface{}{"accepted": true, "completed": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
|
|
|
// Trigger integration lifecycle hooks after successful action
|
|
if r.integrationMgr != nil {
|
|
switch action {
|
|
case "start", "restart":
|
|
go r.integrationMgr.OnStackStart(context.Background(), name)
|
|
case "stop":
|
|
go r.integrationMgr.OnStackStop(context.Background(), name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[INFO] [api] Optional config update requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
|
r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
|
|
}
|
|
|
|
// --- Integration API handlers ---
|
|
|
|
func (r *Router) listIntegrations(w http.ResponseWriter, provider string) {
|
|
if r.integrationMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []integrations.StatusInfo{}})
|
|
return
|
|
}
|
|
list := r.integrationMgr.ListForProvider(provider)
|
|
if list == nil {
|
|
list = []integrations.StatusInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
|
|
}
|
|
|
|
func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, provider, target string) {
|
|
limitBody(w, req)
|
|
if r.integrationMgr == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, apiResponse{OK: false, Error: "integrations not available"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
action := "enable"
|
|
if !body.Enabled {
|
|
action = "disable"
|
|
}
|
|
r.logger.Printf("[INFO] [api] Integration %s requested: %s:%s", action, provider, target)
|
|
|
|
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
msg := r.msg(req, "api.integration.enabled")
|
|
if !body.Enabled {
|
|
msg = r.msg(req, "api.integration.disabled")
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
|
|
}
|
|
|
|
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
|
|
lines := 100
|
|
if v := req.URL.Query().Get("lines"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
lines = n
|
|
if lines > 10000 {
|
|
lines = 10000
|
|
}
|
|
}
|
|
}
|
|
|
|
output, err := r.stackMgr.GetLogs(name, lines)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
|
}
|
|
|
|
func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) {
|
|
resp, err := r.stackMgr.GetStackHDDData(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
|
|
// Compute the felhom-data namespace root for this stack (drive-resident apps: the in-guest
|
|
// mount IS the namespace; SSD-only: <systemDataPath>/felhom-data). Passing the namespace root
|
|
// (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A.
|
|
var nsRoot string
|
|
var mirrors []string
|
|
if r.backupMgr != nil {
|
|
nsRoot = r.backupMgr.AppNamespaceRoot(name)
|
|
mirrors = r.backupMgr.Tier2MirrorDirsForApp(name) // R-485: the real second-drive copy, not a dead `rsync` path
|
|
}
|
|
|
|
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
limitBody(w, req)
|
|
r.logger.Printf("[INFO] [api] Remove requested for stack: %s", name)
|
|
r.dbg("removeStack: name=%s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
RemoveBackups bool `json:"remove_backups"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
body.RemoveBackups = false
|
|
}
|
|
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
|
|
|
|
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
|
|
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
|
|
//
|
|
// R-474 (v0.240.0): "delete backups" deletes the app's WHOLE recovery unit (definition, db-dumps,
|
|
// volume tars) and its Tier-2 mirror, not only db-dumps. The mirror is on another drive, outside
|
|
// RemoveStack's per-app base, so the backup manager removes it — and its location is read NOW,
|
|
// before the cross-drive record is cleared below. Off-site snapshots are not touched by this path.
|
|
var backupPaths, mirrorDirs []string
|
|
if body.RemoveBackups && r.backupMgr != nil {
|
|
nsRoot := r.backupMgr.AppNamespaceRoot(name)
|
|
if nsRoot != "" {
|
|
backupPaths = append(backupPaths, backup.RecoveryUnitPath(nsRoot, name), backup.AppDBDumpPath(nsRoot, name))
|
|
}
|
|
mirrorDirs = r.backupMgr.Tier2MirrorDirsForApp(name)
|
|
}
|
|
|
|
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Remove failed for %s: %v", name, err)
|
|
// R-442: a refused data removal is a first-class, non-2xx answer with the exact customer
|
|
// sentence — never a 200 with empty lists. The app was not removed either.
|
|
var refused *stacks.RemoveRefusedError
|
|
if errors.As(err, &refused) {
|
|
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 deployed") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
|
|
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
|
|
}
|
|
|
|
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
|
|
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
|
|
// deleted. Until v0.239.0 the cross-drive record was cleared on every removal, so an app removed
|
|
// with its backups kept could not be restored from its intact second-drive mirror (measured on
|
|
// demo-hp 2026-09-13: „nincs másodlagos fájlmásolat" over a 236 MB mirror). Pinned by
|
|
// TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo.
|
|
if r.sett != nil && body.RemoveBackups {
|
|
if err := r.sett.DeleteAppBackupPrefs(name); err != nil {
|
|
r.logger.Printf("[WARN] [api] Failed to forget the backup preferences of %s: %v", name, err)
|
|
}
|
|
}
|
|
// R-491 (v0.242.0): an app held after a failed update that the customer then REMOVES is not held
|
|
// any more — the hold named a copy for a deploy that no longer exists, and every start gate reads
|
|
// the store, so a reinstall under the same name would begin refused. Measured on demo-hp
|
|
// 2026-09-13 (nextcloud). An R-379 restore hold is deliberately NOT cleared here.
|
|
if r.sett != nil {
|
|
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
|
|
r.logger.Printf("[WARN] [api] Failed to clear the update hold of %s: %v", name, err)
|
|
} else if cleared {
|
|
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
|
|
|
// Push app removed event to Hub
|
|
if r.notifier != nil {
|
|
r.notifier.NotifyAppRemoved(name, name)
|
|
}
|
|
|
|
// Clean up integrations for removed stack
|
|
if r.integrationMgr != nil {
|
|
r.integrationMgr.OnStackRemove(context.Background(), name)
|
|
}
|
|
|
|
// Re-sync geo rules (hostname removed)
|
|
if r.OnGeoRelevantChange != nil {
|
|
go r.OnGeoRelevantChange()
|
|
}
|
|
|
|
// v0.139.0: the hub sees the removal in seconds (debounced trigger, not per-request)
|
|
r.reportPushNow()
|
|
}
|
|
|
|
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[INFO] [api] Delete requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
}
|
|
|
|
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Delete failed for %s: %v", name, err)
|
|
var refused *stacks.RemoveRefusedError // R-442, same as removeStack
|
|
if errors.As(err, &refused) {
|
|
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
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
|
|
|
|
// v0.139.0: the hub sees the delete in seconds (debounced trigger, not per-request)
|
|
r.reportPushNow()
|
|
}
|
|
|
|
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
|
r.logger.Println("[INFO] [api] Manual catalog sync requested")
|
|
result := r.syncer.TriggerSync()
|
|
if !result.OK {
|
|
writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
|
|
return
|
|
}
|
|
r.logger.Printf("[INFO] [api] Catalog sync completed: %s", result.Message)
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
|
|
}
|
|
|
|
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
|
|
if r.sett != nil {
|
|
if p := r.sett.GetDefaultStoragePath(); p != "" {
|
|
hddPath = p
|
|
}
|
|
}
|
|
info := system.GetInfo(hddPath, r.cpuCollector)
|
|
// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
|
|
// "used" is not observable from the container. Report the controller's accurate committed-app memory
|
|
// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
|
|
if r.stackMgr != nil && info.TotalMemMB > 0 {
|
|
if reqMB, _ := r.stackMgr.CommittedMemory(); reqMB >= 0 {
|
|
used := uint64(reqMB)
|
|
if used > info.TotalMemMB {
|
|
used = info.TotalMemMB
|
|
}
|
|
info.UsedMemMB = used
|
|
info.AvailMemMB = info.TotalMemMB - used
|
|
info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
|
|
}
|
|
}
|
|
var syncStatus interface{}
|
|
if r.syncer != nil { // nil in tests; production always wires the syncer
|
|
syncStatus = r.syncer.Status()
|
|
}
|
|
data := map[string]interface{}{
|
|
"system": info,
|
|
"sync_status": syncStatus,
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
// --- Backup handlers ---
|
|
|
|
func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"enabled": false,
|
|
}})
|
|
return
|
|
}
|
|
|
|
dbDump := r.backupMgr.GetStatus()
|
|
data := map[string]interface{}{
|
|
"enabled": true,
|
|
"running": r.backupMgr.IsRunning(),
|
|
}
|
|
|
|
if dbDump != nil {
|
|
data["db_dump"] = map[string]interface{}{
|
|
"last_run": dbDump.LastRun,
|
|
"success": dbDump.Success,
|
|
"duration": dbDump.Duration.String(),
|
|
"count": len(dbDump.Results),
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
// backupRestoreStatus (Part B) surfaces the async restore-op progress the backups page polls to drive
|
|
// its banner (running: <op>/<stack> → terminal last{ok,message}). Display-only; empty when idle.
|
|
func (r *Router) backupRestoreStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"running": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.backupMgr.RestoreStatus()})
|
|
}
|
|
|
|
// validStackParam reports whether a stack name from a request is a safe single path segment
|
|
// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because
|
|
// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become
|
|
// a filesystem path outside the app's namespace root.
|
|
func validStackParam(name string) bool {
|
|
if name == "" || name == "." || name == ".." {
|
|
return false
|
|
}
|
|
if strings.ContainsAny(name, "/\\\x00") {
|
|
return false
|
|
}
|
|
return name == filepath.Clean(name)
|
|
}
|
|
|
|
// backupSnapshots lists the restorable keep-side backups for one app — the data source of the
|
|
// /backups restore panel's snapshot dropdown (F1: this route was fetched by the template but never
|
|
// existed, so the dropdown could never populate and the restore button never enabled).
|
|
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
|
stack := req.URL.Query().Get("stack")
|
|
if !validStackParam(stack) {
|
|
r.dbg("backupSnapshots: invalid stack param %q", stack)
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
|
|
points, found := r.backupMgr.ListRestorePoints(stack)
|
|
if !found {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + stack})
|
|
return
|
|
}
|
|
r.dbg("backupSnapshots: stack=%s points=%d", stack, len(points))
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: points})
|
|
}
|
|
|
|
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
|
|
// moved to the host agent (slice 8C).
|
|
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
|
|
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
|
|
if r.backupMgr.IsRunning() {
|
|
r.dbg("triggerBackup: backup already running, rejecting")
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
|
return
|
|
}
|
|
|
|
r.logger.Println("[INFO] [api] Manual app-data backup (DB dump) triggered")
|
|
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
|
|
go r.backupMgr.RunDBDumps(context.Background())
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
|
|
}
|
|
|
|
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
|
|
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
|
|
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
if r.backupMgr.IsRunning() {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
|
return
|
|
}
|
|
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
|
|
go r.backupMgr.RunAllTier2()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
|
|
}
|
|
|
|
// --- Metrics handlers ---
|
|
|
|
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 200)
|
|
|
|
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
// Flatten into arrays for Chart.js
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
temp := make([]float64, len(samples))
|
|
load1 := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
|
|
temp[i] = s.TempCelsius
|
|
load1[i] = s.LoadAvg1
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
"temp": temp,
|
|
"load1": load1,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
|
return
|
|
}
|
|
|
|
summary, err := r.metricsStore.QueryContainerSummary()
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
|
|
}
|
|
|
|
func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 150)
|
|
|
|
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
|
|
if err != nil {
|
|
r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = s.MemUsageMB
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
|
|
info := metrics.GetStaticInfo()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
|
|
}
|
|
|
|
// parseTimeRange reads range or from/to query params.
|
|
func parseTimeRange(req *http.Request) (from, to time.Time) {
|
|
to = time.Now()
|
|
|
|
if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
|
|
switch rangeStr {
|
|
case "1h":
|
|
from = to.Add(-1 * time.Hour)
|
|
case "6h":
|
|
from = to.Add(-6 * time.Hour)
|
|
case "24h":
|
|
from = to.Add(-24 * time.Hour)
|
|
case "7d":
|
|
from = to.Add(-7 * 24 * time.Hour)
|
|
case "30d":
|
|
from = to.Add(-30 * 24 * time.Hour)
|
|
default:
|
|
from = to.Add(-24 * time.Hour) // default 24h
|
|
}
|
|
return
|
|
}
|
|
|
|
if fromStr := req.URL.Query().Get("from"); fromStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if toStr := req.URL.Query().Get("to"); toStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
|
|
to = t
|
|
}
|
|
}
|
|
if from.IsZero() {
|
|
from = to.Add(-24 * time.Hour)
|
|
}
|
|
return
|
|
}
|
|
|
|
// parseResolution reads the resolution query param.
|
|
func parseResolution(req *http.Request, defaultVal int) int {
|
|
if v := req.URL.Query().Get("resolution"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
|
|
|
|
func hasSubpath(path, prefix string) bool {
|
|
rest := strings.TrimPrefix(path, prefix)
|
|
return strings.Contains(rest, "/")
|
|
}
|
|
|
|
func trimSegment(path, prefix string) string {
|
|
return strings.TrimPrefix(path, prefix)
|
|
}
|
|
|
|
func extractName(path, suffix string) string {
|
|
s := strings.TrimPrefix(path, "/stacks/")
|
|
name := strings.TrimSuffix(s, suffix)
|
|
// C7: Reject path traversal characters — name is used in file paths and Docker commands.
|
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
|
|
}
|
|
|
|
func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
result := r.updater.CheckForUpdate()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
|
}
|
|
|
|
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
if err := r.updater.TriggerUpdate("manual"); err != nil {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
r.logger.Println("[INFO] [api] Manual self-update triggered")
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
|
|
}
|
|
|
|
// --- Config apply handler ---
|
|
|
|
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
|
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
|
|
// Read YAML body (limit to 1MB)
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
|
return
|
|
}
|
|
|
|
// Validate it's parseable YAML by attempting to load it
|
|
if _, err := config.LoadFromBytes(body); err != nil {
|
|
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
|
return
|
|
}
|
|
|
|
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
|
|
// nothing — don't rewrite, don't restart. The hub may re-push the same config
|
|
// idempotently, and a self-restart on every push would be a needless flap.
|
|
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
|
|
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.unchanged")})
|
|
return
|
|
}
|
|
|
|
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
|
|
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
|
|
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
|
|
if err := writeConfig0600(r.configPath, body); err != nil {
|
|
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
|
return
|
|
}
|
|
|
|
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
|
|
// effect on restart — singletons such as the Cloudflare client are built once at
|
|
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
|
|
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.applied")})
|
|
flushResponse(w)
|
|
if r.restart != nil {
|
|
r.restart()
|
|
}
|
|
}
|
|
|
|
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
|
|
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
|
|
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
|
|
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
|
|
func writeConfig0600(path string, body []byte) error {
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
|
|
return fmt.Errorf("writing temp config: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
|
|
return fmt.Errorf("writing config: %w", err)
|
|
}
|
|
}
|
|
if err := os.Chmod(path, 0600); err != nil {
|
|
return fmt.Errorf("chmod config 0600: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
|
hash, err := config.FileHash(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
|
}
|
|
|
|
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
|
data, err := os.ReadFile(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
|
w.Write(data)
|
|
}
|
|
|
|
// --- Asset sync handlers ---
|
|
|
|
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
|
|
return
|
|
}
|
|
r.logger.Println("[INFO] [api] Manual asset sync requested")
|
|
go func() {
|
|
if err := r.assetsSyncer.Sync(context.Background()); err != nil {
|
|
r.logger.Printf("[WARN] [api] Manual asset sync failed: %v", err)
|
|
}
|
|
}()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
|
|
}
|
|
|
|
func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("[ERROR] [api] Failed to write JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// limitBody wraps the request body with a size limit (default 1MB).
|
|
func limitBody(w http.ResponseWriter, req *http.Request) {
|
|
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
|
|
}
|