platformstack

package
v1.6.3 Latest Latest
Warning

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

Go to latest
Published: Jul 23, 2026 License: AGPL-3.0 Imports: 20 Imported by: 0

Documentation

Overview

Package platformstack installs and updates Miabi's own stack — the network, the volumes, Postgres, Redis, the gateway and the control plane — directly against the Docker API, with every component tagged io.miabi.managed-by=miabi.

Why this exists, when examples/compose/compose.yaml already works:

Compose owns what Compose created. A container Miabi recreates out-of-band is silently reverted by the next `docker compose up -d`, which reads compose.yaml and sees drift. So Miabi could never truthfully "update itself" while Compose held the lifecycle — the update would survive until the operator's next routine command.

The fix is not to have Miabi patch a file Compose owns. It is to change the owner. Everything here is tagged managed-by=miabi, which the label contract (internal/docker/labels.go) already defines as "Miabi created it and may freely recreate it".

The decisive property is that this package runs from the CLI, on the host, OUTSIDE the control-plane container. That is precisely the actor needed to replace the control plane — something in-container code structurally cannot be, since `docker stop miabi` kills the process doing the stopping. It is what makes self-update possible at all.

Compose remains fully supported; see docs. The two paths are distinguished by io.miabi.managed-by, and Miabi refuses to act on components it does not own.

Index

Constants

View Source
const (
	// DefaultGeoIPFile is the GeoIP database's name beside goma.yml — what the docs
	// tell operators to call the file they supply. Provider-neutral, because Miabi
	// does not choose the provider.
	DefaultGeoIPFile = "country.mmdb"
	// LegacyGeoIPFile is accepted too. It is MaxMind's own download name, so an
	// operator following MaxMind's instructions lands on it without thinking — and
	// earlier Miabi releases downloaded the file under that name.
	LegacyGeoIPFile = "GeoLite2-Country.mmdb"
)
View Source
const (
	ContainerControlPlane = "miabi"
	ContainerPostgres     = "miabi-postgres"
	ContainerRedis        = "miabi-redis"
	ContainerGateway      = "miabi-gateway"
)

Container names. They match examples/compose/compose.yaml deliberately: an operator's muscle memory (`docker logs miabi`) keeps working across both install paths, and Phase 1's name shield already knows them.

View Source
const (
	VolumePGData           = "mb-platform-pgdata"
	VolumeRedisData        = "mb-platform-redisdata"
	VolumeLogs             = "mb-platform-logs"
	VolumeGatewayCerts     = "mb-platform-gateway-certs"
	VolumeGatewayProviders = "mb-platform-gateway-providers"

	// VolumeGatewayConfig is NO LONGER CREATED: the gateway config is a bind-mounted
	// file on the host, not a copy in a volume. It survives here only so Teardown can
	// clean it up on installs that predate the change.
	VolumeGatewayConfig = "mb-platform-gateway-config"
)

Volumes. The mb- prefix marks them as Miabi's own, which dockerimport's name shield already recognizes, so they are never offered for import.

View Source
const (
	DefaultPostgresImage = "postgres:17-alpine"
	DefaultRedisImage    = "redis:7-alpine"
	DefaultGatewayImage  = "jkaninda/goma-gateway:latest"
	// DefaultRunnerImage floats on :latest, unlike every other default here — and that
	// is deliberate. The runner is the one image the stack does not RUN: it only names
	// what a CI runner should be enrolled with. So it carries none of the
	// reproducibility weight the others do, and it releases on its own cadence (0.0.x
	// while the panel is 1.x). A hard pin here would simply go stale — install.sh
	// passes --runner-image with the version this release was tested against, and this
	// default is only the fallback for a bare `docker run … install`.
	DefaultRunnerImage = "miabi/runner:latest"
	DefaultNetwork     = "miabi"
	DefaultSubnet      = "10.63.0.0/16"
)

Default images. Overridable in the manifest; pinned here so a bare `miabi install` is reproducible rather than tracking whatever :latest happens to be.

