fleet

package
v0.1.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package fleet is the MT-STRUCT-S1 orchestrator: ONE server serving N DISTINCT apps (different schemas → different APIs) as N engine processes — the Option-A architecture of docs/design/MT-STRUCT.md. Each app is today's engine, unmodified on its hot path; the fleet adds only a supervisor (spawn/health/restart-on-exit) and a Host-routing reverse proxy in front.

Taxonomy (MT-STRUCT §1): an APP is one schema compiled into one API surface; a TENANT is an isolated data instance INSIDE an app; the FLEET is the set of apps on one server. A request resolves app (Host → proxy) then tenant (subdomain → engine middleware), two independent axes.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AddAppToManifestFile

func AddAppToManifestFile(path string, spec *AppSpec) error

AddAppToManifestFile appends spec to the manifest file's apps array (atomically). The caller has already validated spec (ValidateNewApp).

func BootstrapControlPlane

func BootstrapControlPlane(ctx context.Context, dsn string) error

BootstrapControlPlane applies the canonical control-plane DDL (embedded from migrations/001_control_plane.sql — public.tenants, tenant_policies, migration_log, the schema_updated trigger) to one app's database.

Rationale: in the single-app deployment docker-compose's initdb applies this file; a fleet gives EACH app its own fresh database, so provisioning it is the orchestrator's job (both the multi-process supervisor and the in-process ServeFleet runtime call this). Idempotent (IF NOT EXISTS / OR REPLACE) — a no-op on an already-initialized database. The engine itself stays untouched.

func CreateDatabase

func CreateDatabase(ctx context.Context, adminDSN, dbName string) (created bool, err error)

CreateDatabase creates dbName on the server the ADMIN DSN points at, IF it does not already exist. Returns created=true only when it actually ran the CREATE (created=false + nil ⇒ the database already existed, reused, never overwritten). The name is validated AND Sanitize()'d — CREATE DATABASE cannot run inside a transaction, and this issues that one statement only. The caller audits the outcome.

func DBNameOf

func DBNameOf(dsn string) string

DBNameOf extracts the database name (URL path) from a DSN, or "" if unparseable.

func DeriveDSN

func DeriveDSN(baseDSN, dbName string) (string, error)

DeriveDSN returns baseDSN with its database name (the URL path) replaced by dbName — the app's runtime DSN, reusing the instance's host/credentials. The operator can still edit the result before submitting (e.g. a limited user).

func DropDatabase

func DropDatabase(ctx context.Context, adminDSN, dbName string) error

DropDatabase drops dbName — used ONLY to roll back a database this operation just created when the subsequent app-add fails (all-or-nothing). It is never exposed as an operator action: the fleet's vocabulary never destroys a pre-existing database (that is a deliberate, out-of-band act).

func EditAppInManifestFile

func EditAppInManifestFile(path string, spec *AppSpec) error

