controller

package
v0.6.1 Latest Latest
Warning

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

Go to latest
Published: Nov 3, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

Documentation

Overview

Package controller is the automated experiment loop: every tick it evaluates each running experiment (promote.Evaluate), persists the verdict for halo-server, and acts on it.

  • rollback: trip the traffic-plane kill switch immediately, open a PR setting status: paused with the evidence, notify.
  • promote: open a PR concluding the experiment (never merged), notify.
  • expired: open a PR concluding the experiment, notify.

Every action is taken at most once per experiment run (StateLog), so repeated ticks never duplicate PRs or notifications. Nothing here merges.

Index

Constants

View Source
const (
	SignatureHeader = "X-Halo-Signature"
	TimestampHeader = "X-Halo-Timestamp"
	// WebhookTolerance is the recommended receiver clock-skew/replay window.
	WebhookTolerance = 5 * time.Minute
)

Webhook authentication. The signature covers the timestamp so a captured request cannot be replayed later:

X-Halo-Timestamp: <unix seconds>
X-Halo-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>

Receivers must recompute the HMAC over the raw body with the timestamp from X-Halo-Timestamp, compare in constant time, and reject timestamps more than WebhookTolerance from their clock (Verify does all of this).

View Source
const (
	KindResolved = "resolved"

	KindExperimentRollback = "experiment.rollback"
	KindExperimentPromote  = "experiment.promote"
	KindExperimentExpired  = "experiment.expired"
	KindExperimentKilled   = "experiment.killed"

	KindRolloutRollback         = "rollout.rollback"
	KindRolloutPause            = "rollout.pause"
	KindRolloutAdvance          = "rollout.advance"
	KindRolloutComplete         = "rollout.complete"
	KindRolloutKilled           = "rollout.killed"
	KindRolloutFrozen           = "rollout.frozen"
	KindRolloutAwaitingApproval = "rollout.awaiting_approval"
)

Event kinds: what per-sink filters (--notify-<sink>-events) select on. An experiment's events are "experiment.<verdict>", a rollout's are "rollout.<verdict or action>", and an all-clear is "resolved".

View Source
const (
	ActionKill = "kill" // detail: "" = enforced, else why it is not known to be
	ActionPR   = "pr"
	// ActionNotify is recorded per channel as ActionNotify+":"+name (see Named);
	// a bare ActionNotify (older logs) means every channel was notified.
	ActionNotify = "notify"
	// ActionRollback marks that this run rolled back (detail: the report JSON);
	// the controller stops evaluating the experiment until it leaves "running".
	ActionRollback = "rollback"
	// ActionHold marks the once-per-run warning that an experiment is killed
	// and therefore not evaluated.
	ActionHold = "hold"
)

Actions the controller takes at most once per experiment run.

View Source
const Actor = "halo-controller"

Actor is the identity the controller records on kills and audit entries.

View Source
const DefaultTickTimeout = 2 * time.Minute

DefaultTickTimeout bounds one tick (all experiments).

View Source
const EventIDHeader = "X-Halo-Event-Id"

EventIDHeader carries Event.ID on webhook deliveries (also in the body).

View Source
const PagerDutyEventsURL = "https://events.pagerduty.com/v2/enqueue"

