setup

package
v0.8.4 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Preflight, plan rendering, execution, and the receipt.

The shape is deliberate and borrowed from tools that change infrastructure: detect, show what will happen, ask, then act, then prove it worked and write down what was touched. OpenWatch is a compliance product, so "what did the installer change on this host" is a question its own buyers' auditors ask.

The setup plan: one object, three ways to fill it.

Express fills it from detection plus defaults, guided fills it by prompting with those same values pre-filled, and unattended fills it from a file. All three then run identical validation and identical steps. That is deliberate: the alternative is a "quick install" path and a "real install" path that drift, where the automated one is exercised least and breaks quietly.

NO SECRETS ARE STORED HERE. Every credential is a source, never a value, so a plan file is safe to commit, attach to a ticket, or send to support. It is also what makes fleet replay work: run guided once, save the plan, apply it unattended everywhere else.

Platform detection for `openwatch setup`.

WHY THIS EXISTS: setup writes to pg_hba.conf, installs packages, and enables services. Every one of those is distro-specific, and guessing wrong means writing to a path that belongs to something else. So the platform is detected once, carries a support tier, and gates the run: a distro CI does not cover is refused by default rather than approximated.

The tier is deliberately three-valued. Claiming a support matrix that CI does not exercise is how documentation starts lying; refusing everything unrecognised would block the Rocky and Alma users who are, in practice, running the same paths as RHEL. "untested" says both things honestly: it runs with --allow-untested and reports itself in the receipt.

Execution context and command helpers for `openwatch setup`.

Every mutation the installer makes goes through Run, so that three things are true without each step remembering to do them: the command is recorded for the receipt, --dry-run performs no writes, and a step that has already been applied is skipped rather than repeated.

Idempotence is not a nicety here. The most common moment to run setup is after a previous attempt failed part-way, which is exactly when a fresh-machine-only installer is useless.

The steps `openwatch setup` performs, in order.

Each step answers three questions independently: is it already done (Status), what would doing it mean (Describe), and do it (Apply). Keeping Status separate is what makes the whole run idempotent and what lets the plan be shown before anything is touched.

Index

Constants

View Source
const PlanAPIVersion = "openwatch.hanalyx.com/v1alpha1"

PlanAPIVersion is bumped when the schema changes incompatibly, so an old saved plan is rejected with a version message rather than mis-parsed.

View Source
const ReceiptPath = "/var/lib/openwatch/setup-receipt.json"

ReceiptPath is where the record of an applied run lands.

Variables

This section is empty.

Functions

func Execute

func Execute(ctx context.Context, r *Run, planned []PlannedStep) error

Execute applies the steps that are not already satisfied.

func RenderPlan

func RenderPlan(w func(string, ...any), p Plan, checks []Check, planned []PlannedStep)

RenderPlan writes the human-facing plan.

func RenderSummary

func RenderSummary(w func(string, ...any), r *Run, receipt string)

RenderSummary prints what an operator needs after a successful run.

func ResolveSecrets

func ResolveSecrets(ctx context.Context, r *Run, prompt func(label string) (string, error)) error

ResolveSecrets fills the run's credentials from their declared sources. It is the only place a secret enters the process, and nothing here writes one to the plan, the receipt, or the log.

func WriteReceipt

func WriteReceipt(r *Run, version string) (string, error)

WriteReceipt records what happened, for support and for audit.

Types

type AdminPlan

type AdminPlan struct {
	Username string `yaml:"username"`
	Email    string `yaml:"email"`
	Password Secret `yaml:"password"`
}

AdminPlan is the first login.

type Change

type Change struct {
	Step   string `json:"step"`
	Action string `json:"action"`
	Target string `json:"target,omitempty"`
	Backup string `json:"backup,omitempty"`
}

Change records one mutation for the receipt, so an operator (or an auditor) can answer "what did this touch" without reconstructing it from logs.

type Check

type Check struct {
	Name string
	OK   bool
	// Fatal marks a failure that stops the run; a non-fatal failure is a
	// warning the operator should see but can proceed past.
	Fatal  bool
	Detail string
}

Check is one preflight result.

func FatalFailures

func FatalFailures(checks []Check) []Check

FatalFailures returns the checks that block the run.

func Preflight

func Preflight(ctx context.Context, p Plan, allowUntested, interactive bool) []Check

Preflight inspects the host before anything is planned. It never mutates.

AllowUntested lets a recognized-but-unverified distro proceed: refusing outright would block Rocky and Alma users running identical paths, while claiming to support them would be a promise CI does not keep.

Interactive says whether this run can prompt, which decides whether a prompt-sourced credential is obtainable.

type DatabaseMode

type DatabaseMode string

DatabaseMode selects how much of PostgreSQL's lifecycle setup owns.

