410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
157 lines
5.4 KiB
Go
157 lines
5.4 KiB
Go
// Package deployer dispatches plugin-native Source deploys. The legacy
|
|
// project-pipeline lived here until the hard cutover; what remains is a
|
|
// thin holder for the Deployer's shared dependencies that `dispatch.go`
|
|
// hands to every Source via PluginDeps().
|
|
package deployer
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/alexei/tinyforge/internal/dns"
|
|
"github.com/alexei/tinyforge/internal/docker"
|
|
"github.com/alexei/tinyforge/internal/events"
|
|
"github.com/alexei/tinyforge/internal/health"
|
|
"github.com/alexei/tinyforge/internal/notify"
|
|
"github.com/alexei/tinyforge/internal/proxy"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// Deployer owns the dependency bundle each Source plugin needs at deploy
|
|
// time. The plugin pipeline reaches in via PluginDeps(); see dispatch.go
|
|
// for the dispatch surface itself.
|
|
type Deployer struct {
|
|
docker *docker.Client
|
|
proxy proxy.Provider
|
|
store *store.Store
|
|
health *health.Checker
|
|
notifier *notify.Notifier
|
|
eventBus EventPublisher
|
|
backuper PreDeployBackuper // optional; nil disables pre-deploy backups
|
|
encKey [32]byte
|
|
dnsMu sync.RWMutex
|
|
dns dns.Provider // nil when wildcard DNS is active
|
|
|
|
// proxyMu protects hot-swap of d.proxy from runtime settings updates
|
|
// (SetProxyProvider) racing with PluginDeps() reads on the deploy path.
|
|
proxyMu sync.RWMutex
|
|
|
|
// Graceful shutdown: tracks in-progress deploys.
|
|
//
|
|
// drainMu serializes the "is-draining check + activeWg.Add(1)" in
|
|
// beginDispatch against the "set shuttingDown + Wait()" in Drain. Without
|
|
// it, a dispatch could pass the draining check, Drain could then flip the
|
|
// flag and start Wait() with a zero counter, and the dispatch could call
|
|
// Add(1) concurrently with Wait — a documented sync.WaitGroup misuse
|
|
// (panic risk) that also lets a deploy slip past the drain barrier.
|
|
drainMu sync.Mutex
|
|
activeWg sync.WaitGroup
|
|
shuttingDown atomic.Bool
|
|
}
|
|
|
|
// EventPublisher is the interface for publishing events to the event bus.
|
|
type EventPublisher interface {
|
|
Publish(evt events.Event)
|
|
}
|
|
|
|
// PreDeployBackuper takes a "pre-deploy" Tinyforge DB snapshot before any
|
|
// deploy starts when the corresponding setting is enabled. Kept as a small
|
|
// interface so the deployer does not import internal/backup.
|
|
type PreDeployBackuper interface {
|
|
CreateBackup(backupType string) (store.Backup, error)
|
|
}
|
|
|
|
// New creates a new Deployer with all required dependencies.
|
|
func New(
|
|
dockerClient *docker.Client,
|
|
proxyProvider proxy.Provider,
|
|
st *store.Store,
|
|
checker *health.Checker,
|
|
notifier *notify.Notifier,
|
|
eventBus EventPublisher,
|
|
encKey [32]byte,
|
|
) *Deployer {
|
|
return &Deployer{
|
|
docker: dockerClient,
|
|
proxy: proxyProvider,
|
|
store: st,
|
|
health: checker,
|
|
notifier: notifier,
|
|
eventBus: eventBus,
|
|
encKey: encKey,
|
|
}
|
|
}
|
|
|
|
// SetProxyProvider updates the proxy provider at runtime (e.g., when settings change).
|
|
// Guarded by proxyMu so concurrent deploys that read d.proxy via PluginDeps()
|
|
// observe a coherent value (previously a torn-pointer race under -race).
|
|
func (d *Deployer) SetProxyProvider(provider proxy.Provider) {
|
|
d.proxyMu.Lock()
|
|
defer d.proxyMu.Unlock()
|
|
d.proxy = provider
|
|
}
|
|
|
|
// SetPreDeployBackuper wires the backup engine in after construction so the
|
|
// deployer can take a Tinyforge DB snapshot when the
|
|
// auto_backup_before_deploy setting is enabled. Pass nil to disable.
|
|
func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) {
|
|
d.backuper = b
|
|
}
|
|
|
|
// MaybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
|
|
// the setting is enabled. Failures are logged but do not abort the deploy:
|
|
// missing a backup is preferable to refusing to ship a fix. Exposed so
|
|
// Source plugins can opt into the same behaviour.
|
|
func (d *Deployer) MaybeBackupBeforeDeploy(deployID string, settings store.Settings) {
|
|
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
|
|
return
|
|
}
|
|
backup, err := d.backuper.CreateBackup("pre-deploy")
|
|
if err != nil {
|
|
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
|
|
return
|
|
}
|
|
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
|
|
}
|
|
|
|
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
|
// Pass nil to disable DNS management (wildcard DNS mode).
|
|
func (d *Deployer) SetDNSProvider(provider dns.Provider) {
|
|
d.dnsMu.Lock()
|
|
defer d.dnsMu.Unlock()
|
|
d.dns = provider
|
|
}
|
|
|
|
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
|
func (d *Deployer) Drain() {
|
|
d.drainMu.Lock()
|
|
already := d.shuttingDown.Swap(true)
|
|
d.drainMu.Unlock()
|
|
if already {
|
|
slog.Info("deployer: drain already in progress")
|
|
}
|
|
slog.Info("deployer: draining in-progress deploys")
|
|
d.activeWg.Wait()
|
|
slog.Info("deployer: all deploys drained")
|
|
}
|
|
|
|
// ShuttingDown reports whether Drain() has been called.
|
|
func (d *Deployer) ShuttingDown() bool { return d.shuttingDown.Load() }
|
|
|
|
// beginDispatch atomically rejects when draining and otherwise registers the
|
|
// in-flight unit on activeWg. The shuttingDown check and the Add(1) MUST be
|
|
// done together under drainMu (see the field comment): Drain sets the flag
|
|
// under the same mutex before Wait(), so once Wait() observes a zero counter
|
|
// no further Add can race it. Callers must defer d.activeWg.Done() on success.
|
|
func (d *Deployer) beginDispatch() error {
|
|
d.drainMu.Lock()
|
|
defer d.drainMu.Unlock()
|
|
if d.shuttingDown.Load() {
|
|
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
|
}
|
|
d.activeWg.Add(1)
|
|
return nil
|
|
}
|