PagerDutyEventsURL is the Events API v2 endpoint (the EU service region uses https://events.eu.pagerduty.com/v2/enqueue).

View Source
const VerdictDocKind = "verdict"

VerdictDocKind is the document kind of shared verdicts (one per experiment, the verdicts-file row format).

View Source
const VerdictKilled promote.Verdict = "killed"

VerdictKilled is the pseudo-verdict of hold records and of the Event sent when a running experiment is found killed (it is not evaluated).

Variables

View Source
var DefaultRetry = RetryPolicy{Attempts: 3, Base: time.Second, MaxWait: 15 * time.Second, Budget: 30 * time.Second}

DefaultRetry is what the CLI wires: 3 tries, waits of 1s then 2s (or the server's Retry-After, up to 15s), at most 30s in all, inside the tick's own deadline.

View Source
var ErrUnusableKey = errors.New("webhook: HMAC secret shorter than 112 bits cannot be used in FIPS mode")

ErrUnusableKey is a webhook secret Go's FIPS module would refuse (under 14 bytes in FIPS mode). Config load already refuses it (hmackey.Check); this is the last-resort guard for a Webhook built directly, so it is an error, never a panic inside hmac.New.

EventKinds lists every kind the controller emits (resolved last).

PagerDutyPagingKinds is the default event filter of the PagerDuty sink: everything that needs a human now, plus the all-clear.

Functions

func Evidence

func Evidence(rep promote.Report) string

Evidence renders a verdict report as Markdown for PR bodies.

func NotifyFlags added in v0.5.0

func NotifyFlags(fs FlagSet, c *NotifyConfig)

NotifyFlags registers the --notify-* flags on fs, filling c.

func Sign

func Sign(secret []byte, ts int64, body []byte) string

Sign is SignChecked for callers that cannot handle an error: it returns "" (never a valid signature) for an unusable key instead of panicking. Code that can return an error uses SignChecked.

func SignChecked added in v0.5.0

func SignChecked(secret []byte, ts int64, body []byte) (string, error)

SignChecked returns the SignatureHeader value for body sent at unix time ts, or ErrUnusableKey.

func Verify

func Verify(secret []byte, h http.Header, body []byte, now time.Time, tolerance time.Duration) error

Verify is the receiver side: it checks the timestamp header is within tolerance of now and the signature header matches body.

Types

type Controller

type Controller struct {
	Org     func() (*policy.Org, error) // e.g. policy.Load of the served policy dir
	Metrics promote.MetricSource
	Writer  Writer     // nil: no PRs (logged)
	Kill    KillSwitch // nil: rollbacks are not enforced until the PR merges (logged, notified)
	// KillCaveat, when set, is why a successful Kill is not known to be
	// enforced by gateways (e.g. `halo controller run` without
	// --killswitch-served); messages report it instead of claiming enforcement.
	KillCaveat string
	// Kills is read each tick: a killed running experiment is not evaluated
	// (its evidence is all-control) and humans are told once. nil = no check.
	Kills        *KillStore
	Notifier     Notifier // nil: none
	State        *StateLog
	VerdictsPath string // "" = do not persist verdicts
	Clock        func() time.Time
	Log          *slog.Logger
	TickTimeout  time.Duration // default 2m; bounds one whole tick

	// RolloutStateDir holds one hash-chained state file per Rollout ("" =
	// rollouts are not driven); PolicyDir is where step scorecards are read.
	RolloutStateDir, PolicyDir string
	// RolloutStates, when set, replaces RolloutStateDir (the shared store of an HA deployment).
	RolloutStates rollout.StateStore
	// VerdictDocs, when set, replaces VerdictsPath: one VerdictDocKind
	// document per experiment, which every replica's console reads (HA).
	VerdictDocs rollout.Docs
	// Governance, when set, returns halo-server's incident freeze and the
	// approvals recorded for a rollout's live step (ADR-0030). nil: policy
	// freeze windows only, and a step an approval rule covers waits (its
	// approvals live on halo-server).
	Governance func(ctx context.Context, org *policy.Org, r *policy.Rollout) (rollout.Governance, error)
	// contains filtered or unexported fields
}

Controller wires the loop. Org, Metrics and State are required.

func (*Controller) Run

func (c *Controller) Run(ctx context.Context, interval time.Duration)

Run ticks every interval until ctx ends. After a failed tick it retries sooner (15s, 30s, ... capped at interval) so a transient ClickHouse or git error doesn't delay a rollback by a whole interval.

func (*Controller) Tick

func (c *Controller) Tick(ctx context.Context) error

Tick evaluates every running experiment once and acts on the verdicts, then drives active rollouts (see tickRollouts). Per-experiment failures don't stop the others; all are joined in the result.

func (*Controller) WriteMetrics

func (c *Controller) WriteMetrics(w io.Writer)

WriteMetrics writes Prometheus text-format counters.

type Event

type Event struct {
	// ID is the delivery's idempotency key: the same for every attempt at one
	// notification, by any controller replica, so a receiver can drop a
	// duplicate sent across a leader failover (webhook: X-Halo-Event-Id).
	ID string `json:"id,omitempty"`
	// Rollout is set for rollout events; Experiment is then its backing experiment.
	Rollout     string          `json:"rollout,omitempty"`
	Experiment  string          `json:"experiment"`
	Axis        string          `json:"axis,omitempty"`
	Verdict     promote.Verdict `json:"verdict"`
	Reason      string          `json:"reason"`
	Killed      bool            `json:"killed"` // traffic-plane kill switch is active for the experiment
	KillOutcome KillOutcome     `json:"killOutcome,omitempty"`
	KillError   string          `json:"killError,omitempty"` // detail for every non-enforced outcome
	PRURL       string          `json:"prURL,omitempty"`
	PRError     string          `json:"prError,omitempty"`
	Report      promote.Report  `json:"report"`
	At          time.Time       `json:"at"`
	// Resolved marks the all-clear for an earlier event of the same Verdict:
	// the condition that raised it is over (a human merged the pause or
	// advanced the rollout, or the kill was lifted). Only sinks that opt in
	// with the "resolved" event kind receive it (webhook, PagerDuty).
	Resolved bool `json:"resolved,omitempty"`
}

Event is what the controller tells humans about a verdict it acted on.

func (Event) DedupKey added in v0.5.0

func (e Event) DedupKey() string

DedupKey is the stable PagerDuty dedup_key of the condition e reports: one per rollout (or, for a standalone experiment, per experiment) and verdict.

func (Event) Kind added in v0.5.0

func (e Event) Kind() string

Kind is the event's filter kind.

func (Event) PagerDutySeverity added in v0.5.0

func (e Event) PagerDutySeverity() string

PagerDutySeverity maps e to a PagerDuty severity.

critical  a rollback whose exposure continues: the kill switch is not
          enforced (failed, not configured, recorded only, or the
          evidence was not gateway-sourced), so users stay on the
          treatment until a human merges the pause PR
error     a rollback whose kill switch is enforced (mitigated, needs
          follow-up)
warning   a rollout paused on a gate breach, or a killed experiment
          held until someone unkills it
info      anything else (only delivered when an operator opts it in)

func (Event) Text

func (e Event) Text() string

Text is a one-paragraph human summary.

type EventFilter added in v0.5.0

type EventFilter map[string]bool

EventFilter is the set of kinds a sink delivers. nil means every event except resolved (what Slack and the webhook always received); a non-nil set is exact. A resolved event needs "resolved" in the set.

func ParseEventFilter added in v0.5.0

func ParseEventFilter(flag, spec string, allowResolved bool) (EventFilter, error)

ParseEventFilter parses a comma-separated kind list ("" = nil, the default; "all" = every trigger kind). allowResolved is false for sinks that cannot carry an all-clear (Slack, Teams).

func (EventFilter) Allows added in v0.5.0

func (f EventFilter) Allows(e Event) bool

Allows reports whether the sink delivers e.

type FlagSet added in v0.5.0

type FlagSet interface {
	StringVar(p *string, name, value, usage string)
}

FlagSet is the part of flag.FlagSet and pflag.FlagSet NotifyFlags needs, so halo-server and `halo controller run` register identical flags.

type GitWriter

type GitWriter struct {
	RepoDir string // git checkout (or any dir inside one)
	Subdir  string // policy root inside RepoDir
	Base    string // PR base branch; "" = repo default
	Opener  promote.PROpener
}

GitWriter implements Writer with promote.PlanStatus and a promote.PROpener (GHOpener: branch in a temp worktree, git push, gh pr create). It is the one "set experiment status + open PR" implementation: `halo controller run` uses it on the CI checkout; halo-server's server.GitPolicyWriter uses it on its dedicated clone after syncing that clone to origin (under its own lock). It never touches RepoDir's branch, index or working tree.

func (*GitWriter) FindPR added in v0.3.0

func (w *GitWriter) FindPR(ctx context.Context, marker string) (string, bool, error)

FindPR implements PRFinder when the opener is a promote.PRFinder; with another opener it reports not found (the controller then opens anew).

func (*GitWriter) ProposeRollout

func (w *GitWriter) ProposeRollout(ctx context.Context, plan func(string) (*promote.Change, error), branch, title string, body func(string) string) (string, error)

ProposeRollout implements RolloutWriter on this checkout.

func (*GitWriter) ProposeStatus

func (w *GitWriter) ProposeStatus(ctx context.Context, experiment, status, title, body string) (string, error)

type KillFunc

type KillFunc func(ctx context.Context, experiment, by, reason string) (bool, error)

KillFunc adapts a function (e.g. server.KillExperiment, which also audit-logs) to KillSwitch.

func (KillFunc) Kill

func (f KillFunc) Kill(ctx context.Context, experiment, by, reason string) (bool, error)

type KillOutcome

type KillOutcome string

KillOutcome is the typed result of the kill-switch step of a rollback.

const (
	KillEnforced      KillOutcome = "enforced"             // tripped and enforced by gateways
	KillNotGateway    KillOutcome = "not_gateway_evidence" // evidence not gateway-sourced; human decides
	KillNotConfigured KillOutcome = "not_configured"       // no kill switch wired
	KillRecorded      KillOutcome = "recorded_only"        // written, enforcement not guaranteed (KillCaveat)
	KillFailed        KillOutcome = "failed"               // the kill call errored
)

type KillRecord

type KillRecord struct {
	Experiment string    `json:"experiment"`
	Killed     bool      `json:"killed"`
	By         string    `json:"by"`
	Reason     string    `json:"reason,omitempty"`
	At         time.Time `json:"at"`
}

KillRecord is one line of killswitch.jsonl; the last line per experiment wins.

type KillStore

type KillStore struct {
	Now func() time.Time
	// contains filtered or unexported fields
}

KillStore is the persisted kill state halo-server serves to gateways: an append-only JSONL log (killswitch.jsonl in the data dir) replayed into memory. Appends from another process sharing the file (`halo controller run` against halo-server's data dir) are picked up on the next read. ponytail: whole-file re-read when the size changes; kills are rare. Cross-process appends rely on O_APPEND single-line writes (local filesystem).

NewKillStore puts it on a shared log instead (HA): every replica then catches up with the others' kills on each read.

func NewKillStore added in v0.3.0

func NewKillStore(ctx context.Context, o storage.Opener) (*KillStore, error)

NewKillStore opens the "killswitch" log of a shared backend.

func OpenKillStore

func OpenKillStore(dir string) (*KillStore, error)

OpenKillStore opens dir/killswitch.jsonl ("" = memory only).

func (*KillStore) Close

func (s *KillStore) Close() error

Close closes the log file.

func (*KillStore) Freeze added in v0.4.0

func (s *KillStore) Freeze(ctx context.Context, by, reason string) (bool, error)

Freeze sets the incident freeze; changed is false when it was already set.

func (*KillStore) Incident added in v0.4.0

func (s *KillStore) Incident() (*KillRecord, error)

Incident returns the incident freeze, or nil when none is set.

func (*KillStore) Kill

func (s *KillStore) Kill(ctx context.Context, experiment, by, reason string) (bool, error)

Kill implements KillSwitch.

func (*KillStore) Killed

func (s *KillStore) Killed() ([]KillRecord, uint64, error)

Killed returns the active kills sorted by experiment, and the state version.

func (*KillStore) Unfreeze added in v0.4.0

func (s *KillStore) Unfreeze(ctx context.Context, by, reason string) (bool, error)

Unfreeze lifts the incident freeze; changed is false when it was not set.

func (*KillStore) Unkill

func (s *KillStore) Unkill(ctx context.Context, experiment, by, reason string) (bool, error)

Unkill lifts a kill; changed is false when experiment was not killed.

type KillSwitch

type KillSwitch interface {
	// Kill marks experiment killed; changed is false when it already was.
	Kill(ctx context.Context, experiment, by, reason string) (changed bool, err error)
}

KillSwitch trips the traffic-plane kill switch for an experiment.

type Named

type Named interface {
	Name() string
}

Named is implemented by notifiers with a stable channel name. The controller records each channel's delivery separately (ActionNotify + ":" + name), so a failing webhook is retried without re-posting to Slack.

type Notifier

type Notifier interface {
	Notify(ctx context.Context, e Event) error
}

Notifier delivers events. Implementations must bound their own latency.

type Notifiers

type Notifiers []Notifier

Notifiers fans out to every notifier and joins their errors.

func NotifiersFromFiles

func NotifiersFromFiles(slackURLFile, webhookURLFile, webhookSecretFile string) (Notifiers, error)

NotifiersFromFiles builds the Slack and generic-webhook notifiers from secret files (any path may be ""), with no retries. NotifyConfig is the full form.

func (Notifiers) Notify

func (ns Notifiers) Notify(ctx context.Context, e Event) error

func (Notifiers) WriteMetrics added in v0.5.0

func (ns Notifiers) WriteMetrics(w io.Writer)

WriteMetrics writes halo_controller_notifications_total per sink.

type NotifyConfig added in v0.5.0

type NotifyConfig struct {
	SlackURLFile, SlackEvents string
	TeamsURLFile, TeamsEvents string

	WebhookURLFile, WebhookSecretFile, WebhookEvents string

	PagerDutyRoutingKeyFile, PagerDutyEvents string
	PagerDutyURL                             string // "" = PagerDutyEventsURL (EU region, or a test double)

	Retry RetryPolicy // applied to every sink
}

NotifyConfig is the notification setup shared by `halo controller run` and `halo-server --controller`. Every secret (a webhook URL embeds a token; a routing key is one) is read from a file, never from a flag value, and no error echoes one. A sink is on when its URL/key file is set.

func (NotifyConfig) Build added in v0.5.0

func (c NotifyConfig) Build() (Notifiers, error)

Build returns one Sink per configured channel, in a stable order.

type PRFinder added in v0.3.0

type PRFinder interface {
	FindPR(ctx context.Context, marker string) (url string, found bool, err error)
}

PRFinder is implemented by PR writers that can find a pull request they opened by a marker embedded in its body (GitWriter, through a promote.PRFinder opener). The controller uses it when an intent shows a PR may already have been opened by a deposed leader.

type PagerDuty added in v0.5.0

type PagerDuty struct {
	RoutingKey string
	URL        string // default PagerDutyEventsURL
	Source     string // payload.source, the affected system (default "halos-controller")
	Client     *http.Client
	Retry      RetryPolicy // zero: one attempt
}

PagerDuty raises and clears incidents through the Events API v2.

A paging event is a "trigger" with a stable dedup_key, so a repeat for the same rollout or experiment folds into one incident; the all-clear (the condition ended: a human merged the pause or advanced the rollout, or the kill was lifted) is a "resolve" with the same dedup_key. Informational events (a PR opened, a promotion proposed, a freeze) do not page: the default event filter excludes them, and they map to severity "info" if an operator opts them in.

The routing key is a secret: it travels only in the request body, and no error or log line contains it.

func (PagerDuty) Name added in v0.5.0

func (PagerDuty) Name() string

Name implements Named.

func (PagerDuty) Notify added in v0.5.0

func (p PagerDuty) Notify(ctx context.Context, e Event) error

type RetryPolicy added in v0.5.0

type RetryPolicy struct {
	Attempts int           // total tries including the first; <= 1: no retry
	Base     time.Duration // wait before the second try; doubles each retry
	MaxWait  time.Duration // cap on one wait; a longer Retry-After ends the retrying
	Budget   time.Duration // cap on the whole delivery, waits included; 0: unbounded

	// Sleep waits d or until ctx ends (default: a real timer); Now is the
	// clock for Retry-After dates and Budget (default time.Now). Both are
	// injectable so tests need no real waiting.
	Sleep func(ctx context.Context, d time.Duration) error
	Now   func() time.Time
}

RetryPolicy bounds how hard one delivery tries before giving up. The zero value makes a single attempt (what a bare Slack{} or Webhook{} does). A delivery that still fails is not lost: the controller records a notification only once a channel accepted it, so the next tick tries again.

Retried: transport errors and timeouts, HTTP 429 and 5xx. Never retried: other 4xx (a bad URL, routing key or payload does not heal) and a cancelled context.

type RolloutWriter

type RolloutWriter interface {
	ProposeRollout(ctx context.Context, plan func(dir string) (*promote.Change, error), branch, title string, body func(patch string) string) (prURL string, err error)
}

RolloutWriter opens a rollout PR: plan builds the change in the writer's checkout dir, body renders the PR body around its patch. GitWriter and server.GitPolicyWriter implement it. It must never merge.

type Sink added in v0.5.0

type Sink struct {
	SinkName string
	Notifier Notifier
	Events   EventFilter
	// contains filtered or unexported fields
}

Sink is one named delivery channel: a Notifier behind an event filter, with delivery counters. The name is the channel key in the controller's state (ActionNotify + ":" + name), so a failing sink is retried without re-posting to the others.

func (*Sink) Name added in v0.5.0

func (s *Sink) Name() string

Name implements Named.

func (*Sink) Notify added in v0.5.0

func (s *Sink) Notify(ctx context.Context, e Event) error

Notify delivers e unless the filter drops it (a dropped event reports success: it is not an error and must not be retried).

func (*Sink) ResolvesFor added in v0.5.0

func (s *Sink) ResolvesFor(trigger Event) bool

ResolvesFor reports whether this sink wants the all-clear for trigger: it opted into "resolved" and also receives trigger's kind (so a resolve is never sent for an incident the sink never opened).

type Slack

type Slack struct {
	URL    string
	Client *http.Client
	Retry  RetryPolicy // zero: one attempt
}

Slack posts to a Slack(-compatible) incoming webhook.

func (Slack) Name

func (Slack) Name() string

Name implements Named.

func (Slack) Notify

func (s Slack) Notify(ctx context.Context, e Event) error

type StateLog

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

StateLog remembers which (experiment, verdict, action) the controller has already done, so repeated ticks never open a second PR or send a second notification. An experiment's entries are forgotten once it is seen not running (a human merged the pause/conclude), so a later restart starts clean. ponytail: no compaction; one line per action per experiment run.

func OpenStateLog

func OpenStateLog(dir string) (*StateLog, error)

OpenStateLog opens dir/controller-state.jsonl ("" = memory only).

func OpenStateLogOn added in v0.3.0

func OpenStateLogOn(ctx context.Context, o storage.Opener) (*StateLog, error)

OpenStateLogOn opens the controller-state log of o (for an HA deployment, a log fenced by the leader lease, so a deposed leader cannot record).

func (*StateLog) Close

func (l *StateLog) Close() error

Close closes the log.

func (*StateLog) Done

func (l *StateLog) Done(exp, verdict, action string) (string, bool)

Done reports whether the action was already taken, and its detail.

func (*StateLog) Names added in v0.5.0

func (l *StateLog) Names() []string

Names lists the experiments (and "rollout:<name>" trackers) with entries since their last reset, sorted.

func (*StateLog) Record

func (l *StateLog) Record(exp, verdict, action, detail string, at time.Time) error

Record marks an action as taken.

func (*StateLog) Reset

func (l *StateLog) Reset(exp string, at time.Time) error

Reset forgets exp's actions; a no-op (nothing written) when there are none.

type Teams added in v0.5.0

type Teams struct {
	URL    string
	Client *http.Client
	Retry  RetryPolicy // zero: one attempt
}

Teams posts an Adaptive Card to a Microsoft Teams Workflows (Power Automate) webhook: the "When a Teams webhook request is received" trigger (the "Send webhook alerts to a channel" template). It expects a {"type":"message","attachments":[{"contentType": "application/vnd.microsoft.card.adaptive","content":<card>}]} body, which is what this sends.

This is deliberately not the legacy Office 365 connector ("Incoming Webhook", MessageCard JSON): Microsoft retired Office 365 connectors in Teams, and a legacy URL no longer delivers. NewTeams refuses those hosts so the failure is loud at startup instead of silent at 3 a.m.

func (Teams) Name added in v0.5.0

func (Teams) Name() string

Name implements Named.

func (Teams) Notify added in v0.5.0

func (t Teams) Notify(ctx context.Context, e Event) error

type Webhook

type Webhook struct {
	URL    string
	Secret []byte
	Client *http.Client
	Now    func() time.Time // default time.Now
	Retry  RetryPolicy      // zero: one attempt
}

Webhook posts the Event JSON, signed with Secret when set.

func (Webhook) Name

func (Webhook) Name() string

Name implements Named.

func (Webhook) Notify

func (w Webhook) Notify(ctx context.Context, e Event) error

type Writer

type Writer interface {
	ProposeStatus(ctx context.Context, experiment, status, title, body string) (prURL string, err error)
}

Writer opens a policy-repo PR setting an experiment's status. It must never merge.

Jump to

Keyboard shortcuts

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