// 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 }