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
- func Execute(ctx context.Context, r *Run, planned []PlannedStep) error
- func RenderPlan(w func(string, ...any), p Plan, checks []Check, planned []PlannedStep)
- func RenderSummary(w func(string, ...any), r *Run, receipt string)
- func ResolveSecrets(ctx context.Context, r *Run, prompt func(label string) (string, error)) error
- func WriteReceipt(r *Run, version string) (string, error)
- type AdminPlan
- type Change
- type Check
- type DatabaseMode
- type DatabasePlan
- type Family
- type PauseError
- type Plan
- type PlannedStep
- type Platform
- type Receipt
- type Run
- type Secret
- type SecretSource
- type ServicePlan
- type Step
- type StepStatus
- type Support
Constants ¶
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.
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 ¶
RenderSummary prints what an operator needs after a successful run.
func ResolveSecrets ¶
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.
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 ¶
FatalFailures returns the checks that block the run.
func Preflight ¶
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.
type PauseError ¶
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 ¶
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.
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.
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.
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.
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" )