Signed-off-by: dawn dawn@tangled.org
+13607
-435
Diff
Round #5
+82
-1
api/tangled/cbor_gen.go
+82
-1
api/tangled/cbor_gen.go
···
9749
9749
}
9750
9750
9751
9751
cw := cbg.NewCborWriter(w)
9752
+
fieldCount := 5
9752
9753
9753
-
if _, err := cw.Write([]byte{164}); err != nil {
9754
+
if t.RunsOn == nil {
9755
+
fieldCount--
9756
+
}
9757
+
9758
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
9754
9759
return err
9755
9760
}
9756
9761
···
9838
9843
if _, err := cw.WriteString(string(t.Engine)); err != nil {
9839
9844
return err
9840
9845
}
9846
+
9847
+
// t.RunsOn ([]string) (slice)
9848
+
if t.RunsOn != nil {
9849
+
9850
+
if len("runsOn") > 1000000 {
9851
+
return xerrors.Errorf("Value in field \"runsOn\" was too long")
9852
+
}
9853
+
9854
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("runsOn"))); err != nil {
9855
+
return err
9856
+
}
9857
+
if _, err := cw.WriteString(string("runsOn")); err != nil {
9858
+
return err
9859
+
}
9860
+
9861
+
if len(t.RunsOn) > 8192 {
9862
+
return xerrors.Errorf("Slice value in field t.RunsOn was too long")
9863
+
}
9864
+
9865
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.RunsOn))); err != nil {
9866
+
return err
9867
+
}
9868
+
for _, v := range t.RunsOn {
9869
+
if len(v) > 1000000 {
9870
+
return xerrors.Errorf("Value in field v was too long")
9871
+
}
9872
+
9873
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
9874
+
return err
9875
+
}
9876
+
if _, err := cw.WriteString(string(v)); err != nil {
9877
+
return err
9878
+
}
9879
+
9880
+
}
9881
+
}
9841
9882
return nil
9842
9883
}
9843
9884
···
9935
9976
9936
9977
t.Engine = string(sval)
9937
9978
}
9979
+
// t.RunsOn ([]string) (slice)
9980
+
case "runsOn":
9981
+
9982
+
maj, extra, err = cr.ReadHeader()
9983
+
if err != nil {
9984
+
return err
9985
+
}
9986
+
9987
+
if extra > 8192 {
9988
+
return fmt.Errorf("t.RunsOn: array too large (%d)", extra)
9989
+
}
9990
+
9991
+
if maj != cbg.MajArray {
9992
+
return fmt.Errorf("expected cbor array")
9993
+
}
9994
+
9995
+
if extra > 0 {
9996
+
t.RunsOn = make([]string, extra)
9997
+
}
9998
+
9999
+
for i := 0; i < int(extra); i++ {
10000
+
{
10001
+
var maj byte
10002
+
var extra uint64
10003
+
var err error
10004
+
_ = maj
10005
+
_ = extra
10006
+
_ = err
10007
+
10008
+
{
10009
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
10010
+
if err != nil {
10011
+
return err
10012
+
}
10013
+
10014
+
t.RunsOn[i] = string(sval)
10015
+
}
10016
+
10017
+
}
10018
+
}
9938
10019
9939
10020
default:
9940
10021
// Field doesn't exist on this type, so ignore it
+1
api/tangled/tangledpipeline.go
+1
api/tangled/tangledpipeline.go
+4
buf.gen.yaml
+4
buf.gen.yaml
+1
buf.yaml
+1
buf.yaml
+160
cmd/spindle/main.go
+160
cmd/spindle/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
"log/slog"
6
7
"os"
8
+
"strings"
9
+
"text/tabwriter"
10
+
"time"
7
11
8
12
"github.com/urfave/cli/v3"
9
13
tlog "tangled.org/core/log"
10
14
"tangled.org/core/spindle"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/mill"
11
17
)
12
18
13
19
func main() {
···
16
22
Usage: "spindle continuous integration runner",
17
23
Commands: []*cli.Command{
18
24
Command(),
25
+
millCommand(),
19
26
},
20
27
DefaultCommand: "run",
21
28
}
···
41
48
},
42
49
}
43
50
}
51
+
52
+
// for mill host administration and executor management
53
+
func millCommand() *cli.Command {
54
+
dbFlag := &cli.StringFlag{
55
+
Name: "db",
56
+
Usage: "path to the spindle sqlite db",
57
+
Value: "spindle.db",
58
+
Sources: cli.EnvVars("SPINDLE_SERVER_DB_PATH"),
59
+
}
60
+
openDB := func(ctx context.Context, cmd *cli.Command) (*db.DB, error) {
61
+
return db.Make(ctx, cmd.String("db"))
62
+
}
63
+
return &cli.Command{
64
+
Name: "mill",
65
+
Usage: "mill host administration",
66
+
Commands: []*cli.Command{
67
+
{
68
+
Name: "executor",
69
+
Usage: "manage executors allowed to join this mill",
70
+
Commands: []*cli.Command{
71
+
{
72
+
Name: "add",
73
+
Usage: "register an executor and print its token",
74
+
ArgsUsage: "<name>",
75
+
Flags: []cli.Flag{
76
+
dbFlag,
77
+
&cli.DurationFlag{
78
+
Name: "ttl",
79
+
Usage: "token lifetime (e.g. 720h); omit for no expiry",
80
+
},
81
+
&cli.StringSliceFlag{
82
+
Name: "label",
83
+
Usage: "authorized labels for this executor",
84
+
},
85
+
},
86
+
Action: func(ctx context.Context, cmd *cli.Command) error {
87
+
name := cmd.Args().First()
88
+
if name == "" {
89
+
return fmt.Errorf("usage: spindle mill executor add <name>")
90
+
}
91
+
d, err := openDB(ctx, cmd)
92
+
if err != nil {
93
+
return err
94
+
}
95
+
token, err := mill.GenerateToken()
96
+
if err != nil {
97
+
return err
98
+
}
99
+
var expiresAt *time.Time
100
+
if ttl := cmd.Duration("ttl"); ttl > 0 {
101
+
exp := time.Now().UTC().Add(ttl)
102
+
expiresAt = &exp
103
+
}
104
+
labels := cmd.StringSlice("label")
105
+
if err := d.AddExecutorToken(name, mill.HashToken(token), expiresAt, labels); err != nil {
106
+
return fmt.Errorf("registering executor %q: %w", name, err)
107
+
}
108
+
fmt.Println(token)
109
+
return nil
110
+
},
111
+
},
112
+
{
113
+
Name: "list",
114
+
Usage: "list registered executors",
115
+
Flags: []cli.Flag{dbFlag},
116
+
Action: func(ctx context.Context, cmd *cli.Command) error {
117
+
d, err := openDB(ctx, cmd)
118
+
if err != nil {
119
+
return err
120
+
}
121
+
tokens, err := d.ListExecutorTokens()
122
+
if err != nil {
123
+
return err
124
+
}
125
+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
126
+
fmt.Fprintln(w, "NAME\tCREATED\tEXPIRES\tLABELS\tQUARANTINE")
127
+
for _, t := range tokens {
128
+
expires := "never"
129
+
if t.ExpiresAt != nil {
130
+
expires = t.ExpiresAt.Format(time.RFC3339)
131
+
if time.Now().After(*t.ExpiresAt) {
132
+
expires += " (expired)"
133
+
}
134
+
}
135
+
labels := strings.Join(t.Labels, ",")
136
+
if labels == "" {
137
+
labels = "-"
138
+
}
139
+
quarantine := "-"
140
+
if t.QuarantineReason != nil {
141
+
quarantine = *t.QuarantineReason
142
+
if t.QuarantinedAt != nil {
143
+
quarantine = *t.QuarantinedAt + ": " + quarantine
144
+
}
145
+
}
146
+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.Name, t.CreatedAt, expires, labels, quarantine)
147
+
}
148
+
return w.Flush()
149
+
},
150
+
},
151
+
{
152
+
Name: "unquarantine",
153
+
Usage: "allow a quarantined executor to reconnect",
154
+
ArgsUsage: "<name>",
155
+
Flags: []cli.Flag{dbFlag},
156
+
Action: func(ctx context.Context, cmd *cli.Command) error {
157
+
name := cmd.Args().First()
158
+
if name == "" {
159
+
return fmt.Errorf("usage: spindle mill executor unquarantine <name>")
160
+
}
161
+
d, err := openDB(ctx, cmd)
162
+
if err != nil {
163
+
return err
164
+
}
165
+
ok, err := d.ClearExecutorQuarantine(name)
166
+
if err != nil {
167
+
return err
168
+
}
169
+
if !ok {
170
+
return fmt.Errorf("no such executor identity %q", name)
171
+
}
172
+
return nil
173
+
},
174
+
},
175
+
{
176
+
Name: "revoke",
177
+
Usage: "revoke an executor's token",
178
+
ArgsUsage: "<name>",
179
+
Flags: []cli.Flag{dbFlag},
180
+
Action: func(ctx context.Context, cmd *cli.Command) error {
181
+
name := cmd.Args().First()
182
+
if name == "" {
183
+
return fmt.Errorf("usage: spindle mill executor revoke <name>")
184
+
}
185
+
d, err := openDB(ctx, cmd)
186
+
if err != nil {
187
+
return err
188
+
}
189
+
ok, err := d.RevokeExecutorToken(name)
190
+
if err != nil {
191
+
return err
192
+
}
193
+
if !ok {
194
+
return fmt.Errorf("no such executor identity %q", name)
195
+
}
196
+
return nil
197
+
},
198
+
},
199
+
},
200
+
},
201
+
},
202
+
}
203
+
}
+157
docker-compose.mill.yml
+157
docker-compose.mill.yml
···
1
+
# turns the primary spindle into a mill host and runs a three-executor fleet
2
+
# against it. the executors differ on two axes so placement is testable:
3
+
#
4
+
# executor labels images can run
5
+
# --------- ------------- ----------- ------------------------
6
+
# executor-a linux, fast full image/alpine, image/nixos
7
+
# executor-b linux, slow full image/alpine, image/nixos
8
+
# executor-c linux, gpu alpine only image/alpine
9
+
10
+
x-mill-executor: &mill-executor
11
+
profiles: ["linux"]
12
+
build:
13
+
context: .
14
+
dockerfile: localinfra/spindle.Dockerfile
15
+
restart: unless-stopped
16
+
environment: &mill-executor-env
17
+
SPINDLE_ROLE: executor
18
+
SPINDLE_SERVER_LISTEN_ADDR: 0.0.0.0:6555
19
+
SPINDLE_SERVER_DB_PATH: /var/lib/spindle/spindle.db
20
+
SPINDLE_SERVER_PLC_URL: https://plc.tngl.boltless.dev
21
+
SPINDLE_SERVER_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe
22
+
SPINDLE_SERVER_DEV: "true"
23
+
SPINDLE_SERVER_DEV_EXTRA_HOSTS: knot.tngl.boltless.dev,mirror.tngl.boltless.dev
24
+
SPINDLE_SERVER_TAP_DB_PATH: /var/lib/spindle/tap.db
25
+
SPINDLE_SERVER_TAP_RELAY_URL: https://pds.tngl.boltless.dev
26
+
SPINDLE_MICROVM_PIPELINES_IMAGE_DIR: /var/lib/spindle/images
27
+
SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR: /var/lib/spindle/overlays
28
+
SPINDLE_ARTIFACT_STORES_DISK_DIR: /var/lib/spindle/artifacts
29
+
SPINDLE_MILL_ARTIFACT_STORE: disk
30
+
SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS: "false"
31
+
SPINDLE_NIX_CACHE_READ_URLS: http://ncps:8501
32
+
SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS: cache.local:F7YqpMzuBdILYd/v+wMZN2YKxCzliXQyFmeezOxw7rU=
33
+
SPINDLE_NIX_CACHE_UPLOAD_URL: http://ncps:8501/upload
34
+
# dials the mill container directly using ws
35
+
SPINDLE_MILL_URL: ws://spindle:6555/mill
36
+
devices:
37
+
- /dev/vsock:/dev/vsock
38
+
- /dev/kvm:/dev/kvm
39
+
- /dev/vhost-vsock:/dev/vhost-vsock
40
+
- /dev/net/tun:/dev/net/tun
41
+
cap_add:
42
+
- NET_ADMIN
43
+
- SYS_ADMIN
44
+
security_opt:
45
+
- label=disable
46
+
- seccomp=unconfined
47
+
healthcheck:
48
+
test: ["CMD", "wget", "-qO-", "http://localhost:6555/"]
49
+
interval: 2s
50
+
timeout: 2s
51
+
retries: 30
52
+
start_period: 5s
53
+
depends_on: &mill-executor-deps
54
+
plc:
55
+
condition: service_started
56
+
jetstream:
57
+
condition: service_started
58
+
init-accounts:
59
+
condition: service_completed_successfully
60
+
ncps:
61
+
condition: service_started
62
+
spindle:
63
+
condition: service_healthy
64
+
mill-tokens:
65
+
condition: service_completed_successfully
66
+
networks: [tngl]
67
+
68
+
services:
69
+
# configures the primary spindle container as a mill host
70
+
spindle:
71
+
environment:
72
+
SPINDLE_ROLE: mill
73
+
SPINDLE_ARTIFACT_STORES_DISK_DIR: /var/lib/spindle/artifacts
74
+
SPINDLE_MILL_ARTIFACT_STORE: disk
75
+
volumes:
76
+
- spindle-artifacts:/var/lib/spindle/artifacts
77
+
78
+
mill-tokens:
79
+
profiles: ["linux"]
80
+
build:
81
+
context: .
82
+
dockerfile: localinfra/spindle.Dockerfile
83
+
restart: "no"
84
+
entrypoint: ["/bin/sh", "-c"]
85
+
environment:
86
+
SPINDLE_SERVER_DB_PATH: /var/lib/spindle/spindle.db
87
+
command:
88
+
- |
89
+
set -eu
90
+
seed() {
91
+
spindle mill executor revoke "$$1" >/dev/null 2>&1 || true
92
+
args=""
93
+
for l in $$(echo "$$2" | tr ',' ' '); do args="$$args --label $$l"; done
94
+
spindle mill executor add "$$1" $$args > "/shared/$$1.mill-token"
95
+
}
96
+
seed executor-a linux,fast
97
+
seed executor-b linux,slow
98
+
seed executor-c linux,gpu
99
+
echo "seeded executor tokens"
100
+
volumes:
101
+
- spindle-data:/var/lib/spindle
102
+
- init-state:/shared
103
+
depends_on:
104
+
spindle:
105
+
condition: service_healthy
106
+
networks: [tngl]
107
+
108
+
spindle-executor-a:
109
+
<<: *mill-executor
110
+
environment:
111
+
<<: *mill-executor-env
112
+
SPINDLE_SERVER_HOSTNAME: executor-a.tngl.boltless.dev
113
+
SPINDLE_MILL_LABELS: linux,fast
114
+
SPINDLE_MILL_TOKEN_FILE: /shared/executor-a.mill-token
115
+
SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11241"
116
+
volumes:
117
+
- spindle-executor-a-data:/var/lib/spindle
118
+
- spindle-artifacts:/var/lib/spindle/artifacts
119
+
- ./out/localinfra-spindle-images:/var/lib/spindle/images:ro
120
+
- init-state:/shared:ro
121
+
- ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro
122
+
123
+
spindle-executor-b:
124
+
<<: *mill-executor
125
+
environment:
126
+
<<: *mill-executor-env
127
+
SPINDLE_SERVER_HOSTNAME: executor-b.tngl.boltless.dev
128
+
SPINDLE_MILL_LABELS: linux,slow
129
+
SPINDLE_MILL_TOKEN_FILE: /shared/executor-b.mill-token
130
+
SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11242"
131
+
volumes:
132
+
- spindle-executor-b-data:/var/lib/spindle
133
+
- spindle-artifacts:/var/lib/spindle/artifacts
134
+
- ./out/localinfra-spindle-images:/var/lib/spindle/images:ro
135
+
- init-state:/shared:ro
136
+
- ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro
137
+
138
+
spindle-executor-c:
139
+
<<: *mill-executor
140
+
environment:
141
+
<<: *mill-executor-env
142
+
SPINDLE_SERVER_HOSTNAME: executor-c.tngl.boltless.dev
143
+
SPINDLE_MILL_LABELS: linux,gpu
144
+
SPINDLE_MILL_TOKEN_FILE: /shared/executor-c.mill-token
145
+
SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11243"
146
+
volumes:
147
+
- spindle-executor-c-data:/var/lib/spindle
148
+
- spindle-artifacts:/var/lib/spindle/artifacts
149
+
- ./out/localinfra-spindle-images-alpine:/var/lib/spindle/images:ro
150
+
- init-state:/shared:ro
151
+
- ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro
152
+
153
+
volumes:
154
+
spindle-executor-a-data:
155
+
spindle-executor-b-data:
156
+
spindle-executor-c-data:
157
+
spindle-artifacts:
-1
docker-compose.yml
-1
docker-compose.yml
···
181
181
SPINDLE_MICROVM_PIPELINES_IMAGE_DIR: /var/lib/spindle/images
182
182
SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR: /var/lib/spindle/overlays
183
183
SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11240"
184
-
SPINDLE_S3_LOG_BUCKET: ""
185
184
SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS: "false"
186
185
# route guest nix substitution + uploads through the local ncps cache.
187
186
# ncps re-signs on serve with cache.local's key, so the guest trusts the
+49
eventstream/eventstream_test.go
+49
eventstream/eventstream_test.go
···
293
293
}
294
294
}
295
295
296
+
func TestHighWaterSeedsClockFromStoredEvents(t *testing.T) {
297
+
db, err := sql.Open("sqlite3", t.TempDir()+"/events.db")
298
+
if err != nil {
299
+
t.Fatalf("open: %v", err)
300
+
}
301
+
t.Cleanup(func() { db.Close() })
302
+
if _, err := db.Exec(`create table events (
303
+
rkey text not null,
304
+
nsid text not null,
305
+
event text not null,
306
+
created integer not null,
307
+
primary key (rkey, nsid)
308
+
)`); err != nil {
309
+
t.Fatalf("schema: %v", err)
310
+
}
311
+
312
+
stored := time.Now().Add(time.Hour).UnixNano()
313
+
if _, err := db.Exec(
314
+
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
315
+
"stored", "sh.tangled.test", "{}", stored,
316
+
); err != nil {
317
+
t.Fatalf("seed event: %v", err)
318
+
}
319
+
320
+
cut, err := HighWater(db)
321
+
if err != nil {
322
+
t.Fatalf("HighWater() error = %v", err)
323
+
}
324
+
if cut < stored {
325
+
t.Fatalf("HighWater() = %d, want at least stored cursor %d", cut, stored)
326
+
}
327
+
328
+
n := notifier.New()
329
+
if err := Insert(db, Event{
330
+
Rkey: "new",
331
+
Nsid: "sh.tangled.test",
332
+
EventJson: json.RawMessage("{}"),
333
+
}, &n); err != nil {
334
+
t.Fatalf("Insert() error = %v", err)
335
+
}
336
+
events, err := List(db, cut, 10)
337
+
if err != nil {
338
+
t.Fatalf("List() error = %v", err)
339
+
}
340
+
if len(events) != 1 || events[0].Rkey != "new" || events[0].Created <= cut {
341
+
t.Fatalf("events after cut = %+v, want only new event above %d", events, cut)
342
+
}
343
+
}
344
+
296
345
func isCloseErr(err error) bool {
297
346
if err == nil {
298
347
return false
+29
eventstream/store.go
+29
eventstream/store.go
···
19
19
lastNanos int64
20
20
)
21
21
22
+
func HighWater(s Store) (int64, error) {
23
+
clockMu.Lock()
24
+
defer clockMu.Unlock()
25
+
26
+
rows, err := s.Query(`select coalesce(max(created), 0) from events`)
27
+
if err != nil {
28
+
return 0, err
29
+
}
30
+
defer rows.Close()
31
+
32
+
var created int64
33
+
if !rows.Next() {
34
+
if err := rows.Err(); err != nil {
35
+
return 0, err
36
+
}
37
+
return 0, sql.ErrNoRows
38
+
}
39
+
if err := rows.Scan(&created); err != nil {
40
+
return 0, err
41
+
}
42
+
if err := rows.Err(); err != nil {
43
+
return 0, err
44
+
}
45
+
if created > lastNanos {
46
+
lastNanos = created
47
+
}
48
+
return lastNanos, nil
49
+
}
50
+
22
51
func Insert(s Store, ev Event, n *notifier.Notifier) error {
23
52
clockMu.Lock()
24
53
defer clockMu.Unlock()
+6
lexicons/pipeline/pipeline.json
+6
lexicons/pipeline/pipeline.json
+20
localinfra/readme.md
+20
localinfra/readme.md
···
51
51
This writes the image directory under `out/localinfra-spindle-images`.
52
52
5. `docker compose up`
53
53
6. AppView will be running on `127.0.0.1:3000` with two test users: `alice.pds.tngl.boltless.dev` and `bob.pds.tngl.boltless.dev`. Both with password `password`.
54
+
55
+
## Mill mode
56
+
57
+
The default stack runs one standalone spindle. `docker-compose.mill.yml` is an overlay that splits that into the distributed arch: the primary spindle becomes the mill host (`role=mill`, it only places jobs) and a three-executor fleet (`role=executor`) runs the engines.
58
+
59
+
```bash
60
+
docker compose -f docker-compose.yml -f docker-compose.mill.yml --profile linux up
61
+
```
62
+
63
+
The executors differ on the two axes placement cares about, so you can watch candidates get filtered and ranked:
64
+
65
+
| executor | labels | images | runs |
66
+
|------------|---------------|-------------|-----------------------------|
67
+
| executor-a | `linux, fast` | full | `image/alpine, image/nixos` |
68
+
| executor-b | `linux, slow` | full | `image/alpine, image/nixos` |
69
+
| executor-c | `linux, gpu` | alpine only | `image/alpine` |
70
+
71
+
So `image: nixos` has two candidates, `image: alpine` has three, and `runs_on: [gpu]` pins to executor-c. The alpine-only image set is staged by `prepare-spindle-images.sh` (step 4) alongside the full one, no extra step.
72
+
73
+
Each executor needs its own identity (one live session per token), so the `mill-tokens` service registers a token per executor in the mill db and drops it into the shared volume for the executor to read.
+10
localinfra/scripts/prepare-spindle-images.sh
+10
localinfra/scripts/prepare-spindle-images.sh
···
29
29
extract_image spindle-nixos-image-tarball nixos-x86_64 nixos
30
30
extract_image spindle-alpine-image-tarball alpine-x86_64 alpine
31
31
32
+
# a reduced image set (alpine only) for the mill overlay: an executor mounting
33
+
# this advertises image/alpine but not image/nixos, so capability-based
34
+
# placement is testable across a mixed fleet
35
+
alpine_only="$repo/out/localinfra-spindle-images-alpine"
36
+
[ -d "$alpine_only" ] && chmod -R +w "$alpine_only" || true
37
+
rm -rf "$alpine_only"
38
+
mkdir -p "$alpine_only"
39
+
cp -r "$image_root/alpine-x86_64" "$alpine_only/alpine-x86_64"
40
+
ln -s alpine-x86_64 "$alpine_only/alpine"
41
+
32
42
echo "prepared spindle microVM images in $image_root"
+5
localinfra/spindle.Dockerfile
+5
localinfra/spindle.Dockerfile
···
49
49
export SPINDLE_SERVER_OWNER="$(cat /shared/owner-did)"
50
50
: "${SPINDLE_SERVER_OWNER:?set via env or /shared/owner-did}"
51
51
52
+
# executors read their mill token from a file seeded by the mill-tokens
53
+
# service (command substitution strips the trailing newline)
54
+
[ -z "${SPINDLE_MILL_SHARED_SECRET:-}" ] && [ -n "${SPINDLE_MILL_TOKEN_FILE:-}" ] && [ -r "${SPINDLE_MILL_TOKEN_FILE}" ] && \
55
+
export SPINDLE_MILL_SHARED_SECRET="$(cat "${SPINDLE_MILL_TOKEN_FILE}")"
56
+
52
57
mkdir -p /var/lib/spindle /var/lib/spindle/overlays /var/log/spindle
53
58
54
59
if [ -f /usr/local/share/ca-certificates/caddy.crt ]; then
+3
-3
netutil/ssrf.go
+3
-3
netutil/ssrf.go
···
10
10
"github.com/gorilla/websocket"
11
11
)
12
12
13
-
// SSRFDialer returns a net.Dialer that refuses non-public IPs.
13
+
// refuses non-public ips to prevent ssrf
14
14
func SSRFDialer(dev bool) *net.Dialer {
15
15
if dev {
16
16
return &net.Dialer{}
···
18
18
return ssrf.PublicOnlyDialer()
19
19
}
20
20
21
-
// SSRFTransport returns an http.Transport that refuses non-public IPs.
21
+
// refuses non-public ips to prevent ssrf
22
22
func SSRFTransport(dev bool) *http.Transport {
23
23
if dev {
24
24
return &http.Transport{}
···
26
26
return ssrf.PublicOnlyTransport()
27
27
}
28
28
29
-
// SSRFWebsocketDialer returns a websocket.Dialer that refuses non-public IPs.
29
+
// refuses non-public ips to prevent ssrf
30
30
func SSRFWebsocketDialer(dev bool) *websocket.Dialer {
31
31
dialer := *websocket.DefaultDialer
32
32
dialer.NetDialContext = SSRFDialer(dev).DialContext
+22
-4
nix/modules/spindle.nix
+22
-4
nix/modules/spindle.nix
···
136
136
};
137
137
};
138
138
139
-
pipelines = {
140
-
logBucket = mkOption {
139
+
artifactStores = {
140
+
disk.dir = mkOption {
141
+
type = types.path;
142
+
default = "/var/log/spindle";
143
+
description = "Root directory for disk artifacts";
144
+
};
145
+
146
+
s3.bucket = mkOption {
141
147
type = types.str;
142
148
default = "tangled-logs";
143
-
description = "S3 bucket for workflow logs";
149
+
description = "S3 bucket for artifacts";
150
+
};
151
+
152
+
s3.region = mkOption {
153
+
type = types.str;
154
+
default = "us-east-1";
155
+
description = "AWS region for the artifact bucket";
144
156
};
157
+
};
158
+
159
+
pipelines = {
145
160
workflowTimeout = mkOption {
146
161
type = types.str;
147
162
default = "5m";
···
388
403
"SPINDLE_NIX_CACHE_READ_URLS=${concatStringsSep "," cfg.pipelines.nixCache.readUrls}"
389
404
"SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS=${concatStringsSep "," cfg.pipelines.nixCache.trustedPublicKeys}"
390
405
"SPINDLE_NIX_CACHE_UPLOAD_URL=${cfg.pipelines.nixCache.uploadUrl}"
391
-
"SPINDLE_S3_LOG_BUCKET=${cfg.pipelines.logBucket}"
406
+
"SPINDLE_ARTIFACT_STORES_DISK_DIR=${cfg.artifactStores.disk.dir}"
407
+
"SPINDLE_ARTIFACT_STORES_S3_BUCKET=${cfg.artifactStores.s3.bucket}"
408
+
"SPINDLE_ARTIFACT_STORES_S3_REGION=${cfg.artifactStores.s3.region}"
409
+
"SPINDLE_MILL_ARTIFACT_STORE=s3"
392
410
];
393
411
ExecStart = "${cfg.package}/bin/spindle";
394
412
Restart = "always";
+2
-1
nix/vm.nix
+2
-1
nix/vm.nix
···
162
162
};
163
163
};
164
164
165
+
artifactStores.s3.bucket = envVarOr "SPINDLE_ARTIFACT_STORES_S3_BUCKET" "tangled-logs";
166
+
165
167
pipelines = {
166
-
logBucket = envVarOr "SPINDLE_S3_LOG_BUCKET" "";
167
168
microvm.enableKVM = nestedVirt;
168
169
nixCache = {
169
170
readUrls = ["http://127.0.0.1:8501"];
+240
spindle/artifactstore/artifactstore.go
+240
spindle/artifactstore/artifactstore.go
···
1
+
package artifactstore
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"os"
9
+
"path/filepath"
10
+
"strings"
11
+
12
+
"github.com/aws/aws-sdk-go-v2/aws"
13
+
"github.com/aws/aws-sdk-go-v2/config"
14
+
"github.com/aws/aws-sdk-go-v2/service/s3"
15
+
spindleconfig "tangled.org/core/spindle/config"
16
+
)
17
+
18
+
type Writer interface {
19
+
Put(ctx context.Context, ref string, r io.Reader) error
20
+
}
21
+
22
+
type Reader interface {
23
+
Open(ctx context.Context, ref string) (io.ReadCloser, error)
24
+
}
25
+
26
+
type Store interface {
27
+
Writer
28
+
Reader
29
+
}
30
+
31
+
type DiskStore struct {
32
+
root string
33
+
}
34
+
35
+
func NewDiskStore(root string) (*DiskStore, error) {
36
+
if root == "" {
37
+
return nil, fmt.Errorf("artifact disk directory is required")
38
+
}
39
+
return &DiskStore{root: filepath.Clean(root)}, nil
40
+
}
41
+
42
+
func (s *DiskStore) Put(_ context.Context, ref string, r io.Reader) error {
43
+
path, err := s.resolve(ref)
44
+
if err != nil {
45
+
return err
46
+
}
47
+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
48
+
return fmt.Errorf("mkdir for artifact %q: %w", path, err)
49
+
}
50
+
tmpFile, err := os.CreateTemp(filepath.Dir(path), ".tmp-artifact-*")
51
+
if err != nil {
52
+
return fmt.Errorf("create temp artifact: %w", err)
53
+
}
54
+
tmpPath := tmpFile.Name()
55
+
defer func() {
56
+
_ = tmpFile.Close()
57
+
_ = os.Remove(tmpPath)
58
+
}()
59
+
if _, err := io.Copy(tmpFile, r); err != nil {
60
+
return fmt.Errorf("write artifact content: %w", err)
61
+
}
62
+
if err := tmpFile.Sync(); err != nil {
63
+
return fmt.Errorf("sync artifact file: %w", err)
64
+
}
65
+
if err := tmpFile.Close(); err != nil {
66
+
return fmt.Errorf("close artifact file: %w", err)
67
+
}
68
+
if err := os.Rename(tmpPath, path); err != nil {
69
+
return fmt.Errorf("rename artifact file to target: %w", err)
70
+
}
71
+
return nil
72
+
}
73
+
74
+
func (s *DiskStore) Open(_ context.Context, ref string) (io.ReadCloser, error) {
75
+
path, err := s.resolve(ref)
76
+
if err != nil {
77
+
return nil, err
78
+
}
79
+
f, err := os.Open(path)
80
+
if err != nil {
81
+
return nil, fmt.Errorf("open disk artifact %q: %w", path, err)
82
+
}
83
+
return f, nil
84
+
}
85
+
86
+
func (s *DiskStore) resolve(ref string) (string, error) {
87
+
if ref == "" || filepath.IsAbs(ref) {
88
+
return "", fmt.Errorf("invalid disk artifact ref %q", ref)
89
+
}
90
+
path := filepath.Join(s.root, filepath.Clean(ref))
91
+
rel, err := filepath.Rel(s.root, path)
92
+
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
93
+
return "", fmt.Errorf("artifact ref %q escapes disk root %q", ref, s.root)
94
+
}
95
+
return path, nil
96
+
}
97
+
98
+
type s3API interface {
99
+
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
100
+
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
101
+
}
102
+
103
+
type S3Store struct {
104
+
client s3API
105
+
bucket string
106
+
}
107
+
108
+
func NewS3Store(client s3API, bucket string) (*S3Store, error) {
109
+
if client == nil {
110
+
return nil, fmt.Errorf("s3 client is required")
111
+
}
112
+
if bucket == "" {
113
+
return nil, fmt.Errorf("artifact S3 bucket is required")
114
+
}
115
+
return &S3Store{client: client, bucket: bucket}, nil
116
+
}
117
+
118
+
func (s *S3Store) Put(ctx context.Context, ref string, r io.Reader) error {
119
+
if err := validateObjectRef(ref); err != nil {
120
+
return err
121
+
}
122
+
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
123
+
Bucket: aws.String(s.bucket),
124
+
Key: aws.String(ref),
125
+
Body: r,
126
+
})
127
+
if err != nil {
128
+
return fmt.Errorf("s3 put object: %w", err)
129
+
}
130
+
return nil
131
+
}
132
+
133
+
func (s *S3Store) Open(ctx context.Context, ref string) (io.ReadCloser, error) {
134
+
if err := validateObjectRef(ref); err != nil {
135
+
return nil, err
136
+
}
137
+
res, err := s.client.GetObject(ctx, &s3.GetObjectInput{
138
+
Bucket: aws.String(s.bucket),
139
+
Key: aws.String(ref),
140
+
})
141
+
if err != nil {
142
+
return nil, fmt.Errorf("s3 get object: %w", err)
143
+
}
144
+
return res.Body, nil
145
+
}
146
+
147
+
func validateObjectRef(ref string) error {
148
+
if ref == "" || strings.HasPrefix(ref, "/") || strings.Contains(ref, "://") {
149
+
return fmt.Errorf("invalid artifact ref %q", ref)
150
+
}
151
+
return nil
152
+
}
153
+
154
+
type Stores struct {
155
+
order []string
156
+
stores map[string]Store
157
+
}
158
+
159
+
func NewStores(cfg spindleconfig.ArtifactStores, diskFallback, legacyS3Bucket string) (*Stores, error) {
160
+
stores := &Stores{stores: make(map[string]Store)}
161
+
diskDir := cfg.Disk.Dir
162
+
if diskDir == "" {
163
+
diskDir = diskFallback
164
+
}
165
+
if diskDir != "" {
166
+
disk, err := NewDiskStore(diskDir)
167
+
if err != nil {
168
+
return nil, err
169
+
}
170
+
stores.order = append(stores.order, "disk")
171
+
stores.stores["disk"] = disk
172
+
}
173
+
174
+
bucket := cfg.S3.Bucket
175
+
if bucket == "" {
176
+
bucket = legacyS3Bucket
177
+
}
178
+
if bucket != "" {
179
+
awsCfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(cfg.S3.Region))
180
+
if err != nil {
181
+
return nil, fmt.Errorf("load aws config: %w", err)
182
+
}
183
+
s3Store, err := NewS3Store(s3.NewFromConfig(awsCfg), bucket)
184
+
if err != nil {
185
+
return nil, err
186
+
}
187
+
stores.order = append(stores.order, "s3")
188
+
stores.stores["s3"] = s3Store
189
+
}
190
+
return stores, nil
191
+
}
192
+
193
+
func (s *Stores) Names() []string {
194
+
return append([]string(nil), s.order...)
195
+
}
196
+
197
+
func (s *Stores) Store(name string) (Store, bool) {
198
+
store, ok := s.stores[name]
199
+
return store, ok
200
+
}
201
+
202
+
func (s *Stores) Open(ctx context.Context, ref string) (io.ReadCloser, error) {
203
+
var errs []error
204
+
for _, name := range s.order {
205
+
rc, err := s.stores[name].Open(ctx, ref)
206
+
if err == nil {
207
+
return rc, nil
208
+
}
209
+
errs = append(errs, fmt.Errorf("%s: %w", name, err))
210
+
}
211
+
return nil, fmt.Errorf("open artifact %q: %w", ref, errors.Join(errs...))
212
+
}
213
+
214
+
func (s *Stores) PutFile(ctx context.Context, ref, sourcePath string) []error {
215
+
var errs []error
216
+
for _, name := range s.order {
217
+
store := s.stores[name]
218
+
if disk, ok := store.(*DiskStore); ok {
219
+
target, err := disk.resolve(ref)
220
+
if err == nil {
221
+
source, sourceErr := filepath.Abs(sourcePath)
222
+
targetAbs, targetErr := filepath.Abs(target)
223
+
if sourceErr == nil && targetErr == nil && source == targetAbs {
224
+
continue
225
+
}
226
+
}
227
+
}
228
+
file, err := os.Open(sourcePath)
229
+
if err != nil {
230
+
errs = append(errs, fmt.Errorf("%s: open source: %w", name, err))
231
+
continue
232
+
}
233
+
err = store.Put(ctx, ref, file)
234
+
_ = file.Close()
235
+
if err != nil {
236
+
errs = append(errs, fmt.Errorf("%s: %w", name, err))
237
+
}
238
+
}
239
+
return errs
240
+
}
+130
spindle/artifactstore/artifactstore_test.go
+130
spindle/artifactstore/artifactstore_test.go
···
1
+
package artifactstore
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"io"
7
+
"os"
8
+
"strings"
9
+
"sync"
10
+
"testing"
11
+
12
+
"github.com/aws/aws-sdk-go-v2/service/s3"
13
+
)
14
+
15
+
type mockS3Client struct {
16
+
mu sync.Mutex
17
+
store map[string][]byte
18
+
}
19
+
20
+
func newMockS3Client() *mockS3Client {
21
+
return &mockS3Client{
22
+
store: make(map[string][]byte),
23
+
}
24
+
}
25
+
26
+
func (m *mockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
27
+
m.mu.Lock()
28
+
defer m.mu.Unlock()
29
+
b, err := io.ReadAll(params.Body)
30
+
if err != nil {
31
+
return nil, err
32
+
}
33
+
key := *params.Bucket + "/" + *params.Key
34
+
m.store[key] = b
35
+
return &s3.PutObjectOutput{}, nil
36
+
}
37
+
38
+
func (m *mockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
39
+
m.mu.Lock()
40
+
defer m.mu.Unlock()
41
+
key := *params.Bucket + "/" + *params.Key
42
+
data, ok := m.store[key]
43
+
if !ok {
44
+
return nil, os.ErrNotExist
45
+
}
46
+
return &s3.GetObjectOutput{
47
+
Body: io.NopCloser(bytes.NewReader(data)),
48
+
}, nil
49
+
}
50
+
51
+
func TestDiskStore(t *testing.T) {
52
+
tempDir := t.TempDir()
53
+
store, err := NewDiskStore(tempDir)
54
+
if err != nil {
55
+
t.Fatal(err)
56
+
}
57
+
58
+
ctx := context.Background()
59
+
ref := "logs/test.log"
60
+
content := "hello world log content"
61
+
62
+
if err := store.Put(ctx, ref, strings.NewReader(content)); err != nil {
63
+
t.Fatalf("Put failed: %v", err)
64
+
}
65
+
66
+
rc, err := store.Open(ctx, ref)
67
+
if err != nil {
68
+
t.Fatalf("Open failed: %v", err)
69
+
}
70
+
defer rc.Close()
71
+
72
+
got, err := io.ReadAll(rc)
73
+
if err != nil {
74
+
t.Fatalf("ReadAll failed: %v", err)
75
+
}
76
+
if string(got) != content {
77
+
t.Fatalf("got content %q, want %q", string(got), content)
78
+
}
79
+
}
80
+
81
+
func TestDiskStoreTraversalProtection(t *testing.T) {
82
+
tempDir := t.TempDir()
83
+
store, err := NewDiskStore(tempDir)
84
+
if err != nil {
85
+
t.Fatal(err)
86
+
}
87
+
88
+
ctx := context.Background()
89
+
badRef := "../outside"
90
+
91
+
err = store.Put(ctx, badRef, strings.NewReader("bad"))
92
+
if err == nil {
93
+
t.Fatal("expected error putting file outside diskDir, got nil")
94
+
}
95
+
96
+
_, err = store.Open(ctx, badRef)
97
+
if err == nil {
98
+
t.Fatal("expected error opening file outside diskDir, got nil")
99
+
}
100
+
}
101
+
102
+
func TestS3Store(t *testing.T) {
103
+
mock := newMockS3Client()
104
+
store, err := NewS3Store(mock, "mybucket")
105
+
if err != nil {
106
+
t.Fatal(err)
107
+
}
108
+
109
+
ctx := context.Background()
110
+
ref := "logs/run1.log"
111
+
content := "s3 log payload"
112
+
113
+
if err := store.Put(ctx, ref, strings.NewReader(content)); err != nil {
114
+
t.Fatalf("Put to S3 failed: %v", err)
115
+
}
116
+
117
+
rc, err := store.Open(ctx, ref)
118
+
if err != nil {
119
+
t.Fatalf("Open from S3 failed: %v", err)
120
+
}
121
+
defer rc.Close()
122
+
123
+
got, err := io.ReadAll(rc)
124
+
if err != nil {
125
+
t.Fatalf("ReadAll failed: %v", err)
126
+
}
127
+
if string(got) != content {
128
+
t.Fatalf("got content %q, want %q", string(got), content)
129
+
}
130
+
}
+63
-3
spindle/config/config.go
+63
-3
spindle/config/config.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
"time"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
···
57
58
MaxConcurrentWorkflows int `env:"MAX_CONCURRENT_WORKFLOWS, default=8"` // max number of workflow containers running at once (memory cap)
58
59
}
59
60
60
-
type S3 struct {
61
+
type ArtifactStoreDisk struct {
62
+
Dir string `env:"DIR"`
63
+
}
64
+
65
+
type ArtifactStoreS3 struct {
66
+
Bucket string `env:"BUCKET"`
67
+
Region string `env:"REGION, default=us-east-1"`
68
+
}
69
+
70
+
type ArtifactStores struct {
71
+
Disk ArtifactStoreDisk `env:",prefix=DISK_"`
72
+
S3 ArtifactStoreS3 `env:",prefix=S3_"`
73
+
}
74
+
75
+
type LegacyS3 struct {
61
76
LogBucket string `env:"LOG_BUCKET"`
62
77
}
63
78
64
79
type MicroVMPipelines struct {
65
80
ImageDir string `env:"IMAGE_DIR"`
66
-
OverlayDir string `env:"OVERLAY_DIR, default="` // where microVM temporary disks will live
81
+
OverlayDir string `env:"OVERLAY_DIR"` // where microVM temporary disks will live
67
82
DefaultImage string `env:"DEFAULT_IMAGE, default=nixos-x86_64"`
68
83
AgentPort uint32 `env:"AGENT_PORT, default=10240"`
69
84
EnableKVM bool `env:"ENABLE_KVM, default=true"`
···
93
108
UploadURL string `env:"UPLOAD_URL"`
94
109
}
95
110
111
+
// governs how spindle places and runs jobs
112
+
type Role string
113
+
114
+
const (
115
+
RoleStandalone Role = "standalone"
116
+
RoleMill Role = "mill"
117
+
RoleExecutor Role = "executor"
118
+
)
119
+
120
+
// fields are selectively active depending on the role
121
+
type Mill struct {
122
+
URL string `env:"URL"` // mill websocket endpoint dialled by the executor
123
+
SharedSecret string `env:"SHARED_SECRET"` // the executor's token for dialing the mill
124
+
MaxPending int `env:"MAX_PENDING, default=100"` // mill pending job queue limit
125
+
ReconnectGrace time.Duration `env:"RECONNECT_GRACE, default=45s"` // reconnect window before leases are failed
126
+
Seats int `env:"SEATS, default=4"` // executor seats advertised to the mill
127
+
Labels []string `env:"LABELS"` // executor capability labels
128
+
ArtifactStore string `env:"ARTIFACT_STORE"` // store shared by mill and its executors
129
+
}
130
+
96
131
type Config struct {
132
+
Role Role `env:"SPINDLE_ROLE, default=standalone"`
97
133
Server Server `env:",prefix=SPINDLE_SERVER_"`
98
134
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
99
135
MicroVMPipelines MicroVMPipelines `env:",prefix=SPINDLE_MICROVM_PIPELINES_"`
100
136
NixCache NixCache `env:",prefix=SPINDLE_NIX_CACHE_"`
101
-
S3 S3 `env:",prefix=SPINDLE_S3_"`
137
+
ArtifactStores ArtifactStores `env:",prefix=SPINDLE_ARTIFACT_STORES_"`
138
+
LegacyS3 LegacyS3 `env:",prefix=SPINDLE_S3_"`
139
+
Mill Mill `env:",prefix=SPINDLE_MILL_"`
140
+
}
141
+
142
+
func (c *Config) validate() error {
143
+
switch c.Role {
144
+
case RoleStandalone, RoleMill:
145
+
if c.Mill.URL != "" {
146
+
return fmt.Errorf("SPINDLE_MILL_URL is set but SPINDLE_ROLE=%s; only an executor dials a mill", c.Role)
147
+
}
148
+
case RoleExecutor:
149
+
if c.Mill.URL == "" {
150
+
return fmt.Errorf("SPINDLE_ROLE=executor requires SPINDLE_MILL_URL (the mill to dial)")
151
+
}
152
+
if c.Mill.SharedSecret == "" {
153
+
return fmt.Errorf("SPINDLE_ROLE=executor requires SPINDLE_MILL_SHARED_SECRET (its executor token)")
154
+
}
155
+
default:
156
+
return fmt.Errorf("unknown SPINDLE_ROLE %q (want standalone, mill, or executor)", c.Role)
157
+
}
158
+
return nil
102
159
}
103
160
104
161
func Load(ctx context.Context) (*Config, error) {
···
107
164
if err != nil {
108
165
return nil, err
109
166
}
167
+
if err := cfg.validate(); err != nil {
168
+
return nil, err
169
+
}
110
170
111
171
return &cfg, nil
112
172
}
+64
-12
spindle/db/db.go
+64
-12
spindle/db/db.go
···
79
79
);
80
80
81
81
create table if not exists spindle_members (
82
-
-- identifiers for the record
83
82
id integer primary key autoincrement,
84
83
did text not null,
85
84
rkey text not null,
86
-
87
-
-- data
88
85
instance text not null,
89
86
subject text not null,
90
87
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
91
-
92
-
-- constraints
93
88
unique (did, rkey)
94
89
);
95
90
96
-
-- status event for a single workflow
97
91
create table if not exists events (
98
92
rkey text not null,
99
93
nsid text not null,
100
-
event text not null, -- json
101
-
created integer not null -- unix nanos
94
+
event text not null,
95
+
created integer not null
102
96
);
103
97
104
98
create table if not exists nixos_toplevel_cache (
···
132
126
foreign key (pipeline_id) references pipelines(id) on delete cascade
133
127
);
134
128
129
+
create table if not exists mill_executors (
130
+
name text primary key,
131
+
token_hash text not null unique,
132
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
133
+
expires_at text,
134
+
labels text,
135
+
quarantine_reason text,
136
+
quarantined_at text
137
+
);
138
+
139
+
create table if not exists mill_leases (
140
+
lease_id text primary key,
141
+
node_id text not null,
142
+
epoch text not null,
143
+
engine text not null,
144
+
knot text not null,
145
+
rkey text not null,
146
+
workflow text not null,
147
+
state text not null
148
+
);
149
+
150
+
create table if not exists mill_executor_cursors (
151
+
node_id text not null,
152
+
epoch text not null,
153
+
acked_seqno integer not null,
154
+
primary key (node_id, epoch)
155
+
);
156
+
157
+
create table if not exists mill_outbox_state (
158
+
epoch text not null,
159
+
next_seqno integer not null,
160
+
primary key (epoch)
161
+
);
162
+
163
+
create table if not exists mill_outbox_rows (
164
+
epoch text not null,
165
+
seqno integer not null,
166
+
payload blob not null,
167
+
byte_size integer not null,
168
+
control integer not null,
169
+
primary key (epoch, seqno),
170
+
foreign key (epoch) references mill_outbox_state(epoch) on delete cascade
171
+
);
172
+
173
+
create table if not exists mill_artifacts (
174
+
id integer primary key autoincrement,
175
+
lease_id text not null,
176
+
workflow text not null,
177
+
ref text not null,
178
+
hash text not null
179
+
);
180
+
181
+
create table if not exists executor_pending_artifacts (
182
+
lease_id text primary key,
183
+
workflow text not null,
184
+
status text not null,
185
+
error text not null default '',
186
+
exit_code integer not null default 0,
187
+
ref text not null,
188
+
hash text not null
189
+
);
190
+
135
191
create table if not exists migrations (
136
192
id integer primary key autoincrement,
137
193
name text unique
138
194
);
139
195
`)
140
-
if err != nil {
141
-
return nil, err
142
-
}
143
-
144
196
if err := runMigrations(ctx, conn, logger); err != nil {
145
197
return nil, err
146
198
}
+59
-15
spindle/db/events.go
+59
-15
spindle/db/events.go
···
19
19
return eventstream.List(d, cursor, limit)
20
20
}
21
21
22
+
func (d *DB) EventHighWater() (int64, error) {
23
+
return eventstream.HighWater(d)
24
+
}
25
+
22
26
func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error {
23
27
eventJson, err := json.Marshal(pipeline)
24
28
if err != nil {
···
32
36
return d.insertEvent(event, n)
33
37
}
34
38
35
-
func (d *DB) createStatusEvent(
36
-
workflowId models.WorkflowId,
37
-
statusKind models.StatusKind,
38
-
workflowError *string,
39
-
exitCode *int64,
40
-
n *notifier.Notifier,
41
-
) error {
42
-
now := time.Now()
43
-
pipelineAtUri := workflowId.PipelineId.AtUri()
39
+
// leaves created at zero so insertevent stamps the local clock
40
+
func statusEvent(pipelineAtUri, workflow, status string, workflowError *string, exitCode *int64) (eventstream.Event, error) {
44
41
s := tangled.PipelineStatus{
45
-
CreatedAt: now.Format(time.RFC3339),
42
+
CreatedAt: time.Now().Format(time.RFC3339),
46
43
Error: workflowError,
47
44
ExitCode: exitCode,
48
-
Pipeline: string(pipelineAtUri),
49
-
Workflow: workflowId.Name,
50
-
Status: string(statusKind),
45
+
Pipeline: pipelineAtUri,
46
+
Workflow: workflow,
47
+
Status: status,
51
48
}
52
49
53
50
eventJson, err := json.Marshal(s)
54
51
if err != nil {
55
-
return err
52
+
return eventstream.Event{}, err
56
53
}
57
54
58
-
event := eventstream.Event{
55
+
return eventstream.Event{
59
56
Rkey: tid.TID(),
60
57
Nsid: tangled.PipelineStatusNSID,
61
58
EventJson: eventJson,
59
+
}, nil
60
+
}
61
+
62
+
func (d *DB) createStatusEvent(
63
+
workflowId models.WorkflowId,
64
+
statusKind models.StatusKind,
65
+
workflowError *string,
66
+
exitCode *int64,
67
+
n *notifier.Notifier,
68
+
) error {
69
+
event, err := statusEvent(string(workflowId.PipelineId.AtUri()), workflowId.Name, string(statusKind), workflowError, exitCode)
70
+
if err != nil {
71
+
return err
62
72
}
73
+
return d.insertEvent(event, n)
74
+
}
63
75
76
+
// stamps the mill's own clock so it orders against the cursor like a local write
77
+
func (d *DB) InsertEventStatus(
78
+
pipelineAtUri string,
79
+
workflow string,
80
+
status string,
81
+
workflowError *string,
82
+
exitCode *int64,
83
+
n *notifier.Notifier,
84
+
) error {
85
+
event, err := statusEvent(pipelineAtUri, workflow, status, workflowError, exitCode)
86
+
if err != nil {
87
+
return err
88
+
}
64
89
return d.insertEvent(event, n)
65
90
}
66
91
92
+
// deleting the lease in the same transaction prevents the terminal event
93
+
// from replaying
94
+
func (d *DB) CompleteMillLease(
95
+
leaseID string,
96
+
pipelineAtUri string,
97
+
workflow string,
98
+
status string,
99
+
workflowError *string,
100
+
exitCode *int64,
101
+
n *notifier.Notifier,
102
+
) error {
103
+
return d.ApplyEventBatch(n, func(tx *EventBatchTx) error {
104
+
if err := tx.InsertStatusEvent(pipelineAtUri, workflow, status, workflowError, exitCode); err != nil {
105
+
return err
106
+
}
107
+
return tx.DeleteLease(leaseID)
108
+
})
109
+
}
110
+
67
111
func (d *DB) GetStatus(workflowId models.WorkflowId) (*tangled.PipelineStatus, error) {
68
112
pipelineAtUri := workflowId.PipelineId.AtUri()
69
113
+392
spindle/db/mill_state.go
+392
spindle/db/mill_state.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
7
+
"tangled.org/core/eventstream"
8
+
"tangled.org/core/notifier"
9
+
)
10
+
11
+
// enough to rebuild the fencing token and workflow identity after a restart
12
+
type MillLease struct {
13
+
LeaseID string
14
+
NodeID string
15
+
Epoch string
16
+
Engine string
17
+
Knot string
18
+
Rkey string
19
+
Workflow string
20
+
State string
21
+
}
22
+
23
+
type ExecutorCursor struct {
24
+
NodeID string
25
+
Epoch string
26
+
AckedSeqno uint64
27
+
}
28
+
29
+
type OutboxRow struct {
30
+
Epoch string
31
+
Seqno uint64
32
+
Payload []byte
33
+
ByteSize int64
34
+
Control bool
35
+
}
36
+
type OutboxDeletion struct {
37
+
Rows int64
38
+
Bytes int64
39
+
}
40
+
41
+
func (d *DB) SaveMillLease(l MillLease) error {
42
+
_, err := d.Exec(
43
+
`insert into mill_leases (
44
+
lease_id, node_id, epoch, engine, knot, rkey, workflow, state
45
+
) values (?, ?, ?, ?, ?, ?, ?, ?)
46
+
on conflict(lease_id) do update set state = excluded.state`,
47
+
l.LeaseID, l.NodeID, l.Epoch, l.Engine, l.Knot, l.Rkey, l.Workflow, l.State,
48
+
)
49
+
return err
50
+
}
51
+
52
+
func (d *DB) DeleteMillLease(leaseID string) error {
53
+
_, err := d.Exec(`delete from mill_leases where lease_id = ?`, leaseID)
54
+
return err
55
+
}
56
+
57
+
func (d *DB) ListMillLeases() ([]MillLease, error) {
58
+
rows, err := d.Query(`
59
+
select lease_id, node_id, epoch, engine, knot, rkey, workflow, state
60
+
from mill_leases
61
+
`)
62
+
if err != nil {
63
+
return nil, err
64
+
}
65
+
defer rows.Close()
66
+
67
+
var leases []MillLease
68
+
for rows.Next() {
69
+
var l MillLease
70
+
if err := rows.Scan(
71
+
&l.LeaseID, &l.NodeID, &l.Epoch, &l.Engine, &l.Knot, &l.Rkey, &l.Workflow, &l.State,
72
+
); err != nil {
73
+
return nil, err
74
+
}
75
+
leases = append(leases, l)
76
+
}
77
+
return leases, rows.Err()
78
+
}
79
+
80
+
func (d *DB) ListExecutorCursors() ([]ExecutorCursor, error) {
81
+
rows, err := d.Query(`select node_id, epoch, acked_seqno from mill_executor_cursors`)
82
+
if err != nil {
83
+
return nil, err
84
+
}
85
+
defer rows.Close()
86
+
87
+
var cursors []ExecutorCursor
88
+
for rows.Next() {
89
+
var c ExecutorCursor
90
+
if err := rows.Scan(&c.NodeID, &c.Epoch, &c.AckedSeqno); err != nil {
91
+
return nil, err
92
+
}
93
+
cursors = append(cursors, c)
94
+
}
95
+
return cursors, rows.Err()
96
+
}
97
+
98
+
func (d *DB) SetOutboxEpoch(epoch string) error {
99
+
tx, err := d.Begin()
100
+
if err != nil {
101
+
return err
102
+
}
103
+
defer tx.Rollback()
104
+
105
+
if _, err := tx.Exec(`delete from mill_outbox_rows`); err != nil {
106
+
return err
107
+
}
108
+
if _, err := tx.Exec(`delete from mill_outbox_state`); err != nil {
109
+
return err
110
+
}
111
+
if _, err := tx.Exec(`insert into mill_outbox_state (epoch, next_seqno) values (?, 1)`, epoch); err != nil {
112
+
return err
113
+
}
114
+
return tx.Commit()
115
+
}
116
+
117
+
func (d *DB) AppendOutboxRow(payload []byte, control bool) (uint64, error) {
118
+
tx, err := d.Begin()
119
+
if err != nil {
120
+
return 0, err
121
+
}
122
+
defer tx.Rollback()
123
+
124
+
var epoch string
125
+
var nextSeqno uint64
126
+
err = tx.QueryRow(`select epoch, next_seqno from mill_outbox_state limit 1`).Scan(&epoch, &nextSeqno)
127
+
if err == sql.ErrNoRows {
128
+
return 0, fmt.Errorf("no outbox epoch set")
129
+
} else if err != nil {
130
+
return 0, err
131
+
}
132
+
133
+
byteSize := int64(len(payload))
134
+
controlVal := 0
135
+
if control {
136
+
controlVal = 1
137
+
}
138
+
139
+
if _, err := tx.Exec(
140
+
`insert into mill_outbox_rows (epoch, seqno, payload, byte_size, control) values (?, ?, ?, ?, ?)`,
141
+
epoch, nextSeqno, payload, byteSize, controlVal,
142
+
); err != nil {
143
+
return 0, err
144
+
}
145
+
146
+
if _, err := tx.Exec(
147
+
`update mill_outbox_state set next_seqno = ? where epoch = ?`,
148
+
nextSeqno+1, epoch,
149
+
); err != nil {
150
+
return 0, err
151
+
}
152
+
153
+
if err := tx.Commit(); err != nil {
154
+
return 0, err
155
+
}
156
+
return nextSeqno, nil
157
+
}
158
+
159
+
func (d *DB) DeleteOutboxPrefix(ackedSeqno uint64) (OutboxDeletion, error) {
160
+
tx, err := d.Begin()
161
+
if err != nil {
162
+
return OutboxDeletion{}, err
163
+
}
164
+
defer tx.Rollback()
165
+
166
+
var epoch string
167
+
err = tx.QueryRow(`select epoch from mill_outbox_state limit 1`).Scan(&epoch)
168
+
if err == sql.ErrNoRows {
169
+
return OutboxDeletion{}, nil
170
+
}
171
+
if err != nil {
172
+
return OutboxDeletion{}, err
173
+
}
174
+
175
+
var deleted OutboxDeletion
176
+
if err := tx.QueryRow(`
177
+
select count(*),
178
+
coalesce(sum(byte_size), 0)
179
+
from mill_outbox_rows
180
+
where epoch = ? and seqno <= ?
181
+
`, epoch, ackedSeqno).Scan(&deleted.Rows, &deleted.Bytes); err != nil {
182
+
return OutboxDeletion{}, err
183
+
}
184
+
if _, err := tx.Exec(
185
+
`delete from mill_outbox_rows where epoch = ? and seqno <= ?`,
186
+
epoch, ackedSeqno,
187
+
); err != nil {
188
+
return OutboxDeletion{}, err
189
+
}
190
+
if err := tx.Commit(); err != nil {
191
+
return OutboxDeletion{}, err
192
+
}
193
+
return deleted, nil
194
+
}
195
+
196
+
func (d *DB) ListOutboxRows() ([]OutboxRow, error) {
197
+
rows, err := d.Query(`select epoch, seqno, payload, byte_size, control from mill_outbox_rows order by seqno`)
198
+
if err != nil {
199
+
return nil, err
200
+
}
201
+
defer rows.Close()
202
+
203
+
var out []OutboxRow
204
+
for rows.Next() {
205
+
var r OutboxRow
206
+
var controlVal int
207
+
if err := rows.Scan(&r.Epoch, &r.Seqno, &r.Payload, &r.ByteSize, &controlVal); err != nil {
208
+
return nil, err
209
+
}
210
+
r.Control = (controlVal != 0)
211
+
out = append(out, r)
212
+
}
213
+
return out, rows.Err()
214
+
}
215
+
func (d *DB) ListOutboxRowsAfter(seqno uint64, limit int) ([]OutboxRow, error) {
216
+
rows, err := d.Query(`
217
+
select epoch, seqno, payload, byte_size, control
218
+
from mill_outbox_rows
219
+
where epoch = (select epoch from mill_outbox_state limit 1)
220
+
and seqno > ?
221
+
order by seqno
222
+
limit ?
223
+
`, seqno, limit)
224
+
if err != nil {
225
+
return nil, err
226
+
}
227
+
defer rows.Close()
228
+
229
+
var out []OutboxRow
230
+
for rows.Next() {
231
+
var row OutboxRow
232
+
var control int
233
+
if err := rows.Scan(&row.Epoch, &row.Seqno, &row.Payload, &row.ByteSize, &control); err != nil {
234
+
return nil, err
235
+
}
236
+
row.Control = control != 0
237
+
out = append(out, row)
238
+
}
239
+
return out, rows.Err()
240
+
}
241
+
242
+
func (d *DB) GetOutboxState() (string, uint64, error) {
243
+
var epoch string
244
+
var nextSeqno uint64
245
+
err := d.QueryRow(`select epoch, next_seqno from mill_outbox_state limit 1`).Scan(&epoch, &nextSeqno)
246
+
if err == sql.ErrNoRows {
247
+
return "", 0, nil
248
+
}
249
+
return epoch, nextSeqno, err
250
+
}
251
+
252
+
type FinishedLog struct {
253
+
LeaseID string
254
+
Workflow string
255
+
Ref string
256
+
Hash string
257
+
}
258
+
259
+
func (d *DB) GetFinishedLog(workflow string) (*FinishedLog, error) {
260
+
var fl FinishedLog
261
+
err := d.QueryRow(
262
+
`select lease_id, workflow, ref, hash
263
+
from mill_artifacts
264
+
where workflow = ?
265
+
order by id desc limit 1`,
266
+
workflow,
267
+
).Scan(&fl.LeaseID, &fl.Workflow, &fl.Ref, &fl.Hash)
268
+
if err != nil {
269
+
return nil, err
270
+
}
271
+
return &fl, nil
272
+
}
273
+
274
+
type EventBatchTx struct {
275
+
tx *sql.Tx
276
+
db *DB
277
+
}
278
+
279
+
func (tx *EventBatchTx) InsertStatusEvent(pipelineAtUri, workflow, status string, workflowError *string, exitCode *int64) error {
280
+
event, err := statusEvent(pipelineAtUri, workflow, status, workflowError, exitCode)
281
+
if err != nil {
282
+
return err
283
+
}
284
+
return eventstream.Insert(tx.tx, event, nil)
285
+
}
286
+
287
+
func (tx *EventBatchTx) DeleteLease(leaseID string) error {
288
+
_, err := tx.tx.Exec(`delete from mill_leases where lease_id = ?`, leaseID)
289
+
return err
290
+
}
291
+
292
+
func (tx *EventBatchTx) AdvanceCursor(nodeID, epoch string, seqno uint64) error {
293
+
_, err := tx.tx.Exec(
294
+
`insert into mill_executor_cursors (node_id, epoch, acked_seqno) values (?, ?, ?)
295
+
on conflict(node_id, epoch) do update set acked_seqno = excluded.acked_seqno`,
296
+
nodeID, epoch, seqno,
297
+
)
298
+
return err
299
+
}
300
+
301
+
func (tx *EventBatchTx) InsertArtifactRef(leaseID, workflow, ref, hash string) error {
302
+
_, err := tx.tx.Exec(
303
+
`insert into mill_artifacts (lease_id, workflow, ref, hash)
304
+
values (?, ?, ?, ?)`,
305
+
leaseID, workflow, ref, hash,
306
+
)
307
+
return err
308
+
}
309
+
310
+
func (d *DB) SaveArtifactRef(leaseID, workflow, ref, hash string) error {
311
+
_, err := d.Exec(
312
+
`insert into mill_artifacts (lease_id, workflow, ref, hash)
313
+
values (?, ?, ?, ?)`,
314
+
leaseID, workflow, ref, hash,
315
+
)
316
+
return err
317
+
}
318
+
319
+
type PendingArtifact struct {
320
+
LeaseID string
321
+
Workflow string
322
+
Status string
323
+
Error string
324
+
ExitCode int64
325
+
Ref string
326
+
Hash string
327
+
}
328
+
329
+
func (d *DB) SavePendingArtifact(leaseID, workflow, status, errStr string, exitCode int64, ref, hash string) error {
330
+
_, err := d.Exec(
331
+
`insert into executor_pending_artifacts (lease_id, workflow, status, error, exit_code, ref, hash)
332
+
values (?, ?, ?, ?, ?, ?, ?)
333
+
on conflict(lease_id) do update set
334
+
workflow = excluded.workflow,
335
+
status = excluded.status,
336
+
error = excluded.error,
337
+
exit_code = excluded.exit_code,
338
+
ref = excluded.ref,
339
+
hash = excluded.hash`,
340
+
leaseID, workflow, status, errStr, exitCode, ref, hash,
341
+
)
342
+
return err
343
+
}
344
+
345
+
func (d *DB) RemovePendingArtifact(leaseID string) error {
346
+
_, err := d.Exec(`delete from executor_pending_artifacts where lease_id = ?`, leaseID)
347
+
return err
348
+
}
349
+
350
+
func (d *DB) ListPendingArtifacts() ([]PendingArtifact, error) {
351
+
rows, err := d.Query(`select lease_id, workflow, status, error, exit_code, ref, hash from executor_pending_artifacts`)
352
+
if err != nil {
353
+
return nil, err
354
+
}
355
+
defer rows.Close()
356
+
357
+
var res []PendingArtifact
358
+
for rows.Next() {
359
+
var p PendingArtifact
360
+
if err := rows.Scan(&p.LeaseID, &p.Workflow, &p.Status, &p.Error, &p.ExitCode, &p.Ref, &p.Hash); err != nil {
361
+
return nil, err
362
+
}
363
+
res = append(res, p)
364
+
}
365
+
return res, rows.Err()
366
+
}
367
+
368
+
func (d *DB) ApplyEventBatch(n *notifier.Notifier, fn func(tx *EventBatchTx) error) error {
369
+
tx, err := d.Begin()
370
+
if err != nil {
371
+
return err
372
+
}
373
+
defer tx.Rollback()
374
+
375
+
batchTx := &EventBatchTx{
376
+
tx: tx,
377
+
db: d,
378
+
}
379
+
380
+
if err := fn(batchTx); err != nil {
381
+
return err
382
+
}
383
+
384
+
if err := tx.Commit(); err != nil {
385
+
return err
386
+
}
387
+
388
+
if n != nil {
389
+
n.NotifyAll()
390
+
}
391
+
return nil
392
+
}
+394
spindle/db/mill_state_test.go
+394
spindle/db/mill_state_test.go
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"path/filepath"
7
+
"testing"
8
+
9
+
"tangled.org/core/notifier"
10
+
)
11
+
12
+
func TestMillLeaseRoundTrip(t *testing.T) {
13
+
d := newTestDB(t)
14
+
15
+
lease := MillLease{
16
+
LeaseID: "lease-1",
17
+
NodeID: "node-1",
18
+
Epoch: "inc-1",
19
+
Engine: "dummy",
20
+
Knot: "knot.example",
21
+
Rkey: "rkey1",
22
+
Workflow: "build",
23
+
State: "reserved",
24
+
}
25
+
if err := d.SaveMillLease(lease); err != nil {
26
+
t.Fatalf("SaveMillLease: %v", err)
27
+
}
28
+
29
+
lease.State = "running"
30
+
if err := d.SaveMillLease(lease); err != nil {
31
+
t.Fatalf("SaveMillLease(transition): %v", err)
32
+
}
33
+
34
+
leases, err := d.ListMillLeases()
35
+
if err != nil {
36
+
t.Fatalf("ListMillLeases: %v", err)
37
+
}
38
+
if len(leases) != 1 {
39
+
t.Fatalf("ListMillLeases returned %d leases, want 1 (state transition must replace, not duplicate)", len(leases))
40
+
}
41
+
if leases[0].LeaseID != lease.LeaseID || leases[0].State != "running" {
42
+
t.Fatalf("ListMillLeases[0] = %+v, want %+v", leases[0], lease)
43
+
}
44
+
45
+
if err := d.DeleteMillLease("lease-1"); err != nil {
46
+
t.Fatalf("DeleteMillLease: %v", err)
47
+
}
48
+
if leases, _ = d.ListMillLeases(); len(leases) != 0 {
49
+
t.Fatalf("lease survived deletion: %+v", leases)
50
+
}
51
+
}
52
+
53
+
func TestExecutorCursors(t *testing.T) {
54
+
d := newTestDB(t)
55
+
56
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 5) }); err != nil {
57
+
t.Fatalf("AdvanceCursor: %v", err)
58
+
}
59
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 9) }); err != nil {
60
+
t.Fatalf("AdvanceCursor(advance): %v", err)
61
+
}
62
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-2", "inc-1", 1) }); err != nil {
63
+
t.Fatalf("AdvanceCursor(node-2): %v", err)
64
+
}
65
+
66
+
cursors, err := d.ListExecutorCursors()
67
+
if err != nil {
68
+
t.Fatalf("ListExecutorCursors: %v", err)
69
+
}
70
+
if len(cursors) != 2 {
71
+
t.Fatalf("ListExecutorCursors = %v, want 2 cursors", cursors)
72
+
}
73
+
74
+
cursorMap := make(map[string]uint64)
75
+
for _, c := range cursors {
76
+
cursorMap[c.NodeID+"/"+c.Epoch] = c.AckedSeqno
77
+
}
78
+
79
+
if cursorMap["node-1/inc-1"] != 9 || cursorMap["node-2/inc-1"] != 1 {
80
+
t.Fatalf("unexpected cursors: %v", cursorMap)
81
+
}
82
+
83
+
}
84
+
85
+
func TestCompleteMillLeaseIsAtomic(t *testing.T) {
86
+
d := newTestDB(t)
87
+
lease := MillLease{
88
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
89
+
Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: "running",
90
+
}
91
+
if err := d.SaveMillLease(lease); err != nil {
92
+
t.Fatalf("SaveMillLease: %v", err)
93
+
}
94
+
if _, err := d.Exec(`
95
+
create trigger reject_mill_lease_delete
96
+
before delete on mill_leases
97
+
begin
98
+
select raise(abort, 'forced delete failure');
99
+
end
100
+
`); err != nil {
101
+
t.Fatalf("create failure trigger: %v", err)
102
+
}
103
+
104
+
n := notifier.New()
105
+
notifications := n.Subscribe()
106
+
defer n.Unsubscribe(notifications)
107
+
err := d.CompleteMillLease(
108
+
"lease-1",
109
+
"at://knot.example/sh.tangled.pipeline/rkey1",
110
+
"build",
111
+
"failed",
112
+
nil,
113
+
nil,
114
+
&n,
115
+
)
116
+
if err == nil {
117
+
t.Fatal("CompleteMillLease succeeded despite forced lease deletion failure")
118
+
}
119
+
var eventCount int
120
+
if err := d.QueryRow(`select count(*) from events`).Scan(&eventCount); err != nil {
121
+
t.Fatalf("count events after rollback: %v", err)
122
+
}
123
+
if eventCount != 0 {
124
+
t.Fatalf("terminal event count after rollback = %d, want 0", eventCount)
125
+
}
126
+
if leases, listErr := d.ListMillLeases(); listErr != nil || len(leases) != 1 {
127
+
t.Fatalf("leases after rollback = %+v, err = %v; want original lease", leases, listErr)
128
+
}
129
+
select {
130
+
case <-notifications:
131
+
t.Fatal("rollback notified event subscribers")
132
+
default:
133
+
}
134
+
135
+
if _, err := d.Exec(`drop trigger reject_mill_lease_delete`); err != nil {
136
+
t.Fatalf("drop failure trigger: %v", err)
137
+
}
138
+
if err := d.CompleteMillLease(
139
+
"lease-1",
140
+
"at://knot.example/sh.tangled.pipeline/rkey1",
141
+
"build",
142
+
"failed",
143
+
nil,
144
+
nil,
145
+
&n,
146
+
); err != nil {
147
+
t.Fatalf("CompleteMillLease retry: %v", err)
148
+
}
149
+
if err := d.QueryRow(`select count(*) from events`).Scan(&eventCount); err != nil {
150
+
t.Fatalf("count committed events: %v", err)
151
+
}
152
+
if eventCount != 1 {
153
+
t.Fatalf("terminal event count after commit = %d, want 1", eventCount)
154
+
}
155
+
if leases, listErr := d.ListMillLeases(); listErr != nil || len(leases) != 0 {
156
+
t.Fatalf("leases after commit = %+v, err = %v; want none", leases, listErr)
157
+
}
158
+
select {
159
+
case <-notifications:
160
+
default:
161
+
t.Fatal("committed terminal event did not notify subscribers")
162
+
}
163
+
}
164
+
165
+
func TestRestartPersistence(t *testing.T) {
166
+
dbPath := filepath.Join(t.TempDir(), "persist.db")
167
+
ctx := context.Background()
168
+
d, err := Make(ctx, dbPath)
169
+
if err != nil {
170
+
t.Fatalf("Make: %v", err)
171
+
}
172
+
173
+
lease := MillLease{
174
+
LeaseID: "lease-p", NodeID: "node-p", Epoch: "inc-p", Engine: "dummy",
175
+
Knot: "k", Rkey: "r", Workflow: "w", State: "running",
176
+
}
177
+
if err := d.SaveMillLease(lease); err != nil {
178
+
t.Fatalf("SaveMillLease: %v", err)
179
+
}
180
+
181
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-p", "inc-p", 42) }); err != nil {
182
+
t.Fatalf("AdvanceCursor: %v", err)
183
+
}
184
+
185
+
if err := d.SetOutboxEpoch("inc-p"); err != nil {
186
+
t.Fatalf("SetOutboxEpoch: %v", err)
187
+
}
188
+
if _, err := d.AppendOutboxRow([]byte("hello world"), true); err != nil {
189
+
t.Fatalf("AppendOutboxRow: %v", err)
190
+
}
191
+
192
+
if err := d.Close(); err != nil {
193
+
t.Fatalf("Close: %v", err)
194
+
}
195
+
196
+
d2, err := Make(ctx, dbPath)
197
+
if err != nil {
198
+
t.Fatalf("Make reopen: %v", err)
199
+
}
200
+
defer d2.Close()
201
+
202
+
leases, err := d2.ListMillLeases()
203
+
if err != nil {
204
+
t.Fatalf("ListMillLeases: %v", err)
205
+
}
206
+
if len(leases) != 1 || leases[0].LeaseID != "lease-p" || leases[0].Epoch != "inc-p" {
207
+
t.Fatalf("unexpected leases: %+v", leases)
208
+
}
209
+
210
+
cursors, err := d2.ListExecutorCursors()
211
+
if err != nil {
212
+
t.Fatalf("ListExecutorCursors: %v", err)
213
+
}
214
+
if len(cursors) != 1 || cursors[0].NodeID != "node-p" || cursors[0].Epoch != "inc-p" || cursors[0].AckedSeqno != 42 {
215
+
t.Fatalf("unexpected cursors: %+v", cursors)
216
+
}
217
+
218
+
inc, nextSeqno, err := d2.GetOutboxState()
219
+
if err != nil {
220
+
t.Fatalf("GetOutboxState: %v", err)
221
+
}
222
+
if inc != "inc-p" || nextSeqno != 2 {
223
+
t.Fatalf("unexpected outbox state: inc=%q, next=%d", inc, nextSeqno)
224
+
}
225
+
rows, err := d2.ListOutboxRows()
226
+
if err != nil {
227
+
t.Fatalf("ListOutboxRows: %v", err)
228
+
}
229
+
if len(rows) != 1 || string(rows[0].Payload) != "hello world" || !rows[0].Control {
230
+
t.Fatalf("unexpected outbox rows: %+v", rows)
231
+
}
232
+
}
233
+
234
+
func TestOutboxPrefixAck(t *testing.T) {
235
+
d := newTestDB(t)
236
+
237
+
if err := d.SetOutboxEpoch("inc-1"); err != nil {
238
+
t.Fatalf("SetOutboxEpoch: %v", err)
239
+
}
240
+
241
+
o1, err := d.AppendOutboxRow([]byte("msg1"), false)
242
+
if err != nil || o1 != 1 {
243
+
t.Fatalf("AppendOutboxRow 1: %v, seqno=%d", err, o1)
244
+
}
245
+
o2, err := d.AppendOutboxRow([]byte("msg2"), false)
246
+
if err != nil || o2 != 2 {
247
+
t.Fatalf("AppendOutboxRow 2: %v, seqno=%d", err, o2)
248
+
}
249
+
o3, err := d.AppendOutboxRow([]byte("msg3"), true)
250
+
if err != nil || o3 != 3 {
251
+
t.Fatalf("AppendOutboxRow 3: %v, seqno=%d", err, o3)
252
+
}
253
+
254
+
n, err := d.DeleteOutboxPrefix(2)
255
+
if err != nil {
256
+
t.Fatalf("DeleteOutboxPrefix: %v", err)
257
+
}
258
+
if n.Rows != 2 || n.Bytes != 8 {
259
+
t.Fatalf("deleted prefix = %+v, want 2 rows and 8 bytes", n)
260
+
}
261
+
262
+
rows, err := d.ListOutboxRows()
263
+
if err != nil {
264
+
t.Fatalf("ListOutboxRows: %v", err)
265
+
}
266
+
if len(rows) != 1 || rows[0].Seqno != 3 || string(rows[0].Payload) != "msg3" {
267
+
t.Fatalf("expected only msg3 (seqno 3) to remain, got: %+v", rows)
268
+
}
269
+
}
270
+
271
+
func TestCompositeCursors(t *testing.T) {
272
+
d := newTestDB(t)
273
+
274
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 10) }); err != nil {
275
+
t.Fatalf("AdvanceCursor: %v", err)
276
+
}
277
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-2", 20) }); err != nil {
278
+
t.Fatalf("AdvanceCursor: %v", err)
279
+
}
280
+
if err := d.ApplyEventBatch(nil, func(tx *EventBatchTx) error { return tx.AdvanceCursor("node-2", "inc-1", 5) }); err != nil {
281
+
t.Fatalf("AdvanceCursor: %v", err)
282
+
}
283
+
284
+
cursors, err := d.ListExecutorCursors()
285
+
if err != nil {
286
+
t.Fatalf("ListExecutorCursors: %v", err)
287
+
}
288
+
if len(cursors) != 3 {
289
+
t.Fatalf("expected 3 cursors, got %d", len(cursors))
290
+
}
291
+
292
+
cursorMap := make(map[string]uint64)
293
+
for _, c := range cursors {
294
+
key := c.NodeID + "/" + c.Epoch
295
+
cursorMap[key] = c.AckedSeqno
296
+
}
297
+
298
+
if cursorMap["node-1/inc-1"] != 10 || cursorMap["node-1/inc-2"] != 20 || cursorMap["node-2/inc-1"] != 5 {
299
+
t.Fatalf("unexpected cursor values: %v", cursorMap)
300
+
}
301
+
302
+
}
303
+
304
+
func TestBatchRollback(t *testing.T) {
305
+
d := newTestDB(t)
306
+
307
+
lease := MillLease{
308
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
309
+
Knot: "k", Rkey: "r", Workflow: "w", State: "running",
310
+
}
311
+
if err := d.SaveMillLease(lease); err != nil {
312
+
t.Fatalf("SaveMillLease: %v", err)
313
+
}
314
+
315
+
n := notifier.New()
316
+
err := d.ApplyEventBatch(&n, func(tx *EventBatchTx) error {
317
+
if err := tx.DeleteLease("lease-1"); err != nil {
318
+
return err
319
+
}
320
+
if err := tx.AdvanceCursor("node-1", "inc-1", 100); err != nil {
321
+
return err
322
+
}
323
+
return fmt.Errorf("forced batch failure")
324
+
})
325
+
326
+
if err == nil {
327
+
t.Fatal("expected ApplyEventBatch to return error")
328
+
}
329
+
330
+
leases, err := d.ListMillLeases()
331
+
if err != nil {
332
+
t.Fatalf("ListMillLeases: %v", err)
333
+
}
334
+
if len(leases) != 1 {
335
+
t.Fatalf("lease was deleted despite rollback: %+v", leases)
336
+
}
337
+
338
+
cursors, err := d.ListExecutorCursors()
339
+
if err != nil {
340
+
t.Fatalf("ListExecutorCursors: %v", err)
341
+
}
342
+
if len(cursors) != 0 {
343
+
t.Fatalf("cursor was advanced despite rollback: %+v", cursors)
344
+
}
345
+
}
346
+
347
+
func TestTerminalCursorAtomicity(t *testing.T) {
348
+
d := newTestDB(t)
349
+
350
+
lease := MillLease{
351
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
352
+
Knot: "k", Rkey: "r", Workflow: "w", State: "running",
353
+
}
354
+
if err := d.SaveMillLease(lease); err != nil {
355
+
t.Fatalf("SaveMillLease: %v", err)
356
+
}
357
+
358
+
n := notifier.New()
359
+
notifications := n.Subscribe()
360
+
defer n.Unsubscribe(notifications)
361
+
362
+
err := d.ApplyEventBatch(&n, func(tx *EventBatchTx) error {
363
+
if err := tx.DeleteLease("lease-1"); err != nil {
364
+
return err
365
+
}
366
+
return tx.AdvanceCursor("node-1", "inc-1", 100)
367
+
})
368
+
369
+
if err != nil {
370
+
t.Fatalf("ApplyEventBatch: %v", err)
371
+
}
372
+
373
+
leases, err := d.ListMillLeases()
374
+
if err != nil {
375
+
t.Fatalf("ListMillLeases: %v", err)
376
+
}
377
+
if len(leases) != 0 {
378
+
t.Fatalf("lease not deleted: %+v", leases)
379
+
}
380
+
381
+
cursors, err := d.ListExecutorCursors()
382
+
if err != nil {
383
+
t.Fatalf("ListExecutorCursors: %v", err)
384
+
}
385
+
if len(cursors) != 1 || cursors[0].AckedSeqno != 100 {
386
+
t.Fatalf("cursor not advanced correctly: %+v", cursors)
387
+
}
388
+
389
+
select {
390
+
case <-notifications:
391
+
default:
392
+
t.Fatal("notifier was not fired after batch commit")
393
+
}
394
+
}
+167
spindle/db/mill_tokens.go
+167
spindle/db/mill_tokens.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"encoding/json"
6
+
"slices"
7
+
"strings"
8
+
"time"
9
+
)
10
+
11
+
// the raw token is never stored, only its hash
12
+
type ExecutorToken struct {
13
+
Name string
14
+
CreatedAt string
15
+
ExpiresAt *time.Time
16
+
Labels []string
17
+
QuarantineReason *string
18
+
QuarantinedAt *string
19
+
}
20
+
21
+
func (d *DB) AddExecutorToken(name, tokenHash string, expiresAt *time.Time, labels []string) error {
22
+
normalized := normalizeLabels(labels)
23
+
labelsBytes, err := json.Marshal(normalized)
24
+
if err != nil {
25
+
return err
26
+
}
27
+
_, err = d.Exec(
28
+
`insert into mill_executors (name, token_hash, expires_at, labels) values (?, ?, ?, ?)`,
29
+
name, tokenHash, expiryArg(expiresAt), string(labelsBytes),
30
+
)
31
+
return err
32
+
}
33
+
34
+
func (d *DB) ResolveExecutorToken(tokenHash string) (string, []string, bool, error) {
35
+
var name string
36
+
var expires sql.NullString
37
+
var labelsRaw, quarantineReason sql.NullString
38
+
err := d.QueryRow(
39
+
`select name, expires_at, labels, quarantine_reason from mill_executors where token_hash = ?`, tokenHash,
40
+
).Scan(&name, &expires, &labelsRaw, &quarantineReason)
41
+
if err == sql.ErrNoRows {
42
+
return "", nil, false, nil
43
+
}
44
+
if err != nil {
45
+
return "", nil, false, err
46
+
}
47
+
if quarantineReason.Valid {
48
+
return "", nil, false, nil
49
+
}
50
+
exp, hasExpiry, err := parseExpiry(expires)
51
+
if err != nil {
52
+
return "", nil, false, err
53
+
}
54
+
if hasExpiry && time.Now().After(exp) {
55
+
return "", nil, false, nil
56
+
}
57
+
var labels []string
58
+
if labelsRaw.Valid && labelsRaw.String != "" {
59
+
if err := json.Unmarshal([]byte(labelsRaw.String), &labels); err != nil {
60
+
return "", nil, false, err
61
+
}
62
+
}
63
+
return name, labels, true, nil
64
+
}
65
+
66
+
func (d *DB) QuarantineExecutor(name, reason string) error {
67
+
_, err := d.Exec(
68
+
`update mill_executors
69
+
set quarantine_reason = ?, quarantined_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
70
+
where name = ?`,
71
+
reason, name,
72
+
)
73
+
return err
74
+
}
75
+
76
+
func (d *DB) ClearExecutorQuarantine(name string) (bool, error) {
77
+
res, err := d.Exec(
78
+
`update mill_executors set quarantine_reason = null, quarantined_at = null where name = ?`,
79
+
name,
80
+
)
81
+
if err != nil {
82
+
return false, err
83
+
}
84
+
n, err := res.RowsAffected()
85
+
return n > 0, err
86
+
}
87
+
88
+
func (d *DB) RevokeExecutorToken(name string) (bool, error) {
89
+
res, err := d.Exec(`delete from mill_executors where name = ?`, name)
90
+
if err != nil {
91
+
return false, err
92
+
}
93
+
n, err := res.RowsAffected()
94
+
return n > 0, err
95
+
}
96
+
97
+
func (d *DB) ListExecutorTokens() ([]ExecutorToken, error) {
98
+
rows, err := d.Query(`select name, created_at, expires_at, labels, quarantine_reason, quarantined_at from mill_executors order by name`)
99
+
if err != nil {
100
+
return nil, err
101
+
}
102
+
defer rows.Close()
103
+
104
+
var out []ExecutorToken
105
+
for rows.Next() {
106
+
var t ExecutorToken
107
+
var expires, labelsRaw, quarantineReason, quarantinedAt sql.NullString
108
+
if err := rows.Scan(&t.Name, &t.CreatedAt, &expires, &labelsRaw, &quarantineReason, &quarantinedAt); err != nil {
109
+
return nil, err
110
+
}
111
+
exp, hasExpiry, err := parseExpiry(expires)
112
+
if err != nil {
113
+
return nil, err
114
+
}
115
+
if hasExpiry {
116
+
t.ExpiresAt = &exp
117
+
}
118
+
if labelsRaw.Valid && labelsRaw.String != "" {
119
+
if err := json.Unmarshal([]byte(labelsRaw.String), &t.Labels); err != nil {
120
+
return nil, err
121
+
}
122
+
}
123
+
if quarantineReason.Valid {
124
+
t.QuarantineReason = &quarantineReason.String
125
+
}
126
+
if quarantinedAt.Valid {
127
+
t.QuarantinedAt = &quarantinedAt.String
128
+
}
129
+
out = append(out, t)
130
+
}
131
+
return out, rows.Err()
132
+
}
133
+
134
+
func normalizeLabels(labels []string) []string {
135
+
var out []string
136
+
seen := make(map[string]bool)
137
+
for _, l := range labels {
138
+
trimmed := strings.TrimSpace(l)
139
+
if trimmed == "" {
140
+
continue
141
+
}
142
+
if !seen[trimmed] {
143
+
seen[trimmed] = true
144
+
out = append(out, trimmed)
145
+
}
146
+
}
147
+
slices.Sort(out)
148
+
return out
149
+
}
150
+
151
+
func expiryArg(t *time.Time) any {
152
+
if t == nil {
153
+
return nil
154
+
}
155
+
return t.UTC().Format(time.RFC3339)
156
+
}
157
+
158
+
func parseExpiry(s sql.NullString) (time.Time, bool, error) {
159
+
if !s.Valid {
160
+
return time.Time{}, false, nil
161
+
}
162
+
t, err := time.Parse(time.RFC3339, s.String)
163
+
if err != nil {
164
+
return time.Time{}, false, err
165
+
}
166
+
return t, true, nil
167
+
}
+272
spindle/db/mill_tokens_test.go
+272
spindle/db/mill_tokens_test.go
···
1
+
package db
2
+
3
+
import (
4
+
"slices"
5
+
"testing"
6
+
"time"
7
+
)
8
+
9
+
func TestAddExecutorTokenRejectsDuplicateName(t *testing.T) {
10
+
d := newTestDB(t)
11
+
12
+
if err := d.AddExecutorToken("exec-1", "hash-a", nil, nil); err != nil {
13
+
t.Fatalf("AddExecutorToken: %v", err)
14
+
}
15
+
if err := d.AddExecutorToken("exec-1", "hash-b", nil, nil); err == nil {
16
+
t.Fatal("AddExecutorToken re-registered an existing name; a duplicate must be rejected")
17
+
}
18
+
19
+
name, _, ok, err := d.ResolveExecutorToken("hash-a")
20
+
if err != nil {
21
+
t.Fatalf("ResolveExecutorToken(hash-a): %v", err)
22
+
}
23
+
if !ok || name != "exec-1" {
24
+
t.Fatalf("ResolveExecutorToken(hash-a) = (%q, %v), want (exec-1, true)", name, ok)
25
+
}
26
+
if _, _, ok, _ := d.ResolveExecutorToken("hash-b"); ok {
27
+
t.Fatal("rejected duplicate's token resolved; the failed insert leaked a credential")
28
+
}
29
+
}
30
+
31
+
func TestResolveExecutorTokenMissAndHit(t *testing.T) {
32
+
d := newTestDB(t)
33
+
34
+
name, _, ok, err := d.ResolveExecutorToken("no-such-hash")
35
+
if err != nil {
36
+
t.Fatalf("ResolveExecutorToken(miss): %v", err)
37
+
}
38
+
if ok || name != "" {
39
+
t.Fatalf("ResolveExecutorToken(miss) = (%q, %v), want (\"\", false)", name, ok)
40
+
}
41
+
42
+
if err := d.AddExecutorToken("exec-1", "hash-1", nil, nil); err != nil {
43
+
t.Fatalf("AddExecutorToken: %v", err)
44
+
}
45
+
46
+
name, _, ok, err = d.ResolveExecutorToken("hash-1")
47
+
if err != nil {
48
+
t.Fatalf("ResolveExecutorToken(hit): %v", err)
49
+
}
50
+
if !ok || name != "exec-1" {
51
+
t.Fatalf("ResolveExecutorToken(hash-1) = (%q, %v), want (exec-1, true)", name, ok)
52
+
}
53
+
54
+
if _, _, ok, _ := d.ResolveExecutorToken("hash-unregistered"); ok {
55
+
t.Fatal("ResolveExecutorToken matched an unregistered hash")
56
+
}
57
+
}
58
+
59
+
func TestRevokeExecutorToken(t *testing.T) {
60
+
d := newTestDB(t)
61
+
62
+
if err := d.AddExecutorToken("exec-1", "hash-1", nil, nil); err != nil {
63
+
t.Fatalf("AddExecutorToken: %v", err)
64
+
}
65
+
66
+
deleted, err := d.RevokeExecutorToken("exec-1")
67
+
if err != nil {
68
+
t.Fatalf("RevokeExecutorToken: %v", err)
69
+
}
70
+
if !deleted {
71
+
t.Fatal("RevokeExecutorToken reported no deletion for an existing identity")
72
+
}
73
+
74
+
if _, _, ok, _ := d.ResolveExecutorToken("hash-1"); ok {
75
+
t.Fatal("revoked token still resolves; revocation is not enforced")
76
+
}
77
+
78
+
if deleted, err := d.RevokeExecutorToken("exec-1"); err != nil || deleted {
79
+
t.Fatalf("RevokeExecutorToken(already-gone) = (%v, %v), want (false, nil)", deleted, err)
80
+
}
81
+
82
+
if deleted, err := d.RevokeExecutorToken("ghost"); err != nil || deleted {
83
+
t.Fatalf("RevokeExecutorToken(unknown) = (%v, %v), want (false, nil)", deleted, err)
84
+
}
85
+
}
86
+
87
+
func TestExecutorQuarantineIsVisibleAndReversible(t *testing.T) {
88
+
d := newTestDB(t)
89
+
if err := d.AddExecutorToken("exec-1", "hash-1", nil, nil); err != nil {
90
+
t.Fatalf("AddExecutorToken: %v", err)
91
+
}
92
+
if err := d.QuarantineExecutor("exec-1", "missed cancel deadline"); err != nil {
93
+
t.Fatalf("QuarantineExecutor: %v", err)
94
+
}
95
+
if _, _, ok, err := d.ResolveExecutorToken("hash-1"); err != nil || ok {
96
+
t.Fatalf("ResolveExecutorToken(quarantined) = (ok=%v, err=%v), want (false, nil)", ok, err)
97
+
}
98
+
tokens, err := d.ListExecutorTokens()
99
+
if err != nil {
100
+
t.Fatalf("ListExecutorTokens: %v", err)
101
+
}
102
+
if len(tokens) != 1 || tokens[0].QuarantineReason == nil || *tokens[0].QuarantineReason != "missed cancel deadline" || tokens[0].QuarantinedAt == nil {
103
+
t.Fatalf("quarantined token not surfaced: %+v", tokens)
104
+
}
105
+
if cleared, err := d.ClearExecutorQuarantine("exec-1"); err != nil || !cleared {
106
+
t.Fatalf("ClearExecutorQuarantine = (%v, %v), want (true, nil)", cleared, err)
107
+
}
108
+
if _, _, ok, err := d.ResolveExecutorToken("hash-1"); err != nil || !ok {
109
+
t.Fatalf("ResolveExecutorToken(cleared) = (ok=%v, err=%v), want (true, nil)", ok, err)
110
+
}
111
+
if cleared, err := d.ClearExecutorQuarantine("missing"); err != nil || cleared {
112
+
t.Fatalf("ClearExecutorQuarantine(missing) = (%v, %v), want (false, nil)", cleared, err)
113
+
}
114
+
}
115
+
116
+
func TestListExecutorTokens(t *testing.T) {
117
+
d := newTestDB(t)
118
+
119
+
tokens, err := d.ListExecutorTokens()
120
+
if err != nil {
121
+
t.Fatalf("ListExecutorTokens(empty): %v", err)
122
+
}
123
+
if len(tokens) != 0 {
124
+
t.Fatalf("ListExecutorTokens on empty table = %d rows, want 0", len(tokens))
125
+
}
126
+
127
+
// insert out of alphabetical order to test query sorting
128
+
for _, name := range []string{"charlie", "alice", "bob"} {
129
+
if err := d.AddExecutorToken(name, "hash-"+name, nil, nil); err != nil {
130
+
t.Fatalf("AddExecutorToken(%s): %v", name, err)
131
+
}
132
+
}
133
+
134
+
tokens, err = d.ListExecutorTokens()
135
+
if err != nil {
136
+
t.Fatalf("ListExecutorTokens: %v", err)
137
+
}
138
+
want := []string{"alice", "bob", "charlie"}
139
+
if len(tokens) != len(want) {
140
+
t.Fatalf("ListExecutorTokens = %d rows, want %d", len(tokens), len(want))
141
+
}
142
+
for i := range want {
143
+
if tokens[i].Name != want[i] {
144
+
t.Fatalf("ListExecutorTokens[%d].Name = %q, want %q (ordered by name)", i, tokens[i].Name, want[i])
145
+
}
146
+
}
147
+
}
148
+
149
+
// expired tokens must fail closed
150
+
func TestResolveExecutorTokenExpiry(t *testing.T) {
151
+
cases := []struct {
152
+
name string
153
+
seqno time.Duration
154
+
noExpiry bool
155
+
wantOK bool
156
+
}{
157
+
{"future expiry resolves", time.Hour, false, true},
158
+
{"past expiry fails closed", -time.Hour, false, false},
159
+
{"nil expiry never expires", 0, true, true},
160
+
}
161
+
for _, tc := range cases {
162
+
t.Run(tc.name, func(t *testing.T) {
163
+
d := newTestDB(t)
164
+
165
+
var expires *time.Time
166
+
if !tc.noExpiry {
167
+
exp := time.Now().Add(tc.seqno)
168
+
expires = &exp
169
+
}
170
+
if err := d.AddExecutorToken("exec-1", "hash-1", expires, nil); err != nil {
171
+
t.Fatalf("AddExecutorToken: %v", err)
172
+
}
173
+
174
+
name, _, ok, err := d.ResolveExecutorToken("hash-1")
175
+
if err != nil {
176
+
t.Fatalf("ResolveExecutorToken: %v", err)
177
+
}
178
+
if ok != tc.wantOK {
179
+
t.Fatalf("ResolveExecutorToken ok = %v, want %v", ok, tc.wantOK)
180
+
}
181
+
if tc.wantOK && name != "exec-1" {
182
+
t.Fatalf("ResolveExecutorToken name = %q, want exec-1", name)
183
+
}
184
+
if !tc.wantOK && name != "" {
185
+
t.Fatalf("ResolveExecutorToken name = %q, want \"\" when failing closed", name)
186
+
}
187
+
})
188
+
}
189
+
}
190
+
191
+
func TestResolveExecutorTokenRejectsMalformedExpiry(t *testing.T) {
192
+
d := newTestDB(t)
193
+
if _, err := d.Exec(
194
+
`insert into mill_executors (name, token_hash, expires_at) values (?, ?, ?)`,
195
+
"exec-1", "hash-1", "not-a-timestamp",
196
+
); err != nil {
197
+
t.Fatalf("insert malformed token: %v", err)
198
+
}
199
+
200
+
name, _, ok, err := d.ResolveExecutorToken("hash-1")
201
+
if err == nil {
202
+
t.Fatal("ResolveExecutorToken accepted a malformed non-NULL expiry")
203
+
}
204
+
if ok || name != "" {
205
+
t.Fatalf("ResolveExecutorToken = (%q, %v, %v), want (\"\", false, error)", name, ok, err)
206
+
}
207
+
}
208
+
209
+
// expiry storage is RFC3339 second precision UTC so
210
+
// round trips compare to the second
211
+
func TestListExecutorTokensSurfacesExpiry(t *testing.T) {
212
+
d := newTestDB(t)
213
+
214
+
exp := time.Now().Add(24 * time.Hour)
215
+
if err := d.AddExecutorToken("expiring", "hash-exp", &exp, nil); err != nil {
216
+
t.Fatalf("AddExecutorToken(expiring): %v", err)
217
+
}
218
+
if err := d.AddExecutorToken("forever", "hash-forever", nil, nil); err != nil {
219
+
t.Fatalf("AddExecutorToken(forever): %v", err)
220
+
}
221
+
222
+
tokens, err := d.ListExecutorTokens()
223
+
if err != nil {
224
+
t.Fatalf("ListExecutorTokens: %v", err)
225
+
}
226
+
227
+
got := make(map[string]*time.Time, len(tokens))
228
+
for _, tok := range tokens {
229
+
got[tok.Name] = tok.ExpiresAt
230
+
}
231
+
232
+
e, present := got["forever"]
233
+
if !present {
234
+
t.Fatal("ListExecutorTokens omitted the non-expiring identity")
235
+
}
236
+
if e != nil {
237
+
t.Fatalf("forever.ExpiresAt = %v, want nil (never expires)", e)
238
+
}
239
+
240
+
e, present = got["expiring"]
241
+
if !present {
242
+
t.Fatal("ListExecutorTokens omitted the expiring identity")
243
+
}
244
+
if e == nil {
245
+
t.Fatal("expiring.ExpiresAt = nil, want the stored expiry")
246
+
}
247
+
if e.Unix() != exp.Unix() {
248
+
t.Fatalf("expiring.ExpiresAt = %d (unix), want %d", e.Unix(), exp.Unix())
249
+
}
250
+
}
251
+
252
+
func TestExecutorTokenLabels(t *testing.T) {
253
+
d := newTestDB(t)
254
+
255
+
labels := []string{" foo ", "bar", " foo", ""}
256
+
if err := d.AddExecutorToken("exec-1", "hash-1", nil, labels); err != nil {
257
+
t.Fatalf("AddExecutorToken: %v", err)
258
+
}
259
+
260
+
name, resolvedLabels, ok, err := d.ResolveExecutorToken("hash-1")
261
+
if err != nil {
262
+
t.Fatalf("ResolveExecutorToken: %v", err)
263
+
}
264
+
if !ok || name != "exec-1" {
265
+
t.Fatalf("ResolveExecutorToken: ok=%v, name=%q, want true, exec-1", ok, name)
266
+
}
267
+
268
+
wantLabels := []string{"bar", "foo"}
269
+
if !slices.Equal(resolvedLabels, wantLabels) {
270
+
t.Fatalf("resolved labels = %v, want %v", resolvedLabels, wantLabels)
271
+
}
272
+
}
+100
-40
spindle/engine/engine.go
+100
-40
spindle/engine/engine.go
···
2
2
3
3
import (
4
4
"context"
5
+
"crypto/sha256"
6
+
"encoding/hex"
5
7
"errors"
6
8
"fmt"
9
+
"io"
7
10
"log/slog"
8
-
"path/filepath"
11
+
"os"
9
12
"sync"
13
+
"time"
10
14
11
15
"tangled.org/core/notifier"
16
+
"tangled.org/core/spindle/artifactstore"
12
17
"tangled.org/core/spindle/config"
13
18
"tangled.org/core/spindle/db"
14
19
"tangled.org/core/spindle/models"
···
62
67
}
63
68
}
64
69
65
-
type workflowFinalizer interface {
66
-
FinalizeWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error
70
+
// the mill streams the executor's real log file into place itself
71
+
// a local logger here would only write competing lines
72
+
type workflowLoggerProvider interface {
73
+
WorkflowLogger(wid models.WorkflowId) models.WorkflowLogger
67
74
}
68
75
69
-
func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
76
+
// for engines that manage status updates outside StartWorkflows
77
+
type RemoteStatusEngine interface {
78
+
AuthorsRemoteStatus()
79
+
}
80
+
81
+
func reportWorkflowStatusError(l *slog.Logger, database *db.DB, n *notifier.Notifier, wid models.WorkflowId, err error) {
82
+
if errors.Is(err, ErrTimedOut) {
83
+
dbErr := database.StatusTimeout(wid, n)
84
+
if dbErr != nil {
85
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
86
+
}
87
+
} else if errors.Is(err, ErrWorkflowCanceled) {
88
+
dbErr := database.StatusCancelled(wid, err.Error(), -1, n)
89
+
if dbErr != nil {
90
+
l.Error("failed to set workflow status to cancelled", "wid", wid, "err", dbErr)
91
+
}
92
+
} else {
93
+
dbErr := database.StatusFailed(wid, err.Error(), -1, n)
94
+
if dbErr != nil {
95
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
96
+
}
97
+
}
98
+
}
99
+
100
+
func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, stores *artifactstore.Stores, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
70
101
l.Info("starting all workflows in parallel", "pipeline", pipelineId)
71
102
72
103
var allSecrets []secrets.UnlockedSecret
···
84
115
secretValues[i] = s.Value
85
116
}
86
117
87
-
s3, err := NewS3(cfg.S3.LogBucket)
88
-
if err != nil {
89
-
l.Error("error creating s3 client", "err", err)
90
-
}
91
-
92
118
// wid.String() is lossy so two different names can map to the same key
93
119
// eg. "foo bar" and "foo-bar"...
94
120
wfCounts := make(map[string]int)
···
101
127
wfCounts[wid.String()]++
102
128
}
103
129
}
104
-
105
130
var wg sync.WaitGroup
106
131
for eng, wfs := range pipeline.Workflows {
107
132
workflowTimeout := eng.WorkflowTimeout()
···
128
153
l.Info("skipping finished workflow", "wid", wid, "status", st.Status)
129
154
return
130
155
}
131
-
defer func() {
132
-
if s3 != nil {
133
-
logFile := filepath.Join(cfg.Server.LogDir, fmt.Sprintf("%s.log", wid.String()))
134
-
if err := s3.WriteFile(ctx, logFile); err != nil {
135
-
l.Error("error uploading logs", "err", err)
136
-
}
137
-
}
138
-
}()
139
-
140
-
wfLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
141
-
if err != nil {
156
+
var err error
157
+
var wfLogger models.WorkflowLogger
158
+
if p, ok := eng.(workflowLoggerProvider); ok {
159
+
wfLogger = p.WorkflowLogger(wid)
160
+
} else if fileLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues); err != nil {
142
161
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
143
162
wfLogger = models.NullLogger{}
144
163
} else {
145
164
l.Info("setup step logger; logs will be persisted", "logDir", cfg.Server.LogDir, "wid", wid)
146
-
defer wfLogger.Close()
165
+
wfLogger = fileLogger
166
+
defer archiveWorkflowLog(l, stores, db, cfg.Server.LogDir, wid)
167
+
defer fileLogger.Close()
147
168
}
148
169
149
170
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, workflowTimeout)
···
164
185
165
186
l.Info("waiting for slot", "wid", wid)
166
187
slot := WorkflowSlot(NoopSlot{})
188
+
_, remoteStatus := eng.(RemoteStatusEngine)
189
+
167
190
if s, ok := eng.(WorkflowSlotter); ok {
168
-
slot, err = s.AcquireWorkflowSlot(wfCtx, wid, &w)
191
+
slot, err = s.AcquireWorkflowSlot(wfCtx, wid, &w, Wait)
169
192
if err != nil {
170
193
writeWfError(db, n, l, wfCtx, wid, "waiting for slot", err)
171
194
return
···
173
196
}
174
197
defer slot.Release()
175
198
176
-
err = db.StatusRunning(wid, n)
177
-
if err != nil {
178
-
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
179
-
return
199
+
if !remoteStatus {
200
+
err := db.StatusRunning(wid, n)
201
+
if err != nil {
202
+
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
203
+
return
204
+
}
180
205
}
181
206
182
207
err = eng.SetupWorkflow(wfCtx, wid, &w, wfLogger)
···
186
211
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
187
212
}
188
213
}
189
-
writeWfError(db, n, l, wfCtx, wid, "setting up workflow", err)
214
+
if !remoteStatus {
215
+
writeWfError(db, n, l, wfCtx, wid, "setting up workflow", err)
216
+
}
190
217
return
191
218
}
192
219
defer eng.DestroyWorkflow(ctx, wid)
···
207
234
}
208
235
209
236
if err != nil {
210
-
writeWfError(db, n, l, wfCtx, wid, "running step", err)
211
-
return
212
-
}
213
-
}
214
-
215
-
if finalizer, ok := eng.(workflowFinalizer); ok {
216
-
if err := finalizer.FinalizeWorkflow(wfCtx, wid, &w, wfLogger); err != nil {
217
-
writeWfError(db, n, l, wfCtx, wid, "finalizing", err)
237
+
if !remoteStatus {
238
+
writeWfError(db, n, l, wfCtx, wid, "running step", err)
239
+
}
218
240
return
219
241
}
220
242
}
221
243
222
244
if isCanceled(wfCtx) {
223
-
writeWfError(db, n, l, wfCtx, wid, "before success", nil)
245
+
if !remoteStatus {
246
+
writeWfError(db, n, l, wfCtx, wid, "before success", nil)
247
+
}
224
248
return
225
249
}
226
250
227
-
err = db.StatusSuccess(wid, n)
228
-
if err != nil {
229
-
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
251
+
if !remoteStatus {
252
+
err = db.StatusSuccess(wid, n)
253
+
if err != nil {
254
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
255
+
}
230
256
}
231
257
})
232
258
}
···
235
261
wg.Wait()
236
262
l.Info("all workflows completed")
237
263
}
264
+
265
+
func archiveWorkflowLog(l *slog.Logger, stores *artifactstore.Stores, database *db.DB, logDir string, wid models.WorkflowId) {
266
+
if stores == nil {
267
+
return
268
+
}
269
+
logPath := models.LogFilePath(logDir, wid)
270
+
file, err := os.Open(logPath)
271
+
if err != nil {
272
+
l.Error("open workflow log for archival", "wid", wid, "err", err)
273
+
return
274
+
}
275
+
hash := sha256.New()
276
+
if _, err := io.Copy(hash, file); err != nil {
277
+
_ = file.Close()
278
+
l.Error("hash workflow log", "wid", wid, "err", err)
279
+
return
280
+
}
281
+
_ = file.Close()
282
+
283
+
ref := wid.String() + ".log"
284
+
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
285
+
defer cancel()
286
+
errs := stores.PutFile(uploadCtx, ref, logPath)
287
+
for _, err := range errs {
288
+
l.Error("archive workflow log", "wid", wid, "err", err)
289
+
}
290
+
if len(errs) == len(stores.Names()) {
291
+
return
292
+
}
293
+
digest := "sha256:" + hex.EncodeToString(hash.Sum(nil))
294
+
if err := database.SaveArtifactRef(wid.String(), wid.Name, ref, digest); err != nil {
295
+
l.Error("save workflow log artifact", "wid", wid, "err", err)
296
+
}
297
+
}
+3
-3
spindle/engine/engine_test.go
+3
-3
spindle/engine/engine_test.go
···
114
114
}
115
115
116
116
cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}}
117
-
StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId)
117
+
StartWorkflows(logger, nil, cfg, nil, testDB, nil, context.Background(), pipeline, pipelineId)
118
118
119
119
eng.mu.Lock()
120
120
setupCalls := append([]models.WorkflowId(nil), eng.setupCalls...)
···
195
195
cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}}
196
196
doneChan := make(chan struct{})
197
197
go func() {
198
-
StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId)
198
+
StartWorkflows(logger, nil, cfg, nil, testDB, nil, context.Background(), pipeline, pipelineId)
199
199
close(doneChan)
200
200
}()
201
201
···
250
250
}
251
251
252
252
cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}}
253
-
StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId)
253
+
StartWorkflows(logger, nil, cfg, nil, testDB, nil, context.Background(), pipeline, pipelineId)
254
254
255
255
st, err := testDB.GetStatus(wid)
256
256
if err != nil {
+8
spindle/engine/manifest_test.go
+8
spindle/engine/manifest_test.go
···
79
79
}
80
80
}
81
81
82
+
func TestDescribeManifestErrorAcceptsRunsOnGenericWorkflowKey(t *testing.T) {
83
+
raw := "engine: microvm\nruns_on: [linux/arm64, kvm]\nimage: nixos\n"
84
+
85
+
if err := DescribeManifestError(raw, testManifest{}); err != nil {
86
+
t.Fatalf("DescribeManifestError(%q) = %v, want nil", raw, err)
87
+
}
88
+
}
89
+
82
90
func TestDescribeManifestErrorNoFalsePositives(t *testing.T) {
83
91
cases := []string{
84
92
// well-formed manifest
+10
spindle/engine/placement.go
+10
spindle/engine/placement.go
-75
spindle/engine/s3.go
-75
spindle/engine/s3.go
···
1
-
package engine
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"io"
7
-
"os"
8
-
"path/filepath"
9
-
10
-
"github.com/aws/aws-sdk-go-v2/aws"
11
-
"github.com/aws/aws-sdk-go-v2/config"
12
-
"github.com/aws/aws-sdk-go-v2/service/s3"
13
-
)
14
-
15
-
type S3 struct {
16
-
bucket string
17
-
client *s3.Client
18
-
}
19
-
20
-
const BaseS3Path = "spindle/workflows"
21
-
22
-
func NewS3(bucket string) (*S3, error) {
23
-
if bucket == "" {
24
-
return nil, fmt.Errorf("s3 bucket not provided")
25
-
}
26
-
27
-
ctx := context.Background()
28
-
sdkConfig, err := config.LoadDefaultConfig(ctx)
29
-
30
-
if err != nil {
31
-
return nil, fmt.Errorf("error loading s3 config: %w", err)
32
-
}
33
-
s3Client := s3.NewFromConfig(sdkConfig)
34
-
35
-
return &S3{
36
-
bucket: bucket,
37
-
client: s3Client,
38
-
}, nil
39
-
}
40
-
41
-
func (s *S3) WriteFile(ctx context.Context, path string) error {
42
-
s3Key := fmt.Sprintf("%s/%s", BaseS3Path, filepath.Base(path))
43
-
44
-
file, err := os.Open(path)
45
-
if err != nil {
46
-
return fmt.Errorf("error opening file %s: %w", path, err)
47
-
}
48
-
defer file.Close()
49
-
50
-
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
51
-
Bucket: &s.bucket,
52
-
Key: &s3Key,
53
-
Body: file,
54
-
})
55
-
56
-
if err != nil {
57
-
return fmt.Errorf("error writing to s3: %w", err)
58
-
}
59
-
60
-
return nil
61
-
}
62
-
63
-
func (s *S3) ReadFile(ctx context.Context, name string) ([]byte, error) {
64
-
res, err := s.client.GetObject(ctx, &s3.GetObjectInput{
65
-
Bucket: &s.bucket,
66
-
Key: aws.String(name),
67
-
})
68
-
69
-
if err != nil {
70
-
return nil, fmt.Errorf("error reading file %s: %w", name, err)
71
-
}
72
-
defer res.Body.Close()
73
-
74
-
return io.ReadAll(res.Body)
75
-
}
+12
-8
spindle/engine/scheduler.go
+12
-8
spindle/engine/scheduler.go
···
50
50
}
51
51
}
52
52
53
-
func (s *ResourceScheduler[R]) Acquire(ctx context.Context, req R) (WorkflowSlot, error) {
53
+
// the mill owns the backlog so a NoWait caller must have room immediately or fail
54
+
func (s *ResourceScheduler[R]) Acquire(ctx context.Context, req R, mode AcquireMode) (WorkflowSlot, error) {
54
55
if s == nil {
55
56
return NoopSlot{}, nil
56
57
}
···
60
61
s.mu.Unlock()
61
62
return nil, fmt.Errorf("%w: request=%v budget=%v max=%v", ErrNoWorkflowSlots, req, s.budget, s.max)
62
63
}
63
-
if len(s.queue) == 0 && s.used.Add(req).Fits(s.budget) {
64
+
// NoWait ignores the queue because it never blocks
65
+
// Wait only bypasses empty queues to prevent starvation
66
+
if s.used.Add(req).Fits(s.budget) && (mode == NoWait || len(s.queue) == 0) {
64
67
s.used = s.used.Add(req)
65
68
s.mu.Unlock()
66
69
return &resourceLease[R]{scheduler: s, req: req}, nil
67
70
}
71
+
if mode == NoWait {
72
+
used := s.used
73
+
s.mu.Unlock()
74
+
return nil, fmt.Errorf("%w: request=%v used=%v budget=%v", ErrNoWorkflowSlots, req, used, s.budget)
75
+
}
68
76
69
77
waiter := &resourceWaiter[R]{req: req, ready: make(chan struct{}), enqueuedAt: s.now()}
70
78
s.queue = append(s.queue, waiter)
···
108
116
109
117
// start every waiter whose request fits. once a waiter is older than
110
118
// agingThreshold, count its request as already used so younger waiters
111
-
// stop being scheduled ahead of it.
119
+
// stop being scheduled ahead of it
112
120
func (s *ResourceScheduler[R]) schedule() {
113
121
var reserved R
114
122
now := s.now()
···
129
137
}
130
138
131
139
func (s *ResourceScheduler[R]) remove(waiter *resourceWaiter[R]) {
132
-
for i, candidate := range s.queue {
133
-
if candidate != waiter {
134
-
continue
135
-
}
140
+
if i := slices.Index(s.queue, waiter); i >= 0 {
136
141
s.queue = slices.Delete(s.queue, i, i+1)
137
-
return
138
142
}
139
143
}
+75
-9
spindle/engine/scheduler_test.go
+75
-9
spindle/engine/scheduler_test.go
···
36
36
37
37
scheduler := NewResourceScheduler(ru{}, ru{}, 0)
38
38
39
-
slot, err := scheduler.Acquire(context.Background(), ru{a: 1 << 20, b: 1 << 20})
39
+
slot, err := scheduler.Acquire(context.Background(), ru{a: 1 << 20, b: 1 << 20}, Wait)
40
40
if err != nil {
41
41
t.Fatalf("Acquire() error = %v", err)
42
42
}
···
48
48
49
49
scheduler := NewResourceScheduler(ru{a: 1024, b: 10_000}, ru{a: 512, b: 5_000}, 0)
50
50
51
-
_, err := scheduler.Acquire(context.Background(), ru{a: 768, b: 100})
51
+
_, err := scheduler.Acquire(context.Background(), ru{a: 768, b: 100}, Wait)
52
52
if !errors.Is(err, ErrNoWorkflowSlots) {
53
53
t.Fatalf("Acquire() error = %v, want ErrNoWorkflowSlots", err)
54
54
}
55
55
56
-
_, err = scheduler.Acquire(context.Background(), ru{a: 128, b: 12_000})
56
+
_, err = scheduler.Acquire(context.Background(), ru{a: 128, b: 12_000}, Wait)
57
57
if !errors.Is(err, ErrNoWorkflowSlots) {
58
58
t.Fatalf("Acquire() error = %v, want ErrNoWorkflowSlots", err)
59
59
}
···
64
64
65
65
scheduler := NewResourceScheduler(ru{a: 1024}, ru{}, 0)
66
66
67
-
first, err := scheduler.Acquire(context.Background(), ru{a: 1024})
67
+
first, err := scheduler.Acquire(context.Background(), ru{a: 1024}, Wait)
68
68
if err != nil {
69
69
t.Fatalf("first Acquire() error = %v", err)
70
70
}
···
85
85
86
86
scheduler := NewResourceScheduler(ru{a: 1}, ru{}, 0)
87
87
88
-
slot, err := scheduler.Acquire(context.Background(), ru{a: 1})
88
+
slot, err := scheduler.Acquire(context.Background(), ru{a: 1}, Wait)
89
89
if err != nil {
90
90
t.Fatalf("Acquire() error = %v", err)
91
91
}
···
93
93
slot.Release()
94
94
slot.Release()
95
95
96
-
second, err := scheduler.Acquire(context.Background(), ru{a: 1})
96
+
second, err := scheduler.Acquire(context.Background(), ru{a: 1}, Wait)
97
97
if err != nil {
98
98
t.Fatalf("Acquire() after double release error = %v", err)
99
99
}
···
105
105
106
106
scheduler := NewResourceScheduler(ru{a: 1024}, ru{}, time.Hour) // disable aging so we test pure backfill
107
107
108
-
hold, err := scheduler.Acquire(context.Background(), ru{a: 512})
108
+
hold, err := scheduler.Acquire(context.Background(), ru{a: 512}, Wait)
109
109
if err != nil {
110
110
t.Fatalf("hold Acquire() error = %v", err)
111
111
}
···
128
128
fakeNow := time.Now()
129
129
scheduler.now = func() time.Time { return fakeNow }
130
130
131
-
hold, err := scheduler.Acquire(context.Background(), ru{a: 512})
131
+
hold, err := scheduler.Acquire(context.Background(), ru{a: 512}, Wait)
132
132
if err != nil {
133
133
t.Fatalf("hold Acquire() error = %v", err)
134
134
}
···
151
151
big.Release()
152
152
}
153
153
154
+
func TestResourceSchedulerTryRejectsWhenNoRoomNow(t *testing.T) {
155
+
t.Parallel()
156
+
157
+
scheduler := NewResourceScheduler(ru{a: 1024}, ru{}, 0)
158
+
159
+
first, err := scheduler.Acquire(context.Background(), ru{a: 1024}, NoWait)
160
+
if err != nil {
161
+
t.Fatalf("first Acquire(NoWait) error = %v", err)
162
+
}
163
+
164
+
if _, err := scheduler.Acquire(context.Background(), ru{a: 1}, NoWait); !errors.Is(err, ErrNoWorkflowSlots) {
165
+
t.Fatalf("Acquire(NoWait) error = %v, want ErrNoWorkflowSlots", err)
166
+
}
167
+
168
+
first.Release()
169
+
170
+
second, err := scheduler.Acquire(context.Background(), ru{a: 1}, NoWait)
171
+
if err != nil {
172
+
t.Fatalf("Acquire(NoWait) after release error = %v", err)
173
+
}
174
+
second.Release()
175
+
}
176
+
177
+
func TestResourceSchedulerTryRejectsRequestsThatCanNeverFit(t *testing.T) {
178
+
t.Parallel()
179
+
180
+
scheduler := NewResourceScheduler(ru{a: 1024, b: 10_000}, ru{a: 512, b: 5_000}, 0)
181
+
182
+
if _, err := scheduler.Acquire(context.Background(), ru{a: 768, b: 100}, NoWait); !errors.Is(err, ErrNoWorkflowSlots) {
183
+
t.Fatalf("Acquire(NoWait) error = %v, want ErrNoWorkflowSlots", err)
184
+
}
185
+
}
186
+
187
+
func TestResourceSchedulerTryIgnoresQueuedWaiters(t *testing.T) {
188
+
t.Parallel()
189
+
190
+
scheduler := NewResourceScheduler(ru{a: 1024}, ru{}, time.Hour)
191
+
192
+
hold, err := scheduler.Acquire(context.Background(), ru{a: 512}, Wait)
193
+
if err != nil {
194
+
t.Fatalf("hold Acquire() error = %v", err)
195
+
}
196
+
defer hold.Release()
197
+
198
+
bigCh := acquireAsync(context.Background(), scheduler, ru{a: 768})
199
+
assertAcquireBlocked(t, bigCh)
200
+
201
+
slot, err := scheduler.Acquire(context.Background(), ru{a: 256}, NoWait)
202
+
if err != nil {
203
+
t.Fatalf("Acquire(NoWait) error = %v, want success past queued waiter", err)
204
+
}
205
+
slot.Release()
206
+
}
207
+
208
+
func TestResourceSchedulerZeroLimitsTryDoesNotReject(t *testing.T) {
209
+
t.Parallel()
210
+
211
+
scheduler := NewResourceScheduler(ru{}, ru{}, 0)
212
+
213
+
slot, err := scheduler.Acquire(context.Background(), ru{a: 1 << 20, b: 1 << 20}, NoWait)
214
+
if err != nil {
215
+
t.Fatalf("Acquire(NoWait) error = %v", err)
216
+
}
217
+
slot.Release()
218
+
}
219
+
154
220
func acquireAsync(ctx context.Context, scheduler *ResourceScheduler[ru], req ru) <-chan acquireResult {
155
221
ch := make(chan acquireResult, 1)
156
222
go func() {
157
-
slot, err := scheduler.Acquire(ctx, req)
223
+
slot, err := scheduler.Acquire(ctx, req, Wait)
158
224
ch <- acquireResult{slot: slot, err: err}
159
225
}()
160
226
return ch
+21
-2
spindle/engine/slot.go
+21
-2
spindle/engine/slot.go
···
13
13
Release()
14
14
}
15
15
16
+
// governs blocking behaviour when acquiring a slot
17
+
type AcquireMode int
18
+
19
+
const (
20
+
// blocks until a slot is free
21
+
Wait AcquireMode = iota
22
+
// fails immediately if full
23
+
// executors use this because the mill owns the backlog
24
+
NoWait
25
+
)
26
+
16
27
type WorkflowSlotter interface {
17
-
AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (WorkflowSlot, error)
28
+
AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, mode AcquireMode) (WorkflowSlot, error)
18
29
}
19
30
20
31
type releaseFunc func()
···
41
52
return &SemaphoreSlotter{slots: make(chan struct{}, maxConcurrent)}
42
53
}
43
54
44
-
func (a *SemaphoreSlotter) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (WorkflowSlot, error) {
55
+
func (a *SemaphoreSlotter) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, mode AcquireMode) (WorkflowSlot, error) {
45
56
if a == nil || a.slots == nil {
46
57
return NoopSlot{}, nil
47
58
}
59
+
if mode == NoWait {
60
+
select {
61
+
case a.slots <- struct{}{}:
62
+
return releaseFunc(func() { <-a.slots }), nil
63
+
default:
64
+
return nil, ErrNoWorkflowSlots
65
+
}
66
+
}
48
67
select {
49
68
case a.slots <- struct{}{}:
50
69
return releaseFunc(func() { <-a.slots }), nil
+42
-5
spindle/engine/slot_test.go
+42
-5
spindle/engine/slot_test.go
···
15
15
slotter := NewSemaphoreSlotter(0)
16
16
17
17
for range 10 {
18
-
slot, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil)
18
+
slot, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, Wait)
19
19
if err != nil {
20
20
t.Fatalf("AcquireWorkflowSlot() error = %v", err)
21
21
}
···
28
28
29
29
slotter := NewSemaphoreSlotter(1)
30
30
31
-
first, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil)
31
+
first, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, Wait)
32
32
if err != nil {
33
33
t.Fatalf("first AcquireWorkflowSlot() error = %v", err)
34
34
}
···
42
42
acquired := make(chan WorkflowSlot, 1)
43
43
errs := make(chan error, 1)
44
44
go func() {
45
-
slot, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil)
45
+
slot, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, Wait)
46
46
if err != nil {
47
47
errs <- err
48
48
return
···
64
64
65
65
slotter := NewSemaphoreSlotter(1)
66
66
67
-
first, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil)
67
+
first, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, Wait)
68
68
if err != nil {
69
69
t.Fatalf("first AcquireWorkflowSlot() error = %v", err)
70
70
}
···
73
73
ctx, cancel := context.WithCancel(context.Background())
74
74
cancel()
75
75
76
-
_, err = slotter.AcquireWorkflowSlot(ctx, zeroWorkflowID(), nil)
76
+
_, err = slotter.AcquireWorkflowSlot(ctx, zeroWorkflowID(), nil, Wait)
77
77
if !errors.Is(err, context.Canceled) {
78
78
t.Fatalf("AcquireWorkflowSlot() error = %v, want context.Canceled", err)
79
79
}
80
80
}
81
81
82
+
func TestSemaphoreSlotterTryDisabledDoesNotReject(t *testing.T) {
83
+
t.Parallel()
84
+
85
+
slotter := NewSemaphoreSlotter(0)
86
+
87
+
for range 10 {
88
+
slot, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, NoWait)
89
+
if err != nil {
90
+
t.Fatalf("AcquireWorkflowSlot(NoWait) error = %v", err)
91
+
}
92
+
slot.Release()
93
+
}
94
+
}
95
+
96
+
func TestSemaphoreSlotterTryRejectsWhenFull(t *testing.T) {
97
+
t.Parallel()
98
+
99
+
slotter := NewSemaphoreSlotter(1)
100
+
101
+
first, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, NoWait)
102
+
if err != nil {
103
+
t.Fatalf("first AcquireWorkflowSlot(NoWait) error = %v", err)
104
+
}
105
+
106
+
if _, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, NoWait); !errors.Is(err, ErrNoWorkflowSlots) {
107
+
t.Fatalf("AcquireWorkflowSlot(NoWait) error = %v, want ErrNoWorkflowSlots", err)
108
+
}
109
+
110
+
first.Release()
111
+
112
+
second, err := slotter.AcquireWorkflowSlot(context.Background(), zeroWorkflowID(), nil, NoWait)
113
+
if err != nil {
114
+
t.Fatalf("AcquireWorkflowSlot(NoWait) after release error = %v", err)
115
+
}
116
+
second.Release()
117
+
}
118
+
82
119
func assertNotAcquired(t *testing.T, acquired <-chan WorkflowSlot, errs <-chan error) {
83
120
t.Helper()
84
121
+16
-2
spindle/engines/dummy/engine.go
+16
-2
spindle/engines/dummy/engine.go
···
8
8
9
9
"gopkg.in/yaml.v3"
10
10
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/spindle/engine"
11
12
"tangled.org/core/spindle/models"
12
13
"tangled.org/core/spindle/secrets"
13
14
)
···
16
17
// step output to the workflow logger. Useful for testing pipeline plumbing
17
18
// without a real execution backend.
18
19
type DummyEngine struct {
19
-
l *slog.Logger
20
+
l *slog.Logger
21
+
StepDelay time.Duration
20
22
}
21
23
22
24
func New(l *slog.Logger) *DummyEngine {
···
79
81
return 5 * time.Minute
80
82
}
81
83
84
+
// no capacity limit, so always a no-op slot regardless of mode
85
+
func (e *DummyEngine) AcquireWorkflowSlot(_ context.Context, _ models.WorkflowId, _ *models.Workflow, _ engine.AcquireMode) (engine.WorkflowSlot, error) {
86
+
return engine.NoopSlot{}, nil
87
+
}
88
+
82
89
func (e *DummyEngine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error {
83
90
e.l.Info("destroying workflow", "wid", wid)
84
91
return nil
85
92
}
86
93
87
-
func (e *DummyEngine) RunStep(_ context.Context, wid models.WorkflowId, w *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
94
+
func (e *DummyEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
88
95
step := w.Steps[idx]
89
96
e.l.Info("running step", "wid", wid, "step", step.Name(), "command", step.Command())
90
97
fmt.Fprintf(wfLogger.DataWriter(idx, "stdout"), "$ %s", step.Command())
98
+
if e.StepDelay > 0 {
99
+
select {
100
+
case <-ctx.Done():
101
+
return ctx.Err()
102
+
case <-time.After(e.StepDelay):
103
+
}
104
+
}
91
105
return nil
92
106
}
+2
-5
spindle/engines/microvm/budget.go
+2
-5
spindle/engines/microvm/budget.go
···
68
68
return budget, maxReq, cfg.AgingThreshold
69
69
}
70
70
71
-
func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) {
71
+
func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, mode engine.AcquireMode) (engine.WorkflowSlot, error) {
72
72
state, ok := wf.Data.(*workflowState)
73
73
if !ok || state == nil {
74
74
return nil, fmt.Errorf("microVM workflow state is not initialized")
···
77
77
return engine.NoopSlot{}, nil
78
78
}
79
79
req := resourcesForImage(state.ImageSpec)
80
-
if req.MemoryMiB < 0 || req.VCPUs < 0 || req.DiskMiB < 0 {
81
-
return nil, fmt.Errorf("microVM resource request must not be negative: %s", req)
82
-
}
83
-
return e.scheduler.Acquire(ctx, req)
80
+
return e.scheduler.Acquire(ctx, req, mode)
84
81
}
85
82
86
83
func resourcesForImage(spec ImageSpec) Resources {
+4
-4
spindle/engines/microvm/engine.go
+4
-4
spindle/engines/microvm/engine.go
···
52
52
agent *agentHub
53
53
scheduler *engine.ResourceScheduler[Resources]
54
54
cgroupParent *CgroupParent
55
+
budget Resources
56
+
maxWorkflow Resources
55
57
56
58
cleanupMu sync.Mutex
57
59
cleanup map[string][]cleanupFunc
···
91
93
db: d,
92
94
scheduler: engine.NewResourceScheduler(budget, max, agingThreshold),
93
95
cgroupParent: cgroupParent,
96
+
budget: budget,
97
+
maxWorkflow: max,
94
98
cleanup: make(map[string][]cleanupFunc),
95
99
}, nil
96
100
}
···
561
565
return cleanupErr
562
566
}
563
567
564
-
func (e *Engine) FinalizeWorkflow(ctx context.Context, wid models.WorkflowId, w *models.Workflow, wfLogger models.WorkflowLogger) error {
565
-
return nil
566
-
}
567
-
568
568
func (e *Engine) WorkflowTimeout() time.Duration {
569
569
d, err := time.ParseDuration(e.cfg.MicroVMPipelines.WorkflowTimeout)
570
570
if err != nil {
+14
-13
spindle/engines/microvm/image.go
+14
-13
spindle/engines/microvm/image.go
···
240
240
}
241
241
return "", false, err
242
242
}
243
-
if !info.IsDir() {
244
-
return candidate, true, nil
243
+
if info.IsDir() {
244
+
candidate = filepath.Join(candidate, imageSpecFileName)
245
+
info, err = os.Stat(candidate)
246
+
if err != nil {
247
+
if errors.Is(err, os.ErrNotExist) {
248
+
return "", false, fmt.Errorf("microVM image directory %q does not contain %s", filepath.Dir(candidate), imageSpecFileName)
249
+
}
250
+
return "", false, err
251
+
}
252
+
if info.IsDir() {
253
+
return "", false, fmt.Errorf("microVM image spec %q is a directory", candidate)
254
+
}
245
255
}
246
256
247
-
spec := filepath.Join(candidate, imageSpecFileName)
248
-
info, err = os.Stat(spec)
257
+
path, err := filepath.EvalSymlinks(candidate)
249
258
if err != nil {
250
-
if errors.Is(err, os.ErrNotExist) {
251
-
return "", false, fmt.Errorf("microVM image directory %q does not contain %s", candidate, imageSpecFileName)
252
-
}
253
259
return "", false, err
254
260
}
255
-
// this only happens if there is a directory named `spec.json` which would be very silly.
256
-
// but better output an error for it anyway :p
257
-
if info.IsDir() {
258
-
return "", false, fmt.Errorf("microVM image spec %q is a directory", spec)
259
-
}
260
-
return spec, true, nil
261
+
return path, true, nil
261
262
}
+58
spindle/engines/microvm/image_test.go
+58
spindle/engines/microvm/image_test.go
···
65
65
})
66
66
}
67
67
}
68
+
func TestImageSpecPathPinsSymlinkTarget(t *testing.T) {
69
+
dir := t.TempDir()
70
+
imageA := filepath.Join(dir, "image-a")
71
+
imageB := filepath.Join(dir, "image-b")
72
+
writeSpecFile(t, filepath.Join(imageA, imageSpecFileName))
73
+
writeSpecFile(t, filepath.Join(imageB, imageSpecFileName))
74
+
75
+
alias := filepath.Join(dir, "default")
76
+
if err := os.Symlink(imageA, alias); err != nil {
77
+
t.Fatal(err)
78
+
}
79
+
got, ok, err := imageSpecPath(alias)
80
+
if err != nil {
81
+
t.Fatal(err)
82
+
}
83
+
if !ok {
84
+
t.Fatal("imageSpecPath() did not resolve symlinked image")
85
+
}
86
+
87
+
if err := os.Remove(alias); err != nil {
88
+
t.Fatal(err)
89
+
}
90
+
if err := os.Symlink(imageB, alias); err != nil {
91
+
t.Fatal(err)
92
+
}
93
+
want := filepath.Join(imageA, imageSpecFileName)
94
+
if got != want {
95
+
t.Fatalf("imageSpecPath() = %q after alias resolution, want pinned target %q", got, want)
96
+
}
97
+
}
68
98
69
99
func TestResolveImageDirectoryMissingSpec(t *testing.T) {
70
100
dir := t.TempDir()
···
135
165
t.Fatalf("spec without shell should fail validation, got: %v", err)
136
166
}
137
167
}
168
+
func TestMkfsExt4ForVolumesSkipsLookupWithoutVolumes(t *testing.T) {
169
+
t.Setenv("PATH", "")
170
+
path, err := mkfsExt4ForVolumes(nil, "")
171
+
if err != nil {
172
+
t.Fatalf("mkfsExt4ForVolumes() with no volumes returned error: %v", err)
173
+
}
174
+
if path != "" {
175
+
t.Fatalf("mkfsExt4ForVolumes() = %q with no volumes, want empty path", path)
176
+
}
177
+
178
+
_, err = mkfsExt4ForVolumes([]Volume{{Image: "workspace"}}, "")
179
+
if err == nil || !strings.Contains(err.Error(), "mkfs.ext4") {
180
+
t.Fatalf("mkfsExt4ForVolumes() with a volume and no formatter returned %v", err)
181
+
}
182
+
}
183
+
184
+
func TestQEMURunnerValidateRejectsUnsupportedNetworkTypeBeforeHostChecks(t *testing.T) {
185
+
spec := validImageSpec()
186
+
spec.NetworkInterfaces = []NetworkInterface{{
187
+
Type: "tap",
188
+
ID: "net0",
189
+
MAC: "02:00:00:00:00:01",
190
+
}}
191
+
err := (qemuRunner{}).Validate(spec, false)
192
+
if err == nil || !strings.Contains(err.Error(), `unsupported microvm network interface type "tap"`) {
193
+
t.Fatalf("Validate() error = %v, want unsupported network type", err)
194
+
}
195
+
}
+15
spindle/engines/microvm/placement/placement.go
+15
spindle/engines/microvm/placement/placement.go
···
1
+
package placement
2
+
3
+
func IsNativeArchitecture(imageArch, goArch string) bool {
4
+
normalize := func(arch string) string {
5
+
switch arch {
6
+
case "x86_64", "amd64":
7
+
return "amd64"
8
+
case "aarch64", "arm64":
9
+
return "arm64"
10
+
default:
11
+
return arch
12
+
}
13
+
}
14
+
return imageArch != "" && normalize(imageArch) == normalize(goArch)
15
+
}
+26
spindle/engines/microvm/placement/placement_test.go
+26
spindle/engines/microvm/placement/placement_test.go
···
1
+
package placement
2
+
3
+
import (
4
+
"testing"
5
+
)
6
+
7
+
func TestIsNativeArchitecture(t *testing.T) {
8
+
tests := []struct {
9
+
image string
10
+
host string
11
+
want bool
12
+
}{
13
+
{image: "x86_64", host: "amd64", want: true},
14
+
{image: "amd64", host: "amd64", want: true},
15
+
{image: "aarch64", host: "arm64", want: true},
16
+
{image: "arm64", host: "arm64", want: true},
17
+
{image: "x86_64", host: "arm64", want: false},
18
+
{image: "aarch64", host: "amd64", want: false},
19
+
{image: "", host: "amd64", want: false},
20
+
}
21
+
for _, tt := range tests {
22
+
if got := IsNativeArchitecture(tt.image, tt.host); got != tt.want {
23
+
t.Errorf("IsNativeArchitecture(%q, %q) = %v, want %v", tt.image, tt.host, got, tt.want)
24
+
}
25
+
}
26
+
}
+61
spindle/engines/microvm/placement_linux.go
+61
spindle/engines/microvm/placement_linux.go
···
1
+
//go:build linux
2
+
3
+
package microvm
4
+
5
+
import (
6
+
"fmt"
7
+
"os/exec"
8
+
"runtime"
9
+
10
+
"tangled.org/core/spindle/engines/microvm/placement"
11
+
"tangled.org/core/spindle/models"
12
+
)
13
+
14
+
func (e *Engine) ValidateWorkflowPlacement(wf *models.Workflow) error {
15
+
state, ok := wf.Data.(*workflowState)
16
+
if !ok || state == nil {
17
+
return fmt.Errorf("microVM workflow state is not initialized")
18
+
}
19
+
return e.validateImagePlacement(state.ImageSpec)
20
+
}
21
+
22
+
func (e *Engine) validateImagePlacement(spec ImageSpec) error {
23
+
if err := spec.Validate(); err != nil {
24
+
return err
25
+
}
26
+
if !placement.IsNativeArchitecture(spec.Arch, runtime.GOARCH) {
27
+
return fmt.Errorf("microVM image architecture %q is not native to executor architecture %q", spec.Arch, runtime.GOARCH)
28
+
}
29
+
if err := spec.validateImageFiles(); err != nil {
30
+
return err
31
+
}
32
+
runner, err := runnerFor(spec.RunnerType)
33
+
if err != nil {
34
+
return err
35
+
}
36
+
if err := runner.Validate(spec, e.cfg.MicroVMPipelines.EnableKVM); err != nil {
37
+
return err
38
+
}
39
+
if len(spec.Volumes) > 0 {
40
+
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
41
+
return fmt.Errorf("required host command %q not found in PATH: %w", "mkfs.ext4", err)
42
+
}
43
+
for _, volume := range spec.Volumes {
44
+
if volume.ReadOnly {
45
+
return fmt.Errorf("read-only microvm volume %q is not supported yet", volume.Image)
46
+
}
47
+
if volume.FSType != "ext4" {
48
+
return fmt.Errorf("microvm volume %q uses unsupported fsType %q", volume.Image, volume.FSType)
49
+
}
50
+
if volume.ImageType != "" && volume.ImageType != "raw" {
51
+
return fmt.Errorf("microvm volume %q uses unsupported imageType %q", volume.Image, volume.ImageType)
52
+
}
53
+
}
54
+
}
55
+
56
+
request := resourcesForImage(spec)
57
+
if !request.Fits(e.budget) || !request.Fits(e.maxWorkflow) {
58
+
return fmt.Errorf("microVM image resources exceed executor limits: request=%v budget=%v max=%v", request, e.budget, e.maxWorkflow)
59
+
}
60
+
return nil
61
+
}
+4
spindle/engines/microvm/qemu.go
+4
spindle/engines/microvm/qemu.go
···
73
73
type qemuRunner struct{}
74
74
75
75
func (qemuRunner) Validate(spec ImageSpec, enableKVM bool) error {
76
+
b := newArgBuilder(len(spec.NetworkInterfaces) * 4)
77
+
if err := addQEMUNetworkArgs(&b, spec.NetworkInterfaces); err != nil {
78
+
return err
79
+
}
76
80
if _, err := exec.LookPath(spec.RunnerCmd()); err != nil {
77
81
return fmt.Errorf("required host command %q not found in PATH: %w", spec.RunnerCmd(), err)
78
82
}
+14
-6
spindle/engines/microvm/vm.go
+14
-6
spindle/engines/microvm/vm.go
···
47
47
return nil
48
48
}
49
49
50
+
func mkfsExt4ForVolumes(volumes []Volume, configured string) (string, error) {
51
+
if len(volumes) == 0 || configured != "" {
52
+
return configured, nil
53
+
}
54
+
path, err := exec.LookPath("mkfs.ext4")
55
+
if err != nil {
56
+
return "", fmt.Errorf("mkfs.ext4 command not found in PATH: %w", err)
57
+
}
58
+
return path, nil
59
+
}
60
+
50
61
func prepareVolumes(ctx context.Context, workDir string, volumes []Volume, mkfsExt4 string) (map[string]string, error) {
51
62
paths := make(map[string]string, len(volumes))
52
63
for _, volume := range volumes {
···
361
372
return nil, err
362
373
}
363
374
364
-
mkfsExt4 := cfg.MkfsExt4
365
-
if mkfsExt4 == "" {
366
-
mkfsExt4, err = exec.LookPath("mkfs.ext4")
367
-
if err != nil {
368
-
return nil, fmt.Errorf("mkfs.ext4 command not found in PATH: %w", err)
369
-
}
375
+
mkfsExt4, err := mkfsExt4ForVolumes(cfg.Image.Volumes, cfg.MkfsExt4)
376
+
if err != nil {
377
+
return nil, err
370
378
}
371
379
volumePaths, err := prepareVolumes(ctx, cfg.WorkDir, cfg.Image.Volumes, mkfsExt4)
372
380
if err != nil {
+2
-1
spindle/engines/nixery/engine.go
+2
-1
spindle/engines/nixery/engine.go
···
198
198
ctx context.Context,
199
199
wid models.WorkflowId,
200
200
wf *models.Workflow,
201
+
mode engine.AcquireMode,
201
202
) (engine.WorkflowSlot, error) {
202
203
if e.slotter == nil {
203
204
return engine.NoopSlot{}, nil
204
205
}
205
206
206
-
return e.slotter.AcquireWorkflowSlot(ctx, wid, wf)
207
+
return e.slotter.AcquireWorkflowSlot(ctx, wid, wf, mode)
207
208
}
208
209
209
210
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) (err error) {
+52
spindle/logview/logview.go
+52
spindle/logview/logview.go
···
1
+
package logview
2
+
3
+
import (
4
+
"bufio"
5
+
"context"
6
+
"io"
7
+
"strings"
8
+
9
+
"github.com/hpcloud/tail"
10
+
"tangled.org/core/spindle/artifactstore"
11
+
"tangled.org/core/spindle/db"
12
+
"tangled.org/core/spindle/models"
13
+
)
14
+
15
+
// streams a workflow's log lines: tails the log file while running, reads
16
+
// the uploaded artifact once finished, same for every role. stop ends a
17
+
// live follow early, the channel closes when the source drains or ctx ends
18
+
func Follow(ctx context.Context, d *db.DB, reader artifactstore.Reader, logDir string, wid models.WorkflowId, finished bool) (<-chan *tail.Line, func(), error) {
19
+
if finished && reader != nil && d != nil {
20
+
if fl, err := d.GetFinishedLog(wid.Name); err == nil && fl.Ref != "" {
21
+
rc, err := reader.Open(ctx, fl.Ref)
22
+
if err == nil {
23
+
ch := make(chan *tail.Line, 64)
24
+
followCtx, cancel := context.WithCancel(ctx)
25
+
go func() {
26
+
defer close(ch)
27
+
defer rc.Close()
28
+
scanner := bufio.NewScanner(rc)
29
+
for scanner.Scan() {
30
+
select {
31
+
case <-followCtx.Done():
32
+
return
33
+
case ch <- &tail.Line{Text: strings.TrimSuffix(scanner.Text(), "\r")}:
34
+
}
35
+
}
36
+
}()
37
+
return ch, cancel, nil
38
+
}
39
+
}
40
+
}
41
+
42
+
t, err := tail.TailFile(models.LogFilePath(logDir, wid), tail.Config{
43
+
Follow: !finished,
44
+
ReOpen: !finished,
45
+
MustExist: false,
46
+
Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekStart},
47
+
})
48
+
if err != nil {
49
+
return nil, nil, err
50
+
}
51
+
return t.Lines, func() { _ = t.Stop() }, nil
52
+
}
+159
spindle/mill/README.md
+159
spindle/mill/README.md
···
1
+
# spindle mill
2
+
3
+
This document describes the architecture of the mill, spindle's distributed
4
+
job placement layer. A mill is a spindle that doesn't run jobs itself: it
5
+
places them on remote executors, which run the real engines (microvm, nixery,
6
+
dummy) exactly as they would standalone.
7
+
8
+
## The engine mirroring model
9
+
10
+
Neither side's execution path changes between local and remote runs.
11
+
12
+
On the mill host, engines are registered under the real engine names
13
+
("microvm", "nixery", "dummy"), but each is a stand-in that places jobs
14
+
remotely instead of running them. `InitWorkflow` on the stand-in parses
15
+
nothing, it stashes the raw pipeline and workflow and builds a synthetic
16
+
one-step workflow, so everything upstream (trigger handling, pending status,
17
+
`TANGLED_*` env) behaves exactly like a local run. The real `InitWorkflow`
18
+
runs exactly once, on the executor.
19
+
20
+
On the executor, the reserve handler wraps the real engine in a
21
+
`reservedEngine`: the slot acquired when the reservation is accepted is the
22
+
same slot `StartWorkflows` later runs on. Nothing acquires twice, and from the
23
+
engine's point of view the job is indistinguishable from a local one.
24
+
25
+
The same mirroring applies to output: the executor's jobs write status rows
26
+
and log lines exactly like a standalone spindle, and the mill re-authors them
27
+
into its own event stream and log store, so appview and the log endpoints see
28
+
no difference between a local and a remote job.
29
+
30
+
The only real divergence: secrets are withheld from untrusted pipelines at the
31
+
mill, and they cross the wire exactly once, inside `CommitLease`, to the one
32
+
node that won the bid. Losing bidders never see them.
33
+
34
+
## Placement: labels, seats, and rejection
35
+
36
+
Placement decides which executors can run a workflow right now, using one
37
+
kind of fact:
38
+
39
+
- **labels** are operator-defined strings on the executor's token, checked
40
+
against the workflow's `runs_on`. they're for coarse fleet partitioning
41
+
("this pool is for trusted jobs", "this box has a gpu"), nothing more
42
+
43
+
Matching is exact intersection: candidate sessions whose snapshot has the
44
+
engine available, a free seat, and labels covering `runs_on`. Among the
45
+
eligible candidates the mill ranks least-loaded first and bids the top-K
46
+
concurrently with `ReserveSeat`. The best-ranked accept wins, losers get
47
+
`ReleaseLease` so they free their held seats immediately. If nobody
48
+
accepts, the job waits for a change: a new executor connecting, a snapshot
49
+
flipping availability, or a lease finishing somewhere.
50
+
51
+
Image and arch compatibility is deliberately *not* a mill concern. The mill
52
+
never parses an image name or an arch string; the executor re-validates
53
+
everything at reserve time against its own disk (engine exists, workflow
54
+
parses, the image spec validates and is natively runnable, the runner is
55
+
usable) and rejects what it can't run with `incompatible`. A reject rotates
56
+
the bid to the next candidate, and if every candidate rejects, the user
57
+
gets the collected reasons as the placement error. Multi-arch falls out of
58
+
this: an arm64 box simply cannot accept an x86_64 image, so it never holds
59
+
one. If you *want* to pin an arch or an image explicitly, label the nodes
60
+
and use `runs_on`.
61
+
62
+
### Example: an alpine microvm job
63
+
64
+
Say a workflow asks for the microvm engine with `image: alpine`, no
65
+
`runs_on`, and the fleet looks like this:
66
+
67
+
| node | arch | labels | engines | load |
68
+
|---------|---------|---------|-------------------|--------|
69
+
| ci-1 | x86_64 | [linux] | microvm | busy |
70
+
| ci-2 | aarch64 | [linux] | microvm | idle |
71
+
| ci-3 | x86_64 | [linux] | microvm, nixery | idle |
72
+
| ci-4 | x86_64 | [gpu] | microvm | idle |
73
+
74
+
The walkthrough:
75
+
76
+
```mermaid
77
+
flowchart TD
78
+
W["workflow<br/><small>engine: microvm, image: alpine</small>"] --> F{"candidate filter"}
79
+
F -->|"all pass runs_on (empty)"| L{"has microvm<br/>available?"}
80
+
L -->|all four| R{"rank by load"}
81
+
R -->|"ci-1 busy"| X2["ci-1 ranked last"]
82
+
R --> B["bid top-K: ci-2, ci-4, ci-1"]
83
+
```
84
+
85
+
Say ci-2's alpine image is actually x86_64-only: the mill offers the bid
86
+
anyway, ci-2's reserve-time validation rejects it as incompatible, and the
87
+
bid rotates to ci-4. The mill learns "ci-2 can't run alpine" from the
88
+
rejection, not from any advertisement, and the reason reaches the user if
89
+
every candidate fails the same way.
90
+
91
+
The bid then runs concurrently:
92
+
93
+
```mermaid
94
+
sequenceDiagram
95
+
participant M as mill
96
+
participant C2 as ci-2
97
+
participant C4 as ci-4
98
+
participant C1 as ci-1
99
+
100
+
par bids
101
+
M->>C2: ReserveSeat (raw pipeline+workflow)
102
+
M->>C4: ReserveSeat
103
+
M->>C1: ReserveSeat
104
+
end
105
+
C2-->>M: accept (idle)
106
+
C4-->>M: accept
107
+
C1-->>M: reject (transient, seats full)
108
+
Note over M: ci-2 ranked above ci-4,<br/>ci-4 gets ReleaseLease
109
+
M->>C2: CommitLease (secrets)
110
+
C2-->>M: Committed
111
+
M->>C4: ReleaseLease
112
+
```
113
+
114
+
Both idle nodes accepted, so rank breaks the tie: ci-2 keeps the seat, ci-4
115
+
frees its immediately, and only ci-2 ever sees the secrets. From here ci-2
116
+
runs the job exactly like a standalone spindle would, booting the alpine
117
+
image under QEMU, while the mill blocks on the terminal event.
118
+
119
+
## The protocol's durability model
120
+
121
+
Executor to mill is a single websocket per node, and everything the executor
122
+
reports (status, logs, terminal results) travels as sequenced `Event`s. Two
123
+
identifiers keep it all consistent:
124
+
125
+
- the **epoch** names one lifetime of the executor process. a restarted
126
+
executor connects with a fresh epoch, and anything arriving for an old one
127
+
is invalid. leases are bound to node+epoch, so a zombie from a previous
128
+
process can't act on them
129
+
- the **seqno** is a dense per-epoch counter on events. the executor persists
130
+
events in a local outbox before sending and trims it only when the mill
131
+
acks. on reconnect it resumes from the mill's acked position and replays.
132
+
the mill applies idempotently: replays drop, gaps kill the session
133
+
134
+
The mill is equally restartable: leases, acked positions and the canonical
135
+
log all live in its db. Restored leases start as orphans and must be
136
+
reclaimed by the executor's first snapshot, or a sweep fails them after one
137
+
grace window. The invariant throughout: at any moment, for any lease, exactly
138
+
one of {executor outbox, mill db} holds the newest state, and the seqno/epoch
139
+
pair says who.
140
+
141
+
## Leases
142
+
143
+
A lease is the mill-side handle for one placed job:
144
+
145
+
```mermaid
146
+
stateDiagram-v2
147
+
[*] --> reserved: bid won
148
+
reserved --> committing: CommitLease sent
149
+
committing --> running: Committed
150
+
running --> done: terminal arrived
151
+
reserved --> done: released / expired
152
+
committing --> done: released / expired
153
+
```
154
+
155
+
Commit retries ride reconnects: a reservation outlives one disconnect, so a
156
+
lost session means wait and retry, not a failed job. What ends a job is the
157
+
job timeout, a terminal event, or the executor being declared dead after
158
+
reconnect grace expires, at which point every lease the node held fails
159
+
(preserving a pending cancellation as the reason).
+482
spindle/mill/auth_test.go
+482
spindle/mill/auth_test.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"io"
6
+
"log/slog"
7
+
"net/http"
8
+
"net/http/httptest"
9
+
"path/filepath"
10
+
"strings"
11
+
"testing"
12
+
"time"
13
+
14
+
"github.com/gorilla/websocket"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/spindle/db"
17
+
"tangled.org/core/spindle/models"
18
+
19
+
millproto "tangled.org/core/spindle/mill/proto"
20
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
21
+
)
22
+
23
+
func discardLogger() *slog.Logger {
24
+
return slog.New(slog.NewTextHandler(io.Discard, nil))
25
+
}
26
+
27
+
func nopEncoder() scriptedEncoder {
28
+
return scriptedEncoder(func(*millproto.Message) error { return nil })
29
+
}
30
+
31
+
func TestHashToken(t *testing.T) {
32
+
const raw = "super-secret-executor-token"
33
+
34
+
if HashToken(raw) != HashToken(raw) {
35
+
t.Fatal("HashToken is not deterministic; the same token would stop authenticating")
36
+
}
37
+
if HashToken("token-a") == HashToken("token-b") {
38
+
t.Fatal("HashToken collided two distinct tokens")
39
+
}
40
+
if HashToken(raw) == raw {
41
+
t.Fatal("HashToken returned the raw token; a hash leak would expose a usable credential")
42
+
}
43
+
}
44
+
45
+
func TestGenerateTokenDistinct(t *testing.T) {
46
+
const n = 100
47
+
seen := make(map[string]struct{}, n)
48
+
for i := range n {
49
+
tok, err := GenerateToken()
50
+
if err != nil {
51
+
t.Fatalf("GenerateToken: %v", err)
52
+
}
53
+
if tok == "" {
54
+
t.Fatalf("GenerateToken returned an empty token on call %d", i)
55
+
}
56
+
if _, dup := seen[tok]; dup {
57
+
t.Fatalf("GenerateToken repeated a token after %d calls: %q", i, tok)
58
+
}
59
+
seen[tok] = struct{}{}
60
+
}
61
+
}
62
+
63
+
func TestAttachSessionRejectsSecondLiveSession(t *testing.T) {
64
+
l := discardLogger()
65
+
m := New(l, Config{ReconnectGrace: time.Minute})
66
+
67
+
sessionOf := func(node string) *millSession {
68
+
m.mu.Lock()
69
+
defer m.mu.Unlock()
70
+
return m.sessions[node]
71
+
}
72
+
73
+
sess1 := newSession("node-1", "inc-1", nil, nopEncoder(), l)
74
+
if _, ok := m.attachSession(sess1); !ok {
75
+
t.Fatal("first attach of a node was rejected; want accept")
76
+
}
77
+
if sessionOf("node-1") != sess1 {
78
+
t.Fatal("first session was not registered as the live session")
79
+
}
80
+
81
+
sess2 := newSession("node-1", "inc-2", nil, nopEncoder(), l)
82
+
if _, ok := m.attachSession(sess2); ok {
83
+
t.Fatal("second live attach for an already-live node was accepted; a valid token hijacked the executor")
84
+
}
85
+
if sessionOf("node-1") != sess1 {
86
+
t.Fatal("rejected newcomer evicted the incumbent session")
87
+
}
88
+
89
+
m.detachSession(sess1)
90
+
sess3 := newSession("node-1", "inc-3", nil, nopEncoder(), l)
91
+
if _, ok := m.attachSession(sess3); !ok {
92
+
t.Fatal("attach during the incumbent's reconnect grace was rejected; want adopt")
93
+
}
94
+
if sessionOf("node-1") != sess3 {
95
+
t.Fatal("adopted session was not installed as the live session")
96
+
}
97
+
}
98
+
99
+
func TestOnAttemptResultIgnoresForeignLease(t *testing.T) {
100
+
ctx := context.Background()
101
+
l := discardLogger()
102
+
bdb, err := db.Make(ctx, filepath.Join(t.TempDir(), "mill.db"))
103
+
if err != nil {
104
+
t.Fatalf("db.Make: %v", err)
105
+
}
106
+
t.Cleanup(func() { bdb.Close() })
107
+
n := notifier.New()
108
+
109
+
m := New(l, Config{ReconnectGrace: time.Minute})
110
+
m.Attach(bdb, &n)
111
+
112
+
foreign := newLease("lease-foreign", "node-a", "inc-a", "dummy")
113
+
m.mu.Lock()
114
+
m.leases[foreign.id] = foreign
115
+
m.mu.Unlock()
116
+
117
+
sessB := newSession("node-b", "inc-b", nil, nopEncoder(), l)
118
+
m.attachSession(sessB)
119
+
_ = m.onEventBatch(sessB, &millv1.EventBatch{
120
+
Epoch: sessB.epoch,
121
+
Events: []*millv1.Event{
122
+
{
123
+
Seqno: 1,
124
+
LeaseId: foreign.id,
125
+
Payload: &millv1.Event_AttemptResult{
126
+
AttemptResult: &millv1.AttemptResult{
127
+
Status: millv1.TerminalStatus_SUCCESS,
128
+
},
129
+
},
130
+
},
131
+
},
132
+
})
133
+
134
+
if _, ok := pollTerminal(foreign); ok {
135
+
t.Fatal("attempt-result on a foreign lease delivered a terminal; an executor forged another node's job result")
136
+
}
137
+
if foreign.getState() == leaseDone {
138
+
t.Fatal("attempt-result on a foreign lease sealed the lease")
139
+
}
140
+
}
141
+
142
+
func TestOnAttemptResultIgnoresAbsentLease(t *testing.T) {
143
+
ctx := context.Background()
144
+
l := discardLogger()
145
+
bdb, err := db.Make(ctx, filepath.Join(t.TempDir(), "mill.db"))
146
+
if err != nil {
147
+
t.Fatalf("db.Make: %v", err)
148
+
}
149
+
t.Cleanup(func() { bdb.Close() })
150
+
n := notifier.New()
151
+
152
+
m := New(l, Config{ReconnectGrace: time.Minute})
153
+
m.Attach(bdb, &n)
154
+
155
+
// bystander lease the reporting node owns, proving an absent-lease stream
156
+
// does not spill onto another lease
157
+
bystander := newLease("lease-bystander", "node-b", "inc-b", "dummy")
158
+
m.mu.Lock()
159
+
m.leases[bystander.id] = bystander
160
+
m.mu.Unlock()
161
+
162
+
sessB := newSession("node-b", "inc-b", nil, nopEncoder(), l)
163
+
m.attachSession(sessB)
164
+
165
+
_ = m.onEventBatch(sessB, &millv1.EventBatch{
166
+
Epoch: sessB.epoch,
167
+
Events: []*millv1.Event{
168
+
{
169
+
Seqno: 1,
170
+
LeaseId: "lease-nonexistent",
171
+
Payload: &millv1.Event_AttemptResult{
172
+
AttemptResult: &millv1.AttemptResult{
173
+
Status: millv1.TerminalStatus_SUCCESS,
174
+
},
175
+
},
176
+
},
177
+
},
178
+
})
179
+
180
+
if _, ok := pollTerminal(bystander); ok {
181
+
t.Fatal("attempt-result for an absent lease delivered a terminal to a bystander lease")
182
+
}
183
+
if bystander.getState() == leaseDone {
184
+
t.Fatal("attempt-result for an absent lease sealed a bystander lease")
185
+
}
186
+
}
187
+
188
+
// owned-lease path proves ignore tests above are not passing merely because
189
+
// delivery is broken. correctly owned terminal is delivered
190
+
func TestOnAttemptResultDeliversOwnedLease(t *testing.T) {
191
+
ctx := context.Background()
192
+
l := discardLogger()
193
+
bdb, err := db.Make(ctx, filepath.Join(t.TempDir(), "mill.db"))
194
+
if err != nil {
195
+
t.Fatalf("db.Make: %v", err)
196
+
}
197
+
t.Cleanup(func() { bdb.Close() })
198
+
n := notifier.New()
199
+
200
+
m := New(l, Config{ReconnectGrace: time.Minute})
201
+
m.Attach(bdb, &n)
202
+
203
+
owned := newLease("lease-owned", "node-b", "inc-b", "dummy")
204
+
m.mu.Lock()
205
+
m.leases[owned.id] = owned
206
+
m.mu.Unlock()
207
+
208
+
sessB := newSession("node-b", "inc-b", nil, nopEncoder(), l)
209
+
m.attachSession(sessB)
210
+
_ = m.onEventBatch(sessB, &millv1.EventBatch{
211
+
Epoch: sessB.epoch,
212
+
Events: []*millv1.Event{
213
+
{
214
+
Seqno: 1,
215
+
LeaseId: owned.id,
216
+
Payload: &millv1.Event_AttemptResult{
217
+
AttemptResult: &millv1.AttemptResult{
218
+
Status: millv1.TerminalStatus_SUCCESS,
219
+
},
220
+
},
221
+
},
222
+
},
223
+
})
224
+
225
+
res, ok := pollTerminal(owned)
226
+
if !ok {
227
+
t.Fatal("attempt-result on an owned lease was not delivered")
228
+
}
229
+
if got := res.GetStatus(); got != millv1.TerminalStatus_SUCCESS {
230
+
t.Fatalf("delivered terminal status = %v, want %v", got, millv1.TerminalStatus_SUCCESS)
231
+
}
232
+
if owned.getState() != leaseDone {
233
+
t.Fatal("owned lease was not sealed after its terminal was delivered")
234
+
}
235
+
}
236
+
237
+
func TestOnStatusEventOwnership(t *testing.T) {
238
+
ctx := context.Background()
239
+
l := discardLogger()
240
+
241
+
bdb, err := db.Make(ctx, filepath.Join(t.TempDir(), "mill.db"))
242
+
if err != nil {
243
+
t.Fatalf("db.Make: %v", err)
244
+
}
245
+
t.Cleanup(func() { bdb.Close() })
246
+
n := notifier.New()
247
+
248
+
m := New(l, Config{ReconnectGrace: time.Minute})
249
+
m.Attach(bdb, &n)
250
+
251
+
foreign := newLease("lease-foreign", "node-x", "inc-x", "dummy")
252
+
foreign.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "foreign"}, Name: "build"}
253
+
owned := newLease("lease-owned", "node-z", "inc-z", "dummy")
254
+
owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "owned"}, Name: "build"}
255
+
m.mu.Lock()
256
+
m.leases[foreign.id] = foreign
257
+
m.leases[owned.id] = owned
258
+
m.mu.Unlock()
259
+
260
+
sessY := newSession("node-y", "inc-y", nil, nopEncoder(), l)
261
+
m.attachSession(sessY)
262
+
sessZ := newSession("node-z", "inc-z", nil, nopEncoder(), l)
263
+
m.attachSession(sessZ)
264
+
265
+
_ = m.onEventBatch(sessY, &millv1.EventBatch{
266
+
Epoch: sessY.epoch,
267
+
Events: []*millv1.Event{
268
+
{
269
+
Seqno: 1,
270
+
LeaseId: foreign.id,
271
+
Payload: &millv1.Event_StatusEvent{
272
+
StatusEvent: &millv1.StatusEvent{
273
+
Status: millv1.NonterminalStatus_RUNNING,
274
+
},
275
+
},
276
+
},
277
+
},
278
+
})
279
+
if _, err := bdb.GetStatus(foreign.wid); err == nil {
280
+
t.Fatal("status stream for a foreign lease authored a status row; an executor forged another pipeline's status")
281
+
}
282
+
283
+
_ = m.onEventBatch(sessZ, &millv1.EventBatch{
284
+
Epoch: sessZ.epoch,
285
+
Events: []*millv1.Event{
286
+
{
287
+
Seqno: 1,
288
+
LeaseId: owned.id,
289
+
Payload: &millv1.Event_StatusEvent{
290
+
StatusEvent: &millv1.StatusEvent{
291
+
Status: millv1.NonterminalStatus_RUNNING,
292
+
},
293
+
},
294
+
},
295
+
},
296
+
})
297
+
st, err := bdb.GetStatus(owned.wid)
298
+
if err != nil {
299
+
t.Fatalf("owned status stream did not author a status row: %v", err)
300
+
}
301
+
if st.Status != "running" {
302
+
t.Fatalf("owned status = %q, want %q", st.Status, "running")
303
+
}
304
+
}
305
+
306
+
func setupTestServer(t *testing.T, authorizedLabels []string) (*Mill, *db.DB, *httptest.Server, string) {
307
+
ctx := context.Background()
308
+
bdb, err := db.Make(ctx, filepath.Join(t.TempDir(), "mill.db"))
309
+
if err != nil {
310
+
t.Fatalf("db.Make: %v", err)
311
+
}
312
+
t.Cleanup(func() { bdb.Close() })
313
+
n := notifier.New()
314
+
315
+
m := New(discardLogger(), Config{
316
+
ReconnectGrace: time.Minute,
317
+
})
318
+
m.Attach(bdb, &n)
319
+
320
+
const secret = "test-secret"
321
+
if err := bdb.AddExecutorToken("dev-node", HashToken(secret), nil, authorizedLabels); err != nil {
322
+
t.Fatalf("AddExecutorToken: %v", err)
323
+
}
324
+
325
+
server := httptest.NewServer(http.HandlerFunc(m.HandleExecutorConn))
326
+
t.Cleanup(server.Close)
327
+
328
+
return m, bdb, server, secret
329
+
}
330
+
331
+
func TestAuthLabelEscalation(t *testing.T) {
332
+
_, _, server, secret := setupTestServer(t, []string{"linux", "amd64"})
333
+
334
+
wsUrl := "ws" + strings.TrimPrefix(server.URL, "http")
335
+
336
+
{
337
+
header := http.Header{}
338
+
header.Set("Authorization", "Bearer bad-token")
339
+
_, resp, err := websocket.DefaultDialer.Dial(wsUrl, header)
340
+
if err == nil {
341
+
t.Fatal("expected connection with invalid token to fail")
342
+
}
343
+
if resp != nil && resp.StatusCode != http.StatusUnauthorized {
344
+
t.Fatalf("expected 401 Unauthorized, got %d", resp.StatusCode)
345
+
}
346
+
}
347
+
348
+
{
349
+
header := http.Header{}
350
+
header.Set("Authorization", "Bearer "+secret)
351
+
conn, _, err := websocket.DefaultDialer.Dial(wsUrl, header)
352
+
if err != nil {
353
+
t.Fatalf("dial failed: %v", err)
354
+
}
355
+
defer conn.Close()
356
+
357
+
stream := millproto.NewWSStream(conn)
358
+
enc := millproto.NewEncoder(stream)
359
+
dec := millproto.NewDecoder(stream)
360
+
361
+
hello := &millproto.Message{Hello: &millv1.Hello{
362
+
ProtocolVersion: millproto.ProtocolVersion,
363
+
Arch: "amd64",
364
+
Labels: []string{"linux", "gpu"},
365
+
Epoch: "inc-1",
366
+
}}
367
+
if err := enc.Encode(hello); err != nil {
368
+
t.Fatalf("encode hello: %v", err)
369
+
}
370
+
371
+
_, err = dec.Decode()
372
+
if err == nil {
373
+
t.Fatal("expected server to close connection for unauthorized label, but got a message")
374
+
}
375
+
}
376
+
377
+
{
378
+
header := http.Header{}
379
+
header.Set("Authorization", "Bearer "+secret)
380
+
conn, _, err := websocket.DefaultDialer.Dial(wsUrl, header)
381
+
if err != nil {
382
+
t.Fatalf("dial failed: %v", err)
383
+
}
384
+
defer conn.Close()
385
+
386
+
stream := millproto.NewWSStream(conn)
387
+
enc := millproto.NewEncoder(stream)
388
+
dec := millproto.NewDecoder(stream)
389
+
390
+
hello := &millproto.Message{Hello: &millv1.Hello{
391
+
ProtocolVersion: millproto.ProtocolVersion,
392
+
Arch: "amd64",
393
+
Labels: []string{"linux"},
394
+
Epoch: "inc-1",
395
+
}}
396
+
if err := enc.Encode(hello); err != nil {
397
+
t.Fatalf("encode hello: %v", err)
398
+
}
399
+
400
+
msg, err := dec.Decode()
401
+
if err != nil {
402
+
t.Fatalf("expected resume message, got error: %v", err)
403
+
}
404
+
res := msg.GetResume()
405
+
if res == nil {
406
+
t.Fatal("expected Resume message, got nil")
407
+
}
408
+
if res.GetEpoch() != "inc-1" {
409
+
t.Fatalf("expected epoch inc-1, got %q", res.GetEpoch())
410
+
}
411
+
}
412
+
}
413
+
414
+
func TestHandshakeTimeoutAndConcurrency(t *testing.T) {
415
+
_, _, server, secret := setupTestServer(t, []string{"linux"})
416
+
wsUrl := "ws" + strings.TrimPrefix(server.URL, "http")
417
+
418
+
// executor that never sends Hello is dropped after the 5s pre-hello deadline
419
+
{
420
+
header := http.Header{}
421
+
header.Set("Authorization", "Bearer "+secret)
422
+
conn, _, err := websocket.DefaultDialer.Dial(wsUrl, header)
423
+
if err != nil {
424
+
t.Fatalf("dial failed: %v", err)
425
+
}
426
+
defer conn.Close()
427
+
428
+
time.Sleep(6 * time.Second)
429
+
430
+
stream := millproto.NewWSStream(conn)
431
+
enc := millproto.NewEncoder(stream)
432
+
hello := &millproto.Message{Hello: &millv1.Hello{
433
+
ProtocolVersion: millproto.ProtocolVersion,
434
+
Arch: "amd64",
435
+
Labels: []string{"linux"},
436
+
Epoch: "inc-1",
437
+
}}
438
+
err = enc.Encode(hello)
439
+
dec := millproto.NewDecoder(stream)
440
+
_, readErr := dec.Decode()
441
+
if readErr == nil {
442
+
t.Fatal("expected server to have closed connection due to handshake timeout")
443
+
}
444
+
}
445
+
446
+
// second live session for one identity is rejected with 409 before the ws upgrade
447
+
{
448
+
header := http.Header{}
449
+
header.Set("Authorization", "Bearer "+secret)
450
+
451
+
conn1, _, err := websocket.DefaultDialer.Dial(wsUrl, header)
452
+
if err != nil {
453
+
t.Fatalf("dial 1 failed: %v", err)
454
+
}
455
+
defer conn1.Close()
456
+
457
+
stream1 := millproto.NewWSStream(conn1)
458
+
enc1 := millproto.NewEncoder(stream1)
459
+
dec1 := millproto.NewDecoder(stream1)
460
+
hello1 := &millproto.Message{Hello: &millv1.Hello{
461
+
ProtocolVersion: millproto.ProtocolVersion,
462
+
Arch: "amd64",
463
+
Labels: []string{"linux"},
464
+
Epoch: "inc-1",
465
+
}}
466
+
if err := enc1.Encode(hello1); err != nil {
467
+
t.Fatalf("encode hello 1: %v", err)
468
+
}
469
+
_, err = dec1.Decode()
470
+
if err != nil {
471
+
t.Fatalf("first connection handshake failed: %v", err)
472
+
}
473
+
474
+
_, resp, err := websocket.DefaultDialer.Dial(wsUrl, header)
475
+
if err == nil {
476
+
t.Fatal("expected second connection for same live identity to be rejected")
477
+
}
478
+
if resp != nil && resp.StatusCode != http.StatusConflict {
479
+
t.Fatalf("expected 409 Conflict for duplicate session, got %d", resp.StatusCode)
480
+
}
481
+
}
482
+
}
+85
spindle/mill/engine.go
+85
spindle/mill/engine.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"time"
7
+
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/spindle/engine"
10
+
"tangled.org/core/spindle/models"
11
+
"tangled.org/core/spindle/secrets"
12
+
)
13
+
14
+
// raw pipeline/workflow carried forward, executor runs the real InitWorkflow
15
+
type millWorkflowState struct {
16
+
RawWorkflow tangled.Pipeline_Workflow
17
+
RawPipeline tangled.Pipeline
18
+
Lease *RemoteLease
19
+
}
20
+
21
+
// stand-in for a real engine, registered under the real names
22
+
// ("microvm", "nixery"), all sharing one Mill
23
+
type Engine struct {
24
+
name string
25
+
mill *Mill
26
+
l *slog.Logger
27
+
}
28
+
29
+
func (e *Engine) AuthorsRemoteStatus() {}
30
+
31
+
func NewEngine(name string, mill *Mill) *Engine {
32
+
return &Engine{name: name, mill: mill, l: mill.l.With("engine", "mill:"+name)}
33
+
}
34
+
35
+
// synthetic one-step workflow so processPipeline injects TANGLED_* env
36
+
// and marks pending normally. the real InitWorkflow runs exactly once, on
37
+
// the executor inside ReserveSeat, and commit reuses that workflow
38
+
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
39
+
return &models.Workflow{
40
+
Name: twf.Name,
41
+
Environment: map[string]string{},
42
+
Steps: []models.Step{remoteStep{}},
43
+
Data: &millWorkflowState{
44
+
RawWorkflow: twf,
45
+
RawPipeline: tpl,
46
+
},
47
+
}, nil
48
+
}
49
+
50
+
// no-op logger for the synthetic workflow. the executor's lines stream
51
+
// into this wid's log directly, a local logger would just write competing
52
+
// lines
53
+
func (e *Engine) WorkflowLogger(wid models.WorkflowId) models.WorkflowLogger {
54
+
return models.NullLogger{}
55
+
}
56
+
57
+
// the placement seam, blocks on remote placement which the user sees as
58
+
// "pending". only StartWorkflows calls this, always Wait
59
+
func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, _ engine.AcquireMode) (engine.WorkflowSlot, error) {
60
+
return e.mill.place(ctx, e.name, wid, wf)
61
+
}
62
+
63
+
// no-op. real setup happens on the executor
64
+
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error {
65
+
e.l.Info("remote job placed, awaiting commit", "wid", wid)
66
+
return nil
67
+
}
68
+
69
+
// hands over the secrets and blocks on the terminal result streamed over the
70
+
// session
71
+
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, unlocked []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
72
+
return e.mill.commitAndWait(ctx, w, unlocked)
73
+
}
74
+
75
+
// deliberately generous, the executor enforces the real timeout. the mill
76
+
// only caps a hung or silent executor, true death is caught by reconnect grace
77
+
func (e *Engine) WorkflowTimeout() time.Duration {
78
+
return e.mill.cfg.JobTimeout
79
+
}
80
+
81
+
// cancels a still-running attempt. no-op if already terminal
82
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
83
+
e.mill.destroy(wid)
84
+
return nil
85
+
}
+119
spindle/mill/executor/capability_test.go
+119
spindle/mill/executor/capability_test.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"errors"
7
+
"io"
8
+
"log/slog"
9
+
"testing"
10
+
11
+
"tangled.org/core/api/tangled"
12
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
13
+
"tangled.org/core/spindle/models"
14
+
)
15
+
16
+
type placementEngine struct {
17
+
*fakeEngine
18
+
validationErr error
19
+
validated bool
20
+
}
21
+
22
+
func (e *placementEngine) ValidateWorkflowPlacement(*models.Workflow) error {
23
+
e.validated = true
24
+
return e.validationErr
25
+
}
26
+
27
+
func TestHandleReserveRejectsMissingTriggerMetadata(t *testing.T) {
28
+
enc := newCaptureEncoder()
29
+
e := &Executor{
30
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
31
+
enc: enc,
32
+
seats: 1,
33
+
engines: map[string]models.Engine{"microvm": &fakeEngine{}},
34
+
active: make(map[string]*reservation),
35
+
}
36
+
twf, err := json.Marshal(tangled.Pipeline_Workflow{Name: "build"})
37
+
if err != nil {
38
+
t.Fatal(err)
39
+
}
40
+
tpl, err := json.Marshal(tangled.Pipeline{})
41
+
if err != nil {
42
+
t.Fatal(err)
43
+
}
44
+
45
+
// engines deref TriggerMetadata unconditionally. this must be a reject,
46
+
// not a panic that takes the whole executor down
47
+
e.handleReserve(context.Background(), &millv1.ReserveSeat{
48
+
LeaseId: "lease-1",
49
+
TargetEngine: "microvm",
50
+
RawWorkflowJson: string(twf),
51
+
RawPipelineJson: string(tpl),
52
+
Knot: "k",
53
+
Rkey: "r",
54
+
})
55
+
56
+
result := (<-enc.messages).GetReserveResult()
57
+
if result == nil {
58
+
t.Fatal("handleReserve() did not send ReserveResult")
59
+
}
60
+
if result.GetAccepted() {
61
+
t.Fatal("handleReserve() accepted a pipeline without trigger metadata")
62
+
}
63
+
if result.GetRejectClass() != millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE {
64
+
t.Fatalf("reject class = %v, want incompatible", result.GetRejectClass())
65
+
}
66
+
if len(e.active) != 0 {
67
+
t.Fatalf("active reservations = %d, want 0", len(e.active))
68
+
}
69
+
}
70
+
71
+
func TestHandleReserveValidatesPlacementBeforeAcquiringSlot(t *testing.T) {
72
+
enc := newCaptureEncoder()
73
+
validationErr := errors.New("image architecture is not native")
74
+
eng := &placementEngine{fakeEngine: &fakeEngine{}, validationErr: validationErr}
75
+
e := &Executor{
76
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
77
+
enc: enc,
78
+
seats: 1,
79
+
engines: map[string]models.Engine{"microvm": eng},
80
+
active: make(map[string]*reservation),
81
+
}
82
+
twf, err := json.Marshal(tangled.Pipeline_Workflow{Name: "build"})
83
+
if err != nil {
84
+
t.Fatal(err)
85
+
}
86
+
tpl, err := json.Marshal(tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{}})
87
+
if err != nil {
88
+
t.Fatal(err)
89
+
}
90
+
91
+
e.handleReserve(context.Background(), &millv1.ReserveSeat{
92
+
LeaseId: "lease-1",
93
+
TargetEngine: "microvm",
94
+
RawWorkflowJson: string(twf),
95
+
RawPipelineJson: string(tpl),
96
+
Knot: "k",
97
+
Rkey: "r",
98
+
})
99
+
100
+
result := (<-enc.messages).GetReserveResult()
101
+
if result == nil {
102
+
t.Fatal("handleReserve() did not send ReserveResult")
103
+
}
104
+
if result.GetAccepted() {
105
+
t.Fatal("handleReserve() accepted placement validation failure")
106
+
}
107
+
if result.GetRejectClass() != millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE {
108
+
t.Fatalf("reject class = %v, want incompatible", result.GetRejectClass())
109
+
}
110
+
if !eng.validated {
111
+
t.Fatal("placement validator was not called")
112
+
}
113
+
if eng.acquireCalled {
114
+
t.Fatal("slot acquisition ran after placement validation failed")
115
+
}
116
+
if len(e.active) != 0 {
117
+
t.Fatalf("active reservations = %d, want 0", len(e.active))
118
+
}
119
+
}
+613
spindle/mill/executor/executor.go
+613
spindle/mill/executor/executor.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"errors"
7
+
"fmt"
8
+
"maps"
9
+
"net/http"
10
+
"runtime"
11
+
"sync"
12
+
"time"
13
+
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"log/slog"
16
+
"strings"
17
+
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/netutil"
20
+
"tangled.org/core/notifier"
21
+
"tangled.org/core/spindle/artifactstore"
22
+
"tangled.org/core/spindle/config"
23
+
"tangled.org/core/spindle/db"
24
+
"tangled.org/core/spindle/engine"
25
+
millproto "tangled.org/core/spindle/mill/proto"
26
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
27
+
"tangled.org/core/spindle/models"
28
+
)
29
+
30
+
const (
31
+
dialBackoffMin = 1 * time.Second
32
+
dialBackoffMax = 30 * time.Second
33
+
snapshotEvery = 15 * time.Second
34
+
defaultSeats = 4
35
+
)
36
+
37
+
type Executor struct {
38
+
millURL string
39
+
token string
40
+
nodeID string
41
+
seats int
42
+
labels []string
43
+
44
+
engines map[string]models.Engine
45
+
db *db.DB
46
+
n *notifier.Notifier
47
+
cfg *config.Config
48
+
l *slog.Logger
49
+
writer artifactstore.Writer
50
+
51
+
epoch string
52
+
outboxBytes int64
53
+
maxOutboxBytes int64 // 10 MiB default outbox cap
54
+
55
+
eventMu sync.Mutex
56
+
sendMu sync.Mutex
57
+
flushMu sync.Mutex
58
+
59
+
sentSeqno uint64
60
+
61
+
connMu sync.Mutex
62
+
enc messageEncoder
63
+
sessionCancel context.CancelFunc
64
+
65
+
mu sync.Mutex
66
+
active map[string]*reservation
67
+
draining bool
68
+
69
+
snapshotMu sync.Mutex
70
+
nextSeqno uint64
71
+
72
+
lifecycleCtx context.Context
73
+
jobsWG sync.WaitGroup
74
+
}
75
+
76
+
type reservation struct {
77
+
leaseID string
78
+
wid models.WorkflowId
79
+
realEngine models.Engine
80
+
slot engine.WorkflowSlot
81
+
wf *models.Workflow
82
+
repoDid syntax.DID
83
+
vault *memVault
84
+
85
+
committed bool
86
+
cancelled bool
87
+
cancel context.CancelFunc
88
+
ttlTimer *time.Timer
89
+
stopTail func()
90
+
}
91
+
92
+
type messageEncoder interface {
93
+
Encode(*millproto.Message) error
94
+
}
95
+
96
+
func New(cfg *config.Config, engines map[string]models.Engine, d *db.DB, n *notifier.Notifier, l *slog.Logger, writers ...artifactstore.Writer) (*Executor, error) {
97
+
seats := defaultSeats
98
+
millURL := ""
99
+
token := ""
100
+
nodeID := ""
101
+
var labels []string
102
+
if cfg != nil {
103
+
if cfg.Mill.Seats > 0 {
104
+
seats = cfg.Mill.Seats
105
+
}
106
+
labels = normalizeLabels(cfg.Mill.Labels)
107
+
millURL = cfg.Mill.URL
108
+
token = cfg.Mill.SharedSecret
109
+
nodeID = cfg.Server.Hostname
110
+
}
111
+
if d == nil || n == nil {
112
+
return nil, fmt.Errorf("executor requires a database and notifier")
113
+
}
114
+
var writer artifactstore.Writer
115
+
if len(writers) > 0 {
116
+
writer = writers[0]
117
+
}
118
+
e := &Executor{
119
+
millURL: millURL,
120
+
token: token,
121
+
nodeID: nodeID,
122
+
seats: seats,
123
+
labels: labels,
124
+
engines: engines,
125
+
db: d,
126
+
n: n,
127
+
cfg: cfg,
128
+
l: l.With("component", "mill.executor"),
129
+
writer: writer,
130
+
active: make(map[string]*reservation),
131
+
maxOutboxBytes: 10 * 1024 * 1024,
132
+
}
133
+
if err := e.initOutbox(); err != nil {
134
+
return nil, fmt.Errorf("initialize executor outbox: %w", err)
135
+
}
136
+
return e, nil
137
+
}
138
+
139
+
func (e *Executor) Connect(ctx context.Context) {
140
+
e.lifecycleCtx = ctx
141
+
sub := e.n.Subscribe()
142
+
cursor, err := e.db.EventHighWater()
143
+
if err != nil {
144
+
e.n.Unsubscribe(sub)
145
+
e.l.Error("establish event cursor failed", "err", err)
146
+
return
147
+
}
148
+
e.drainEvents(&cursor)
149
+
observerCtx, stopObserver := context.WithCancel(ctx)
150
+
observerDone := make(chan struct{})
151
+
go func() {
152
+
defer close(observerDone)
153
+
e.observeLoop(observerCtx, sub, cursor)
154
+
}()
155
+
defer e.n.Unsubscribe(sub)
156
+
157
+
backoff := dialBackoffMin
158
+
for {
159
+
if ctx.Err() != nil {
160
+
break
161
+
}
162
+
err := e.runSession(ctx)
163
+
if ctx.Err() != nil {
164
+
break
165
+
}
166
+
e.l.Warn("mill session ended; reconnecting", "err", err, "backoff", backoff)
167
+
select {
168
+
case <-ctx.Done():
169
+
break
170
+
case <-time.After(backoff):
171
+
}
172
+
backoff = min(backoff*2, dialBackoffMax)
173
+
}
174
+
175
+
e.jobsWG.Wait()
176
+
stopObserver()
177
+
<-observerDone
178
+
e.drainEvents(&cursor)
179
+
}
180
+
181
+
func (e *Executor) runSession(ctx context.Context) error {
182
+
dev := e.cfg == nil || e.cfg.Server.Dev
183
+
if _, err := netutil.EnforceWSSURL(e.millURL, dev); err != nil {
184
+
return fmt.Errorf("mill url: %w", err)
185
+
}
186
+
header := http.Header{}
187
+
if e.token != "" {
188
+
header.Set("Authorization", "Bearer "+e.token)
189
+
}
190
+
conn, _, err := netutil.SSRFWebsocketDialer(dev).DialContext(ctx, e.millURL, header)
191
+
if err != nil {
192
+
return fmt.Errorf("dial mill: %w", err)
193
+
}
194
+
defer conn.Close()
195
+
196
+
sessionCtx, cancelSession := context.WithCancel(ctx)
197
+
defer cancelSession()
198
+
stopClose := context.AfterFunc(sessionCtx, func() { _ = conn.Close() })
199
+
defer stopClose()
200
+
201
+
stream := millproto.NewWSStream(conn)
202
+
enc := millproto.NewEncoder(stream)
203
+
dec := millproto.NewDecoder(stream)
204
+
205
+
hello := &millproto.Message{Hello: &millv1.Hello{
206
+
ProtocolVersion: millproto.ProtocolVersion,
207
+
Arch: runtime.GOARCH,
208
+
Labels: e.labels,
209
+
Epoch: e.epoch,
210
+
}}
211
+
if err := enc.Encode(hello); err != nil {
212
+
return fmt.Errorf("send hello: %w", err)
213
+
}
214
+
215
+
resumeMsg, err := dec.Decode()
216
+
if err != nil {
217
+
return fmt.Errorf("read resume: %w", err)
218
+
}
219
+
resume := resumeMsg.GetResume()
220
+
if resume == nil {
221
+
return fmt.Errorf("expected resume, got something else")
222
+
}
223
+
if resume.GetEpoch() != e.epoch {
224
+
return fmt.Errorf("resume epoch mismatch: got %q, want %q", resume.GetEpoch(), e.epoch)
225
+
}
226
+
227
+
e.connMu.Lock()
228
+
e.sessionCancel = cancelSession
229
+
e.enc = enc
230
+
e.connMu.Unlock()
231
+
defer func() {
232
+
e.connMu.Lock()
233
+
e.enc = nil
234
+
e.sessionCancel = nil
235
+
e.connMu.Unlock()
236
+
}()
237
+
238
+
readErr := make(chan error, 1)
239
+
go func() {
240
+
for {
241
+
msg, err := dec.Decode()
242
+
if err != nil {
243
+
readErr <- fmt.Errorf("read: %w", err)
244
+
return
245
+
}
246
+
e.dispatch(sessionCtx, msg)
247
+
}
248
+
}()
249
+
250
+
if err := e.replay(resume.GetAckSeqno()); err != nil {
251
+
cancelSession()
252
+
<-readErr
253
+
return fmt.Errorf("replay failed: %w", err)
254
+
}
255
+
e.pushSnapshot()
256
+
e.l.Info("connected to mill", "node", e.nodeID, "resumeFrom", resume.GetAckSeqno())
257
+
258
+
go e.snapshotLoop(sessionCtx, enc)
259
+
return <-readErr
260
+
}
261
+
func (e *Executor) send(msg *millproto.Message) {
262
+
e.connMu.Lock()
263
+
enc := e.enc
264
+
cancel := e.sessionCancel
265
+
e.connMu.Unlock()
266
+
if enc != nil {
267
+
e.sendMu.Lock()
268
+
err := enc.Encode(msg)
269
+
e.sendMu.Unlock()
270
+
if err != nil {
271
+
e.l.Error("send failed, ending session", "err", err)
272
+
if cancel != nil {
273
+
cancel()
274
+
}
275
+
}
276
+
}
277
+
}
278
+
279
+
func (e *Executor) dispatch(ctx context.Context, msg *millproto.Message) {
280
+
switch {
281
+
case msg.GetReserveSeat() != nil:
282
+
e.handleReserve(ctx, msg.GetReserveSeat())
283
+
case msg.GetCommitLease() != nil:
284
+
e.handleCommit(ctx, msg.GetCommitLease())
285
+
case msg.GetReleaseLease() != nil:
286
+
e.handleRelease(msg.GetReleaseLease().GetLeaseId())
287
+
case msg.GetCancelAttempt() != nil:
288
+
e.handleCancel(msg.GetCancelAttempt().GetLeaseId())
289
+
case msg.GetAck() != nil:
290
+
e.handleAck(msg.GetAck())
291
+
default:
292
+
e.l.Warn("unhandled incoming message", "type", fmt.Sprintf("%T", msg))
293
+
}
294
+
}
295
+
296
+
func (e *Executor) sendReject(leaseID string, reason string, class millv1.RejectClass) {
297
+
e.send(&millproto.Message{ReserveResult: &millv1.ReserveResult{
298
+
LeaseId: leaseID,
299
+
Accepted: false,
300
+
RejectReason: reason,
301
+
RejectClass: class,
302
+
}})
303
+
}
304
+
305
+
func (e *Executor) sendCommitted(leaseID string) {
306
+
e.send(&millproto.Message{Committed: &millv1.Committed{LeaseId: leaseID}})
307
+
}
308
+
309
+
func (e *Executor) sendCancelAck(leaseID string) {
310
+
e.send(&millproto.Message{CancelAck: &millv1.CancelAck{LeaseId: leaseID}})
311
+
}
312
+
313
+
func (e *Executor) releaseReservation(cleanup func()) {
314
+
if cleanup != nil {
315
+
cleanup()
316
+
}
317
+
e.pushSnapshot()
318
+
}
319
+
320
+
func (e *Executor) handleReserve(ctx context.Context, rs *millv1.ReserveSeat) {
321
+
reject := func(reason string, class millv1.RejectClass) {
322
+
e.sendReject(rs.GetLeaseId(), reason, class)
323
+
}
324
+
325
+
e.mu.Lock()
326
+
draining := e.draining
327
+
e.mu.Unlock()
328
+
if draining {
329
+
reject("draining", millv1.RejectClass_REJECT_CLASS_TRANSIENT)
330
+
return
331
+
}
332
+
333
+
realEngine, ok := e.engines[rs.GetTargetEngine()]
334
+
if !ok {
335
+
reject("unknown engine "+rs.GetTargetEngine(), millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
336
+
return
337
+
}
338
+
slotter, ok := realEngine.(engine.WorkflowSlotter)
339
+
if !ok {
340
+
reject("engine does not support workflow slots", millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
341
+
return
342
+
}
343
+
344
+
var twf tangled.Pipeline_Workflow
345
+
if err := json.Unmarshal([]byte(rs.GetRawWorkflowJson()), &twf); err != nil {
346
+
reject("bad workflow json", millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
347
+
return
348
+
}
349
+
var tpl tangled.Pipeline
350
+
if err := json.Unmarshal([]byte(rs.GetRawPipelineJson()), &tpl); err != nil {
351
+
reject("bad pipeline json", millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
352
+
return
353
+
}
354
+
if tpl.TriggerMetadata == nil {
355
+
reject("pipeline missing trigger metadata", millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
356
+
return
357
+
}
358
+
359
+
pipelineId := models.PipelineId{Knot: rs.GetKnot(), Rkey: rs.GetRkey()}
360
+
wid := models.WorkflowId{PipelineId: pipelineId, Name: twf.Name}
361
+
362
+
wf, err := realEngine.InitWorkflow(twf, tpl)
363
+
if err != nil {
364
+
reject("init workflow: "+err.Error(), millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
365
+
return
366
+
}
367
+
if validator, ok := realEngine.(engine.WorkflowPlacementValidator); ok {
368
+
if err := validator.ValidateWorkflowPlacement(wf); err != nil {
369
+
reject("validate workflow placement: "+err.Error(), millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE)
370
+
return
371
+
}
372
+
}
373
+
if wf.Environment == nil {
374
+
wf.Environment = make(map[string]string)
375
+
}
376
+
maps.Copy(wf.Environment, models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId))
377
+
378
+
slot, err := slotter.AcquireWorkflowSlot(ctx, wid, wf, engine.NoWait)
379
+
if err != nil {
380
+
class := millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE
381
+
if errors.Is(err, engine.ErrNoWorkflowSlots) {
382
+
class = millv1.RejectClass_REJECT_CLASS_TRANSIENT
383
+
}
384
+
reject(err.Error(), class)
385
+
return
386
+
}
387
+
388
+
var repoDid syntax.DID
389
+
if tpl.TriggerMetadata != nil && tpl.TriggerMetadata.Repo != nil && tpl.TriggerMetadata.Repo.RepoDid != nil {
390
+
repoDid, _ = syntax.ParseDID(*tpl.TriggerMetadata.Repo.RepoDid)
391
+
}
392
+
393
+
res := &reservation{
394
+
leaseID: rs.GetLeaseId(),
395
+
wid: wid,
396
+
realEngine: realEngine,
397
+
slot: slot,
398
+
wf: wf,
399
+
repoDid: repoDid,
400
+
}
401
+
402
+
e.snapshotMu.Lock()
403
+
e.mu.Lock()
404
+
e.active[res.leaseID] = res
405
+
res.ttlTimer = time.AfterFunc(ttlDuration(rs.GetTtlSeconds()), func() { e.expireReservation(res.leaseID) })
406
+
e.mu.Unlock()
407
+
408
+
e.send(&millproto.Message{ReserveResult: &millv1.ReserveResult{
409
+
LeaseId: rs.GetLeaseId(),
410
+
Accepted: true,
411
+
}})
412
+
e.pushSnapshotLocked()
413
+
e.snapshotMu.Unlock()
414
+
}
415
+
416
+
func (e *Executor) handleCommit(ctx context.Context, cl *millv1.CommitLease) {
417
+
e.mu.Lock()
418
+
res := e.active[cl.GetLeaseId()]
419
+
if res == nil {
420
+
e.mu.Unlock()
421
+
e.sendReject(cl.GetLeaseId(), "reservation missing or expired", millv1.RejectClass_REJECT_CLASS_TRANSIENT)
422
+
return
423
+
}
424
+
if res.committed {
425
+
e.mu.Unlock()
426
+
e.sendCommitted(cl.GetLeaseId())
427
+
return
428
+
}
429
+
res.committed = true
430
+
if res.ttlTimer != nil {
431
+
res.ttlTimer.Stop()
432
+
}
433
+
434
+
jobCtx, cancel := context.WithCancel(e.lifecycleCtx)
435
+
res.cancel = cancel
436
+
e.mu.Unlock()
437
+
438
+
vault := newMemVault(cl.GetSecrets())
439
+
re := newReservedEngine(res.realEngine, res.slot)
440
+
pipeline := &models.Pipeline{
441
+
RepoDid: res.repoDid,
442
+
Workflows: map[models.Engine][]models.Workflow{re: {*res.wf}},
443
+
TrustedSource: true,
444
+
}
445
+
446
+
e.startTail(res)
447
+
448
+
e.jobsWG.Add(1)
449
+
go func() {
450
+
defer e.jobsWG.Done()
451
+
engine.StartWorkflows(e.l, vault, e.cfg, nil, e.db, e.n, jobCtx, pipeline, res.wid.PipelineId)
452
+
}()
453
+
454
+
e.sendCommitted(cl.GetLeaseId())
455
+
}
456
+
457
+
func (e *Executor) handleRelease(leaseID string) {
458
+
cleanup, ok := e.takeUncommittedReservation(leaseID, true)
459
+
if !ok {
460
+
return
461
+
}
462
+
e.releaseReservation(cleanup)
463
+
}
464
+
465
+
func (e *Executor) handleCancel(leaseID string) {
466
+
e.mu.Lock()
467
+
res := e.active[leaseID]
468
+
if res == nil {
469
+
e.mu.Unlock()
470
+
if err := e.appendTerminal(leaseID, string(models.StatusKindCancelled), nil); err != nil {
471
+
e.l.Error("persist cancelled reservation terminal", "lease", leaseID, "err", err)
472
+
return
473
+
}
474
+
e.sendCancelAck(leaseID)
475
+
return
476
+
}
477
+
res.cancelled = true
478
+
cancel := res.cancel
479
+
committed := res.committed
480
+
var cleanup func()
481
+
if !committed {
482
+
cleanup = e.removeReservationLocked(res, true)
483
+
}
484
+
e.mu.Unlock()
485
+
486
+
if !committed {
487
+
if err := e.appendTerminal(leaseID, string(models.StatusKindCancelled), nil); err != nil {
488
+
e.l.Error("persist cancelled reservation terminal", "lease", leaseID, "err", err)
489
+
e.releaseReservation(cleanup)
490
+
return
491
+
}
492
+
e.sendCancelAck(leaseID)
493
+
e.releaseReservation(cleanup)
494
+
return
495
+
}
496
+
497
+
e.sendCancelAck(leaseID)
498
+
if cancel != nil {
499
+
cancel()
500
+
}
501
+
}
502
+
503
+
func (e *Executor) expireReservation(leaseID string) {
504
+
cleanup, ok := e.takeUncommittedReservation(leaseID, true)
505
+
if !ok {
506
+
return
507
+
}
508
+
e.releaseReservation(cleanup)
509
+
}
510
+
511
+
func (e *Executor) takeUncommittedReservation(leaseID string, releaseSlot bool) (func(), bool) {
512
+
e.mu.Lock()
513
+
defer e.mu.Unlock()
514
+
res := e.active[leaseID]
515
+
if res == nil || res.committed {
516
+
return nil, false
517
+
}
518
+
if res.ttlTimer != nil {
519
+
res.ttlTimer.Stop()
520
+
}
521
+
return e.removeReservationLocked(res, releaseSlot), true
522
+
}
523
+
524
+
func (e *Executor) removeReservationLocked(res *reservation, releaseSlot bool) func() {
525
+
delete(e.active, res.leaseID)
526
+
slot := res.slot
527
+
return func() {
528
+
if releaseSlot && slot != nil {
529
+
slot.Release()
530
+
}
531
+
}
532
+
}
533
+
534
+
func (e *Executor) snapshotLoop(ctx context.Context, enc *millproto.Encoder) {
535
+
ticker := time.NewTicker(snapshotEvery)
536
+
defer ticker.Stop()
537
+
538
+
for {
539
+
select {
540
+
case <-ctx.Done():
541
+
return
542
+
case <-ticker.C:
543
+
e.pushSnapshot()
544
+
}
545
+
}
546
+
}
547
+
548
+
func (e *Executor) pushSnapshot() {
549
+
e.snapshotMu.Lock()
550
+
defer e.snapshotMu.Unlock()
551
+
e.pushSnapshotLocked()
552
+
}
553
+
554
+
func (e *Executor) pushSnapshotLocked() {
555
+
e.mu.Lock()
556
+
activeLeases := make([]string, 0, len(e.active))
557
+
for leaseID := range e.active {
558
+
activeLeases = append(activeLeases, leaseID)
559
+
}
560
+
e.mu.Unlock()
561
+
562
+
avail := make(map[string]*millv1.EngineAvailability)
563
+
for name, eng := range e.engines {
564
+
a := &millv1.EngineAvailability{Available: true}
565
+
if getter, ok := eng.(interface{ Load() map[string]float64 }); ok {
566
+
a.Load = getter.Load()
567
+
}
568
+
avail[name] = a
569
+
}
570
+
571
+
e.nextSeqno++
572
+
snap := &millproto.Message{
573
+
NodeSnapshot: &millv1.NodeSnapshot{
574
+
Seqno: e.nextSeqno,
575
+
Engines: avail,
576
+
ActiveLeaseIds: activeLeases,
577
+
},
578
+
}
579
+
e.send(snap)
580
+
}
581
+
582
+
func (e *Executor) Drain() {
583
+
e.mu.Lock()
584
+
e.draining = true
585
+
e.mu.Unlock()
586
+
e.pushSnapshot()
587
+
}
588
+
589
+
func ttlDuration(secs uint32) time.Duration {
590
+
if secs == 0 {
591
+
return defaultReservationTTL
592
+
}
593
+
return time.Duration(secs) * time.Second
594
+
}
595
+
596
+
const defaultReservationTTL = 60 * time.Second
597
+
598
+
func normalizeLabels(labels []string) []string {
599
+
seen := make(map[string]struct{}, len(labels))
600
+
out := make([]string, 0, len(labels))
601
+
for _, label := range labels {
602
+
label = strings.TrimSpace(label)
603
+
if label == "" {
604
+
continue
605
+
}
606
+
if _, ok := seen[label]; ok {
607
+
continue
608
+
}
609
+
seen[label] = struct{}{}
610
+
out = append(out, label)
611
+
}
612
+
return out
613
+
}
+251
spindle/mill/executor/observe.go
+251
spindle/mill/executor/observe.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"crypto/sha256"
6
+
"encoding/hex"
7
+
"fmt"
8
+
"io"
9
+
"os"
10
+
"strings"
11
+
"sync"
12
+
"time"
13
+
14
+
"github.com/hpcloud/tail"
15
+
"tangled.org/core/api/tangled"
16
+
millproto "tangled.org/core/spindle/mill/proto"
17
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
18
+
"tangled.org/core/spindle/models"
19
+
"tangled.org/core/spindle/secrets"
20
+
)
21
+
22
+
func (e *Executor) observeLoop(ctx context.Context, sub <-chan struct{}, cursor int64) {
23
+
ticker := time.NewTicker(5 * time.Second)
24
+
defer ticker.Stop()
25
+
26
+
for {
27
+
select {
28
+
case <-ctx.Done():
29
+
return
30
+
case <-sub:
31
+
case <-ticker.C:
32
+
}
33
+
e.drainEvents(&cursor)
34
+
}
35
+
}
36
+
37
+
func (e *Executor) drainEvents(cursor *int64) {
38
+
events, err := e.db.GetEvents(*cursor, 128)
39
+
if err != nil {
40
+
e.l.Error("drain status events failed", "err", err)
41
+
return
42
+
}
43
+
for _, ev := range events {
44
+
if ev.Created > *cursor {
45
+
*cursor = ev.Created
46
+
}
47
+
st, ok := parseStatus(ev.EventJson)
48
+
if !ok {
49
+
continue
50
+
}
51
+
if err := e.onStatusRow(st); err != nil {
52
+
e.l.Error("process status row failed", "err", err)
53
+
}
54
+
}
55
+
}
56
+
57
+
func (e *Executor) onStatusRow(st *tangled.PipelineStatus) error {
58
+
res := e.reservationFor(st.Pipeline, st.Workflow)
59
+
if res == nil {
60
+
return nil
61
+
}
62
+
63
+
if models.StatusKind(st.Status).IsFinish() {
64
+
return e.finishJob(res, st)
65
+
}
66
+
67
+
return e.appendStatus(res.leaseID, st)
68
+
}
69
+
70
+
func (e *Executor) finishJob(res *reservation, st *tangled.PipelineStatus) error {
71
+
e.mu.Lock()
72
+
if e.active[res.leaseID] != res {
73
+
e.mu.Unlock()
74
+
return nil
75
+
}
76
+
cancelled := res.cancelled
77
+
e.mu.Unlock()
78
+
79
+
// 1. Finalize log tail first so all log lines precede terminal event
80
+
if res.stopTail != nil {
81
+
res.stopTail()
82
+
}
83
+
84
+
terminalStatus := st.Status
85
+
if cancelled {
86
+
terminalStatus = string(models.StatusKindCancelled)
87
+
}
88
+
var logDir string
89
+
if e.cfg != nil {
90
+
logDir = e.cfg.Server.LogDir
91
+
}
92
+
logPath := models.LogFilePath(logDir, res.wid)
93
+
94
+
var errStr string
95
+
if st != nil && st.Error != nil {
96
+
errStr = *st.Error
97
+
}
98
+
var exitCode int64
99
+
if st != nil && st.ExitCode != nil {
100
+
exitCode = *st.ExitCode
101
+
}
102
+
103
+
// Calculate SHA256 of log file
104
+
hash := ""
105
+
if f, err := os.Open(logPath); err == nil {
106
+
h := sha256.New()
107
+
if _, err := io.Copy(h, f); err == nil {
108
+
hash = "sha256:" + hex.EncodeToString(h.Sum(nil))
109
+
}
110
+
_ = f.Close()
111
+
}
112
+
113
+
// refs are opaque keys interpreted by the configured artifact store
114
+
ref := "logs/" + res.leaseID + ".log"
115
+
116
+
// 2. Persist restart-retryable pending artifact state
117
+
if e.db != nil {
118
+
_ = e.db.SavePendingArtifact(res.leaseID, res.wid.Name, terminalStatus, errStr, exitCode, ref, hash)
119
+
}
120
+
121
+
// 3. Upload artifact with non-cancelled cleanup context
122
+
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Minute)
123
+
defer cancel()
124
+
125
+
if e.writer != nil {
126
+
if f, err := os.Open(logPath); err == nil {
127
+
defer f.Close()
128
+
if uploadErr := e.writer.Put(cleanupCtx, ref, f); uploadErr != nil {
129
+
e.l.Error("artifact upload failed", "lease", res.leaseID, "err", uploadErr)
130
+
return fmt.Errorf("artifact upload: %w", uploadErr)
131
+
}
132
+
}
133
+
}
134
+
135
+
// 4. Append terminal event (with LogArtifact) to outbox
136
+
if err := e.appendTerminalWithArtifact(res.leaseID, terminalStatus, st, ref, hash); err != nil {
137
+
return err
138
+
}
139
+
140
+
// 5. Remove pending artifact state after terminal outbox append succeeds
141
+
if e.db != nil {
142
+
_ = e.db.RemovePendingArtifact(res.leaseID)
143
+
}
144
+
e.mu.Lock()
145
+
cleanup := e.removeReservationLocked(res, false)
146
+
e.mu.Unlock()
147
+
148
+
cleanup()
149
+
150
+
e.pushSnapshot()
151
+
return nil
152
+
}
153
+
154
+
func (e *Executor) reservationFor(pipelineAturi, workflow string) *reservation {
155
+
e.mu.Lock()
156
+
defer e.mu.Unlock()
157
+
for _, res := range e.active {
158
+
if string(res.wid.PipelineId.AtUri()) == pipelineAturi && res.wid.Name == workflow {
159
+
return res
160
+
}
161
+
}
162
+
return nil
163
+
}
164
+
165
+
func (e *Executor) maskSecrets(res *reservation, text string) string {
166
+
if res == nil || res.vault == nil {
167
+
return text
168
+
}
169
+
for _, s := range res.vault.secrets {
170
+
if s.Value != "" {
171
+
text = strings.ReplaceAll(text, s.Value, "***")
172
+
}
173
+
}
174
+
return text
175
+
}
176
+
177
+
func (e *Executor) SendLiveLog(leaseID string, raw []byte) error {
178
+
e.connMu.Lock()
179
+
enc := e.enc
180
+
e.connMu.Unlock()
181
+
if enc == nil {
182
+
return nil
183
+
}
184
+
return enc.Encode(&millproto.Message{
185
+
LiveLog: &millv1.LiveLog{
186
+
LeaseId: leaseID,
187
+
RawJson: raw,
188
+
},
189
+
})
190
+
}
191
+
192
+
func (e *Executor) startTail(res *reservation) {
193
+
path := models.LogFilePath(e.cfg.Server.LogDir, res.wid)
194
+
t, err := tail.TailFile(path, tail.Config{
195
+
Follow: true,
196
+
ReOpen: true,
197
+
MustExist: false,
198
+
Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekStart},
199
+
Logger: tail.DiscardingLogger,
200
+
})
201
+
if err != nil {
202
+
e.l.Error("tail log file failed", "wid", res.wid, "err", err)
203
+
return
204
+
}
205
+
206
+
done := make(chan struct{})
207
+
go func() {
208
+
defer close(done)
209
+
for line := range t.Lines {
210
+
if line == nil || line.Err != nil {
211
+
continue
212
+
}
213
+
masked := e.maskSecrets(res, line.Text)
214
+
_ = e.SendLiveLog(res.leaseID, []byte(masked+"\n"))
215
+
}
216
+
}()
217
+
218
+
var once sync.Once
219
+
res.stopTail = func() {
220
+
once.Do(func() {
221
+
_ = t.StopAtEOF()
222
+
<-done
223
+
})
224
+
}
225
+
}
226
+
227
+
type memVault struct {
228
+
secrets []secrets.UnlockedSecret
229
+
}
230
+
231
+
func newMemVault(pb []*millv1.Secret) *memVault {
232
+
v := &memVault{secrets: make([]secrets.UnlockedSecret, 0, len(pb))}
233
+
for _, s := range pb {
234
+
v.secrets = append(v.secrets, secrets.UnlockedSecret{
235
+
Key: s.Key,
236
+
Value: s.Value,
237
+
})
238
+
}
239
+
return v
240
+
}
241
+
242
+
func (v *memVault) GetSecretsUnlocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.UnlockedSecret, error) {
243
+
return v.secrets, nil
244
+
}
245
+
func (v *memVault) GetSecretsLocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.LockedSecret, error) {
246
+
return nil, nil
247
+
}
248
+
func (v *memVault) AddSecret(ctx context.Context, s secrets.UnlockedSecret) error { return nil }
249
+
func (v *memVault) RemoveSecret(ctx context.Context, s secrets.Secret[any]) error { return nil }
250
+
251
+
var _ secrets.Manager = (*memVault)(nil)
+344
spindle/mill/executor/outbox.go
+344
spindle/mill/executor/outbox.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"crypto/rand"
6
+
"encoding/hex"
7
+
"encoding/json"
8
+
"fmt"
9
+
"os"
10
+
"time"
11
+
12
+
"google.golang.org/protobuf/proto"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/spindle/db"
15
+
millproto "tangled.org/core/spindle/mill/proto"
16
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
17
+
"tangled.org/core/spindle/models"
18
+
)
19
+
20
+
const (
21
+
maxBatchBytes = 4 * 1024 * 1024
22
+
maxBatchEvents = 128
23
+
)
24
+
25
+
func generateEpoch() string {
26
+
var b [8]byte
27
+
_, _ = rand.Read(b[:])
28
+
return hex.EncodeToString(b[:])
29
+
}
30
+
31
+
func (e *Executor) initOutbox() error {
32
+
e.eventMu.Lock()
33
+
defer e.eventMu.Unlock()
34
+
35
+
epoch, _, err := e.db.GetOutboxState()
36
+
if err != nil {
37
+
return err
38
+
}
39
+
if epoch != "" {
40
+
e.epoch = epoch
41
+
} else {
42
+
e.epoch = generateEpoch()
43
+
if err := e.db.SetOutboxEpoch(e.epoch); err != nil {
44
+
return fmt.Errorf("set outbox epoch: %w", err)
45
+
}
46
+
}
47
+
48
+
rows, err := e.db.ListOutboxRows()
49
+
if err != nil {
50
+
return fmt.Errorf("list outbox rows: %w", err)
51
+
}
52
+
53
+
for _, r := range rows {
54
+
e.outboxBytes += r.ByteSize
55
+
}
56
+
_ = e.recoverPendingArtifacts()
57
+
return nil
58
+
}
59
+
60
+
func (e *Executor) appendAndSend(leaseID string, payload any, control bool) error {
61
+
entry := &millv1.Event{LeaseId: leaseID}
62
+
switch payload := payload.(type) {
63
+
case *millv1.Event_StatusEvent:
64
+
entry.Payload = payload
65
+
case *millv1.Event_AttemptResult:
66
+
entry.Payload = payload
67
+
default:
68
+
return fmt.Errorf("unsupported stream payload %T", payload)
69
+
}
70
+
isTerminal := false
71
+
if _, ok := payload.(*millv1.Event_AttemptResult); ok {
72
+
isTerminal = true
73
+
}
74
+
entry.Seqno = ^uint64(0)
75
+
wireSize := proto.Size(&millproto.Message{EventBatch: &millv1.EventBatch{
76
+
Epoch: e.epoch,
77
+
Events: []*millv1.Event{entry},
78
+
}})
79
+
entry.Seqno = 0
80
+
if wireSize > maxBatchBytes {
81
+
return fmt.Errorf("control stream entry exceeds wire limit: %d > %d", wireSize, maxBatchBytes)
82
+
}
83
+
84
+
encoded, err := proto.Marshal(entry)
85
+
if err != nil {
86
+
return fmt.Errorf("marshal stream entry: %w", err)
87
+
}
88
+
89
+
e.eventMu.Lock()
90
+
if control && !isTerminal && e.maxOutboxBytes > 0 && e.outboxBytes+int64(len(encoded)) > e.maxOutboxBytes {
91
+
e.l.Warn("outbox reserve exhausted; dropping nonterminal status", "cap", e.maxOutboxBytes)
92
+
e.eventMu.Unlock()
93
+
return nil
94
+
}
95
+
if _, err := e.db.AppendOutboxRow(encoded, control); err != nil {
96
+
e.eventMu.Unlock()
97
+
return fmt.Errorf("append outbox row: %w", err)
98
+
}
99
+
e.outboxBytes += int64(len(encoded))
100
+
e.eventMu.Unlock()
101
+
102
+
e.sendPending()
103
+
return nil
104
+
}
105
+
106
+
func (e *Executor) appendStatus(leaseID string, st *tangled.PipelineStatus) error {
107
+
if st.Status != string(models.StatusKindRunning) {
108
+
return fmt.Errorf("unsupported nonterminal status %q", st.Status)
109
+
}
110
+
exit, errStr := parseStatusExitAndError(st)
111
+
payload := &millv1.Event_StatusEvent{StatusEvent: &millv1.StatusEvent{
112
+
Status: millv1.NonterminalStatus_RUNNING,
113
+
Error: errStr,
114
+
ExitCode: exit,
115
+
}}
116
+
return e.appendAndSend(leaseID, payload, true)
117
+
}
118
+
119
+
func (e *Executor) appendTerminal(leaseID, status string, st *tangled.PipelineStatus) error {
120
+
return e.appendTerminalWithArtifact(leaseID, status, st, "", "")
121
+
}
122
+
123
+
func (e *Executor) appendTerminalWithArtifact(leaseID, status string, st *tangled.PipelineStatus, ref, hash string) error {
124
+
var terminalStatus millv1.TerminalStatus
125
+
switch status {
126
+
case string(models.StatusKindSuccess):
127
+
terminalStatus = millv1.TerminalStatus_SUCCESS
128
+
case string(models.StatusKindFailed):
129
+
terminalStatus = millv1.TerminalStatus_FAILED
130
+
case string(models.StatusKindTimeout):
131
+
terminalStatus = millv1.TerminalStatus_TIMEOUT
132
+
case string(models.StatusKindCancelled):
133
+
terminalStatus = millv1.TerminalStatus_CANCELLED
134
+
default:
135
+
return fmt.Errorf("unsupported terminal status %q", status)
136
+
}
137
+
138
+
exit, errStr := parseStatusExitAndError(st)
139
+
var logArtifact *millv1.LogArtifact
140
+
if ref != "" {
141
+
logArtifact = &millv1.LogArtifact{
142
+
Ref: ref,
143
+
Hash: hash,
144
+
}
145
+
}
146
+
payload := &millv1.Event_AttemptResult{AttemptResult: &millv1.AttemptResult{
147
+
Status: terminalStatus,
148
+
Error: errStr,
149
+
ExitCode: exit,
150
+
LogArtifact: logArtifact,
151
+
}}
152
+
return e.appendAndSend(leaseID, payload, true)
153
+
}
154
+
155
+
func (e *Executor) recoverPendingArtifacts() error {
156
+
if e.db == nil {
157
+
return nil
158
+
}
159
+
pending, err := e.db.ListPendingArtifacts()
160
+
if err != nil || len(pending) == 0 {
161
+
return err
162
+
}
163
+
for _, p := range pending {
164
+
if p.Ref != "" {
165
+
if e.writer == nil {
166
+
continue
167
+
}
168
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
169
+
var logDir string
170
+
if e.cfg != nil {
171
+
logDir = e.cfg.Server.LogDir
172
+
}
173
+
logPath := models.LogFilePath(logDir, models.WorkflowId{Name: p.Workflow})
174
+
f, openErr := os.Open(logPath)
175
+
if openErr != nil {
176
+
cancel()
177
+
continue
178
+
}
179
+
uploadErr := e.writer.Put(ctx, p.Ref, f)
180
+
_ = f.Close()
181
+
cancel()
182
+
if uploadErr != nil {
183
+
continue
184
+
}
185
+
}
186
+
187
+
st := &tangled.PipelineStatus{
188
+
Status: p.Status,
189
+
Error: &p.Error,
190
+
ExitCode: &p.ExitCode,
191
+
}
192
+
if err := e.appendTerminalWithArtifact(p.LeaseID, p.Status, st, p.Ref, p.Hash); err == nil {
193
+
_ = e.db.RemovePendingArtifact(p.LeaseID)
194
+
}
195
+
}
196
+
return nil
197
+
}
198
+
199
+
func truncateEventString(value string) string {
200
+
if len(value) > 65536 {
201
+
return value[:65536]
202
+
}
203
+
return value
204
+
}
205
+
206
+
func (e *Executor) sendPending() {
207
+
e.connMu.Lock()
208
+
enc := e.enc
209
+
e.connMu.Unlock()
210
+
211
+
if enc == nil {
212
+
return
213
+
}
214
+
215
+
e.flushMu.Lock()
216
+
defer e.flushMu.Unlock()
217
+
218
+
if err := e.sendPendingLocked(enc); err != nil {
219
+
e.l.Error("send pending events failed", "err", err)
220
+
}
221
+
}
222
+
223
+
func (e *Executor) sendPendingLocked(enc messageEncoder) error {
224
+
for {
225
+
rows, err := e.db.ListOutboxRowsAfter(e.sentSeqno, maxBatchEvents)
226
+
if err != nil {
227
+
return fmt.Errorf("list outbox rows after %d: %w", e.sentSeqno, err)
228
+
}
229
+
if len(rows) == 0 {
230
+
return nil
231
+
}
232
+
if err := e.sendRows(enc, rows); err != nil {
233
+
return err
234
+
}
235
+
}
236
+
}
237
+
238
+
func (e *Executor) sendRows(enc messageEncoder, rows []db.OutboxRow) error {
239
+
events := make([]*millv1.Event, 0, len(rows))
240
+
var lastSeqno uint64
241
+
242
+
for _, r := range rows {
243
+
ev, err := decodeEvent(r.Payload, r.Seqno)
244
+
if err != nil {
245
+
return fmt.Errorf("decode event at seqno %d: %w", r.Seqno, err)
246
+
}
247
+
events = append(events, ev)
248
+
lastSeqno = r.Seqno
249
+
}
250
+
251
+
batch := &millv1.EventBatch{
252
+
Epoch: e.epoch,
253
+
Events: events,
254
+
}
255
+
256
+
e.sendMu.Lock()
257
+
defer e.sendMu.Unlock()
258
+
259
+
if err := enc.Encode(&millproto.Message{EventBatch: batch}); err != nil {
260
+
return fmt.Errorf("encode event batch (seqno %d..%d): %w", rows[0].Seqno, lastSeqno, err)
261
+
}
262
+
e.sentSeqno = lastSeqno
263
+
return nil
264
+
}
265
+
266
+
func (e *Executor) replay(ackSeqno uint64) error {
267
+
e.connMu.Lock()
268
+
enc := e.enc
269
+
e.connMu.Unlock()
270
+
if enc == nil {
271
+
return nil
272
+
}
273
+
274
+
e.flushMu.Lock()
275
+
defer e.flushMu.Unlock()
276
+
277
+
e.sendMu.Lock()
278
+
e.sentSeqno = ackSeqno
279
+
e.sendMu.Unlock()
280
+
281
+
return e.sendPendingLocked(enc)
282
+
}
283
+
284
+
func (e *Executor) handleAck(ack *millv1.Ack) {
285
+
if ack == nil || ack.GetEpoch() != e.epoch {
286
+
return
287
+
}
288
+
289
+
upTo := ack.GetUpToSeqno()
290
+
if upTo == 0 {
291
+
return
292
+
}
293
+
294
+
if err := e.deleteOutboxPrefix(upTo); err != nil {
295
+
e.l.Error("delete outbox prefix failed", "upTo", upTo, "err", err)
296
+
}
297
+
}
298
+
299
+
func (e *Executor) subtractOutboxBytes(deleted db.OutboxDeletion) {
300
+
e.outboxBytes = max(0, e.outboxBytes-deleted.Bytes)
301
+
}
302
+
303
+
func parseStatus(raw json.RawMessage) (*tangled.PipelineStatus, bool) {
304
+
var st tangled.PipelineStatus
305
+
if err := json.Unmarshal(raw, &st); err != nil {
306
+
return nil, false
307
+
}
308
+
return &st, true
309
+
}
310
+
311
+
func (e *Executor) deleteOutboxPrefix(upTo uint64) error {
312
+
e.eventMu.Lock()
313
+
defer e.eventMu.Unlock()
314
+
315
+
deleted, err := e.db.DeleteOutboxPrefix(upTo)
316
+
if err == nil {
317
+
e.subtractOutboxBytes(deleted)
318
+
}
319
+
return err
320
+
}
321
+
322
+
func parseStatusExitAndError(st *tangled.PipelineStatus) (int64, string) {
323
+
if st == nil {
324
+
return 0, ""
325
+
}
326
+
var exit int64
327
+
if st.ExitCode != nil {
328
+
exit = *st.ExitCode
329
+
}
330
+
var errStr string
331
+
if st.Error != nil {
332
+
errStr = truncateEventString(*st.Error)
333
+
}
334
+
return exit, errStr
335
+
}
336
+
337
+
func decodeEvent(payload []byte, seqno uint64) (*millv1.Event, error) {
338
+
var ev millv1.Event
339
+
if err := proto.Unmarshal(payload, &ev); err != nil {
340
+
return nil, err
341
+
}
342
+
ev.Seqno = seqno
343
+
return &ev, nil
344
+
}
+37
spindle/mill/executor/reserved.go
+37
spindle/mill/executor/reserved.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"sync"
7
+
8
+
"tangled.org/core/spindle/engine"
9
+
"tangled.org/core/spindle/models"
10
+
)
11
+
12
+
// wraps a real engine so StartWorkflows gets the slot ReserveSeat already
13
+
// acquired, not a second one. everything else delegates, the execution
14
+
// path runs exactly like standalone
15
+
type reservedEngine struct {
16
+
models.Engine
17
+
slot engine.WorkflowSlot
18
+
once sync.Once
19
+
}
20
+
21
+
func newReservedEngine(inner models.Engine, slot engine.WorkflowSlot) models.Engine {
22
+
return &reservedEngine{Engine: inner, slot: slot}
23
+
}
24
+
25
+
// hands back the pre-acquired slot exactly once, a second acquire would
26
+
// double-count it
27
+
func (e *reservedEngine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, _ engine.AcquireMode) (engine.WorkflowSlot, error) {
28
+
var slot engine.WorkflowSlot
29
+
e.once.Do(func() {
30
+
slot = e.slot
31
+
e.slot = nil
32
+
})
33
+
if slot == nil {
34
+
return nil, fmt.Errorf("reserved slot already consumed")
35
+
}
36
+
return slot, nil
37
+
}
+546
spindle/mill/executor/reserved_test.go
+546
spindle/mill/executor/reserved_test.go
···
1
+
package executor
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"github.com/gorilla/websocket"
8
+
"google.golang.org/protobuf/proto"
9
+
"io"
10
+
"log/slog"
11
+
"net/http"
12
+
"net/http/httptest"
13
+
"path/filepath"
14
+
"strings"
15
+
"testing"
16
+
"time"
17
+
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/notifier"
20
+
"tangled.org/core/spindle/config"
21
+
"tangled.org/core/spindle/db"
22
+
"tangled.org/core/spindle/engine"
23
+
millproto "tangled.org/core/spindle/mill/proto"
24
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
25
+
"tangled.org/core/spindle/models"
26
+
"tangled.org/core/spindle/secrets"
27
+
)
28
+
29
+
type captureEncoder struct {
30
+
messages chan *millproto.Message
31
+
}
32
+
33
+
func newCaptureEncoder() *captureEncoder {
34
+
return &captureEncoder{messages: make(chan *millproto.Message, 4)}
35
+
}
36
+
37
+
func (e *captureEncoder) Encode(msg *millproto.Message) error {
38
+
e.messages <- msg
39
+
return nil
40
+
}
41
+
42
+
type fakeSlot struct{ released int }
43
+
44
+
func (s *fakeSlot) Release() { s.released++ }
45
+
46
+
type fakeEngine struct {
47
+
setupCalled bool
48
+
runCalled bool
49
+
destroyCalled bool
50
+
acquireCalled bool
51
+
secrets chan []secrets.UnlockedSecret
52
+
done chan struct{}
53
+
}
54
+
55
+
func (e *fakeEngine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
56
+
return &models.Workflow{Name: twf.Name}, nil
57
+
}
58
+
func (e *fakeEngine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, l models.WorkflowLogger) error {
59
+
e.setupCalled = true
60
+
return nil
61
+
}
62
+
func (e *fakeEngine) WorkflowTimeout() time.Duration { return 7 * time.Minute }
63
+
func (e *fakeEngine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
64
+
e.destroyCalled = true
65
+
return nil
66
+
}
67
+
func (e *fakeEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, s []secrets.UnlockedSecret, l models.WorkflowLogger) error {
68
+
e.runCalled = true
69
+
if e.secrets != nil {
70
+
e.secrets <- s
71
+
}
72
+
if e.done != nil {
73
+
close(e.done)
74
+
}
75
+
return nil
76
+
}
77
+
func (e *fakeEngine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, mode engine.AcquireMode) (engine.WorkflowSlot, error) {
78
+
e.acquireCalled = true
79
+
return &fakeSlot{}, nil
80
+
}
81
+
82
+
type fakeStep struct{}
83
+
84
+
func (fakeStep) Name() string { return "test" }
85
+
func (fakeStep) Command() string { return "true" }
86
+
func (fakeStep) Kind() models.StepKind { return models.StepKindUser }
87
+
88
+
func TestNewFailsWhenOutboxCannotInitialize(t *testing.T) {
89
+
d := testDB(t)
90
+
if err := d.Close(); err != nil {
91
+
t.Fatal(err)
92
+
}
93
+
n := notifier.New()
94
+
cfg := &config.Config{}
95
+
if _, err := New(cfg, nil, d, &n, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
96
+
t.Fatal("New succeeded with an unavailable outbox database")
97
+
}
98
+
}
99
+
100
+
func TestReservedEngineHandsBackHeldSlotOnce(t *testing.T) {
101
+
inner := &fakeEngine{}
102
+
slot := &fakeSlot{}
103
+
re := newReservedEngine(inner, slot)
104
+
105
+
got, err := re.(engine.WorkflowSlotter).AcquireWorkflowSlot(context.Background(), models.WorkflowId{}, nil, engine.Wait)
106
+
if err != nil {
107
+
t.Fatal(err)
108
+
}
109
+
if got != engine.WorkflowSlot(slot) {
110
+
t.Fatal("AcquireWorkflowSlot() did not return the held slot")
111
+
}
112
+
if inner.acquireCalled {
113
+
t.Fatal("wrapper must not call the inner engine's AcquireWorkflowSlot")
114
+
}
115
+
116
+
if _, err := re.(engine.WorkflowSlotter).AcquireWorkflowSlot(context.Background(), models.WorkflowId{}, nil, engine.Wait); err == nil {
117
+
t.Fatal("second AcquireWorkflowSlot() should error")
118
+
}
119
+
}
120
+
121
+
func TestHandleCommitIsIdempotent(t *testing.T) {
122
+
enc := newCaptureEncoder()
123
+
e := testExecutor(t)
124
+
e.enc = enc
125
+
e.active["lease-1"] = &reservation{leaseID: "lease-1", committed: true}
126
+
127
+
e.handleCommit(context.Background(), &millv1.CommitLease{LeaseId: "lease-1"})
128
+
msg := <-enc.messages
129
+
if got := msg.GetCommitted().GetLeaseId(); got != "lease-1" {
130
+
t.Fatalf("Committed lease = %q, want lease-1", got)
131
+
}
132
+
}
133
+
134
+
func TestHandleCommitRejectsMissingReservation(t *testing.T) {
135
+
enc := newCaptureEncoder()
136
+
e := testExecutor(t)
137
+
e.enc = enc
138
+
139
+
e.handleCommit(context.Background(), &millv1.CommitLease{LeaseId: "expired"})
140
+
result := (<-enc.messages).GetReserveResult()
141
+
if result == nil {
142
+
t.Fatal("missing reservation commit did not receive a ReserveResult")
143
+
}
144
+
if result.GetLeaseId() != "expired" || result.GetAccepted() {
145
+
t.Fatalf("ReserveResult = %+v, want correlated rejection", result)
146
+
}
147
+
}
148
+
149
+
func TestHandleCancelFinalizesExpiredReservation(t *testing.T) {
150
+
enc := newCaptureEncoder()
151
+
e := testExecutor(t)
152
+
e.enc = enc
153
+
154
+
e.handleCancel("lease-expired")
155
+
var ack *millv1.CancelAck
156
+
for ack == nil {
157
+
select {
158
+
case msg := <-enc.messages:
159
+
ack = msg.GetCancelAck()
160
+
case <-time.After(time.Second):
161
+
t.Fatal("cancel acknowledgement timed out")
162
+
}
163
+
}
164
+
if ack.GetLeaseId() != "lease-expired" {
165
+
t.Fatalf("CancelAck = %+v, want lease-expired", ack)
166
+
}
167
+
rows, err := e.db.ListOutboxRows()
168
+
if err != nil {
169
+
t.Fatal(err)
170
+
}
171
+
if len(rows) != 1 {
172
+
t.Fatalf("cancel terminal outbox rows = %d, want 1", len(rows))
173
+
}
174
+
var entry millv1.Event
175
+
if err := proto.Unmarshal(rows[0].Payload, &entry); err != nil {
176
+
t.Fatal(err)
177
+
}
178
+
if got := entry.GetAttemptResult().GetStatus(); got != millv1.TerminalStatus_CANCELLED {
179
+
t.Fatalf("cancel terminal = %v, want CANCELLED", got)
180
+
}
181
+
}
182
+
183
+
func TestHandleCommitPreservesPreauthorizedSecrets(t *testing.T) {
184
+
d := testDB(t)
185
+
n := notifier.New()
186
+
enc := newCaptureEncoder()
187
+
e := &Executor{
188
+
cfg: &config.Config{Server: config.Server{LogDir: t.TempDir()}},
189
+
db: d,
190
+
n: &n,
191
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
192
+
active: make(map[string]*reservation),
193
+
maxOutboxBytes: 10 * 1024 * 1024,
194
+
enc: enc,
195
+
}
196
+
if err := e.initOutbox(); err != nil {
197
+
t.Fatal(err)
198
+
}
199
+
e.lifecycleCtx = context.Background()
200
+
201
+
inner := &fakeEngine{secrets: make(chan []secrets.UnlockedSecret, 1), done: make(chan struct{})}
202
+
slot := &fakeSlot{}
203
+
repoDid, err := syntax.ParseDID("did:web:example.com")
204
+
if err != nil {
205
+
t.Fatal(err)
206
+
}
207
+
res := &reservation{
208
+
leaseID: "lease-1",
209
+
wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"},
210
+
realEngine: inner,
211
+
slot: slot,
212
+
wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}},
213
+
repoDid: repoDid,
214
+
}
215
+
e.active[res.leaseID] = res
216
+
217
+
e.handleCommit(context.Background(), &millv1.CommitLease{
218
+
LeaseId: res.leaseID,
219
+
Secrets: []*millv1.Secret{{Key: "TOKEN", Value: "secret-value"}},
220
+
})
221
+
if got := (<-enc.messages).GetCommitted().GetLeaseId(); got != res.leaseID {
222
+
t.Fatalf("Committed lease = %q, want %q", got, res.leaseID)
223
+
}
224
+
select {
225
+
case got := <-inner.secrets:
226
+
if len(got) != 1 || got[0].Key != "TOKEN" || got[0].Value != "secret-value" {
227
+
t.Fatalf("RunStep secrets = %+v", got)
228
+
}
229
+
case <-time.After(2 * time.Second):
230
+
t.Fatal("RunStep did not receive CommitLease secrets")
231
+
}
232
+
select {
233
+
case <-inner.done:
234
+
case <-time.After(2 * time.Second):
235
+
t.Fatal("workflow did not finish")
236
+
}
237
+
if res.stopTail != nil {
238
+
res.stopTail()
239
+
}
240
+
e.jobsWG.Wait()
241
+
}
242
+
243
+
func TestRunSessionCancellationClosesStalledWebsocket(t *testing.T) {
244
+
connected := make(chan struct{})
245
+
release := make(chan struct{})
246
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247
+
conn, err := websocket.Upgrade(w, r, nil, 1024, 1024)
248
+
if err != nil {
249
+
return
250
+
}
251
+
defer conn.Close()
252
+
close(connected)
253
+
<-release
254
+
}))
255
+
t.Cleanup(func() {
256
+
close(release)
257
+
srv.Close()
258
+
})
259
+
260
+
e := testSessionExecutor(t, "ws"+strings.TrimPrefix(srv.URL, "http"))
261
+
ctx, cancel := context.WithCancel(context.Background())
262
+
done := make(chan error, 1)
263
+
go func() { done <- e.runSession(ctx) }()
264
+
<-connected
265
+
cancel()
266
+
267
+
select {
268
+
case <-done:
269
+
case <-time.After(2 * time.Second):
270
+
t.Fatal("runSession did not return after context cancellation")
271
+
}
272
+
}
273
+
274
+
func testSessionExecutor(t *testing.T, url string) *Executor {
275
+
d := testDB(t)
276
+
e := &Executor{
277
+
millURL: url,
278
+
seats: 1,
279
+
engines: make(map[string]models.Engine),
280
+
db: d,
281
+
cfg: &config.Config{Server: config.Server{Dev: true}},
282
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
283
+
active: make(map[string]*reservation),
284
+
maxOutboxBytes: 10 * 1024 * 1024,
285
+
}
286
+
if err := e.initOutbox(); err != nil {
287
+
t.Fatal(err)
288
+
}
289
+
return e
290
+
}
291
+
292
+
func testExecutor(t *testing.T) *Executor {
293
+
d := testDB(t)
294
+
e := &Executor{
295
+
db: d,
296
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
297
+
active: make(map[string]*reservation),
298
+
maxOutboxBytes: 10 * 1024 * 1024,
299
+
}
300
+
if err := e.initOutbox(); err != nil {
301
+
t.Fatal(err)
302
+
}
303
+
return e
304
+
}
305
+
306
+
func testDB(t *testing.T) *db.DB {
307
+
d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db"))
308
+
if err != nil {
309
+
t.Fatal(err)
310
+
}
311
+
return d
312
+
}
313
+
314
+
func TestFinishJobReportsCancelledReservationAsCancelled(t *testing.T) {
315
+
d := testDB(t)
316
+
res := &reservation{
317
+
leaseID: "lease-1",
318
+
wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"},
319
+
cancelled: true,
320
+
}
321
+
e := &Executor{
322
+
db: d,
323
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
324
+
active: map[string]*reservation{res.leaseID: res},
325
+
maxOutboxBytes: 10 * 1024 * 1024,
326
+
}
327
+
if err := e.initOutbox(); err != nil {
328
+
t.Fatal(err)
329
+
}
330
+
331
+
e.finishJob(res, &tangled.PipelineStatus{
332
+
Pipeline: string(res.wid.PipelineId.AtUri()),
333
+
Workflow: res.wid.Name,
334
+
Status: string(models.StatusKindFailed),
335
+
})
336
+
337
+
rows, err := d.ListOutboxRows()
338
+
if err != nil {
339
+
t.Fatal(err)
340
+
}
341
+
if len(rows) != 1 {
342
+
t.Fatalf("outbox rows = %d, want 1", len(rows))
343
+
}
344
+
345
+
var entry millv1.Event
346
+
if err := proto.Unmarshal(rows[0].Payload, &entry); err != nil {
347
+
t.Fatal(err)
348
+
}
349
+
got := entry.GetAttemptResult().GetStatus()
350
+
if got != millv1.TerminalStatus_CANCELLED {
351
+
t.Fatalf("terminal status = %v, want CANCELLED", got)
352
+
}
353
+
}
354
+
355
+
func TestReplayRejectsMalformedOutboxRow(t *testing.T) {
356
+
d := testDB(t)
357
+
e := &Executor{
358
+
db: d,
359
+
enc: newCaptureEncoder(),
360
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
361
+
}
362
+
if err := e.initOutbox(); err != nil {
363
+
t.Fatal(err)
364
+
}
365
+
if _, err := d.AppendOutboxRow([]byte("not protobuf"), true); err != nil {
366
+
t.Fatal(err)
367
+
}
368
+
if err := e.replay(0); err == nil {
369
+
t.Fatal("replay accepted a malformed row and would leave a permanent seqno gap")
370
+
}
371
+
}
372
+
373
+
func TestSocketCancellationIndependence(t *testing.T) {
374
+
d := testDB(t)
375
+
n := notifier.New()
376
+
e := &Executor{
377
+
db: d,
378
+
n: &n,
379
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
380
+
active: make(map[string]*reservation),
381
+
maxOutboxBytes: 10 * 1024 * 1024,
382
+
cfg: &config.Config{Server: config.Server{LogDir: t.TempDir()}},
383
+
}
384
+
if err := e.initOutbox(); err != nil {
385
+
t.Fatal(err)
386
+
}
387
+
388
+
lifecycleCtx, cancelLifecycle := context.WithCancel(context.Background())
389
+
defer cancelLifecycle()
390
+
e.lifecycleCtx = lifecycleCtx
391
+
392
+
inner := &fakeEngine{secrets: make(chan []secrets.UnlockedSecret, 1), done: make(chan struct{})}
393
+
slot := &fakeSlot{}
394
+
repoDid, err := syntax.ParseDID("did:web:example.com")
395
+
if err != nil {
396
+
t.Fatal(err)
397
+
}
398
+
res := &reservation{
399
+
leaseID: "lease-1",
400
+
wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"},
401
+
realEngine: inner,
402
+
slot: slot,
403
+
wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}},
404
+
repoDid: repoDid,
405
+
}
406
+
e.active[res.leaseID] = res
407
+
408
+
sessionCtx, cancelSession := context.WithCancel(lifecycleCtx)
409
+
410
+
e.handleCommit(sessionCtx, &millv1.CommitLease{
411
+
LeaseId: "lease-1",
412
+
})
413
+
414
+
cancelSession()
415
+
416
+
// session disconnect must not cancel the running job
417
+
select {
418
+
case <-inner.done:
419
+
case <-time.After(2 * time.Second):
420
+
t.Fatal("workflow did not complete even though websocket session was cancelled")
421
+
}
422
+
423
+
e.jobsWG.Wait()
424
+
}
425
+
426
+
func TestMonotonicSnapshots(t *testing.T) {
427
+
enc := newCaptureEncoder()
428
+
e := &Executor{
429
+
enc: enc,
430
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
431
+
active: make(map[string]*reservation),
432
+
maxOutboxBytes: 10 * 1024 * 1024,
433
+
}
434
+
435
+
e.pushSnapshot()
436
+
msg1 := <-enc.messages
437
+
seq1 := msg1.GetNodeSnapshot().GetSeqno()
438
+
if seq1 != 1 {
439
+
t.Fatalf("first seq = %d, want 1", seq1)
440
+
}
441
+
442
+
e.pushSnapshot()
443
+
msg2 := <-enc.messages
444
+
seq2 := msg2.GetNodeSnapshot().GetSeqno()
445
+
if seq2 != 2 {
446
+
t.Fatalf("second seq = %d, want 2", seq2)
447
+
}
448
+
}
449
+
450
+
func TestTimerRace(t *testing.T) {
451
+
d := testDB(t)
452
+
e := &Executor{
453
+
db: d,
454
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
455
+
active: make(map[string]*reservation),
456
+
maxOutboxBytes: 10 * 1024 * 1024,
457
+
}
458
+
if err := e.initOutbox(); err != nil {
459
+
t.Fatal(err)
460
+
}
461
+
462
+
twf, _ := json.Marshal(tangled.Pipeline_Workflow{Name: "build"})
463
+
tpl, _ := json.Marshal(tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{}})
464
+
465
+
inner := &fakeEngine{}
466
+
e.engines = map[string]models.Engine{"microvm": inner}
467
+
468
+
e.handleReserve(context.Background(), &millv1.ReserveSeat{
469
+
LeaseId: "lease-1",
470
+
TargetEngine: "microvm",
471
+
RawWorkflowJson: string(twf),
472
+
RawPipelineJson: string(tpl),
473
+
TtlSeconds: 1,
474
+
})
475
+
476
+
e.mu.Lock()
477
+
res := e.active["lease-1"]
478
+
e.mu.Unlock()
479
+
480
+
if res == nil {
481
+
t.Fatal("reservation was not added")
482
+
}
483
+
484
+
deadline := time.Now().Add(5 * time.Second)
485
+
for {
486
+
e.mu.Lock()
487
+
activeLen := len(e.active)
488
+
e.mu.Unlock()
489
+
if activeLen == 0 {
490
+
break
491
+
}
492
+
if time.Now().After(deadline) {
493
+
t.Fatal("reservation was leaked and never expired")
494
+
}
495
+
time.Sleep(10 * time.Millisecond)
496
+
}
497
+
}
498
+
499
+
func TestStructuredShutdown(t *testing.T) {
500
+
d := testDB(t)
501
+
n := notifier.New()
502
+
e := &Executor{
503
+
db: d,
504
+
n: &n,
505
+
l: slog.New(slog.NewTextHandler(io.Discard, nil)),
506
+
active: make(map[string]*reservation),
507
+
maxOutboxBytes: 10 * 1024 * 1024,
508
+
cfg: &config.Config{Server: config.Server{LogDir: t.TempDir()}},
509
+
}
510
+
if err := e.initOutbox(); err != nil {
511
+
t.Fatal(err)
512
+
}
513
+
514
+
ctx, cancel := context.WithCancel(context.Background())
515
+
e.lifecycleCtx = ctx
516
+
517
+
inner := &fakeEngine{secrets: make(chan []secrets.UnlockedSecret, 1), done: make(chan struct{})}
518
+
slot := &fakeSlot{}
519
+
repoDid, err := syntax.ParseDID("did:web:example.com")
520
+
if err != nil {
521
+
t.Fatal(err)
522
+
}
523
+
res := &reservation{
524
+
leaseID: "lease-1",
525
+
wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"},
526
+
realEngine: inner,
527
+
slot: slot,
528
+
wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}},
529
+
repoDid: repoDid,
530
+
}
531
+
e.active[res.leaseID] = res
532
+
533
+
e.handleCommit(ctx, &millv1.CommitLease{
534
+
LeaseId: "lease-1",
535
+
})
536
+
537
+
cancel()
538
+
539
+
e.jobsWG.Wait()
540
+
541
+
select {
542
+
case <-inner.done:
543
+
default:
544
+
t.Fatal("shutdown returned but running job did not finish")
545
+
}
546
+
}
+183
spindle/mill/handler.go
+183
spindle/mill/handler.go
···
1
+
package mill
2
+
3
+
import (
4
+
"github.com/gorilla/websocket"
5
+
"io"
6
+
"net/http"
7
+
"slices"
8
+
"strings"
9
+
"sync"
10
+
"time"
11
+
12
+
millproto "tangled.org/core/spindle/mill/proto"
13
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
14
+
)
15
+
16
+
var (
17
+
handshakeSem = make(chan struct{}, 16)
18
+
handshakeMu sync.Mutex
19
+
inFlightHandshakes = make(map[string]struct{})
20
+
)
21
+
22
+
type livenessReader struct {
23
+
r io.Reader
24
+
conn *websocket.Conn
25
+
readTimeout time.Duration
26
+
}
27
+
28
+
func (lr *livenessReader) Read(p []byte) (int, error) {
29
+
if err := lr.conn.SetReadDeadline(time.Now().Add(lr.readTimeout)); err != nil {
30
+
return 0, err
31
+
}
32
+
return lr.r.Read(p)
33
+
}
34
+
35
+
var upgrader = websocket.Upgrader{
36
+
ReadBufferSize: 1024,
37
+
WriteBufferSize: 1024,
38
+
}
39
+
40
+
// auth before upgrade, a bad token never opens a socket
41
+
func (m *Mill) HandleExecutorConn(w http.ResponseWriter, r *http.Request) {
42
+
name, authorizedLabels, ok := m.authenticate(r)
43
+
if !ok {
44
+
http.Error(w, "unauthorized", http.StatusUnauthorized)
45
+
return
46
+
}
47
+
48
+
select {
49
+
case handshakeSem <- struct{}{}:
50
+
case <-r.Context().Done():
51
+
return
52
+
}
53
+
handshakeSlotHeld := true
54
+
defer func() {
55
+
if handshakeSlotHeld {
56
+
<-handshakeSem
57
+
}
58
+
}()
59
+
60
+
// enforces one in-flight handshake and one live session at a time per identity
61
+
handshakeMu.Lock()
62
+
if _, ok := inFlightHandshakes[name]; ok {
63
+
handshakeMu.Unlock()
64
+
http.Error(w, "handshake already in progress", http.StatusConflict)
65
+
return
66
+
}
67
+
m.mu.Lock()
68
+
old, exists := m.sessions[name]
69
+
isLive := exists && old.live(m.cfg.ReconnectGrace)
70
+
m.mu.Unlock()
71
+
if isLive {
72
+
handshakeMu.Unlock()
73
+
http.Error(w, "session already active", http.StatusConflict)
74
+
return
75
+
}
76
+
inFlightHandshakes[name] = struct{}{}
77
+
handshakeMu.Unlock()
78
+
identityHandshakeHeld := true
79
+
80
+
defer func() {
81
+
if identityHandshakeHeld {
82
+
handshakeMu.Lock()
83
+
delete(inFlightHandshakes, name)
84
+
handshakeMu.Unlock()
85
+
}
86
+
}()
87
+
88
+
conn, err := upgrader.Upgrade(w, r, nil)
89
+
if err != nil {
90
+
m.l.Error("fleet ws upgrade failed", "err", err)
91
+
return
92
+
}
93
+
defer conn.Close()
94
+
95
+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
96
+
m.l.Error("failed to set pre-hello read deadline", "err", err)
97
+
return
98
+
}
99
+
100
+
stream := millproto.NewWSStream(conn)
101
+
enc := millproto.NewEncoder(stream)
102
+
dec := millproto.NewDecoder(stream)
103
+
104
+
hello, err := dec.Decode()
105
+
if err != nil {
106
+
m.l.Error("fleet read hello failed", "err", err)
107
+
return
108
+
}
109
+
h := hello.GetHello()
110
+
if h == nil {
111
+
m.l.Error("fleet first frame was not hello")
112
+
return
113
+
}
114
+
if h.GetProtocolVersion() != millproto.ProtocolVersion {
115
+
m.l.Error("fleet protocol version mismatch", "got", h.GetProtocolVersion(), "want", millproto.ProtocolVersion)
116
+
return
117
+
}
118
+
if h.GetEpoch() == "" {
119
+
m.l.Error("fleet hello missing epoch")
120
+
return
121
+
}
122
+
123
+
for _, l := range h.GetLabels() {
124
+
if !slices.Contains(authorizedLabels, l) {
125
+
m.l.Error("executor requested unauthorized label", "label", l, "authorized", authorizedLabels)
126
+
return
127
+
}
128
+
}
129
+
130
+
sess := newSession(name, h.GetEpoch(), authorizedLabels, enc, m.l)
131
+
sess.closeTransport = conn.Close
132
+
sess.labels = h.GetLabels()
133
+
134
+
resume, ok := m.attachSession(sess)
135
+
if !ok {
136
+
m.l.Warn("rejecting duplicate live executor session", "node", name)
137
+
return
138
+
}
139
+
m.l.Info("executor connected", "node", sess.nodeID, "arch", h.GetArch(), "labels", h.GetLabels(), "resume", resume)
140
+
141
+
if err := sess.send(&millproto.Message{Resume: &millv1.Resume{Epoch: h.GetEpoch(), AckSeqno: resume}}); err != nil {
142
+
m.l.Error("fleet send resume failed", "err", err)
143
+
m.detachSession(sess)
144
+
return
145
+
}
146
+
handshakeSlotHeld = false
147
+
<-handshakeSem
148
+
handshakeMu.Lock()
149
+
delete(inFlightHandshakes, name)
150
+
handshakeMu.Unlock()
151
+
identityHandshakeHeld = false
152
+
m.sessionReady(sess)
153
+
154
+
readTimeout := m.cfg.ReconnectGrace
155
+
if readTimeout <= 0 {
156
+
readTimeout = 45 * time.Second
157
+
}
158
+
liveDec := millproto.NewDecoder(&livenessReader{r: stream, conn: conn, readTimeout: readTimeout})
159
+
160
+
if err := sess.readLoop(m, liveDec); err != nil {
161
+
m.l.Debug("session read ended", "node", sess.nodeID, "err", err)
162
+
}
163
+
m.detachSession(sess)
164
+
}
165
+
166
+
// identity comes from the token hash. unknown or missing token fails closed
167
+
func (m *Mill) authenticate(r *http.Request) (string, []string, bool) {
168
+
const prefix = "Bearer "
169
+
h := r.Header.Get("Authorization")
170
+
if !strings.HasPrefix(h, prefix) {
171
+
return "", nil, false
172
+
}
173
+
token := strings.TrimPrefix(h, prefix)
174
+
if token == "" || m.db == nil {
175
+
return "", nil, false
176
+
}
177
+
name, labels, ok, err := m.db.ResolveExecutorToken(HashToken(token))
178
+
if err != nil {
179
+
m.l.Error("executor token lookup failed", "err", err)
180
+
return "", nil, false
181
+
}
182
+
return name, labels, ok
183
+
}
+345
spindle/mill/integration_test.go
+345
spindle/mill/integration_test.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"io"
7
+
"log/slog"
8
+
"net/http"
9
+
"net/http/httptest"
10
+
"os"
11
+
"path/filepath"
12
+
"strings"
13
+
"testing"
14
+
"time"
15
+
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/notifier"
18
+
"tangled.org/core/spindle/config"
19
+
"tangled.org/core/spindle/db"
20
+
"tangled.org/core/spindle/engines/dummy"
21
+
"tangled.org/core/spindle/mill/executor"
22
+
"tangled.org/core/spindle/models"
23
+
)
24
+
25
+
func TestEndToEndDummyJob(t *testing.T) {
26
+
ctx, cancel := context.WithCancel(context.Background())
27
+
defer cancel()
28
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
29
+
30
+
millDir := t.TempDir()
31
+
bdb, err := db.Make(ctx, filepath.Join(millDir, "mill.db"))
32
+
if err != nil {
33
+
t.Fatalf("mill db: %v", err)
34
+
}
35
+
bn := notifier.New()
36
+
mill := New(l, Config{LogDir: millDir, ReconnectGrace: time.Minute, BidTimeout: 2 * time.Second})
37
+
mill.Attach(bdb, &bn)
38
+
if err := bdb.AddExecutorToken("exec-1", HashToken("test-token"), nil, nil); err != nil {
39
+
t.Fatalf("register executor token: %v", err)
40
+
}
41
+
42
+
srv := httptest.NewServer(http.HandlerFunc(mill.HandleExecutorConn))
43
+
defer srv.Close()
44
+
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
45
+
46
+
execDir := t.TempDir()
47
+
edb, err := db.Make(ctx, filepath.Join(execDir, "exec.db"))
48
+
if err != nil {
49
+
t.Fatalf("exec db: %v", err)
50
+
}
51
+
en := notifier.New()
52
+
cfg := &config.Config{}
53
+
cfg.Server.Dev = true
54
+
cfg.Server.LogDir = execDir
55
+
cfg.ArtifactStores.Disk.Dir = filepath.Join(execDir, "artifacts")
56
+
cfg.Server.Hostname = "exec-1"
57
+
cfg.Mill.URL = wsURL
58
+
cfg.Mill.Seats = 2
59
+
cfg.Mill.SharedSecret = "test-token"
60
+
61
+
dummyEng := dummy.New(l)
62
+
dummyEng.StepDelay = 50 * time.Millisecond
63
+
engines := map[string]models.Engine{"dummy": dummyEng}
64
+
exec, err := executor.New(cfg, engines, edb, &en, l)
65
+
if err != nil {
66
+
t.Fatalf("executor.New: %v", err)
67
+
}
68
+
go exec.Connect(ctx)
69
+
70
+
be := NewEngine("dummy", mill)
71
+
twf := tangled.Pipeline_Workflow{
72
+
Name: "build",
73
+
Raw: "steps:\n - name: hello\n command: echo hi\n",
74
+
}
75
+
wf, err := be.InitWorkflow(twf, tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{}})
76
+
if err != nil {
77
+
t.Fatalf("InitWorkflow: %v", err)
78
+
}
79
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"}
80
+
81
+
placeCtx, placeCancel := context.WithTimeout(ctx, 10*time.Second)
82
+
defer placeCancel()
83
+
84
+
slot, err := mill.place(placeCtx, "dummy", wid, wf)
85
+
if err != nil {
86
+
t.Fatalf("place: %v", err)
87
+
}
88
+
defer slot.Release()
89
+
90
+
logPath := models.LogFilePath(millDir, wid)
91
+
ch := bn.Subscribe()
92
+
defer bn.Unsubscribe(ch)
93
+
94
+
sawLogContent := make(chan bool, 1)
95
+
go func() {
96
+
ticker := time.NewTicker(20 * time.Millisecond)
97
+
defer ticker.Stop()
98
+
timeout := time.After(10 * time.Second)
99
+
for {
100
+
select {
101
+
case <-ch:
102
+
case <-ticker.C:
103
+
case <-timeout:
104
+
sawLogContent <- false
105
+
return
106
+
}
107
+
data, err := os.ReadFile(logPath)
108
+
if err == nil && strings.Contains(string(data), "echo hi") {
109
+
sawLogContent <- true
110
+
return
111
+
}
112
+
}
113
+
}()
114
+
115
+
if err := mill.commitAndWait(placeCtx, wf, nil); err != nil {
116
+
t.Fatalf("commitAndWait: %v, want success", err)
117
+
}
118
+
119
+
if !<-sawLogContent {
120
+
t.Fatal("mill live log file never received expected content while job was running")
121
+
}
122
+
123
+
if !waitForFileRemoval(t, logPath) {
124
+
t.Fatalf("mill live log file was not removed after terminal artifact was recorded")
125
+
}
126
+
127
+
if !waitForStatus(t, bdb, wid, "running") {
128
+
events, _ := bdb.GetEvents(0, 1000)
129
+
t.Logf("mill events after completion: %+v", events)
130
+
t.Fatal("mill never saw streamed running status")
131
+
}
132
+
}
133
+
134
+
func TestExecutorConfiguredLabelsAreStoredOnSession(t *testing.T) {
135
+
ctx, cancel := context.WithCancel(context.Background())
136
+
defer cancel()
137
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
138
+
139
+
millDir := t.TempDir()
140
+
bdb, err := db.Make(ctx, filepath.Join(millDir, "mill.db"))
141
+
if err != nil {
142
+
t.Fatalf("mill db: %v", err)
143
+
}
144
+
bn := notifier.New()
145
+
mill := New(l, Config{LogDir: millDir, ReconnectGrace: time.Minute, BidTimeout: 2 * time.Second})
146
+
mill.Attach(bdb, &bn)
147
+
if err := bdb.AddExecutorToken("exec-labels", HashToken("test-token"), nil, []string{"linux", "arm64", "gpu"}); err != nil {
148
+
t.Fatalf("register executor token: %v", err)
149
+
}
150
+
151
+
srv := httptest.NewServer(http.HandlerFunc(mill.HandleExecutorConn))
152
+
defer srv.Close()
153
+
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
154
+
155
+
execDir := t.TempDir()
156
+
edb, err := db.Make(ctx, filepath.Join(execDir, "exec.db"))
157
+
if err != nil {
158
+
t.Fatalf("exec db: %v", err)
159
+
}
160
+
en := notifier.New()
161
+
cfg := &config.Config{}
162
+
cfg.Server.Dev = true
163
+
cfg.Server.LogDir = execDir
164
+
cfg.ArtifactStores.Disk.Dir = filepath.Join(execDir, "artifacts")
165
+
cfg.Server.Hostname = "exec-labels"
166
+
cfg.Mill.URL = wsURL
167
+
cfg.Mill.Seats = 2
168
+
cfg.Mill.SharedSecret = "test-token"
169
+
cfg.Mill.Labels = []string{"linux", "arm64", "gpu"}
170
+
171
+
engines := map[string]models.Engine{"dummy": dummy.New(l)}
172
+
exec, err := executor.New(cfg, engines, edb, &en, l)
173
+
if err != nil {
174
+
t.Fatalf("executor.New: %v", err)
175
+
}
176
+
go exec.Connect(ctx)
177
+
178
+
if !waitForSessionLabels(t, mill, "exec-labels", []string{"linux", "arm64", "gpu"}) {
179
+
t.Fatal("mill session never stored executor labels from hello")
180
+
}
181
+
}
182
+
183
+
func TestEndToEndDummyJobUsesRequiredLabelsAcrossExecutors(t *testing.T) {
184
+
ctx, cancel := context.WithCancel(context.Background())
185
+
defer cancel()
186
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
187
+
188
+
millDir := t.TempDir()
189
+
bdb, err := db.Make(ctx, filepath.Join(millDir, "mill.db"))
190
+
if err != nil {
191
+
t.Fatalf("mill db: %v", err)
192
+
}
193
+
bn := notifier.New()
194
+
mill := New(l, Config{LogDir: millDir, ReconnectGrace: time.Minute, BidTimeout: 2 * time.Second})
195
+
mill.Attach(bdb, &bn)
196
+
if err := bdb.AddExecutorToken("exec-x86", HashToken("token-x86"), nil, []string{"linux/amd64", "kvm"}); err != nil {
197
+
t.Fatalf("register x86 executor token: %v", err)
198
+
}
199
+
if err := bdb.AddExecutorToken("exec-arm", HashToken("token-arm"), nil, []string{"linux/arm64", "kvm"}); err != nil {
200
+
t.Fatalf("register arm executor token: %v", err)
201
+
}
202
+
203
+
srv := httptest.NewServer(http.HandlerFunc(mill.HandleExecutorConn))
204
+
defer srv.Close()
205
+
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
206
+
207
+
startExecutor := func(name, token string, labels []string) {
208
+
t.Helper()
209
+
execDir := t.TempDir()
210
+
edb, err := db.Make(ctx, filepath.Join(execDir, "exec.db"))
211
+
if err != nil {
212
+
t.Fatalf("%s exec db: %v", name, err)
213
+
}
214
+
en := notifier.New()
215
+
cfg := &config.Config{}
216
+
cfg.Server.Dev = true
217
+
cfg.Server.LogDir = execDir
218
+
cfg.ArtifactStores.Disk.Dir = filepath.Join(execDir, "artifacts")
219
+
cfg.Server.Hostname = name
220
+
cfg.Mill.URL = wsURL
221
+
cfg.Mill.Seats = 1
222
+
cfg.Mill.SharedSecret = token
223
+
cfg.Mill.Labels = labels
224
+
225
+
engines := map[string]models.Engine{"dummy": dummy.New(l)}
226
+
exec, err := executor.New(cfg, engines, edb, &en, l)
227
+
if err != nil {
228
+
t.Fatalf("executor.New: %v", err)
229
+
}
230
+
go exec.Connect(ctx)
231
+
}
232
+
startExecutor("exec-x86", "token-x86", []string{"linux/amd64", "kvm"})
233
+
startExecutor("exec-arm", "token-arm", []string{"linux/arm64", "kvm"})
234
+
235
+
if !waitForSessionLabels(t, mill, "exec-x86", []string{"linux/amd64", "kvm"}) {
236
+
t.Fatal("x86 executor did not connect with labels")
237
+
}
238
+
if !waitForSessionLabels(t, mill, "exec-arm", []string{"linux/arm64", "kvm"}) {
239
+
t.Fatal("arm executor did not connect with labels")
240
+
}
241
+
242
+
be := NewEngine("dummy", mill)
243
+
twf := tangled.Pipeline_Workflow{
244
+
Name: "build-arm",
245
+
RunsOn: []string{"linux/arm64"},
246
+
Raw: "steps:\n - name: hello\n command: echo hi\n",
247
+
}
248
+
wf, err := be.InitWorkflow(twf, tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{}})
249
+
if err != nil {
250
+
t.Fatalf("InitWorkflow: %v", err)
251
+
}
252
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey-arm"}, Name: "build-arm"}
253
+
254
+
placeCtx, placeCancel := context.WithTimeout(ctx, 10*time.Second)
255
+
defer placeCancel()
256
+
257
+
slot, err := mill.place(placeCtx, "dummy", wid, wf)
258
+
if err != nil {
259
+
t.Fatalf("place: %v", err)
260
+
}
261
+
defer slot.Release()
262
+
263
+
lease := wf.Data.(*millWorkflowState).Lease
264
+
if lease == nil {
265
+
t.Fatal("place did not attach lease to workflow state")
266
+
}
267
+
if lease.nodeID != "exec-arm" {
268
+
t.Fatalf("placed on %q, want exec-arm", lease.nodeID)
269
+
}
270
+
271
+
if err := mill.commitAndWait(placeCtx, wf, nil); err != nil {
272
+
t.Fatalf("commitAndWait: %v, want success", err)
273
+
}
274
+
}
275
+
276
+
func waitForSessionLabels(t *testing.T, m *Mill, nodeID string, want []string) bool {
277
+
t.Helper()
278
+
deadline := time.Now().Add(3 * time.Second)
279
+
for time.Now().Before(deadline) {
280
+
m.mu.Lock()
281
+
sess := m.sessions[nodeID]
282
+
var got []string
283
+
if sess != nil {
284
+
got = append([]string(nil), sess.labels...)
285
+
}
286
+
m.mu.Unlock()
287
+
if sameStringMultiset(got, want) {
288
+
return true
289
+
}
290
+
time.Sleep(10 * time.Millisecond)
291
+
}
292
+
return false
293
+
}
294
+
295
+
func waitForStatus(t *testing.T, d *db.DB, wid models.WorkflowId, want string) bool {
296
+
t.Helper()
297
+
deadline := time.Now().Add(3 * time.Second)
298
+
aturi := string(wid.PipelineId.AtUri())
299
+
for time.Now().Before(deadline) {
300
+
evs, err := d.GetEvents(0, 1000)
301
+
if err != nil {
302
+
t.Fatalf("GetEvents: %v", err)
303
+
}
304
+
for _, ev := range evs {
305
+
if ev.Nsid != tangled.PipelineStatusNSID {
306
+
continue
307
+
}
308
+
var st tangled.PipelineStatus
309
+
if err := json.Unmarshal(ev.EventJson, &st); err != nil {
310
+
continue
311
+
}
312
+
if st.Pipeline == aturi && st.Workflow == wid.Name && st.Status == want {
313
+
return true
314
+
}
315
+
}
316
+
time.Sleep(50 * time.Millisecond)
317
+
}
318
+
return false
319
+
}
320
+
321
+
func waitForLogFileContent(t *testing.T, path, want string) bool {
322
+
t.Helper()
323
+
deadline := time.Now().Add(5 * time.Second)
324
+
for time.Now().Before(deadline) {
325
+
data, err := os.ReadFile(path)
326
+
if err == nil && strings.Contains(string(data), want) {
327
+
return true
328
+
}
329
+
time.Sleep(20 * time.Millisecond)
330
+
}
331
+
return false
332
+
}
333
+
334
+
func waitForFileRemoval(t *testing.T, path string) bool {
335
+
t.Helper()
336
+
deadline := time.Now().Add(5 * time.Second)
337
+
for time.Now().Before(deadline) {
338
+
_, err := os.Stat(path)
339
+
if os.IsNotExist(err) {
340
+
return true
341
+
}
342
+
time.Sleep(20 * time.Millisecond)
343
+
}
344
+
return false
345
+
}
+186
spindle/mill/lease.go
+186
spindle/mill/lease.go
···
1
+
package mill
2
+
3
+
import (
4
+
"sync"
5
+
6
+
"tangled.org/core/spindle/models"
7
+
8
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
9
+
)
10
+
11
+
// the mill's view of a remote attempt
12
+
type leaseState int32
13
+
14
+
const (
15
+
// won a bid, executor is holding a seat, not yet committed
16
+
leaseReserved leaseState = iota
17
+
// CommitLease sent. the executor may already be running, but the mill
18
+
// may not have seen Committed yet
19
+
leaseCommitting
20
+
// CommitLease sent and acked. job is running on the executor
21
+
leaseRunning
22
+
// terminal result arrived or we gave up, no further action
23
+
leaseDone
24
+
)
25
+
26
+
type cancelAction int
27
+
28
+
const (
29
+
cancelNoop cancelAction = iota
30
+
cancelLocal
31
+
cancelRemote
32
+
)
33
+
34
+
// mill-side fencing token for one placed job
35
+
type RemoteLease struct {
36
+
id string
37
+
nodeID string
38
+
epoch string
39
+
engine string
40
+
wid models.WorkflowId // job this lease carries, set once placed
41
+
// restored after a mill restart. no RunStep waits on it, so terminals
42
+
// and death are authored directly. set before publication, never mutated
43
+
orphaned bool
44
+
claimed bool
45
+
cancelAcked bool
46
+
cleanedUp bool
47
+
cleanupRetry bool
48
+
49
+
mu sync.Mutex
50
+
state leaseState
51
+
cancel bool
52
+
reason string
53
+
released bool
54
+
terminal chan *millv1.AttemptResult // buffered(1), RunStep waits here
55
+
56
+
finishMu sync.Mutex
57
+
}
58
+
59
+
func newLease(id, nodeID, epoch, engine string) *RemoteLease {
60
+
return &RemoteLease{
61
+
id: id,
62
+
nodeID: nodeID,
63
+
epoch: epoch,
64
+
engine: engine,
65
+
state: leaseReserved,
66
+
terminal: make(chan *millv1.AttemptResult, 1),
67
+
}
68
+
}
69
+
70
+
func (l *RemoteLease) setState(s leaseState) {
71
+
l.mu.Lock()
72
+
l.state = s
73
+
l.mu.Unlock()
74
+
}
75
+
76
+
func (l *RemoteLease) markCommitting() bool {
77
+
l.mu.Lock()
78
+
defer l.mu.Unlock()
79
+
if l.state == leaseDone {
80
+
return false
81
+
}
82
+
if l.state == leaseReserved {
83
+
l.state = leaseCommitting
84
+
}
85
+
return true
86
+
}
87
+
88
+
func (l *RemoteLease) markRunning() {
89
+
l.mu.Lock()
90
+
defer l.mu.Unlock()
91
+
if l.state != leaseDone {
92
+
l.state = leaseRunning
93
+
}
94
+
}
95
+
96
+
func (l *RemoteLease) getState() leaseState {
97
+
l.mu.Lock()
98
+
defer l.mu.Unlock()
99
+
return l.state
100
+
}
101
+
102
+
// only one caller gets to mark it done
103
+
func (l *RemoteLease) markDone() bool {
104
+
l.mu.Lock()
105
+
defer l.mu.Unlock()
106
+
if l.state == leaseDone {
107
+
return false
108
+
}
109
+
l.state = leaseDone
110
+
return true
111
+
}
112
+
113
+
func (l *RemoteLease) requestCancel(reason string) cancelAction {
114
+
l.mu.Lock()
115
+
defer l.mu.Unlock()
116
+
if l.state == leaseDone {
117
+
return cancelNoop
118
+
}
119
+
l.cancel = true
120
+
l.reason = reason
121
+
if l.state == leaseReserved {
122
+
return cancelLocal
123
+
}
124
+
return cancelRemote
125
+
}
126
+
127
+
func (l *RemoteLease) cancelRequested() (bool, string) {
128
+
l.mu.Lock()
129
+
defer l.mu.Unlock()
130
+
return l.cancel, l.reason
131
+
}
132
+
133
+
func (l *RemoteLease) releaseState() (leaseState, bool) {
134
+
l.mu.Lock()
135
+
defer l.mu.Unlock()
136
+
l.released = true
137
+
return l.state, l.cancel
138
+
}
139
+
140
+
func (l *RemoteLease) cleanupReady() bool {
141
+
l.mu.Lock()
142
+
defer l.mu.Unlock()
143
+
return l.released && l.state == leaseDone
144
+
}
145
+
146
+
func (l *RemoteLease) deliverCancelled(reason string) {
147
+
l.deliverTerminal(&millv1.AttemptResult{
148
+
Status: millv1.TerminalStatus_CANCELLED,
149
+
Error: reason,
150
+
})
151
+
}
152
+
153
+
// hands the terminal to a waiting RunStep without blocking. duplicates
154
+
// (eg. reconnect replays) just drop, the channel holds one and the lease
155
+
// is already done
156
+
func (l *RemoteLease) deliverTerminal(res *millv1.AttemptResult) {
157
+
if !l.markDone() {
158
+
return
159
+
}
160
+
select {
161
+
case l.terminal <- res:
162
+
default:
163
+
}
164
+
}
165
+
166
+
// mill's synthetic single step. real steps run on the executor and the
167
+
// mill never mirrors them
168
+
type remoteStep struct{}
169
+
170
+
func (remoteStep) Name() string { return "remote execution" }
171
+
func (remoteStep) Command() string { return "" }
172
+
func (remoteStep) Kind() models.StepKind { return models.StepKindSystem }
173
+
174
+
// what AcquireWorkflowSlot returns. Release unwinds placement
175
+
type millSlot struct {
176
+
fleet *Mill
177
+
lease *RemoteLease
178
+
once sync.Once
179
+
}
180
+
181
+
func (s *millSlot) Release() {
182
+
if s == nil {
183
+
return
184
+
}
185
+
s.once.Do(func() { s.fleet.releaseSlot(s) })
186
+
}
+1119
spindle/mill/mill.go
+1119
spindle/mill/mill.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"errors"
7
+
"fmt"
8
+
"log/slog"
9
+
"os"
10
+
"path/filepath"
11
+
"slices"
12
+
"strings"
13
+
"sync"
14
+
"time"
15
+
16
+
"tangled.org/core/notifier"
17
+
"tangled.org/core/spindle/db"
18
+
"tangled.org/core/spindle/engine"
19
+
"tangled.org/core/spindle/models"
20
+
"tangled.org/core/spindle/secrets"
21
+
"tangled.org/core/tid"
22
+
23
+
millproto "tangled.org/core/spindle/mill/proto"
24
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
25
+
)
26
+
27
+
const (
28
+
defaultReconnectGrace = 45 * time.Second
29
+
defaultJobTimeout = 24 * time.Hour
30
+
defaultBidTimeout = 5 * time.Second
31
+
defaultTopK = 3
32
+
defaultMaxPending = 100
33
+
)
34
+
35
+
type Config struct {
36
+
// mill appends live-tailed executor lines here so logview can follow running remote jobs.
37
+
LogDir string
38
+
MaxPending int
39
+
ReconnectGrace time.Duration
40
+
JobTimeout time.Duration
41
+
BidTimeout time.Duration
42
+
TopK int
43
+
CancelTimeout time.Duration
44
+
}
45
+
46
+
type Mill struct {
47
+
l *slog.Logger
48
+
cfg Config
49
+
50
+
db *db.DB
51
+
n *notifier.Notifier
52
+
53
+
mu sync.Mutex
54
+
sessions map[string]*millSession
55
+
leases map[string]*RemoteLease
56
+
reservations map[string]*RemoteLease
57
+
nodeSeqno map[string]uint64
58
+
pending int
59
+
changeCh chan struct{} // closed and replaced to wake placement waiters
60
+
61
+
leaseSeq uint64
62
+
}
63
+
64
+
func New(l *slog.Logger, cfg Config) *Mill {
65
+
if cfg.ReconnectGrace <= 0 {
66
+
cfg.ReconnectGrace = defaultReconnectGrace
67
+
}
68
+
if cfg.JobTimeout <= 0 {
69
+
cfg.JobTimeout = defaultJobTimeout
70
+
}
71
+
if cfg.BidTimeout <= 0 {
72
+
cfg.BidTimeout = defaultBidTimeout
73
+
}
74
+
if cfg.TopK <= 0 {
75
+
cfg.TopK = defaultTopK
76
+
}
77
+
if cfg.MaxPending <= 0 {
78
+
cfg.MaxPending = defaultMaxPending
79
+
}
80
+
if cfg.CancelTimeout <= 0 {
81
+
cfg.CancelTimeout = 10 * time.Second
82
+
}
83
+
return &Mill{
84
+
l: l,
85
+
cfg: cfg,
86
+
sessions: make(map[string]*millSession),
87
+
leases: make(map[string]*RemoteLease),
88
+
reservations: make(map[string]*RemoteLease),
89
+
nodeSeqno: make(map[string]uint64),
90
+
changeCh: make(chan struct{}),
91
+
}
92
+
}
93
+
94
+
func (m *Mill) Attach(d *db.DB, n *notifier.Notifier) {
95
+
m.mu.Lock()
96
+
m.db = d
97
+
m.n = n
98
+
m.mu.Unlock()
99
+
}
100
+
101
+
func (m *Mill) nextLeaseID() string {
102
+
m.mu.Lock()
103
+
m.leaseSeq++
104
+
seq := m.leaseSeq
105
+
m.mu.Unlock()
106
+
return fmt.Sprintf("%s-%d", tid.TID(), seq)
107
+
}
108
+
109
+
func (m *Mill) dropReservation(id string) {
110
+
m.mu.Lock()
111
+
delete(m.reservations, id)
112
+
m.mu.Unlock()
113
+
}
114
+
115
+
func (m *Mill) notifyChange() {
116
+
m.mu.Lock()
117
+
m.notifyChangeLocked()
118
+
m.mu.Unlock()
119
+
}
120
+
121
+
func (m *Mill) notifyChangeLocked() {
122
+
close(m.changeCh)
123
+
m.changeCh = make(chan struct{})
124
+
}
125
+
126
+
func (m *Mill) currentChangeCh() <-chan struct{} {
127
+
m.mu.Lock()
128
+
defer m.mu.Unlock()
129
+
return m.changeCh
130
+
}
131
+
132
+
func (m *Mill) attachSession(sess *millSession) (uint64, bool) {
133
+
m.mu.Lock()
134
+
defer m.mu.Unlock()
135
+
136
+
if old := m.sessions[sess.nodeID]; old != nil {
137
+
if old.live(m.cfg.ReconnectGrace) {
138
+
// a second live session for the same identity is a hijack
139
+
// attempt, reject it
140
+
return 0, false
141
+
}
142
+
if old.graceTimer != nil {
143
+
old.graceTimer.Stop()
144
+
}
145
+
old.close()
146
+
if old.disconnected {
147
+
m.l.Info("executor reconnected", "node", sess.nodeID)
148
+
} else {
149
+
m.l.Warn("replacing silent executor session", "node", sess.nodeID)
150
+
}
151
+
}
152
+
m.sessions[sess.nodeID] = sess
153
+
// wakes commit retries waiting out reconnect grace
154
+
m.notifyChangeLocked()
155
+
return m.nodeSeqno[sess.nodeID+"/"+sess.epoch], true
156
+
}
157
+
158
+
func (m *Mill) touchSession(sess *millSession) bool {
159
+
m.mu.Lock()
160
+
defer m.mu.Unlock()
161
+
if m.sessions[sess.nodeID] != sess || sess.disconnected {
162
+
return false
163
+
}
164
+
sess.lastSeen = time.Now()
165
+
return true
166
+
}
167
+
168
+
func (m *Mill) detachSession(sess *millSession) {
169
+
m.mu.Lock()
170
+
if m.sessions[sess.nodeID] != sess {
171
+
// already replaced by a reconnect
172
+
m.mu.Unlock()
173
+
sess.close()
174
+
return
175
+
}
176
+
sess.disconnected = true
177
+
sess.graceTimer = time.AfterFunc(m.cfg.ReconnectGrace, func() { m.failLeasesAfterGrace(sess) })
178
+
m.mu.Unlock()
179
+
180
+
sess.close()
181
+
m.l.Warn("executor session lost; entering reconnect grace", "node", sess.nodeID, "grace", m.cfg.ReconnectGrace)
182
+
m.notifyChange()
183
+
}
184
+
185
+
func (m *Mill) sessionReady(sess *millSession) {
186
+
for _, lease := range m.cancelledLeasesForNode(sess.nodeID) {
187
+
_, reason := lease.cancelRequested()
188
+
m.sendCancel(sess, lease, reason)
189
+
}
190
+
m.notifyChange()
191
+
}
192
+
193
+
func (m *Mill) cancelledLeasesForNode(nodeID string) []*RemoteLease {
194
+
m.mu.Lock()
195
+
var candidates []*RemoteLease
196
+
for _, lease := range m.leases {
197
+
if lease.nodeID == nodeID {
198
+
candidates = append(candidates, lease)
199
+
}
200
+
}
201
+
m.mu.Unlock()
202
+
203
+
var leases []*RemoteLease
204
+
for _, lease := range candidates {
205
+
if cancelled, _ := lease.cancelRequested(); cancelled && lease.getState() != leaseDone {
206
+
leases = append(leases, lease)
207
+
}
208
+
}
209
+
return leases
210
+
}
211
+
212
+
// reconnect grace ran out without the executor coming back. fails every
213
+
// lease the node held, and reschedules itself if any failure didn't stick
214
+
func (m *Mill) failLeasesAfterGrace(sess *millSession) {
215
+
m.mu.Lock()
216
+
if m.sessions[sess.nodeID] != sess || !sess.disconnected {
217
+
// reconnected in the meantime
218
+
m.mu.Unlock()
219
+
return
220
+
}
221
+
var dead []*RemoteLease
222
+
for _, lease := range m.leases {
223
+
if lease.nodeID == sess.nodeID {
224
+
dead = append(dead, lease)
225
+
}
226
+
}
227
+
m.mu.Unlock()
228
+
229
+
m.l.Warn("executor declared dead; failing its in-flight jobs", "node", sess.nodeID, "jobs", len(dead))
230
+
deadReason := "executor lost"
231
+
success := true
232
+
for _, lease := range dead {
233
+
switch {
234
+
case lease.orphaned:
235
+
// restored leases have no RunStep waiting, fail them straight
236
+
// into the event stream
237
+
if err := m.finishOrphan(lease, string(models.StatusKindFailed), &deadReason, nil); err != nil {
238
+
m.l.Error("finish orphan after executor loss failed, will retry", "lease", lease.id, "err", err)
239
+
success = false
240
+
}
241
+
default:
242
+
// live leases have a blocked RunStep, keep a pending cancellation
243
+
// as the terminal reason
244
+
status := string(models.StatusKindFailed)
245
+
reason := deadReason
246
+
if cancelled, cancelReason := lease.cancelRequested(); cancelled {
247
+
status = string(models.StatusKindCancelled)
248
+
reason = cancelReason
249
+
}
250
+
if err := m.finishLiveLease(lease, status, reason); err != nil {
251
+
m.l.Error("finish lease after executor loss failed, will retry", "lease", lease.id, "err", err)
252
+
success = false
253
+
}
254
+
}
255
+
}
256
+
257
+
m.mu.Lock()
258
+
if !success {
259
+
// something didn't finish cleanly, try again shortly. finished
260
+
// leases skip themselves on the next pass
261
+
sess.graceTimer = time.AfterFunc(5*time.Second, func() { m.failLeasesAfterGrace(sess) })
262
+
m.mu.Unlock()
263
+
return
264
+
}
265
+
266
+
// everything failed cleanly. forget the node and wake placement
267
+
if m.sessions[sess.nodeID] == sess {
268
+
delete(m.sessions, sess.nodeID)
269
+
}
270
+
m.mu.Unlock()
271
+
m.notifyChange()
272
+
m.sweepUnclaimedOrphans()
273
+
}
274
+
275
+
func (m *Mill) place(ctx context.Context, engineName string, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) {
276
+
m.mu.Lock()
277
+
if m.cfg.MaxPending > 0 && m.pending >= m.cfg.MaxPending {
278
+
max := m.cfg.MaxPending
279
+
cur := m.pending
280
+
m.mu.Unlock()
281
+
return nil, fmt.Errorf("%w: mill has %d pending jobs (max %d)", engine.ErrNoWorkflowSlots, cur, max)
282
+
}
283
+
m.pending++
284
+
m.mu.Unlock()
285
+
defer func() {
286
+
m.mu.Lock()
287
+
m.pending--
288
+
m.mu.Unlock()
289
+
}()
290
+
291
+
for {
292
+
if err := ctx.Err(); err != nil {
293
+
return nil, err
294
+
}
295
+
296
+
// grab the channel before bidding. a change mid-bid closes it, so
297
+
// the wait below re-bids right away
298
+
ch := m.currentChangeCh()
299
+
300
+
lease, err := m.bid(ctx, engineName, wid, wf)
301
+
if err != nil {
302
+
return nil, err
303
+
}
304
+
if lease != nil {
305
+
lease.wid = wid
306
+
if err := m.persistLease(lease, leaseRowReserved); err != nil {
307
+
m.releaseRemote(lease)
308
+
m.mu.Lock()
309
+
delete(m.reservations, lease.id)
310
+
m.mu.Unlock()
311
+
return nil, fmt.Errorf("persist reserved mill lease: %w", err)
312
+
}
313
+
m.mu.Lock()
314
+
delete(m.reservations, lease.id)
315
+
m.leases[lease.id] = lease
316
+
if st, ok := wf.Data.(*millWorkflowState); ok && st != nil {
317
+
st.Lease = lease
318
+
}
319
+
m.mu.Unlock()
320
+
return &millSlot{fleet: m, lease: lease}, nil
321
+
}
322
+
323
+
// no executor available. wait for a change or ctx
324
+
select {
325
+
case <-ctx.Done():
326
+
return nil, ctx.Err()
327
+
case <-ch:
328
+
}
329
+
}
330
+
}
331
+
332
+
func (m *Mill) bid(ctx context.Context, engineName string, wid models.WorkflowId, wf *models.Workflow) (*RemoteLease, error) {
333
+
rawPipeline, rawWorkflow, err := marshalJob(wf)
334
+
if err != nil {
335
+
return nil, err
336
+
}
337
+
338
+
requiredLabels := requiredLabels(wf)
339
+
candidates := m.rankCandidates(engineName, requiredLabels)
340
+
if len(candidates) == 0 {
341
+
return nil, nil
342
+
}
343
+
344
+
type bidResult struct {
345
+
sess *millSession
346
+
lease *RemoteLease
347
+
rank int
348
+
incompatible bool
349
+
reason string
350
+
}
351
+
limit := m.cfg.TopK
352
+
if limit <= 0 {
353
+
limit = len(candidates)
354
+
}
355
+
if len(candidates) > limit {
356
+
candidates = candidates[:limit]
357
+
}
358
+
results := make(chan bidResult, limit)
359
+
ask := func(rank int, sess *millSession) {
360
+
bidCtx, cancel := context.WithTimeout(ctx, m.cfg.BidTimeout)
361
+
defer cancel()
362
+
leaseID := m.nextLeaseID()
363
+
lease := newLease(leaseID, sess.nodeID, sess.epoch, engineName)
364
+
m.mu.Lock()
365
+
m.reservations[leaseID] = lease
366
+
m.mu.Unlock()
367
+
msg := &millproto.Message{ReserveSeat: &millv1.ReserveSeat{
368
+
LeaseId: leaseID,
369
+
TargetEngine: engineName,
370
+
RawPipelineJson: rawPipeline,
371
+
RawWorkflowJson: rawWorkflow,
372
+
Knot: wid.Knot,
373
+
Rkey: wid.Rkey,
374
+
TtlSeconds: uint32(m.cfg.ReconnectGrace / time.Second),
375
+
}}
376
+
resp, err := sess.request(bidCtx, leaseID, msg)
377
+
if err != nil {
378
+
m.dropReservation(leaseID)
379
+
results <- bidResult{}
380
+
return
381
+
}
382
+
rr := resp.GetReserveResult()
383
+
if rr == nil {
384
+
m.dropReservation(leaseID)
385
+
results <- bidResult{}
386
+
return
387
+
}
388
+
if !rr.GetAccepted() {
389
+
m.dropReservation(leaseID)
390
+
if rr.GetRejectClass() == millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE {
391
+
results <- bidResult{sess: sess, rank: rank, incompatible: true, reason: rr.GetRejectReason()}
392
+
return
393
+
}
394
+
results <- bidResult{}
395
+
return
396
+
}
397
+
results <- bidResult{sess: sess, lease: lease, rank: rank}
398
+
}
399
+
next := 0
400
+
inFlight := 0
401
+
for next < len(candidates) && inFlight < limit {
402
+
inFlight++
403
+
go ask(next, candidates[next])
404
+
next++
405
+
}
406
+
407
+
var winner *bidResult
408
+
var losers []*RemoteLease
409
+
var incompatible []string
410
+
// any soft reject (transient or timeout) means the fleet was just
411
+
// busy, so an all-incompatible outcome isn't a hard placement error
412
+
softReject := false
413
+
for inFlight > 0 {
414
+
r := <-results
415
+
inFlight--
416
+
// incompatible rejects get reported to the user, any other failure
417
+
// just means the fleet is busy
418
+
if r.incompatible {
419
+
if r.reason != "" {
420
+
incompatible = append(incompatible, r.reason)
421
+
}
422
+
} else if r.lease == nil {
423
+
softReject = true
424
+
}
425
+
// a failed bid means ask the next candidate, unless someone won
426
+
if r.lease == nil {
427
+
for winner == nil && next < len(candidates) && inFlight < limit {
428
+
inFlight++
429
+
go ask(next, candidates[next])
430
+
next++
431
+
}
432
+
continue
433
+
}
434
+
// if this bid is worse than the winner it goes to the losers pile
435
+
if winner != nil && r.rank >= winner.rank {
436
+
losers = append(losers, r.lease)
437
+
continue
438
+
}
439
+
// otherwise it's the new best and the old winner joins the losers
440
+
if winner != nil {
441
+
losers = append(losers, winner.lease)
442
+
}
443
+
winner = &r
444
+
}
445
+
446
+
// let the losers go so they free their seats right away
447
+
for _, l := range losers {
448
+
m.dropReservation(l.id)
449
+
m.releaseRemote(l)
450
+
}
451
+
452
+
if winner == nil {
453
+
if len(incompatible) > 0 && !softReject {
454
+
return nil, fmt.Errorf("no compatible executor for %s: %s", engineName, strings.Join(incompatible, "; "))
455
+
}
456
+
return nil, nil
457
+
}
458
+
return winner.lease, nil
459
+
}
460
+
461
+
// ranks nodes that are least busy first. if a resource is used a lot
462
+
// then that node will lose to one that is more even across the board.
463
+
func (m *Mill) rankCandidates(engineName string, requiredLabels []string) []*millSession {
464
+
m.mu.Lock()
465
+
defer m.mu.Unlock()
466
+
467
+
type ranked struct {
468
+
sess *millSession
469
+
worst float64
470
+
sum float64
471
+
}
472
+
var rs []ranked
473
+
for _, s := range m.sessions {
474
+
// only live, reporting sessions can take work
475
+
if s.disconnected {
476
+
continue
477
+
}
478
+
if s.snapshot == nil {
479
+
continue
480
+
}
481
+
// the engine has to exist and have room right now
482
+
ea, ok := s.snapshot.GetEngines()[engineName]
483
+
if !ok || !ea.GetAvailable() {
484
+
continue
485
+
}
486
+
// and satisfy the wf's label requirements
487
+
if !hasLabels(s.labels, requiredLabels) {
488
+
continue
489
+
}
490
+
worst, sum := loadScore(ea.GetLoad())
491
+
rs = append(rs, ranked{sess: s, worst: worst, sum: sum})
492
+
}
493
+
slices.SortStableFunc(rs, func(a, b ranked) int {
494
+
if a.worst < b.worst {
495
+
return -1
496
+
}
497
+
if a.worst > b.worst {
498
+
return 1
499
+
}
500
+
if a.sum < b.sum {
501
+
return -1
502
+
}
503
+
if a.sum > b.sum {
504
+
return 1
505
+
}
506
+
return 0
507
+
})
508
+
509
+
out := make([]*millSession, len(rs))
510
+
for i := range rs {
511
+
out[i] = rs[i].sess
512
+
}
513
+
return out
514
+
}
515
+
516
+
func loadScore(load map[string]float64) (worst, sum float64) {
517
+
for _, v := range load {
518
+
if v > worst {
519
+
worst = v
520
+
}
521
+
sum += v
522
+
}
523
+
return worst, sum
524
+
}
525
+
526
+
func requiredLabels(wf *models.Workflow) []string {
527
+
st, ok := wf.Data.(*millWorkflowState)
528
+
if !ok || st == nil {
529
+
return nil
530
+
}
531
+
return st.RawWorkflow.RunsOn
532
+
}
533
+
534
+
func hasLabels(labels []string, required []string) bool {
535
+
for _, want := range required {
536
+
if !slices.Contains(labels, want) {
537
+
return false
538
+
}
539
+
}
540
+
return true
541
+
}
542
+
543
+
func (m *Mill) commitAndWait(ctx context.Context, wf *models.Workflow, unlocked []secrets.UnlockedSecret) error {
544
+
st, ok := wf.Data.(*millWorkflowState)
545
+
if !ok || st == nil || st.Lease == nil {
546
+
return fmt.Errorf("mill workflow state missing lease")
547
+
}
548
+
lease := st.Lease
549
+
550
+
pbSecrets := make([]*millv1.Secret, len(unlocked))
551
+
for i, s := range unlocked {
552
+
pbSecrets[i] = &millv1.Secret{Key: s.Key, Value: s.Value}
553
+
}
554
+
555
+
commit := &millproto.Message{CommitLease: &millv1.CommitLease{
556
+
LeaseId: lease.id,
557
+
Secrets: pbSecrets,
558
+
}}
559
+
560
+
// commit retries ride reconnects, a reservation outlives one
561
+
// disconnect. lost session or slow executor just means wait and retry,
562
+
// only job timeout or a dead lease stops the loop
563
+
for {
564
+
if res, ok := pollTerminal(lease); ok {
565
+
return terminalError(res.Status)
566
+
}
567
+
if !lease.markCommitting() {
568
+
return engine.ErrWorkflowFailed
569
+
}
570
+
571
+
sess := m.sessionForNode(lease.nodeID)
572
+
if sess == nil {
573
+
if done, err := m.waitCommitRetry(ctx, lease); done || err != nil {
574
+
return err
575
+
}
576
+
continue
577
+
}
578
+
579
+
reqCtx, cancel := context.WithTimeout(ctx, m.cfg.BidTimeout)
580
+
resp, err := sess.request(reqCtx, lease.id, commit)
581
+
cancel()
582
+
if err != nil {
583
+
switch {
584
+
case errors.Is(err, errSessionClosed):
585
+
// session died mid-request. wait out the grace, then retry on
586
+
// the new one
587
+
if done, err := m.waitCommitRetry(ctx, lease); done || err != nil {
588
+
return err
589
+
}
590
+
continue
591
+
case errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil:
592
+
// executor didn't answer in time, but its seat is still held so
593
+
// retrying is safe
594
+
continue
595
+
case errors.Is(err, context.DeadlineExceeded):
596
+
// the job ctx itself ran out, a real timeout
597
+
return engine.ErrTimedOut
598
+
case errors.Is(err, context.Canceled):
599
+
return err
600
+
default:
601
+
m.l.Warn("commit lease send failed; waiting for reconnect", "lease", lease.id, "node", lease.nodeID, "err", err)
602
+
if done, err := m.waitCommitRetry(ctx, lease); done || err != nil {
603
+
return err
604
+
}
605
+
continue
606
+
}
607
+
}
608
+
if resp.GetCommitted() == nil {
609
+
return engine.ErrWorkflowFailed
610
+
}
611
+
lease.markRunning()
612
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
613
+
m.l.Error("persist running mill lease", "lease", lease.id, "err", err)
614
+
}
615
+
if cancelled, reason := lease.cancelRequested(); cancelled {
616
+
m.sendCancel(sess, lease, reason)
617
+
}
618
+
break
619
+
}
620
+
621
+
select {
622
+
case res := <-lease.terminal:
623
+
return terminalError(res.Status)
624
+
case <-ctx.Done():
625
+
if ctx.Err() == context.DeadlineExceeded {
626
+
return engine.ErrTimedOut
627
+
}
628
+
return ctx.Err()
629
+
}
630
+
}
631
+
632
+
func (m *Mill) waitCommitRetry(ctx context.Context, lease *RemoteLease) (bool, error) {
633
+
// grab the channel before checking for a live session again
634
+
// a reconnect will still close the channel if it happens in between
635
+
ch := m.currentChangeCh()
636
+
if m.sessionForNode(lease.nodeID) != nil {
637
+
return false, nil
638
+
}
639
+
select {
640
+
case res := <-lease.terminal:
641
+
return true, terminalError(res.Status)
642
+
case <-ctx.Done():
643
+
if ctx.Err() == context.DeadlineExceeded {
644
+
return true, engine.ErrTimedOut
645
+
}
646
+
return true, ctx.Err()
647
+
case <-ch:
648
+
return false, nil
649
+
}
650
+
}
651
+
652
+
func terminalError(status millv1.TerminalStatus) error {
653
+
switch status {
654
+
case millv1.TerminalStatus_SUCCESS:
655
+
return nil
656
+
case millv1.TerminalStatus_TIMEOUT:
657
+
return engine.ErrTimedOut
658
+
case millv1.TerminalStatus_CANCELLED:
659
+
return engine.ErrWorkflowCanceled
660
+
default:
661
+
return engine.ErrWorkflowFailed
662
+
}
663
+
}
664
+
665
+
func pollTerminal(lease *RemoteLease) (*millv1.AttemptResult, bool) {
666
+
select {
667
+
case res := <-lease.terminal:
668
+
return res, true
669
+
default:
670
+
return nil, false
671
+
}
672
+
}
673
+
674
+
func (m *Mill) destroy(wid models.WorkflowId) {
675
+
m.mu.Lock()
676
+
var lease *RemoteLease
677
+
for _, l := range m.leases {
678
+
if l.wid == wid {
679
+
lease = l
680
+
break
681
+
}
682
+
}
683
+
m.mu.Unlock()
684
+
if lease == nil {
685
+
return
686
+
}
687
+
reason := "workflow destroyed"
688
+
switch lease.requestCancel(reason) {
689
+
case cancelLocal:
690
+
if sess := m.sessionForNode(lease.nodeID); sess != nil {
691
+
_ = sess.send(&millproto.Message{ReleaseLease: &millv1.ReleaseLease{LeaseId: lease.id}})
692
+
}
693
+
lease.deliverCancelled(reason)
694
+
case cancelRemote:
695
+
if sess := m.sessionForNode(lease.nodeID); sess != nil {
696
+
m.sendCancel(sess, lease, reason)
697
+
}
698
+
}
699
+
}
700
+
701
+
func (m *Mill) releaseSlot(s *millSlot) {
702
+
lease := s.lease
703
+
state, cancelled := lease.releaseState()
704
+
if state == leaseReserved {
705
+
m.releaseRemote(lease)
706
+
} else if cancelled && state != leaseDone {
707
+
return
708
+
}
709
+
if err := m.cleanupLease(lease); err != nil {
710
+
m.l.Error("releaseSlot cleanupLease failed", "lease", lease.id, "err", err)
711
+
}
712
+
}
713
+
func (m *Mill) cleanupLease(lease *RemoteLease) error {
714
+
lease.finishMu.Lock()
715
+
defer lease.finishMu.Unlock()
716
+
return m.cleanupLeaseLocked(lease)
717
+
}
718
+
719
+
func (m *Mill) cleanupLeaseLocked(lease *RemoteLease) error {
720
+
if lease.cleanedUp {
721
+
return nil
722
+
}
723
+
if m.db != nil {
724
+
if err := m.db.DeleteMillLease(lease.id); err != nil {
725
+
m.scheduleCleanupLocked(lease)
726
+
return err
727
+
}
728
+
}
729
+
m.mu.Lock()
730
+
delete(m.leases, lease.id)
731
+
m.mu.Unlock()
732
+
lease.cleanedUp = true
733
+
m.notifyChange()
734
+
return nil
735
+
}
736
+
737
+
func (m *Mill) scheduleCleanupLocked(lease *RemoteLease) {
738
+
if lease.cleanedUp || lease.cleanupRetry {
739
+
return
740
+
}
741
+
lease.cleanupRetry = true
742
+
time.AfterFunc(5*time.Second, func() {
743
+
lease.finishMu.Lock()
744
+
lease.cleanupRetry = false
745
+
err := m.cleanupLeaseLocked(lease)
746
+
lease.finishMu.Unlock()
747
+
if err != nil {
748
+
m.l.Error("retry lease cleanup failed", "lease", lease.id, "err", err)
749
+
}
750
+
})
751
+
}
752
+
753
+
func (m *Mill) releaseRemote(lease *RemoteLease) {
754
+
lease.setState(leaseDone)
755
+
if sess := m.sessionForNode(lease.nodeID); sess != nil {
756
+
_ = sess.send(&millproto.Message{ReleaseLease: &millv1.ReleaseLease{LeaseId: lease.id}})
757
+
}
758
+
}
759
+
760
+
func (m *Mill) sendCancel(sess *millSession, lease *RemoteLease, reason string) {
761
+
if err := sess.send(&millproto.Message{CancelAttempt: &millv1.CancelAttempt{
762
+
LeaseId: lease.id,
763
+
Reason: reason,
764
+
}}); err != nil {
765
+
return
766
+
}
767
+
time.AfterFunc(m.cfg.CancelTimeout, func() { m.checkCancelDeadline(lease) })
768
+
}
769
+
770
+
func (m *Mill) sessionForNode(nodeID string) *millSession {
771
+
m.mu.Lock()
772
+
defer m.mu.Unlock()
773
+
sess := m.sessions[nodeID]
774
+
if sess == nil || sess.disconnected {
775
+
return nil
776
+
}
777
+
return sess
778
+
}
779
+
780
+
func (m *Mill) onSnapshot(sess *millSession, snap *millv1.NodeSnapshot) error {
781
+
m.mu.Lock()
782
+
if sess.snapshot != nil && snap.Seqno <= sess.snapshot.Seqno {
783
+
m.mu.Unlock()
784
+
return fmt.Errorf("protocol error: snapshot seqno regression. Got %d, last seen %d", snap.Seqno, sess.snapshot.Seqno)
785
+
}
786
+
sess.snapshot = snap
787
+
m.mu.Unlock()
788
+
789
+
if err := m.reconcileLeases(sess, snap.GetActiveLeaseIds()); err != nil {
790
+
return err
791
+
}
792
+
m.mu.Lock()
793
+
for _, id := range snap.GetActiveLeaseIds() {
794
+
if lease := m.leases[id]; lease != nil && lease.nodeID == sess.nodeID && lease.epoch == sess.epoch {
795
+
lease.claimed = true
796
+
}
797
+
}
798
+
m.mu.Unlock()
799
+
m.notifyChange()
800
+
return nil
801
+
}
802
+
803
+
func (m *Mill) onEventBatch(sess *millSession, batch *millv1.EventBatch) error {
804
+
if batch == nil {
805
+
return nil
806
+
}
807
+
808
+
if batch.Epoch != sess.epoch {
809
+
return fmt.Errorf("protocol error: batch epoch %q does not match session %q", batch.Epoch, sess.epoch)
810
+
}
811
+
812
+
m.mu.Lock()
813
+
currentKey := sess.nodeID + "/" + sess.epoch
814
+
current := m.nodeSeqno[currentKey]
815
+
m.mu.Unlock()
816
+
817
+
expected := current + 1
818
+
var newEntries []*millv1.Event
819
+
for _, entry := range batch.Events {
820
+
// reconnect replays old seqnos, drop those
821
+
if entry.Seqno <= current {
822
+
continue
823
+
}
824
+
// gap means executor lost rows. so dont apply a partial batch
825
+
if entry.Seqno != expected {
826
+
return fmt.Errorf("protocol error: gap in stream seqnos. Expected %d, got %d", expected, entry.Seqno)
827
+
}
828
+
newEntries = append(newEntries, entry)
829
+
expected++
830
+
}
831
+
832
+
// all replays, still ack so the executor can trim its outbox
833
+
if len(newEntries) == 0 {
834
+
return m.sendAck(sess, current)
835
+
}
836
+
837
+
type pendingTerminal struct {
838
+
lease *RemoteLease
839
+
ar *millv1.AttemptResult
840
+
}
841
+
var pendingTerminals []pendingTerminal
842
+
var artifactLeases []*RemoteLease
843
+
finishedInBatch := make(map[string]struct{})
844
+
845
+
var highestSeqno uint64 = current
846
+
847
+
applyFunc := func(tx *db.EventBatchTx) error {
848
+
for _, entry := range newEntries {
849
+
m.mu.Lock()
850
+
lease := m.leases[entry.LeaseId]
851
+
m.mu.Unlock()
852
+
853
+
// events for leases this node doesn't own are skipped but still
854
+
// count as processed
855
+
if lease == nil || lease.nodeID != sess.nodeID {
856
+
highestSeqno = entry.Seqno
857
+
continue
858
+
}
859
+
860
+
// events arriving for a different epoch are invalid (different session)
861
+
if lease.epoch != "" && lease.epoch != sess.epoch {
862
+
return fmt.Errorf("protocol error: lease %q epoch %q does not match session %q", lease.id, lease.epoch, sess.epoch)
863
+
}
864
+
865
+
lease.mu.Lock()
866
+
state := lease.state
867
+
lease.mu.Unlock()
868
+
869
+
// done leases can replay terminals on reconnect, skip them
870
+
if state == leaseDone {
871
+
highestSeqno = entry.Seqno
872
+
continue
873
+
}
874
+
875
+
if _, finished := finishedInBatch[lease.id]; finished {
876
+
return fmt.Errorf("protocol error: stream entry follows terminal for lease %q", lease.id)
877
+
}
878
+
879
+
switch {
880
+
case entry.GetStatusEvent() != nil:
881
+
ev := entry.GetStatusEvent()
882
+
statusStr := string(models.StatusKindRunning)
883
+
var errMsg *string
884
+
if e := ev.GetError(); e != "" {
885
+
errMsg = &e
886
+
}
887
+
var exitCode *int64
888
+
if c := ev.GetExitCode(); c != 0 {
889
+
exitCode = &c
890
+
}
891
+
pipelineAtUri := string(lease.wid.PipelineId.AtUri())
892
+
if tx != nil {
893
+
if err := tx.InsertStatusEvent(pipelineAtUri, lease.wid.Name, statusStr, errMsg, exitCode); err != nil {
894
+
return err
895
+
}
896
+
}
897
+
898
+
case entry.GetAttemptResult() != nil:
899
+
ar := entry.GetAttemptResult()
900
+
statusStr := "success"
901
+
switch ar.Status {
902
+
case millv1.TerminalStatus_SUCCESS:
903
+
statusStr = "success"
904
+
case millv1.TerminalStatus_FAILED:
905
+
statusStr = "failed"
906
+
case millv1.TerminalStatus_TIMEOUT:
907
+
statusStr = "timeout"
908
+
case millv1.TerminalStatus_CANCELLED:
909
+
statusStr = "cancelled"
910
+
default:
911
+
return fmt.Errorf("protocol error: unsupported terminal status %v", ar.Status)
912
+
}
913
+
var errMsg *string
914
+
if e := ar.GetError(); e != "" {
915
+
errMsg = &e
916
+
}
917
+
var exitCode *int64
918
+
if c := ar.GetExitCode(); c != 0 {
919
+
exitCode = &c
920
+
}
921
+
finishedInBatch[lease.id] = struct{}{}
922
+
pipelineAtUri := string(lease.wid.PipelineId.AtUri())
923
+
if tx != nil {
924
+
if err := tx.InsertStatusEvent(pipelineAtUri, lease.wid.Name, statusStr, errMsg, exitCode); err != nil {
925
+
return err
926
+
}
927
+
if err := tx.DeleteLease(lease.id); err != nil {
928
+
return err
929
+
}
930
+
if a := ar.GetLogArtifact(); a != nil {
931
+
if a.GetRef() == "" {
932
+
return fmt.Errorf("protocol error: empty log artifact ref")
933
+
}
934
+
if !strings.HasPrefix(a.GetHash(), "sha256:") {
935
+
return fmt.Errorf("protocol error: invalid log artifact hash %q", a.GetHash())
936
+
}
937
+
if tx != nil {
938
+
if err := tx.InsertArtifactRef(lease.id, lease.wid.Name, a.GetRef(), a.GetHash()); err != nil {
939
+
return err
940
+
}
941
+
}
942
+
artifactLeases = append(artifactLeases, lease)
943
+
}
944
+
}
945
+
pendingTerminals = append(pendingTerminals, pendingTerminal{
946
+
lease: lease,
947
+
ar: ar,
948
+
})
949
+
}
950
+
951
+
highestSeqno = entry.Seqno
952
+
}
953
+
954
+
if tx != nil {
955
+
return tx.AdvanceCursor(sess.nodeID, sess.epoch, highestSeqno)
956
+
}
957
+
return nil
958
+
}
959
+
960
+
var err error
961
+
if m.db != nil {
962
+
err = m.db.ApplyEventBatch(m.n, applyFunc)
963
+
} else {
964
+
err = applyFunc(nil)
965
+
}
966
+
967
+
if err != nil {
968
+
return err
969
+
}
970
+
971
+
if m.cfg.LogDir != "" {
972
+
for _, lease := range artifactLeases {
973
+
path := models.LogFilePath(m.cfg.LogDir, lease.wid)
974
+
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
975
+
m.l.Warn("failed to remove live log file after artifact recorded", "path", path, "err", err)
976
+
}
977
+
}
978
+
}
979
+
980
+
m.mu.Lock()
981
+
if highestSeqno > m.nodeSeqno[currentKey] {
982
+
m.nodeSeqno[currentKey] = highestSeqno
983
+
}
984
+
m.mu.Unlock()
985
+
986
+
for _, pt := range pendingTerminals {
987
+
// orphans have no waiting RunStep, just mark and clean up
988
+
if pt.lease.orphaned {
989
+
pt.lease.markDone()
990
+
_ = m.cleanupLease(pt.lease)
991
+
continue
992
+
}
993
+
pt.lease.deliverTerminal(pt.ar)
994
+
if pt.lease.cleanupReady() {
995
+
_ = m.cleanupLease(pt.lease)
996
+
}
997
+
}
998
+
999
+
return m.sendAck(sess, highestSeqno)
1000
+
}
1001
+
1002
+
func (m *Mill) sendAck(sess *millSession, seqno uint64) error {
1003
+
msg := &millproto.Message{Ack: &millv1.Ack{
1004
+
Epoch: sess.epoch,
1005
+
UpToSeqno: seqno,
1006
+
}}
1007
+
if err := sess.send(msg); err != nil {
1008
+
return fmt.Errorf("send ack message: %w", err)
1009
+
}
1010
+
return nil
1011
+
}
1012
+
func (m *Mill) onLiveLog(sess *millSession, ll *millv1.LiveLog) error {
1013
+
if ll == nil || ll.GetLeaseId() == "" {
1014
+
return nil
1015
+
}
1016
+
m.mu.Lock()
1017
+
lease := m.leases[ll.GetLeaseId()]
1018
+
m.mu.Unlock()
1019
+
if lease == nil || lease.nodeID != sess.nodeID {
1020
+
return nil
1021
+
}
1022
+
raw := ll.GetRawJson()
1023
+
if m.cfg.LogDir == "" || len(raw) == 0 {
1024
+
if m.n != nil {
1025
+
m.n.NotifyAll()
1026
+
}
1027
+
return nil
1028
+
}
1029
+
lease.mu.Lock()
1030
+
isDone := (lease.state == leaseDone)
1031
+
lease.mu.Unlock()
1032
+
if isDone {
1033
+
if m.n != nil {
1034
+
m.n.NotifyAll()
1035
+
}
1036
+
return nil
1037
+
}
1038
+
1039
+
logPath := models.LogFilePath(m.cfg.LogDir, lease.wid)
1040
+
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
1041
+
m.l.Warn("failed to create log dir", "path", filepath.Dir(logPath), "err", err)
1042
+
if m.n != nil {
1043
+
m.n.NotifyAll()
1044
+
}
1045
+
return nil
1046
+
}
1047
+
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
1048
+
if err != nil {
1049
+
m.l.Warn("failed to open log file", "path", logPath, "err", err)
1050
+
if m.n != nil {
1051
+
m.n.NotifyAll()
1052
+
}
1053
+
return nil
1054
+
}
1055
+
if _, err := f.Write(raw); err != nil {
1056
+
m.l.Warn("failed to write log file", "path", logPath, "err", err)
1057
+
}
1058
+
_ = f.Close()
1059
+
1060
+
if m.n != nil {
1061
+
m.n.NotifyAll()
1062
+
}
1063
+
return nil
1064
+
}
1065
+
1066
+
func (m *Mill) onCancelAck(sess *millSession, ca *millv1.CancelAck) {
1067
+
m.mu.Lock()
1068
+
lease := m.leases[ca.GetLeaseId()]
1069
+
m.mu.Unlock()
1070
+
if lease == nil || lease.nodeID != sess.nodeID {
1071
+
return
1072
+
}
1073
+
lease.mu.Lock()
1074
+
lease.cancelAcked = true
1075
+
lease.mu.Unlock()
1076
+
}
1077
+
1078
+
func (m *Mill) checkCancelDeadline(lease *RemoteLease) {
1079
+
lease.mu.Lock()
1080
+
isDone := (lease.state == leaseDone)
1081
+
isAcked := lease.cancelAcked
1082
+
lease.mu.Unlock()
1083
+
1084
+
if isDone && isAcked {
1085
+
return
1086
+
}
1087
+
1088
+
m.l.Warn("node failed to comply with cancel request within deadline, quarantining", "node", lease.nodeID, "lease", lease.id, "done", isDone, "acked", isAcked)
1089
+
1090
+
reason := fmt.Sprintf("cancel noncompliance for lease %s (done: %t, acked: %t)", lease.id, isDone, isAcked)
1091
+
if m.db != nil {
1092
+
if err := m.db.QuarantineExecutor(lease.nodeID, reason); err != nil {
1093
+
m.l.Error("failed to quarantine executor", "node", lease.nodeID, "err", err)
1094
+
}
1095
+
}
1096
+
1097
+
m.mu.Lock()
1098
+
sess := m.sessions[lease.nodeID]
1099
+
m.mu.Unlock()
1100
+
if sess != nil {
1101
+
sess.close()
1102
+
}
1103
+
}
1104
+
1105
+
func marshalJob(wf *models.Workflow) (pipeline string, workflow string, err error) {
1106
+
st, ok := wf.Data.(*millWorkflowState)
1107
+
if !ok || st == nil {
1108
+
return "", "", fmt.Errorf("mill workflow state missing")
1109
+
}
1110
+
p, err := json.Marshal(st.RawPipeline)
1111
+
if err != nil {
1112
+
return "", "", fmt.Errorf("marshal pipeline: %w", err)
1113
+
}
1114
+
w, err := json.Marshal(st.RawWorkflow)
1115
+
if err != nil {
1116
+
return "", "", fmt.Errorf("marshal workflow: %w", err)
1117
+
}
1118
+
return string(p), string(w), nil
1119
+
}
+873
spindle/mill/mill_test.go
+873
spindle/mill/mill_test.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"io"
7
+
"log/slog"
8
+
"sync"
9
+
"testing"
10
+
"time"
11
+
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/spindle/engine"
14
+
"tangled.org/core/spindle/models"
15
+
16
+
millproto "tangled.org/core/spindle/mill/proto"
17
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
18
+
)
19
+
20
+
type scriptedEncoder func(*millproto.Message) error
21
+
22
+
func (e scriptedEncoder) Encode(msg *millproto.Message) error { return e(msg) }
23
+
24
+
func testWorkflow(name string) *models.Workflow {
25
+
return &models.Workflow{
26
+
Name: name,
27
+
Environment: map[string]string{},
28
+
Steps: []models.Step{remoteStep{}},
29
+
Data: &millWorkflowState{
30
+
RawWorkflow: tangled.Pipeline_Workflow{Name: name},
31
+
RawPipeline: tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{}},
32
+
},
33
+
}
34
+
}
35
+
36
+
func testWorkflowWithRunsOn(name string, runsOn []string) *models.Workflow {
37
+
wf := testWorkflow(name)
38
+
wf.Data.(*millWorkflowState).RawWorkflow.RunsOn = runsOn
39
+
return wf
40
+
}
41
+
42
+
func addCandidateSession(t *testing.T, m *Mill, nodeID string, labels []string, load float64, enc messageEncoder) *millSession {
43
+
t.Helper()
44
+
if enc == nil {
45
+
enc = scriptedEncoder(func(*millproto.Message) error { return nil })
46
+
}
47
+
sess := newSession(nodeID, "inc-"+nodeID, labels, enc, slog.New(slog.NewTextHandler(io.Discard, nil)))
48
+
sess.snapshot = &millv1.NodeSnapshot{
49
+
Seqno: 1,
50
+
Engines: map[string]*millv1.EngineAvailability{
51
+
"dummy": {Available: load < 1.0, Load: map[string]float64{"slots": load}},
52
+
},
53
+
}
54
+
m.mu.Lock()
55
+
m.sessions[nodeID] = sess
56
+
m.mu.Unlock()
57
+
return sess
58
+
}
59
+
60
+
func assertRankedNodes(t *testing.T, got []*millSession, want []string) {
61
+
t.Helper()
62
+
if len(got) != len(want) {
63
+
t.Fatalf("rankCandidates() returned %d candidates, want %d: got %v want %v", len(got), len(want), sessionIDs(got), want)
64
+
}
65
+
for i := range want {
66
+
if got[i].nodeID != want[i] {
67
+
t.Fatalf("rankCandidates()[%d] = %q, want %q; full order got %v want %v", i, got[i].nodeID, want[i], sessionIDs(got), want)
68
+
}
69
+
}
70
+
}
71
+
72
+
func sessionIDs(sessions []*millSession) []string {
73
+
out := make([]string, len(sessions))
74
+
for i, sess := range sessions {
75
+
out[i] = sess.nodeID
76
+
}
77
+
return out
78
+
}
79
+
80
+
func sameStringMultiset(a, b []string) bool {
81
+
if len(a) != len(b) {
82
+
return false
83
+
}
84
+
counts := make(map[string]int, len(a))
85
+
for _, s := range a {
86
+
counts[s]++
87
+
}
88
+
for _, s := range b {
89
+
if counts[s] == 0 {
90
+
return false
91
+
}
92
+
counts[s]--
93
+
}
94
+
return true
95
+
}
96
+
97
+
type reserveReply struct {
98
+
accepted bool
99
+
rejectClass millv1.RejectClass
100
+
reason string
101
+
}
102
+
103
+
func addReplyingCandidateSession(t *testing.T, m *Mill, nodeID string, labels []string, load float64, asked chan<- string, reply reserveReply) *millSession {
104
+
t.Helper()
105
+
var sess *millSession
106
+
sess = addCandidateSession(t, m, nodeID, labels, load, scriptedEncoder(func(msg *millproto.Message) error {
107
+
rs := msg.GetReserveSeat()
108
+
if rs == nil {
109
+
return nil
110
+
}
111
+
if asked != nil {
112
+
asked <- nodeID
113
+
}
114
+
sess.deliver(rs.GetLeaseId(), &millproto.Message{ReserveResult: &millv1.ReserveResult{
115
+
LeaseId: rs.GetLeaseId(),
116
+
Accepted: reply.accepted,
117
+
RejectReason: reply.reason,
118
+
RejectClass: reply.rejectClass,
119
+
}})
120
+
return nil
121
+
}))
122
+
return sess
123
+
}
124
+
125
+
func drainAsked(ch <-chan string) []string {
126
+
var out []string
127
+
for {
128
+
select {
129
+
case nodeID := <-ch:
130
+
out = append(out, nodeID)
131
+
default:
132
+
return out
133
+
}
134
+
}
135
+
}
136
+
137
+
func TestCommitRetriesAfterSessionCloseBeforeCommitted(t *testing.T) {
138
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
139
+
m := New(l, Config{BidTimeout: 25 * time.Millisecond, ReconnectGrace: time.Second})
140
+
wf := testWorkflow("build")
141
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
142
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
143
+
lease.wid = wid
144
+
wf.Data.(*millWorkflowState).Lease = lease
145
+
146
+
m.mu.Lock()
147
+
m.leases[lease.id] = lease
148
+
m.mu.Unlock()
149
+
150
+
var sess1 *millSession
151
+
firstCommit := make(chan struct{})
152
+
sess1 = newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
153
+
if msg.GetCommitLease() != nil {
154
+
close(firstCommit)
155
+
m.detachSession(sess1)
156
+
}
157
+
return nil
158
+
}), l)
159
+
m.attachSession(sess1)
160
+
161
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
162
+
defer cancel()
163
+
done := make(chan error, 1)
164
+
go func() { done <- m.commitAndWait(ctx, wf, nil) }()
165
+
166
+
select {
167
+
case <-firstCommit:
168
+
case <-ctx.Done():
169
+
t.Fatal("first commit was not sent")
170
+
}
171
+
172
+
var sess2 *millSession
173
+
sess2 = newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
174
+
if msg.GetCommitLease() == nil {
175
+
return nil
176
+
}
177
+
leaseID := msg.GetCommitLease().GetLeaseId()
178
+
sess2.deliver(leaseID, &millproto.Message{Committed: &millv1.Committed{LeaseId: leaseID}})
179
+
_ = m.onEventBatch(sess2, &millv1.EventBatch{
180
+
Epoch: sess2.epoch,
181
+
Events: []*millv1.Event{
182
+
{
183
+
Seqno: 1,
184
+
LeaseId: leaseID,
185
+
Payload: &millv1.Event_AttemptResult{
186
+
AttemptResult: &millv1.AttemptResult{
187
+
Status: millv1.TerminalStatus_SUCCESS,
188
+
},
189
+
},
190
+
},
191
+
},
192
+
})
193
+
return nil
194
+
}), l)
195
+
m.attachSession(sess2)
196
+
m.sessionReady(sess2)
197
+
198
+
select {
199
+
case err := <-done:
200
+
if err != nil {
201
+
t.Fatalf("commitAndWait() error = %v, want success after reconnect", err)
202
+
}
203
+
case <-ctx.Done():
204
+
t.Fatal("commitAndWait() did not finish after reconnect")
205
+
}
206
+
}
207
+
208
+
func TestDestroyRunningLeaseDoesNotDropCancelledTerminal(t *testing.T) {
209
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
210
+
m := New(l, Config{})
211
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
212
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
213
+
lease.wid = wid
214
+
lease.setState(leaseRunning)
215
+
216
+
m.mu.Lock()
217
+
m.leases[lease.id] = lease
218
+
m.mu.Unlock()
219
+
220
+
m.destroy(wid)
221
+
if lease.getState() == leaseDone {
222
+
t.Fatal("destroy sealed the lease before the terminal result")
223
+
}
224
+
225
+
lease.deliverTerminal(&millv1.AttemptResult{
226
+
Status: millv1.TerminalStatus_CANCELLED,
227
+
})
228
+
res := <-lease.terminal
229
+
if err := terminalError(res.Status); !errors.Is(err, engine.ErrWorkflowCanceled) {
230
+
t.Fatalf("terminalError() = %v, want ErrCancelled", err)
231
+
}
232
+
}
233
+
234
+
func TestPlaceBlocksWhenNoCapacity(t *testing.T) {
235
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
236
+
m := New(l, Config{})
237
+
wf := testWorkflow("build")
238
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
239
+
240
+
// no executors at all: place must block until ctx expires (user sees pending)
241
+
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
242
+
defer cancel()
243
+
244
+
_, err := m.place(ctx, "dummy", wid, wf)
245
+
if err != context.DeadlineExceeded {
246
+
t.Fatalf("place() error = %v, want DeadlineExceeded", err)
247
+
}
248
+
}
249
+
250
+
func TestRankCandidatesFiltersRequiredLabelsWithANDSemantics(t *testing.T) {
251
+
m := New(slog.New(slog.NewTextHandler(io.Discard, nil)), Config{})
252
+
addCandidateSession(t, m, "linux-high", []string{"linux"}, 0.0, nil)
253
+
addCandidateSession(t, m, "linux-arm", []string{"linux", "arm64"}, 0.25, nil)
254
+
addCandidateSession(t, m, "unlabeled", nil, 0.5, nil)
255
+
addCandidateSession(t, m, "linux-arm-gpu", []string{"linux", "arm64", "gpu"}, 0.75, nil)
256
+
addCandidateSession(t, m, "linux-arm-full", []string{"linux", "arm64"}, 1.0, nil)
257
+
258
+
tests := []struct {
259
+
name string
260
+
requiredLabels []string
261
+
want []string
262
+
}{
263
+
{
264
+
name: "no required labels keeps old capacity ranking",
265
+
want: []string{"linux-high", "linux-arm", "unlabeled", "linux-arm-gpu"},
266
+
},
267
+
{
268
+
name: "single required label includes every candidate carrying it",
269
+
requiredLabels: []string{"linux"},
270
+
want: []string{"linux-high", "linux-arm", "linux-arm-gpu"},
271
+
},
272
+
{
273
+
name: "all required labels must be present",
274
+
requiredLabels: []string{"linux", "arm64"},
275
+
want: []string{"linux-arm", "linux-arm-gpu"},
276
+
},
277
+
{
278
+
name: "one missing required label excludes the candidate",
279
+
requiredLabels: []string{"linux", "arm64", "gpu"},
280
+
want: []string{"linux-arm-gpu"},
281
+
},
282
+
{
283
+
name: "unknown required label leaves no candidate",
284
+
requiredLabels: []string{"linux", "arm64", "metal"},
285
+
},
286
+
}
287
+
288
+
for _, tt := range tests {
289
+
t.Run(tt.name, func(t *testing.T) {
290
+
assertRankedNodes(t, m.rankCandidates("dummy", tt.requiredLabels), tt.want)
291
+
})
292
+
}
293
+
}
294
+
295
+
func TestPlaceWithMissingRequiredLabelsStaysPendingWithoutReserve(t *testing.T) {
296
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
297
+
m := New(l, Config{BidTimeout: 10 * time.Millisecond})
298
+
reserveSent := make(chan struct{}, 1)
299
+
addCandidateSession(t, m, "linux-only", []string{"linux"}, 0.75, scriptedEncoder(func(msg *millproto.Message) error {
300
+
if msg.GetReserveSeat() != nil {
301
+
select {
302
+
case reserveSent <- struct{}{}:
303
+
default:
304
+
}
305
+
}
306
+
return nil
307
+
}))
308
+
wf := testWorkflowWithRunsOn("build", []string{"linux", "arm64"})
309
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
310
+
311
+
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
312
+
defer cancel()
313
+
_, err := m.place(ctx, "dummy", wid, wf)
314
+
if err != context.DeadlineExceeded {
315
+
t.Fatalf("place() error = %v, want DeadlineExceeded while job remains pending", err)
316
+
}
317
+
select {
318
+
case <-reserveSent:
319
+
t.Fatal("place() sent ReserveSeat to executor missing a required label")
320
+
default:
321
+
}
322
+
}
323
+
324
+
func TestMaxPendingRejects(t *testing.T) {
325
+
l := slog.New(slog.NewTextHandler(io.Discard, nil))
326
+
m := New(l, Config{MaxPending: 1})
327
+
328
+
m.mu.Lock()
329
+
m.pending = 1
330
+
m.mu.Unlock()
331
+
332
+
wf2 := testWorkflow("b")
333
+
_, err := m.place(context.Background(), "dummy", models.WorkflowId{Name: "b"}, wf2)
334
+
if err == nil {
335
+
t.Fatal("place() past maxPending should error")
336
+
}
337
+
}
338
+
339
+
func TestCancelledRunningLeaseSurvivesReleaseForReconnectReplay(t *testing.T) {
340
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
341
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
342
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
343
+
lease.wid = wid
344
+
lease.setState(leaseRunning)
345
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
346
+
t.Fatalf("persistLease: %v", err)
347
+
}
348
+
m.mu.Lock()
349
+
m.leases[lease.id] = lease
350
+
m.mu.Unlock()
351
+
352
+
m.destroy(wid)
353
+
slot := &millSlot{fleet: m, lease: lease}
354
+
slot.Release()
355
+
356
+
m.mu.Lock()
357
+
_, retained := m.leases[lease.id]
358
+
m.mu.Unlock()
359
+
if !retained {
360
+
t.Fatal("slot release removed a cancellation-requested running lease before its terminal result")
361
+
}
362
+
if rows, err := bdb.ListMillLeases(); err != nil || len(rows) != 1 {
363
+
t.Fatalf("durable leases after slot release = %+v, err = %v; want retained lease", rows, err)
364
+
}
365
+
366
+
var sentMu sync.Mutex
367
+
var sent []*millproto.Message
368
+
sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
369
+
sentMu.Lock()
370
+
sent = append(sent, msg)
371
+
sentMu.Unlock()
372
+
return nil
373
+
}), discardLogger())
374
+
if _, ok := m.attachSession(sess); !ok {
375
+
t.Fatal("attachSession rejected reconnect")
376
+
}
377
+
m.sessionReady(sess)
378
+
sentMu.Lock()
379
+
var replayed bool
380
+
for _, msg := range sent {
381
+
if cancel := msg.GetCancelAttempt(); cancel != nil && cancel.GetLeaseId() == lease.id {
382
+
replayed = true
383
+
}
384
+
}
385
+
sentMu.Unlock()
386
+
if !replayed {
387
+
t.Fatal("reconnect did not replay CancelAttempt for retained lease")
388
+
}
389
+
390
+
if err := m.onEventBatch(sess, &millv1.EventBatch{
391
+
Epoch: sess.epoch,
392
+
Events: []*millv1.Event{
393
+
{
394
+
Seqno: 1,
395
+
LeaseId: lease.id,
396
+
Payload: &millv1.Event_AttemptResult{
397
+
AttemptResult: &millv1.AttemptResult{
398
+
Status: millv1.TerminalStatus_CANCELLED,
399
+
},
400
+
},
401
+
},
402
+
},
403
+
}); err != nil {
404
+
t.Fatalf("onEventBatch: %v", err)
405
+
}
406
+
m.mu.Lock()
407
+
_, retained = m.leases[lease.id]
408
+
m.mu.Unlock()
409
+
if retained {
410
+
t.Fatal("terminal result did not clean retained cancelled lease")
411
+
}
412
+
if rows, err := bdb.ListMillLeases(); err != nil || len(rows) != 0 {
413
+
t.Fatalf("durable leases after terminal = %+v, err = %v; want none", rows, err)
414
+
}
415
+
slot.Release()
416
+
}
417
+
418
+
func TestSessionRequestCancelledBeforeRegistrationOrSend(t *testing.T) {
419
+
sent := 0
420
+
sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(*millproto.Message) error {
421
+
sent++
422
+
return nil
423
+
}), discardLogger())
424
+
ctx, cancel := context.WithCancel(context.Background())
425
+
cancel()
426
+
_, err := sess.request(ctx, "lease-1", &millproto.Message{ReleaseLease: &millv1.ReleaseLease{LeaseId: "lease-1"}})
427
+
if !errors.Is(err, context.Canceled) {
428
+
t.Fatalf("request error = %v, want context.Canceled", err)
429
+
}
430
+
if sent != 0 {
431
+
t.Fatalf("request sent %d messages for an already-cancelled context, want 0", sent)
432
+
}
433
+
sess.mu.Lock()
434
+
pending := len(sess.pending)
435
+
sess.mu.Unlock()
436
+
if pending != 0 {
437
+
t.Fatalf("request left %d pending waiters, want 0", pending)
438
+
}
439
+
}
440
+
441
+
func TestAttachSessionReplacesSilentIncumbentButRejectsActiveDuplicate(t *testing.T) {
442
+
m := New(discardLogger(), Config{ReconnectGrace: time.Minute})
443
+
old := newSession("node-1", "inc-old", nil, nopEncoder(), discardLogger())
444
+
transportClosed := make(chan struct{})
445
+
old.closeTransport = func() error {
446
+
close(transportClosed)
447
+
return nil
448
+
}
449
+
if _, ok := m.attachSession(old); !ok {
450
+
t.Fatal("first attach rejected")
451
+
}
452
+
m.mu.Lock()
453
+
old.lastSeen = time.Now().Add(-2 * m.cfg.ReconnectGrace)
454
+
m.mu.Unlock()
455
+
456
+
replacement := newSession("node-1", "inc-new", nil, nopEncoder(), discardLogger())
457
+
if _, ok := m.attachSession(replacement); !ok {
458
+
t.Fatal("silent incumbent blocked authenticated replacement")
459
+
}
460
+
select {
461
+
case <-transportClosed:
462
+
default:
463
+
t.Fatal("replacing a silent incumbent did not close its transport")
464
+
}
465
+
m.mu.Lock()
466
+
replacement.lastSeen = time.Now().Add(-2 * m.cfg.ReconnectGrace)
467
+
m.mu.Unlock()
468
+
if err := replacement.dispatch(m, &millproto.Message{NodeSnapshot: &millv1.NodeSnapshot{Seqno: 1}}); err != nil {
469
+
t.Fatalf("periodic snapshot dispatch: %v", err)
470
+
}
471
+
if _, ok := m.attachSession(newSession("node-1", "inc-dup", nil, nopEncoder(), discardLogger())); ok {
472
+
t.Fatal("active replacement did not reject a duplicate session")
473
+
}
474
+
}
475
+
476
+
func TestPlaceReleasesRemoteReservationWhenInitialPersistenceFails(t *testing.T) {
477
+
m, bdb := restoreTestMill(t, Config{BidTimeout: time.Second})
478
+
if err := bdb.Close(); err != nil {
479
+
t.Fatalf("close db: %v", err)
480
+
}
481
+
released := make(chan string, 1)
482
+
var sess *millSession
483
+
sess = addCandidateSession(t, m, "node-1", nil, 0, scriptedEncoder(func(msg *millproto.Message) error {
484
+
switch {
485
+
case msg.GetReserveSeat() != nil:
486
+
leaseID := msg.GetReserveSeat().GetLeaseId()
487
+
sess.deliver(leaseID, &millproto.Message{ReserveResult: &millv1.ReserveResult{
488
+
LeaseId: leaseID,
489
+
Accepted: true,
490
+
}})
491
+
case msg.GetReleaseLease() != nil:
492
+
released <- msg.GetReleaseLease().GetLeaseId()
493
+
}
494
+
return nil
495
+
}))
496
+
497
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
498
+
defer cancel()
499
+
slot, err := m.place(
500
+
ctx,
501
+
"dummy",
502
+
models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"},
503
+
testWorkflow("build"),
504
+
)
505
+
if err == nil {
506
+
t.Fatal("place succeeded after reserved lease persistence failed")
507
+
}
508
+
if slot != nil {
509
+
t.Fatalf("place returned slot %T after persistence failure", slot)
510
+
}
511
+
select {
512
+
case leaseID := <-released:
513
+
if leaseID == "" {
514
+
t.Fatal("ReleaseLease had empty lease id")
515
+
}
516
+
case <-time.After(time.Second):
517
+
t.Fatal("persistence failure did not compensate with ReleaseLease")
518
+
}
519
+
m.mu.Lock()
520
+
leases := len(m.leases)
521
+
m.mu.Unlock()
522
+
if leases != 0 {
523
+
t.Fatalf("mill published %d leases after initial persistence failure, want 0", leases)
524
+
}
525
+
}
526
+
527
+
func TestGapsAndDuplicates(t *testing.T) {
528
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
529
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
530
+
m.attachSession(sess)
531
+
532
+
owned := newLease("lease-1", "node-1", "inc-1", "dummy")
533
+
owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
534
+
m.mu.Lock()
535
+
m.leases[owned.id] = owned
536
+
m.mu.Unlock()
537
+
538
+
err := m.onEventBatch(sess, &millv1.EventBatch{
539
+
Epoch: sess.epoch,
540
+
Events: []*millv1.Event{
541
+
{
542
+
Seqno: 0,
543
+
LeaseId: owned.id,
544
+
Payload: &millv1.Event_StatusEvent{
545
+
StatusEvent: &millv1.StatusEvent{Status: millv1.NonterminalStatus_RUNNING},
546
+
},
547
+
},
548
+
},
549
+
})
550
+
if err != nil {
551
+
t.Fatalf("expected duplicate to be skipped without error, got: %v", err)
552
+
}
553
+
554
+
err = m.onEventBatch(sess, &millv1.EventBatch{
555
+
Epoch: sess.epoch,
556
+
Events: []*millv1.Event{
557
+
{
558
+
Seqno: 2,
559
+
LeaseId: owned.id,
560
+
Payload: &millv1.Event_StatusEvent{
561
+
StatusEvent: &millv1.StatusEvent{Status: millv1.NonterminalStatus_RUNNING},
562
+
},
563
+
},
564
+
},
565
+
})
566
+
if err == nil {
567
+
t.Fatal("expected error due to seqno gap, got nil")
568
+
}
569
+
}
570
+
571
+
func TestAtomicBatchRollback(t *testing.T) {
572
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
573
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
574
+
m.attachSession(sess)
575
+
576
+
owned := newLease("lease-1", "node-1", "inc-1", "dummy")
577
+
owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
578
+
m.mu.Lock()
579
+
m.leases[owned.id] = owned
580
+
m.mu.Unlock()
581
+
582
+
if _, err := bdb.Exec(`
583
+
create trigger reject_status_event
584
+
before insert on events
585
+
begin
586
+
select raise(abort, 'forced status event failure');
587
+
end
588
+
`); err != nil {
589
+
t.Fatalf("failed to create fail trigger: %v", err)
590
+
}
591
+
defer bdb.Exec("drop trigger reject_status_event")
592
+
593
+
err := m.onEventBatch(sess, &millv1.EventBatch{
594
+
Epoch: sess.epoch,
595
+
Events: []*millv1.Event{
596
+
{
597
+
Seqno: 1,
598
+
LeaseId: owned.id,
599
+
Payload: &millv1.Event_StatusEvent{
600
+
StatusEvent: &millv1.StatusEvent{Status: millv1.NonterminalStatus_RUNNING},
601
+
},
602
+
},
603
+
},
604
+
})
605
+
if err == nil {
606
+
t.Fatal("expected status event insertion to fail due to trigger")
607
+
}
608
+
609
+
m.mu.Lock()
610
+
seqno := m.nodeSeqno["node-1/inc-1"]
611
+
m.mu.Unlock()
612
+
if seqno != 0 {
613
+
t.Fatalf("expected seqno 0 due to rollback, got %d", seqno)
614
+
}
615
+
}
616
+
617
+
func TestTerminalBeforeACK(t *testing.T) {
618
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
619
+
620
+
ackSent := make(chan struct{})
621
+
sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
622
+
if msg.GetAck() != nil {
623
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
624
+
st, err := bdb.GetStatus(wid)
625
+
if err != nil || st.Status != "success" {
626
+
t.Errorf("expected terminal status success at ACK time, got status: %v, err: %v", st, err)
627
+
}
628
+
close(ackSent)
629
+
}
630
+
return nil
631
+
}), discardLogger())
632
+
m.attachSession(sess)
633
+
634
+
owned := newLease("lease-1", "node-1", "inc-1", "dummy")
635
+
owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
636
+
m.mu.Lock()
637
+
m.leases[owned.id] = owned
638
+
m.mu.Unlock()
639
+
640
+
err := m.onEventBatch(sess, &millv1.EventBatch{
641
+
Epoch: sess.epoch,
642
+
Events: []*millv1.Event{
643
+
{
644
+
Seqno: 1,
645
+
LeaseId: owned.id,
646
+
Payload: &millv1.Event_AttemptResult{
647
+
AttemptResult: &millv1.AttemptResult{Status: millv1.TerminalStatus_SUCCESS},
648
+
},
649
+
},
650
+
},
651
+
})
652
+
if err != nil {
653
+
t.Fatalf("onEventBatch: %v", err)
654
+
}
655
+
656
+
select {
657
+
case <-ackSent:
658
+
case <-time.After(2 * time.Second):
659
+
t.Fatal("ACK was not sent")
660
+
}
661
+
}
662
+
663
+
func TestExecutorRestartEmptySnapshot(t *testing.T) {
664
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
665
+
666
+
lease := newLease("lease-1", "node-1", "inc-old", "dummy")
667
+
lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
668
+
m.mu.Lock()
669
+
m.leases[lease.id] = lease
670
+
m.mu.Unlock()
671
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
672
+
t.Fatalf("persistLease: %v", err)
673
+
}
674
+
675
+
sess := newSession("node-1", "inc-new", nil, nopEncoder(), discardLogger())
676
+
m.attachSession(sess)
677
+
678
+
err := m.onSnapshot(sess, &millv1.NodeSnapshot{
679
+
Seqno: 1,
680
+
ActiveLeaseIds: nil,
681
+
})
682
+
if err != nil {
683
+
t.Fatalf("onSnapshot: %v", err)
684
+
}
685
+
686
+
m.mu.Lock()
687
+
_, stillActive := m.leases["lease-1"]
688
+
m.mu.Unlock()
689
+
if stillActive {
690
+
t.Fatal("expected old epoch lease to be reconciled and failed")
691
+
}
692
+
}
693
+
694
+
func TestReplacementLostBeforeSnapshotFailsOldEpochLease(t *testing.T) {
695
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
696
+
lease := newLease("lease-1", "node-1", "inc-old", "dummy")
697
+
lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
698
+
lease.setState(leaseRunning)
699
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
700
+
t.Fatalf("persistLease: %v", err)
701
+
}
702
+
m.mu.Lock()
703
+
m.leases[lease.id] = lease
704
+
m.mu.Unlock()
705
+
706
+
replacement := newSession("node-1", "inc-new", nil, nopEncoder(), discardLogger())
707
+
if _, ok := m.attachSession(replacement); !ok {
708
+
t.Fatal("attachSession rejected replacement")
709
+
}
710
+
replacement.disconnected = true
711
+
m.failLeasesAfterGrace(replacement)
712
+
713
+
m.mu.Lock()
714
+
_, stillActive := m.leases[lease.id]
715
+
m.mu.Unlock()
716
+
if stillActive {
717
+
t.Fatal("replacement loss stranded old-epoch lease")
718
+
}
719
+
}
720
+
721
+
func TestSeqRegression(t *testing.T) {
722
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
723
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
724
+
m.attachSession(sess)
725
+
726
+
err := m.onSnapshot(sess, &millv1.NodeSnapshot{
727
+
Seqno: 5,
728
+
})
729
+
if err != nil {
730
+
t.Fatalf("first snapshot: %v", err)
731
+
}
732
+
733
+
err = m.onSnapshot(sess, &millv1.NodeSnapshot{
734
+
Seqno: 4,
735
+
})
736
+
if err == nil {
737
+
t.Fatal("expected seqno regression to be rejected")
738
+
}
739
+
}
740
+
741
+
func TestClaimedSweep(t *testing.T) {
742
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
743
+
744
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
745
+
lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
746
+
lease.orphaned = true
747
+
lease.claimed = false
748
+
m.mu.Lock()
749
+
m.leases[lease.id] = lease
750
+
m.mu.Unlock()
751
+
752
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
753
+
m.attachSession(sess)
754
+
err := m.onSnapshot(sess, &millv1.NodeSnapshot{
755
+
Seqno: 1,
756
+
ActiveLeaseIds: []string{"lease-1"},
757
+
})
758
+
if err != nil {
759
+
t.Fatalf("onSnapshot: %v", err)
760
+
}
761
+
762
+
m.detachSession(sess)
763
+
764
+
m.sweepUnclaimedOrphans()
765
+
766
+
m.mu.Lock()
767
+
_, stillRunning := m.leases["lease-1"]
768
+
m.mu.Unlock()
769
+
if !stillRunning {
770
+
t.Fatal("claimed lease was incorrectly swept by startup sweep")
771
+
}
772
+
}
773
+
774
+
func TestCancelDeadline(t *testing.T) {
775
+
m, _ := restoreTestMill(t, Config{CancelTimeout: 50 * time.Millisecond})
776
+
777
+
sessClosed := make(chan struct{})
778
+
sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
779
+
return nil
780
+
}), discardLogger())
781
+
sess.closeTransport = func() error {
782
+
close(sessClosed)
783
+
return nil
784
+
}
785
+
m.attachSession(sess)
786
+
787
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
788
+
lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
789
+
lease.setState(leaseRunning)
790
+
m.mu.Lock()
791
+
m.leases[lease.id] = lease
792
+
m.mu.Unlock()
793
+
794
+
m.destroy(lease.wid)
795
+
796
+
select {
797
+
case <-sessClosed:
798
+
case <-time.After(1 * time.Second):
799
+
t.Fatal("session was not closed after cancel deadline expiration")
800
+
}
801
+
}
802
+
803
+
func TestCleanupRetry(t *testing.T) {
804
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
805
+
806
+
lease := newLease("lease-1", "node-1", "inc-1", "dummy")
807
+
lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
808
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
809
+
t.Fatalf("persist lease: %v", err)
810
+
}
811
+
m.mu.Lock()
812
+
m.leases[lease.id] = lease
813
+
m.mu.Unlock()
814
+
815
+
if _, err := bdb.Exec(`
816
+
create trigger reject_cleanup_delete
817
+
before delete on mill_leases
818
+
begin
819
+
select raise(abort, 'forced delete failure');
820
+
end
821
+
`); err != nil {
822
+
t.Fatalf("failed to create fail trigger: %v", err)
823
+
}
824
+
825
+
err := m.cleanupLease(lease)
826
+
if err == nil {
827
+
t.Fatal("expected cleanupLease to fail")
828
+
}
829
+
830
+
m.mu.Lock()
831
+
_, stillRunning := m.leases["lease-1"]
832
+
m.mu.Unlock()
833
+
if !stillRunning {
834
+
t.Fatal("lease was removed from memory despite cleanup failure")
835
+
}
836
+
837
+
if _, err := bdb.Exec("drop trigger reject_cleanup_delete"); err != nil {
838
+
t.Fatalf("drop trigger: %v", err)
839
+
}
840
+
841
+
err = m.cleanupLease(lease)
842
+
if err != nil {
843
+
t.Fatalf("expected retry cleanup to succeed, got: %v", err)
844
+
}
845
+
846
+
m.mu.Lock()
847
+
_, stillRunning = m.leases["lease-1"]
848
+
m.mu.Unlock()
849
+
if stillRunning {
850
+
t.Fatal("lease still in memory after successful cleanup retry")
851
+
}
852
+
}
853
+
854
+
func TestBoundedBidding(t *testing.T) {
855
+
m := New(discardLogger(), Config{TopK: 2, BidTimeout: 10 * time.Millisecond})
856
+
857
+
addCandidateSession(t, m, "node-1", nil, 0, nil)
858
+
addCandidateSession(t, m, "node-2", nil, 0, nil)
859
+
addCandidateSession(t, m, "node-3", nil, 0, nil)
860
+
addCandidateSession(t, m, "node-4", nil, 0, nil)
861
+
addCandidateSession(t, m, "node-5", nil, 0, nil)
862
+
863
+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
864
+
defer cancel()
865
+
866
+
lease, err := m.bid(ctx, "dummy", models.WorkflowId{}, testWorkflow("build"))
867
+
if err != nil {
868
+
t.Fatalf("bid: %v", err)
869
+
}
870
+
if lease != nil {
871
+
t.Fatalf("did not expect a lease, got %+v", lease)
872
+
}
873
+
}
+1679
spindle/mill/proto/gen/mill.pb.go
+1679
spindle/mill/proto/gen/mill.pb.go
···
1
+
// Code generated by protoc-gen-go. DO NOT EDIT.
2
+
// versions:
3
+
// protoc-gen-go v1.36.11
4
+
// protoc (unknown)
5
+
// source: spindle/mill/v1/mill.proto
6
+
7
+
package millv1
8
+
9
+
import (
10
+
_ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"
11
+
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
12
+
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
13
+
reflect "reflect"
14
+
sync "sync"
15
+
unsafe "unsafe"
16
+
)
17
+
18
+
const (
19
+
// Verify that this generated code is sufficiently up-to-date.
20
+
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
21
+
// Verify that runtime/protoimpl is sufficiently up-to-date.
22
+
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
23
+
)
24
+
25
+
type RejectClass int32
26
+
27
+
const (
28
+
RejectClass_REJECT_CLASS_UNSPECIFIED RejectClass = 0
29
+
RejectClass_REJECT_CLASS_TRANSIENT RejectClass = 1
30
+
RejectClass_REJECT_CLASS_INCOMPATIBLE RejectClass = 2
31
+
)
32
+
33
+
// Enum value maps for RejectClass.
34
+
var (
35
+
RejectClass_name = map[int32]string{
36
+
0: "REJECT_CLASS_UNSPECIFIED",
37
+
1: "REJECT_CLASS_TRANSIENT",
38
+
2: "REJECT_CLASS_INCOMPATIBLE",
39
+
}
40
+
RejectClass_value = map[string]int32{
41
+
"REJECT_CLASS_UNSPECIFIED": 0,
42
+
"REJECT_CLASS_TRANSIENT": 1,
43
+
"REJECT_CLASS_INCOMPATIBLE": 2,
44
+
}
45
+
)
46
+
47
+
func (x RejectClass) Enum() *RejectClass {
48
+
p := new(RejectClass)
49
+
*p = x
50
+
return p
51
+
}
52
+
53
+
func (x RejectClass) String() string {
54
+
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
55
+
}
56
+
57
+
func (RejectClass) Descriptor() protoreflect.EnumDescriptor {
58
+
return file_spindle_mill_v1_mill_proto_enumTypes[0].Descriptor()
59
+
}
60
+
61
+
func (RejectClass) Type() protoreflect.EnumType {
62
+
return &file_spindle_mill_v1_mill_proto_enumTypes[0]
63
+
}
64
+
65
+
func (x RejectClass) Number() protoreflect.EnumNumber {
66
+
return protoreflect.EnumNumber(x)
67
+
}
68
+
69
+
// Deprecated: Use RejectClass.Descriptor instead.
70
+
func (RejectClass) EnumDescriptor() ([]byte, []int) {
71
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{0}
72
+
}
73
+
74
+
type NonterminalStatus int32
75
+
76
+
const (
77
+
NonterminalStatus_NONTERMINAL_STATUS_UNSPECIFIED NonterminalStatus = 0
78
+
NonterminalStatus_RUNNING NonterminalStatus = 1
79
+
)
80
+
81
+
// Enum value maps for NonterminalStatus.
82
+
var (
83
+
NonterminalStatus_name = map[int32]string{
84
+
0: "NONTERMINAL_STATUS_UNSPECIFIED",
85
+
1: "RUNNING",
86
+
}
87
+
NonterminalStatus_value = map[string]int32{
88
+
"NONTERMINAL_STATUS_UNSPECIFIED": 0,
89
+
"RUNNING": 1,
90
+
}
91
+
)
92
+
93
+
func (x NonterminalStatus) Enum() *NonterminalStatus {
94
+
p := new(NonterminalStatus)
95
+
*p = x
96
+
return p
97
+
}
98
+
99
+
func (x NonterminalStatus) String() string {
100
+
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
101
+
}
102
+
103
+
func (NonterminalStatus) Descriptor() protoreflect.EnumDescriptor {
104
+
return file_spindle_mill_v1_mill_proto_enumTypes[1].Descriptor()
105
+
}
106
+
107
+
func (NonterminalStatus) Type() protoreflect.EnumType {
108
+
return &file_spindle_mill_v1_mill_proto_enumTypes[1]
109
+
}
110
+
111
+
func (x NonterminalStatus) Number() protoreflect.EnumNumber {
112
+
return protoreflect.EnumNumber(x)
113
+
}
114
+
115
+
// Deprecated: Use NonterminalStatus.Descriptor instead.
116
+
func (NonterminalStatus) EnumDescriptor() ([]byte, []int) {
117
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{1}
118
+
}
119
+
120
+
type TerminalStatus int32
121
+
122
+
const (
123
+
TerminalStatus_TERMINAL_STATUS_UNSPECIFIED TerminalStatus = 0
124
+
TerminalStatus_SUCCESS TerminalStatus = 1
125
+
TerminalStatus_FAILED TerminalStatus = 2
126
+
TerminalStatus_TIMEOUT TerminalStatus = 3
127
+
TerminalStatus_CANCELLED TerminalStatus = 4
128
+
)
129
+
130
+
// Enum value maps for TerminalStatus.
131
+
var (
132
+
TerminalStatus_name = map[int32]string{
133
+
0: "TERMINAL_STATUS_UNSPECIFIED",
134
+
1: "SUCCESS",
135
+
2: "FAILED",
136
+
3: "TIMEOUT",
137
+
4: "CANCELLED",
138
+
}
139
+
TerminalStatus_value = map[string]int32{
140
+
"TERMINAL_STATUS_UNSPECIFIED": 0,
141
+
"SUCCESS": 1,
142
+
"FAILED": 2,
143
+
"TIMEOUT": 3,
144
+
"CANCELLED": 4,
145
+
}
146
+
)
147
+
148
+
func (x TerminalStatus) Enum() *TerminalStatus {
149
+
p := new(TerminalStatus)
150
+
*p = x
151
+
return p
152
+
}
153
+
154
+
func (x TerminalStatus) String() string {
155
+
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
156
+
}
157
+
158
+
func (TerminalStatus) Descriptor() protoreflect.EnumDescriptor {
159
+
return file_spindle_mill_v1_mill_proto_enumTypes[2].Descriptor()
160
+
}
161
+
162
+
func (TerminalStatus) Type() protoreflect.EnumType {
163
+
return &file_spindle_mill_v1_mill_proto_enumTypes[2]
164
+
}
165
+
166
+
func (x TerminalStatus) Number() protoreflect.EnumNumber {
167
+
return protoreflect.EnumNumber(x)
168
+
}
169
+
170
+
// Deprecated: Use TerminalStatus.Descriptor instead.
171
+
func (TerminalStatus) EnumDescriptor() ([]byte, []int) {
172
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{2}
173
+
}
174
+
175
+
// executor identity, sent on connect
176
+
type Hello struct {
177
+
state protoimpl.MessageState `protogen:"open.v1"`
178
+
ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
179
+
// GOARCH of the node, informational only
180
+
Arch string `protobuf:"bytes,2,opt,name=arch,proto3" json:"arch,omitempty"`
181
+
// operator-defined labels, matched against runs_on
182
+
Labels []string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty"`
183
+
Epoch string `protobuf:"bytes,4,opt,name=epoch,proto3" json:"epoch,omitempty"`
184
+
unknownFields protoimpl.UnknownFields
185
+
sizeCache protoimpl.SizeCache
186
+
}
187
+
188
+
func (x *Hello) Reset() {
189
+
*x = Hello{}
190
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[0]
191
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
192
+
ms.StoreMessageInfo(mi)
193
+
}
194
+
195
+
func (x *Hello) String() string {
196
+
return protoimpl.X.MessageStringOf(x)
197
+
}
198
+
199
+
func (*Hello) ProtoMessage() {}
200
+
201
+
func (x *Hello) ProtoReflect() protoreflect.Message {
202
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[0]
203
+
if x != nil {
204
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
205
+
if ms.LoadMessageInfo() == nil {
206
+
ms.StoreMessageInfo(mi)
207
+
}
208
+
return ms
209
+
}
210
+
return mi.MessageOf(x)
211
+
}
212
+
213
+
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
214
+
func (*Hello) Descriptor() ([]byte, []int) {
215
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{0}
216
+
}
217
+
218
+
func (x *Hello) GetProtocolVersion() uint32 {
219
+
if x != nil {
220
+
return x.ProtocolVersion
221
+
}
222
+
return 0
223
+
}
224
+
225
+
func (x *Hello) GetArch() string {
226
+
if x != nil {
227
+
return x.Arch
228
+
}
229
+
return ""
230
+
}
231
+
232
+
func (x *Hello) GetLabels() []string {
233
+
if x != nil {
234
+
return x.Labels
235
+
}
236
+
return nil
237
+
}
238
+
239
+
func (x *Hello) GetEpoch() string {
240
+
if x != nil {
241
+
return x.Epoch
242
+
}
243
+
return ""
244
+
}
245
+
246
+
// reconnect state for an existing epoch
247
+
type Resume struct {
248
+
state protoimpl.MessageState `protogen:"open.v1"`
249
+
Epoch string `protobuf:"bytes,1,opt,name=epoch,proto3" json:"epoch,omitempty"`
250
+
AckSeqno uint64 `protobuf:"varint,2,opt,name=ack_seqno,json=ackSeqno,proto3" json:"ack_seqno,omitempty"`
251
+
unknownFields protoimpl.UnknownFields
252
+
sizeCache protoimpl.SizeCache
253
+
}
254
+
255
+
func (x *Resume) Reset() {
256
+
*x = Resume{}
257
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[1]
258
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
259
+
ms.StoreMessageInfo(mi)
260
+
}
261
+
262
+
func (x *Resume) String() string {
263
+
return protoimpl.X.MessageStringOf(x)
264
+
}
265
+
266
+
func (*Resume) ProtoMessage() {}
267
+
268
+
func (x *Resume) ProtoReflect() protoreflect.Message {
269
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[1]
270
+
if x != nil {
271
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
272
+
if ms.LoadMessageInfo() == nil {
273
+
ms.StoreMessageInfo(mi)
274
+
}
275
+
return ms
276
+
}
277
+
return mi.MessageOf(x)
278
+
}
279
+
280
+
// Deprecated: Use Resume.ProtoReflect.Descriptor instead.
281
+
func (*Resume) Descriptor() ([]byte, []int) {
282
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{1}
283
+
}
284
+
285
+
func (x *Resume) GetEpoch() string {
286
+
if x != nil {
287
+
return x.Epoch
288
+
}
289
+
return ""
290
+
}
291
+
292
+
func (x *Resume) GetAckSeqno() uint64 {
293
+
if x != nil {
294
+
return x.AckSeqno
295
+
}
296
+
return 0
297
+
}
298
+
299
+
// per-engine state of a node
300
+
type EngineAvailability struct {
301
+
state protoimpl.MessageState `protogen:"open.v1"`
302
+
Available bool `protobuf:"varint,1,opt,name=available,proto3" json:"available,omitempty"`
303
+
// opaque engine-defined load metrics, higher means more loaded
304
+
Load map[string]float64 `protobuf:"bytes,2,rep,name=load,proto3" json:"load,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"fixed64,2,opt,name=value"`
305
+
unknownFields protoimpl.UnknownFields
306
+
sizeCache protoimpl.SizeCache
307
+
}
308
+
309
+
func (x *EngineAvailability) Reset() {
310
+
*x = EngineAvailability{}
311
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[2]
312
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
313
+
ms.StoreMessageInfo(mi)
314
+
}
315
+
316
+
func (x *EngineAvailability) String() string {
317
+
return protoimpl.X.MessageStringOf(x)
318
+
}
319
+
320
+
func (*EngineAvailability) ProtoMessage() {}
321
+
322
+
func (x *EngineAvailability) ProtoReflect() protoreflect.Message {
323
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[2]
324
+
if x != nil {
325
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
326
+
if ms.LoadMessageInfo() == nil {
327
+
ms.StoreMessageInfo(mi)
328
+
}
329
+
return ms
330
+
}
331
+
return mi.MessageOf(x)
332
+
}
333
+
334
+
// Deprecated: Use EngineAvailability.ProtoReflect.Descriptor instead.
335
+
func (*EngineAvailability) Descriptor() ([]byte, []int) {
336
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{2}
337
+
}
338
+
339
+
func (x *EngineAvailability) GetAvailable() bool {
340
+
if x != nil {
341
+
return x.Available
342
+
}
343
+
return false
344
+
}
345
+
346
+
func (x *EngineAvailability) GetLoad() map[string]float64 {
347
+
if x != nil {
348
+
return x.Load
349
+
}
350
+
return nil
351
+
}
352
+
353
+
// full node state
354
+
type NodeSnapshot struct {
355
+
state protoimpl.MessageState `protogen:"open.v1"`
356
+
Seqno uint64 `protobuf:"varint,1,opt,name=seqno,proto3" json:"seqno,omitempty"`
357
+
Engines map[string]*EngineAvailability `protobuf:"bytes,2,rep,name=engines,proto3" json:"engines,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
358
+
// every lease the executor currently holds, reserved or running
359
+
ActiveLeaseIds []string `protobuf:"bytes,3,rep,name=active_lease_ids,json=activeLeaseIds,proto3" json:"active_lease_ids,omitempty"`
360
+
unknownFields protoimpl.UnknownFields
361
+
sizeCache protoimpl.SizeCache
362
+
}
363
+
364
+
func (x *NodeSnapshot) Reset() {
365
+
*x = NodeSnapshot{}
366
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[3]
367
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
368
+
ms.StoreMessageInfo(mi)
369
+
}
370
+
371
+
func (x *NodeSnapshot) String() string {
372
+
return protoimpl.X.MessageStringOf(x)
373
+
}
374
+
375
+
func (*NodeSnapshot) ProtoMessage() {}
376
+
377
+
func (x *NodeSnapshot) ProtoReflect() protoreflect.Message {
378
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[3]
379
+
if x != nil {
380
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
381
+
if ms.LoadMessageInfo() == nil {
382
+
ms.StoreMessageInfo(mi)
383
+
}
384
+
return ms
385
+
}
386
+
return mi.MessageOf(x)
387
+
}
388
+
389
+
// Deprecated: Use NodeSnapshot.ProtoReflect.Descriptor instead.
390
+
func (*NodeSnapshot) Descriptor() ([]byte, []int) {
391
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{3}
392
+
}
393
+
394
+
func (x *NodeSnapshot) GetSeqno() uint64 {
395
+
if x != nil {
396
+
return x.Seqno
397
+
}
398
+
return 0
399
+
}
400
+
401
+
func (x *NodeSnapshot) GetEngines() map[string]*EngineAvailability {
402
+
if x != nil {
403
+
return x.Engines
404
+
}
405
+
return nil
406
+
}
407
+
408
+
func (x *NodeSnapshot) GetActiveLeaseIds() []string {
409
+
if x != nil {
410
+
return x.ActiveLeaseIds
411
+
}
412
+
return nil
413
+
}
414
+
415
+
// a seat reservation, carries no secrets
416
+
type ReserveSeat struct {
417
+
state protoimpl.MessageState `protogen:"open.v1"`
418
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
419
+
TargetEngine string `protobuf:"bytes,2,opt,name=target_engine,json=targetEngine,proto3" json:"target_engine,omitempty"`
420
+
RawPipelineJson string `protobuf:"bytes,3,opt,name=raw_pipeline_json,json=rawPipelineJson,proto3" json:"raw_pipeline_json,omitempty"`
421
+
RawWorkflowJson string `protobuf:"bytes,4,opt,name=raw_workflow_json,json=rawWorkflowJson,proto3" json:"raw_workflow_json,omitempty"`
422
+
// pipeline id, the executor reconstructs the exact WorkflowId from it
423
+
Knot string `protobuf:"bytes,5,opt,name=knot,proto3" json:"knot,omitempty"`
424
+
Rkey string `protobuf:"bytes,6,opt,name=rkey,proto3" json:"rkey,omitempty"`
425
+
TtlSeconds uint32 `protobuf:"varint,7,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"`
426
+
unknownFields protoimpl.UnknownFields
427
+
sizeCache protoimpl.SizeCache
428
+
}
429
+
430
+
func (x *ReserveSeat) Reset() {
431
+
*x = ReserveSeat{}
432
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[4]
433
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
434
+
ms.StoreMessageInfo(mi)
435
+
}
436
+
437
+
func (x *ReserveSeat) String() string {
438
+
return protoimpl.X.MessageStringOf(x)
439
+
}
440
+
441
+
func (*ReserveSeat) ProtoMessage() {}
442
+
443
+
func (x *ReserveSeat) ProtoReflect() protoreflect.Message {
444
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[4]
445
+
if x != nil {
446
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
447
+
if ms.LoadMessageInfo() == nil {
448
+
ms.StoreMessageInfo(mi)
449
+
}
450
+
return ms
451
+
}
452
+
return mi.MessageOf(x)
453
+
}
454
+
455
+
// Deprecated: Use ReserveSeat.ProtoReflect.Descriptor instead.
456
+
func (*ReserveSeat) Descriptor() ([]byte, []int) {
457
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{4}
458
+
}
459
+
460
+
func (x *ReserveSeat) GetLeaseId() string {
461
+
if x != nil {
462
+
return x.LeaseId
463
+
}
464
+
return ""
465
+
}
466
+
467
+
func (x *ReserveSeat) GetTargetEngine() string {
468
+
if x != nil {
469
+
return x.TargetEngine
470
+
}
471
+
return ""
472
+
}
473
+
474
+
func (x *ReserveSeat) GetRawPipelineJson() string {
475
+
if x != nil {
476
+
return x.RawPipelineJson
477
+
}
478
+
return ""
479
+
}
480
+
481
+
func (x *ReserveSeat) GetRawWorkflowJson() string {
482
+
if x != nil {
483
+
return x.RawWorkflowJson
484
+
}
485
+
return ""
486
+
}
487
+
488
+
func (x *ReserveSeat) GetKnot() string {
489
+
if x != nil {
490
+
return x.Knot
491
+
}
492
+
return ""
493
+
}
494
+
495
+
func (x *ReserveSeat) GetRkey() string {
496
+
if x != nil {
497
+
return x.Rkey
498
+
}
499
+
return ""
500
+
}
501
+
502
+
func (x *ReserveSeat) GetTtlSeconds() uint32 {
503
+
if x != nil {
504
+
return x.TtlSeconds
505
+
}
506
+
return 0
507
+
}
508
+
509
+
type ReserveResult struct {
510
+
state protoimpl.MessageState `protogen:"open.v1"`
511
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
512
+
Accepted bool `protobuf:"varint,2,opt,name=accepted,proto3" json:"accepted,omitempty"`
513
+
RejectReason string `protobuf:"bytes,3,opt,name=reject_reason,json=rejectReason,proto3" json:"reject_reason,omitempty"`
514
+
RejectClass RejectClass `protobuf:"varint,4,opt,name=reject_class,json=rejectClass,proto3,enum=spindle.mill.v1.RejectClass" json:"reject_class,omitempty"`
515
+
unknownFields protoimpl.UnknownFields
516
+
sizeCache protoimpl.SizeCache
517
+
}
518
+
519
+
func (x *ReserveResult) Reset() {
520
+
*x = ReserveResult{}
521
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[5]
522
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
523
+
ms.StoreMessageInfo(mi)
524
+
}
525
+
526
+
func (x *ReserveResult) String() string {
527
+
return protoimpl.X.MessageStringOf(x)
528
+
}
529
+
530
+
func (*ReserveResult) ProtoMessage() {}
531
+
532
+
func (x *ReserveResult) ProtoReflect() protoreflect.Message {
533
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[5]
534
+
if x != nil {
535
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
536
+
if ms.LoadMessageInfo() == nil {
537
+
ms.StoreMessageInfo(mi)
538
+
}
539
+
return ms
540
+
}
541
+
return mi.MessageOf(x)
542
+
}
543
+
544
+
// Deprecated: Use ReserveResult.ProtoReflect.Descriptor instead.
545
+
func (*ReserveResult) Descriptor() ([]byte, []int) {
546
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{5}
547
+
}
548
+
549
+
func (x *ReserveResult) GetLeaseId() string {
550
+
if x != nil {
551
+
return x.LeaseId
552
+
}
553
+
return ""
554
+
}
555
+
556
+
func (x *ReserveResult) GetAccepted() bool {
557
+
if x != nil {
558
+
return x.Accepted
559
+
}
560
+
return false
561
+
}
562
+
563
+
func (x *ReserveResult) GetRejectReason() string {
564
+
if x != nil {
565
+
return x.RejectReason
566
+
}
567
+
return ""
568
+
}
569
+
570
+
func (x *ReserveResult) GetRejectClass() RejectClass {
571
+
if x != nil {
572
+
return x.RejectClass
573
+
}
574
+
return RejectClass_REJECT_CLASS_UNSPECIFIED
575
+
}
576
+
577
+
// a single unlocked secret
578
+
type Secret struct {
579
+
state protoimpl.MessageState `protogen:"open.v1"`
580
+
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
581
+
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
582
+
unknownFields protoimpl.UnknownFields
583
+
sizeCache protoimpl.SizeCache
584
+
}
585
+
586
+
func (x *Secret) Reset() {
587
+
*x = Secret{}
588
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[6]
589
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
590
+
ms.StoreMessageInfo(mi)
591
+
}
592
+
593
+
func (x *Secret) String() string {
594
+
return protoimpl.X.MessageStringOf(x)
595
+
}
596
+
597
+
func (*Secret) ProtoMessage() {}
598
+
599
+
func (x *Secret) ProtoReflect() protoreflect.Message {
600
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[6]
601
+
if x != nil {
602
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
603
+
if ms.LoadMessageInfo() == nil {
604
+
ms.StoreMessageInfo(mi)
605
+
}
606
+
return ms
607
+
}
608
+
return mi.MessageOf(x)
609
+
}
610
+
611
+
// Deprecated: Use Secret.ProtoReflect.Descriptor instead.
612
+
func (*Secret) Descriptor() ([]byte, []int) {
613
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{6}
614
+
}
615
+
616
+
func (x *Secret) GetKey() string {
617
+
if x != nil {
618
+
return x.Key
619
+
}
620
+
return ""
621
+
}
622
+
623
+
func (x *Secret) GetValue() string {
624
+
if x != nil {
625
+
return x.Value
626
+
}
627
+
return ""
628
+
}
629
+
630
+
// promotes a reservation to a running job and hands over the secrets
631
+
type CommitLease struct {
632
+
state protoimpl.MessageState `protogen:"open.v1"`
633
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
634
+
Secrets []*Secret `protobuf:"bytes,2,rep,name=secrets,proto3" json:"secrets,omitempty"`
635
+
unknownFields protoimpl.UnknownFields
636
+
sizeCache protoimpl.SizeCache
637
+
}
638
+
639
+
func (x *CommitLease) Reset() {
640
+
*x = CommitLease{}
641
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[7]
642
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
643
+
ms.StoreMessageInfo(mi)
644
+
}
645
+
646
+
func (x *CommitLease) String() string {
647
+
return protoimpl.X.MessageStringOf(x)
648
+
}
649
+
650
+
func (*CommitLease) ProtoMessage() {}
651
+
652
+
func (x *CommitLease) ProtoReflect() protoreflect.Message {
653
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[7]
654
+
if x != nil {
655
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
656
+
if ms.LoadMessageInfo() == nil {
657
+
ms.StoreMessageInfo(mi)
658
+
}
659
+
return ms
660
+
}
661
+
return mi.MessageOf(x)
662
+
}
663
+
664
+
// Deprecated: Use CommitLease.ProtoReflect.Descriptor instead.
665
+
func (*CommitLease) Descriptor() ([]byte, []int) {
666
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{7}
667
+
}
668
+
669
+
func (x *CommitLease) GetLeaseId() string {
670
+
if x != nil {
671
+
return x.LeaseId
672
+
}
673
+
return ""
674
+
}
675
+
676
+
func (x *CommitLease) GetSecrets() []*Secret {
677
+
if x != nil {
678
+
return x.Secrets
679
+
}
680
+
return nil
681
+
}
682
+
683
+
type Committed struct {
684
+
state protoimpl.MessageState `protogen:"open.v1"`
685
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
686
+
unknownFields protoimpl.UnknownFields
687
+
sizeCache protoimpl.SizeCache
688
+
}
689
+
690
+
func (x *Committed) Reset() {
691
+
*x = Committed{}
692
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[8]
693
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
694
+
ms.StoreMessageInfo(mi)
695
+
}
696
+
697
+
func (x *Committed) String() string {
698
+
return protoimpl.X.MessageStringOf(x)
699
+
}
700
+
701
+
func (*Committed) ProtoMessage() {}
702
+
703
+
func (x *Committed) ProtoReflect() protoreflect.Message {
704
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[8]
705
+
if x != nil {
706
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
707
+
if ms.LoadMessageInfo() == nil {
708
+
ms.StoreMessageInfo(mi)
709
+
}
710
+
return ms
711
+
}
712
+
return mi.MessageOf(x)
713
+
}
714
+
715
+
// Deprecated: Use Committed.ProtoReflect.Descriptor instead.
716
+
func (*Committed) Descriptor() ([]byte, []int) {
717
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{8}
718
+
}
719
+
720
+
func (x *Committed) GetLeaseId() string {
721
+
if x != nil {
722
+
return x.LeaseId
723
+
}
724
+
return ""
725
+
}
726
+
727
+
// drops a reservation that was never committed
728
+
type ReleaseLease struct {
729
+
state protoimpl.MessageState `protogen:"open.v1"`
730
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
731
+
unknownFields protoimpl.UnknownFields
732
+
sizeCache protoimpl.SizeCache
733
+
}
734
+
735
+
func (x *ReleaseLease) Reset() {
736
+
*x = ReleaseLease{}
737
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[9]
738
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
739
+
ms.StoreMessageInfo(mi)
740
+
}
741
+
742
+
func (x *ReleaseLease) String() string {
743
+
return protoimpl.X.MessageStringOf(x)
744
+
}
745
+
746
+
func (*ReleaseLease) ProtoMessage() {}
747
+
748
+
func (x *ReleaseLease) ProtoReflect() protoreflect.Message {
749
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[9]
750
+
if x != nil {
751
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
752
+
if ms.LoadMessageInfo() == nil {
753
+
ms.StoreMessageInfo(mi)
754
+
}
755
+
return ms
756
+
}
757
+
return mi.MessageOf(x)
758
+
}
759
+
760
+
// Deprecated: Use ReleaseLease.ProtoReflect.Descriptor instead.
761
+
func (*ReleaseLease) Descriptor() ([]byte, []int) {
762
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{9}
763
+
}
764
+
765
+
func (x *ReleaseLease) GetLeaseId() string {
766
+
if x != nil {
767
+
return x.LeaseId
768
+
}
769
+
return ""
770
+
}
771
+
772
+
// cancels a running attempt
773
+
type CancelAttempt struct {
774
+
state protoimpl.MessageState `protogen:"open.v1"`
775
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
776
+
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
777
+
unknownFields protoimpl.UnknownFields
778
+
sizeCache protoimpl.SizeCache
779
+
}
780
+
781
+
func (x *CancelAttempt) Reset() {
782
+
*x = CancelAttempt{}
783
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[10]
784
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
785
+
ms.StoreMessageInfo(mi)
786
+
}
787
+
788
+
func (x *CancelAttempt) String() string {
789
+
return protoimpl.X.MessageStringOf(x)
790
+
}
791
+
792
+
func (*CancelAttempt) ProtoMessage() {}
793
+
794
+
func (x *CancelAttempt) ProtoReflect() protoreflect.Message {
795
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[10]
796
+
if x != nil {
797
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
798
+
if ms.LoadMessageInfo() == nil {
799
+
ms.StoreMessageInfo(mi)
800
+
}
801
+
return ms
802
+
}
803
+
return mi.MessageOf(x)
804
+
}
805
+
806
+
// Deprecated: Use CancelAttempt.ProtoReflect.Descriptor instead.
807
+
func (*CancelAttempt) Descriptor() ([]byte, []int) {
808
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{10}
809
+
}
810
+
811
+
func (x *CancelAttempt) GetLeaseId() string {
812
+
if x != nil {
813
+
return x.LeaseId
814
+
}
815
+
return ""
816
+
}
817
+
818
+
func (x *CancelAttempt) GetReason() string {
819
+
if x != nil {
820
+
return x.Reason
821
+
}
822
+
return ""
823
+
}
824
+
825
+
type CancelAck struct {
826
+
state protoimpl.MessageState `protogen:"open.v1"`
827
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
828
+
unknownFields protoimpl.UnknownFields
829
+
sizeCache protoimpl.SizeCache
830
+
}
831
+
832
+
func (x *CancelAck) Reset() {
833
+
*x = CancelAck{}
834
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[11]
835
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
836
+
ms.StoreMessageInfo(mi)
837
+
}
838
+
839
+
func (x *CancelAck) String() string {
840
+
return protoimpl.X.MessageStringOf(x)
841
+
}
842
+
843
+
func (*CancelAck) ProtoMessage() {}
844
+
845
+
func (x *CancelAck) ProtoReflect() protoreflect.Message {
846
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[11]
847
+
if x != nil {
848
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
849
+
if ms.LoadMessageInfo() == nil {
850
+
ms.StoreMessageInfo(mi)
851
+
}
852
+
return ms
853
+
}
854
+
return mi.MessageOf(x)
855
+
}
856
+
857
+
// Deprecated: Use CancelAck.ProtoReflect.Descriptor instead.
858
+
func (*CancelAck) Descriptor() ([]byte, []int) {
859
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{11}
860
+
}
861
+
862
+
func (x *CancelAck) GetLeaseId() string {
863
+
if x != nil {
864
+
return x.LeaseId
865
+
}
866
+
return ""
867
+
}
868
+
869
+
type StatusEvent struct {
870
+
state protoimpl.MessageState `protogen:"open.v1"`
871
+
Status NonterminalStatus `protobuf:"varint,1,opt,name=status,proto3,enum=spindle.mill.v1.NonterminalStatus" json:"status,omitempty"`
872
+
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
873
+
ExitCode int64 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
874
+
unknownFields protoimpl.UnknownFields
875
+
sizeCache protoimpl.SizeCache
876
+
}
877
+
878
+
func (x *StatusEvent) Reset() {
879
+
*x = StatusEvent{}
880
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[12]
881
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
882
+
ms.StoreMessageInfo(mi)
883
+
}
884
+
885
+
func (x *StatusEvent) String() string {
886
+
return protoimpl.X.MessageStringOf(x)
887
+
}
888
+
889
+
func (*StatusEvent) ProtoMessage() {}
890
+
891
+
func (x *StatusEvent) ProtoReflect() protoreflect.Message {
892
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[12]
893
+
if x != nil {
894
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
895
+
if ms.LoadMessageInfo() == nil {
896
+
ms.StoreMessageInfo(mi)
897
+
}
898
+
return ms
899
+
}
900
+
return mi.MessageOf(x)
901
+
}
902
+
903
+
// Deprecated: Use StatusEvent.ProtoReflect.Descriptor instead.
904
+
func (*StatusEvent) Descriptor() ([]byte, []int) {
905
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{12}
906
+
}
907
+
908
+
func (x *StatusEvent) GetStatus() NonterminalStatus {
909
+
if x != nil {
910
+
return x.Status
911
+
}
912
+
return NonterminalStatus_NONTERMINAL_STATUS_UNSPECIFIED
913
+
}
914
+
915
+
func (x *StatusEvent) GetError() string {
916
+
if x != nil {
917
+
return x.Error
918
+
}
919
+
return ""
920
+
}
921
+
922
+
func (x *StatusEvent) GetExitCode() int64 {
923
+
if x != nil {
924
+
return x.ExitCode
925
+
}
926
+
return 0
927
+
}
928
+
929
+
type LogArtifact struct {
930
+
state protoimpl.MessageState `protogen:"open.v1"`
931
+
Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"`
932
+
Hash string `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"`
933
+
unknownFields protoimpl.UnknownFields
934
+
sizeCache protoimpl.SizeCache
935
+
}
936
+
937
+
func (x *LogArtifact) Reset() {
938
+
*x = LogArtifact{}
939
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[13]
940
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
941
+
ms.StoreMessageInfo(mi)
942
+
}
943
+
944
+
func (x *LogArtifact) String() string {
945
+
return protoimpl.X.MessageStringOf(x)
946
+
}
947
+
948
+
func (*LogArtifact) ProtoMessage() {}
949
+
950
+
func (x *LogArtifact) ProtoReflect() protoreflect.Message {
951
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[13]
952
+
if x != nil {
953
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
954
+
if ms.LoadMessageInfo() == nil {
955
+
ms.StoreMessageInfo(mi)
956
+
}
957
+
return ms
958
+
}
959
+
return mi.MessageOf(x)
960
+
}
961
+
962
+
// Deprecated: Use LogArtifact.ProtoReflect.Descriptor instead.
963
+
func (*LogArtifact) Descriptor() ([]byte, []int) {
964
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{13}
965
+
}
966
+
967
+
func (x *LogArtifact) GetRef() string {
968
+
if x != nil {
969
+
return x.Ref
970
+
}
971
+
return ""
972
+
}
973
+
974
+
func (x *LogArtifact) GetHash() string {
975
+
if x != nil {
976
+
return x.Hash
977
+
}
978
+
return ""
979
+
}
980
+
981
+
// terminal outcome of an attempt
982
+
type AttemptResult struct {
983
+
state protoimpl.MessageState `protogen:"open.v1"`
984
+
Status TerminalStatus `protobuf:"varint,1,opt,name=status,proto3,enum=spindle.mill.v1.TerminalStatus" json:"status,omitempty"`
985
+
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
986
+
ExitCode int64 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
987
+
LogArtifact *LogArtifact `protobuf:"bytes,4,opt,name=log_artifact,json=logArtifact,proto3" json:"log_artifact,omitempty"`
988
+
unknownFields protoimpl.UnknownFields
989
+
sizeCache protoimpl.SizeCache
990
+
}
991
+
992
+
func (x *AttemptResult) Reset() {
993
+
*x = AttemptResult{}
994
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[14]
995
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
996
+
ms.StoreMessageInfo(mi)
997
+
}
998
+
999
+
func (x *AttemptResult) String() string {
1000
+
return protoimpl.X.MessageStringOf(x)
1001
+
}
1002
+
1003
+
func (*AttemptResult) ProtoMessage() {}
1004
+
1005
+
func (x *AttemptResult) ProtoReflect() protoreflect.Message {
1006
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[14]
1007
+
if x != nil {
1008
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1009
+
if ms.LoadMessageInfo() == nil {
1010
+
ms.StoreMessageInfo(mi)
1011
+
}
1012
+
return ms
1013
+
}
1014
+
return mi.MessageOf(x)
1015
+
}
1016
+
1017
+
// Deprecated: Use AttemptResult.ProtoReflect.Descriptor instead.
1018
+
func (*AttemptResult) Descriptor() ([]byte, []int) {
1019
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{14}
1020
+
}
1021
+
1022
+
func (x *AttemptResult) GetStatus() TerminalStatus {
1023
+
if x != nil {
1024
+
return x.Status
1025
+
}
1026
+
return TerminalStatus_TERMINAL_STATUS_UNSPECIFIED
1027
+
}
1028
+
1029
+
func (x *AttemptResult) GetError() string {
1030
+
if x != nil {
1031
+
return x.Error
1032
+
}
1033
+
return ""
1034
+
}
1035
+
1036
+
func (x *AttemptResult) GetExitCode() int64 {
1037
+
if x != nil {
1038
+
return x.ExitCode
1039
+
}
1040
+
return 0
1041
+
}
1042
+
1043
+
func (x *AttemptResult) GetLogArtifact() *LogArtifact {
1044
+
if x != nil {
1045
+
return x.LogArtifact
1046
+
}
1047
+
return nil
1048
+
}
1049
+
1050
+
// live non-replay log frame
1051
+
type LiveLog struct {
1052
+
state protoimpl.MessageState `protogen:"open.v1"`
1053
+
LeaseId string `protobuf:"bytes,1,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
1054
+
RawJson []byte `protobuf:"bytes,2,opt,name=raw_json,json=rawJson,proto3" json:"raw_json,omitempty"`
1055
+
unknownFields protoimpl.UnknownFields
1056
+
sizeCache protoimpl.SizeCache
1057
+
}
1058
+
1059
+
func (x *LiveLog) Reset() {
1060
+
*x = LiveLog{}
1061
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[15]
1062
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1063
+
ms.StoreMessageInfo(mi)
1064
+
}
1065
+
1066
+
func (x *LiveLog) String() string {
1067
+
return protoimpl.X.MessageStringOf(x)
1068
+
}
1069
+
1070
+
func (*LiveLog) ProtoMessage() {}
1071
+
1072
+
func (x *LiveLog) ProtoReflect() protoreflect.Message {
1073
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[15]
1074
+
if x != nil {
1075
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1076
+
if ms.LoadMessageInfo() == nil {
1077
+
ms.StoreMessageInfo(mi)
1078
+
}
1079
+
return ms
1080
+
}
1081
+
return mi.MessageOf(x)
1082
+
}
1083
+
1084
+
// Deprecated: Use LiveLog.ProtoReflect.Descriptor instead.
1085
+
func (*LiveLog) Descriptor() ([]byte, []int) {
1086
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{15}
1087
+
}
1088
+
1089
+
func (x *LiveLog) GetLeaseId() string {
1090
+
if x != nil {
1091
+
return x.LeaseId
1092
+
}
1093
+
return ""
1094
+
}
1095
+
1096
+
func (x *LiveLog) GetRawJson() []byte {
1097
+
if x != nil {
1098
+
return x.RawJson
1099
+
}
1100
+
return nil
1101
+
}
1102
+
1103
+
// one event in the executor's stream back to the mill
1104
+
type Event struct {
1105
+
state protoimpl.MessageState `protogen:"open.v1"`
1106
+
Seqno uint64 `protobuf:"varint,1,opt,name=seqno,proto3" json:"seqno,omitempty"`
1107
+
LeaseId string `protobuf:"bytes,2,opt,name=lease_id,json=leaseId,proto3" json:"lease_id,omitempty"`
1108
+
// Types that are valid to be assigned to Payload:
1109
+
//
1110
+
// *Event_StatusEvent
1111
+
// *Event_AttemptResult
1112
+
Payload isEvent_Payload `protobuf_oneof:"payload"`
1113
+
unknownFields protoimpl.UnknownFields
1114
+
sizeCache protoimpl.SizeCache
1115
+
}
1116
+
1117
+
func (x *Event) Reset() {
1118
+
*x = Event{}
1119
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[16]
1120
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1121
+
ms.StoreMessageInfo(mi)
1122
+
}
1123
+
1124
+
func (x *Event) String() string {
1125
+
return protoimpl.X.MessageStringOf(x)
1126
+
}
1127
+
1128
+
func (*Event) ProtoMessage() {}
1129
+
1130
+
func (x *Event) ProtoReflect() protoreflect.Message {
1131
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[16]
1132
+
if x != nil {
1133
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1134
+
if ms.LoadMessageInfo() == nil {
1135
+
ms.StoreMessageInfo(mi)
1136
+
}
1137
+
return ms
1138
+
}
1139
+
return mi.MessageOf(x)
1140
+
}
1141
+
1142
+
// Deprecated: Use Event.ProtoReflect.Descriptor instead.
1143
+
func (*Event) Descriptor() ([]byte, []int) {
1144
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{16}
1145
+
}
1146
+
1147
+
func (x *Event) GetSeqno() uint64 {
1148
+
if x != nil {
1149
+
return x.Seqno
1150
+
}
1151
+
return 0
1152
+
}
1153
+
1154
+
func (x *Event) GetLeaseId() string {
1155
+
if x != nil {
1156
+
return x.LeaseId
1157
+
}
1158
+
return ""
1159
+
}
1160
+
1161
+
func (x *Event) GetPayload() isEvent_Payload {
1162
+
if x != nil {
1163
+
return x.Payload
1164
+
}
1165
+
return nil
1166
+
}
1167
+
1168
+
func (x *Event) GetStatusEvent() *StatusEvent {
1169
+
if x != nil {
1170
+
if x, ok := x.Payload.(*Event_StatusEvent); ok {
1171
+
return x.StatusEvent
1172
+
}
1173
+
}
1174
+
return nil
1175
+
}
1176
+
1177
+
func (x *Event) GetAttemptResult() *AttemptResult {
1178
+
if x != nil {
1179
+
if x, ok := x.Payload.(*Event_AttemptResult); ok {
1180
+
return x.AttemptResult
1181
+
}
1182
+
}
1183
+
return nil
1184
+
}
1185
+
1186
+
type isEvent_Payload interface {
1187
+
isEvent_Payload()
1188
+
}
1189
+
1190
+
type Event_StatusEvent struct {
1191
+
StatusEvent *StatusEvent `protobuf:"bytes,3,opt,name=status_event,json=statusEvent,proto3,oneof"`
1192
+
}
1193
+
1194
+
type Event_AttemptResult struct {
1195
+
AttemptResult *AttemptResult `protobuf:"bytes,4,opt,name=attempt_result,json=attemptResult,proto3,oneof"`
1196
+
}
1197
+
1198
+
func (*Event_StatusEvent) isEvent_Payload() {}
1199
+
1200
+
func (*Event_AttemptResult) isEvent_Payload() {}
1201
+
1202
+
// a flushed bundle of events
1203
+
type EventBatch struct {
1204
+
state protoimpl.MessageState `protogen:"open.v1"`
1205
+
Epoch string `protobuf:"bytes,1,opt,name=epoch,proto3" json:"epoch,omitempty"`
1206
+
Events []*Event `protobuf:"bytes,2,rep,name=events,proto3" json:"events,omitempty"`
1207
+
unknownFields protoimpl.UnknownFields
1208
+
sizeCache protoimpl.SizeCache
1209
+
}
1210
+
1211
+
func (x *EventBatch) Reset() {
1212
+
*x = EventBatch{}
1213
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[17]
1214
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1215
+
ms.StoreMessageInfo(mi)
1216
+
}
1217
+
1218
+
func (x *EventBatch) String() string {
1219
+
return protoimpl.X.MessageStringOf(x)
1220
+
}
1221
+
1222
+
func (*EventBatch) ProtoMessage() {}
1223
+
1224
+
func (x *EventBatch) ProtoReflect() protoreflect.Message {
1225
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[17]
1226
+
if x != nil {
1227
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1228
+
if ms.LoadMessageInfo() == nil {
1229
+
ms.StoreMessageInfo(mi)
1230
+
}
1231
+
return ms
1232
+
}
1233
+
return mi.MessageOf(x)
1234
+
}
1235
+
1236
+
// Deprecated: Use EventBatch.ProtoReflect.Descriptor instead.
1237
+
func (*EventBatch) Descriptor() ([]byte, []int) {
1238
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{17}
1239
+
}
1240
+
1241
+
func (x *EventBatch) GetEpoch() string {
1242
+
if x != nil {
1243
+
return x.Epoch
1244
+
}
1245
+
return ""
1246
+
}
1247
+
1248
+
func (x *EventBatch) GetEvents() []*Event {
1249
+
if x != nil {
1250
+
return x.Events
1251
+
}
1252
+
return nil
1253
+
}
1254
+
1255
+
// all events up to and including up_to_seqno are durably processed
1256
+
type Ack struct {
1257
+
state protoimpl.MessageState `protogen:"open.v1"`
1258
+
Epoch string `protobuf:"bytes,1,opt,name=epoch,proto3" json:"epoch,omitempty"`
1259
+
UpToSeqno uint64 `protobuf:"varint,2,opt,name=up_to_seqno,json=upToSeqno,proto3" json:"up_to_seqno,omitempty"`
1260
+
unknownFields protoimpl.UnknownFields
1261
+
sizeCache protoimpl.SizeCache
1262
+
}
1263
+
1264
+
func (x *Ack) Reset() {
1265
+
*x = Ack{}
1266
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[18]
1267
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1268
+
ms.StoreMessageInfo(mi)
1269
+
}
1270
+
1271
+
func (x *Ack) String() string {
1272
+
return protoimpl.X.MessageStringOf(x)
1273
+
}
1274
+
1275
+
func (*Ack) ProtoMessage() {}
1276
+
1277
+
func (x *Ack) ProtoReflect() protoreflect.Message {
1278
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[18]
1279
+
if x != nil {
1280
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1281
+
if ms.LoadMessageInfo() == nil {
1282
+
ms.StoreMessageInfo(mi)
1283
+
}
1284
+
return ms
1285
+
}
1286
+
return mi.MessageOf(x)
1287
+
}
1288
+
1289
+
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
1290
+
func (*Ack) Descriptor() ([]byte, []int) {
1291
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{18}
1292
+
}
1293
+
1294
+
func (x *Ack) GetEpoch() string {
1295
+
if x != nil {
1296
+
return x.Epoch
1297
+
}
1298
+
return ""
1299
+
}
1300
+
1301
+
func (x *Ack) GetUpToSeqno() uint64 {
1302
+
if x != nil {
1303
+
return x.UpToSeqno
1304
+
}
1305
+
return 0
1306
+
}
1307
+
1308
+
type Message struct {
1309
+
state protoimpl.MessageState `protogen:"open.v1"`
1310
+
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3" json:"hello,omitempty"`
1311
+
Resume *Resume `protobuf:"bytes,2,opt,name=resume,proto3" json:"resume,omitempty"`
1312
+
NodeSnapshot *NodeSnapshot `protobuf:"bytes,3,opt,name=node_snapshot,json=nodeSnapshot,proto3" json:"node_snapshot,omitempty"`
1313
+
ReserveSeat *ReserveSeat `protobuf:"bytes,4,opt,name=reserve_seat,json=reserveSeat,proto3" json:"reserve_seat,omitempty"`
1314
+
ReserveResult *ReserveResult `protobuf:"bytes,5,opt,name=reserve_result,json=reserveResult,proto3" json:"reserve_result,omitempty"`
1315
+
CommitLease *CommitLease `protobuf:"bytes,6,opt,name=commit_lease,json=commitLease,proto3" json:"commit_lease,omitempty"`
1316
+
Committed *Committed `protobuf:"bytes,7,opt,name=committed,proto3" json:"committed,omitempty"`
1317
+
ReleaseLease *ReleaseLease `protobuf:"bytes,8,opt,name=release_lease,json=releaseLease,proto3" json:"release_lease,omitempty"`
1318
+
CancelAttempt *CancelAttempt `protobuf:"bytes,9,opt,name=cancel_attempt,json=cancelAttempt,proto3" json:"cancel_attempt,omitempty"`
1319
+
CancelAck *CancelAck `protobuf:"bytes,10,opt,name=cancel_ack,json=cancelAck,proto3" json:"cancel_ack,omitempty"`
1320
+
EventBatch *EventBatch `protobuf:"bytes,11,opt,name=event_batch,json=eventBatch,proto3" json:"event_batch,omitempty"`
1321
+
Ack *Ack `protobuf:"bytes,12,opt,name=ack,proto3" json:"ack,omitempty"`
1322
+
LiveLog *LiveLog `protobuf:"bytes,13,opt,name=live_log,json=liveLog,proto3" json:"live_log,omitempty"`
1323
+
unknownFields protoimpl.UnknownFields
1324
+
sizeCache protoimpl.SizeCache
1325
+
}
1326
+
1327
+
func (x *Message) Reset() {
1328
+
*x = Message{}
1329
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[19]
1330
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1331
+
ms.StoreMessageInfo(mi)
1332
+
}
1333
+
1334
+
func (x *Message) String() string {
1335
+
return protoimpl.X.MessageStringOf(x)
1336
+
}
1337
+
1338
+
func (*Message) ProtoMessage() {}
1339
+
1340
+
func (x *Message) ProtoReflect() protoreflect.Message {
1341
+
mi := &file_spindle_mill_v1_mill_proto_msgTypes[19]
1342
+
if x != nil {
1343
+
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1344
+
if ms.LoadMessageInfo() == nil {
1345
+
ms.StoreMessageInfo(mi)
1346
+
}
1347
+
return ms
1348
+
}
1349
+
return mi.MessageOf(x)
1350
+
}
1351
+
1352
+
// Deprecated: Use Message.ProtoReflect.Descriptor instead.
1353
+
func (*Message) Descriptor() ([]byte, []int) {
1354
+
return file_spindle_mill_v1_mill_proto_rawDescGZIP(), []int{19}
1355
+
}
1356
+
1357
+
func (x *Message) GetHello() *Hello {
1358
+
if x != nil {
1359
+
return x.Hello
1360
+
}
1361
+
return nil
1362
+
}
1363
+
1364
+
func (x *Message) GetResume() *Resume {
1365
+
if x != nil {
1366
+
return x.Resume
1367
+
}
1368
+
return nil
1369
+
}
1370
+
1371
+
func (x *Message) GetNodeSnapshot() *NodeSnapshot {
1372
+
if x != nil {
1373
+
return x.NodeSnapshot
1374
+
}
1375
+
return nil
1376
+
}
1377
+
1378
+
func (x *Message) GetReserveSeat() *ReserveSeat {
1379
+
if x != nil {
1380
+
return x.ReserveSeat
1381
+
}
1382
+
return nil
1383
+
}
1384
+
1385
+
func (x *Message) GetReserveResult() *ReserveResult {
1386
+
if x != nil {
1387
+
return x.ReserveResult
1388
+
}
1389
+
return nil
1390
+
}
1391
+
1392
+
func (x *Message) GetCommitLease() *CommitLease {
1393
+
if x != nil {
1394
+
return x.CommitLease
1395
+
}
1396
+
return nil
1397
+
}
1398
+
1399
+
func (x *Message) GetCommitted() *Committed {
1400
+
if x != nil {
1401
+
return x.Committed
1402
+
}
1403
+
return nil
1404
+
}
1405
+
1406
+
func (x *Message) GetReleaseLease() *ReleaseLease {
1407
+
if x != nil {
1408
+
return x.ReleaseLease
1409
+
}
1410
+
return nil
1411
+
}
1412
+
1413
+
func (x *Message) GetCancelAttempt() *CancelAttempt {
1414
+
if x != nil {
1415
+
return x.CancelAttempt
1416
+
}
1417
+
return nil
1418
+
}
1419
+
1420
+
func (x *Message) GetCancelAck() *CancelAck {
1421
+
if x != nil {
1422
+
return x.CancelAck
1423
+
}
1424
+
return nil
1425
+
}
1426
+
1427
+
func (x *Message) GetEventBatch() *EventBatch {
1428
+
if x != nil {
1429
+
return x.EventBatch
1430
+
}
1431
+
return nil
1432
+
}
1433
+
1434
+
func (x *Message) GetAck() *Ack {
1435
+
if x != nil {
1436
+
return x.Ack
1437
+
}
1438
+
return nil
1439
+
}
1440
+
1441
+
func (x *Message) GetLiveLog() *LiveLog {
1442
+
if x != nil {
1443
+
return x.LiveLog
1444
+
}
1445
+
return nil
1446
+
}
1447
+
1448
+
var File_spindle_mill_v1_mill_proto protoreflect.FileDescriptor
1449
+
1450
+
const file_spindle_mill_v1_mill_proto_rawDesc = "" +
1451
+
"\n" +
1452
+
"\x1aspindle/mill/v1/mill.proto\x12\x0fspindle.mill.v1\x1a\x1bbuf/validate/validate.proto\"}\n" +
1453
+
"\x05Hello\x12)\n" +
1454
+
"\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x12\x12\n" +
1455
+
"\x04arch\x18\x02 \x01(\tR\x04arch\x12\x16\n" +
1456
+
"\x06labels\x18\x03 \x03(\tR\x06labels\x12\x1d\n" +
1457
+
"\x05epoch\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x05epoch\"D\n" +
1458
+
"\x06Resume\x12\x1d\n" +
1459
+
"\x05epoch\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x05epoch\x12\x1b\n" +
1460
+
"\tack_seqno\x18\x02 \x01(\x04R\backSeqno\"\xae\x01\n" +
1461
+
"\x12EngineAvailability\x12\x1c\n" +
1462
+
"\tavailable\x18\x01 \x01(\bR\tavailable\x12A\n" +
1463
+
"\x04load\x18\x02 \x03(\v2-.spindle.mill.v1.EngineAvailability.LoadEntryR\x04load\x1a7\n" +
1464
+
"\tLoadEntry\x12\x10\n" +
1465
+
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1466
+
"\x05value\x18\x02 \x01(\x01R\x05value:\x028\x01\"\xfe\x01\n" +
1467
+
"\fNodeSnapshot\x12\x1d\n" +
1468
+
"\x05seqno\x18\x01 \x01(\x04B\a\xbaH\x042\x02 \x00R\x05seqno\x12D\n" +
1469
+
"\aengines\x18\x02 \x03(\v2*.spindle.mill.v1.NodeSnapshot.EnginesEntryR\aengines\x12(\n" +
1470
+
"\x10active_lease_ids\x18\x03 \x03(\tR\x0eactiveLeaseIds\x1a_\n" +
1471
+
"\fEnginesEntry\x12\x10\n" +
1472
+
"\x03key\x18\x01 \x01(\tR\x03key\x129\n" +
1473
+
"\x05value\x18\x02 \x01(\v2#.spindle.mill.v1.EngineAvailabilityR\x05value:\x028\x01\"\x80\x02\n" +
1474
+
"\vReserveSeat\x12\"\n" +
1475
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12,\n" +
1476
+
"\rtarget_engine\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\ftargetEngine\x12*\n" +
1477
+
"\x11raw_pipeline_json\x18\x03 \x01(\tR\x0frawPipelineJson\x12*\n" +
1478
+
"\x11raw_workflow_json\x18\x04 \x01(\tR\x0frawWorkflowJson\x12\x12\n" +
1479
+
"\x04knot\x18\x05 \x01(\tR\x04knot\x12\x12\n" +
1480
+
"\x04rkey\x18\x06 \x01(\tR\x04rkey\x12\x1f\n" +
1481
+
"\vttl_seconds\x18\a \x01(\rR\n" +
1482
+
"ttlSeconds\"\xbf\x01\n" +
1483
+
"\rReserveResult\x12\"\n" +
1484
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12\x1a\n" +
1485
+
"\baccepted\x18\x02 \x01(\bR\baccepted\x12#\n" +
1486
+
"\rreject_reason\x18\x03 \x01(\tR\frejectReason\x12I\n" +
1487
+
"\freject_class\x18\x04 \x01(\x0e2\x1c.spindle.mill.v1.RejectClassB\b\xbaH\x05\x82\x01\x02\x10\x01R\vrejectClass\"0\n" +
1488
+
"\x06Secret\x12\x10\n" +
1489
+
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1490
+
"\x05value\x18\x02 \x01(\tR\x05value\"d\n" +
1491
+
"\vCommitLease\x12\"\n" +
1492
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x121\n" +
1493
+
"\asecrets\x18\x02 \x03(\v2\x17.spindle.mill.v1.SecretR\asecrets\"/\n" +
1494
+
"\tCommitted\x12\"\n" +
1495
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\"2\n" +
1496
+
"\fReleaseLease\x12\"\n" +
1497
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\"K\n" +
1498
+
"\rCancelAttempt\x12\"\n" +
1499
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12\x16\n" +
1500
+
"\x06reason\x18\x02 \x01(\tR\x06reason\"/\n" +
1501
+
"\tCancelAck\x12\"\n" +
1502
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\"\x93\x01\n" +
1503
+
"\vStatusEvent\x12F\n" +
1504
+
"\x06status\x18\x01 \x01(\x0e2\".spindle.mill.v1.NonterminalStatusB\n" +
1505
+
"\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06status\x12\x1f\n" +
1506
+
"\x05error\x18\x02 \x01(\tB\t\xbaH\x06r\x04(\x80\x80\x04R\x05error\x12\x1b\n" +
1507
+
"\texit_code\x18\x03 \x01(\x03R\bexitCode\"E\n" +
1508
+
"\vLogArtifact\x12\x19\n" +
1509
+
"\x03ref\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03ref\x12\x1b\n" +
1510
+
"\x04hash\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04hash\"\xd3\x01\n" +
1511
+
"\rAttemptResult\x12C\n" +
1512
+
"\x06status\x18\x01 \x01(\x0e2\x1f.spindle.mill.v1.TerminalStatusB\n" +
1513
+
"\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06status\x12\x1f\n" +
1514
+
"\x05error\x18\x02 \x01(\tB\t\xbaH\x06r\x04(\x80\x80\x04R\x05error\x12\x1b\n" +
1515
+
"\texit_code\x18\x03 \x01(\x03R\bexitCode\x12?\n" +
1516
+
"\flog_artifact\x18\x04 \x01(\v2\x1c.spindle.mill.v1.LogArtifactR\vlogArtifact\"Q\n" +
1517
+
"\aLiveLog\x12\"\n" +
1518
+
"\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12\"\n" +
1519
+
"\braw_json\x18\x02 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\arawJson\"\xe8\x01\n" +
1520
+
"\x05Event\x12\x1d\n" +
1521
+
"\x05seqno\x18\x01 \x01(\x04B\a\xbaH\x042\x02 \x00R\x05seqno\x12\"\n" +
1522
+
"\blease_id\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12A\n" +
1523
+
"\fstatus_event\x18\x03 \x01(\v2\x1c.spindle.mill.v1.StatusEventH\x00R\vstatusEvent\x12G\n" +
1524
+
"\x0eattempt_result\x18\x04 \x01(\v2\x1e.spindle.mill.v1.AttemptResultH\x00R\rattemptResultB\x10\n" +
1525
+
"\apayload\x12\x05\xbaH\x02\b\x01\"e\n" +
1526
+
"\n" +
1527
+
"EventBatch\x12\x1d\n" +
1528
+
"\x05epoch\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x05epoch\x128\n" +
1529
+
"\x06events\x18\x02 \x03(\v2\x16.spindle.mill.v1.EventB\b\xbaH\x05\x92\x01\x02\b\x01R\x06events\"D\n" +
1530
+
"\x03Ack\x12\x1d\n" +
1531
+
"\x05epoch\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x05epoch\x12\x1e\n" +
1532
+
"\vup_to_seqno\x18\x02 \x01(\x04R\tupToSeqno\"\xb8\a\n" +
1533
+
"\aMessage\x12,\n" +
1534
+
"\x05hello\x18\x01 \x01(\v2\x16.spindle.mill.v1.HelloR\x05hello\x12/\n" +
1535
+
"\x06resume\x18\x02 \x01(\v2\x17.spindle.mill.v1.ResumeR\x06resume\x12B\n" +
1536
+
"\rnode_snapshot\x18\x03 \x01(\v2\x1d.spindle.mill.v1.NodeSnapshotR\fnodeSnapshot\x12?\n" +
1537
+
"\freserve_seat\x18\x04 \x01(\v2\x1c.spindle.mill.v1.ReserveSeatR\vreserveSeat\x12E\n" +
1538
+
"\x0ereserve_result\x18\x05 \x01(\v2\x1e.spindle.mill.v1.ReserveResultR\rreserveResult\x12?\n" +
1539
+
"\fcommit_lease\x18\x06 \x01(\v2\x1c.spindle.mill.v1.CommitLeaseR\vcommitLease\x128\n" +
1540
+
"\tcommitted\x18\a \x01(\v2\x1a.spindle.mill.v1.CommittedR\tcommitted\x12B\n" +
1541
+
"\rrelease_lease\x18\b \x01(\v2\x1d.spindle.mill.v1.ReleaseLeaseR\freleaseLease\x12E\n" +
1542
+
"\x0ecancel_attempt\x18\t \x01(\v2\x1e.spindle.mill.v1.CancelAttemptR\rcancelAttempt\x129\n" +
1543
+
"\n" +
1544
+
"cancel_ack\x18\n" +
1545
+
" \x01(\v2\x1a.spindle.mill.v1.CancelAckR\tcancelAck\x12<\n" +
1546
+
"\vevent_batch\x18\v \x01(\v2\x1b.spindle.mill.v1.EventBatchR\n" +
1547
+
"eventBatch\x12&\n" +
1548
+
"\x03ack\x18\f \x01(\v2\x14.spindle.mill.v1.AckR\x03ack\x123\n" +
1549
+
"\blive_log\x18\r \x01(\v2\x18.spindle.mill.v1.LiveLogR\aliveLog:\xa5\x01\xbaH\xa1\x01\"\x9e\x01\n" +
1550
+
"\x05hello\n" +
1551
+
"\x06resume\n" +
1552
+
"\rnode_snapshot\n" +
1553
+
"\freserve_seat\n" +
1554
+
"\x0ereserve_result\n" +
1555
+
"\fcommit_lease\n" +
1556
+
"\tcommitted\n" +
1557
+
"\rrelease_lease\n" +
1558
+
"\x0ecancel_attempt\n" +
1559
+
"\n" +
1560
+
"cancel_ack\n" +
1561
+
"\vevent_batch\n" +
1562
+
"\x03ack\n" +
1563
+
"\blive_log\x10\x01*f\n" +
1564
+
"\vRejectClass\x12\x1c\n" +
1565
+
"\x18REJECT_CLASS_UNSPECIFIED\x10\x00\x12\x1a\n" +
1566
+
"\x16REJECT_CLASS_TRANSIENT\x10\x01\x12\x1d\n" +
1567
+
"\x19REJECT_CLASS_INCOMPATIBLE\x10\x02*D\n" +
1568
+
"\x11NonterminalStatus\x12\"\n" +
1569
+
"\x1eNONTERMINAL_STATUS_UNSPECIFIED\x10\x00\x12\v\n" +
1570
+
"\aRUNNING\x10\x01*f\n" +
1571
+
"\x0eTerminalStatus\x12\x1f\n" +
1572
+
"\x1bTERMINAL_STATUS_UNSPECIFIED\x10\x00\x12\v\n" +
1573
+
"\aSUCCESS\x10\x01\x12\n" +
1574
+
"\n" +
1575
+
"\x06FAILED\x10\x02\x12\v\n" +
1576
+
"\aTIMEOUT\x10\x03\x12\r\n" +
1577
+
"\tCANCELLED\x10\x04B0Z.tangled.org/core/spindle/mill/proto/gen;millv1b\x06proto3"
1578
+
1579
+
var (
1580
+
file_spindle_mill_v1_mill_proto_rawDescOnce sync.Once
1581
+
file_spindle_mill_v1_mill_proto_rawDescData []byte
1582
+
)
1583
+
1584
+
func file_spindle_mill_v1_mill_proto_rawDescGZIP() []byte {
1585
+
file_spindle_mill_v1_mill_proto_rawDescOnce.Do(func() {
1586
+
file_spindle_mill_v1_mill_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_spindle_mill_v1_mill_proto_rawDesc), len(file_spindle_mill_v1_mill_proto_rawDesc)))
1587
+
})
1588
+
return file_spindle_mill_v1_mill_proto_rawDescData
1589
+
}
1590
+
1591
+
var file_spindle_mill_v1_mill_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
1592
+
var file_spindle_mill_v1_mill_proto_msgTypes = make([]protoimpl.MessageInfo, 22)
1593
+
var file_spindle_mill_v1_mill_proto_goTypes = []any{
1594
+
(RejectClass)(0), // 0: spindle.mill.v1.RejectClass
1595
+
(NonterminalStatus)(0), // 1: spindle.mill.v1.NonterminalStatus
1596
+
(TerminalStatus)(0), // 2: spindle.mill.v1.TerminalStatus
1597
+
(*Hello)(nil), // 3: spindle.mill.v1.Hello
1598
+
(*Resume)(nil), // 4: spindle.mill.v1.Resume
1599
+
(*EngineAvailability)(nil), // 5: spindle.mill.v1.EngineAvailability
1600
+
(*NodeSnapshot)(nil), // 6: spindle.mill.v1.NodeSnapshot
1601
+
(*ReserveSeat)(nil), // 7: spindle.mill.v1.ReserveSeat
1602
+
(*ReserveResult)(nil), // 8: spindle.mill.v1.ReserveResult
1603
+
(*Secret)(nil), // 9: spindle.mill.v1.Secret
1604
+
(*CommitLease)(nil), // 10: spindle.mill.v1.CommitLease
1605
+
(*Committed)(nil), // 11: spindle.mill.v1.Committed
1606
+
(*ReleaseLease)(nil), // 12: spindle.mill.v1.ReleaseLease
1607
+
(*CancelAttempt)(nil), // 13: spindle.mill.v1.CancelAttempt
1608
+
(*CancelAck)(nil), // 14: spindle.mill.v1.CancelAck
1609
+
(*StatusEvent)(nil), // 15: spindle.mill.v1.StatusEvent
1610
+
(*LogArtifact)(nil), // 16: spindle.mill.v1.LogArtifact
1611
+
(*AttemptResult)(nil), // 17: spindle.mill.v1.AttemptResult
1612
+
(*LiveLog)(nil), // 18: spindle.mill.v1.LiveLog
1613
+
(*Event)(nil), // 19: spindle.mill.v1.Event
1614
+
(*EventBatch)(nil), // 20: spindle.mill.v1.EventBatch
1615
+
(*Ack)(nil), // 21: spindle.mill.v1.Ack
1616
+
(*Message)(nil), // 22: spindle.mill.v1.Message
1617
+
nil, // 23: spindle.mill.v1.EngineAvailability.LoadEntry
1618
+
nil, // 24: spindle.mill.v1.NodeSnapshot.EnginesEntry
1619
+
}
1620
+
var file_spindle_mill_v1_mill_proto_depIdxs = []int32{
1621
+
23, // 0: spindle.mill.v1.EngineAvailability.load:type_name -> spindle.mill.v1.EngineAvailability.LoadEntry
1622
+
24, // 1: spindle.mill.v1.NodeSnapshot.engines:type_name -> spindle.mill.v1.NodeSnapshot.EnginesEntry
1623
+
0, // 2: spindle.mill.v1.ReserveResult.reject_class:type_name -> spindle.mill.v1.RejectClass
1624
+
9, // 3: spindle.mill.v1.CommitLease.secrets:type_name -> spindle.mill.v1.Secret
1625
+
1, // 4: spindle.mill.v1.StatusEvent.status:type_name -> spindle.mill.v1.NonterminalStatus
1626
+
2, // 5: spindle.mill.v1.AttemptResult.status:type_name -> spindle.mill.v1.TerminalStatus
1627
+
16, // 6: spindle.mill.v1.AttemptResult.log_artifact:type_name -> spindle.mill.v1.LogArtifact
1628
+
15, // 7: spindle.mill.v1.Event.status_event:type_name -> spindle.mill.v1.StatusEvent
1629
+
17, // 8: spindle.mill.v1.Event.attempt_result:type_name -> spindle.mill.v1.AttemptResult
1630
+
19, // 9: spindle.mill.v1.EventBatch.events:type_name -> spindle.mill.v1.Event
1631
+
3, // 10: spindle.mill.v1.Message.hello:type_name -> spindle.mill.v1.Hello
1632
+
4, // 11: spindle.mill.v1.Message.resume:type_name -> spindle.mill.v1.Resume
1633
+
6, // 12: spindle.mill.v1.Message.node_snapshot:type_name -> spindle.mill.v1.NodeSnapshot
1634
+
7, // 13: spindle.mill.v1.Message.reserve_seat:type_name -> spindle.mill.v1.ReserveSeat
1635
+
8, // 14: spindle.mill.v1.Message.reserve_result:type_name -> spindle.mill.v1.ReserveResult
1636
+
10, // 15: spindle.mill.v1.Message.commit_lease:type_name -> spindle.mill.v1.CommitLease
1637
+
11, // 16: spindle.mill.v1.Message.committed:type_name -> spindle.mill.v1.Committed
1638
+
12, // 17: spindle.mill.v1.Message.release_lease:type_name -> spindle.mill.v1.ReleaseLease
1639
+
13, // 18: spindle.mill.v1.Message.cancel_attempt:type_name -> spindle.mill.v1.CancelAttempt
1640
+
14, // 19: spindle.mill.v1.Message.cancel_ack:type_name -> spindle.mill.v1.CancelAck
1641
+
20, // 20: spindle.mill.v1.Message.event_batch:type_name -> spindle.mill.v1.EventBatch
1642
+
21, // 21: spindle.mill.v1.Message.ack:type_name -> spindle.mill.v1.Ack
1643
+
18, // 22: spindle.mill.v1.Message.live_log:type_name -> spindle.mill.v1.LiveLog
1644
+
5, // 23: spindle.mill.v1.NodeSnapshot.EnginesEntry.value:type_name -> spindle.mill.v1.EngineAvailability
1645
+
24, // [24:24] is the sub-list for method output_type
1646
+
24, // [24:24] is the sub-list for method input_type
1647
+
24, // [24:24] is the sub-list for extension type_name
1648
+
24, // [24:24] is the sub-list for extension extendee
1649
+
0, // [0:24] is the sub-list for field type_name
1650
+
}
1651
+
1652
+
func init() { file_spindle_mill_v1_mill_proto_init() }
1653
+
func file_spindle_mill_v1_mill_proto_init() {
1654
+
if File_spindle_mill_v1_mill_proto != nil {
1655
+
return
1656
+
}
1657
+
file_spindle_mill_v1_mill_proto_msgTypes[16].OneofWrappers = []any{
1658
+
(*Event_StatusEvent)(nil),
1659
+
(*Event_AttemptResult)(nil),
1660
+
}
1661
+
type x struct{}
1662
+
out := protoimpl.TypeBuilder{
1663
+
File: protoimpl.DescBuilder{
1664
+
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
1665
+
RawDescriptor: unsafe.Slice(unsafe.StringData(file_spindle_mill_v1_mill_proto_rawDesc), len(file_spindle_mill_v1_mill_proto_rawDesc)),
1666
+
NumEnums: 3,
1667
+
NumMessages: 22,
1668
+
NumExtensions: 0,
1669
+
NumServices: 0,
1670
+
},
1671
+
GoTypes: file_spindle_mill_v1_mill_proto_goTypes,
1672
+
DependencyIndexes: file_spindle_mill_v1_mill_proto_depIdxs,
1673
+
EnumInfos: file_spindle_mill_v1_mill_proto_enumTypes,
1674
+
MessageInfos: file_spindle_mill_v1_mill_proto_msgTypes,
1675
+
}.Build()
1676
+
File_spindle_mill_v1_mill_proto = out.File
1677
+
file_spindle_mill_v1_mill_proto_goTypes = nil
1678
+
file_spindle_mill_v1_mill_proto_depIdxs = nil
1679
+
}
+103
spindle/mill/proto/protocol.go
+103
spindle/mill/proto/protocol.go
···
1
+
// package millproto carries the mill<->executor session protocol. a new message
2
+
// vocabulary over the same length-prefixed protobuf framing the spindle already
3
+
// uses to talk to the microVM guest (see spindle/agentproto). only the framing
4
+
// pattern is shared. the messages are entirely separate
5
+
package millproto
6
+
7
+
import (
8
+
"encoding/binary"
9
+
"fmt"
10
+
"io"
11
+
"sync"
12
+
13
+
"buf.build/go/protovalidate"
14
+
"google.golang.org/protobuf/proto"
15
+
16
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
17
+
)
18
+
19
+
const (
20
+
ProtocolVersion = 1
21
+
// generous vs agentproto's 1 MiB. a ReserveSeat carries the raw pipeline and
22
+
// workflow JSON, and streamed log lines can be chunky
23
+
MaxMessageBytes = 8 * 1024 * 1024
24
+
)
25
+
26
+
type Message = millv1.Message
27
+
28
+
var validator protovalidate.Validator
29
+
30
+
func init() {
31
+
var err error
32
+
validator, err = protovalidate.New()
33
+
if err != nil {
34
+
panic(fmt.Errorf("failed to initialize protovalidate validator: %w", err))
35
+
}
36
+
}
37
+
38
+
type Encoder struct {
39
+
mu sync.Mutex
40
+
w io.Writer
41
+
}
42
+
43
+
func NewEncoder(w io.Writer) *Encoder {
44
+
return &Encoder{w: w}
45
+
}
46
+
47
+
func (e *Encoder) Encode(msg *Message) error {
48
+
if err := validator.Validate(msg); err != nil {
49
+
return fmt.Errorf("validate fleet message: %w", err)
50
+
}
51
+
52
+
data, err := proto.Marshal(msg)
53
+
if err != nil {
54
+
return fmt.Errorf("marshal fleet message: %w", err)
55
+
}
56
+
if len(data) > MaxMessageBytes {
57
+
return fmt.Errorf("fleet message exceeded %d bytes", MaxMessageBytes)
58
+
}
59
+
60
+
// single write of header and payload maps to exactly one websocket binary
61
+
// frame when the writer is a ws stream
62
+
frame := make([]byte, 4+len(data))
63
+
binary.BigEndian.PutUint32(frame[:4], uint32(len(data)))
64
+
copy(frame[4:], data)
65
+
66
+
e.mu.Lock()
67
+
defer e.mu.Unlock()
68
+
_, err = e.w.Write(frame)
69
+
return err
70
+
}
71
+
72
+
type Decoder struct {
73
+
r io.Reader
74
+
}
75
+
76
+
func NewDecoder(r io.Reader) *Decoder {
77
+
return &Decoder{r: r}
78
+
}
79
+
80
+
func (d *Decoder) Decode() (*Message, error) {
81
+
msg := &Message{}
82
+
var header [4]byte
83
+
if _, err := io.ReadFull(d.r, header[:]); err != nil {
84
+
return msg, err
85
+
}
86
+
87
+
size := binary.BigEndian.Uint32(header[:])
88
+
if size > MaxMessageBytes {
89
+
return msg, fmt.Errorf("fleet message exceeded %d bytes", MaxMessageBytes)
90
+
}
91
+
92
+
data := make([]byte, size)
93
+
if _, err := io.ReadFull(d.r, data); err != nil {
94
+
return msg, err
95
+
}
96
+
if err := proto.Unmarshal(data, msg); err != nil {
97
+
return msg, fmt.Errorf("parse fleet message: %w", err)
98
+
}
99
+
if err := validator.Validate(msg); err != nil {
100
+
return msg, fmt.Errorf("validate fleet message: %w", err)
101
+
}
102
+
return msg, nil
103
+
}
+227
spindle/mill/proto/protocol_test.go
+227
spindle/mill/proto/protocol_test.go
···
1
+
package millproto
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/binary"
6
+
"testing"
7
+
8
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
9
+
)
10
+
11
+
func TestEncodeDecodeRoundTrip(t *testing.T) {
12
+
var buf bytes.Buffer
13
+
enc := NewEncoder(&buf)
14
+
15
+
want := &Message{
16
+
ReserveSeat: &millv1.ReserveSeat{
17
+
LeaseId: "lease-1",
18
+
TargetEngine: "microvm",
19
+
RawWorkflowJson: `{"name":"build"}`,
20
+
Knot: "knot.example",
21
+
Rkey: "abc123",
22
+
TtlSeconds: 30,
23
+
},
24
+
}
25
+
if err := enc.Encode(want); err != nil {
26
+
t.Fatalf("Encode() error = %v", err)
27
+
}
28
+
29
+
got, err := NewDecoder(&buf).Decode()
30
+
if err != nil {
31
+
t.Fatalf("Decode() error = %v", err)
32
+
}
33
+
rs := got.GetReserveSeat()
34
+
if rs == nil {
35
+
t.Fatal("decoded message missing reserve_seat")
36
+
}
37
+
if rs.LeaseId != "lease-1" || rs.TargetEngine != "microvm" || rs.TtlSeconds != 30 {
38
+
t.Fatalf("round-trip mismatch: %+v", rs)
39
+
}
40
+
}
41
+
42
+
func TestDecoderRejectsOversizedMessage(t *testing.T) {
43
+
var tooLarge bytes.Buffer
44
+
var header [4]byte
45
+
binary.BigEndian.PutUint32(header[:], MaxMessageBytes+1)
46
+
tooLarge.Write(header[:])
47
+
48
+
if _, err := NewDecoder(&tooLarge).Decode(); err == nil {
49
+
t.Fatal("expected oversized message error")
50
+
}
51
+
}
52
+
53
+
func TestValidationRules(t *testing.T) {
54
+
tests := []struct {
55
+
name string
56
+
msg *Message
57
+
wantErr bool
58
+
}{
59
+
{
60
+
name: "valid ack message",
61
+
msg: &Message{
62
+
Ack: &millv1.Ack{
63
+
Epoch: "inc-1",
64
+
UpToSeqno: 5,
65
+
},
66
+
},
67
+
wantErr: false,
68
+
},
69
+
{
70
+
name: "valid hello message",
71
+
msg: &Message{
72
+
Hello: &millv1.Hello{
73
+
ProtocolVersion: 1,
74
+
Arch: "amd64",
75
+
Labels: []string{"linux"},
76
+
Epoch: "inc-1",
77
+
},
78
+
},
79
+
wantErr: false,
80
+
},
81
+
{
82
+
name: "invalid message with zero payloads",
83
+
msg: &Message{},
84
+
wantErr: true,
85
+
},
86
+
{
87
+
name: "invalid message with multiple payloads",
88
+
msg: &Message{
89
+
Ack: &millv1.Ack{Epoch: "inc-1", UpToSeqno: 5},
90
+
Committed: &millv1.Committed{LeaseId: "x"},
91
+
},
92
+
wantErr: true,
93
+
},
94
+
{
95
+
name: "invalid ack message missing epoch",
96
+
msg: &Message{
97
+
Ack: &millv1.Ack{
98
+
UpToSeqno: 5,
99
+
},
100
+
},
101
+
wantErr: true,
102
+
},
103
+
{
104
+
name: "invalid node snapshot with zero seqno",
105
+
msg: &Message{
106
+
NodeSnapshot: &millv1.NodeSnapshot{
107
+
Seqno: 0,
108
+
},
109
+
},
110
+
wantErr: true,
111
+
},
112
+
{
113
+
name: "valid node snapshot with positive seqno",
114
+
msg: &Message{
115
+
NodeSnapshot: &millv1.NodeSnapshot{
116
+
Seqno: 1,
117
+
},
118
+
},
119
+
wantErr: false,
120
+
},
121
+
{
122
+
name: "invalid stream batch with zero seqno entry",
123
+
msg: &Message{
124
+
EventBatch: &millv1.EventBatch{
125
+
Epoch: "inc-1",
126
+
Events: []*millv1.Event{
127
+
{
128
+
Seqno: 0,
129
+
LeaseId: "lease-1",
130
+
Payload: &millv1.Event_StatusEvent{
131
+
StatusEvent: &millv1.StatusEvent{
132
+
Status: millv1.NonterminalStatus_RUNNING,
133
+
},
134
+
},
135
+
},
136
+
},
137
+
},
138
+
},
139
+
wantErr: true,
140
+
},
141
+
{
142
+
name: "invalid stream batch with empty entries",
143
+
msg: &Message{
144
+
EventBatch: &millv1.EventBatch{
145
+
Epoch: "inc-1",
146
+
Events: []*millv1.Event{},
147
+
},
148
+
},
149
+
wantErr: true,
150
+
},
151
+
{
152
+
name: "invalid reserve result with unknown enum",
153
+
msg: &Message{
154
+
ReserveResult: &millv1.ReserveResult{
155
+
LeaseId: "lease-1",
156
+
RejectClass: millv1.RejectClass(99),
157
+
},
158
+
},
159
+
wantErr: true,
160
+
},
161
+
{
162
+
name: "invalid stream entry with malformed oneof (empty payload)",
163
+
msg: &Message{
164
+
EventBatch: &millv1.EventBatch{
165
+
Epoch: "inc-1",
166
+
Events: []*millv1.Event{
167
+
{
168
+
Seqno: 1,
169
+
LeaseId: "lease-1",
170
+
Payload: nil,
171
+
},
172
+
},
173
+
},
174
+
},
175
+
wantErr: true,
176
+
},
177
+
{
178
+
name: "valid stream batch status event",
179
+
msg: &Message{
180
+
EventBatch: &millv1.EventBatch{
181
+
Epoch: "inc-1",
182
+
Events: []*millv1.Event{
183
+
{
184
+
Seqno: 1,
185
+
LeaseId: "lease-1",
186
+
Payload: &millv1.Event_StatusEvent{
187
+
StatusEvent: &millv1.StatusEvent{
188
+
Status: millv1.NonterminalStatus_RUNNING,
189
+
},
190
+
},
191
+
},
192
+
},
193
+
},
194
+
},
195
+
wantErr: false,
196
+
},
197
+
{
198
+
name: "valid stream batch attempt result",
199
+
msg: &Message{
200
+
EventBatch: &millv1.EventBatch{
201
+
Epoch: "inc-1",
202
+
Events: []*millv1.Event{
203
+
{
204
+
Seqno: 1,
205
+
LeaseId: "lease-1",
206
+
Payload: &millv1.Event_AttemptResult{
207
+
AttemptResult: &millv1.AttemptResult{
208
+
Status: millv1.TerminalStatus_SUCCESS,
209
+
},
210
+
},
211
+
},
212
+
},
213
+
},
214
+
},
215
+
wantErr: false,
216
+
},
217
+
}
218
+
219
+
for _, tc := range tests {
220
+
t.Run(tc.name, func(t *testing.T) {
221
+
err := validator.Validate(tc.msg)
222
+
if (err != nil) != tc.wantErr {
223
+
t.Fatalf("Validate() error = %v, wantErr = %v", err, tc.wantErr)
224
+
}
225
+
})
226
+
}
227
+
}
+187
spindle/mill/proto/spindle/mill/v1/mill.proto
+187
spindle/mill/proto/spindle/mill/v1/mill.proto
···
1
+
syntax = "proto3";
2
+
3
+
package spindle.mill.v1;
4
+
5
+
import "buf/validate/validate.proto";
6
+
7
+
option go_package = "tangled.org/core/spindle/mill/proto/gen;millv1";
8
+
9
+
// executor identity, sent on connect
10
+
message Hello {
11
+
uint32 protocol_version = 1;
12
+
// GOARCH of the node, informational only
13
+
string arch = 2;
14
+
// operator-defined labels, matched against runs_on
15
+
repeated string labels = 3;
16
+
string epoch = 4 [(buf.validate.field).string.min_len = 1];
17
+
}
18
+
19
+
// reconnect state for an existing epoch
20
+
message Resume {
21
+
string epoch = 1 [(buf.validate.field).string.min_len = 1];
22
+
uint64 ack_seqno = 2;
23
+
}
24
+
25
+
// per-engine state of a node
26
+
message EngineAvailability {
27
+
bool available = 1;
28
+
// opaque engine-defined load metrics, higher means more loaded
29
+
map<string, double> load = 2;
30
+
}
31
+
32
+
// full node state
33
+
message NodeSnapshot {
34
+
uint64 seqno = 1 [(buf.validate.field).uint64.gt = 0];
35
+
map<string, EngineAvailability> engines = 2;
36
+
// every lease the executor currently holds, reserved or running
37
+
repeated string active_lease_ids = 3;
38
+
}
39
+
40
+
// a seat reservation, carries no secrets
41
+
message ReserveSeat {
42
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
43
+
string target_engine = 2 [(buf.validate.field).string.min_len = 1];
44
+
string raw_pipeline_json = 3;
45
+
string raw_workflow_json = 4;
46
+
// pipeline id, the executor reconstructs the exact WorkflowId from it
47
+
string knot = 5;
48
+
string rkey = 6;
49
+
uint32 ttl_seconds = 7;
50
+
}
51
+
52
+
enum RejectClass {
53
+
REJECT_CLASS_UNSPECIFIED = 0;
54
+
REJECT_CLASS_TRANSIENT = 1;
55
+
REJECT_CLASS_INCOMPATIBLE = 2;
56
+
}
57
+
58
+
message ReserveResult {
59
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
60
+
bool accepted = 2;
61
+
string reject_reason = 3;
62
+
RejectClass reject_class = 4 [(buf.validate.field).enum.defined_only = true];
63
+
}
64
+
65
+
// a single unlocked secret
66
+
message Secret {
67
+
string key = 1;
68
+
string value = 2;
69
+
}
70
+
71
+
// promotes a reservation to a running job and hands over the secrets
72
+
message CommitLease {
73
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
74
+
repeated Secret secrets = 2;
75
+
}
76
+
77
+
message Committed {
78
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
79
+
}
80
+
81
+
// drops a reservation that was never committed
82
+
message ReleaseLease {
83
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
84
+
}
85
+
86
+
// cancels a running attempt
87
+
message CancelAttempt {
88
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
89
+
string reason = 2;
90
+
}
91
+
92
+
message CancelAck {
93
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
94
+
}
95
+
96
+
enum NonterminalStatus {
97
+
NONTERMINAL_STATUS_UNSPECIFIED = 0;
98
+
RUNNING = 1;
99
+
}
100
+
101
+
enum TerminalStatus {
102
+
TERMINAL_STATUS_UNSPECIFIED = 0;
103
+
SUCCESS = 1;
104
+
FAILED = 2;
105
+
TIMEOUT = 3;
106
+
CANCELLED = 4;
107
+
}
108
+
109
+
message StatusEvent {
110
+
NonterminalStatus status = 1 [(buf.validate.field).enum = {
111
+
defined_only: true
112
+
not_in: 0
113
+
}];
114
+
string error = 2 [(buf.validate.field).string.max_bytes = 65536];
115
+
int64 exit_code = 3;
116
+
}
117
+
118
+
message LogArtifact {
119
+
string ref = 1 [(buf.validate.field).string.min_len = 1];
120
+
string hash = 2 [(buf.validate.field).string.min_len = 1];
121
+
}
122
+
123
+
// terminal outcome of an attempt
124
+
message AttemptResult {
125
+
TerminalStatus status = 1 [(buf.validate.field).enum = {
126
+
defined_only: true
127
+
not_in: 0
128
+
}];
129
+
string error = 2 [(buf.validate.field).string.max_bytes = 65536];
130
+
int64 exit_code = 3;
131
+
LogArtifact log_artifact = 4;
132
+
}
133
+
134
+
// live non-replay log frame
135
+
message LiveLog {
136
+
string lease_id = 1 [(buf.validate.field).string.min_len = 1];
137
+
bytes raw_json = 2 [(buf.validate.field).bytes.min_len = 1];
138
+
}
139
+
140
+
// one event in the executor's stream back to the mill
141
+
message Event {
142
+
uint64 seqno = 1 [(buf.validate.field).uint64.gt = 0];
143
+
string lease_id = 2 [(buf.validate.field).string.min_len = 1];
144
+
145
+
oneof payload {
146
+
option (buf.validate.oneof).required = true;
147
+
StatusEvent status_event = 3;
148
+
AttemptResult attempt_result = 4;
149
+
}
150
+
}
151
+
152
+
// a flushed bundle of events
153
+
message EventBatch {
154
+
string epoch = 1 [(buf.validate.field).string.min_len = 1];
155
+
repeated Event events = 2 [(buf.validate.field).repeated.min_items = 1];
156
+
}
157
+
158
+
// all events up to and including up_to_seqno are durably processed
159
+
message Ack {
160
+
string epoch = 1 [(buf.validate.field).string.min_len = 1];
161
+
uint64 up_to_seqno = 2;
162
+
}
163
+
164
+
message Message {
165
+
option (buf.validate.message).oneof = {
166
+
fields: [
167
+
"hello", "resume", "node_snapshot", "reserve_seat", "reserve_result",
168
+
"commit_lease", "committed", "release_lease", "cancel_attempt",
169
+
"cancel_ack", "event_batch", "ack", "live_log"
170
+
],
171
+
required: true
172
+
};
173
+
174
+
Hello hello = 1;
175
+
Resume resume = 2;
176
+
NodeSnapshot node_snapshot = 3;
177
+
ReserveSeat reserve_seat = 4;
178
+
ReserveResult reserve_result = 5;
179
+
CommitLease commit_lease = 6;
180
+
Committed committed = 7;
181
+
ReleaseLease release_lease = 8;
182
+
CancelAttempt cancel_attempt = 9;
183
+
CancelAck cancel_ack = 10;
184
+
EventBatch event_batch = 11;
185
+
Ack ack = 12;
186
+
LiveLog live_log = 13;
187
+
}
+58
spindle/mill/proto/ws.go
+58
spindle/mill/proto/ws.go
···
1
+
package millproto
2
+
3
+
import (
4
+
"io"
5
+
"sync"
6
+
7
+
"github.com/gorilla/websocket"
8
+
)
9
+
10
+
// adapts a gorilla websocket connection to an io.ReadWriteCloser so the
11
+
// length-prefixed fleet framing rides over it. each Encode produces exactly one
12
+
// binary frame. the reader reassembles the byte stream across frames
13
+
type WSStream struct {
14
+
conn *websocket.Conn
15
+
16
+
rmu sync.Mutex
17
+
r io.Reader // current message reader, advanced as frames are consumed
18
+
19
+
wmu sync.Mutex
20
+
}
21
+
22
+
func NewWSStream(conn *websocket.Conn) *WSStream {
23
+
return &WSStream{conn: conn}
24
+
}
25
+
26
+
func (s *WSStream) Read(p []byte) (int, error) {
27
+
s.rmu.Lock()
28
+
defer s.rmu.Unlock()
29
+
for {
30
+
if s.r == nil {
31
+
_, r, err := s.conn.NextReader()
32
+
if err != nil {
33
+
return 0, err
34
+
}
35
+
s.r = r
36
+
}
37
+
n, err := s.r.Read(p)
38
+
if err == io.EOF {
39
+
s.r = nil
40
+
if n > 0 {
41
+
return n, nil
42
+
}
43
+
continue
44
+
}
45
+
return n, err
46
+
}
47
+
}
48
+
49
+
func (s *WSStream) Write(p []byte) (int, error) {
50
+
s.wmu.Lock()
51
+
defer s.wmu.Unlock()
52
+
if err := s.conn.WriteMessage(websocket.BinaryMessage, p); err != nil {
53
+
return 0, err
54
+
}
55
+
return len(p), nil
56
+
}
57
+
58
+
func (s *WSStream) Close() error { return s.conn.Close() }
+221
spindle/mill/restore.go
+221
spindle/mill/restore.go
···
1
+
package mill
2
+
3
+
import (
4
+
"fmt"
5
+
"tangled.org/core/spindle/db"
6
+
millproto "tangled.org/core/spindle/mill/proto"
7
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
8
+
"tangled.org/core/spindle/models"
9
+
"time"
10
+
)
11
+
12
+
const (
13
+
leaseRowReserved = "reserved"
14
+
leaseRowRunning = "running"
15
+
)
16
+
17
+
func (m *Mill) persistLease(lease *RemoteLease, state string) error {
18
+
if m.db == nil {
19
+
return nil
20
+
}
21
+
return m.db.SaveMillLease(db.MillLease{
22
+
LeaseID: lease.id,
23
+
NodeID: lease.nodeID,
24
+
Epoch: lease.epoch,
25
+
Engine: lease.engine,
26
+
Knot: lease.wid.Knot,
27
+
Rkey: lease.wid.Rkey,
28
+
Workflow: lease.wid.Name,
29
+
State: state,
30
+
})
31
+
}
32
+
33
+
func (m *Mill) RestoreState() error {
34
+
if m.db == nil {
35
+
return nil
36
+
}
37
+
cursors, err := m.db.ListExecutorCursors()
38
+
if err != nil {
39
+
return err
40
+
}
41
+
rows, err := m.db.ListMillLeases()
42
+
if err != nil {
43
+
return err
44
+
}
45
+
46
+
m.mu.Lock()
47
+
for _, c := range cursors {
48
+
m.nodeSeqno[c.NodeID+"/"+c.Epoch] = c.AckedSeqno
49
+
}
50
+
for _, r := range rows {
51
+
lease := newLease(r.LeaseID, r.NodeID, r.Epoch, r.Engine)
52
+
lease.wid = models.WorkflowId{
53
+
PipelineId: models.PipelineId{Knot: r.Knot, Rkey: r.Rkey},
54
+
Name: r.Workflow,
55
+
}
56
+
// restored leases start as orphans, an executor must reclaim it via
57
+
// its first snapshot, or the sweep will fail it
58
+
lease.orphaned = true
59
+
lease.claimed = false
60
+
if r.State == leaseRowRunning {
61
+
lease.state = leaseRunning
62
+
}
63
+
m.leases[r.LeaseID] = lease
64
+
}
65
+
restored := len(rows)
66
+
m.mu.Unlock()
67
+
68
+
if restored > 0 {
69
+
m.l.Info("restored mill leases from previous run", "leases", restored, "cursors", len(cursors))
70
+
// executors get a grace window to reconnect and claim their leases
71
+
time.AfterFunc(m.cfg.ReconnectGrace, m.sweepUnclaimedOrphans)
72
+
}
73
+
return nil
74
+
}
75
+
func (m *Mill) sweepUnclaimedOrphans() {
76
+
m.mu.Lock()
77
+
var unclaimed []*RemoteLease
78
+
for _, lease := range m.leases {
79
+
if !lease.orphaned {
80
+
continue
81
+
}
82
+
if lease.claimed {
83
+
continue
84
+
}
85
+
if lease.getState() == leaseDone {
86
+
continue
87
+
}
88
+
if sess := m.sessions[lease.nodeID]; sess == nil || sess.disconnected {
89
+
unclaimed = append(unclaimed, lease)
90
+
}
91
+
}
92
+
m.mu.Unlock()
93
+
94
+
retry := false
95
+
reason := "executor did not reconnect after mill restart"
96
+
for _, lease := range unclaimed {
97
+
m.l.Warn("failing unclaimed restored lease", "lease", lease.id, "node", lease.nodeID)
98
+
if err := m.finishOrphan(lease, string(models.StatusKindFailed), &reason, nil); err != nil {
99
+
m.l.Error("finish unclaimed restored lease", "lease", lease.id, "err", err)
100
+
retry = true
101
+
}
102
+
}
103
+
if retry {
104
+
time.AfterFunc(5*time.Second, m.sweepUnclaimedOrphans)
105
+
}
106
+
}
107
+
108
+
func (m *Mill) reconcileLeases(sess *millSession, activeLeaseIDs []string) error {
109
+
active := make(map[string]struct{}, len(activeLeaseIDs))
110
+
for _, id := range activeLeaseIDs {
111
+
active[id] = struct{}{}
112
+
}
113
+
114
+
known := make(map[string]struct{})
115
+
m.mu.Lock()
116
+
var gone []*RemoteLease
117
+
for _, lease := range m.leases {
118
+
if lease.nodeID == sess.nodeID {
119
+
known[lease.id] = struct{}{}
120
+
if lease.epoch != sess.epoch {
121
+
gone = append(gone, lease)
122
+
} else if _, ok := active[lease.id]; !ok {
123
+
gone = append(gone, lease)
124
+
}
125
+
}
126
+
}
127
+
for _, lease := range m.reservations {
128
+
if lease.nodeID == sess.nodeID && lease.epoch == sess.epoch {
129
+
known[lease.id] = struct{}{}
130
+
}
131
+
}
132
+
m.mu.Unlock()
133
+
var unknown []string
134
+
for id := range active {
135
+
if _, ok := known[id]; !ok {
136
+
unknown = append(unknown, id)
137
+
}
138
+
}
139
+
140
+
for _, lease := range gone {
141
+
status := string(models.StatusKindFailed)
142
+
reason := "executor no longer holds lease"
143
+
if cancelled, cancelReason := lease.cancelRequested(); cancelled {
144
+
status = string(models.StatusKindCancelled)
145
+
reason = cancelReason
146
+
}
147
+
m.l.Warn("finishing reconciled lease", "lease", lease.id, "node", sess.nodeID, "leaseInc", lease.epoch, "sessInc", sess.epoch)
148
+
if lease.orphaned {
149
+
if err := m.finishOrphan(lease, status, &reason, nil); err != nil {
150
+
return err
151
+
}
152
+
} else if err := m.finishLiveLease(lease, status, reason); err != nil {
153
+
return err
154
+
}
155
+
}
156
+
for _, id := range unknown {
157
+
if err := sess.send(&millproto.Message{CancelAttempt: &millv1.CancelAttempt{LeaseId: id, Reason: "lease is not owned by this mill"}}); err != nil {
158
+
return fmt.Errorf("cancel unknown executor lease %q: %w", id, err)
159
+
}
160
+
}
161
+
return nil
162
+
}
163
+
164
+
func (m *Mill) completeLeaseRow(lease *RemoteLease, status string, errMsg *string, exitCode *int64) error {
165
+
if m.db == nil {
166
+
return nil
167
+
}
168
+
return m.db.CompleteMillLease(
169
+
lease.id,
170
+
string(lease.wid.PipelineId.AtUri()),
171
+
lease.wid.Name,
172
+
status,
173
+
errMsg,
174
+
exitCode,
175
+
m.n,
176
+
)
177
+
}
178
+
179
+
func (m *Mill) finishLiveLease(lease *RemoteLease, status, reason string) error {
180
+
lease.finishMu.Lock()
181
+
defer lease.finishMu.Unlock()
182
+
if lease.getState() == leaseDone {
183
+
return nil
184
+
}
185
+
if err := m.completeLeaseRow(lease, status, &reason, nil); err != nil {
186
+
return err
187
+
}
188
+
lease.deliverTerminal(&millv1.AttemptResult{
189
+
Status: mapTerminalStatusString(status),
190
+
Error: reason,
191
+
})
192
+
return m.cleanupLeaseLocked(lease)
193
+
}
194
+
195
+
func (m *Mill) finishOrphan(lease *RemoteLease, status string, errMsg *string, exitCode *int64) error {
196
+
lease.finishMu.Lock()
197
+
defer lease.finishMu.Unlock()
198
+
if lease.getState() == leaseDone {
199
+
return nil
200
+
}
201
+
if err := m.completeLeaseRow(lease, status, errMsg, exitCode); err != nil {
202
+
return err
203
+
}
204
+
lease.markDone()
205
+
return m.cleanupLeaseLocked(lease)
206
+
}
207
+
208
+
func mapTerminalStatusString(s string) millv1.TerminalStatus {
209
+
switch s {
210
+
case "success":
211
+
return millv1.TerminalStatus_SUCCESS
212
+
case "failed":
213
+
return millv1.TerminalStatus_FAILED
214
+
case "timeout":
215
+
return millv1.TerminalStatus_TIMEOUT
216
+
case "cancelled":
217
+
return millv1.TerminalStatus_CANCELLED
218
+
default:
219
+
return millv1.TerminalStatus_TERMINAL_STATUS_UNSPECIFIED
220
+
}
221
+
}
+386
spindle/mill/restore_test.go
+386
spindle/mill/restore_test.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"path/filepath"
6
+
"testing"
7
+
"time"
8
+
9
+
"tangled.org/core/notifier"
10
+
"tangled.org/core/spindle/db"
11
+
millproto "tangled.org/core/spindle/mill/proto"
12
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
13
+
"tangled.org/core/spindle/models"
14
+
)
15
+
16
+
func restoreTestMill(t *testing.T, cfg Config) (*Mill, *db.DB) {
17
+
t.Helper()
18
+
bdb, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "mill.db"))
19
+
if err != nil {
20
+
t.Fatalf("db.Make: %v", err)
21
+
}
22
+
t.Cleanup(func() { bdb.Close() })
23
+
n := notifier.New()
24
+
m := New(discardLogger(), cfg)
25
+
m.Attach(bdb, &n)
26
+
return m, bdb
27
+
}
28
+
29
+
func restoredMill(t *testing.T, bdb *db.DB, cfg Config) *Mill {
30
+
t.Helper()
31
+
n := notifier.New()
32
+
m := New(discardLogger(), cfg)
33
+
m.Attach(bdb, &n)
34
+
if err := m.RestoreState(); err != nil {
35
+
t.Fatalf("RestoreState: %v", err)
36
+
}
37
+
return m
38
+
}
39
+
40
+
func TestRestoreStateRebuildsLeasesAndCursors(t *testing.T) {
41
+
_, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
42
+
43
+
if err := bdb.SaveMillLease(db.MillLease{
44
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
45
+
Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning,
46
+
}); err != nil {
47
+
t.Fatalf("SaveMillLease: %v", err)
48
+
}
49
+
if err := bdb.ApplyEventBatch(nil, func(tx *db.EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 7) }); err != nil {
50
+
t.Fatalf("AdvanceCursor: %v", err)
51
+
}
52
+
53
+
m2 := restoredMill(t, bdb, Config{ReconnectGrace: time.Minute})
54
+
55
+
m2.mu.Lock()
56
+
lease := m2.leases["lease-1"]
57
+
seqno := m2.nodeSeqno["node-1/inc-1"]
58
+
m2.mu.Unlock()
59
+
60
+
if lease == nil {
61
+
t.Fatal("restored mill has no lease-1")
62
+
}
63
+
if !lease.orphaned {
64
+
t.Fatal("restored lease is not orphaned; a terminal would be delivered to a waiter that does not exist")
65
+
}
66
+
if lease.getState() != leaseRunning {
67
+
t.Fatalf("restored lease state = %v, want leaseRunning", lease.getState())
68
+
}
69
+
wantWid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "rkey1"}, Name: "build"}
70
+
if lease.wid != wantWid {
71
+
t.Fatalf("restored lease wid = %+v, want %+v", lease.wid, wantWid)
72
+
}
73
+
if seqno != 7 {
74
+
t.Fatalf("restored cursor = %d, want 7", seqno)
75
+
}
76
+
}
77
+
78
+
func TestOrphanTerminalAuthorsStatusRow(t *testing.T) {
79
+
_, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
80
+
if err := bdb.SaveMillLease(db.MillLease{
81
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
82
+
Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning,
83
+
}); err != nil {
84
+
t.Fatalf("SaveMillLease: %v", err)
85
+
}
86
+
if err := bdb.ApplyEventBatch(nil, func(tx *db.EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 3) }); err != nil {
87
+
t.Fatalf("AdvanceCursor: %v", err)
88
+
}
89
+
90
+
m := restoredMill(t, bdb, Config{ReconnectGrace: time.Minute})
91
+
92
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
93
+
resume, ok := m.attachSession(sess)
94
+
if !ok {
95
+
t.Fatal("attachSession rejected the reconnecting executor")
96
+
}
97
+
if resume != 3 {
98
+
t.Fatalf("attachSession resume seqno = %d, want restored cursor 3", resume)
99
+
}
100
+
101
+
_ = m.onEventBatch(sess, &millv1.EventBatch{
102
+
Epoch: sess.epoch,
103
+
Events: []*millv1.Event{
104
+
{
105
+
Seqno: 4,
106
+
LeaseId: "lease-1",
107
+
Payload: &millv1.Event_AttemptResult{
108
+
AttemptResult: &millv1.AttemptResult{
109
+
Status: millv1.TerminalStatus_SUCCESS,
110
+
},
111
+
},
112
+
},
113
+
},
114
+
})
115
+
116
+
wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "rkey1"}, Name: "build"}
117
+
st, err := bdb.GetStatus(wid)
118
+
if err != nil {
119
+
t.Fatalf("GetStatus after orphan terminal: %v", err)
120
+
}
121
+
if st.Status != string(models.StatusKindSuccess) {
122
+
t.Fatalf("orphan terminal authored status %q, want success", st.Status)
123
+
}
124
+
125
+
m.mu.Lock()
126
+
_, still := m.leases["lease-1"]
127
+
m.mu.Unlock()
128
+
if still {
129
+
t.Fatal("finished orphan still in the lease map")
130
+
}
131
+
if rows, _ := bdb.ListMillLeases(); len(rows) != 0 {
132
+
t.Fatalf("finished orphan still persisted: %+v", rows)
133
+
}
134
+
}
135
+
136
+
func TestSnapshotReconciliationFailsDroppedOrphans(t *testing.T) {
137
+
_, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
138
+
for _, l := range []db.MillLease{
139
+
{LeaseID: "lease-kept", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", Knot: "k", Rkey: "r1", Workflow: "w", State: leaseRowRunning},
140
+
{LeaseID: "lease-gone", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", Knot: "k", Rkey: "r2", Workflow: "w", State: leaseRowRunning},
141
+
} {
142
+
if err := bdb.SaveMillLease(l); err != nil {
143
+
t.Fatalf("SaveMillLease(%s): %v", l.LeaseID, err)
144
+
}
145
+
}
146
+
147
+
m := restoredMill(t, bdb, Config{ReconnectGrace: time.Minute})
148
+
149
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
150
+
m.attachSession(sess)
151
+
152
+
m.onSnapshot(sess, &millv1.NodeSnapshot{
153
+
Seqno: 1,
154
+
ActiveLeaseIds: []string{"lease-kept"},
155
+
})
156
+
157
+
m.mu.Lock()
158
+
_, kept := m.leases["lease-kept"]
159
+
_, gone := m.leases["lease-gone"]
160
+
m.mu.Unlock()
161
+
if !kept {
162
+
t.Fatal("reconciliation dropped a lease the executor still holds")
163
+
}
164
+
if gone {
165
+
t.Fatal("reconciliation kept a lease the executor no longer holds")
166
+
}
167
+
168
+
st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r2"}, Name: "w"})
169
+
if err != nil {
170
+
t.Fatalf("GetStatus for dropped orphan: %v", err)
171
+
}
172
+
if st.Status != string(models.StatusKindFailed) {
173
+
t.Fatalf("dropped orphan authored status %q, want failed", st.Status)
174
+
}
175
+
}
176
+
func TestSnapshotReconciliationPreservesRequestedCancellation(t *testing.T) {
177
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
178
+
lease := newLease("lease-1", "node-1", "inc-old", "dummy")
179
+
lease.wid = models.WorkflowId{
180
+
PipelineId: models.PipelineId{Knot: "k", Rkey: "r1"},
181
+
Name: "w",
182
+
}
183
+
lease.setState(leaseRunning)
184
+
lease.requestCancel("workflow destroyed")
185
+
if err := m.persistLease(lease, leaseRowRunning); err != nil {
186
+
t.Fatalf("persistLease: %v", err)
187
+
}
188
+
m.mu.Lock()
189
+
m.leases[lease.id] = lease
190
+
m.mu.Unlock()
191
+
192
+
sess := newSession("node-1", "inc-new", nil, nopEncoder(), discardLogger())
193
+
m.attachSession(sess)
194
+
if err := m.onSnapshot(sess, &millv1.NodeSnapshot{Seqno: 1}); err != nil {
195
+
t.Fatalf("onSnapshot: %v", err)
196
+
}
197
+
198
+
st, err := bdb.GetStatus(lease.wid)
199
+
if err != nil {
200
+
t.Fatalf("GetStatus: %v", err)
201
+
}
202
+
if st.Status != string(models.StatusKindCancelled) {
203
+
t.Fatalf("reconciled status = %q, want cancelled", st.Status)
204
+
}
205
+
}
206
+
207
+
func TestSnapshotReconciliationCancelsUnknownExecutorLease(t *testing.T) {
208
+
m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
209
+
cancelled := make(chan string, 1)
210
+
sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error {
211
+
if cancel := msg.GetCancelAttempt(); cancel != nil {
212
+
cancelled <- cancel.GetLeaseId()
213
+
}
214
+
return nil
215
+
}), discardLogger())
216
+
m.attachSession(sess)
217
+
218
+
if err := m.onSnapshot(sess, &millv1.NodeSnapshot{
219
+
Seqno: 1,
220
+
ActiveLeaseIds: []string{"executor-only"},
221
+
}); err != nil {
222
+
t.Fatalf("onSnapshot: %v", err)
223
+
}
224
+
select {
225
+
case id := <-cancelled:
226
+
if id != "executor-only" {
227
+
t.Fatalf("cancelled lease = %q, want executor-only", id)
228
+
}
229
+
default:
230
+
t.Fatal("snapshot reconciliation left an executor-only lease running")
231
+
}
232
+
}
233
+
234
+
func TestSweepFailsOrphansOfAbsentExecutors(t *testing.T) {
235
+
_, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
236
+
if err := bdb.SaveMillLease(db.MillLease{
237
+
LeaseID: "lease-1", NodeID: "node-absent", Epoch: "inc-absent", Engine: "dummy",
238
+
Knot: "k", Rkey: "r1", Workflow: "w", State: leaseRowReserved,
239
+
}); err != nil {
240
+
t.Fatalf("SaveMillLease: %v", err)
241
+
}
242
+
243
+
m := restoredMill(t, bdb, Config{ReconnectGrace: time.Minute})
244
+
m.sweepUnclaimedOrphans()
245
+
246
+
m.mu.Lock()
247
+
_, still := m.leases["lease-1"]
248
+
m.mu.Unlock()
249
+
if still {
250
+
t.Fatal("sweep kept an orphan whose executor never reconnected")
251
+
}
252
+
st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r1"}, Name: "w"})
253
+
if err != nil {
254
+
t.Fatalf("GetStatus after sweep: %v", err)
255
+
}
256
+
if st.Status != string(models.StatusKindFailed) {
257
+
t.Fatalf("sweep authored status %q, want failed", st.Status)
258
+
}
259
+
if rows, _ := bdb.ListMillLeases(); len(rows) != 0 {
260
+
t.Fatalf("swept orphan still persisted: %+v", rows)
261
+
}
262
+
}
263
+
264
+
func TestAckSeqnoPersistsCursor(t *testing.T) {
265
+
m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
266
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
267
+
m.attachSession(sess)
268
+
269
+
owned := newLease("lease-1", "node-1", "inc-1", "dummy")
270
+
owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}
271
+
m.mu.Lock()
272
+
m.leases[owned.id] = owned
273
+
m.mu.Unlock()
274
+
275
+
err := m.onEventBatch(sess, &millv1.EventBatch{
276
+
Epoch: sess.epoch,
277
+
Events: []*millv1.Event{
278
+
{
279
+
Seqno: 1,
280
+
LeaseId: owned.id,
281
+
Payload: &millv1.Event_StatusEvent{
282
+
StatusEvent: &millv1.StatusEvent{
283
+
Status: millv1.NonterminalStatus_RUNNING,
284
+
},
285
+
},
286
+
},
287
+
},
288
+
})
289
+
if err != nil {
290
+
t.Fatalf("onEventBatch: %v", err)
291
+
}
292
+
293
+
cursors, err := bdb.ListExecutorCursors()
294
+
if err != nil {
295
+
t.Fatalf("ListExecutorCursors: %v", err)
296
+
}
297
+
if len(cursors) != 1 || cursors[0].AckedSeqno != 1 {
298
+
t.Fatalf("persisted cursor = %+v, want seqno 1", cursors)
299
+
}
300
+
}
301
+
302
+
func TestOrphanTerminalFailureKeepsLeaseAndSeqnoRetryable(t *testing.T) {
303
+
_, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute})
304
+
if err := bdb.SaveMillLease(db.MillLease{
305
+
LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy",
306
+
Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning,
307
+
}); err != nil {
308
+
t.Fatalf("SaveMillLease: %v", err)
309
+
}
310
+
if err := bdb.ApplyEventBatch(nil, func(tx *db.EventBatchTx) error { return tx.AdvanceCursor("node-1", "inc-1", 3) }); err != nil {
311
+
t.Fatalf("AdvanceCursor: %v", err)
312
+
}
313
+
if _, err := bdb.Exec(`
314
+
create trigger reject_orphan_lease_delete
315
+
before delete on mill_leases
316
+
begin
317
+
select raise(abort, 'forced delete failure');
318
+
end
319
+
`); err != nil {
320
+
t.Fatalf("create failure trigger: %v", err)
321
+
}
322
+
323
+
m := restoredMill(t, bdb, Config{ReconnectGrace: time.Minute})
324
+
sess := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger())
325
+
if _, ok := m.attachSession(sess); !ok {
326
+
t.Fatal("attachSession rejected reconnect")
327
+
}
328
+
batch := &millv1.EventBatch{
329
+
Epoch: sess.epoch,
330
+
Events: []*millv1.Event{
331
+
{
332
+
Seqno: 4,
333
+
LeaseId: "lease-1",
334
+
Payload: &millv1.Event_AttemptResult{
335
+
AttemptResult: &millv1.AttemptResult{
336
+
Status: millv1.TerminalStatus_SUCCESS,
337
+
},
338
+
},
339
+
},
340
+
},
341
+
}
342
+
if err := m.onEventBatch(sess, batch); err == nil {
343
+
t.Fatal("orphan terminal stream succeeded despite forced transaction failure")
344
+
}
345
+
346
+
m.mu.Lock()
347
+
lease := m.leases["lease-1"]
348
+
seqno := m.nodeSeqno["node-1/inc-1"]
349
+
m.mu.Unlock()
350
+
if lease == nil || lease.getState() == leaseDone {
351
+
t.Fatal("failed orphan completion made the in-memory lease unretryable")
352
+
}
353
+
if seqno != 3 {
354
+
t.Fatalf("in-memory stream seqno = %d, want 3", seqno)
355
+
}
356
+
if rows, err := bdb.ListMillLeases(); err != nil || len(rows) != 1 {
357
+
t.Fatalf("durable leases after transaction rollback = %+v, err = %v; want retained lease", rows, err)
358
+
}
359
+
var events int
360
+
if err := bdb.QueryRow(`select count(*) from events`).Scan(&events); err != nil {
361
+
t.Fatalf("count events: %v", err)
362
+
}
363
+
if events != 0 {
364
+
t.Fatalf("terminal events after transaction rollback = %d, want 0", events)
365
+
}
366
+
367
+
if _, err := bdb.Exec(`drop trigger reject_orphan_lease_delete`); err != nil {
368
+
t.Fatalf("drop failure trigger: %v", err)
369
+
}
370
+
if err := m.onEventBatch(sess, batch); err != nil {
371
+
t.Fatalf("retry orphan terminal: %v", err)
372
+
}
373
+
m.mu.Lock()
374
+
_, still := m.leases["lease-1"]
375
+
seqno = m.nodeSeqno["node-1/inc-1"]
376
+
m.mu.Unlock()
377
+
if still {
378
+
t.Fatal("successful orphan completion retained in-memory lease")
379
+
}
380
+
if seqno != 4 {
381
+
t.Fatalf("in-memory stream seqno after retry = %d, want 4", seqno)
382
+
}
383
+
if rows, err := bdb.ListMillLeases(); err != nil || len(rows) != 0 {
384
+
t.Fatalf("durable leases after successful retry = %+v, err = %v; want none", rows, err)
385
+
}
386
+
}
+160
spindle/mill/session.go
+160
spindle/mill/session.go
···
1
+
package mill
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"log/slog"
7
+
"sync"
8
+
"time"
9
+
10
+
millproto "tangled.org/core/spindle/mill/proto"
11
+
millv1 "tangled.org/core/spindle/mill/proto/gen"
12
+
)
13
+
14
+
var errSessionClosed = errors.New("mill: executor session closed")
15
+
16
+
// one live websocket to an executor. many leases and async streams share
17
+
// it, so one reader goroutine demuxes by message type and correlates by
18
+
// lease ID. never hold locks across decodes
19
+
type millSession struct {
20
+
nodeID string
21
+
epoch string
22
+
labels []string
23
+
enc messageEncoder
24
+
l *slog.Logger
25
+
closeTransport func() error
26
+
27
+
// snapshot, disconnected and graceTimer are guarded by Mill.mu, the
28
+
// fleet ranks across sessions under its own lock
29
+
snapshot *millv1.NodeSnapshot
30
+
disconnected bool
31
+
graceTimer *time.Timer
32
+
lastSeen time.Time
33
+
34
+
mu sync.Mutex
35
+
pending map[string]chan *millproto.Message // maps lease ID to response waiter
36
+
37
+
closeOnce sync.Once
38
+
closed chan struct{}
39
+
}
40
+
41
+
type messageEncoder interface {
42
+
Encode(*millproto.Message) error
43
+
}
44
+
45
+
func newSession(nodeID string, epoch string, labels []string, enc messageEncoder, l *slog.Logger) *millSession {
46
+
return &millSession{
47
+
nodeID: nodeID,
48
+
epoch: epoch,
49
+
labels: labels,
50
+
enc: enc,
51
+
l: l,
52
+
pending: make(map[string]chan *millproto.Message),
53
+
closed: make(chan struct{}),
54
+
lastSeen: time.Now(),
55
+
}
56
+
}
57
+
58
+
// caller holds Mill.mu
59
+
func (s *millSession) live(grace time.Duration) bool {
60
+
return !s.disconnected && time.Since(s.lastSeen) <= grace
61
+
}
62
+
63
+
func (s *millSession) send(msg *millproto.Message) error {
64
+
return s.enc.Encode(msg)
65
+
}
66
+
67
+
func (s *millSession) close() {
68
+
s.closeOnce.Do(func() {
69
+
close(s.closed)
70
+
if s.closeTransport != nil {
71
+
_ = s.closeTransport()
72
+
}
73
+
})
74
+
}
75
+
76
+
// one-shot waiter for the next response on the lease, cancel unregisters it
77
+
func (s *millSession) await(leaseID string) (<-chan *millproto.Message, func()) {
78
+
ch := make(chan *millproto.Message, 1)
79
+
s.mu.Lock()
80
+
s.pending[leaseID] = ch
81
+
s.mu.Unlock()
82
+
return ch, func() {
83
+
s.mu.Lock()
84
+
if s.pending[leaseID] == ch {
85
+
delete(s.pending, leaseID)
86
+
}
87
+
s.mu.Unlock()
88
+
}
89
+
}
90
+
91
+
func (s *millSession) deliver(leaseID string, msg *millproto.Message) {
92
+
s.mu.Lock()
93
+
ch := s.pending[leaseID]
94
+
delete(s.pending, leaseID)
95
+
s.mu.Unlock()
96
+
if ch != nil {
97
+
select {
98
+
case ch <- msg:
99
+
default:
100
+
}
101
+
}
102
+
}
103
+
104
+
// sends a message and waits for its response, respecting ctx and session closure
105
+
func (s *millSession) request(ctx context.Context, leaseID string, msg *millproto.Message) (*millproto.Message, error) {
106
+
if err := ctx.Err(); err != nil {
107
+
return nil, err
108
+
}
109
+
ch, cancel := s.await(leaseID)
110
+
defer cancel()
111
+
112
+
if err := s.send(msg); err != nil {
113
+
return nil, err
114
+
}
115
+
116
+
select {
117
+
case resp := <-ch:
118
+
return resp, nil
119
+
case <-ctx.Done():
120
+
return nil, ctx.Err()
121
+
case <-s.closed:
122
+
return nil, errSessionClosed
123
+
}
124
+
}
125
+
126
+
// demuxes frames until the decoder errors (connection gone)
127
+
func (s *millSession) readLoop(m *Mill, dec *millproto.Decoder) error {
128
+
for {
129
+
msg, err := dec.Decode()
130
+
if err != nil {
131
+
return err
132
+
}
133
+
if err := s.dispatch(m, msg); err != nil {
134
+
return err
135
+
}
136
+
}
137
+
}
138
+
139
+
func (s *millSession) dispatch(m *Mill, msg *millproto.Message) error {
140
+
if !m.touchSession(s) {
141
+
return errSessionClosed
142
+
}
143
+
switch {
144
+
case msg.GetNodeSnapshot() != nil:
145
+
return m.onSnapshot(s, msg.GetNodeSnapshot())
146
+
case msg.GetReserveResult() != nil:
147
+
s.deliver(msg.GetReserveResult().GetLeaseId(), msg)
148
+
case msg.GetCommitted() != nil:
149
+
s.deliver(msg.GetCommitted().GetLeaseId(), msg)
150
+
case msg.GetEventBatch() != nil:
151
+
return m.onEventBatch(s, msg.GetEventBatch())
152
+
case msg.GetCancelAck() != nil:
153
+
m.onCancelAck(s, msg.GetCancelAck())
154
+
case msg.GetLiveLog() != nil:
155
+
return m.onLiveLog(s, msg.GetLiveLog())
156
+
default:
157
+
s.l.Warn("session received unexpected message", "node", s.nodeID)
158
+
}
159
+
return nil
160
+
}
+23
spindle/mill/token.go
+23
spindle/mill/token.go
···
1
+
package mill
2
+
3
+
import (
4
+
"crypto/rand"
5
+
"crypto/sha256"
6
+
"encoding/base64"
7
+
"encoding/hex"
8
+
)
9
+
10
+
func GenerateToken() (string, error) {
11
+
var b [32]byte
12
+
if _, err := rand.Read(b[:]); err != nil {
13
+
return "", err
14
+
}
15
+
return base64.RawURLEncoding.EncodeToString(b[:]), nil
16
+
}
17
+
18
+
// hashes the token for persistence and comparison. a database leak never
19
+
// exposes a usable token, and lookup avoids a secret-dependent comparison
20
+
func HashToken(token string) string {
21
+
sum := sha256.Sum256([]byte(token))
22
+
return hex.EncodeToString(sum[:])
23
+
}
+236
-151
spindle/server.go
+236
-151
spindle/server.go
···
32
32
"tangled.org/core/rbac"
33
33
"tangled.org/core/repoident"
34
34
"tangled.org/core/repoverify"
35
+
"tangled.org/core/spindle/artifactstore"
35
36
"tangled.org/core/spindle/config"
36
37
"tangled.org/core/spindle/db"
37
38
"tangled.org/core/spindle/engine"
38
39
"tangled.org/core/spindle/engines/dummy"
39
40
"tangled.org/core/spindle/engines/nixery"
40
41
"tangled.org/core/spindle/git"
42
+
"tangled.org/core/spindle/mill"
43
+
"tangled.org/core/spindle/mill/executor"
41
44
"tangled.org/core/spindle/models"
42
45
"tangled.org/core/spindle/secrets"
43
46
"tangled.org/core/spindle/xrpc"
···
62
65
l *slog.Logger
63
66
n *notifier.Notifier
64
67
engs map[string]models.Engine
68
+
jobWake chan struct{}
65
69
cfg *config.Config
66
70
ks *eventconsumer.Consumer
67
71
res *idresolver.Resolver
···
70
74
motd []byte
71
75
motdMu sync.RWMutex
72
76
rootCtx context.Context
73
-
jobWake chan struct{}
77
+
store artifactstore.Store
78
+
stores *artifactstore.Stores
79
+
reader artifactstore.Reader
80
+
// set only when this spindle hosts the mill or joins one as an executor
81
+
mill *mill.Mill
82
+
exec *executor.Executor
74
83
}
75
84
76
85
// New creates a new Spindle server with the provided configuration and engines.
77
86
func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) {
78
87
logger := log.FromContext(ctx)
88
+
n := notifier.New()
89
+
90
+
if cfg.Role == config.RoleExecutor {
91
+
if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
92
+
return nil, fmt.Errorf("failed to run startup cleanup: %w", err)
93
+
}
94
+
} else if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil {
95
+
return nil, fmt.Errorf("failed to run startup migrations: %w", err)
96
+
}
97
+
98
+
spindle := &Spindle{
99
+
db: d,
100
+
l: logger,
101
+
n: &n,
102
+
engs: engines,
103
+
cfg: cfg,
104
+
motd: defaultMotd,
105
+
rootCtx: ctx,
106
+
jobWake: make(chan struct{}, 1),
107
+
}
108
+
diskFallback := ""
109
+
if cfg.Role == config.RoleStandalone {
110
+
diskFallback = cfg.Server.LogDir
111
+
if cfg.ArtifactStores.Disk.Dir == "" {
112
+
logger.Warn("using SPINDLE_SERVER_LOG_DIR as the implicit disk artifact store; configure SPINDLE_ARTIFACT_STORES_DISK_DIR explicitly")
113
+
}
114
+
}
115
+
stores, err := artifactstore.NewStores(cfg.ArtifactStores, diskFallback, cfg.LegacyS3.LogBucket)
116
+
if err != nil {
117
+
return nil, fmt.Errorf("failed to setup artifact stores: %w", err)
118
+
}
119
+
spindle.stores = stores
120
+
if cfg.LegacyS3.LogBucket != "" {
121
+
logger.Warn("SPINDLE_S3_LOG_BUCKET is deprecated; use SPINDLE_ARTIFACT_STORES_S3_BUCKET")
122
+
}
123
+
if cfg.Role == config.RoleStandalone {
124
+
spindle.reader = stores
125
+
} else {
126
+
name := cfg.Mill.ArtifactStore
127
+
if name == "" {
128
+
names := stores.Names()
129
+
if len(names) != 1 {
130
+
return nil, fmt.Errorf("%s requires SPINDLE_MILL_ARTIFACT_STORE when %d artifact stores are configured", cfg.Role, len(names))
131
+
}
132
+
name = names[0]
133
+
logger.Warn("SPINDLE_MILL_ARTIFACT_STORE is not set; inferred the only configured store", "store", name)
134
+
}
135
+
store, ok := stores.Store(name)
136
+
if !ok {
137
+
return nil, fmt.Errorf("SPINDLE_MILL_ARTIFACT_STORE=%q is not configured", name)
138
+
}
139
+
spindle.store = store
140
+
spindle.reader = store
141
+
}
142
+
if cfg.Role == config.RoleExecutor {
143
+
return spindle, nil
144
+
}
79
145
80
146
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
81
147
if err != nil {
82
148
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
83
149
}
84
150
e.E.EnableAutoSave(true)
151
+
spindle.e = e
85
152
86
-
n := notifier.New()
87
-
88
-
var vault secrets.Manager
89
153
switch cfg.Server.Secrets.Provider {
90
154
case "openbao":
91
155
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
92
156
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
93
157
}
94
-
vault, err = secrets.NewOpenBaoManager(
158
+
spindle.vault, err = secrets.NewOpenBaoManager(
95
159
cfg.Server.Secrets.OpenBao.ProxyAddr,
96
160
logger,
97
161
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
···
101
165
}
102
166
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
103
167
case "sqlite", "":
104
-
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
168
+
spindle.vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
105
169
if err != nil {
106
170
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
107
171
}
···
110
174
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
111
175
}
112
176
113
-
if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil {
114
-
return nil, fmt.Errorf("failed to run startup migrations: %w", err)
115
-
}
116
-
117
177
collections := []string{
118
178
tangled.SpindleMemberNSID,
119
179
tangled.RepoNSID,
···
124
184
if err != nil {
125
185
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
126
186
}
187
+
spindle.jc = jc
127
188
jc.AddDid(cfg.Server.Owner)
128
189
// pull records are created by arbitrary users too, same hack as in tap
129
190
jc.ExemptCollection(tangled.RepoPullNSID)
···
147
208
}
148
209
}
149
210
150
-
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
151
-
152
-
spindle := &Spindle{
153
-
jc: jc,
154
-
e: e,
155
-
db: d,
156
-
l: logger,
157
-
n: &n,
158
-
engs: engines,
159
-
cfg: cfg,
160
-
res: resolver,
161
-
verify: repoverify.New(resolver, cfg.Server.Dev),
162
-
vault: vault,
163
-
motd: defaultMotd,
164
-
rootCtx: ctx,
165
-
jobWake: make(chan struct{}, 1),
166
-
}
211
+
spindle.res = idresolver.DefaultResolver(cfg.Server.PlcUrl)
212
+
spindle.verify = repoverify.New(spindle.res, cfg.Server.Dev)
167
213
168
214
err = e.AddSpindle(rbacDomain)
169
215
if err != nil {
···
192
238
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
193
239
ccfg.ProcessFunc = spindle.processKnotStream
194
240
ccfg.CursorStore = cursorStore
195
-
ccfg.WorkerCount = 16
196
-
ccfg.QueueSize = 200
197
241
if cfg.Server.Dev {
198
242
ccfg.RetryInterval = 5 * time.Second
199
243
ccfg.MaxRetryInterval = 10 * time.Second
···
212
256
ccfg.Sources[src] = struct{}{}
213
257
}
214
258
spindle.ks = eventconsumer.NewConsumer(*ccfg)
215
-
216
259
if cfg.Server.Tap.Embed {
217
260
pw, err := randomAdminPassword()
218
261
if err != nil {
···
225
268
226
269
return spindle, nil
227
270
}
228
-
229
-
// DB returns the database instance.
230
271
func (s *Spindle) DB() *db.DB {
231
272
return s.db
232
273
}
···
265
306
return s.motd
266
307
}
267
308
268
-
// Start starts the Spindle server (blocking).
309
+
// runs the server. blocks
269
310
func (s *Spindle) Start(ctx context.Context) error {
270
-
// starts a job queue runner in the background
311
+
// only standalone runs the local queue. mill hosts place directly onto
312
+
// executors, and executors only run jobs explicitly assigned by a mill
271
313
s.StartJobWorkers(ctx)
272
314
273
-
// Stop vault token renewal if it implements Stopper
315
+
// an executor dials out to its mill and takes work from it
316
+
if s.exec != nil {
317
+
go s.exec.Connect(ctx)
318
+
}
319
+
274
320
if stopper, ok := s.vault.(secrets.Stopper); ok {
275
321
defer stopper.Stop()
276
322
}
277
323
278
-
tapCtx, tapCancel := context.WithCancel(ctx)
324
+
if s.cfg.Role != config.RoleExecutor {
325
+
tapCtx, tapCancel := context.WithCancel(ctx)
279
326
280
-
if s.cfg.Server.Tap.Embed {
281
-
emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap"))
282
-
if err != nil {
283
-
tapCancel()
284
-
return fmt.Errorf("starting embedded tap: %w", err)
327
+
if s.cfg.Server.Tap.Embed {
328
+
emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap"))
329
+
if err != nil {
330
+
tapCancel()
331
+
return fmt.Errorf("starting embedded tap: %w", err)
332
+
}
333
+
s.embedTap = emb
334
+
defer func() {
335
+
tapCancel()
336
+
s.embedTap.Shutdown()
337
+
}()
338
+
339
+
go s.watchTapDrain(tapCtx, tapCancel)
340
+
} else {
341
+
defer tapCancel()
285
342
}
286
-
s.embedTap = emb
287
-
defer func() {
288
-
tapCancel()
289
-
s.embedTap.Shutdown()
343
+
344
+
go func() {
345
+
s.l.Info("starting knot event consumer")
346
+
s.ks.Start(ctx)
290
347
}()
291
348
292
-
go s.watchTapDrain(tapCtx, tapCancel)
293
-
} else {
294
-
defer tapCancel()
349
+
s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url)
350
+
s.tap.Start(tapCtx)
295
351
}
296
352
297
-
go func() {
298
-
s.l.Info("starting knot event consumer")
299
-
s.ks.Start(ctx)
300
-
}()
301
-
302
-
s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url)
303
-
s.tap.Start(tapCtx)
304
-
305
353
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
306
354
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
307
355
}
···
346
394
return fmt.Errorf("failed to setup db: %w", err)
347
395
}
348
396
349
-
nixeryEng, err := nixery.New(ctx, cfg)
350
-
if err != nil {
351
-
return err
397
+
logger := log.FromContext(ctx)
398
+
399
+
var engines map[string]models.Engine
400
+
var m *mill.Mill
401
+
402
+
if cfg.Role == config.RoleMill {
403
+
// mill host: register engines that place jobs on executors instead of
404
+
// running them. all names share one Mill
405
+
m = mill.New(log.SubLogger(logger, "mill"), mill.Config{
406
+
LogDir: cfg.Server.LogDir,
407
+
MaxPending: cfg.Mill.MaxPending,
408
+
ReconnectGrace: cfg.Mill.ReconnectGrace,
409
+
})
410
+
engines = map[string]models.Engine{
411
+
"nixery": mill.NewEngine("nixery", m),
412
+
"microvm": mill.NewEngine("microvm", m),
413
+
"dummy": mill.NewEngine("dummy", m),
414
+
}
415
+
} else {
416
+
// standalone and executor both run real engines locally.
417
+
nixeryEng, err := nixery.New(ctx, cfg)
418
+
if err != nil {
419
+
return err
420
+
}
421
+
microvmEng, err := newMicrovmEngine(ctx, cfg, d)
422
+
if err != nil {
423
+
return err
424
+
}
425
+
engines = map[string]models.Engine{
426
+
"nixery": nixeryEng,
427
+
"microvm": microvmEng,
428
+
"dummy": dummy.New(logger),
429
+
}
352
430
}
353
431
354
-
microvmEng, err := newMicrovmEngine(ctx, cfg, d)
432
+
s, err := New(ctx, cfg, d, engines)
355
433
if err != nil {
356
434
return err
357
435
}
358
436
359
-
s, err := New(ctx, cfg, d, map[string]models.Engine{
360
-
"nixery": nixeryEng,
361
-
"microvm": microvmEng,
362
-
"dummy": dummy.New(log.FromContext(ctx)),
363
-
})
364
-
if err != nil {
365
-
return err
437
+
if m != nil {
438
+
// resolve the chicken-and-egg: the engines (built above) hold the mill,
439
+
// but the mill's db/notifier are created inside New
440
+
m.Attach(s.DB(), s.Notifier())
441
+
s.mill = m
442
+
if err := m.RestoreState(); err != nil {
443
+
return fmt.Errorf("restoring mill state: %w", err)
444
+
}
445
+
}
446
+
if cfg.Role == config.RoleExecutor {
447
+
s.exec, err = executor.New(cfg, engines, s.DB(), s.Notifier(), log.SubLogger(logger, "executor"), s.store)
448
+
if err != nil {
449
+
return err
450
+
}
366
451
}
367
452
368
453
return s.Start(ctx)
···
374
459
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
375
460
w.Write(s.GetMotdContent())
376
461
})
462
+
if s.cfg.Role == config.RoleExecutor {
463
+
return mux
464
+
}
465
+
377
466
mux.HandleFunc("/events", s.Events)
378
467
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
379
468
469
+
// mill host: executors dial in here (plain ws, shared-secret auth)
470
+
if s.mill != nil {
471
+
mux.HandleFunc("/mill", s.mill.HandleExecutorConn)
472
+
}
473
+
380
474
mux.Mount("/xrpc", s.XrpcRouter())
381
475
return mux
382
476
}
···
387
481
l := log.SubLogger(s.l, "xrpc")
388
482
389
483
x := xrpc.Xrpc{
390
-
Logger: l,
391
-
Db: s.db,
392
-
Enforcer: s.e,
393
-
Engines: s.engs,
394
-
Config: s.cfg,
395
-
Resolver: s.res,
396
-
Vault: s.vault,
397
-
Notifier: s.Notifier(),
398
-
ServiceAuth: serviceAuth,
399
-
Trigger: s,
484
+
Logger: l,
485
+
Db: s.db,
486
+
Enforcer: s.e,
487
+
Engines: s.engs,
488
+
Config: s.cfg,
489
+
ArtifactReader: s.reader,
490
+
Resolver: s.res,
491
+
Vault: s.vault,
492
+
Notifier: s.Notifier(),
493
+
ServiceAuth: serviceAuth,
494
+
Trigger: s,
400
495
}
401
496
402
497
return x.Router()
···
435
530
// NOTE: we are blindly trusting the knot that it will return only repos it own
436
531
repoCloneUri := s.newRepoCloneUrl(src.Host, repoDid)
437
532
repoPath := s.newRepoPath(repoDid)
533
+
if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil {
534
+
return fmt.Errorf("sync git repo: %w", err)
535
+
}
536
+
l.Info("synced git repo")
438
537
439
538
triggerRepo, err := s.buildTriggerRepo(ctx, repo)
440
539
if err != nil {
···
747
846
return rawPipeline, nil
748
847
}
749
848
849
+
// newRepoPath creates a path to store repository by its did and rkey.
850
+
// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey
851
+
func (s *Spindle) newRepoPath(repo syntax.DID) string {
852
+
return filepath.Join(s.cfg.Server.RepoDir, repo.String())
853
+
}
854
+
855
+
func (s *Spindle) newRepoCloneUrl(knot string, did syntax.DID) string {
856
+
scheme := "https://"
857
+
if s.cfg.Server.Dev {
858
+
scheme = "http://"
859
+
}
860
+
return fmt.Sprintf("%s%s/%s", scheme, knot, did)
861
+
}
862
+
863
+
const RequiredVersion = "2.49.0"
864
+
865
+
func ensureGitVersion() error {
866
+
v, err := git.Version()
867
+
if err != nil {
868
+
return fmt.Errorf("fetching git version: %w", err)
869
+
}
870
+
if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) {
871
+
return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion)
872
+
}
873
+
return nil
874
+
}
875
+
876
+
func (s *Spindle) configureOwner() error {
877
+
cfgOwner := s.cfg.Server.Owner
878
+
879
+
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
880
+
if err != nil {
881
+
return err
882
+
}
883
+
884
+
switch len(existing) {
885
+
case 0:
886
+
// no owner configured, continue
887
+
case 1:
888
+
// find existing owner
889
+
existingOwner := existing[0]
890
+
891
+
// no ownership change, this is okay
892
+
if existingOwner == s.cfg.Server.Owner {
893
+
break
894
+
}
895
+
896
+
// remove existing owner
897
+
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
898
+
if err != nil {
899
+
return nil
900
+
}
901
+
default:
902
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
903
+
}
904
+
905
+
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
906
+
}
907
+
750
908
func (s *Spindle) StartJobWorkers(ctx context.Context) {
751
909
for range s.cfg.Server.MaxJobCount {
752
910
go func() {
···
820
978
workflows[eng] = append(workflows[eng], *ewf)
821
979
}
822
980
823
-
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, &models.Pipeline{
981
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.stores, s.db, s.n, s.rootCtx, &models.Pipeline{
824
982
RepoDid: syntax.DID(job.RepoDid),
825
983
Workflows: workflows,
826
984
TrustedSource: trustedSource,
···
855
1013
}
856
1014
return nil
857
1015
}
858
-
859
-
// newRepoPath creates a path to store repository by its did and rkey.
860
-
// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey
861
-
func (s *Spindle) newRepoPath(repo syntax.DID) string {
862
-
return filepath.Join(s.cfg.Server.RepoDir, repo.String())
863
-
}
864
-
865
-
func (s *Spindle) newRepoCloneUrl(knot string, did syntax.DID) string {
866
-
scheme := "https://"
867
-
if s.cfg.Server.Dev {
868
-
scheme = "http://"
869
-
}
870
-
return fmt.Sprintf("%s%s/%s", scheme, knot, did)
871
-
}
872
-
873
-
const RequiredVersion = "2.49.0"
874
-
875
-
func ensureGitVersion() error {
876
-
v, err := git.Version()
877
-
if err != nil {
878
-
return fmt.Errorf("fetching git version: %w", err)
879
-
}
880
-
if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) {
881
-
return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion)
882
-
}
883
-
return nil
884
-
}
885
-
886
-
func (s *Spindle) resolvePipelineRepoDid(repo *tangled.Pipeline_TriggerRepo) (syntax.DID, error) {
887
-
if repo.RepoDid == nil || *repo.RepoDid == "" {
888
-
return "", fmt.Errorf("pipeline trigger missing repoDid")
889
-
}
890
-
repoDid, err := syntax.ParseDID(*repo.RepoDid)
891
-
if err != nil {
892
-
return "", fmt.Errorf("parse repoDid %s: %w", *repo.RepoDid, err)
893
-
}
894
-
if _, err := s.db.GetRepoByDid(repoDid); err != nil {
895
-
return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
896
-
}
897
-
return repoDid, nil
898
-
}
899
-
900
-
func (s *Spindle) configureOwner() error {
901
-
cfgOwner := s.cfg.Server.Owner
902
-
903
-
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
904
-
if err != nil {
905
-
return err
906
-
}
907
-
908
-
switch len(existing) {
909
-
case 0:
910
-
// no owner configured, continue
911
-
case 1:
912
-
// find existing owner
913
-
existingOwner := existing[0]
914
-
915
-
// no ownership change, this is okay
916
-
if existingOwner == s.cfg.Server.Owner {
917
-
break
918
-
}
919
-
920
-
// remove existing owner
921
-
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
922
-
if err != nil {
923
-
return nil
924
-
}
925
-
default:
926
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
927
-
}
928
-
929
-
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
930
-
}
+930
spindle/server.go.master
+930
spindle/server.go.master
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
_ "embed"
7
+
"encoding/json"
8
+
"errors"
9
+
"fmt"
10
+
"log/slog"
11
+
"maps"
12
+
"net/http"
13
+
"path/filepath"
14
+
"sync"
15
+
"time"
16
+
17
+
"github.com/bluesky-social/indigo/atproto/syntax"
18
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/go-chi/chi/v5"
20
+
"github.com/go-git/go-git/v5/plumbing/object"
21
+
"github.com/hashicorp/go-version"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/eventconsumer"
24
+
"tangled.org/core/eventconsumer/cursor"
25
+
"tangled.org/core/eventstream"
26
+
"tangled.org/core/idresolver"
27
+
"tangled.org/core/jetstream"
28
+
knotdb "tangled.org/core/knotserver/db"
29
+
kgit "tangled.org/core/knotserver/git"
30
+
"tangled.org/core/log"
31
+
"tangled.org/core/notifier"
32
+
"tangled.org/core/rbac"
33
+
"tangled.org/core/repoident"
34
+
"tangled.org/core/repoverify"
35
+
"tangled.org/core/spindle/config"
36
+
"tangled.org/core/spindle/db"
37
+
"tangled.org/core/spindle/engine"
38
+
"tangled.org/core/spindle/engines/dummy"
39
+
"tangled.org/core/spindle/engines/nixery"
40
+
"tangled.org/core/spindle/git"
41
+
"tangled.org/core/spindle/models"
42
+
"tangled.org/core/spindle/secrets"
43
+
"tangled.org/core/spindle/xrpc"
44
+
"tangled.org/core/tid"
45
+
"tangled.org/core/workflow"
46
+
"tangled.org/core/xrpc/serviceauth"
47
+
)
48
+
49
+
//go:embed motd
50
+
var defaultMotd []byte
51
+
52
+
const (
53
+
rbacDomain = "thisserver"
54
+
)
55
+
56
+
type Spindle struct {
57
+
jc *jetstream.JetstreamClient
58
+
tap *Tap
59
+
embedTap *embeddedTap
60
+
db *db.DB
61
+
e *rbac.Enforcer
62
+
l *slog.Logger
63
+
n *notifier.Notifier
64
+
engs map[string]models.Engine
65
+
cfg *config.Config
66
+
ks *eventconsumer.Consumer
67
+
res *idresolver.Resolver
68
+
verify repoverify.Verifier
69
+
vault secrets.Manager
70
+
motd []byte
71
+
motdMu sync.RWMutex
72
+
rootCtx context.Context
73
+
jobWake chan struct{}
74
+
}
75
+
76
+
// New creates a new Spindle server with the provided configuration and engines.
77
+
func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) {
78
+
logger := log.FromContext(ctx)
79
+
80
+
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
81
+
if err != nil {
82
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
83
+
}
84
+
e.E.EnableAutoSave(true)
85
+
86
+
n := notifier.New()
87
+
88
+
var vault secrets.Manager
89
+
switch cfg.Server.Secrets.Provider {
90
+
case "openbao":
91
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
92
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
93
+
}
94
+
vault, err = secrets.NewOpenBaoManager(
95
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
96
+
logger,
97
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
98
+
)
99
+
if err != nil {
100
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
101
+
}
102
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
103
+
case "sqlite", "":
104
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
105
+
if err != nil {
106
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
107
+
}
108
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
109
+
default:
110
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
111
+
}
112
+
113
+
if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil {
114
+
return nil, fmt.Errorf("failed to run startup migrations: %w", err)
115
+
}
116
+
117
+
collections := []string{
118
+
tangled.SpindleMemberNSID,
119
+
tangled.RepoNSID,
120
+
tangled.RepoCollaboratorNSID,
121
+
tangled.RepoPullNSID,
122
+
}
123
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
124
+
if err != nil {
125
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
126
+
}
127
+
jc.AddDid(cfg.Server.Owner)
128
+
// pull records are created by arbitrary users too, same hack as in tap
129
+
jc.ExemptCollection(tangled.RepoPullNSID)
130
+
131
+
// Check if the spindle knows about any Dids;
132
+
dids, err := d.GetAllDids()
133
+
if err != nil {
134
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
135
+
}
136
+
for _, d := range dids {
137
+
jc.AddDid(d)
138
+
}
139
+
140
+
knownRepos, err := d.AllRepos()
141
+
if err != nil {
142
+
return nil, fmt.Errorf("failed to get known repos: %w", err)
143
+
}
144
+
for _, r := range knownRepos {
145
+
if r.Owner != "" {
146
+
jc.AddDid(r.Owner.String())
147
+
}
148
+
}
149
+
150
+
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
151
+
152
+
spindle := &Spindle{
153
+
jc: jc,
154
+
e: e,
155
+
db: d,
156
+
l: logger,
157
+
n: &n,
158
+
engs: engines,
159
+
cfg: cfg,
160
+
res: resolver,
161
+
verify: repoverify.New(resolver, cfg.Server.Dev),
162
+
vault: vault,
163
+
motd: defaultMotd,
164
+
rootCtx: ctx,
165
+
jobWake: make(chan struct{}, 1),
166
+
}
167
+
168
+
err = e.AddSpindle(rbacDomain)
169
+
if err != nil {
170
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
171
+
}
172
+
err = spindle.configureOwner()
173
+
if err != nil {
174
+
return nil, err
175
+
}
176
+
logger.Info("owner set", "did", cfg.Server.Owner)
177
+
178
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
179
+
if err != nil {
180
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
181
+
}
182
+
183
+
err = jc.StartJetstream(ctx, spindle.ingest())
184
+
if err != nil {
185
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
186
+
}
187
+
188
+
// spindle listen to knot stream for sh.tangled.git.refUpdate
189
+
// which will sync the local workflow files in spindle and enqueues the
190
+
// pipeline job for on-push workflows
191
+
ccfg := eventconsumer.NewConsumerConfig()
192
+
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
193
+
ccfg.ProcessFunc = spindle.processKnotStream
194
+
ccfg.CursorStore = cursorStore
195
+
ccfg.WorkerCount = 16
196
+
ccfg.QueueSize = 200
197
+
if cfg.Server.Dev {
198
+
ccfg.RetryInterval = 5 * time.Second
199
+
ccfg.MaxRetryInterval = 10 * time.Second
200
+
} else {
201
+
ccfg.RetryInterval = 1 * time.Minute
202
+
ccfg.MaxRetryInterval = 10 * time.Minute
203
+
}
204
+
knownKnots, err := d.Knots()
205
+
if err != nil {
206
+
return nil, err
207
+
}
208
+
for _, knot := range knownKnots {
209
+
logger.Info("adding source start", "knot", knot)
210
+
src := eventconsumer.NewKnotSource(knot)
211
+
eventconsumer.MigrateLegacyCursor(cursorStore, src)
212
+
ccfg.Sources[src] = struct{}{}
213
+
}
214
+
spindle.ks = eventconsumer.NewConsumer(*ccfg)
215
+
216
+
if cfg.Server.Tap.Embed {
217
+
pw, err := randomAdminPassword()
218
+
if err != nil {
219
+
return nil, err
220
+
}
221
+
cfg.Server.Tap.AdminPassword = pw
222
+
logger.Info("embedded tap: using random admin password")
223
+
}
224
+
spindle.tap = NewTapClient(spindle)
225
+
226
+
return spindle, nil
227
+
}
228
+
229
+
// DB returns the database instance.
230
+
func (s *Spindle) DB() *db.DB {
231
+
return s.db
232
+
}
233
+
234
+
// Engines returns the map of available engines.
235
+
func (s *Spindle) Engines() map[string]models.Engine {
236
+
return s.engs
237
+
}
238
+
239
+
// Vault returns the secrets manager instance.
240
+
func (s *Spindle) Vault() secrets.Manager {
241
+
return s.vault
242
+
}
243
+
244
+
// Notifier returns the notifier instance.
245
+
func (s *Spindle) Notifier() *notifier.Notifier {
246
+
return s.n
247
+
}
248
+
249
+
// Enforcer returns the RBAC enforcer instance.
250
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
251
+
return s.e
252
+
}
253
+
254
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
255
+
func (s *Spindle) SetMotdContent(content []byte) {
256
+
s.motdMu.Lock()
257
+
defer s.motdMu.Unlock()
258
+
s.motd = content
259
+
}
260
+
261
+
// GetMotdContent returns the current MOTD content.
262
+
func (s *Spindle) GetMotdContent() []byte {
263
+
s.motdMu.RLock()
264
+
defer s.motdMu.RUnlock()
265
+
return s.motd
266
+
}
267
+
268
+
// Start starts the Spindle server (blocking).
269
+
func (s *Spindle) Start(ctx context.Context) error {
270
+
// starts a job queue runner in the background
271
+
s.StartJobWorkers(ctx)
272
+
273
+
// Stop vault token renewal if it implements Stopper
274
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
275
+
defer stopper.Stop()
276
+
}
277
+
278
+
tapCtx, tapCancel := context.WithCancel(ctx)
279
+
280
+
if s.cfg.Server.Tap.Embed {
281
+
emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap"))
282
+
if err != nil {
283
+
tapCancel()
284
+
return fmt.Errorf("starting embedded tap: %w", err)
285
+
}
286
+
s.embedTap = emb
287
+
defer func() {
288
+
tapCancel()
289
+
s.embedTap.Shutdown()
290
+
}()
291
+
292
+
go s.watchTapDrain(tapCtx, tapCancel)
293
+
} else {
294
+
defer tapCancel()
295
+
}
296
+
297
+
go func() {
298
+
s.l.Info("starting knot event consumer")
299
+
s.ks.Start(ctx)
300
+
}()
301
+
302
+
s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url)
303
+
s.tap.Start(tapCtx)
304
+
305
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
306
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
307
+
}
308
+
309
+
func (s *Spindle) declareTapInterest(ctx context.Context) {
310
+
repos, err := s.db.AllRepos()
311
+
if err != nil {
312
+
s.l.Warn("tap declare: failed to load known repos", "err", err)
313
+
return
314
+
}
315
+
seen := make(map[syntax.DID]struct{}, len(repos))
316
+
dids := make([]syntax.DID, 0, len(repos))
317
+
for _, r := range repos {
318
+
if r.Owner == "" {
319
+
continue
320
+
}
321
+
if _, ok := seen[r.Owner]; ok {
322
+
continue
323
+
}
324
+
seen[r.Owner] = struct{}{}
325
+
dids = append(dids, r.Owner)
326
+
}
327
+
if err := s.tap.AddOwnerDIDs(ctx, dids); err != nil {
328
+
s.l.Warn("tap declare: AddRepos rejected", "count", len(dids), "err", err)
329
+
return
330
+
}
331
+
s.l.Info("tap declare: known owner DIDs registered", "count", len(dids))
332
+
}
333
+
334
+
func Run(ctx context.Context) error {
335
+
cfg, err := config.Load(ctx)
336
+
if err != nil {
337
+
return fmt.Errorf("failed to load config: %w", err)
338
+
}
339
+
340
+
if err := ensureGitVersion(); err != nil {
341
+
return fmt.Errorf("ensuring git version: %w", err)
342
+
}
343
+
344
+
d, err := db.Make(ctx, cfg.Server.DBPath)
345
+
if err != nil {
346
+
return fmt.Errorf("failed to setup db: %w", err)
347
+
}
348
+
349
+
nixeryEng, err := nixery.New(ctx, cfg)
350
+
if err != nil {
351
+
return err
352
+
}
353
+
354
+
microvmEng, err := newMicrovmEngine(ctx, cfg, d)
355
+
if err != nil {
356
+
return err
357
+
}
358
+
359
+
s, err := New(ctx, cfg, d, map[string]models.Engine{
360
+
"nixery": nixeryEng,
361
+
"microvm": microvmEng,
362
+
"dummy": dummy.New(log.FromContext(ctx)),
363
+
})
364
+
if err != nil {
365
+
return err
366
+
}
367
+
368
+
return s.Start(ctx)
369
+
}
370
+
371
+
func (s *Spindle) Router() http.Handler {
372
+
mux := chi.NewRouter()
373
+
374
+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
375
+
w.Write(s.GetMotdContent())
376
+
})
377
+
mux.HandleFunc("/events", s.Events)
378
+
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
379
+
380
+
mux.Mount("/xrpc", s.XrpcRouter())
381
+
return mux
382
+
}
383
+
384
+
func (s *Spindle) XrpcRouter() http.Handler {
385
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res.Directory(), s.cfg.Server.Did().String())
386
+
387
+
l := log.SubLogger(s.l, "xrpc")
388
+
389
+
x := xrpc.Xrpc{
390
+
Logger: l,
391
+
Db: s.db,
392
+
Enforcer: s.e,
393
+
Engines: s.engs,
394
+
Config: s.cfg,
395
+
Resolver: s.res,
396
+
Vault: s.vault,
397
+
Notifier: s.Notifier(),
398
+
ServiceAuth: serviceAuth,
399
+
Trigger: s,
400
+
}
401
+
402
+
return x.Router()
403
+
}
404
+
405
+
func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error {
406
+
l := log.FromContext(ctx).With("handler", "processKnotStream")
407
+
l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey)
408
+
if msg.Nsid == knotdb.RepoCollaboratorUpdateNSID {
409
+
return s.ingestKnotCollaborator(ctx, l, src, msg)
410
+
}
411
+
if msg.Nsid == tangled.GitRefUpdateNSID {
412
+
event := tangled.GitRefUpdate{}
413
+
if err := json.Unmarshal(msg.EventJson, &event); err != nil {
414
+
l.Error("error unmarshalling", "err", err)
415
+
return err
416
+
}
417
+
l = l.With("repo", event.Repo, "ref", event.Ref, "newSha", event.NewSha)
418
+
l.Debug("debug")
419
+
420
+
repoDid := syntax.DID(event.Repo)
421
+
repo, err := s.db.GetRepoByDid(repoDid)
422
+
if err != nil {
423
+
return fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
424
+
}
425
+
426
+
if src.Host != repo.Knot {
427
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Host, repo.Knot)
428
+
}
429
+
430
+
if kgit.HasSkipCIPushOption(event.PushOptions) {
431
+
l.Info("push event requested ci skip, skipping the event")
432
+
return nil
433
+
}
434
+
435
+
// NOTE: we are blindly trusting the knot that it will return only repos it own
436
+
repoCloneUri := s.newRepoCloneUrl(src.Host, repoDid)
437
+
repoPath := s.newRepoPath(repoDid)
438
+
439
+
triggerRepo, err := s.buildTriggerRepo(ctx, repo)
440
+
if err != nil {
441
+
return fmt.Errorf("building trigger repo: %w", err)
442
+
}
443
+
444
+
trigger := tangled.Pipeline_TriggerMetadata{
445
+
Kind: string(workflow.TriggerKindPush),
446
+
Push: &tangled.Pipeline_PushTriggerData{
447
+
Ref: event.Ref,
448
+
OldSha: event.OldSha,
449
+
NewSha: event.NewSha,
450
+
},
451
+
Repo: triggerRepo,
452
+
}
453
+
454
+
pipelineId, err := s.runPipeline(ctx, repoDid, trigger, event.ChangedFiles, repoCloneUri, repoPath, event.NewSha, nil, triggerRepo)
455
+
if err != nil {
456
+
return err
457
+
}
458
+
if pipelineId.Rkey == "" {
459
+
l.Info("no workflow matched 'push' trigger, skipping the event")
460
+
return nil
461
+
}
462
+
l.Info("pipeline triggered", "pipeline", pipelineId.AtUri())
463
+
}
464
+
465
+
return nil
466
+
}
467
+
468
+
func (s *Spindle) ingestKnotCollaborator(ctx context.Context, l *slog.Logger, src eventconsumer.Source, msg eventstream.Event) error {
469
+
var rec knotdb.RepoCollaboratorUpdate
470
+
if err := json.Unmarshal(msg.EventJson, &rec); err != nil {
471
+
l.Error("error unmarshalling collaboratorUpdate", "err", err)
472
+
return err
473
+
}
474
+
475
+
subject, err := syntax.ParseDID(rec.Subject)
476
+
if err != nil {
477
+
l.Info("skipping collaboratorUpdate with malformed subject", "subject", rec.Subject, "err", err)
478
+
return nil
479
+
}
480
+
repoDid, err := syntax.ParseDID(rec.Repo)
481
+
if err != nil {
482
+
l.Info("skipping collaboratorUpdate with malformed repo", "repo", rec.Repo, "err", err)
483
+
return nil
484
+
}
485
+
486
+
repo, err := s.db.GetRepoByDid(repoDid)
487
+
if errors.Is(err, sql.ErrNoRows) {
488
+
l.Info("skipping collaboratorUpdate for unknown repo", "repo", repoDid)
489
+
return nil
490
+
}
491
+
if err != nil {
492
+
return fmt.Errorf("lookup repo %s: %w", repoDid, err)
493
+
}
494
+
if src.Host != repo.Knot {
495
+
l.Warn("dropping collaboratorUpdate from non-owning knot", "src", src.Host, "repoKnot", repo.Knot)
496
+
return nil
497
+
}
498
+
499
+
switch rec.Op {
500
+
case knotdb.AclOpAdd:
501
+
if err := s.e.AddCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil {
502
+
return fmt.Errorf("add collaborator policy: %w", err)
503
+
}
504
+
if err := s.db.AddKnotCollaborator(repoDid, subject); err != nil {
505
+
return fmt.Errorf("track collaborator: %w", err)
506
+
}
507
+
l.Info("added knot-managed collaborator", "subject", subject, "repo", repoDid)
508
+
case knotdb.AclOpRemove:
509
+
if err := s.e.RemoveCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil {
510
+
return fmt.Errorf("remove collaborator policy: %w", err)
511
+
}
512
+
if err := s.db.DeleteRepoCollaboratorBySubjectRepo(subject, repoDid); err != nil {
513
+
return fmt.Errorf("delete collaborator row: %w", err)
514
+
}
515
+
l.Info("removed knot-managed collaborator", "subject", subject, "repo", repoDid)
516
+
default:
517
+
return fmt.Errorf("collaboratorUpdate unknown op %q", rec.Op)
518
+
}
519
+
return nil
520
+
}
521
+
522
+
// buildTriggerRepo gathers trigger metadata, resolving default branch from the knot
523
+
func (s *Spindle) buildTriggerRepo(ctx context.Context, repo *db.Repo) (*tangled.Pipeline_TriggerRepo, error) {
524
+
rkey := string(repo.Rkey)
525
+
repoDid := repo.RepoDid.String()
526
+
return s.buildTriggerRepoFrom(ctx, repo.Knot, repo.Owner.String(), rkey, repoDid), nil
527
+
}
528
+
529
+
func (s *Spindle) buildTriggerRepoFrom(ctx context.Context, knot, did, rkey, repoDid string) *tangled.Pipeline_TriggerRepo {
530
+
scheme := "https"
531
+
if s.cfg.Server.Dev {
532
+
scheme = "http"
533
+
}
534
+
client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, knot)}
535
+
536
+
// this should maybe (?) be in the refUpdate event itself to save a roundtrip
537
+
defaultBranch := ""
538
+
if out, err := tangled.RepoGetDefaultBranch(ctx, client, repoDid); err == nil {
539
+
defaultBranch = out.Name
540
+
}
541
+
542
+
var rkeyPtr *string
543
+
if rkey != "" {
544
+
rkeyPtr = &rkey
545
+
}
546
+
return &tangled.Pipeline_TriggerRepo{
547
+
Did: did,
548
+
Knot: knot,
549
+
Repo: rkeyPtr,
550
+
RepoDid: &repoDid,
551
+
DefaultBranch: defaultBranch,
552
+
}
553
+
}
554
+
555
+
func (s *Spindle) resolvePipelineSourceRepo(ctx context.Context, trigger *tangled.Pipeline_TriggerMetadata) (*tangled.Pipeline_TriggerRepo, error) {
556
+
if trigger == nil {
557
+
return nil, nil
558
+
}
559
+
if trigger.SourceRepo == nil || *trigger.SourceRepo == "" {
560
+
return trigger.Repo, nil
561
+
}
562
+
repoDid, err := syntax.ParseDID(*trigger.SourceRepo)
563
+
if err != nil {
564
+
return nil, fmt.Errorf("parse sourceRepo %s: %w", *trigger.SourceRepo, err)
565
+
}
566
+
return s.resolveSourceRepoInfo(ctx, repoDid)
567
+
}
568
+
569
+
// resolveSourceRepoInfo resolves trigger-repo metadata for a source repo DID.
570
+
func (s *Spindle) resolveSourceRepoInfo(ctx context.Context, repoDid syntax.DID) (*tangled.Pipeline_TriggerRepo, error) {
571
+
repo, err := s.db.GetRepoByDid(repoDid)
572
+
if err == nil {
573
+
return s.buildTriggerRepo(ctx, repo)
574
+
}
575
+
576
+
// verify repo, we don't want git sync to point to arbitrary endpoints
577
+
res, err := s.verify(ctx, repoident.RepoDid(repoDid))
578
+
if err != nil {
579
+
return nil, fmt.Errorf("verify sourceRepo %s: %w", repoDid, err)
580
+
}
581
+
return s.buildTriggerRepoFrom(ctx, res.KnotURL.Host, res.OwnerDid.String(), res.Rkey, repoDid.String()), nil
582
+
}
583
+
584
+
// runPipeline compiles and enqueues the pipeline for the given revision.
585
+
// sourceRepo is the resolved repo the code was checked out from, forwarded to
586
+
// processPipeline for env vars.
587
+
func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger tangled.Pipeline_TriggerMetadata, changedFiles []string, repoCloneUri, repoPath, rev string, only []string, sourceRepo *tangled.Pipeline_TriggerRepo) (models.PipelineId, error) {
588
+
l := log.FromContext(ctx)
589
+
590
+
compiler := workflow.Compiler{
591
+
ChangedFiles: changedFiles,
592
+
Trigger: trigger,
593
+
}
594
+
595
+
rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, rev)
596
+
if err != nil {
597
+
return models.PipelineId{}, fmt.Errorf("loading pipeline: %w", err)
598
+
}
599
+
if len(rawPipeline) == 0 {
600
+
return models.PipelineId{}, nil
601
+
}
602
+
603
+
tpl := compiler.Compile(compiler.Parse(rawPipeline))
604
+
// todo(dawn): pass compile error to workflow log
605
+
for _, w := range compiler.Diagnostics.Errors {
606
+
l.Error(w.String())
607
+
}
608
+
for _, w := range compiler.Diagnostics.Warnings {
609
+
l.Warn(w.String())
610
+
}
611
+
612
+
if len(only) > 0 {
613
+
tpl.Workflows = filterWorkflows(tpl.Workflows, only)
614
+
}
615
+
if len(tpl.Workflows) == 0 {
616
+
return models.PipelineId{}, nil
617
+
}
618
+
619
+
pipelineId := models.PipelineId{
620
+
Knot: trigger.Repo.Knot,
621
+
Rkey: tid.TID(),
622
+
}
623
+
if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil {
624
+
return models.PipelineId{}, fmt.Errorf("creating pipeline event: %w", err)
625
+
}
626
+
err = s.processPipeline(repoDid, tpl, pipelineId, sourceRepo)
627
+
return pipelineId, err
628
+
}
629
+
630
+
// filterWorkflows filters workflows to the requested names
631
+
func filterWorkflows(workflows []*tangled.Pipeline_Workflow, only []string) []*tangled.Pipeline_Workflow {
632
+
allowed := make(map[string]struct{}, len(only))
633
+
for _, n := range only {
634
+
allowed[n] = struct{}{}
635
+
}
636
+
var filtered []*tangled.Pipeline_Workflow
637
+
for _, w := range workflows {
638
+
if w == nil {
639
+
continue
640
+
}
641
+
if _, ok := allowed[w.Name]; ok {
642
+
filtered = append(filtered, w)
643
+
}
644
+
}
645
+
return filtered
646
+
}
647
+
648
+
// TriggerManual dispatches a pipeline at sha, authorized against and recorded
649
+
// under repoDid. sourceRepo, pull, and inputs are optional trigger payload.
650
+
func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull xrpc.PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) {
651
+
repo, err := s.db.GetRepoByDid(repoDid)
652
+
if err != nil {
653
+
return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
654
+
}
655
+
656
+
triggerRepo, err := s.buildTriggerRepo(ctx, repo)
657
+
if err != nil {
658
+
return "", fmt.Errorf("building trigger repo: %w", err)
659
+
}
660
+
661
+
trigger := tangled.Pipeline_TriggerMetadata{Repo: triggerRepo}
662
+
if pull.IsPullRequest {
663
+
var pullAt *string
664
+
if pull.Pull != "" {
665
+
pullAtStr := pull.Pull.String()
666
+
pullAt = &pullAtStr
667
+
}
668
+
trigger.Kind = string(workflow.TriggerKindPullRequest)
669
+
trigger.PullRequest = &tangled.Pipeline_PullRequestTriggerData{
670
+
SourceBranch: pull.SourceBranch,
671
+
TargetBranch: pull.TargetBranch,
672
+
SourceSha: sha,
673
+
Pull: pullAt,
674
+
}
675
+
} else {
676
+
var refPtr *string
677
+
if ref != "" {
678
+
refPtr = &ref
679
+
}
680
+
trigger.Kind = string(workflow.TriggerKindManual)
681
+
trigger.Manual = &tangled.Pipeline_ManualTriggerData{
682
+
Sha: sha,
683
+
Ref: refPtr,
684
+
Inputs: inputs,
685
+
}
686
+
}
687
+
688
+
repoCloneUri := s.newRepoCloneUrl(repo.Knot, repoDid)
689
+
repoPath := s.newRepoPath(repoDid)
690
+
sourceInfo := triggerRepo // default: code comes from the repo itself
691
+
if sourceRepo != "" && sourceRepo != repoDid {
692
+
sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo)
693
+
if err != nil {
694
+
return "", err
695
+
}
696
+
sourceRepoStr := sourceRepo.String()
697
+
trigger.SourceRepo = &sourceRepoStr
698
+
repoCloneUri = models.BuildRepoURL(sourceInfo)
699
+
repoPath = s.newRepoPath(sourceRepo)
700
+
}
701
+
702
+
pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows, sourceInfo)
703
+
if err != nil {
704
+
return "", err
705
+
}
706
+
if pipelineId.Rkey == "" {
707
+
return "", xrpc.ErrNoMatchingWorkflows
708
+
}
709
+
return pipelineId.AtUri(), nil
710
+
}
711
+
712
+
func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) {
713
+
if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil {
714
+
return nil, fmt.Errorf("syncing git repo: %w", err)
715
+
}
716
+
gr, err := kgit.Open(repoPath, rev)
717
+
if err != nil {
718
+
return nil, fmt.Errorf("opening git repo: %w", err)
719
+
}
720
+
721
+
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
722
+
if errors.Is(err, object.ErrDirectoryNotFound) {
723
+
// return empty RawPipeline when directory doesn't exist
724
+
return nil, nil
725
+
} else if err != nil {
726
+
return nil, fmt.Errorf("loading file tree: %w", err)
727
+
}
728
+
729
+
var rawPipeline workflow.RawPipeline
730
+
for _, e := range workflowDir {
731
+
if !e.IsFile() {
732
+
continue
733
+
}
734
+
735
+
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
736
+
contents, err := gr.RawContent(fpath)
737
+
if err != nil {
738
+
return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err)
739
+
}
740
+
741
+
rawPipeline = append(rawPipeline, workflow.RawWorkflow{
742
+
Name: e.Name,
743
+
Contents: contents,
744
+
})
745
+
}
746
+
747
+
return rawPipeline, nil
748
+
}
749
+
750
+
func (s *Spindle) StartJobWorkers(ctx context.Context) {
751
+
for range s.cfg.Server.MaxJobCount {
752
+
go func() {
753
+
for {
754
+
job, err := s.db.DequeueJob(ctx)
755
+
if err != nil {
756
+
s.l.Error("failed to dequeue job", "error", err)
757
+
}
758
+
if job == nil {
759
+
// sleep until a new job wakes us
760
+
select {
761
+
case <-ctx.Done():
762
+
return
763
+
case <-s.jobWake:
764
+
}
765
+
continue
766
+
}
767
+
s.runJob(ctx, job)
768
+
}
769
+
}()
770
+
}
771
+
}
772
+
773
+
func (s *Spindle) runJob(ctx context.Context, job *db.JobRow) {
774
+
pipelineId := models.PipelineId{
775
+
Knot: job.PipelineIdKnot,
776
+
Rkey: job.PipelineIdRkey,
777
+
}
778
+
779
+
pipelineEnv := models.PipelineEnvVarsForSource(job.Tpl.TriggerMetadata, pipelineId, job.SourceRepo)
780
+
trustedSource := true
781
+
if tm := job.Tpl.TriggerMetadata; tm != nil && tm.SourceRepo != nil &&
782
+
*tm.SourceRepo != "" && *tm.SourceRepo != job.RepoDid {
783
+
trustedSource = false
784
+
}
785
+
786
+
initTpl := job.Tpl
787
+
if job.SourceRepo != nil && job.Tpl.TriggerMetadata != nil {
788
+
tm := *job.Tpl.TriggerMetadata
789
+
tm.Repo = job.SourceRepo
790
+
initTpl.TriggerMetadata = &tm
791
+
}
792
+
793
+
workflows := make(map[models.Engine][]models.Workflow)
794
+
for _, w := range job.Tpl.Workflows {
795
+
if w == nil {
796
+
continue
797
+
}
798
+
eng, ok := s.engs[w.Engine]
799
+
if !ok {
800
+
_ = s.db.StatusFailed(models.WorkflowId{
801
+
PipelineId: pipelineId,
802
+
Name: w.Name,
803
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
804
+
continue
805
+
}
806
+
807
+
ewf, err := eng.InitWorkflow(*w, initTpl)
808
+
if err != nil {
809
+
_ = s.db.StatusFailed(models.WorkflowId{
810
+
PipelineId: pipelineId,
811
+
Name: w.Name,
812
+
}, fmt.Sprintf("init workflow: %s", err), -1, s.n)
813
+
continue
814
+
}
815
+
816
+
if ewf.Environment == nil {
817
+
ewf.Environment = make(map[string]string)
818
+
}
819
+
maps.Copy(ewf.Environment, pipelineEnv)
820
+
workflows[eng] = append(workflows[eng], *ewf)
821
+
}
822
+
823
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, &models.Pipeline{
824
+
RepoDid: syntax.DID(job.RepoDid),
825
+
Workflows: workflows,
826
+
TrustedSource: trustedSource,
827
+
}, pipelineId)
828
+
}
829
+
830
+
// enqueues the workflows in tpl.
831
+
func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId, sourceRepo *tangled.Pipeline_TriggerRepo) error {
832
+
err := s.db.EnqueueJob(s.rootCtx, repoDid.String(), pipelineId, sourceRepo, tpl)
833
+
if err != nil {
834
+
return fmt.Errorf("failed to enqueue durable job: %w", err)
835
+
}
836
+
s.l.Info("pipeline enqueued successfully to db", "id", pipelineId)
837
+
838
+
// wake up an idle worker to pick up more jobs if any
839
+
select {
840
+
case s.jobWake <- struct{}{}:
841
+
default:
842
+
}
843
+
844
+
// pipelines visible from now on, they are sitting in queue
845
+
for _, w := range tpl.Workflows {
846
+
if w == nil {
847
+
continue
848
+
}
849
+
if err := s.db.StatusPending(models.WorkflowId{
850
+
PipelineId: pipelineId,
851
+
Name: w.Name,
852
+
}, s.n); err != nil {
853
+
return fmt.Errorf("db.StatusPending: %w", err)
854
+
}
855
+
}
856
+
return nil
857
+
}
858
+
859
+
// newRepoPath creates a path to store repository by its did and rkey.
860
+
// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey
861
+
func (s *Spindle) newRepoPath(repo syntax.DID) string {
862
+
return filepath.Join(s.cfg.Server.RepoDir, repo.String())
863
+
}
864
+
865
+
func (s *Spindle) newRepoCloneUrl(knot string, did syntax.DID) string {
866
+
scheme := "https://"
867
+
if s.cfg.Server.Dev {
868
+
scheme = "http://"
869
+
}
870
+
return fmt.Sprintf("%s%s/%s", scheme, knot, did)
871
+
}
872
+
873
+
const RequiredVersion = "2.49.0"
874
+
875
+
func ensureGitVersion() error {
876
+
v, err := git.Version()
877
+
if err != nil {
878
+
return fmt.Errorf("fetching git version: %w", err)
879
+
}
880
+
if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) {
881
+
return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion)
882
+
}
883
+
return nil
884
+
}
885
+
886
+
func (s *Spindle) resolvePipelineRepoDid(repo *tangled.Pipeline_TriggerRepo) (syntax.DID, error) {
887
+
if repo.RepoDid == nil || *repo.RepoDid == "" {
888
+
return "", fmt.Errorf("pipeline trigger missing repoDid")
889
+
}
890
+
repoDid, err := syntax.ParseDID(*repo.RepoDid)
891
+
if err != nil {
892
+
return "", fmt.Errorf("parse repoDid %s: %w", *repo.RepoDid, err)
893
+
}
894
+
if _, err := s.db.GetRepoByDid(repoDid); err != nil {
895
+
return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
896
+
}
897
+
return repoDid, nil
898
+
}
899
+
900
+
func (s *Spindle) configureOwner() error {
901
+
cfgOwner := s.cfg.Server.Owner
902
+
903
+
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
904
+
if err != nil {
905
+
return err
906
+
}
907
+
908
+
switch len(existing) {
909
+
case 0:
910
+
// no owner configured, continue
911
+
case 1:
912
+
// find existing owner
913
+
existingOwner := existing[0]
914
+
915
+
// no ownership change, this is okay
916
+
if existingOwner == s.cfg.Server.Owner {
917
+
break
918
+
}
919
+
920
+
// remove existing owner
921
+
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
922
+
if err != nil {
923
+
return nil
924
+
}
925
+
default:
926
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
927
+
}
928
+
929
+
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
930
+
}
+1028
spindle/server.go.mill
+1028
spindle/server.go.mill
···
1
+
package spindle
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
_ "embed"
7
+
"encoding/json"
8
+
"errors"
9
+
"fmt"
10
+
"log/slog"
11
+
"maps"
12
+
"net/http"
13
+
"path/filepath"
14
+
"sync"
15
+
"time"
16
+
17
+
"github.com/bluesky-social/indigo/atproto/syntax"
18
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
+
"github.com/go-chi/chi/v5"
20
+
"github.com/go-git/go-git/v5/plumbing/object"
21
+
"github.com/hashicorp/go-version"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/eventconsumer"
24
+
"tangled.org/core/eventconsumer/cursor"
25
+
"tangled.org/core/eventstream"
26
+
"tangled.org/core/idresolver"
27
+
"tangled.org/core/jetstream"
28
+
knotdb "tangled.org/core/knotserver/db"
29
+
kgit "tangled.org/core/knotserver/git"
30
+
"tangled.org/core/log"
31
+
"tangled.org/core/notifier"
32
+
"tangled.org/core/rbac"
33
+
"tangled.org/core/repoident"
34
+
"tangled.org/core/repoverify"
35
+
"tangled.org/core/spindle/artifactstore"
36
+
"tangled.org/core/spindle/config"
37
+
"tangled.org/core/spindle/db"
38
+
"tangled.org/core/spindle/engine"
39
+
"tangled.org/core/spindle/engines/dummy"
40
+
"tangled.org/core/spindle/engines/nixery"
41
+
"tangled.org/core/spindle/git"
42
+
"tangled.org/core/spindle/mill"
43
+
"tangled.org/core/spindle/mill/executor"
44
+
"tangled.org/core/spindle/models"
45
+
"tangled.org/core/spindle/queue"
46
+
"tangled.org/core/spindle/secrets"
47
+
"tangled.org/core/spindle/xrpc"
48
+
"tangled.org/core/tid"
49
+
"tangled.org/core/workflow"
50
+
"tangled.org/core/xrpc/serviceauth"
51
+
)
52
+
53
+
//go:embed motd
54
+
var defaultMotd []byte
55
+
56
+
const (
57
+
rbacDomain = "thisserver"
58
+
)
59
+
60
+
type Spindle struct {
61
+
jc *jetstream.JetstreamClient
62
+
tap *Tap
63
+
embedTap *embeddedTap
64
+
db *db.DB
65
+
e *rbac.Enforcer
66
+
l *slog.Logger
67
+
n *notifier.Notifier
68
+
engs map[string]models.Engine
69
+
jq *queue.Queue
70
+
cfg *config.Config
71
+
ks *eventconsumer.Consumer
72
+
res *idresolver.Resolver
73
+
verify repoverify.Verifier
74
+
vault secrets.Manager
75
+
motd []byte
76
+
motdMu sync.RWMutex
77
+
rootCtx context.Context
78
+
store artifactstore.Store
79
+
stores *artifactstore.Stores
80
+
reader artifactstore.Reader
81
+
// set only when this spindle hosts the mill or joins one as an executor
82
+
mill *mill.Mill
83
+
exec *executor.Executor
84
+
}
85
+
86
+
// New creates a new Spindle server with the provided configuration and engines.
87
+
func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) {
88
+
logger := log.FromContext(ctx)
89
+
n := notifier.New()
90
+
91
+
if cfg.Role == config.RoleExecutor {
92
+
if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
93
+
return nil, fmt.Errorf("failed to run startup cleanup: %w", err)
94
+
}
95
+
} else if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil {
96
+
return nil, fmt.Errorf("failed to run startup migrations: %w", err)
97
+
}
98
+
99
+
spindle := &Spindle{
100
+
db: d,
101
+
l: logger,
102
+
n: &n,
103
+
engs: engines,
104
+
cfg: cfg,
105
+
motd: defaultMotd,
106
+
rootCtx: ctx,
107
+
}
108
+
diskFallback := ""
109
+
if cfg.Role == config.RoleStandalone {
110
+
diskFallback = cfg.Server.LogDir
111
+
if cfg.ArtifactStores.Disk.Dir == "" {
112
+
logger.Warn("using SPINDLE_SERVER_LOG_DIR as the implicit disk artifact store; configure SPINDLE_ARTIFACT_STORES_DISK_DIR explicitly")
113
+
}
114
+
}
115
+
stores, err := artifactstore.NewStores(cfg.ArtifactStores, diskFallback, cfg.LegacyS3.LogBucket)
116
+
if err != nil {
117
+
return nil, fmt.Errorf("failed to setup artifact stores: %w", err)
118
+
}
119
+
spindle.stores = stores
120
+
if cfg.LegacyS3.LogBucket != "" {
121
+
logger.Warn("SPINDLE_S3_LOG_BUCKET is deprecated; use SPINDLE_ARTIFACT_STORES_S3_BUCKET")
122
+
}
123
+
if cfg.Role == config.RoleStandalone {
124
+
spindle.reader = stores
125
+
} else {
126
+
name := cfg.Mill.ArtifactStore
127
+
if name == "" {
128
+
names := stores.Names()
129
+
if len(names) != 1 {
130
+
return nil, fmt.Errorf("%s requires SPINDLE_MILL_ARTIFACT_STORE when %d artifact stores are configured", cfg.Role, len(names))
131
+
}
132
+
name = names[0]
133
+
logger.Warn("SPINDLE_MILL_ARTIFACT_STORE is not set; inferred the only configured store", "store", name)
134
+
}
135
+
store, ok := stores.Store(name)
136
+
if !ok {
137
+
return nil, fmt.Errorf("SPINDLE_MILL_ARTIFACT_STORE=%q is not configured", name)
138
+
}
139
+
spindle.store = store
140
+
spindle.reader = store
141
+
}
142
+
if cfg.Role == config.RoleExecutor {
143
+
return spindle, nil
144
+
}
145
+
146
+
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
147
+
if err != nil {
148
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
149
+
}
150
+
e.E.EnableAutoSave(true)
151
+
spindle.e = e
152
+
153
+
switch cfg.Server.Secrets.Provider {
154
+
case "openbao":
155
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
156
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
157
+
}
158
+
spindle.vault, err = secrets.NewOpenBaoManager(
159
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
160
+
logger,
161
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
162
+
)
163
+
if err != nil {
164
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
165
+
}
166
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
167
+
case "sqlite", "":
168
+
spindle.vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
169
+
if err != nil {
170
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
171
+
}
172
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
173
+
default:
174
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
175
+
}
176
+
177
+
if cfg.Role == config.RoleStandalone {
178
+
spindle.jq = queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
179
+
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
180
+
}
181
+
182
+
collections := []string{
183
+
tangled.SpindleMemberNSID,
184
+
tangled.RepoNSID,
185
+
tangled.RepoCollaboratorNSID,
186
+
tangled.RepoPullNSID,
187
+
}
188
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
189
+
if err != nil {
190
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
191
+
}
192
+
spindle.jc = jc
193
+
jc.AddDid(cfg.Server.Owner)
194
+
// pull records are created by arbitrary users too, same hack as in tap
195
+
jc.ExemptCollection(tangled.RepoPullNSID)
196
+
197
+
// Check if the spindle knows about any Dids;
198
+
dids, err := d.GetAllDids()
199
+
if err != nil {
200
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
201
+
}
202
+
for _, d := range dids {
203
+
jc.AddDid(d)
204
+
}
205
+
206
+
knownRepos, err := d.AllRepos()
207
+
if err != nil {
208
+
return nil, fmt.Errorf("failed to get known repos: %w", err)
209
+
}
210
+
for _, r := range knownRepos {
211
+
if r.Owner != "" {
212
+
jc.AddDid(r.Owner.String())
213
+
}
214
+
}
215
+
216
+
spindle.res = idresolver.DefaultResolver(cfg.Server.PlcUrl)
217
+
spindle.verify = repoverify.New(spindle.res, cfg.Server.Dev)
218
+
219
+
err = e.AddSpindle(rbacDomain)
220
+
if err != nil {
221
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
222
+
}
223
+
err = spindle.configureOwner()
224
+
if err != nil {
225
+
return nil, err
226
+
}
227
+
logger.Info("owner set", "did", cfg.Server.Owner)
228
+
229
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
230
+
if err != nil {
231
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
232
+
}
233
+
234
+
err = jc.StartJetstream(ctx, spindle.ingest())
235
+
if err != nil {
236
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
237
+
}
238
+
239
+
// spindle listen to knot stream for sh.tangled.git.refUpdate
240
+
// which will sync the local workflow files in spindle and enqueues the
241
+
// pipeline job for on-push workflows
242
+
ccfg := eventconsumer.NewConsumerConfig()
243
+
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
244
+
ccfg.ProcessFunc = spindle.processKnotStream
245
+
ccfg.CursorStore = cursorStore
246
+
if cfg.Server.Dev {
247
+
ccfg.RetryInterval = 5 * time.Second
248
+
ccfg.MaxRetryInterval = 10 * time.Second
249
+
} else {
250
+
ccfg.RetryInterval = 1 * time.Minute
251
+
ccfg.MaxRetryInterval = 10 * time.Minute
252
+
}
253
+
knownKnots, err := d.Knots()
254
+
if err != nil {
255
+
return nil, err
256
+
}
257
+
for _, knot := range knownKnots {
258
+
logger.Info("adding source start", "knot", knot)
259
+
src := eventconsumer.NewKnotSource(knot)
260
+
eventconsumer.MigrateLegacyCursor(cursorStore, src)
261
+
ccfg.Sources[src] = struct{}{}
262
+
}
263
+
spindle.ks = eventconsumer.NewConsumer(*ccfg)
264
+
if cfg.Server.Tap.Embed {
265
+
pw, err := randomAdminPassword()
266
+
if err != nil {
267
+
return nil, err
268
+
}
269
+
cfg.Server.Tap.AdminPassword = pw
270
+
logger.Info("embedded tap: using random admin password")
271
+
}
272
+
spindle.tap = NewTapClient(spindle)
273
+
274
+
return spindle, nil
275
+
}
276
+
func (s *Spindle) DB() *db.DB {
277
+
return s.db
278
+
}
279
+
func (s *Spindle) Queue() *queue.Queue {
280
+
return s.jq
281
+
}
282
+
283
+
// Engines returns the map of available engines.
284
+
func (s *Spindle) Engines() map[string]models.Engine {
285
+
return s.engs
286
+
}
287
+
288
+
// Vault returns the secrets manager instance.
289
+
func (s *Spindle) Vault() secrets.Manager {
290
+
return s.vault
291
+
}
292
+
293
+
// Notifier returns the notifier instance.
294
+
func (s *Spindle) Notifier() *notifier.Notifier {
295
+
return s.n
296
+
}
297
+
298
+
// Enforcer returns the RBAC enforcer instance.
299
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
300
+
return s.e
301
+
}
302
+
303
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
304
+
func (s *Spindle) SetMotdContent(content []byte) {
305
+
s.motdMu.Lock()
306
+
defer s.motdMu.Unlock()
307
+
s.motd = content
308
+
}
309
+
310
+
// GetMotdContent returns the current MOTD content.
311
+
func (s *Spindle) GetMotdContent() []byte {
312
+
s.motdMu.RLock()
313
+
defer s.motdMu.RUnlock()
314
+
return s.motd
315
+
}
316
+
317
+
// runs the server. blocks
318
+
func (s *Spindle) Start(ctx context.Context) error {
319
+
// only standalone runs the local queue. mill hosts place directly onto
320
+
// executors, and executors only run jobs explicitly assigned by a mill
321
+
if s.cfg.Role == config.RoleStandalone {
322
+
if s.jq != nil {
323
+
s.jq.Start()
324
+
defer s.jq.Stop()
325
+
}
326
+
}
327
+
328
+
// an executor dials out to its mill and takes work from it
329
+
if s.exec != nil {
330
+
go s.exec.Connect(ctx)
331
+
}
332
+
333
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
334
+
defer stopper.Stop()
335
+
}
336
+
337
+
if s.cfg.Role != config.RoleExecutor {
338
+
tapCtx, tapCancel := context.WithCancel(ctx)
339
+
340
+
if s.cfg.Server.Tap.Embed {
341
+
emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap"))
342
+
if err != nil {
343
+
tapCancel()
344
+
return fmt.Errorf("starting embedded tap: %w", err)
345
+
}
346
+
s.embedTap = emb
347
+
defer func() {
348
+
tapCancel()
349
+
s.embedTap.Shutdown()
350
+
}()
351
+
352
+
go s.watchTapDrain(tapCtx, tapCancel)
353
+
} else {
354
+
defer tapCancel()
355
+
}
356
+
357
+
go func() {
358
+
s.l.Info("starting knot event consumer")
359
+
s.ks.Start(ctx)
360
+
}()
361
+
362
+
s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url)
363
+
s.tap.Start(tapCtx)
364
+
}
365
+
366
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
367
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
368
+
}
369
+
370
+
func (s *Spindle) declareTapInterest(ctx context.Context) {
371
+
repos, err := s.db.AllRepos()
372
+
if err != nil {
373
+
s.l.Warn("tap declare: failed to load known repos", "err", err)
374
+
return
375
+
}
376
+
seen := make(map[syntax.DID]struct{}, len(repos))
377
+
dids := make([]syntax.DID, 0, len(repos))
378
+
for _, r := range repos {
379
+
if r.Owner == "" {
380
+
continue
381
+
}
382
+
if _, ok := seen[r.Owner]; ok {
383
+
continue
384
+
}
385
+
seen[r.Owner] = struct{}{}
386
+
dids = append(dids, r.Owner)
387
+
}
388
+
if err := s.tap.AddOwnerDIDs(ctx, dids); err != nil {
389
+
s.l.Warn("tap declare: AddRepos rejected", "count", len(dids), "err", err)
390
+
return
391
+
}
392
+
s.l.Info("tap declare: known owner DIDs registered", "count", len(dids))
393
+
}
394
+
395
+
func Run(ctx context.Context) error {
396
+
cfg, err := config.Load(ctx)
397
+
if err != nil {
398
+
return fmt.Errorf("failed to load config: %w", err)
399
+
}
400
+
401
+
if err := ensureGitVersion(); err != nil {
402
+
return fmt.Errorf("ensuring git version: %w", err)
403
+
}
404
+
405
+
d, err := db.Make(ctx, cfg.Server.DBPath)
406
+
if err != nil {
407
+
return fmt.Errorf("failed to setup db: %w", err)
408
+
}
409
+
410
+
logger := log.FromContext(ctx)
411
+
412
+
var engines map[string]models.Engine
413
+
var m *mill.Mill
414
+
415
+
if cfg.Role == config.RoleMill {
416
+
// mill host: register engines that place jobs on executors instead of
417
+
// running them. all names share one Mill
418
+
m = mill.New(log.SubLogger(logger, "mill"), mill.Config{
419
+
LogDir: cfg.Server.LogDir,
420
+
MaxPending: cfg.Mill.MaxPending,
421
+
ReconnectGrace: cfg.Mill.ReconnectGrace,
422
+
})
423
+
engines = map[string]models.Engine{
424
+
"nixery": mill.NewEngine("nixery", m),
425
+
"microvm": mill.NewEngine("microvm", m),
426
+
"dummy": mill.NewEngine("dummy", m),
427
+
}
428
+
} else {
429
+
// standalone and executor both run real engines locally.
430
+
nixeryEng, err := nixery.New(ctx, cfg)
431
+
if err != nil {
432
+
return err
433
+
}
434
+
microvmEng, err := newMicrovmEngine(ctx, cfg, d)
435
+
if err != nil {
436
+
return err
437
+
}
438
+
engines = map[string]models.Engine{
439
+
"nixery": nixeryEng,
440
+
"microvm": microvmEng,
441
+
"dummy": dummy.New(logger),
442
+
}
443
+
}
444
+
445
+
s, err := New(ctx, cfg, d, engines)
446
+
if err != nil {
447
+
return err
448
+
}
449
+
450
+
if m != nil {
451
+
// resolve the chicken-and-egg: the engines (built above) hold the mill,
452
+
// but the mill's db/notifier are created inside New
453
+
m.Attach(s.DB(), s.Notifier())
454
+
s.mill = m
455
+
if err := m.RestoreState(); err != nil {
456
+
return fmt.Errorf("restoring mill state: %w", err)
457
+
}
458
+
}
459
+
if cfg.Role == config.RoleExecutor {
460
+
s.exec, err = executor.New(cfg, engines, s.DB(), s.Notifier(), log.SubLogger(logger, "executor"), s.store)
461
+
if err != nil {
462
+
return err
463
+
}
464
+
}
465
+
466
+
return s.Start(ctx)
467
+
}
468
+
469
+
func (s *Spindle) Router() http.Handler {
470
+
mux := chi.NewRouter()
471
+
472
+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
473
+
w.Write(s.GetMotdContent())
474
+
})
475
+
if s.cfg.Role == config.RoleExecutor {
476
+
return mux
477
+
}
478
+
479
+
mux.HandleFunc("/events", s.Events)
480
+
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
481
+
482
+
// mill host: executors dial in here (plain ws, shared-secret auth)
483
+
if s.mill != nil {
484
+
mux.HandleFunc("/mill", s.mill.HandleExecutorConn)
485
+
}
486
+
487
+
mux.Mount("/xrpc", s.XrpcRouter())
488
+
return mux
489
+
}
490
+
491
+
func (s *Spindle) XrpcRouter() http.Handler {
492
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res.Directory(), s.cfg.Server.Did().String())
493
+
494
+
l := log.SubLogger(s.l, "xrpc")
495
+
496
+
x := xrpc.Xrpc{
497
+
Logger: l,
498
+
Db: s.db,
499
+
Enforcer: s.e,
500
+
Engines: s.engs,
501
+
Config: s.cfg,
502
+
ArtifactReader: s.reader,
503
+
Resolver: s.res,
504
+
Vault: s.vault,
505
+
Notifier: s.Notifier(),
506
+
ServiceAuth: serviceAuth,
507
+
Trigger: s,
508
+
}
509
+
510
+
return x.Router()
511
+
}
512
+
513
+
func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventstream.Event) error {
514
+
l := log.FromContext(ctx).With("handler", "processKnotStream")
515
+
l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey)
516
+
if msg.Nsid == knotdb.RepoCollaboratorUpdateNSID {
517
+
return s.ingestKnotCollaborator(ctx, l, src, msg)
518
+
}
519
+
if msg.Nsid == tangled.GitRefUpdateNSID {
520
+
event := tangled.GitRefUpdate{}
521
+
if err := json.Unmarshal(msg.EventJson, &event); err != nil {
522
+
l.Error("error unmarshalling", "err", err)
523
+
return err
524
+
}
525
+
l = l.With("repo", event.Repo, "ref", event.Ref, "newSha", event.NewSha)
526
+
l.Debug("debug")
527
+
528
+
repoDid := syntax.DID(event.Repo)
529
+
repo, err := s.db.GetRepoByDid(repoDid)
530
+
if err != nil {
531
+
return fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
532
+
}
533
+
534
+
if src.Host != repo.Knot {
535
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Host, repo.Knot)
536
+
}
537
+
538
+
if kgit.HasSkipCIPushOption(event.PushOptions) {
539
+
l.Info("push event requested ci skip, skipping the event")
540
+
return nil
541
+
}
542
+
543
+
// NOTE: we are blindly trusting the knot that it will return only repos it own
544
+
repoCloneUri := s.newRepoCloneUrl(src.Host, repoDid)
545
+
repoPath := s.newRepoPath(repoDid)
546
+
if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil {
547
+
return fmt.Errorf("sync git repo: %w", err)
548
+
}
549
+
l.Info("synced git repo")
550
+
551
+
triggerRepo, err := s.buildTriggerRepo(ctx, repo)
552
+
if err != nil {
553
+
return fmt.Errorf("building trigger repo: %w", err)
554
+
}
555
+
556
+
trigger := tangled.Pipeline_TriggerMetadata{
557
+
Kind: string(workflow.TriggerKindPush),
558
+
Push: &tangled.Pipeline_PushTriggerData{
559
+
Ref: event.Ref,
560
+
OldSha: event.OldSha,
561
+
NewSha: event.NewSha,
562
+
},
563
+
Repo: triggerRepo,
564
+
}
565
+
566
+
pipelineId, err := s.runPipeline(ctx, repoDid, trigger, event.ChangedFiles, repoCloneUri, repoPath, event.NewSha, nil, triggerRepo)
567
+
if err != nil {
568
+
return err
569
+
}
570
+
if pipelineId.Rkey == "" {
571
+
l.Info("no workflow matched 'push' trigger, skipping the event")
572
+
return nil
573
+
}
574
+
l.Info("pipeline triggered", "pipeline", pipelineId.AtUri())
575
+
}
576
+
577
+
return nil
578
+
}
579
+
580
+
func (s *Spindle) ingestKnotCollaborator(ctx context.Context, l *slog.Logger, src eventconsumer.Source, msg eventstream.Event) error {
581
+
var rec knotdb.RepoCollaboratorUpdate
582
+
if err := json.Unmarshal(msg.EventJson, &rec); err != nil {
583
+
l.Error("error unmarshalling collaboratorUpdate", "err", err)
584
+
return err
585
+
}
586
+
587
+
subject, err := syntax.ParseDID(rec.Subject)
588
+
if err != nil {
589
+
l.Info("skipping collaboratorUpdate with malformed subject", "subject", rec.Subject, "err", err)
590
+
return nil
591
+
}
592
+
repoDid, err := syntax.ParseDID(rec.Repo)
593
+
if err != nil {
594
+
l.Info("skipping collaboratorUpdate with malformed repo", "repo", rec.Repo, "err", err)
595
+
return nil
596
+
}
597
+
598
+
repo, err := s.db.GetRepoByDid(repoDid)
599
+
if errors.Is(err, sql.ErrNoRows) {
600
+
l.Info("skipping collaboratorUpdate for unknown repo", "repo", repoDid)
601
+
return nil
602
+
}
603
+
if err != nil {
604
+
return fmt.Errorf("lookup repo %s: %w", repoDid, err)
605
+
}
606
+
if src.Host != repo.Knot {
607
+
l.Warn("dropping collaboratorUpdate from non-owning knot", "src", src.Host, "repoKnot", repo.Knot)
608
+
return nil
609
+
}
610
+
611
+
switch rec.Op {
612
+
case knotdb.AclOpAdd:
613
+
if err := s.e.AddCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil {
614
+
return fmt.Errorf("add collaborator policy: %w", err)
615
+
}
616
+
if err := s.db.AddKnotCollaborator(repoDid, subject); err != nil {
617
+
return fmt.Errorf("track collaborator: %w", err)
618
+
}
619
+
l.Info("added knot-managed collaborator", "subject", subject, "repo", repoDid)
620
+
case knotdb.AclOpRemove:
621
+
if err := s.e.RemoveCollaborator(subject.String(), rbac.ThisServer, repoDid.String()); err != nil {
622
+
return fmt.Errorf("remove collaborator policy: %w", err)
623
+
}
624
+
if err := s.db.DeleteRepoCollaboratorBySubjectRepo(subject, repoDid); err != nil {
625
+
return fmt.Errorf("delete collaborator row: %w", err)
626
+
}
627
+
l.Info("removed knot-managed collaborator", "subject", subject, "repo", repoDid)
628
+
default:
629
+
return fmt.Errorf("collaboratorUpdate unknown op %q", rec.Op)
630
+
}
631
+
return nil
632
+
}
633
+
634
+
// buildTriggerRepo gathers trigger metadata, resolving default branch from the knot
635
+
func (s *Spindle) buildTriggerRepo(ctx context.Context, repo *db.Repo) (*tangled.Pipeline_TriggerRepo, error) {
636
+
rkey := string(repo.Rkey)
637
+
repoDid := repo.RepoDid.String()
638
+
return s.buildTriggerRepoFrom(ctx, repo.Knot, repo.Owner.String(), rkey, repoDid), nil
639
+
}
640
+
641
+
func (s *Spindle) buildTriggerRepoFrom(ctx context.Context, knot, did, rkey, repoDid string) *tangled.Pipeline_TriggerRepo {
642
+
scheme := "https"
643
+
if s.cfg.Server.Dev {
644
+
scheme = "http"
645
+
}
646
+
client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, knot)}
647
+
648
+
// this should maybe (?) be in the refUpdate event itself to save a roundtrip
649
+
defaultBranch := ""
650
+
if out, err := tangled.RepoGetDefaultBranch(ctx, client, repoDid); err == nil {
651
+
defaultBranch = out.Name
652
+
}
653
+
654
+
var rkeyPtr *string
655
+
if rkey != "" {
656
+
rkeyPtr = &rkey
657
+
}
658
+
return &tangled.Pipeline_TriggerRepo{
659
+
Did: did,
660
+
Knot: knot,
661
+
Repo: rkeyPtr,
662
+
RepoDid: &repoDid,
663
+
DefaultBranch: defaultBranch,
664
+
}
665
+
}
666
+
667
+
func (s *Spindle) resolvePipelineSourceRepo(ctx context.Context, trigger *tangled.Pipeline_TriggerMetadata) (*tangled.Pipeline_TriggerRepo, error) {
668
+
if trigger == nil {
669
+
return nil, nil
670
+
}
671
+
if trigger.SourceRepo == nil || *trigger.SourceRepo == "" {
672
+
return trigger.Repo, nil
673
+
}
674
+
repoDid, err := syntax.ParseDID(*trigger.SourceRepo)
675
+
if err != nil {
676
+
return nil, fmt.Errorf("parse sourceRepo %s: %w", *trigger.SourceRepo, err)
677
+
}
678
+
return s.resolveSourceRepoInfo(ctx, repoDid)
679
+
}
680
+
681
+
// resolveSourceRepoInfo resolves trigger-repo metadata for a source repo DID.
682
+
func (s *Spindle) resolveSourceRepoInfo(ctx context.Context, repoDid syntax.DID) (*tangled.Pipeline_TriggerRepo, error) {
683
+
repo, err := s.db.GetRepoByDid(repoDid)
684
+
if err == nil {
685
+
return s.buildTriggerRepo(ctx, repo)
686
+
}
687
+
688
+
// verify repo, we don't want git sync to point to arbitrary endpoints
689
+
res, err := s.verify(ctx, repoident.RepoDid(repoDid))
690
+
if err != nil {
691
+
return nil, fmt.Errorf("verify sourceRepo %s: %w", repoDid, err)
692
+
}
693
+
return s.buildTriggerRepoFrom(ctx, res.KnotURL.Host, res.OwnerDid.String(), res.Rkey, repoDid.String()), nil
694
+
}
695
+
696
+
// runPipeline compiles and enqueues the pipeline for the given revision.
697
+
// sourceRepo is the resolved repo the code was checked out from, forwarded to
698
+
// processPipeline for env vars.
699
+
func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger tangled.Pipeline_TriggerMetadata, changedFiles []string, repoCloneUri, repoPath, rev string, only []string, sourceRepo *tangled.Pipeline_TriggerRepo) (models.PipelineId, error) {
700
+
l := log.FromContext(ctx)
701
+
702
+
compiler := workflow.Compiler{
703
+
ChangedFiles: changedFiles,
704
+
Trigger: trigger,
705
+
}
706
+
707
+
rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, rev)
708
+
if err != nil {
709
+
return models.PipelineId{}, fmt.Errorf("loading pipeline: %w", err)
710
+
}
711
+
if len(rawPipeline) == 0 {
712
+
return models.PipelineId{}, nil
713
+
}
714
+
715
+
tpl := compiler.Compile(compiler.Parse(rawPipeline))
716
+
// todo(dawn): pass compile error to workflow log
717
+
for _, w := range compiler.Diagnostics.Errors {
718
+
l.Error(w.String())
719
+
}
720
+
for _, w := range compiler.Diagnostics.Warnings {
721
+
l.Warn(w.String())
722
+
}
723
+
724
+
if len(only) > 0 {
725
+
tpl.Workflows = filterWorkflows(tpl.Workflows, only)
726
+
}
727
+
if len(tpl.Workflows) == 0 {
728
+
return models.PipelineId{}, nil
729
+
}
730
+
731
+
pipelineId := models.PipelineId{
732
+
Knot: trigger.Repo.Knot,
733
+
Rkey: tid.TID(),
734
+
}
735
+
if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil {
736
+
return models.PipelineId{}, fmt.Errorf("creating pipeline event: %w", err)
737
+
}
738
+
err = s.processPipeline(repoDid, tpl, pipelineId, sourceRepo)
739
+
return pipelineId, err
740
+
}
741
+
742
+
// filterWorkflows filters workflows to the requested names
743
+
func filterWorkflows(workflows []*tangled.Pipeline_Workflow, only []string) []*tangled.Pipeline_Workflow {
744
+
allowed := make(map[string]struct{}, len(only))
745
+
for _, n := range only {
746
+
allowed[n] = struct{}{}
747
+
}
748
+
var filtered []*tangled.Pipeline_Workflow
749
+
for _, w := range workflows {
750
+
if w == nil {
751
+
continue
752
+
}
753
+
if _, ok := allowed[w.Name]; ok {
754
+
filtered = append(filtered, w)
755
+
}
756
+
}
757
+
return filtered
758
+
}
759
+
760
+
// TriggerManual dispatches a pipeline at sha, authorized against and recorded
761
+
// under repoDid. sourceRepo, pull, and inputs are optional trigger payload.
762
+
func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull xrpc.PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) {
763
+
repo, err := s.db.GetRepoByDid(repoDid)
764
+
if err != nil {
765
+
return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err)
766
+
}
767
+
768
+
triggerRepo, err := s.buildTriggerRepo(ctx, repo)
769
+
if err != nil {
770
+
return "", fmt.Errorf("building trigger repo: %w", err)
771
+
}
772
+
773
+
trigger := tangled.Pipeline_TriggerMetadata{Repo: triggerRepo}
774
+
if pull.IsPullRequest {
775
+
var pullAt *string
776
+
if pull.Pull != "" {
777
+
pullAtStr := pull.Pull.String()
778
+
pullAt = &pullAtStr
779
+
}
780
+
trigger.Kind = string(workflow.TriggerKindPullRequest)
781
+
trigger.PullRequest = &tangled.Pipeline_PullRequestTriggerData{
782
+
SourceBranch: pull.SourceBranch,
783
+
TargetBranch: pull.TargetBranch,
784
+
SourceSha: sha,
785
+
Pull: pullAt,
786
+
}
787
+
} else {
788
+
var refPtr *string
789
+
if ref != "" {
790
+
refPtr = &ref
791
+
}
792
+
trigger.Kind = string(workflow.TriggerKindManual)
793
+
trigger.Manual = &tangled.Pipeline_ManualTriggerData{
794
+
Sha: sha,
795
+
Ref: refPtr,
796
+
Inputs: inputs,
797
+
}
798
+
}
799
+
800
+
repoCloneUri := s.newRepoCloneUrl(repo.Knot, repoDid)
801
+
repoPath := s.newRepoPath(repoDid)
802
+
sourceInfo := triggerRepo // default: code comes from the repo itself
803
+
if sourceRepo != "" && sourceRepo != repoDid {
804
+
sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo)
805
+
if err != nil {
806
+
return "", err
807
+
}
808
+
sourceRepoStr := sourceRepo.String()
809
+
trigger.SourceRepo = &sourceRepoStr
810
+
repoCloneUri = models.BuildRepoURL(sourceInfo)
811
+
repoPath = s.newRepoPath(sourceRepo)
812
+
}
813
+
814
+
pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows, sourceInfo)
815
+
if err != nil {
816
+
return "", err
817
+
}
818
+
if pipelineId.Rkey == "" {
819
+
return "", xrpc.ErrNoMatchingWorkflows
820
+
}
821
+
return pipelineId.AtUri(), nil
822
+
}
823
+
824
+
func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) {
825
+
if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil {
826
+
return nil, fmt.Errorf("syncing git repo: %w", err)
827
+
}
828
+
gr, err := kgit.Open(repoPath, rev)
829
+
if err != nil {
830
+
return nil, fmt.Errorf("opening git repo: %w", err)
831
+
}
832
+
833
+
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
834
+
if errors.Is(err, object.ErrDirectoryNotFound) {
835
+
// return empty RawPipeline when directory doesn't exist
836
+
return nil, nil
837
+
} else if err != nil {
838
+
return nil, fmt.Errorf("loading file tree: %w", err)
839
+
}
840
+
841
+
var rawPipeline workflow.RawPipeline
842
+
for _, e := range workflowDir {
843
+
if !e.IsFile() {
844
+
continue
845
+
}
846
+
847
+
fpath := filepath.Join(workflow.WorkflowDir, e.Name)
848
+
contents, err := gr.RawContent(fpath)
849
+
if err != nil {
850
+
return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err)
851
+
}
852
+
853
+
rawPipeline = append(rawPipeline, workflow.RawWorkflow{
854
+
Name: e.Name,
855
+
Contents: contents,
856
+
})
857
+
}
858
+
859
+
return rawPipeline, nil
860
+
}
861
+
862
+
// processPipeline enqueues the workflows in tpl.
863
+
func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId, sourceRepo *tangled.Pipeline_TriggerRepo) error {
864
+
// derive security-relevant things like whether this run is trusted and can be passed
865
+
// secrets to from the original metadata.
866
+
pipelineEnv := models.PipelineEnvVarsForSource(tpl.TriggerMetadata, pipelineId, sourceRepo)
867
+
trustedSource := true
868
+
if tm := tpl.TriggerMetadata; tm != nil && tm.SourceRepo != nil &&
869
+
*tm.SourceRepo != "" && *tm.SourceRepo != repoDid.String() {
870
+
trustedSource = false
871
+
}
872
+
873
+
// swap the repo with our sourceRepo if we are running a pipeline on a fork.
874
+
// the metadata stays the same. we check whether the repo is trusted above,
875
+
// so this only affects the clone URL.
876
+
initTpl := tpl
877
+
if sourceRepo != nil && tpl.TriggerMetadata != nil {
878
+
tm := *tpl.TriggerMetadata
879
+
tm.Repo = sourceRepo
880
+
initTpl.TriggerMetadata = &tm
881
+
}
882
+
883
+
// filter & init workflows
884
+
workflows := make(map[models.Engine][]models.Workflow)
885
+
for _, w := range tpl.Workflows {
886
+
if w == nil {
887
+
continue
888
+
}
889
+
eng, ok := s.engs[w.Engine]
890
+
if !ok {
891
+
err := s.db.StatusFailed(models.WorkflowId{
892
+
PipelineId: pipelineId,
893
+
Name: w.Name,
894
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
895
+
if err != nil {
896
+
return fmt.Errorf("db.StatusFailed: %w", err)
897
+
}
898
+
899
+
continue
900
+
}
901
+
902
+
ewf, err := eng.InitWorkflow(*w, initTpl)
903
+
if err != nil {
904
+
err = s.db.StatusFailed(models.WorkflowId{
905
+
PipelineId: pipelineId,
906
+
Name: w.Name,
907
+
}, fmt.Sprintf("init workflow: %s", err), -1, s.n)
908
+
if err != nil {
909
+
return fmt.Errorf("db.StatusFailed: %w", err)
910
+
}
911
+
912
+
continue
913
+
}
914
+
915
+
// inject TANGLED_* env vars after InitWorkflow
916
+
// This prevents user-defined env vars from overriding them
917
+
if ewf.Environment == nil {
918
+
ewf.Environment = make(map[string]string)
919
+
}
920
+
maps.Copy(ewf.Environment, pipelineEnv)
921
+
922
+
workflows[eng] = append(workflows[eng], *ewf)
923
+
}
924
+
925
+
pipeline := &models.Pipeline{
926
+
RepoDid: repoDid,
927
+
Workflows: workflows,
928
+
TrustedSource: trustedSource,
929
+
}
930
+
931
+
if s.mill != nil {
932
+
// mill host: no bounded pool. each job blocks in placement
933
+
// (AcquireWorkflowSlot) which the user sees as pending. the only bound
934
+
// is the mill's maxPending. rootCtx is the long-lived consumer context,
935
+
// so the goroutine safely outlives this call
936
+
go engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.stores, s.db, s.n, s.rootCtx, pipeline, pipelineId)
937
+
s.l.Info("pipeline handed to mill placement", "id", pipelineId)
938
+
} else if s.jq != nil {
939
+
ok := s.jq.Enqueue(repoDid, queue.Job{
940
+
Run: func() error {
941
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.stores, s.db, s.n, s.rootCtx, pipeline, pipelineId)
942
+
return nil
943
+
},
944
+
OnFail: func(jobError error) {
945
+
s.l.Error("pipeline run failed", "error", jobError)
946
+
},
947
+
})
948
+
if !ok {
949
+
return fmt.Errorf("failed to enqueue pipeline: queue is full")
950
+
}
951
+
s.l.Info("pipeline enqueued successfully", "id", pipelineId)
952
+
} else {
953
+
return fmt.Errorf("no queue or mill available to process pipeline")
954
+
}
955
+
956
+
// after successful enqueue, emit StatusPending for all workflows
957
+
for _, ewfs := range workflows {
958
+
for _, ewf := range ewfs {
959
+
err := s.db.StatusPending(models.WorkflowId{
960
+
PipelineId: pipelineId,
961
+
Name: ewf.Name,
962
+
}, s.n)
963
+
if err != nil {
964
+
return fmt.Errorf("db.StatusPending: %w", err)
965
+
}
966
+
}
967
+
}
968
+
return nil
969
+
}
970
+
971
+
// newRepoPath creates a path to store repository by its did and rkey.
972
+
// The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey
973
+
func (s *Spindle) newRepoPath(repo syntax.DID) string {
974
+
return filepath.Join(s.cfg.Server.RepoDir, repo.String())
975
+
}
976
+
977
+
func (s *Spindle) newRepoCloneUrl(knot string, did syntax.DID) string {
978
+
scheme := "https://"
979
+
if s.cfg.Server.Dev {
980
+
scheme = "http://"
981
+
}
982
+
return fmt.Sprintf("%s%s/%s", scheme, knot, did)
983
+
}
984
+
985
+
const RequiredVersion = "2.49.0"
986
+
987
+
func ensureGitVersion() error {
988
+
v, err := git.Version()
989
+
if err != nil {
990
+
return fmt.Errorf("fetching git version: %w", err)
991
+
}
992
+
if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) {
993
+
return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion)
994
+
}
995
+
return nil
996
+
}
997
+
998
+
func (s *Spindle) configureOwner() error {
999
+
cfgOwner := s.cfg.Server.Owner
1000
+
1001
+
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
1002
+
if err != nil {
1003
+
return err
1004
+
}
1005
+
1006
+
switch len(existing) {
1007
+
case 0:
1008
+
// no owner configured, continue
1009
+
case 1:
1010
+
// find existing owner
1011
+
existingOwner := existing[0]
1012
+
1013
+
// no ownership change, this is okay
1014
+
if existingOwner == s.cfg.Server.Owner {
1015
+
break
1016
+
}
1017
+
1018
+
// remove existing owner
1019
+
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
1020
+
if err != nil {
1021
+
return nil
1022
+
}
1023
+
default:
1024
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath)
1025
+
}
1026
+
1027
+
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
1028
+
}
+46
spindle/server_test.go
+46
spindle/server_test.go
···
1
1
package spindle
2
2
3
3
import (
4
+
"context"
5
+
"net/http"
6
+
"net/http/httptest"
7
+
"path/filepath"
4
8
"testing"
5
9
6
10
kgit "tangled.org/core/knotserver/git"
11
+
"tangled.org/core/spindle/config"
12
+
"tangled.org/core/spindle/db"
13
+
"tangled.org/core/spindle/models"
7
14
)
8
15
9
16
func TestHasSkipCIPushOption(t *testing.T) {
···
53
60
})
54
61
}
55
62
}
63
+
64
+
func TestExecutorRoleBuildsMinimalSpindle(t *testing.T) {
65
+
ctx := context.Background()
66
+
dbPath := filepath.Join(t.TempDir(), "spindle.db")
67
+
d, err := db.Make(ctx, dbPath)
68
+
if err != nil {
69
+
t.Fatalf("db.Make() error = %v", err)
70
+
}
71
+
72
+
cfg := &config.Config{Role: config.RoleExecutor}
73
+
cfg.Server.DBPath = dbPath
74
+
cfg.Server.Hostname = "executor.test"
75
+
cfg.Server.Tap.Embed = true
76
+
cfg.ArtifactStores.Disk.Dir = t.TempDir()
77
+
cfg.Mill.ArtifactStore = "disk"
78
+
79
+
s, err := New(ctx, cfg, d, map[string]models.Engine{})
80
+
if err != nil {
81
+
t.Fatalf("New() error = %v", err)
82
+
}
83
+
84
+
if s.jc != nil || s.tap != nil || s.e != nil || s.ks != nil || s.res != nil || s.vault != nil {
85
+
t.Fatal("executor role built coordinator-only spindle dependencies")
86
+
}
87
+
88
+
rr := httptest.NewRecorder()
89
+
req := httptest.NewRequest(http.MethodGet, "/", nil)
90
+
s.Router().ServeHTTP(rr, req)
91
+
if rr.Code != http.StatusOK {
92
+
t.Fatalf("root status = %d, want %d", rr.Code, http.StatusOK)
93
+
}
94
+
95
+
rr = httptest.NewRecorder()
96
+
req = httptest.NewRequest(http.MethodGet, "/xrpc/_health", nil)
97
+
s.Router().ServeHTTP(rr, req)
98
+
if rr.Code != http.StatusNotFound {
99
+
t.Fatalf("executor xrpc status = %d, want %d", rr.Code, http.StatusNotFound)
100
+
}
101
+
}
+12
-26
spindle/stream.go
+12
-26
spindle/stream.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"io"
8
7
"net/http"
9
8
"time"
10
9
11
10
"tangled.org/core/eventstream"
12
11
"tangled.org/core/log"
12
+
"tangled.org/core/spindle/logview"
13
13
"tangled.org/core/spindle/models"
14
14
15
15
"github.com/go-chi/chi/v5"
16
16
"github.com/gorilla/websocket"
17
-
"github.com/hpcloud/tail"
18
17
)
19
18
20
19
var upgrader = websocket.Upgrader{
···
89
88
}
90
89
isFinished := models.StatusKind(status.Status).IsFinish()
91
90
92
-
filePath := models.LogFilePath(s.cfg.Server.LogDir, wid)
93
-
94
-
config := tail.Config{
95
-
Follow: !isFinished,
96
-
ReOpen: !isFinished,
97
-
MustExist: false,
98
-
Location: &tail.SeekInfo{
99
-
Offset: 0,
100
-
Whence: io.SeekStart,
101
-
},
102
-
// Logger: tail.DiscardingLogger,
103
-
}
104
-
105
-
t, err := tail.TailFile(filePath, config)
91
+
lines, stop, err := logview.Follow(ctx, s.db, s.reader, s.cfg.Server.LogDir, wid, isFinished)
106
92
if err != nil {
107
-
return fmt.Errorf("failed to tail log file: %w", err)
93
+
return fmt.Errorf("failed to follow workflow log: %w", err)
108
94
}
109
-
defer t.Stop()
110
-
95
+
defer stop()
111
96
for {
112
97
select {
113
98
case <-ctx.Done():
114
99
return ctx.Err()
115
-
case line := <-t.Lines:
116
-
if line == nil && isFinished {
117
-
return fmt.Errorf("tail completed")
100
+
case line, ok := <-lines:
101
+
if !ok && isFinished {
102
+
return fmt.Errorf("log completed")
103
+
}
104
+
if !ok {
105
+
return fmt.Errorf("log channel closed unexpectedly")
118
106
}
119
-
120
107
if line == nil {
121
-
return fmt.Errorf("tail channel closed unexpectedly")
108
+
continue
122
109
}
123
-
124
110
if line.Err != nil {
125
-
return fmt.Errorf("error tailing log file: %w", line.Err)
111
+
return fmt.Errorf("error following workflow log: %w", line.Err)
126
112
}
127
113
128
114
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
+7
-20
spindle/xrpc/ci_pipeline_subscribe_logs.go
+7
-20
spindle/xrpc/ci_pipeline_subscribe_logs.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
-
"io"
8
7
"net/http"
9
8
"sync"
10
9
"time"
···
12
11
"github.com/bluesky-social/indigo/atproto/atclient"
13
12
"github.com/bluesky-social/indigo/atproto/syntax"
14
13
"github.com/gorilla/websocket"
15
-
"github.com/hpcloud/tail"
16
14
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/spindle/logview"
17
16
"tangled.org/core/spindle/models"
18
17
)
19
18
···
166
165
isFinished = models.StatusKind(status.Status).IsFinish()
167
166
}
168
167
169
-
filePath := models.LogFilePath(x.Config.Server.LogDir, wid)
170
-
171
-
tailConfig := tail.Config{
172
-
Follow: !isFinished,
173
-
ReOpen: !isFinished,
174
-
MustExist: false,
175
-
Location: &tail.SeekInfo{
176
-
Offset: 0,
177
-
Whence: io.SeekStart,
178
-
},
179
-
}
180
-
181
-
t, err := tail.TailFile(filePath, tailConfig)
168
+
lines, stop, err := logview.Follow(ctx, x.Db, x.ArtifactReader, x.Config.Server.LogDir, wid, isFinished)
182
169
if err != nil {
183
-
l.Error("failed to tail log file", "workflow", wfName, "err", err)
170
+
l.Error("failed to follow workflow log", "workflow", wfName, "err", err)
184
171
return
185
172
}
186
-
defer t.Stop()
173
+
defer stop()
187
174
188
-
// if we are following, poll status in database to stop tailing when finished
175
+
// if we are following, poll status in database to stop when finished
189
176
if !isFinished {
190
177
go func() {
191
178
ticker := time.NewTicker(2 * time.Second)
···
197
184
case <-ticker.C:
198
185
status, err := x.Db.GetStatus(wid)
199
186
if err == nil && models.StatusKind(status.Status).IsFinish() {
200
-
t.Stop()
187
+
stop()
201
188
return
202
189
}
203
190
}
···
209
196
select {
210
197
case <-ctx.Done():
211
198
return
212
-
case line, ok := <-t.Lines:
199
+
case line, ok := <-lines:
213
200
if !ok || line == nil {
214
201
return
215
202
}
+12
-10
spindle/xrpc/xrpc.go
+12
-10
spindle/xrpc/xrpc.go
···
15
15
"tangled.org/core/idresolver"
16
16
"tangled.org/core/notifier"
17
17
"tangled.org/core/rbac"
18
+
"tangled.org/core/spindle/artifactstore"
18
19
"tangled.org/core/spindle/config"
19
20
"tangled.org/core/spindle/db"
20
21
"tangled.org/core/spindle/models"
···
41
42
}
42
43
43
44
type Xrpc struct {
44
-
Logger *slog.Logger
45
-
Db *db.DB
46
-
Enforcer *rbac.Enforcer
47
-
Engines map[string]models.Engine
48
-
Config *config.Config
49
-
Resolver *idresolver.Resolver
50
-
Vault secrets.Manager
51
-
Notifier *notifier.Notifier
52
-
ServiceAuth *serviceauth.ServiceAuth
53
-
Trigger PipelineTrigger
45
+
Logger *slog.Logger
46
+
Db *db.DB
47
+
Enforcer *rbac.Enforcer
48
+
Engines map[string]models.Engine
49
+
Config *config.Config
50
+
ArtifactReader artifactstore.Reader
51
+
Resolver *idresolver.Resolver
52
+
Vault secrets.Manager
53
+
Notifier *notifier.Notifier
54
+
ServiceAuth *serviceauth.ServiceAuth
55
+
Trigger PipelineTrigger
54
56
}
55
57
56
58
func (x *Xrpc) Router() http.Handler {
+7
-8
workflow/compile.go
+7
-8
workflow/compile.go
···
112
112
}
113
113
114
114
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
115
-
cw := &tangled.Pipeline_Workflow{}
116
-
117
115
matched, err := w.Match(compiler.Trigger, compiler.ChangedFiles)
118
116
if err != nil {
119
117
compiler.Diagnostics.AddError(
···
134
132
// validate clone options
135
133
compiler.analyzeCloneOptions(w)
136
134
137
-
cw.Name = w.Name
138
-
139
135
if w.Engine == "" {
140
136
compiler.Diagnostics.AddError(w.Name, MissingEngine)
141
137
return nil
142
138
}
143
139
144
-
cw.Engine = w.Engine
145
-
cw.Raw = w.Raw
146
-
147
140
o := w.CloneOpts.AsRecord()
148
-
cw.Clone = &o
141
+
cw := &tangled.Pipeline_Workflow{
142
+
Clone: &o,
143
+
Engine: w.Engine,
144
+
Name: w.Name,
145
+
Raw: w.Raw,
146
+
RunsOn: w.RunsOn,
147
+
}
149
148
150
149
return cw
151
150
}
+45
workflow/compile_test.go
+45
workflow/compile_test.go
···
1
1
package workflow
2
2
3
3
import (
4
+
"encoding/json"
4
5
"strings"
5
6
"testing"
6
7
···
41
42
assert.False(t, c.Diagnostics.IsErr())
42
43
}
43
44
45
+
func TestCompileWorkflow_RunsOnLabelsPersistWithoutFanout(t *testing.T) {
46
+
wf := Workflow{
47
+
Name: ".tangled/workflows/arm64.yml",
48
+
Engine: "microvm",
49
+
When: when,
50
+
RunsOn: []string{"linux/arm64", "kvm"},
51
+
}
52
+
53
+
c := Compiler{Trigger: trigger}
54
+
cp := c.Compile([]Workflow{wf})
55
+
56
+
assert.Len(t, cp.Workflows, 1)
57
+
assert.Equal(t, []string{"linux/arm64", "kvm"}, cp.Workflows[0].RunsOn)
58
+
59
+
raw, err := json.Marshal(cp.Workflows[0])
60
+
assert.NoError(t, err)
61
+
62
+
var persisted map[string]any
63
+
assert.NoError(t, json.Unmarshal(raw, &persisted))
64
+
assert.Equal(t, []any{"linux/arm64", "kvm"}, persisted["runsOn"])
65
+
}
66
+
67
+
func TestCompileWorkflow_LegacyWorkflowOmitsRunsOn(t *testing.T) {
68
+
wf := Workflow{
69
+
Name: ".tangled/workflows/legacy.yml",
70
+
Engine: "microvm",
71
+
When: when,
72
+
}
73
+
74
+
c := Compiler{Trigger: trigger}
75
+
cp := c.Compile([]Workflow{wf})
76
+
77
+
assert.Len(t, cp.Workflows, 1)
78
+
assert.Empty(t, cp.Workflows[0].RunsOn)
79
+
80
+
raw, err := json.Marshal(cp.Workflows[0])
81
+
assert.NoError(t, err)
82
+
83
+
var persisted map[string]any
84
+
assert.NoError(t, json.Unmarshal(raw, &persisted))
85
+
_, ok := persisted["runsOn"]
86
+
assert.False(t, ok, "legacy workflow JSON should not gain runsOn")
87
+
}
88
+
44
89
func TestCompileWorkflow_TriggerMismatch(t *testing.T) {
45
90
wf := Workflow{
46
91
Name: ".tangled/workflows/mismatch.yml",
+14
-7
workflow/def.go
+14
-7
workflow/def.go
···
27
27
Workflow struct {
28
28
Name string `yaml:"-"` // name of the workflow file
29
29
Engine string `yaml:"engine"`
30
+
RunsOn []string `yaml:"runs_on"`
30
31
When []Constraint `yaml:"when"`
31
32
CloneOpts CloneOpts `yaml:"clone"`
32
33
Raw string `yaml:"-"`
···
121
122
}
122
123
123
124
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata, changedFiles []string) (bool, error) {
124
-
match := true
125
-
126
125
// manual triggers always pass this constraint
127
126
if trigger.Manual != nil {
128
127
return true, nil
129
128
}
130
129
131
130
// apply event constraints
132
-
match = match && c.MatchEvent(trigger.Kind)
131
+
if !c.MatchEvent(trigger.Kind) {
132
+
return false, nil
133
+
}
133
134
134
135
// apply branch constraints for PRs
135
136
if trigger.PullRequest != nil {
···
137
138
if err != nil {
138
139
return false, err
139
140
}
140
-
match = match && matched
141
+
if !matched {
142
+
return false, nil
143
+
}
141
144
}
142
145
143
146
// apply ref constraints for pushes
···
146
149
if err != nil {
147
150
return false, err
148
151
}
149
-
match = match && matched
152
+
if !matched {
153
+
return false, nil
154
+
}
150
155
}
151
156
152
157
// apply paths filter: if specified, at least one changed file must match
···
155
160
if err != nil {
156
161
return false, err
157
162
}
158
-
match = match && matched
163
+
if !matched {
164
+
return false, nil
165
+
}
159
166
}
160
167
161
-
return match, nil
168
+
return true, nil
162
169
}
163
170
164
171
// matchesAnyFile returns true if any file in files matches any of the glob patterns.
+13
workflow/def_test.go
+13
workflow/def_test.go
···
23
23
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
24
24
}
25
25
26
+
func TestUnmarshalWorkflowWithRunsOnLabels(t *testing.T) {
27
+
yamlData := `
28
+
engine: microvm
29
+
runs_on: [linux/arm64, kvm]
30
+
when:
31
+
- event: push`
32
+
33
+
wf, err := FromFile("test.yml", []byte(yamlData))
34
+
assert.NoError(t, err)
35
+
36
+
assert.Equal(t, []string{"linux/arm64", "kvm"}, wf.RunsOn)
37
+
}
38
+
26
39
func TestUnmarshalCloneFalse(t *testing.T) {
27
40
yamlData := `
28
41
when:
History
9 rounds
0 comments
1 commit
Expand
Collapse
spindle: add artifact store
Signed-off-by: dawn <dawn@tangled.org>
3/3 success
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: add artifact store
Signed-off-by: dawn <dawn@tangled.org>
1/3 failed, 2/3 cancelled
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: add artifact store
Signed-off-by: dawn <dawn@tangled.org>
2/3 success, 1/3 failed
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>
3/3 success
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>
2/3 success, 1/3 failed
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>
2/3 success, 1/3 failed
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>
1/3 success, 2/3 failed
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>
3/3 failed
Expand
Collapse
Expand 0 comments
1 commit
Expand
Collapse
spindle: implement mill
Signed-off-by: dawn <dawn@tangled.org>