EditAppInManifestFile replaces the manifest entry named spec.Name with spec (atomically) — used after EditApp (FLEET-EDIT-S1) resolves a new merged env: the app's env now lives in a (possibly freshly written) env_file, so the entry's env_file pointer is updated and any old inline env is cleared. This is EDIT, not create: it errors if the manifest does not already declare the app (mirrors RemoveAppFromManifestFile's not-found handling; see AddAppToManifestFile for the create path).

func RemoveAppFromManifestFile

func RemoveAppFromManifestFile(path, name string) error

RemoveAppFromManifestFile removes the app named name from the manifest file (atomically). Returns an error if the manifest does not declare it.

func StatusHandler

func StatusHandler(s *Supervisor) http.Handler

StatusHandler serves the fleet's internal status/control API:

GET  /fleet/status                  → {apps: [AppStatus…]}
POST /fleet/apps/{name}/stop        → graceful stop (no auto-restart)
POST /fleet/apps/{name}/start       → start a stopped app
POST /fleet/apps/{name}/restart     → deliberate stop+start of ONE app

It binds StatusAddr (default loopback). Like the engine control plane it is an INTERNAL surface — never expose it publicly.

func SuggestDBName

func SuggestDBName(app string) string

SuggestDBName derives the conventional database name for an app: app_<name>, lowercasing and mapping '-' to '_' so a valid app name always yields a valid database name (mirrors deriveAppDSN / fleet-init).

func ValidDBName

func ValidDBName(name string) bool

ValidDBName reports whether name is a safe database name.

Types

type AppSpec

type AppSpec struct {
	// Name identifies the app (lowercase, ^[a-z][a-z0-9_-]*$, unique).
	Name string `json:"name"`
	// Schema is the path to the app's schema JSON (the engine's --schema).
	// Relative paths resolve against the manifest file's directory.
	Schema string `json:"schema"`
	// Domains are the public hostnames of this app. A request whose Host is a
	// domain OR any subdomain of it routes here — subdomains stay free for the
	// engine's tenant resolution (acme.crm.example.com → app crm, tenant acme).
	Domains []string `json:"domains"`
	// Port / ControlPort pin the app's internal data/control ports; 0 (the
	// default) auto-allocates a free port. Internal only — the proxy is the
	// public face.
	Port        int `json:"port,omitempty"`
	ControlPort int `json:"control_port,omitempty"`
	// EnvFile is an optional KEY=VALUE file (e.g. the app's secrets) loaded
	// first; Env entries override it. Relative to the manifest directory.
	EnvFile string `json:"env_file,omitempty"`
	// Env is the app's environment: DATABASE_URL, JWT_SECRET, ADMIN_KEY are
	// REQUIRED (the engine refuses to boot without them); anything else the
	// engine reads (APPXIMO_*, RATE_LIMIT_*, …) may be set per app.
	Env map[string]string `json:"env,omitempty"`
	// contains filtered or unexported fields
}

AppSpec declares one app of the fleet: its schema, the domains the proxy routes to it, and its OWN config/secrets (per-app by design — MT-STRUCT §7: a shared JWT_SECRET would let a token minted for app X validate on app Y).

func (*AppSpec) MergedEnv

func (a *AppSpec) MergedEnv() map[string]string

MergedEnv returns the app's resolved environment (EnvFile overlaid by Env).

func (*AppSpec) SetMergedEnv

func (a *AppSpec) SetMergedEnv(env map[string]string)

SetMergedEnv overwrites the resolved-env cache MergedEnv returns (FLEET-EDIT-S1): after EditApp writes a new env file for this app, the in-memory spec must reflect the change immediately — a concurrent JWT_SECRET-uniqueness check against a DIFFERENT app's edit, or the next read of THIS app, must see the new values without a manifest reload.

type AppStatus

type AppStatus struct {
	Name        string   `json:"name"`
	Domains     []string `json:"domains"`
	Schema      string   `json:"schema"`
	Port        int      `json:"port"`
	ControlPort int      `json:"control_port"`
	PID         int      `json:"pid"`
	Running     bool     `json:"running"`
	Healthy     bool     `json:"healthy"`
	Health      string   `json:"health"` // ready | draining_or_down | unreachable | stopped
	Restarts    int      `json:"restarts"`
	UptimeS     int64    `json:"uptime_s"`
	LastExit    string   `json:"last_exit,omitempty"`
	Log         string   `json:"log"`
}

AppStatus is one app's row in the fleet status API.

type DBInstance

type DBInstance struct {
	// Name identifies the instance (^[a-z][a-z0-9_-]*$, unique). Referenced by
	// the console when it asks the server to suggest/test/create — the DSN
	// itself never travels to the browser.
	Name string `json:"name"`
	// Label is the human-friendly text shown in the console's instance picker.
	Label string `json:"label,omitempty"`
	// AdminDSNEnv is the NAME of the env var holding the privileged DSN. The
	// secret is never a manifest key (committable-safe). Required and must be
	// set — a declared-but-unwired instance fails the load loudly.
	AdminDSNEnv string `json:"admin_dsn_env"`
	// contains filtered or unexported fields
}

DBInstance is one operator-declared Postgres server the console may create databases on (FLEET-DB-ASSIST). AdminDSNEnv names an env var holding a PRIVILEGED DSN (a role that may CREATE DATABASE, pointing at a maintenance database such as `postgres`); the console derives the app's runtime DSN from it by swapping the database name. Declaring an instance is the explicit, auditable grant of create-power on that server — the credentials stay in the env-file, never the committable manifest.

func (*DBInstance) AdminDSN

func (i *DBInstance) AdminDSN() string

AdminDSN returns the resolved privileged DSN (empty until LoadManifest ran).

type DBTestResult

type DBTestResult struct {
	OK            bool   `json:"ok"`            // connected AND the target database exists
	DBExists      bool   `json:"db_exists"`     // the DSN's database exists (false ⇒ needs creating)
	CanCreateDB   bool   `json:"can_create_db"` // the connected role may CREATE DATABASE
	ServerVersion string `json:"server_version,omitempty"`
	Code          string `json:"code,omitempty"`  // pg SQLSTATE (or "" for a network error)
	Error         string `json:"error,omitempty"` // actionable, client-safe message
}

DBTestResult is the structured verdict of a connection test — enough for the console to be actionable without leaking server internals.

func TestDSN

func TestDSN(ctx context.Context, dsn string) DBTestResult

TestDSN connects with dsn, reports a structured verdict, and CLOSES — it is a pure probe with zero administrative effect. It classifies the common failures into actionable messages (database missing vs auth vs unreachable) rather than surfacing a raw driver error.

type Manifest

type Manifest struct {
	// Listen is the proxy's public address (default ":8080").
	Listen string `json:"listen,omitempty"`
	// StatusAddr serves the fleet status/control API (default "127.0.0.1:9601").
	// Internal-only, like the engine control plane — keep it off the internet.
	StatusAddr string `json:"status_addr,omitempty"`
	// DataDir roots per-app state the fleet assigns when the app's env does not
	// set it: <DataDir>/<app>/obs.db, <DataDir>/<app>/files, logs. Default
	// "/var/lib/appximo/fleet".
	DataDir string `json:"data_dir,omitempty"`
	// OperatorKey is the FLEET-OPERATOR credential (MT-STRUCT-S5): it gates the
	// unified fleet console (`/fleet` on the in-process runtime's process-level
	// handler) — the server owner's view over ALL apps. It is a level ABOVE the
	// per-app credentials and is deliberately DISTINCT from every app's
	// ADMIN_KEY/JWT_SECRET: holding one app's keys never reveals the fleet, and
	// the fleet key opens NO app API (per-app JWT/RBAC/admin auth still applies
	// underneath — the S3 isolation is not bypassable from the console). Empty
	// falls back to APPXIMO_FLEET_OPERATOR_KEY; still empty ⇒ the console is
	// DISABLED (safe by default).
	OperatorKey string `json:"operator_key,omitempty"`
	// OperatorAdminEmail enables the UNIFIED OPERATOR IDENTITY
	// (FLEET-CONSOLE-S2): the in-process runtime ensures a platform
	// super-admin with this email exists in EVERY app's database (idempotent,
	// never overwriting an existing account), so ONE login works on every
	// app's /admin — without weakening the S3 isolation (each app keeps its
	// own admin row, DB and tokens). The PASSWORD is deliberately NOT a
	// manifest key: it comes from the APPXIMO_FLEET_ADMIN_PASSWORD env var
	// (an env-file, like the app secrets), so the manifest stays committable.
	// Empty email falls back to APPXIMO_FLEET_ADMIN_EMAIL; both empty ⇒
	// feature off (each app manages its own admins, the pre-S2 behavior).
	OperatorAdminEmail string `json:"operator_admin_email,omitempty"`
	// DBInstances declares the Postgres servers the console's Add-app form may
	// suggest DSNs from and create databases on (FLEET-DB-ASSIST). The engine
	// NEVER discovers Postgres on its own — this list, and only this list, is
	// what the console offers. Committable: it holds no credentials, only each
	// instance's name/label and the NAME of the env var carrying its privileged
	// DSN (the secret lives in the env-file). Absent/empty ⇒ the form is
	// manual-DSN + test-connection only (no create power). See DBInstance.
	DBInstances []DBInstance `json:"db_instances,omitempty"`
	// Apps are the fleet's apps (≥1).
	Apps []AppSpec `json:"apps"`
	// contains filtered or unexported fields
}

Manifest is the fleet config file: the proxy listen address, the status endpoint, the per-app data root, and the apps.

func LoadManifest

func LoadManifest(path string) (*Manifest, error)

LoadManifest reads, resolves and validates a fleet manifest. Every error is actionable (names the app and the rule) — the same load-fails-loud contract as the engine's schema validation.

func (*Manifest) AppByName

func (m *Manifest) AppByName(name string) *AppSpec

AppByName returns the manifest entry named name, or nil.

func (*Manifest) DBInstanceByName

func (m *Manifest) DBInstanceByName(name string) *DBInstance

DBInstanceByName returns the declared instance named name, or nil.

func (*Manifest) OperatorAdmin

func (m *Manifest) OperatorAdmin() (string, string)

OperatorAdmin returns the unified operator identity (email, password), or ("", "") when disabled. Password always from env — never the manifest.

func (*Manifest) Path

func (m *Manifest) Path() string

Path returns the absolute path of the file this manifest was loaded from (empty for a manifest constructed in code).

func (*Manifest) SafeDBInstances

func (m *Manifest) SafeDBInstances() []SafeDBInstance

SafeDBInstances returns the credential-free instance list for the console.

func (*Manifest) ValidateNewApp

func (m *Manifest) ValidateNewApp(a *AppSpec) error

ValidateNewApp checks a candidate app against this manifest with the SAME rules LoadManifest applies, resolving the candidate's env (env_file + env) as a side effect. The candidate is NOT added; callers add it after the whole add operation (compile, registry, persist) succeeds.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy is the fleet's public face: it routes by Host to the owning app's engine and does NOTHING else — pure transport (MVP choice documented in docs/FLEET.md: a single-binary httputil.ReverseProxy; production TLS/HTTP2 termination can sit in front — Caddy/nginx — with the same domain table). Auth, RBAC, tenancy, rate limiting all happen in the destination engine, exactly as without a proxy.

Matching: a request Host matches an app if it equals one of its domains or is a SUBDOMAIN of one (longest domain wins). Subdomain labels stay free for the engine's tenant resolution: acme.crm.example.com → app "crm" (here) → tenant "acme" (engine middleware).

func NewProxy

func NewProxy(mf *Manifest, portOf func(name string) (int, bool)) (*Proxy, error)

NewProxy builds the routing table from the manifest apps and their assigned ports. The table is immutable for the run (ports are fixed per fleet run).

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes by Host. Lookup walks the host's label suffixes so the LONGEST matching domain wins (api.crm.example.com prefers crm.example.com over example.com), each step one map hit — O(labels), no allocation.

type SafeDBInstance

type SafeDBInstance struct {
	Name        string `json:"name"`
	Label       string `json:"label"`
	CanCreateDB bool   `json:"can_create_db"`
}

SafeDBInstance is the console-facing view of an instance — never the DSN.

type Supervisor

type Supervisor struct {
	// contains filtered or unexported fields
}

Supervisor runs one engine process per app and keeps it alive.

The one reconciliation rule that matters: the supervisor restarts an app ONLY when its process EXITS — never because health looks bad. The engine's own self-restart (UI-F4-S2) re-execs with syscall.Exec: same PID, the child never exits, so from here a self-restart is invisible except as a ~6 s window where /readyz answers 503 ("draining"). Restart-on-unhealthy would fight that drain and kill a healthy self-restart mid-flight; restart-on-exit composes with it for free.

func NewSupervisor

func NewSupervisor(mf *Manifest, bin string) *Supervisor

NewSupervisor prepares (but does not start) a supervisor for the manifest. bin is the engine binary to spawn — normally the fleet's own executable.

func (*Supervisor) Port

func (s *Supervisor) Port(name string) (int, bool)

Port returns an app's internal data port (for the proxy table).

func (*Supervisor) RestartApp

func (s *Supervisor) RestartApp(name string) error

RestartApp is a deliberate stop+start of ONE app; the others are untouched.

func (*Supervisor) Shutdown

func (s *Supervisor) Shutdown()

Shutdown stops every app in parallel (graceful, bounded by StopApp's 15 s).

func (*Supervisor) Start

func (s *Supervisor) Start(ctx context.Context) error

Start allocates ports, spawns every app, and launches the health poller. It returns once all apps are spawned (readiness is reported via Status).

func (*Supervisor) StartApp

func (s *Supervisor) StartApp(name string) error

StartApp starts a previously stopped app.

func (*Supervisor) Status

func (s *Supervisor) Status() []AppStatus

Status snapshots every app, sorted by name.

func (*Supervisor) StopApp

func (s *Supervisor) StopApp(name string) error

StopApp gracefully stops one app (SIGTERM → engine drain; SIGKILL after 15 s) and marks it so onExit does not restart it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL