Files
DDI/server/internal/app/app.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00

261 lines
8.6 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)
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
}
// 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:
}
}
}