const (
	// DBProvision installs PostgreSQL if absent, initializes the cluster,
	// starts it, and creates the role and database.
	DBProvision DatabaseMode = "provision"
	// DBExisting connects to a PostgreSQL that already runs, creating only
	// the role and database if they are missing.
	DBExisting DatabaseMode = "existing"
)

type DatabasePlan

type DatabasePlan struct {
	Mode     DatabaseMode `yaml:"mode"`
	Host     string       `yaml:"host"`
	Port     int          `yaml:"port"`
	Name     string       `yaml:"name"`
	RoleName string       `yaml:"role_name"`
	Password Secret       `yaml:"password"`
	// SSLMode is forced to at least "require" when Host is not loopback; a
	// remote database over cleartext is not something to allow by accident.
	SSLMode string `yaml:"sslmode"`
	// ManagePgHba is opt-in. Editing pg_hba.conf is the single most effective
	// way to lock an operator out of their own database, so the default is to
	// print the required lines and re-check rather than to write them.
	ManagePgHba bool `yaml:"manage_pg_hba"`
	// NoManagePgHba declines the edit even when this run provisioned the
	// cluster, which is otherwise managed without asking.
	NoManagePgHba bool `yaml:"no_manage_pg_hba,omitempty"`
}

DatabasePlan is everything about reaching and owning the database.

func (DatabasePlan) DSN

func (d DatabasePlan) DSN(password string) string

DSN builds the connection string for a resolved password.

The password is passed raw and encoded here, by net/url, which is the whole point: a DSN is a URI, and a password containing '@' or '/' silently changes what the URI means. Hand-assembling this string is how an install ends up authenticating as something other than what the operator typed.

func (DatabasePlan) IsLoopback

func (d DatabasePlan) IsLoopback() bool

IsLoopback reports whether the database lives on this machine.

type Family

type Family string

Family groups distributions that share package manager and file layout.

const (
	FamilyRHEL    Family = "rhel"
	FamilyDebian  Family = "debian"
	FamilyUnknown Family = "unknown"
)

type PauseError

type PauseError struct {
	Step   string
	Reason string
}

PauseError stops the run for a manual step the operator chose to own, rather than for a failure.

The distinction is not cosmetic. Without it, declining to manage pg_hba.conf means setup writes the DSN, then fails at the migration step with "Ident authentication failed for user openwatch" -- an error two steps downstream of its cause, naming the role rather than the host-based authentication rules. Halting at the step that needs the operator keeps the message next to the problem, and because every step is idempotent, re-running afterwards continues from here.

func (*PauseError) Error

func (e *PauseError) Error() string

type Plan

type Plan struct {
	APIVersion string       `yaml:"apiVersion"`
	Platform   Platform     `yaml:"platform"`
	Database   DatabasePlan `yaml:"database"`
	Service    ServicePlan  `yaml:"service"`
	Admin      AdminPlan    `yaml:"admin"`
	Migrate    bool         `yaml:"migrate"`
}

Plan is the whole intent. Platform is detected rather than authored and is re-detected on apply.

func DefaultPlan

func DefaultPlan(p Platform) Plan

DefaultPlan returns the plan a bare `openwatch setup` would apply on this host. Guided mode renders these as pre-filled answers, so holding Enter and running --yes produce the same result.

func (*Plan) Derive

func (p *Plan) Derive()

Derive recomputes the fields that follow from other answers. Called after every edit in guided mode so the operator sees a consequence at the moment of the choice rather than when the service fails to start.

func (Plan) Validate

func (p Plan) Validate() []error

Validate reports every problem at once, so an operator fixes one round of answers instead of discovering them one prompt at a time.

type PlannedStep

type PlannedStep struct {
	Step   Step
	Status StepStatus
}

PlannedStep pairs a step with its current status, so the rendered plan can distinguish what will happen from what is already true.

func Resolve

func Resolve(ctx context.Context, p Plan) []PlannedStep

Resolve inspects every step without mutating, producing the plan to show.

type Platform

type Platform struct {
	// ID is the os-release ID, e.g. "rhel", "rocky", "almalinux", "ubuntu".
	ID string `yaml:"id"`
	// VersionID is the os-release VERSION_ID, e.g. "9.8".
	VersionID string `yaml:"version_id"`
	// Major is VersionID's leading integer, e.g. 9. Zero when unparseable.
	Major int `yaml:"major"`
	// Family determines package manager and PostgreSQL layout.
	Family Family `yaml:"family"`
	// Arch is the Go architecture, e.g. "amd64".
	Arch string `yaml:"arch"`
	// Support gates the run.
	Support Support `yaml:"support"`
	// SELinux is "enforcing", "permissive", "disabled", or "" when absent.
	SELinux string `yaml:"selinux,omitempty"`
	// FIPS reports the kernel-level FIPS switch (/proc/sys/crypto/fips_enabled).
	FIPS bool `yaml:"fips"`
	// Fapolicyd reports whether the file-access policy daemon is active. It
	// blocks execution of anything absent from its trust database, which is
	// derived from the package database, so a non-packaged install is denied.
	Fapolicyd bool `yaml:"fapolicyd"`
}

