B2b: decommission orchestration + missing-storage indicator + re-enroll fix (v0.65.0)
agentapi.Decommission + handleStorageDecommission (migrate-all-or-none, Change 2): migrate-then-decommission via the migration done-hook, or decommission-anyway (stop apps, keep HDD_PATH). 'Hiányzó tárhely' badge on dashboard/stacks/app card when an app's drive is decommissioned/disconnected/absent. Change 4: registerStoragePath clears the decommissioned marker on re-enroll (ClearDecommissioned had no callers). Non-hollow tests incl. mutation-proven Change-4 companion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,8 +23,8 @@ type ContainerState string
|
||||
|
||||
const (
|
||||
StateRunning ContainerState = "running"
|
||||
StateStarting ContainerState = "starting" // running but health: starting
|
||||
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
||||
StateStarting ContainerState = "starting" // running but health: starting
|
||||
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
|
||||
StateStopped ContainerState = "stopped"
|
||||
StateRestarting ContainerState = "restarting"
|
||||
StateExited ContainerState = "exited"
|
||||
@@ -52,12 +52,12 @@ type HealthProbeResult struct {
|
||||
|
||||
// HealthCheckDetail holds the result of a single health check item.
|
||||
type HealthCheckDetail struct {
|
||||
Type string `json:"type"` // "http", "api", "tcp"
|
||||
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
||||
Type string `json:"type"` // "http", "api", "tcp"
|
||||
Target string `json:"target"` // e.g. ":3456/api/v1/info"
|
||||
Healthy bool `json:"healthy"`
|
||||
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
||||
Latency string `json:"latency"` // e.g. "45ms"
|
||||
Error string `json:"error,omitempty"` // error message if unhealthy
|
||||
Status int `json:"status,omitempty"` // HTTP status code (for http/api)
|
||||
Latency string `json:"latency"` // e.g. "45ms"
|
||||
Error string `json:"error,omitempty"` // error message if unhealthy
|
||||
}
|
||||
|
||||
// Stack represents a docker compose stack on disk.
|
||||
@@ -66,14 +66,14 @@ type Stack struct {
|
||||
Meta Metadata `json:"meta"`
|
||||
ComposePath string `json:"compose_path"`
|
||||
State ContainerState `json:"state"`
|
||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
||||
Protected bool `json:"protected"`
|
||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||
Containers []ContainerInfo `json:"containers"`
|
||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
||||
Deploying bool `json:"deploying"` // compose up in progress
|
||||
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
Deploying bool `json:"deploying"` // compose up in progress
|
||||
DeployError string `json:"deploy_error,omitempty"` // last async deploy error
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ type Manager struct {
|
||||
migJob *MigrationJob
|
||||
settings *settings.Settings
|
||||
sysDataPath string
|
||||
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3)
|
||||
migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller)
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
}
|
||||
|
||||
// NewManager creates a new stack manager.
|
||||
@@ -1105,4 +1106,4 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
|
||||
m.logger.Printf("[DEBUG] [stacks] getCatalogTemplateSlugs: found %d template slugs in %s", len(slugs), cacheDir)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,11 @@ type MigrationJob struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||
|
||||
// DecommissionOnDone: when set, the migration was started by the decommission flow — on successful
|
||||
// completion the done-hook soft-marks the source registry path + tells the agent to decommission it.
|
||||
// The engine itself does NOT decommission; it only fires the hook (the policy lives in the caller).
|
||||
DecommissionOnDone bool `json:"decommission_on_done,omitempty"`
|
||||
}
|
||||
|
||||
func (j *MigrationJob) clone() *MigrationJob {
|
||||
@@ -185,17 +190,27 @@ func (m *Manager) MigrationStatus() *MigrationJob {
|
||||
return m.migJob.clone()
|
||||
}
|
||||
|
||||
// SetMigrationDoneHook registers a callback fired (in the migration goroutine) when a migration
|
||||
// completes successfully. The decommission flow uses it to soft-mark + agent-decommission the source.
|
||||
func (m *Manager) SetMigrationDoneHook(fn func(*MigrationJob)) { m.migDoneHook = fn }
|
||||
|
||||
// MigrateAll moves the whole namespace off sourcePath onto targetPath.
|
||||
func (m *Manager) MigrateAll(ctx context.Context, sourcePath, targetPath string) (string, error) {
|
||||
return m.startMigration("all", sourcePath, "", targetPath)
|
||||
return m.startMigration("all", sourcePath, "", targetPath, false)
|
||||
}
|
||||
|
||||
// MigrateAllAndDecommission moves the whole namespace then (on success) fires the done-hook so the
|
||||
// caller decommissions the now-empty source drive.
|
||||
func (m *Manager) MigrateAllAndDecommission(ctx context.Context, sourcePath, targetPath string) (string, error) {
|
||||
return m.startMigration("all", sourcePath, "", targetPath, true)
|
||||
}
|
||||
|
||||
// MigrateApp moves a single app's data subtree onto targetPath.
|
||||
func (m *Manager) MigrateApp(ctx context.Context, appName, targetPath string) (string, error) {
|
||||
return m.startMigration("app", "", appName, targetPath)
|
||||
return m.startMigration("app", "", appName, targetPath, false)
|
||||
}
|
||||
|
||||
func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string) (string, error) {
|
||||
func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string, decommission bool) (string, error) {
|
||||
if err := m.acquireMigrating(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -207,10 +222,11 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string)
|
||||
}()
|
||||
|
||||
j := &MigrationJob{
|
||||
Scope: scope,
|
||||
Target: filepath.Clean(targetPath),
|
||||
Units: map[string]*MigUnit{},
|
||||
StartedAt: time.Now().UTC(),
|
||||
Scope: scope,
|
||||
Target: filepath.Clean(targetPath),
|
||||
Units: map[string]*MigUnit{},
|
||||
StartedAt: time.Now().UTC(),
|
||||
DecommissionOnDone: decommission,
|
||||
}
|
||||
j.ID = "mig-" + j.StartedAt.Format("20060102-150405")
|
||||
|
||||
@@ -389,6 +405,9 @@ func (m *Manager) runMigration(ctx context.Context, j *MigrationJob) {
|
||||
}
|
||||
if j.Phase == PhaseDone {
|
||||
m.logger.Printf("[INFO] [migrate] %s complete: %s → %s (%d app(s))", j.ID, j.Source, j.Target, len(j.Apps))
|
||||
if m.migDoneHook != nil {
|
||||
m.migDoneHook(j.clone()) // decommission policy (soft-mark + agent) lives in the hook
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user