0054d4bd69
gates / gates (push) Successful in 27s
R-634: a whole-box backup no longer stops/restarts a DEPLOYING app (the measured cause of containers running under 'not deployed'); StopStack and StartStack refuse a deploying stack for every caller. R-625: held badge 'Stopped - restore needed', no Update button. R-636: kernel oom_kill counter; 20+ in 30 min -> one app_oom_storm. R-647: held error per reader, copy_holds key, two log wordings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
1539 lines
61 KiB
Go
1539 lines
61 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, req *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
|
|
}
|
|
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
|
|
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
|
|
// COPY, so nothing here touches the manager's own state.
|
|
// v0.265.0 (R-647): EVERY reader, Hungarian included — a Hungarian reader of an English box read
|
|
// the hold in English before. On a Hungarian box a Hungarian reader still gets the same bytes.
|
|
lang := r.langFor(req)
|
|
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
|
|
stack.UpdateError = r.stackMgr.UpdateErrorFor(*stack, lang)
|
|
stack.HoldReason = r.stackMgr.HoldReasonFor(*stack, lang)
|
|
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 {
|
|
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
|
|
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
|
|
// tell it from a passing backup window: it would press a terminally-refused button for
|
|
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
|
|
// person acts, and it is the one reason that matters most to get right.
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
|
|
Data: map[string]string{"reason": "held"}})
|
|
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
|
|
}
|
|
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
|
|
// bundle key, so the household reads the refusal in its own language. A refusal still
|
|
// built from a Hungarian literal has no key and errText returns ref.Message byte for
|
|
// byte — which is why this line is safe to change for all of them at once, and why the
|
|
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
|
|
// wired" class).
|
|
//
|
|
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
|
|
// sentence says WHAT happened; only the reason says whether trying again can ever work —
|
|
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
|
|
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
|
|
// those apart either gives up on a passing backup window or presses a refused button for
|
|
// ever. The sentence is unchanged, so no page moves.
|
|
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
|
|
Data: map[string]string{"reason": ref.Reason}})
|
|
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
|
|
}
|
|
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
|
|
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
|
|
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
|
|
// the state the containers are in right now, so that is what it says; whether the APP works is
|
|
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
|
|
if action == "start" || action == "restart" {
|
|
state := ""
|
|
_ = r.stackMgr.RefreshStatus()
|
|
if st, ok := r.stackMgr.GetStack(name); ok {
|
|
state = string(st.State)
|
|
}
|
|
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
|
|
} else {
|
|
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
|
|
}
|
|
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
|
|
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
|
|
// the busy sentence contains none of the words it looks for, so the first live run of this
|
|
// guard answered 500 while doing exactly the right thing.
|
|
var busy *stacks.RemoveBusyError
|
|
if errors.As(err, &busy) {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
|
return
|
|
}
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
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
|
|
}
|
|
|
|
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
|
|
// containers are in right now — never "completed", which nothing at this point can know.
|
|
func startAnswer(name, action, state string) apiResponse {
|
|
return apiResponse{OK: true,
|
|
Message: "Stack " + name + " " + action + " requested — state now: " + state,
|
|
Data: map[string]interface{}{"state": state}}
|
|
}
|