package deployer import ( "log/slog" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // deployHistoryKeepPerWorkload bounds the ledger per workload. Newer rows // always have larger ids, so pruning keeps the most recent N — enough for a // useful rollback menu without unbounded growth on hot workloads. const deployHistoryKeepPerWorkload = 50 // recordDeployHistory appends one ledger row for a completed dispatch. // // Best-effort: a store failure is logged and swallowed — recording must // never turn a successful deploy into a failed request (same contract as // EmitDeployEvent and the pre-deploy backup). The raw deploy error is NEVER // persisted: it can carry registry-auth bytes or compose stdout, so only a // fixed, secret-free marker lands in the row (raw detail goes to slog at the // call site). Called only from DispatchPlugin — reconcile/teardown ticks are // not deploys and must not appear in the ledger. func (d *Deployer) recordDeployHistory(w plugin.Workload, intent plugin.DeploymentIntent, outcome string, deployErr error, startedAt string) { if d.store == nil { return } entry := store.DeployHistoryEntry{ WorkloadID: w.ID, SourceKind: w.SourceKind, Reference: d.effectiveReference(w, intent), Reason: intent.Reason, TriggeredBy: intent.TriggeredBy, Note: intent.Metadata["note"], // nil map read is safe Outcome: outcome, StartedAt: startedAt, FinishedAt: store.Now(), } if deployErr != nil { entry.Error = "deploy failed (see server logs)" } if _, err := d.store.InsertDeployHistory(entry); err != nil { slog.Warn("deploy history: insert failed", "workload", w.ID, "error", err) return } // Cheap indexed DELETE — negligible next to a multi-second deploy, so it // stays inline rather than on an untracked goroutine that could outrace // graceful shutdown's db.Close(). if err := d.store.PruneDeployHistory(w.ID, deployHistoryKeepPerWorkload); err != nil { slog.Warn("deploy history: prune failed", "workload", w.ID, "error", err) } } // effectiveReference resolves the artifact handle to record (and, for // rollback-capable sources, to replay). It starts from the trigger-supplied // intent.Reference and, for the image source, prefers the tag actually // written onto the freshest container row — capturing the DefaultTag / // "latest" resolution the source performs when intent.Reference is empty // (e.g. a manual deploy with no override). ListContainersByWorkload returns // newest-first, so rows[0] is the just-deployed container on success. // // For static/dockerfile the git trigger already supplies the commit SHA as // intent.Reference; a manual deploy of those may record an empty reference // (acceptable — they are not rollback-capable in this phase). compose has no // single artifact handle. func (d *Deployer) effectiveReference(w plugin.Workload, intent plugin.DeploymentIntent) string { ref := intent.Reference if w.SourceKind == "image" && d.store != nil { if rows, err := d.store.ListContainersByWorkload(w.ID); err == nil && len(rows) > 0 { if tag := rows[0].ImageTag; tag != "" { ref = tag } } } return ref }