Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/runstate/runstate.go

Raw
package runstate

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

type Active struct {
	RunID     string    `json:"run_id"`
	ChildID   string    `json:"child_id"`
	Repo      string    `json:"repo"`
	Job       string    `json:"job"`
	Rev       string    `json:"rev"`
	Ref       string    `json:"ref,omitempty"`
	Trigger   string    `json:"trigger"`
	StartedAt time.Time `json:"started_at"`
}

type Store struct{ DataDir string }

func (s Store) Write(active Active) error {
	if err := active.validate(active.RunID); err != nil {
		return err
	}
	dir := filepath.Join(s.DataDir, "active")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	active.StartedAt = active.StartedAt.UTC()
	data, err := json.Marshal(active)
	if err != nil {
		return err
	}
	data = append(data, '\n')
	path := filepath.Join(dir, active.RunID+".json")
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	if err := os.Rename(tmp, path); err != nil {
		_ = os.Remove(tmp)
		return err
	}
	return nil
}

func (s Store) Remove(runID string) error {
	if err := cleanID(runID); err != nil {
		return err
	}
	return os.Remove(filepath.Join(s.DataDir, "active", runID+".json"))
}

func (s Store) List() ([]Active, []error, error) {
	dir := filepath.Join(s.DataDir, "active")
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, nil, err
	}
	sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
	var out []Active
	var warnings []error
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
			continue
		}
		data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				continue
			}
			warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err))
			continue
		}
		var active Active
		if err := json.Unmarshal(data, &active); err != nil {
			warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err))
			continue
		}
		stem := strings.TrimSuffix(entry.Name(), ".json")
		if err := active.validate(stem); err != nil {
			warnings = append(warnings, fmt.Errorf("%s: %w", entry.Name(), err))
			continue
		}
		out = append(out, active)
	}
	return out, warnings, nil
}

func cleanID(id string) error {
	if id == "" || filepath.Base(id) != id {
		return fmt.Errorf("unsafe run id %q", id)
	}
	return nil
}

func (a Active) validate(stem string) error {
	if err := cleanID(a.RunID); err != nil {
		return err
	}
	if a.RunID != stem {
		return fmt.Errorf("run id %q does not match filename", a.RunID)
	}
	ordinal, err := strconv.Atoi(a.ChildID)
	if err != nil || ordinal < 1 {
		return fmt.Errorf("invalid child id %q", a.ChildID)
	}
	if strings.TrimSpace(a.Repo) == "" || strings.TrimSpace(a.Job) == "" || strings.TrimSpace(a.Rev) == "" {
		return fmt.Errorf("active record requires repo, job, and revision")
	}
	if a.Trigger != "push" && a.Trigger != "manual" && a.Trigger != "schedule" {
		return fmt.Errorf("invalid trigger %q", a.Trigger)
	}
	if a.StartedAt.IsZero() {
		return fmt.Errorf("active record requires start time")
	}
	return nil
}