carlos / platform Public

runs: ids, keys, wire shapes and validation for carlos run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f774b15ab0badc9180c1ce1a3378e47216d7bd0d parent ef8f2a4
5 files changed, +686 −1
  • approved_run_copy_test.go +1 −1
  • internal/runs/check.go +106 −0
  • internal/runs/runs.go +226 −0
  • internal/runs/runs_test.go +220 −0
  • internal/runs/store.go +133 −0
diff --git a/approved_run_copy_test.go b/approved_run_copy_test.go
index 86006821f..48097ea28 100644
--- a/approved_run_copy_test.go
+++ b/approved_run_copy_test.go
@@ -33,7 +33,7 @@ var runCopyIDs = func() []string {
// runCopyBuilt is every deck id whose text the code or docs now carry. The
// task that writes a row's text adds its id here in the same commit.
-var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C43"}
+var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C43", "RUN-C44", "RUN-C45", "RUN-C46", "RUN-C82"}
func mustParseRunRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/runs/check.go b/internal/runs/check.go
new file mode 100644
index 000000000..52dfcda8e
--- /dev/null
+++ b/internal/runs/check.go
@@ -0,0 +1,106 @@
+package runs
+
+import (
+ "errors"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+)
+
+var (
+ ErrBadID = errors.New("runs: not a run id")
+ ErrNoArgs = errors.New("runs: no arguments")
+ ErrTooManyArgs = errors.New("runs: more than 256 arguments")
+ ErrArgvTooLarge = errors.New("runs: arguments over 64 KB")
+ ErrArgNUL = errors.New("runs: an argument holds a NUL byte")
+ ErrTimeout = errors.New("runs: timeout outside 1s to 6h")
+ ErrFilesTooLarge = errors.New("runs: files over 64 MB")
+ ErrFileName = errors.New("runs: not a plain file name")
+ ErrFileTwice = errors.New("runs: two files share a name")
+)
+
+// FileNameError names the file a refusal is about.
+type FileNameError struct {
+ Name string
+ Twice bool
+}
+
+func (e *FileNameError) Error() string {
+ if e.Twice {
+ return ErrFileTwice.Error() + ": " + e.Name
+ }
+ return ErrFileName.Error() + ": " + e.Name
+}
+
+func (e *FileNameError) Is(target error) bool {
+ return (e.Twice && target == ErrFileTwice) || (!e.Twice && target == ErrFileName)
+}
+
+// CheckArgv is spec §5.1's argv rule.
+func CheckArgv(argv []string) error {
+ switch {
+ case len(argv) == 0:
+ return ErrNoArgs
+ case len(argv) > MaxArgs:
+ return ErrTooManyArgs
+ }
+ total := 0
+ for _, a := range argv {
+ if strings.IndexByte(a, 0) >= 0 {
+ return ErrArgNUL
+ }
+ total += len(a)
+ }
+ if total > MaxArgvBytes {
+ return ErrArgvTooLarge
+ }
+ return nil
+}
+
+// CheckTimeout is spec §5.1's timeout rule.
+func CheckTimeout(d time.Duration) error {
+ if d < MinTimeout || d > MaxTimeout {
+ return ErrTimeout
+ }
+ return nil
+}
+
+// PlainName reports whether name is a plain base name: not empty, at most
+// 255 bytes, valid UTF-8, no slash, backslash or control character, and
+// not "." or "..". The console checks it, and the box again (spec §7).
+func PlainName(name string) bool {
+ if name == "" || name == "." || name == ".." || len(name) > 255 || !utf8.ValidString(name) {
+ return false
+ }
+ for _, r := range name {
+ if r == '/' || r == '\\' || unicode.IsControl(r) {
+ return false
+ }
+ }
+ return true
+}
+
+// CheckFiles is spec §5.1's file rule: plain names, each once, sizes not
+// negative, 64 MB in total.
+func CheckFiles(files []MetaFile) error {
+ seen := map[string]bool{}
+ var total int64
+ for _, f := range files {
+ if !PlainName(f.Name) {
+ return &FileNameError{Name: f.Name}
+ }
+ if seen[f.Name] {
+ return &FileNameError{Name: f.Name, Twice: true}
+ }
+ seen[f.Name] = true
+ if f.Size < 0 {
+ return ErrFilesTooLarge
+ }
+ total += f.Size
+ if total > MaxFileBytes {
+ return ErrFilesTooLarge
+ }
+ }
+ return nil
+}
diff --git a/internal/runs/runs.go b/internal/runs/runs.go
new file mode 100644
index 000000000..db0f1f2f7
--- /dev/null
+++ b/internal/runs/runs.go
@@ -0,0 +1,226 @@
+// Package runs is what the console, the box and the CLI share about one
+// `carlos run` (run spec): ids, the run's objects under its app's subtree,
+// the wire shapes, the limits and the reasons a run ends without an exit.
+// It holds no policy beyond validation; the record's run state is
+// provision.RunState.
+package runs
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ MaxArgs = 256
+ MaxArgvBytes = 64 << 10
+ MaxFileBytes = 64 << 20
+ MaxOutputBytes = 64 << 20
+ // MetaAllowance bounds the meta part and the multipart framing on top
+ // of the files (run spec §5.1).
+ MetaAllowance = 1 << 20
+ MinTimeout = time.Second
+ MaxTimeout = 6 * time.Hour
+ DefaultTimeout = 30 * time.Minute
+ PickupWindow = 60 * time.Second
+ LostAfter = time.Hour
+ KeepFor = 24 * time.Hour
+ ChunkBytes = 256 << 10
+ ChunkEvery = time.Second
+ StreamOut = "o"
+ StreamErr = "e"
+ // A follow page holds at most PageChunks chunks and PageBytes of
+ // output, so its JSON stays under the CLI's 1 MiB response cap.
+ PageChunks = 64
+ PageBytes = 512 << 10
+)
+
+// Why the box refused a run (result.json's refused).
+const (
+ RefusedFlat = "flat"
+ RefusedClaim = "claim"
+ RefusedWake = "wake"
+ RefusedIncarnation = "incarnation"
+ RefusedImport = "import"
+)
+
+// Why a run was interrupted, beside activator's Stop* reasons (RUN-C44 to
+// RUN-C46, RUN-C82).
+const (
+ InterruptedLost = "its box stopped reporting"
+ InterruptedAgentRestart = "the box's agent restarted"
+ InterruptedGone = "the command's unit went away"
+ InterruptedStartFailed = "the box could not start the command"
+)
+
+// DirName is the directory under an instance's own data directory that
+// holds each run's files: activator.CmdRunDirName, restated here because
+// runs imports no activator (Plan 3 pins the two equal).
+const DirName = ".carlos-run"
+
+var idEnc = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
+
+var idShape = regexp.MustCompile(`^run_[a-z2-7]{26}$`)
+
+// NewID mints a run id from r: "run_" and 26 lowercase base32 characters,
+// the encoding of 16 random bytes (128 bits). A run id is the only thing that
+// stops the record from admitting a run twice and the only thing that names
+// a run's objects, so production code mints with MintID; NewID takes a reader
+// only so a test can supply a fixed one.
+func NewID(r io.Reader) (string, error) {
+ var b [16]byte
+ if _, err := io.ReadFull(r, b[:]); err != nil {
+ return "", err
+ }
+ return "run_" + idEnc.EncodeToString(b[:]), nil
+}
+
+// MintID mints a run id from the operating system's cryptographic random
+// source.
+func MintID() (string, error) { return NewID(rand.Reader) }
+
+// ValidID reports whether id is a run id. Every id that passes is exactly 30
+// bytes of [a-z2-7_] and so is one clean path element: never empty, with no
+// slash, backslash or dot, and never "." or "..". The console validates an
+// id from a request with it, the box validates one from a record with it, and
+// the store helpers below refuse an id that fails it.
+func ValidID(id string) bool { return idShape.MatchString(id) }
+
+// RunsPrefix is where an app's runs live: under its own subtree, so the
+// owner gate maps onto them and app trash and purge sweep them.
+func RunsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "runs/" }
+
+func Prefix(acct, app, id string) string { return RunsPrefix(acct, app) + id + "/" }
+func RequestKey(acct, app, id string) string { return Prefix(acct, app, id) + "request.json" }
+func ResultKey(acct, app, id string) string { return Prefix(acct, app, id) + "result.json" }
+func AuditedKey(acct, app, id string) string { return Prefix(acct, app, id) + "audited" }
+func OutPrefix(acct, app, id string) string { return Prefix(acct, app, id) + "out/" }
+
+func InputKey(acct, app, id, name string) string { return Prefix(acct, app, id) + "in/" + name }
+
+// ChunkKey is out/<seq>-<stream>, the seq zero-padded so keys sort in the
+// order the agent read the output.
+func ChunkKey(acct, app, id string, seq int64, stream string) string {
+ return OutPrefix(acct, app, id) + fmt.Sprintf("%08d-%s", seq, stream)
+}
+
+// ParseChunkKey reads a key ChunkKey made under outPrefix.
+func ParseChunkKey(key, outPrefix string) (int64, string, bool) {
+ name, ok := strings.CutPrefix(key, outPrefix)
+ if !ok || len(name) != 10 || name[8] != '-' {
+ return 0, "", false
+ }
+ stream := name[9:]
+ if stream != StreamOut && stream != StreamErr {
+ return 0, "", false
+ }
+ seq, err := strconv.ParseInt(name[:8], 10, 64)
+ if err != nil || seq < 1 {
+ return 0, "", false
+ }
+ for i := 0; i < 8; i++ { // ParseInt would also take a sign
+ if name[i] < '0' || name[i] > '9' {
+ return 0, "", false
+ }
+ }
+ return seq, stream, true
+}
+
+// Meta is the create request's first part.
+type Meta struct {
+ Argv []string `json:"argv"`
+ TimeoutS int64 `json:"timeout_s"`
+ Files []MetaFile `json:"files"`
+}
+
+type MetaFile struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+}
+
+// File is one uploaded file as request.json records it.
+type File struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ SHA256 string `json:"sha256"`
+}
+
+// Request is runs/<id>/request.json (run spec §5.1). Incarnation is the
+// record's (hostclaim.Incarnation of account, app, host and CreatedAt).
+type Request struct {
+ Schema int `json:"schema"`
+ ID string `json:"id"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ Argv []string `json:"argv"`
+ TimeoutS int64 `json:"timeout_s"`
+ Files []File `json:"files"`
+ By string `json:"by"`
+ RequestedAt time.Time `json:"requested_at"`
+}
+
+// Timeout is the run's timeout as a duration.
+func (r Request) Timeout() time.Duration { return time.Duration(r.TimeoutS) * time.Second }
+
+// Result is runs/<id>/result.json (run spec §5.5), written only after the
+// last chunk. Signal is the decimal signal number when a signal ended the
+// command (index item 5); Detail is the member-safe wake error for a
+// refused wake (index item 4).
+type Result struct {
+ Exit int `json:"exit"`
+ Signal string `json:"signal"`
+ TimedOut bool `json:"timed_out"`
+ Cancelled bool `json:"cancelled"`
+ Interrupted string `json:"interrupted"`
+ Refused string `json:"refused"`
+ Detail string `json:"detail,omitempty"`
+ Started time.Time `json:"started"`
+ Ended time.Time `json:"ended"`
+ Box string `json:"box"`
+ LastSeq int64 `json:"last_seq"`
+ Truncated bool `json:"truncated"`
+}
+
+// Chunk is one output chunk in a follow page; Data is base64 in JSON.
+type Chunk struct {
+ Seq int64 `json:"seq"`
+ Stream string `json:"stream"`
+ Data []byte `json:"data"`
+}
+
+// Page is GET …/runs/{id}?after=<seq>. State is the record's state for
+// this run, or "" when the record no longer names it. More says the page
+// was cut short: ask again at once.
+type Page struct {
+ ID string `json:"id"`
+ State string `json:"state"`
+ Chunks []Chunk `json:"chunks"`
+ Result *Result `json:"result,omitempty"`
+ More bool `json:"more"`
+}
+
+// Ack is POST …/runs/{id}/ack.
+type Ack struct {
+ LastSeq int64 `json:"last_seq"`
+}
+
+// Created is the 202 to a create.
+type Created struct {
+ ID string `json:"id"`
+ Host string `json:"host"`
+ State string `json:"state"`
+}
+
+// StateAnswer is the answer to cancel, expire and ack.
+type StateAnswer struct {
+ State string `json:"state"`
+}
diff --git a/internal/runs/runs_test.go b/internal/runs/runs_test.go
new file mode 100644
index 000000000..f30acfafc
--- /dev/null
+++ b/internal/runs/runs_test.go
@@ -0,0 +1,220 @@
+package runs
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "errors"
+ "regexp"
+ "strings"
+ "testing"
+ "testing/iotest"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestIDsAndKeys(t *testing.T) {
+ id, err := NewID(rand.Reader)
+ if err != nil || !regexp.MustCompile(`^run_[a-z2-7]{26}$`).MatchString(id) || !ValidID(id) {
+ t.Fatalf("id %q, %v", id, err)
+ }
+ for _, bad := range []string{"", "run_", "run_ABCDEFGHIJKLMNOPQRSTUVWXYZ", "run_abcdefghijklmnopqrstuvwxy/", "mv_abcdefghijklmnopqrstuvwxyz"} {
+ if ValidID(bad) {
+ t.Errorf("%q accepted", bad)
+ }
+ }
+ if got := Prefix("bab", "titogo", id); got != "apps/bab/titogo/runs/"+id+"/" {
+ t.Errorf("Prefix = %q", got)
+ }
+ k := ChunkKey("bab", "titogo", id, 17, StreamErr)
+ if k != OutPrefix("bab", "titogo", id)+"00000017-e" {
+ t.Errorf("ChunkKey = %q", k)
+ }
+ seq, stream, ok := ParseChunkKey(k, OutPrefix("bab", "titogo", id))
+ if !ok || seq != 17 || stream != StreamErr {
+ t.Errorf("ParseChunkKey = %d %q %v", seq, stream, ok)
+ }
+ for _, bad := range []string{OutPrefix("bab", "titogo", id) + "00000017-x", OutPrefix("bab", "titogo", id) + "17-o", "elsewhere"} {
+ if _, _, ok := ParseChunkKey(bad, OutPrefix("bab", "titogo", id)); ok {
+ t.Errorf("ParseChunkKey(%q) accepted", bad)
+ }
+ }
+}
+
+func TestCheckArgvAndTimeout(t *testing.T) {
+ cases := []struct {
+ argv []string
+ want error
+ }{
+ {nil, ErrNoArgs},
+ {make([]string, 257), ErrTooManyArgs},
+ {[]string{strings.Repeat("a", 64<<10+1)}, ErrArgvTooLarge},
+ {[]string{strings.Repeat("a", 64<<10-1), "b"}, nil}, // "at most 64 KB in total": the limit itself passes
+ {[]string{strings.Repeat("a", 32<<10), strings.Repeat("b", 32<<10+1)}, ErrArgvTooLarge},
+ {[]string{"a\x00b"}, ErrArgNUL},
+ {[]string{"import", "--dry-run"}, nil},
+ }
+ for i := range cases[1].argv {
+ cases[1].argv[i] = "x"
+ }
+ for _, c := range cases {
+ if err := CheckArgv(c.argv); !errors.Is(err, c.want) && !(c.want == nil && err == nil) {
+ t.Errorf("CheckArgv(%d args) = %v, want %v", len(c.argv), err, c.want)
+ }
+ }
+ for d, ok := range map[time.Duration]bool{0: false, 999 * time.Millisecond: false, time.Second: true, 6 * time.Hour: true, 6*time.Hour + time.Second: false} {
+ if (CheckTimeout(d) == nil) != ok {
+ t.Errorf("CheckTimeout(%s) = %v", d, CheckTimeout(d))
+ }
+ }
+}
+
+func TestCheckFiles(t *testing.T) {
+ if err := CheckFiles([]MetaFile{{"a.csv", 1}, {"b.json", 2}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckFiles([]MetaFile{{"a", MaxFileBytes}, {"b", 1}}); !errors.Is(err, ErrFilesTooLarge) {
+ t.Errorf("over the cap: %v", err)
+ }
+ for _, name := range []string{"", ".", "..", "a/b", "../x", "a\x00b", "a\nb", strings.Repeat("n", 256)} {
+ var fe *FileNameError
+ if err := CheckFiles([]MetaFile{{name, 1}}); !errors.Is(err, ErrFileName) || !errors.As(err, &fe) || fe.Name != name {
+ t.Errorf("name %q: %v", name, err)
+ }
+ }
+ if err := CheckFiles([]MetaFile{{"a", 1}, {"a", 1}}); !errors.Is(err, ErrFileTwice) {
+ t.Errorf("twice: %v", err)
+ }
+ if err := CheckFiles([]MetaFile{{"a", -1}}); !errors.Is(err, ErrFilesTooLarge) {
+ t.Errorf("a negative size must be refused: %v", err)
+ }
+}
+
+func TestStoreHelpers(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ id := "run_abcdefghijklmnopqrstuvwxyz"
+ if r, err := ReadResult(ctx, s, "bab", "titogo", id); r != nil || err != nil {
+ t.Fatalf("absent result = %v, %v", r, err)
+ }
+ for seq := int64(1); seq <= 5; seq++ {
+ if _, err := s.Put(ctx, ChunkKey("bab", "titogo", id, seq, StreamOut), []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ keys, err := ListChunks(ctx, s, "bab", "titogo", id, 2, 2)
+ if err != nil || len(keys) != 2 || !strings.HasSuffix(keys[0], "00000003-o") {
+ t.Fatalf("ListChunks = %q, %v", keys, err)
+ }
+ if last, err := LastChunkSeq(ctx, s, "bab", "titogo", id); err != nil || last != 5 {
+ t.Fatalf("LastChunkSeq = %d, %v", last, err)
+ }
+ first := Result{Exit: 3, LastSeq: 5}
+ if err := WriteResult(ctx, s, "bab", "titogo", id, first); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteResult(ctx, s, "bab", "titogo", id, Result{Interrupted: InterruptedLost}); err != nil {
+ t.Fatalf("a second result is not an error: %v", err)
+ }
+ if r, err := ReadResult(ctx, s, "bab", "titogo", id); err != nil || r.Exit != 3 || r.Interrupted != "" {
+ t.Fatalf("the first result must win: %+v, %v", r, err)
+ }
+ if err := DeleteRun(ctx, s, "bab", "titogo", id); err != nil {
+ t.Fatal(err)
+ }
+ if left, _ := s.List(ctx, Prefix("bab", "titogo", id)); len(left) != 0 {
+ t.Fatalf("DeleteRun left %q", left)
+ }
+}
+
+// A run id becomes one path element of an object key and of a directory on
+// the box, so ValidID must refuse anything that could be more or less.
+func TestValidIDIsOneCleanPathElement(t *testing.T) {
+ good := "run_abcdefghijklmnopqrstuvwxyz"
+ if !ValidID(good) {
+ t.Fatalf("%q refused", good)
+ }
+ for _, bad := range []string{
+ "", ".", "..", "run_", "/", "run_/", "../" + good, good + "/", good + "/..", good + "\n",
+ "\n" + good, good + "\x00", good + " ", "run_abcdefghijklmnopqrstuvwxy", good + "a",
+ "run_abcdefghijklmnopqrstuvwx\\z", "run_abcdefghijklmnopqrstuvwx.z", "run_abcdefghijklmnopqrstuvwxé",
+ "RUN_abcdefghijklmnopqrstuvwxyz", "run_abcdefghijklmnopqrstuvwx01",
+ } {
+ if ValidID(bad) {
+ t.Errorf("ValidID(%q) accepted", bad)
+ }
+ }
+}
+
+func TestNewIDDrawsFromTheReader(t *testing.T) {
+ // 16 bytes in, 128 bits of the id out: a different draw is a different id.
+ a, err := NewID(bytes.NewReader(bytes.Repeat([]byte{0x00}, 16)))
+ if err != nil || a != "run_"+strings.Repeat("a", 26) {
+ t.Fatalf("zero draw = %q, %v", a, err)
+ }
+ b, err := NewID(bytes.NewReader(append(bytes.Repeat([]byte{0x00}, 15), 0x01)))
+ if err != nil || b == a || !ValidID(b) {
+ t.Fatalf("last bit draw = %q, %v", b, err)
+ }
+ if _, err := NewID(bytes.NewReader(make([]byte, 15))); err == nil {
+ t.Error("a short read minted an id")
+ }
+ if _, err := NewID(iotest.ErrReader(errors.New("no entropy"))); err == nil {
+ t.Error("a failing reader minted an id")
+ }
+}
+
+func TestMintIDIsRandomAndValid(t *testing.T) {
+ seen := map[string]bool{}
+ for i := 0; i < 2000; i++ {
+ id, err := MintID()
+ if err != nil || !ValidID(id) {
+ t.Fatalf("MintID = %q, %v", id, err)
+ }
+ if seen[id] {
+ t.Fatalf("MintID repeated %q", id)
+ }
+ seen[id] = true
+ }
+}
+
+func TestParseChunkKeyIsCanonicalOnly(t *testing.T) {
+ out := OutPrefix("bab", "titogo", "run_abcdefghijklmnopqrstuvwxyz")
+ for _, name := range []string{"+0000017-o", "-0000017-o", "0000000-o", "00000000-o", " 0000017-o", "0x000017-o", "0000017 -o"} {
+ if _, _, ok := ParseChunkKey(out+name, out); ok {
+ t.Errorf("ParseChunkKey(%q) accepted", name)
+ }
+ }
+}
+
+func TestStoreHelpersRefuseAnIdThatIsNotOne(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if _, err := s.Put(ctx, "apps/bab/titogo/runs/keep", []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{"", "..", "../x", "run_x/../..", "run_abcdefghijklmnopqrstuvwxyz/"} {
+ if _, err := ReadRequest(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) {
+ t.Errorf("ReadRequest(%q) = %v", id, err)
+ }
+ if _, err := ReadResult(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) {
+ t.Errorf("ReadResult(%q) = %v", id, err)
+ }
+ if err := WriteResult(ctx, s, "bab", "titogo", id, Result{}); !errors.Is(err, ErrBadID) {
+ t.Errorf("WriteResult(%q) = %v", id, err)
+ }
+ if _, err := ListChunks(ctx, s, "bab", "titogo", id, 0, 0); !errors.Is(err, ErrBadID) {
+ t.Errorf("ListChunks(%q) = %v", id, err)
+ }
+ if _, err := LastChunkSeq(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) {
+ t.Errorf("LastChunkSeq(%q) = %v", id, err)
+ }
+ if err := DeleteRun(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) {
+ t.Errorf("DeleteRun(%q) = %v", id, err)
+ }
+ }
+ if left, _ := s.List(ctx, "apps/bab/titogo/runs/"); len(left) != 1 {
+ t.Fatalf("a refused delete touched the store: %q", left)
+ }
+}
diff --git a/internal/runs/store.go b/internal/runs/store.go
new file mode 100644
index 000000000..22d727f1c
--- /dev/null
+++ b/internal/runs/store.go
@@ -0,0 +1,133 @@
+package runs
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Every helper refuses an id that is not a run id (ErrBadID) before it builds
+// a key, so no caller can reach another run's objects, or a whole app's runs,
+// through a crafted one.
+
+// ReadRequest reads request.json; absent is os.ErrNotExist.
+func ReadRequest(ctx context.Context, s release.Store, acct, app, id string) (*Request, error) {
+ if !ValidID(id) {
+ return nil, ErrBadID
+ }
+ raw, _, err := s.Get(ctx, RequestKey(acct, app, id))
+ if err != nil {
+ return nil, err
+ }
+ var r Request
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return nil, err
+ }
+ return &r, nil
+}
+
+// ReadResult reads result.json; absent is (nil, nil).
+func ReadResult(ctx context.Context, s release.Store, acct, app, id string) (*Result, error) {
+ if !ValidID(id) {
+ return nil, ErrBadID
+ }
+ raw, _, err := s.Get(ctx, ResultKey(acct, app, id))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var r Result
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return nil, err
+ }
+ return &r, nil
+}
+
+// WriteResult writes result.json create-only: the first result wins, and a
+// later writer (the sweep's lost result racing the box's) changes nothing.
+func WriteResult(ctx context.Context, s release.Store, acct, app, id string, r Result) error {
+ if !ValidID(id) {
+ return ErrBadID
+ }
+ body, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, ResultKey(acct, app, id), body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ return nil
+}
+
+// ListChunks lists chunk keys after seq, sorted, at most limit (0: all).
+func ListChunks(ctx context.Context, s release.Store, acct, app, id string, after int64, limit int) ([]string, error) {
+ if !ValidID(id) {
+ return nil, ErrBadID
+ }
+ prefix := OutPrefix(acct, app, id)
+ startAfter := prefix
+ if after > 0 {
+ startAfter = ChunkKey(acct, app, id, after, "~") // "~" sorts after both streams of seq
+ }
+ if pl, ok := s.(release.PageLister); ok {
+ return pl.ListPage(ctx, prefix, startAfter, limit)
+ }
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(keys)
+ var out []string
+ for _, k := range keys {
+ if k <= startAfter {
+ continue
+ }
+ out = append(out, k)
+ if limit > 0 && len(out) == limit {
+ break
+ }
+ }
+ return out, nil
+}
+
+// LastChunkSeq is the highest seq written, 0 when none.
+func LastChunkSeq(ctx context.Context, s release.Store, acct, app, id string) (int64, error) {
+ if !ValidID(id) {
+ return 0, ErrBadID
+ }
+ keys, err := ListChunks(ctx, s, acct, app, id, 0, 0)
+ if err != nil {
+ return 0, err
+ }
+ var last int64
+ for _, k := range keys {
+ if seq, _, ok := ParseChunkKey(k, OutPrefix(acct, app, id)); ok && seq > last {
+ last = seq
+ }
+ }
+ return last, nil
+}
+
+// DeleteRun deletes every object of the run.
+func DeleteRun(ctx context.Context, s release.Store, acct, app, id string) error {
+ if !ValidID(id) {
+ return ErrBadID
+ }
+ keys, err := s.List(ctx, Prefix(acct, app, id))
+ if err != nil {
+ return err
+ }
+ var errs []error
+ for _, k := range keys {
+ if err := s.Delete(ctx, k); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}