feat(apps): per-app deploy/activity timeline
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).
Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.
Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.
Reviewed: go + typescript APPROVE; security HIGH fixed.
This commit is contained in:
@@ -118,7 +118,7 @@ func (*source) Validate(cfg json.RawMessage) error {
|
||||
//
|
||||
// Any failure between create and face-registration rolls back the new
|
||||
// container + its row; old serving state is preserved.
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (err error) {
|
||||
cfg, err := plugin.SourceConfigOf[Config](w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: decode config: %w", err)
|
||||
@@ -162,6 +162,19 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload,
|
||||
}
|
||||
}
|
||||
|
||||
// Past the idempotency short-circuit: this is a real deploy. Emit a
|
||||
// terminal audit event for the per-app timeline. Armed here (not at the
|
||||
// top) so duplicate-webhook no-ops above don't flood the log, and
|
||||
// pre-flight config/settings errors above stay quiet. err is the named
|
||||
// return, so the deferred closure observes the final outcome.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
plugin.EmitDeployEvent(deps, w, "image", "failed: "+err.Error())
|
||||
} else {
|
||||
plugin.EmitDeployEvent(deps, w, "image", "deployed")
|
||||
}
|
||||
}()
|
||||
|
||||
authConfig, err := buildRegistryAuth(deps, cfg.RegistryName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: %w", err)
|
||||
@@ -486,37 +499,22 @@ type containerExtra struct {
|
||||
ProxyRoutes map[string]string `json:"proxy_routes,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile syncs the containers index for this workload with reality.
|
||||
// MVP: just refreshes State from Docker. Future versions can re-deploy
|
||||
// when the running container disagrees with the desired source config.
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
rows, err := deps.Store.ListContainersByWorkload(w.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image source: list containers: %w", err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID)
|
||||
if err != nil {
|
||||
// Most likely "no such container" — mark as missing so the UI
|
||||
// surfaces it and the next deploy recreates.
|
||||
if err := deps.Store.UpdateContainerState(c.ID, "missing"); err != nil {
|
||||
slog.Warn("image source: mark missing", "id", c.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
desired := "running"
|
||||
if !running {
|
||||
desired = "stopped"
|
||||
}
|
||||
if c.State != desired {
|
||||
if err := deps.Store.UpdateContainerState(c.ID, desired); err != nil {
|
||||
slog.Warn("image source: state sync", "id", c.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconcile is intentionally a no-op for the image source.
|
||||
//
|
||||
// State sync is fully handled by the generic reconciler pass that runs
|
||||
// EARLIER in the same Reconciler.ReconcileOnce: its upsert loop writes each
|
||||
// present container's State from the single `docker ps -a` snapshot
|
||||
// (ListAllForReconciler), and its markMissing pass flips rows whose container
|
||||
// ID is absent from that snapshot to 'missing'. Every image container carries
|
||||
// the tinyforge.workload.id label (ContainerConfig.WorkloadID at create time),
|
||||
// so the generic pass covers all of them.
|
||||
//
|
||||
// The previous implementation looped this workload's container rows and called
|
||||
// Docker.IsContainerRunning per row — a redundant Docker inspect per container
|
||||
// per tick that duplicated work already done from the snapshot and scaled as N
|
||||
// Docker API calls/tick. Returning nil here drops that cost without changing
|
||||
// observable state. The method stays because the source interface requires it.
|
||||
func (*source) Reconcile(context.Context, plugin.Deps, plugin.Workload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user