feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s
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.
This commit is contained in:
@@ -61,7 +61,7 @@ type Workload struct {
|
||||
SourceKind string // "image" | "compose" | "static" | ...
|
||||
SourceConfig json.RawMessage // shape determined by SourceKind
|
||||
|
||||
TriggerKind string // "registry" | "git" | "manual" | "cron" | ...
|
||||
TriggerKind string // "registry" | "git" | "manual" | "schedule" | ...
|
||||
TriggerConfig json.RawMessage // shape determined by TriggerKind
|
||||
|
||||
PublicFaces []PublicFace // zero or more public routes
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg json.RawMessage
|
||||
wantErr string // substring; empty = expect success
|
||||
}{
|
||||
{"empty body rejected", nil, "config is required"},
|
||||
{"empty object rejected", json.RawMessage(`{}`), "interval is required"},
|
||||
{"missing interval rejected", json.RawMessage(`{"reference":"v1"}`), "interval is required"},
|
||||
{"invalid json rejected", json.RawMessage(`not json`), "invalid json"},
|
||||
{"unparseable interval rejected", json.RawMessage(`{"interval":"banana"}`), "invalid interval"},
|
||||
{"zero interval rejected", json.RawMessage(`{"interval":"0s"}`), "must be positive"},
|
||||
{"sub-minute rejected", json.RawMessage(`{"interval":"30s"}`), "below minimum"},
|
||||
{"exact minimum accepted", json.RawMessage(`{"interval":"1m"}`), ""},
|
||||
{"hour accepted", json.RawMessage(`{"interval":"1h"}`), ""},
|
||||
{"day accepted", json.RawMessage(`{"interval":"24h"}`), ""},
|
||||
{"unknown keys tolerated", json.RawMessage(`{"interval":"1h","future_field":42}`), ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tr.Validate(tc.cfg)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil err, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error %q missing substring %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInterval(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
err bool
|
||||
}{
|
||||
{"1h", time.Hour, false},
|
||||
{"24h", 24 * time.Hour, false},
|
||||
{" 5m ", 5 * time.Minute, false},
|
||||
{"168h", 7 * 24 * time.Hour, false},
|
||||
{"", 0, true},
|
||||
{"banana", 0, true},
|
||||
{"-1h", 0, true},
|
||||
{"0s", 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got, err := ParseInterval(tc.in)
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalOfRaw(t *testing.T) {
|
||||
d, err := IntervalOfRaw(`{"interval":"30m"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if d != 30*time.Minute {
|
||||
t.Fatalf("got %s, want 30m", d)
|
||||
}
|
||||
if _, err := IntervalOfRaw(""); err == nil {
|
||||
t.Fatalf("expected error on empty raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_WrongKindIgnored(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
|
||||
plugin.InboundEvent{Kind: "manual"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if intent != nil {
|
||||
t.Fatalf("expected nil intent for wrong kind, got %+v", intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_MissingSchedulePayload(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{ID: "w1", TriggerConfig: json.RawMessage(`{"interval":"1h"}`)}
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
|
||||
plugin.InboundEvent{Kind: "schedule"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if intent != nil {
|
||||
t.Fatalf("expected nil intent when payload missing, got %+v", intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_SchedulePopulatesIntent(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{
|
||||
ID: "w1",
|
||||
TriggerConfig: json.RawMessage(`{"interval":"6h","reference":"stable"}`),
|
||||
}
|
||||
fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
|
||||
plugin.InboundEvent{
|
||||
Kind: "schedule",
|
||||
Schedule: &plugin.ScheduleEvent{FiredAt: fixed},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if intent == nil {
|
||||
t.Fatalf("expected intent")
|
||||
}
|
||||
if intent.Reason != "schedule" {
|
||||
t.Errorf("Reason = %q, want schedule", intent.Reason)
|
||||
}
|
||||
if intent.Reference != "stable" {
|
||||
t.Errorf("Reference = %q, want stable", intent.Reference)
|
||||
}
|
||||
if intent.TriggeredBy != "scheduler" {
|
||||
t.Errorf("TriggeredBy = %q, want scheduler", intent.TriggeredBy)
|
||||
}
|
||||
if !intent.TriggeredAt.Equal(fixed) {
|
||||
t.Errorf("TriggeredAt = %s, want %s", intent.TriggeredAt, fixed)
|
||||
}
|
||||
if intent.Metadata["interval"] != "6h" {
|
||||
t.Errorf("interval metadata = %q, want 6h", intent.Metadata["interval"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch_ZeroFiredAtFallsBackToNow(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
wl := plugin.Workload{
|
||||
ID: "w1",
|
||||
TriggerConfig: json.RawMessage(`{"interval":"1h"}`),
|
||||
}
|
||||
before := time.Now().UTC()
|
||||
intent, err := tr.Match(context.Background(), plugin.Deps{}, wl,
|
||||
plugin.InboundEvent{
|
||||
Kind: "schedule",
|
||||
Schedule: &plugin.ScheduleEvent{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
if intent.TriggeredAt.Before(before) || intent.TriggeredAt.After(after.Add(time.Second)) {
|
||||
t.Errorf("TriggeredAt %s not in [%s, %s]", intent.TriggeredAt, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindAndSchemaSample(t *testing.T) {
|
||||
tr := &trigger{}
|
||||
if tr.Kind() != "schedule" {
|
||||
t.Fatalf("Kind = %q, want schedule", tr.Kind())
|
||||
}
|
||||
type withSample interface{ SchemaSample() any }
|
||||
s, ok := any(tr).(withSample)
|
||||
if !ok {
|
||||
t.Fatalf("trigger does not expose SchemaSample()")
|
||||
}
|
||||
sample, ok := s.SchemaSample().(Config)
|
||||
if !ok {
|
||||
t.Fatalf("SchemaSample is not Config: %T", s.SchemaSample())
|
||||
}
|
||||
if _, err := ParseInterval(sample.Interval); err != nil {
|
||||
t.Errorf("SchemaSample.Interval is not parseable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -29,18 +29,19 @@ type PublicFace struct {
|
||||
EnableSSL bool
|
||||
}
|
||||
|
||||
// InboundEvent is what an upstream signal (webhook, poll, manual click)
|
||||
// looks like to a Trigger.Match call. Triggers consult Kind first to
|
||||
// decide whether the event is interesting, then read the matching payload
|
||||
// field. RawBody / Headers are kept so trigger plugins can perform their
|
||||
// own signature verification or vendor-specific parsing.
|
||||
// InboundEvent is what an upstream signal (webhook, poll, manual click,
|
||||
// scheduler tick) looks like to a Trigger.Match call. Triggers consult
|
||||
// Kind first to decide whether the event is interesting, then read the
|
||||
// matching payload field. RawBody / Headers are kept so trigger plugins
|
||||
// can perform their own signature verification or vendor-specific parsing.
|
||||
type InboundEvent struct {
|
||||
Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "cron-tick"
|
||||
Image *ImagePushEvent
|
||||
Git *GitEvent
|
||||
Manual *ManualEvent
|
||||
RawBody []byte
|
||||
Headers map[string][]string
|
||||
Kind string // "image-push" | "git-push" | "git-tag" | "manual" | "schedule"
|
||||
Image *ImagePushEvent
|
||||
Git *GitEvent
|
||||
Manual *ManualEvent
|
||||
Schedule *ScheduleEvent
|
||||
RawBody []byte
|
||||
Headers map[string][]string
|
||||
}
|
||||
|
||||
// ImagePushEvent is normalized across registry vendors (generic, Gitea,
|
||||
@@ -72,6 +73,14 @@ type ManualEvent struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// ScheduleEvent is fired by the internal scheduler when a schedule
|
||||
// trigger's next-fire window is reached. FiredAt is the wall-clock the
|
||||
// scheduler observed (already truncated to the second). The trigger
|
||||
// plugin uses FiredAt + its own config to populate the DeploymentIntent.
|
||||
type ScheduleEvent struct {
|
||||
FiredAt time.Time
|
||||
}
|
||||
|
||||
// SourceConfigOf decodes the workload's SourceConfig blob into the typed
|
||||
// shape a specific Source uses. Kept here so callers do not duplicate the
|
||||
// boilerplate.
|
||||
|
||||
Reference in New Issue
Block a user