The loop now consults quota and host state before every dispatch and
DEFERS gated work with a logged reason instead of letting turns die at
the provider (the 2026-08-28 19:00 quota-wall failure mode, replayed as
a test). Adds internal/quota: 5h/weekly credit buckets (provider poll
when z.ai ships an endpoint - fake-server tested - else locally
estimated from the documented credit formula), TZ-aware peak window
(default 01:00-05:00 America/Chicago weekdays, matching the documented
z.ai peak Mon-Fri 14:00-18:00 Singapore), block/defer thresholds, a
read-only load/mem/disk/IO-PSI monitor, an optional redis shared-state
hop (stdlib RESP2 mini-client) so all instances of an account
coordinate, per-class token+credit accounting in loop.jsonl, and
`harness quota status|probe|gate`. Config: [quota] + [resources]
sections; README runbook covers the redis container and deploy-time
cgroup enforcement.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
709 lines
21 KiB
Go
709 lines
21 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config is the full harness.toml surface. Defaults live in Default();
|
|
// apply() overlays the parsed document; Validate() enforces invariants.
|
|
type Config struct {
|
|
Vertical string
|
|
WorkRoot string
|
|
ReportDir string
|
|
Loop LoopConfig
|
|
Redmine RedmineConfig
|
|
LiteLLM LiteLLMConfig
|
|
Models ModelsConfig
|
|
Bash BashConfig
|
|
Demo DemoConfig
|
|
Events EventsConfig
|
|
Serve ServeConfig
|
|
KeyProxy KeyProxyConfig
|
|
Gitea GiteaConfig
|
|
Quota QuotaConfig
|
|
Resources ResourcesConfig
|
|
}
|
|
|
|
// LoopConfig is both the per-turn bound and the `harness loop` daemon
|
|
// cadence + state location.
|
|
type LoopConfig struct {
|
|
MaxRounds int
|
|
PollIntervalSecs int // loop: Redmine scan interval (default 120)
|
|
StateDir string // loop: append-only loop.jsonl + dedup index
|
|
}
|
|
|
|
type RedmineConfig struct {
|
|
URL string
|
|
KeyRef string
|
|
ScopeQuery string
|
|
ScopeQueryID int
|
|
ClassField string
|
|
DefaultClass string
|
|
Limit int
|
|
// StatusMap: issue status name -> status name to set after a REPORT
|
|
// lands (loop path only; e.g. [redmine.status_map] "In Progress" =
|
|
// "Done"). Empty map = leave status alone.
|
|
StatusMap map[string]string
|
|
}
|
|
|
|
type LiteLLMConfig struct {
|
|
BaseURL string
|
|
KeyRef string
|
|
TimeoutSecs int
|
|
MaxRetries int
|
|
}
|
|
|
|
type ModelsConfig struct {
|
|
// Tiers maps tier aliases (mopac-study, ...) to concrete proxy models.
|
|
Tiers map[string]string
|
|
// Classes maps task classes (study, code, ...) to tier aliases.
|
|
Classes map[string]string
|
|
// DefaultTier is used when a task carries no class.
|
|
DefaultTier string
|
|
}
|
|
|
|
type BashConfig struct {
|
|
Enabled bool
|
|
Allow []string
|
|
Deny []string
|
|
DefaultAllow bool
|
|
TimeoutSecs int
|
|
MaxOutputBytes int
|
|
}
|
|
|
|
type DemoConfig struct {
|
|
ID string
|
|
Subject string
|
|
Prompt string
|
|
Class string
|
|
}
|
|
|
|
// ServeConfig is the `harness serve` OpenAI-compatible front door (the
|
|
// OpenWebUI connection). Own port — coexists with [events].
|
|
type ServeConfig struct {
|
|
Listen string // bind address (default ":8090"; publish via docker -p)
|
|
VKeyRef string // bearer vkey ref; the key OWUI connections present
|
|
EnabledModels []string // optional subset of the catalog (mopac-<class>); empty = all classes
|
|
}
|
|
|
|
// KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs
|
|
// POST here. All hosts/paths live in this config, never in code.
|
|
type KeyProxyConfig struct {
|
|
URL string // hop base url (e.g. http://127.0.0.1:8082)
|
|
TokenRef string // local ref (env:/file:/literal:) holding the bearer token
|
|
CacheTTLSecs int // in-memory mpk resolution cache (default 60)
|
|
}
|
|
|
|
// GiteaConfig is the optional REPORT-commit step (off by default). When
|
|
// commit_reports is true the loop commits each REPORT file to the
|
|
// configured repo right after writing it.
|
|
type GiteaConfig struct {
|
|
URL string
|
|
KeyRef string
|
|
Owner string
|
|
Repo string
|
|
Branch string
|
|
CommitReports bool
|
|
}
|
|
|
|
// QuotaConfig is the z.ai coding-plan quota gate: bucket limits, polling,
|
|
// back-pressure thresholds, the peak-hour schedule, and the optional
|
|
// shared-state hop (redis container) for multi-instance coordination.
|
|
// The quota gate NEVER hard-fails the loop; when it cannot see quota state
|
|
// it degrades to permissive (allow) with a logged caveat.
|
|
type QuotaConfig struct {
|
|
Enabled bool
|
|
|
|
// Account labels this instance's z.ai plan in the shared state so the
|
|
// nine harness instances across two hosts do not cross-contaminate.
|
|
Account string
|
|
|
|
// Plan credit buckets (docs: Lite 2k/10k, Pro 12k/60k, Max 28k/140k).
|
|
Plan5hCredits float64
|
|
PlanWeeklyCredits float64
|
|
|
|
// UsageURL + KeyRef: the z.ai usage/limits endpoint (bearer auth; the
|
|
// key is a ref, resolved at call time, never logged). Empty = no
|
|
// upstream polling; the gate runs on locally estimated consumption.
|
|
UsageURL string
|
|
KeyRef string
|
|
PollIntervalSecs int
|
|
|
|
// Back-pressure thresholds, percent of bucket used. defer_at: heavy
|
|
// classes defer, flash-tier continue. block_at: everything defers.
|
|
DeferAtPct float64
|
|
BlockAtPct float64
|
|
|
|
// Peak window (z.ai charges 3x-ish during peak; off-peak is 50% off).
|
|
// Defaults verified against the z.ai docs: peak = Mon-Fri 14:00-18:00
|
|
// Singapore (UTC+8) == 01:00-05:00 America/Chicago in winter.
|
|
PeakStart string // "HH:MM" in Timezone
|
|
PeakEnd string
|
|
Timezone string // IANA name; default America/Chicago (CST)
|
|
PeakWeekdaysOnly bool
|
|
// PeakClasses: task classes allowed to run inside the peak window
|
|
// (the LLM-lite / flash tier); every other class defers with a reason.
|
|
PeakClasses []string
|
|
|
|
// RedisURL: shared quota state for all instances of this account
|
|
// (docker container; no host packages). Empty = local-only estimate.
|
|
RedisURL string
|
|
}
|
|
|
|
// ResourcesConfig is the read-only system resource gate (Redmine 491):
|
|
// load average, memory available, work-root disk free, IO pressure. The
|
|
// loop defers dispatch while the host is busy; enforcement via cgroups is
|
|
// a deploy-time concern (see the runbook in README).
|
|
type ResourcesConfig struct {
|
|
Enabled bool
|
|
MaxLoadAvg float64 // 1m load average
|
|
MinMemAvailableMB float64
|
|
MinDiskFreeMB float64 // work_root filesystem
|
|
MaxIODelayPct float64 // /proc/pressure/io "some avg60" percent
|
|
// ProcRoot/SysRoot are seams for tests; default /proc and /sys.
|
|
ProcRoot string
|
|
SysRoot string
|
|
}
|
|
|
|
// EventsConfig is the `harness events` webhook receiver surface.
|
|
type EventsConfig struct {
|
|
Listen string // bind address (publish via docker -p)
|
|
StateDir string // append-only events.jsonl + dedup index
|
|
Redmine EventSourceConfig
|
|
Discourse EventSourceConfig
|
|
Gitea EventSourceConfig
|
|
}
|
|
|
|
// EventSourceConfig is one provider's webhook verification setup. Secrets
|
|
// are refs only (env:/file:/literal:); values never live in this file, are
|
|
// never logged, and never reach the event log.
|
|
type EventSourceConfig struct {
|
|
SecretRef string
|
|
SecretHeader string // shared-secret header name; gitea ignores it (HMAC)
|
|
}
|
|
|
|
// Default returns the built-in defaults for every field.
|
|
func Default() *Config {
|
|
return &Config{
|
|
WorkRoot: ".",
|
|
ReportDir: "reports",
|
|
Loop: LoopConfig{
|
|
MaxRounds: 8,
|
|
PollIntervalSecs: 120,
|
|
StateDir: "state/loop",
|
|
},
|
|
Redmine: RedmineConfig{
|
|
ClassField: "Class",
|
|
DefaultClass: "primary",
|
|
Limit: 50,
|
|
StatusMap: map[string]string{},
|
|
},
|
|
LiteLLM: LiteLLMConfig{TimeoutSecs: 120, MaxRetries: 2},
|
|
Models: ModelsConfig{
|
|
Tiers: map[string]string{},
|
|
Classes: map[string]string{},
|
|
DefaultTier: "mopac-primary",
|
|
},
|
|
Bash: BashConfig{Enabled: true, TimeoutSecs: 60, MaxOutputBytes: 100_000},
|
|
Events: EventsConfig{
|
|
Listen: ":4100",
|
|
StateDir: "state/events",
|
|
Redmine: EventSourceConfig{
|
|
SecretHeader: "X-Redmine-Webhook-Secret",
|
|
},
|
|
Discourse: EventSourceConfig{
|
|
SecretHeader: "X-Discourse-Webhook-Secret",
|
|
},
|
|
},
|
|
Serve: ServeConfig{Listen: ":8090"},
|
|
KeyProxy: KeyProxyConfig{CacheTTLSecs: 60},
|
|
Quota: QuotaConfig{
|
|
Enabled: false, // opt-in per instance until verified live
|
|
Plan5hCredits: 28000, // Max plan
|
|
PlanWeeklyCredits: 140000,
|
|
PollIntervalSecs: 300,
|
|
DeferAtPct: 85,
|
|
BlockAtPct: 95,
|
|
PeakStart: "01:00",
|
|
PeakEnd: "05:00",
|
|
Timezone: "America/Chicago",
|
|
PeakWeekdaysOnly: true,
|
|
PeakClasses: []string{"study", "read"},
|
|
},
|
|
Resources: ResourcesConfig{
|
|
Enabled: false,
|
|
MaxLoadAvg: 6.0,
|
|
MinMemAvailableMB: 2048,
|
|
MinDiskFreeMB: 5120,
|
|
MaxIODelayPct: 90.0,
|
|
ProcRoot: "/proc",
|
|
SysRoot: "/sys",
|
|
},
|
|
Demo: DemoConfig{
|
|
ID: "demo-1",
|
|
Subject: "MVP demo: GLM self-description",
|
|
Prompt: "tell me about yourself",
|
|
Class: "primary",
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Config) apply(doc TOMLDoc) error {
|
|
if v, ok := doc.String("vertical"); ok {
|
|
c.Vertical = v
|
|
}
|
|
if v, ok := doc.String("work_root"); ok {
|
|
c.WorkRoot = v
|
|
}
|
|
if v, ok := doc.String("report_dir"); ok {
|
|
c.ReportDir = v
|
|
}
|
|
if v, ok := doc.Table("loop").Int("max_rounds"); ok {
|
|
c.Loop.MaxRounds = int(v)
|
|
}
|
|
if v, ok := doc.Table("loop").Int("poll_interval_secs"); ok {
|
|
c.Loop.PollIntervalSecs = int(v)
|
|
}
|
|
if v, ok := doc.Table("loop").String("state_dir"); ok {
|
|
c.Loop.StateDir = v
|
|
}
|
|
|
|
rm := doc.Table("redmine")
|
|
if v, ok := rm.String("url"); ok {
|
|
c.Redmine.URL = v
|
|
}
|
|
if v, ok := rm.String("key_ref"); ok {
|
|
c.Redmine.KeyRef = v
|
|
}
|
|
if v, ok := rm.String("scope_query"); ok {
|
|
c.Redmine.ScopeQuery = v
|
|
}
|
|
if v, ok := rm.Int("scope_query_id"); ok {
|
|
c.Redmine.ScopeQueryID = int(v)
|
|
}
|
|
if v, ok := rm.String("class_field"); ok {
|
|
c.Redmine.ClassField = v
|
|
}
|
|
if v, ok := rm.String("default_class"); ok {
|
|
c.Redmine.DefaultClass = v
|
|
}
|
|
if v, ok := rm.Int("limit"); ok {
|
|
c.Redmine.Limit = int(v)
|
|
}
|
|
for _, k := range doc.Table("redmine", "status_map").Keys() {
|
|
if v, ok := doc.Table("redmine", "status_map").String(k); ok {
|
|
if c.Redmine.StatusMap == nil {
|
|
c.Redmine.StatusMap = map[string]string{}
|
|
}
|
|
c.Redmine.StatusMap[k] = v
|
|
}
|
|
}
|
|
|
|
lt := doc.Table("litellm")
|
|
if v, ok := lt.String("base_url"); ok {
|
|
c.LiteLLM.BaseURL = v
|
|
}
|
|
if v, ok := lt.String("key_ref"); ok {
|
|
c.LiteLLM.KeyRef = v
|
|
}
|
|
if v, ok := lt.Int("timeout_secs"); ok {
|
|
c.LiteLLM.TimeoutSecs = int(v)
|
|
}
|
|
if v, ok := lt.Int("max_retries"); ok {
|
|
c.LiteLLM.MaxRetries = int(v)
|
|
}
|
|
|
|
md := doc.Table("models")
|
|
for _, k := range md.Keys() {
|
|
if k == "default_tier" {
|
|
continue
|
|
}
|
|
if v, ok := md.String(k); ok {
|
|
c.Models.Tiers[k] = v
|
|
}
|
|
}
|
|
if v, ok := md.String("default_tier"); ok {
|
|
c.Models.DefaultTier = v
|
|
}
|
|
cls := doc.Table("models", "classes")
|
|
for _, k := range cls.Keys() {
|
|
if v, ok := cls.String(k); ok {
|
|
c.Models.Classes[k] = v
|
|
}
|
|
}
|
|
|
|
bt := doc.Table("tools", "bash")
|
|
if v, ok := bt.Bool("enabled"); ok {
|
|
c.Bash.Enabled = v
|
|
}
|
|
if v, ok := bt.StringList("allow"); ok {
|
|
c.Bash.Allow = v
|
|
}
|
|
if v, ok := bt.StringList("deny"); ok {
|
|
c.Bash.Deny = v
|
|
}
|
|
if v, ok := bt.String("default"); ok {
|
|
switch v {
|
|
case "allow":
|
|
c.Bash.DefaultAllow = true
|
|
case "deny":
|
|
c.Bash.DefaultAllow = false
|
|
default:
|
|
return fmt.Errorf("[tools.bash]: default must be \"allow\" or \"deny\", got %q", v)
|
|
}
|
|
}
|
|
if v, ok := bt.Int("timeout_secs"); ok {
|
|
c.Bash.TimeoutSecs = int(v)
|
|
}
|
|
if v, ok := bt.Int("max_output_bytes"); ok {
|
|
c.Bash.MaxOutputBytes = int(v)
|
|
}
|
|
|
|
dm := doc.Table("demo")
|
|
if v, ok := dm.String("id"); ok {
|
|
c.Demo.ID = v
|
|
}
|
|
if v, ok := dm.String("subject"); ok {
|
|
c.Demo.Subject = v
|
|
}
|
|
if v, ok := dm.String("prompt"); ok {
|
|
c.Demo.Prompt = v
|
|
}
|
|
if v, ok := dm.String("class"); ok {
|
|
c.Demo.Class = v
|
|
}
|
|
|
|
kp := doc.Table("keyproxy")
|
|
if v, ok := kp.String("url"); ok {
|
|
c.KeyProxy.URL = v
|
|
}
|
|
if v, ok := kp.String("token_ref"); ok {
|
|
c.KeyProxy.TokenRef = v
|
|
}
|
|
if v, ok := kp.Int("cache_ttl_secs"); ok {
|
|
c.KeyProxy.CacheTTLSecs = int(v)
|
|
}
|
|
|
|
gt := doc.Table("gitea")
|
|
if v, ok := gt.String("url"); ok {
|
|
c.Gitea.URL = v
|
|
}
|
|
if v, ok := gt.String("key_ref"); ok {
|
|
c.Gitea.KeyRef = v
|
|
}
|
|
if v, ok := gt.String("owner"); ok {
|
|
c.Gitea.Owner = v
|
|
}
|
|
if v, ok := gt.String("repo"); ok {
|
|
c.Gitea.Repo = v
|
|
}
|
|
if v, ok := gt.String("branch"); ok {
|
|
c.Gitea.Branch = v
|
|
}
|
|
if v, ok := gt.Bool("commit_reports"); ok {
|
|
c.Gitea.CommitReports = v
|
|
}
|
|
|
|
ev := doc.Table("events")
|
|
if v, ok := ev.String("listen"); ok {
|
|
c.Events.Listen = v
|
|
}
|
|
if v, ok := ev.String("state_dir"); ok {
|
|
c.Events.StateDir = v
|
|
}
|
|
applyEventSource(&c.Events.Redmine, doc.Table("events", "redmine"))
|
|
applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse"))
|
|
applyEventSource(&c.Events.Gitea, doc.Table("events", "gitea"))
|
|
|
|
sv := doc.Table("serve")
|
|
if v, ok := sv.String("listen"); ok {
|
|
c.Serve.Listen = v
|
|
}
|
|
if v, ok := sv.String("vkey_ref"); ok {
|
|
c.Serve.VKeyRef = v
|
|
}
|
|
if v, ok := sv.StringList("enabled_models"); ok {
|
|
c.Serve.EnabledModels = v
|
|
}
|
|
|
|
qt := doc.Table("quota")
|
|
if v, ok := qt.Bool("enabled"); ok {
|
|
c.Quota.Enabled = v
|
|
}
|
|
if v, ok := qt.String("account"); ok {
|
|
c.Quota.Account = v
|
|
}
|
|
if v, ok := qt.Float("plan_5h_credits"); ok {
|
|
c.Quota.Plan5hCredits = v
|
|
}
|
|
if v, ok := qt.Float("plan_weekly_credits"); ok {
|
|
c.Quota.PlanWeeklyCredits = v
|
|
}
|
|
if v, ok := qt.String("usage_url"); ok {
|
|
c.Quota.UsageURL = v
|
|
}
|
|
if v, ok := qt.String("key_ref"); ok {
|
|
c.Quota.KeyRef = v
|
|
}
|
|
if v, ok := qt.Int("poll_interval_secs"); ok {
|
|
c.Quota.PollIntervalSecs = int(v)
|
|
}
|
|
if v, ok := qt.Float("defer_at_pct"); ok {
|
|
c.Quota.DeferAtPct = v
|
|
}
|
|
if v, ok := qt.Float("block_at_pct"); ok {
|
|
c.Quota.BlockAtPct = v
|
|
}
|
|
if v, ok := qt.String("peak_start"); ok {
|
|
c.Quota.PeakStart = v
|
|
}
|
|
if v, ok := qt.String("peak_end"); ok {
|
|
c.Quota.PeakEnd = v
|
|
}
|
|
if v, ok := qt.String("timezone"); ok {
|
|
c.Quota.Timezone = v
|
|
}
|
|
if v, ok := qt.Bool("peak_weekdays_only"); ok {
|
|
c.Quota.PeakWeekdaysOnly = v
|
|
}
|
|
if v, ok := qt.StringList("peak_classes"); ok {
|
|
c.Quota.PeakClasses = v
|
|
}
|
|
if v, ok := qt.String("redis_url"); ok {
|
|
c.Quota.RedisURL = v
|
|
}
|
|
|
|
rs := doc.Table("resources")
|
|
if v, ok := rs.Bool("enabled"); ok {
|
|
c.Resources.Enabled = v
|
|
}
|
|
if v, ok := rs.Float("max_load_avg"); ok {
|
|
c.Resources.MaxLoadAvg = v
|
|
}
|
|
if v, ok := rs.Float("min_mem_available_mb"); ok {
|
|
c.Resources.MinMemAvailableMB = v
|
|
}
|
|
if v, ok := rs.Float("min_disk_free_mb"); ok {
|
|
c.Resources.MinDiskFreeMB = v
|
|
}
|
|
if v, ok := rs.Float("max_io_delay_pct"); ok {
|
|
c.Resources.MaxIODelayPct = v
|
|
}
|
|
if v, ok := rs.String("proc_root"); ok {
|
|
c.Resources.ProcRoot = v
|
|
}
|
|
if v, ok := rs.String("sys_root"); ok {
|
|
c.Resources.SysRoot = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyEventSource(dst *EventSourceConfig, src TOMLDoc) {
|
|
if v, ok := src.String("secret_ref"); ok {
|
|
dst.SecretRef = v
|
|
}
|
|
if v, ok := src.String("secret_header"); ok {
|
|
dst.SecretHeader = v
|
|
}
|
|
}
|
|
|
|
// Validate enforces the invariants the conductor relies on.
|
|
func (c *Config) Validate() error {
|
|
if c.Vertical == "" {
|
|
return fmt.Errorf("[core]: vertical is required")
|
|
}
|
|
if c.Loop.MaxRounds < 1 {
|
|
return fmt.Errorf("[loop]: max_rounds must be >= 1")
|
|
}
|
|
if c.LiteLLM.BaseURL == "" {
|
|
return fmt.Errorf("[litellm]: base_url is required")
|
|
}
|
|
if c.LiteLLM.KeyRef == "" {
|
|
return fmt.Errorf("[litellm]: key_ref is required (env:NAME, file:PATH, or literal:VALUE)")
|
|
}
|
|
if err := CheckKeyRef(c.LiteLLM.KeyRef); err != nil {
|
|
return fmt.Errorf("[litellm]: %w", err)
|
|
}
|
|
if c.LiteLLM.TimeoutSecs < 1 || c.LiteLLM.MaxRetries < 0 {
|
|
return fmt.Errorf("[litellm]: bad timeout_secs/max_retries")
|
|
}
|
|
|
|
// Redmine is optional (demo-only configs); if any piece is set, the rest
|
|
// of the minimum set must be too.
|
|
rc := c.Redmine
|
|
if rc.URL != "" || rc.KeyRef != "" || rc.ScopeQuery != "" || rc.ScopeQueryID != 0 {
|
|
if rc.URL == "" {
|
|
return fmt.Errorf("[redmine]: url is required when redmine is configured")
|
|
}
|
|
if rc.KeyRef == "" {
|
|
return fmt.Errorf("[redmine]: key_ref is required when redmine is configured")
|
|
}
|
|
if err := CheckKeyRef(rc.KeyRef); err != nil {
|
|
return fmt.Errorf("[redmine]: %w", err)
|
|
}
|
|
if rc.ScopeQuery == "" && rc.ScopeQueryID == 0 {
|
|
return fmt.Errorf("[redmine]: scope_query or scope_query_id is required")
|
|
}
|
|
}
|
|
|
|
if len(c.Models.Tiers) == 0 {
|
|
return fmt.Errorf("[models]: at least one tier alias is required")
|
|
}
|
|
if _, ok := c.Models.Tiers[c.Models.DefaultTier]; !ok {
|
|
return fmt.Errorf("[models]: default_tier %q has no entry in [models]", c.Models.DefaultTier)
|
|
}
|
|
for class, tier := range c.Models.Classes {
|
|
if _, ok := c.Models.Tiers[tier]; !ok {
|
|
return fmt.Errorf("[models.classes]: class %q points at unknown tier %q", class, tier)
|
|
}
|
|
}
|
|
|
|
if c.Bash.TimeoutSecs < 1 || c.Bash.MaxOutputBytes < 1 {
|
|
return fmt.Errorf("[tools.bash]: bad timeout_secs/max_output_bytes")
|
|
}
|
|
if c.Demo.Prompt == "" {
|
|
return fmt.Errorf("[demo]: prompt is required")
|
|
}
|
|
|
|
// Events are optional (`once` configs need none); refs that ARE set must
|
|
// be well-formed so the receiver fails at startup, not mid-webhook.
|
|
for name, src := range map[string]EventSourceConfig{
|
|
"redmine": c.Events.Redmine,
|
|
"discourse": c.Events.Discourse,
|
|
"gitea": c.Events.Gitea,
|
|
} {
|
|
if src.SecretRef == "" {
|
|
continue
|
|
}
|
|
if err := CheckKeyRef(src.SecretRef); err != nil {
|
|
return fmt.Errorf("[events.%s]: %w", name, err)
|
|
}
|
|
}
|
|
if c.Events.Listen == "" || c.Events.StateDir == "" {
|
|
return fmt.Errorf("[events]: listen and state_dir must not be empty")
|
|
}
|
|
|
|
// Serve is optional (`once`/`loop` configs need none); a vkey that IS
|
|
// set must be well-formed, and enabled_models entries must name real
|
|
// catalog models (mopac-<class>) so the front door fails at startup.
|
|
if c.Serve.VKeyRef != "" {
|
|
if err := CheckKeyRef(c.Serve.VKeyRef); err != nil {
|
|
return fmt.Errorf("[serve]: %w", err)
|
|
}
|
|
}
|
|
for _, m := range c.Serve.EnabledModels {
|
|
class := strings.TrimPrefix(m, "mopac-")
|
|
if !strings.HasPrefix(m, "mopac-") || class == "" || c.Models.Classes[class] == "" {
|
|
return fmt.Errorf("[serve]: enabled_models entry %q is not servable (must be mopac-<class> from [models.classes])", m)
|
|
}
|
|
}
|
|
if c.Serve.Listen == "" {
|
|
return fmt.Errorf("[serve]: listen must not be empty")
|
|
}
|
|
|
|
if c.Loop.PollIntervalSecs < 1 {
|
|
return fmt.Errorf("[loop]: poll_interval_secs must be >= 1")
|
|
}
|
|
if c.Loop.StateDir == "" {
|
|
return fmt.Errorf("[loop]: state_dir must not be empty")
|
|
}
|
|
|
|
// Keyproxy is optional; if any piece is set, url + token_ref must be.
|
|
if c.KeyProxy.URL != "" || c.KeyProxy.TokenRef != "" {
|
|
if c.KeyProxy.URL == "" {
|
|
return fmt.Errorf("[keyproxy]: url is required when keyproxy is configured")
|
|
}
|
|
if c.KeyProxy.TokenRef == "" {
|
|
return fmt.Errorf("[keyproxy]: token_ref is required when keyproxy is configured (the bearer token for /v1/resolve)")
|
|
}
|
|
if err := CheckKeyRef(c.KeyProxy.TokenRef); err != nil {
|
|
return fmt.Errorf("[keyproxy]: %w", err)
|
|
}
|
|
if strings.HasPrefix(c.KeyProxy.TokenRef, "mpk:") {
|
|
return fmt.Errorf("[keyproxy]: token_ref must be a local ref (env:/file:/literal:), not mpk:")
|
|
}
|
|
if c.KeyProxy.CacheTTLSecs < 1 {
|
|
return fmt.Errorf("[keyproxy]: bad cache_ttl_secs")
|
|
}
|
|
}
|
|
|
|
// Gitea REPORT commit is optional; when on, the minimum set must be set.
|
|
if c.Gitea.CommitReports {
|
|
if c.Gitea.URL == "" || c.Gitea.KeyRef == "" || c.Gitea.Owner == "" || c.Gitea.Repo == "" {
|
|
return fmt.Errorf("[gitea]: url, key_ref, owner and repo are required when commit_reports = true")
|
|
}
|
|
if err := CheckKeyRef(c.Gitea.KeyRef); err != nil {
|
|
return fmt.Errorf("[gitea]: %w", err)
|
|
}
|
|
}
|
|
|
|
// The quota gate is optional; when on, buckets, thresholds and the peak
|
|
// window must be coherent so the gate never divides by zero or wraps
|
|
// nonsense windows. usage_url without key_ref is an error (bearer-only).
|
|
if c.Quota.Enabled {
|
|
if c.Quota.Plan5hCredits <= 0 || c.Quota.PlanWeeklyCredits <= 0 {
|
|
return fmt.Errorf("[quota]: plan_5h_credits and plan_weekly_credits must be > 0")
|
|
}
|
|
if c.Quota.PollIntervalSecs < 1 {
|
|
return fmt.Errorf("[quota]: poll_interval_secs must be >= 1")
|
|
}
|
|
if !(c.Quota.DeferAtPct > 0 && c.Quota.DeferAtPct < 100) || !(c.Quota.BlockAtPct > 0 && c.Quota.BlockAtPct <= 100) {
|
|
return fmt.Errorf("[quota]: defer_at_pct and block_at_pct must be in (0,100]")
|
|
}
|
|
if c.Quota.DeferAtPct > c.Quota.BlockAtPct {
|
|
return fmt.Errorf("[quota]: defer_at_pct (%.0f) must be <= block_at_pct (%.0f)", c.Quota.DeferAtPct, c.Quota.BlockAtPct)
|
|
}
|
|
if _, err := ParseHHMM(c.Quota.PeakStart); err != nil {
|
|
return fmt.Errorf("[quota]: peak_start: %w", err)
|
|
}
|
|
if _, err := ParseHHMM(c.Quota.PeakEnd); err != nil {
|
|
return fmt.Errorf("[quota]: peak_end: %w", err)
|
|
}
|
|
if _, err := time.LoadLocation(c.Quota.Timezone); err != nil {
|
|
return fmt.Errorf("[quota]: unknown timezone %q", c.Quota.Timezone)
|
|
}
|
|
if c.Quota.Account == "" {
|
|
return fmt.Errorf("[quota]: account is required when the quota gate is enabled (shared-state bucket label)")
|
|
}
|
|
if c.Quota.UsageURL != "" && c.Quota.KeyRef == "" {
|
|
return fmt.Errorf("[quota]: key_ref is required when usage_url is set (bearer auth)")
|
|
}
|
|
if c.Quota.KeyRef != "" {
|
|
if err := CheckKeyRef(c.Quota.KeyRef); err != nil {
|
|
return fmt.Errorf("[quota]: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The resource gate is optional; when on, thresholds must be sane.
|
|
if c.Resources.Enabled {
|
|
if c.Resources.MaxLoadAvg <= 0 || c.Resources.MinMemAvailableMB <= 0 || c.Resources.MinDiskFreeMB <= 0 {
|
|
return fmt.Errorf("[resources]: thresholds must be > 0")
|
|
}
|
|
if !(c.Resources.MaxIODelayPct > 0 && c.Resources.MaxIODelayPct <= 100) {
|
|
return fmt.Errorf("[resources]: max_io_delay_pct must be in (0,100]")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseHHMM parses a wall-clock time of day, "HH:MM" (24h).
|
|
func ParseHHMM(s string) (time.Duration, error) {
|
|
parts := strings.Split(s, ":")
|
|
if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 {
|
|
return 0, fmt.Errorf("want HH:MM, got %q", s)
|
|
}
|
|
h, err := strconv.Atoi(parts[0])
|
|
if err != nil || h < 0 || h > 23 {
|
|
return 0, fmt.Errorf("bad hour in %q", s)
|
|
}
|
|
m, err := strconv.Atoi(parts[1])
|
|
if err != nil || m < 0 || m > 59 {
|
|
return 0, fmt.Errorf("bad minute in %q", s)
|
|
}
|
|
return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute, nil
|
|
}
|