// This file is apps///schedules.json end to end (spec §4.1): the // member-facing validation shared with the console door, so a typo costs a // round trip nothing; the CAS-guarded read/modify/write cycle with one // retry on a conflicting writer; and schedule-run.json (spec §4.2), whose // etag movement alone — never its content — is the "fire it now" signal. package schedule import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/url" "os" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/release" ) const MaxSchedulesPerApp = 20 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) func ValidName(name string) bool { return nameRE.MatchString(name) } // ValidPath: absolute, no scheme/host, no "..", short, and rejects whatever // url.ParseRequestURI rejects, plus a scheme/host/userinfo of its own. It is // NOT the same parser BuildRequest sends through (http.NewRequest / // url.Parse): the two differ on "#" — a fragment passes validation here and // is silently dropped from the tick, which is harmless since nothing reads // it. A path is what the tick POSTs to on the instance's own socket; // nothing here can reach another host. func ValidPath(p string) error { switch { case p == "" || p[0] != '/': return errors.New("path must start with /") case strings.HasPrefix(p, "//"): return errors.New("path must not start with //") case len(p) > 256: return errors.New("path is longer than 256 bytes") case strings.ContainsAny(p, " \t\r\n"): return errors.New("path must not contain whitespace") } u, err := url.ParseRequestURI(p) if err != nil { return fmt.Errorf("path: %w", err) } if u.Scheme != "" || u.Host != "" || u.User != nil { return errors.New("path must not carry a scheme or host") } for _, seg := range strings.Split(u.Path, "/") { if seg == ".." { return errors.New("path must not contain ..") } } return nil } // Schedule is one declared recurring job, as the member typed it. type Schedule struct { Name string `json:"name"` Every string `json:"every,omitempty"` Cron string `json:"cron,omitempty"` Path string `json:"path"` } // Spec is the schedule as the box stores it. Exactly one of every/cron. func (s Schedule) Spec() (Spec, error) { switch { case s.Every != "" && s.Cron != "": return Spec{}, errors.New("give --every or --cron, not both") case s.Every != "": d, err := time.ParseDuration(s.Every) if err != nil { return Spec{}, fmt.Errorf("every: %w", err) } if err := ValidEvery(d); err != nil { return Spec{}, err } return Spec{Kind: KindSchedule, Every: d}, nil case s.Cron != "": c, err := ParseCron(s.Cron) if err != nil { return Spec{}, err } return Spec{Kind: KindSchedule, Cron: c}, nil } return Spec{}, errors.New("give --every or --cron \"<5 fields>\"") } // Validate checks every field: name, path shape, and exactly one of // every/cron parses. It does not mutate the receiver — normalising // every/cron to their canonical text (so what lands in the bucket is what // ParseSpec reads back) is UpsertSchedule's job, done on its own addressable // copy just before storing. func (s Schedule) Validate() error { _, err := s.validate() return err } // validate is Validate's work, plus the parsed Spec — UpsertSchedule needs // that Spec to normalise every/cron text, and calling Validate then Spec // separately parsed the every/cron string twice for one call. Kept private // so Validate's public signature (and its non-mutating, error-only // contract) does not change. func (s Schedule) validate() (Spec, error) { if !ValidName(s.Name) { return Spec{}, fmt.Errorf("name %q: want [a-z0-9][a-z0-9-]{0,31}", s.Name) } if err := ValidPath(s.Path); err != nil { return Spec{}, err } sp, err := s.Spec() if err != nil { return Spec{}, err } // A cron expression can parse and still match nothing ("0 0 30 2 *"): // its Next is the zero time, which a box must never store as a due // instant. Refuse it here, where the member can read the answer. if sp.Next(time.Now()).IsZero() { return Spec{}, errors.New("this expression never fires (no matching minute within the next 5 years)") } return sp, nil } // Declaration is apps///schedules.json (spec §4.1). type Declaration struct { Schema int `json:"schema"` UpdatedAt string `json:"updated_at"` Schedules []Schedule `json:"schedules"` } func (d Declaration) Find(name string) (Schedule, bool) { for _, s := range d.Schedules { if s.Name == name { return s, true } } return Schedule{}, false } func DeclarationKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedules.json" } func RunKey(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-run.json" } func StatusPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "schedule-status/" } func StatusKey(acct, app, host string) string { return StatusPrefix(acct, app) + host + ".json" } // ReadDeclaration: absent is the empty declaration with etag "" — legal // forever, like restart-gen. func ReadDeclaration(ctx context.Context, s release.Store, acct, app string) (Declaration, string, error) { if err := checkIDs(acct, app); err != nil { return Declaration{}, "", err } raw, etag, err := s.Get(ctx, DeclarationKey(acct, app)) if errors.Is(err, os.ErrNotExist) { return Declaration{Schema: 1}, "", nil } if err != nil { return Declaration{}, "", err } var d Declaration if err := json.Unmarshal(raw, &d); err != nil { return Declaration{}, "", fmt.Errorf("schedules.json: %w", err) } return d, etag, nil } func checkIDs(acct, app string) error { if !release.ValidAccount(acct) { return fmt.Errorf("schedule: invalid account %q", acct) } if !release.ValidApp(app) { return fmt.Errorf("schedule: invalid app %q", app) } return nil } // modify applies fn under CAS with ONE retry on conflict — two members // editing the same app at once should both land, not one of them fail. func modify(ctx context.Context, s release.Store, acct, app string, now time.Time, fn func(*Declaration) error) (Declaration, error) { if err := checkIDs(acct, app); err != nil { return Declaration{}, err } var last error for attempt := 0; attempt < 2; attempt++ { d, etag, err := ReadDeclaration(ctx, s, acct, app) if err != nil { return Declaration{}, err } if err := fn(&d); err != nil { return Declaration{}, err } sort.Slice(d.Schedules, func(i, j int) bool { return d.Schedules[i].Name < d.Schedules[j].Name }) d.Schema = 1 d.UpdatedAt = now.UTC().Format(time.RFC3339) raw, err := json.Marshal(d) if err != nil { return Declaration{}, err } if _, err := s.Put(ctx, DeclarationKey(acct, app), raw, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { last = err continue } return Declaration{}, err } return d, nil } return Declaration{}, fmt.Errorf("schedules.json changed underneath us twice: %w", last) } // UpsertSchedule validates sc, normalises its every/cron text to what // ParseSpec reads back, then replaces-or-appends it by name. func claimRawUpsertSchedule(ctx context.Context, s release.Store, acct, app string, sc Schedule, now time.Time) (Declaration, error) { sp, err := sc.validate() // one parse of every/cron, not Validate then Spec if err != nil { return Declaration{}, err } if sp.Every > 0 { sc.Every = sp.Every.String() } else { sc.Cron = sp.Cron.String() } return modify(ctx, s, acct, app, now, func(d *Declaration) error { for i := range d.Schedules { if d.Schedules[i].Name == sc.Name { d.Schedules[i] = sc return nil } } if len(d.Schedules) >= MaxSchedulesPerApp { return fmt.Errorf("an app may declare at most %d schedules", MaxSchedulesPerApp) } d.Schedules = append(d.Schedules, sc) return nil }) } // RemoveSchedule drops name; present reports whether it was there. Removing // an absent name still rewrites updated_at — harmless, and it keeps the // door's answer uniform. func claimRawRemoveSchedule(ctx context.Context, s release.Store, acct, app, name string, now time.Time) (Declaration, bool, error) { present := false d, err := modify(ctx, s, acct, app, now, func(d *Declaration) error { kept := d.Schedules[:0] for _, sc := range d.Schedules { if sc.Name == name { present = true continue } kept = append(kept, sc) } d.Schedules = kept return nil }) return d, present, err } // RunSignal is schedule-run.json: a touch object whose etag movement is the // signal, exactly restart-gen's shape and rules (release/restartgen.go). type RunSignal struct { Schema int `json:"schema"` Name string `json:"name"` TouchedAt string `json:"touched_at"` Nonce string `json:"nonce"` } // TouchRun bumps the run signal for name. A CAS conflict is success: the // signal already fired. func claimRawTouchRun(ctx context.Context, s release.Store, acct, app, name string, now time.Time) error { if err := checkIDs(acct, app); err != nil { return err } if !ValidName(name) { return fmt.Errorf("schedule: invalid name %q", name) } key := RunKey(acct, app) _, etag, err := s.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if errors.Is(err, os.ErrNotExist) { etag = "" } nonce := make([]byte, 16) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return err } raw, err := json.Marshal(RunSignal{Schema: 1, Name: name, TouchedAt: now.UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce)}) if err != nil { return err } if _, err := s.Put(ctx, key, raw, etag); err != nil && !errors.Is(err, release.ErrCASConflict) { return err } return nil } func RemoveSchedule(ctx context.Context, s release.Store, acct, app, name string, now time.Time) (claimResult0 Declaration, claimResult1 bool, claimResult2 error) { claimResult2 = release.WithAppMutation(ctx, s, acct, app, "schedule.RemoveSchedule", func(claimContext context.Context) error { claimResult0, claimResult1, claimResult2 = claimRawRemoveSchedule(claimContext, s, acct, app, name, now) return claimResult2 }) return } func TouchRun(ctx context.Context, s release.Store, acct, app, name string, now time.Time) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "schedule.TouchRun", func(claimContext context.Context) error { claimResult0 = claimRawTouchRun(claimContext, s, acct, app, name, now) return claimResult0 }) return } func UpsertSchedule(ctx context.Context, s release.Store, acct, app string, sc Schedule, now time.Time) (claimResult0 Declaration, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "schedule.UpsertSchedule", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawUpsertSchedule(claimContext, s, acct, app, sc, now) return claimResult1 }) return }