retention.audit_days and retention.revisions_keep (DDI_RETENTION_*) drive a cleanup five minutes after the start and then daily. The latest revision, revisions agents run, fall back to or target, and revisions with pending deployments are never deleted; deployment rows stay. A cleanup that deletes anything records retention.cleanup in the audit log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
9.8 KiB
Go
299 lines
9.8 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
// Package app wires the management server together: configuration,
|
|
// database, secrets, licensing, services, HTTP API and background jobs.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
|
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agentapi"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/config"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/events"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/httpapi"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/license"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
"git.beugsiud.de/beug.k/DDI/web"
|
|
)
|
|
|
|
// Persistence adapts the store to license.Persistence.
|
|
type Persistence struct{ Store *store.Store }
|
|
|
|
// GetLicenseState implements license.Persistence.
|
|
func (p Persistence) GetLicenseState(ctx context.Context) ([]byte, error) {
|
|
b, err := p.Store.Q().GetLicenseState(ctx)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, license.ErrNoState
|
|
}
|
|
return b, err
|
|
}
|
|
|
|
// PutLicenseState implements license.Persistence.
|
|
func (p Persistence) PutLicenseState(ctx context.Context, d []byte) error {
|
|
return p.Store.Q().PutLicenseState(ctx, d)
|
|
}
|
|
|
|
// DeleteLicenseState implements license.Persistence.
|
|
func (p Persistence) DeleteLicenseState(ctx context.Context) error {
|
|
return p.Store.Q().DeleteLicenseState(ctx)
|
|
}
|
|
|
|
// EnsureInstalledAt implements license.Persistence.
|
|
func (p Persistence) EnsureInstalledAt(ctx context.Context, now time.Time) (time.Time, error) {
|
|
return p.Store.Q().EnsureInstalledAt(ctx, now)
|
|
}
|
|
|
|
// Core holds the components shared by the server and the CLI commands.
|
|
type Core struct {
|
|
Config config.Config
|
|
Store *store.Store
|
|
Keyring *secrets.Keyring
|
|
License *license.Manager
|
|
Service *service.Service
|
|
PKI *pki.Authority
|
|
Hub *agenthub.Hub
|
|
Events *events.Bus
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Close releases resources.
|
|
func (c *Core) Close() {
|
|
if c.Store != nil {
|
|
c.Store.Close()
|
|
}
|
|
}
|
|
|
|
// Open connects to the database, optionally migrates it and builds the
|
|
// service layer.
|
|
func Open(ctx context.Context, cfg config.Config, log *slog.Logger, migrate bool) (*Core, error) {
|
|
kr, err := secrets.LoadKeyFile(cfg.MasterKeyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("master key: %w", err)
|
|
}
|
|
st, err := store.Open(ctx, cfg.Database.URL, cfg.Database.MaxConns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
core := &Core{Config: cfg, Store: st, Keyring: kr, Log: log}
|
|
if migrate {
|
|
if err := st.Migrate(ctx, log); err != nil {
|
|
st.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := core.initLicense(ctx); err != nil {
|
|
st.Close()
|
|
return nil, err
|
|
}
|
|
core.PKI, err = pki.LoadOrCreate(ctx, st, kr, log.With(slog.String("component", "pki")), nil)
|
|
if err != nil {
|
|
st.Close()
|
|
return nil, fmt.Errorf("agent PKI: %w", err)
|
|
}
|
|
core.Hub = agenthub.New()
|
|
core.Events = events.New()
|
|
core.Service = service.New(service.Options{
|
|
Store: st, Keyring: kr, License: core.License, ProductName: brand.DisplayName,
|
|
SessionLifetime: cfg.Session.Lifetime.Std(), SessionIdle: cfg.Session.IdleTimeout.Std(), Logger: log,
|
|
PKI: core.PKI, Hub: core.Hub, AgentAPIAddress: cfg.AgentAPI.PublicAddress, AgentServerNames: cfg.AgentAPI.ServerNames,
|
|
Events: core.Events,
|
|
})
|
|
return core, nil
|
|
}
|
|
|
|
func (c *Core) initLicense(ctx context.Context) error {
|
|
trust, err := license.ResolveTrust(c.Config.License.AccountID, c.Config.License.PublicKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
provider, err := license.NewKeygenProvider(license.KeygenConfig{
|
|
BaseURL: c.Config.License.KeygenURL, AccountID: trust.AccountID, ProductID: c.Config.License.ProductID,
|
|
PublicKey: trust.PublicKey,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fp, err := license.Fingerprint(brand.Product, c.Config.StateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("machine fingerprint: %w", err)
|
|
}
|
|
host, _ := os.Hostname()
|
|
c.License = license.NewManager(license.ManagerOptions{
|
|
Provider: provider, Persistence: Persistence{Store: c.Store}, Keyring: c.Keyring, PublicKey: trust.PublicKey,
|
|
ProductID: c.Config.License.ProductID, Fingerprint: fp, Hostname: host,
|
|
Grace: c.Config.License.GracePeriod.Std(), Revalidate: c.Config.License.RevalidateInterval.Std(),
|
|
Logger: c.Log.With(slog.String("component", "license")),
|
|
})
|
|
if err := c.License.Load(ctx); err != nil {
|
|
return err
|
|
}
|
|
if !trust.Embedded {
|
|
c.Log.Warn("license trust anchor comes from configuration (development build)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Serve runs the HTTP server and background jobs until ctx is canceled.
|
|
func Serve(ctx context.Context, core *Core) error {
|
|
cfg, log := core.Config, core.Log
|
|
secure := cfg.TLSEnabled()
|
|
if u, err := url.Parse(cfg.HTTP.PublicURL); err == nil && u.Scheme == "https" {
|
|
secure = true
|
|
}
|
|
info := buildinfo.Get()
|
|
metrics := newMetrics(core.License, core.Hub)
|
|
api := httpapi.New(httpapi.Options{
|
|
Service: core.Service, PublicURL: cfg.HTTP.PublicURL, SecureCookies: secure,
|
|
ProductName: brand.DisplayName, Version: info.Version, Logger: log,
|
|
})
|
|
srv := httpserver.New(httpserver.Options{
|
|
Addr: cfg.HTTP.Listen, TLS: cfg.TLSEnabled(), Logger: log, UI: web.Handler(), API: api.Handler(),
|
|
Metrics: metrics.handler(), Middleware: metrics.middleware,
|
|
Readiness: map[string]httpserver.ReadinessCheck{"database": core.Store.Ping},
|
|
})
|
|
|
|
agentLog := log.With(slog.String("component", "agentapi"))
|
|
agentSrv, err := agentapi.New(agentapi.Options{Service: core.Service, PKI: core.PKI, Hub: core.Hub,
|
|
ServerNames: cfg.AgentAPI.ServerNames, Logger: agentLog})
|
|
if err != nil {
|
|
return fmt.Errorf("agent API: %w", err)
|
|
}
|
|
agentLis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.AgentAPI.Listen)
|
|
if err != nil {
|
|
return fmt.Errorf("agent API listener: %w", err)
|
|
}
|
|
|
|
jobs, stopJobs := context.WithCancel(ctx)
|
|
defer stopJobs()
|
|
go core.License.Run(jobs)
|
|
go cleanup(jobs, core)
|
|
go retention(jobs, core, retentionStartDelay, retentionInterval)
|
|
|
|
log.Info("starting server", slog.String("version", info.Version), slog.String("commit", info.Commit),
|
|
slog.String("listen", cfg.HTTP.Listen), slog.Bool("tls", cfg.TLSEnabled()),
|
|
slog.String("agent_api_listen", cfg.AgentAPI.Listen), slog.String("agent_api_address", cfg.AgentAPI.PublicAddress),
|
|
slog.String("agent_ca_fingerprint", core.PKI.Fingerprint()),
|
|
slog.String("license_state", string(core.License.Status().State)))
|
|
if !cfg.TLSEnabled() {
|
|
log.Warn("TLS is disabled; use only for local development or behind a TLS-terminating proxy")
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
if cfg.TLSEnabled() {
|
|
errCh <- srv.ListenAndServeTLS(cfg.HTTP.TLSCertFile, cfg.HTTP.TLSKeyFile)
|
|
} else {
|
|
errCh <- srv.ListenAndServe()
|
|
}
|
|
}()
|
|
agentErrCh := make(chan error, 1)
|
|
go func() { agentErrCh <- agentSrv.Serve(agentLis) }()
|
|
|
|
var runErr error
|
|
select {
|
|
case err := <-errCh:
|
|
runErr = fmt.Errorf("http listener: %w", err)
|
|
errCh <- nil // already received; keep the shutdown path uniform
|
|
case err := <-agentErrCh:
|
|
runErr = fmt.Errorf("agent API listener: %w", err)
|
|
agentErrCh <- nil
|
|
case <-ctx.Done():
|
|
}
|
|
log.Info("shutting down", slog.Duration("timeout", cfg.ShutdownTimeout.Std()))
|
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout.Std())
|
|
defer cancel()
|
|
// Event streams never become idle: end them so that the HTTP server
|
|
// can shut down gracefully.
|
|
core.Events.Close()
|
|
agentSrv.Shutdown(shutdownCtx)
|
|
if err := srv.Shutdown(shutdownCtx); err != nil && runErr == nil {
|
|
runErr = fmt.Errorf("graceful shutdown: %w", err)
|
|
}
|
|
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) && runErr == nil {
|
|
runErr = fmt.Errorf("http listener: %w", err)
|
|
}
|
|
if err := <-agentErrCh; err != nil && runErr == nil {
|
|
runErr = fmt.Errorf("agent API listener: %w", err)
|
|
}
|
|
if runErr != nil {
|
|
return runErr
|
|
}
|
|
log.Info("stopped")
|
|
return nil
|
|
}
|
|
|
|
// The retention cleanup runs shortly after the start (not during it, so
|
|
// that a restart loop does not hammer the database) and then daily.
|
|
const (
|
|
retentionStartDelay = 5 * time.Minute
|
|
retentionInterval = 24 * time.Hour
|
|
)
|
|
|
|
// retention applies the configured retention policy (ADR 0024).
|
|
func retention(ctx context.Context, core *Core, delay, interval time.Duration) {
|
|
policy := service.RetentionPolicy{AuditDays: core.Config.Retention.AuditDays,
|
|
RevisionsKeep: core.Config.Retention.RevisionsKeep}
|
|
if !policy.Enabled() {
|
|
return
|
|
}
|
|
log := core.Log.With(slog.String("component", "retention"))
|
|
t := time.NewTimer(delay)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
res, err := core.Service.ApplyRetention(ctx, policy)
|
|
switch {
|
|
case err != nil:
|
|
log.Warn("retention cleanup failed", slog.Any("error", err))
|
|
case res.AuditDeleted > 0 || len(res.RevisionsDeleted) > 0:
|
|
log.Info("retention cleanup", slog.Int64("audit_entries_deleted", res.AuditDeleted),
|
|
slog.Int("revisions_deleted", len(res.RevisionsDeleted)))
|
|
default:
|
|
log.Debug("retention cleanup: nothing to delete")
|
|
}
|
|
t.Reset(interval)
|
|
}
|
|
}
|
|
|
|
// cleanup removes expired sessions and old enrollment tokens periodically.
|
|
func cleanup(ctx context.Context, core *Core) {
|
|
t := time.NewTicker(15 * time.Minute)
|
|
defer t.Stop()
|
|
for {
|
|
if n, err := core.Service.CleanupSessions(ctx); err != nil {
|
|
core.Log.Warn("session cleanup failed", slog.Any("error", err))
|
|
} else if n > 0 {
|
|
core.Log.Debug("removed expired sessions", slog.Int64("count", n))
|
|
}
|
|
if n, err := core.Service.CleanupEnrollmentTokens(ctx); err != nil {
|
|
core.Log.Warn("enrollment token cleanup failed", slog.Any("error", err))
|
|
} else if n > 0 {
|
|
core.Log.Debug("removed old enrollment tokens", slog.Int64("count", n))
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}
|