39e1e36510
Build / build (push) Successful in 10m42s
Fourth trigger kind alongside registry/git/manual. Recurring time-interval fires driven by a new internal/scheduler tick loop (default 30s, clamped to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as inbound HTTP webhooks, so per-binding concurrency, outcome accounting, and config-merge semantics are identical. Schema: triggers.last_fired_at TEXT column (additive ALTER for existing DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking Match cannot wedge a tight loop; failed deploys wait one full interval before retry — correct trade-off for a periodic refresh trigger. Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly, custom interval input matched to Go time.ParseDuration syntax, optional pinned reference). /triggers/[id] surfaces "last fired" on schedule rows. EN+RU i18n in parity. Review fixes from go-reviewer / security-reviewer / typescript-reviewer: - Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double- cancel panic on shutdown re-entry). - shouldFire rejects sub-MinInterval as defense-in-depth against hand-inserted rows that bypassed Validate. - fire() asserts trigger Kind=="schedule" before dispatching. - Aligned isValidInterval regex across all three frontend sites; reject the unsupported "d" unit (Go time.ParseDuration doesn't accept it). - formatLastFired falls back to lastFiredNever on malformed timestamps rather than leaking raw bytes into the UI. - main.go scheduler closure logs per-fire deployed/errored counts.
130 lines
4.0 KiB
Go
130 lines
4.0 KiB
Go
// Package schedule implements the "schedule" trigger: fires a deploy on
|
|
// a recurring time interval driven by the internal scheduler.
|
|
//
|
|
// v1 is interval-based ("every 24h"). A future revision can add a cron
|
|
// expression field; the plugin keeps the JSON shape forward-compatible
|
|
// by ignoring unknown keys.
|
|
package schedule
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
|
)
|
|
|
|
// Config is the per-trigger schedule config.
|
|
//
|
|
// Interval is a Go duration string ("1h", "24h", "168h"). The scheduler
|
|
// rejects intervals below MinInterval so a misconfigured trigger cannot
|
|
// run away and saturate the docker daemon.
|
|
//
|
|
// Reference is an optional string passed through to DeploymentIntent so
|
|
// the operator can pin a specific tag/sha to redeploy on every tick
|
|
// (e.g. always re-pull "stable"). Empty means the Source uses whatever
|
|
// it normally would.
|
|
type Config struct {
|
|
Interval string `json:"interval"`
|
|
Reference string `json:"reference,omitempty"`
|
|
}
|
|
|
|
// MinInterval is the floor enforced at Validate time. One minute is a
|
|
// pragmatic lower bound: shorter intervals would mostly serve as
|
|
// accidental denial-of-service against the deployer.
|
|
const MinInterval = time.Minute
|
|
|
|
type trigger struct{}
|
|
|
|
func init() { plugin.RegisterTrigger(&trigger{}) }
|
|
|
|
func (*trigger) Kind() string { return "schedule" }
|
|
|
|
func (*trigger) SchemaSample() any {
|
|
return Config{Interval: "24h"}
|
|
}
|
|
|
|
func (*trigger) Validate(cfg json.RawMessage) error {
|
|
if len(cfg) == 0 {
|
|
return fmt.Errorf("schedule trigger: config is required")
|
|
}
|
|
var c Config
|
|
if err := json.Unmarshal(cfg, &c); err != nil {
|
|
return fmt.Errorf("schedule trigger: invalid json: %w", err)
|
|
}
|
|
if strings.TrimSpace(c.Interval) == "" {
|
|
return fmt.Errorf("schedule trigger: interval is required (e.g. \"24h\")")
|
|
}
|
|
d, err := ParseInterval(c.Interval)
|
|
if err != nil {
|
|
return fmt.Errorf("schedule trigger: %w", err)
|
|
}
|
|
if d < MinInterval {
|
|
return fmt.Errorf("schedule trigger: interval %s is below minimum %s",
|
|
d, MinInterval)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseInterval parses a duration string with the same syntax as
|
|
// time.ParseDuration ("90s", "5m", "2h45m"). Exported so the scheduler
|
|
// can reuse the same parser and stay consistent with Validate.
|
|
func ParseInterval(s string) (time.Duration, error) {
|
|
d, err := time.ParseDuration(strings.TrimSpace(s))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid interval %q: %w", s, err)
|
|
}
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf("invalid interval %q: must be positive", s)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// IntervalOf reads the interval out of a workload-effective trigger
|
|
// config. Returns the parsed duration; an error if the config is
|
|
// missing the interval or malformed. Used by the scheduler when it
|
|
// already knows the merged config and wants to schedule next fire.
|
|
func IntervalOf(cfg json.RawMessage) (time.Duration, error) {
|
|
var c Config
|
|
if len(cfg) == 0 {
|
|
return 0, fmt.Errorf("schedule trigger: empty config")
|
|
}
|
|
if err := json.Unmarshal(cfg, &c); err != nil {
|
|
return 0, fmt.Errorf("schedule trigger: invalid json: %w", err)
|
|
}
|
|
return ParseInterval(c.Interval)
|
|
}
|
|
|
|
// IntervalOfRaw is a convenience that decodes the raw trigger.config
|
|
// blob from store.Trigger (a JSON string). Wrapper kept tiny because
|
|
// the scheduler holds the raw string, not a parsed shape.
|
|
func IntervalOfRaw(raw string) (time.Duration, error) {
|
|
return IntervalOf(json.RawMessage(raw))
|
|
}
|
|
|
|
func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) {
|
|
if evt.Kind != "schedule" || evt.Schedule == nil {
|
|
return nil, nil
|
|
}
|
|
cfg, err := plugin.TriggerConfigOf[Config](w)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("schedule trigger: decode config: %w", err)
|
|
}
|
|
firedAt := evt.Schedule.FiredAt
|
|
if firedAt.IsZero() {
|
|
firedAt = time.Now().UTC()
|
|
}
|
|
meta := map[string]string{
|
|
"interval": cfg.Interval,
|
|
}
|
|
return &plugin.DeploymentIntent{
|
|
Reason: "schedule",
|
|
Reference: strings.TrimSpace(cfg.Reference),
|
|
Metadata: meta,
|
|
TriggeredAt: firedAt,
|
|
TriggeredBy: "scheduler",
|
|
}, nil
|
|
}
|