NOTE the tags carry no leading "v". Git tags do (v0.11.0); Docker tags do not (0.11.0) — install.sh spells this out as ${GOMA_VERSION#v}. Writing the git form here produced an image reference that simply does not exist, and the install got all the way to the last component before failing on it.

View Source
const (
	DefaultTimezone = "UTC"
	// DefaultLogLevel matches what the control plane would pick anyway in production;
	// writing it down makes it discoverable instead of folklore.
	DefaultLogLevel = "info"
)

Seeded env: defaults. They are written into the manifest rather than applied invisibly, so `env:` shows an operator the two knobs they are most likely to want and where to change them. Both stay ordinary env: entries — editable, and not reserved — because neither needs to agree with anything else in the manifest.

View Source
const CurrentVersion = 1

CurrentVersion is the manifest schema version this build writes.

View Source
const DefaultGatewayConfigFile = "goma.yml"

DefaultGatewayConfigFile is the gateway config's name beside stack.yaml.

View Source
const (
	// DefaultGomaLogLevel matches Goma's own default; writing it down makes the knob
	// visible. Real effect, measured: info emits 28 lines and 0 DEBUG on boot, debug
	// emits 51 lines and 22 DEBUG.
	DefaultGomaLogLevel = "info"
)

Seeded gateway.env defaults, written into the manifest so they are discoverable rather than folklore.

View Source
const DefaultManifestPath = "/etc/miabi/stack.yaml"

DefaultManifestPath is where the installed stack's desired state lives.

It has to be a file on the host, and it cannot be the database: Postgres is itself a component of the stack, so the CLI cannot read the database to learn how to start the database. Nor can it be a table Miabi owns — `miabi install` runs before Miabi exists.

Be honest about what this is: it is compose.yaml + .env under another name. The win is not that the file is gone. The win is that Miabi *writes* it, so it can never disagree with what Miabi actually did — which is exactly the failure mode of having Miabi patch a compose file that Compose owns.

View Source
const ManifestPathEnv = "MIABI_STACK_FILE"

ManifestPathEnv overrides DefaultManifestPath (tests, rootless installs, a second stack on one host).

Variables

View Source
var ErrNotInstalled = errors.New("no Miabi stack manifest found")

ErrNotInstalled means there is no stack manifest — nothing was installed by the CLI here. It does NOT mean there is no Miabi: a Compose install has no manifest either, and callers say so.

View Source
var ErrOrphanedData = errors.New("an existing Miabi database was found, but its manifest is missing")

ErrOrphanedData means Postgres data is already on this host, but the manifest that holds its password is gone.

Functions

func GomaConfig

func GomaConfig() []byte

GomaConfig exposes the embedded config so the drift test can compare it.

func ManifestPath

func ManifestPath() string

ManifestPath resolves the manifest location.

func PortConflictError

func PortConflictError(conflicts []PortConflict) error

PortConflictError renders conflicts as something an operator can act on.

func Save

func Save(path string, m *Manifest) error

Save writes the manifest atomically at 0600, creating its directory.

Atomic because this file is the only record of the install's secrets: a partial write from a killed process would lose the database password, and with it the database.

Types

type Component

type Component struct {
	Name      string
	Role      string
	State     string // running, exited, …
	Health    string // healthy, unhealthy, starting, "" (no healthcheck)
	Image     string
	ManagedBy string // compose | miabi | external | "" (an install predating labels)
	Status    string
}

Component is a running piece of the platform stack, as found on the engine.

This is a READ MODEL, not a record. There is deliberately no database table for the stack: the engine already knows what is running (the labels say so), and the manifest already says what should be. A third copy in Postgres could only drift from both — and worse, making the platform's own Postgres an ordinary Database row would hand it to the reconcilers, backup and delete paths that manage user databases. See plans/platform-labels.md §4.4.

type Gateway

type Gateway struct {
	// Config is the gateway config file, relative to the manifest's own directory
	// (so it sits next to stack.yaml and is backed up with it). Absolute paths are
	// taken as-is. Empty means goma.yml.
	Config string `yaml:"config,omitempty"`

	// ConfigSHA is the digest of the DEFAULT config Miabi last wrote to that file.
	//
	// It is what lets an unmodified config keep receiving upstream improvements while
	// a customized one is never clobbered: if the file still hashes to this, nobody
	// touched it and a newer release's default may replace it; if it does not, the
	// operator edited it and Miabi leaves it alone. Without this, either customization
	// is impossible (we always overwrite) or every install is frozen on the base
	// config it shipped with (we never overwrite) — and nobody would be told which.
	ConfigSHA string `yaml:"config_sha,omitempty"`

	// Env is the gateway container's environment: anything the config interpolates
	// (`${MY_UPSTREAM}`), plus GOMA_CONFIG_ENCRYPTION_KEY.
	//
	// Variables Miabi sets itself (the domain, the ACME email, the Redis password) are
	// refused here, exactly as in the top-level env: — a manifest where `domain` says
	// one thing and `gateway.env.MIABI_DOMAIN` says another has two sources of truth.
	Env map[string]string `yaml:"env,omitempty"`
}

Gateway configures Goma: its config file and its environment.

The config is a FILE ON THE HOST, bind-mounted read-only — exactly as examples/compose/compose.yaml does it (`./goma/goma.yml:/etc/goma/goma.yml:ro`). It is not copied into a volume: copying makes the volume the source of truth and the host file a stale duplicate that every converge silently overwrites, so an operator following goma.yml's own instructions ("uncomment this to restrict the panel to trusted IPs") would watch their edit disappear on the next `miabi install`.

type Images

type Images struct {
	Miabi    string `yaml:"miabi"`
	Postgres string `yaml:"postgres"`
	Redis    string `yaml:"redis"`
	Gateway  string `yaml:"gateway"`
	// Runner is not run by the stack; it is the image shown in the runner enrollment
	// command, so it belongs to the install's identity even though nothing here starts
	// it. It is therefore the one image whose default floats on :latest — see
	// DefaultRunnerImage. install.sh still pins it to the tested version.
	Runner string `yaml:"runner"`
}

Images pins every image the stack runs. Each is a full ref (repo:tag), not a bare tag: the point of pinning is that `miabi install` on two hosts a month apart produces the same stack.

type Manifest

type Manifest struct {
	// Version is the manifest schema, not the Miabi version.
	Version int `yaml:"version"`

	Domain    string `yaml:"domain"`
	WebURL    string `yaml:"web_url"`
	ACMEEmail string `yaml:"acme_email"`

	// ControlURL is the URL remote nodes and agents reach this control plane at: the
	// node gateways' route provider fetches from it, the agent dials it, and the
	// registry points at it for auth.
	//
	// Defaults to WebURL, which is right for a single public hostname. It is separate
	// because it does not have to be: a node on a private network may reach the control
	// plane at an internal address the public panel URL never resolves to, and pinning
	// the two together would force that traffic out over the internet and back.
	ControlURL string `yaml:"control_url"`

	Network  NetworkConfig `yaml:"network"`
	Images   Images        `yaml:"images"`
	Secrets  Secrets       `yaml:"secrets"`
	Registry Registry      `yaml:"registry"`
	Gateway  Gateway       `yaml:"gateway"`

	// Env are extra environment variables for the control plane — anything Miabi reads
	// that this manifest does not already model: MIABI_SMTP_*, MIABI_LOG_LEVEL,
	// MIABI_OAUTH_*, TZ, HTTP_PROXY, and so on.
	//
	// It may not contain any variable Miabi sets ITSELF (the database password, the
	// domain, the encryption key…). Those are refused rather than merged: a manifest
	// where `secrets.db_password` says one thing and `env.MIABI_DB_PASSWORD` says
	// another has two sources of truth, and whichever loses is a silent, invisible
	// misconfiguration. Normalize enforces this — see normalizeEnv.
	Env map[string]string `yaml:"env,omitempty"`

	// DockerGID is the host's docker group, added to the control plane so it can read
	// /var/run/docker.sock without running as root.
	DockerGID string `yaml:"docker_gid,omitempty"`

	// HostProc binds the host's /proc read-only into the control plane, so the admin
	// Nodes page can report real host CPU and memory.
	//
	// A POINTER, not a bool: absent must mean "on", and a plain bool's zero value is
	// false — every manifest written before this field existed would silently turn the
	// bind off on the next converge. nil is resolved to true in Normalize and written
	// back explicitly, so the file always says which it is.
	//
	// Turning it off is safe. Miabi falls back to its own /proc, which inside a
	// container already reflects host CPU/memory (procfs is not namespaced for those),
	// so the Nodes page keeps working. Set it false where the bind is refused outright:
	// a rootless daemon, a hardened host, or a socket proxy that forbids host binds.
	HostProc *bool `yaml:"host_proc"`
	// contains filtered or unexported fields
}

Manifest is the installed stack's desired state.

func Defaults

func Defaults(miabiImage string) *Manifest

Defaults returns a manifest with everything the caller did not supply. miabiImage is the control-plane image, which has no sensible default — it is the version being installed, and the CLI knows it (it is that version).

func Load

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

Load reads the manifest. A missing file returns ErrNotInstalled, which callers distinguish from a corrupt one — "you have not installed yet" and "your install is unreadable" need very different messages.

func (*Manifest) GenerateSecrets

func (m *Manifest) GenerateSecrets() error

GenerateSecrets fills any secret that is still empty, leaving existing values alone so a re-install never rotates a live install's credentials out from under it (which would lock Miabi out of its own database).

func (*Manifest) ImageFor

func (m *Manifest) ImageFor(container string) (string, bool)

ImageFor is the read-only form of ImagePin, for drift reporting.

func (*Manifest) ImagePin

func (m *Manifest) ImagePin(container string) (*string, bool)

ImagePin returns a pointer to the manifest field pinning the named component's image, so an update can write the new value back to the right place. ok is false for a name that is not a stack component.

func (*Manifest) Normalize

func (m *Manifest) Normalize() error

Normalize fills in derived fields and validates what the caller must supply. It is called by Install and Update, so a hand-edited manifest gets the same treatment as a generated one.

type NetworkConfig

type NetworkConfig struct {
	Name   string `yaml:"name"`
	Subnet string `yaml:"subnet"`
}

type PortConflict

type PortConflict struct {
	Port int
	// Holder names what has it — a container, or the host itself when the port is
	// taken by a process Docker knows nothing about (a system nginx, Apache, or a
	// Caddy installed from a package).
	Holder string
	// Container is true when Holder is a container name, which the operator can
	// simply stop.
	Container bool
}

PortConflict is something already holding a port the gateway needs.

func (PortConflict) String

func (c PortConflict) String() string

type Registry

type Registry struct {
	Enabled bool `yaml:"enabled"`
	// Host is the registry's own public hostname (registry.example.com). It gets a
	// DNS record and its own TLS certificate, separate from the panel's.
	Host string `yaml:"host,omitempty"`
}

Registry configures the built-in OCI registry.

Only written into the control plane's environment when Enabled. That is not a stylistic choice: a non-empty MIABI_REGISTRY_* value is a ONE-WAY OVERRIDE — it pins the setting and the admin UI can no longer change it. Leaving the keys absent is what keeps the registry a UI-managed setting, which is the right default for an operator who has not asked for it. examples/compose/compose.yaml and install.sh take exactly the same position.

type Secrets

type Secrets struct {
	DBPassword    string `yaml:"db_password"`
	RedisPassword string `yaml:"redis_password"`
	JWTSecret     string `yaml:"jwt_secret"`
	EncryptionKey string `yaml:"encryption_key"`
	AdminEmail    string `yaml:"admin_email"`
	AdminPassword string `yaml:"admin_password"`
}

type Service

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

Service installs and updates the stack.

func New

func New(dc docker.Client, log func(string, ...any), manifestPath string) *Service

func (*Service) CheckOrphanedData

func (s *Service) CheckOrphanedData(ctx context.Context) error

CheckOrphanedData refuses a FRESH install onto an existing Postgres volume.

Postgres only honours POSTGRES_PASSWORD when it initializes an EMPTY data directory. Point it at a data dir that already exists and it keeps the password it was first created with — so a new manifest, with its freshly generated password, can never authenticate. The database is intact and entirely unreadable.

Nothing catches this on its own: `pg_isready` does not check credentials, so the health gate goes green, and only the control plane discovers the truth, by crash-looping on "password authentication failed".

This is precisely why the manifest is worth backing up, and the error says so.

func (*Service) CheckPorts

func (s *Service) CheckPorts(ctx context.Context) ([]PortConflict, error)

CheckPorts reports what would stop the gateway from binding 80 and 443.

It runs BEFORE anything is created. Without it the install gets all the way to the last component — Postgres up, Redis up, the control plane running — and only then dies because something else already owns :443. The operator is left with a half-built stack and an error about a port, which is the worst possible moment to learn it.

Two passes, because neither alone is enough:

  • Docker's own containers, which is the common case (an existing gateway, a Traefik, a stray nginx container) and lets the message name the culprit.
  • An actual bind, for anything Docker cannot see. A system nginx holding :80 is invisible to `docker ps`, and from inside the installer container we cannot read the host's listening sockets either — our network namespace is not the host's. But asking Docker to publish the port answers the question exactly: it either succeeds or it does not.

func (*Service) Component

func (s *Service) Component(m *Manifest, name string) (component, bool)

Component returns the named component, or false. Exported so the CLI can validate `miabi update <component>` before touching anything.

func (*Service) ComponentNames

func (s *Service) ComponentNames(m *Manifest) []string

ComponentNames lists the updatable components, in dependency order.

func (*Service) Converge

func (s *Service) Converge(ctx context.Context, m *Manifest) error

Converge brings the stack to the manifest's desired state, in dependency order, and is safe to run repeatedly: a component whose spec already matches is left alone, so `miabi install` on a healthy stack is a no-op rather than a recreate.

Ordering is not cosmetic. Postgres and Redis must be *healthy*, not merely started, before the control plane boots — it runs migrations against one and connects to the other on startup, and a control plane that comes up first just crash-loops until they do. The gateway goes last because it is the only component that publishes ports, so an incomplete stack fails closed rather than serving errors to the world.

func (*Service) Discover

func (s *Service) Discover(ctx context.Context) ([]Component, error)

Discover lists the platform stack as it actually is — by role, so it answers for a Compose install just as well as a CLI one. That is the point: `miabi status` should describe whatever is running, not only what the CLI put there.

func (*Service) EnsureGatewayConfig

func (s *Service) EnsureGatewayConfig(ctx context.Context, m *Manifest) error

EnsureGatewayConfig makes sure the gateway config exists, is valid, and is honest about whether the operator owns it.

The policy, and why it is not simply "always write the default":

  • absent → write the shipped default, record its digest.
  • matches digest → nobody touched it. A newer release's default REPLACES it, so installs that never customized still receive upstream fixes.
  • differs → the operator edited it. Never touch it again.

Without the digest you must choose between "customization is impossible" (always overwrite — which is what copying into a volume did) and "every install is frozen on the config it shipped with" (never overwrite). Neither tells the operator which one they are living in.

func (*Service) Restart

func (s *Service) Restart(ctx context.Context, m *Manifest, only string) error

Restart restarts the stack, or one component of it.

It restarts CONTAINERS; it does not recreate them. That distinction is the whole point: a restart re-reads what is on disk (the gateway's bind-mounted goma.yml, most obviously — Goma watches its providers directory but NOT its base config, so an edit there does nothing until Goma is restarted). Anything that changes a container's SPEC — an image, an env var, a mount — needs `miabi install`, which recreates. Restart deliberately cannot apply those, and says so when it notices they are pending.

Order matters for a whole-stack restart: Postgres and Redis first, and healthy before the control plane follows, or Miabi comes back to a database that is not there yet and exits. The gateway goes last, so the panel is already serving by the time traffic can reach it.

func (*Service) Rollout

func (s *Service) Rollout(ctx context.Context, m *Manifest, name, newImage string, onPhase func(string, error)) error

Rollout replaces one component with a new image and returns the manifest field to persist. It is the update path; Converge is the install path.

func (*Service) Teardown

func (s *Service) Teardown(ctx context.Context, withVolumes bool) error

Teardown removes the stack's containers. Volumes are kept unless withVolumes: the database lives in one, and `uninstall` is far too easy to type for it to be the thing that silently destroys it.

Reverse dependency order — gateway first (stop serving), control plane, then the stores it depends on — so nothing is left talking to a database that just vanished.

func (*Service) ValidateGatewayConfig

func (s *Service) ValidateGatewayConfig(ctx context.Context, m *Manifest) error

ValidateGatewayConfig checks the gateway config without writing it.

EnsureGatewayConfig would also (re)write the file; a restart must not — the whole reason to restart is that the operator changed something on disk, and rewriting it underneath them would be the opposite of what they asked for.

Jump to

Keyboard shortcuts

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