Platform is the detected host. Every field is measured, never authored: a saved plan replayed on another machine re-detects and compares, so a plan captured on RHEL cannot be silently applied to Ubuntu.

func DetectPlatform

func DetectPlatform() Platform

DetectPlatform reads the host's identity. It never fails: an unrecognised host is returned with Support unsupported so the caller reports it rather than a detection error the operator cannot act on.

func (Platform) String

func (p Platform) String() string

String renders the platform for a plan header.

type Receipt

type Receipt struct {
	AppliedAt string   `json:"applied_at"`
	Version   string   `json:"openwatch_version"`
	Platform  Platform `json:"platform"`
	Plan      Plan     `json:"plan"`
	Changes   []Change `json:"changes"`
	URL       string   `json:"url"`
}

Receipt is the record of an applied run. It holds no secrets: the resolved plan records how each credential was obtained, never what it was.

type Run

type Run struct {
	Plan Plan
	// DryRun performs detection and planning but no writes.
	DryRun bool
	// Secrets resolved at apply time. Never serialized anywhere.
	DBPassword    string
	AdminPassword string

	// Changes accumulates what actually happened.
	Changes []Change
	// Out receives progress lines.
	Out func(format string, args ...any)
}

Run carries everything a step needs and everything it produces.

type Secret

type Secret struct {
	Source SecretSource `yaml:"source"`
	// Ref names the env var or file path for the env/file sources.
	Ref string `yaml:"ref,omitempty"`
	// Length is the generated length; ignored for other sources.
	Length int `yaml:"length,omitempty"`
}

Secret describes how to obtain a credential without recording it.

type SecretSource

type SecretSource string

SecretSource says where a credential comes from at apply time.

const (
	// SecretGenerate mints a random value. The default for the database role,
	// because a generated password is built into the DSN by the same code that
	// creates the role, so the two cannot disagree and the operator never has
	// to think about URI encoding.
	SecretGenerate SecretSource = "generate"
	// SecretPrompt reads it interactively.
	SecretPrompt SecretSource = "prompt"
	// SecretEnv reads it from an environment variable named by SecretRef.
	SecretEnv SecretSource = "env"
	// SecretFile reads it from the file named by SecretRef.
	SecretFile SecretSource = "file"
)

type ServicePlan

type ServicePlan struct {
	ListenHost string `yaml:"listen_host"`
	ListenPort int    `yaml:"listen_port"`
	// BindCapability is DERIVED from ListenPort, never authored: a port below
	// 1024 needs CAP_NET_BIND_SERVICE because the service runs unprivileged.
	BindCapability bool `yaml:"bind_capability"`
	EnableOnBoot   bool `yaml:"enable_on_boot"`
	StartNow       bool `yaml:"start_now"`
	// OpenFirewall allows inbound traffic to ListenPort. Default true: the
	// health check runs over loopback, where the firewall does not apply, so
	// without this an install can report itself healthy while being
	// unreachable from every other machine.
	OpenFirewall bool `yaml:"open_firewall"`
}

ServicePlan covers the unit and how it listens.

type Step

type Step interface {
	ID() string
	// Describe says what applying it would do, for the plan.
	Describe(p Plan) string
	// Status reports whether it is already satisfied. Must not mutate.
	Status(ctx context.Context, p Plan) StepStatus
	// Apply performs it.
	Apply(ctx context.Context, r *Run) error
}

Step is one unit of the install.

func Steps

func Steps(p Plan) []Step

Steps returns the ordered steps for a plan. Order matters and is not configurable: the database must exist before migrations, migrations before the admin user, and the service starts last so it never boots against a schema that is not there.

type StepStatus

type StepStatus struct {
	// Done means the desired state already holds; Apply will be skipped.
	Done bool
	// Detail is shown in the plan, e.g. "already exists" or the version found.
	Detail string
}

StepStatus is the result of a step's idempotence check.

type Support

type Support string

Support states how much confidence the project has in a platform.

const (
	// SupportTested means a CI job runs `openwatch setup` on this platform on
	// every push and asserts the result, and that the platform is blocking in
	// release/gates.toml. The set is whatever that file marks blocking, so
	// this comment names no list: the previous one said "v0.7.0: RHEL 9 only"
	// and was three platforms out of date while supportOf right below it
	// returned SupportTested for four.
	SupportTested Support = "tested"
	// SupportUntested means the family is recognized and the paths are
	// believed correct, but nothing proves it. Requires --allow-untested.
	SupportUntested Support = "untested"
	// SupportUnsupported means setup will not run: unknown family, or a
	// version whose layout differs in ways this code does not model.
	SupportUnsupported Support = "unsupported"
)

Jump to

Keyboard shortcuts

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