relay-flow 0.3.7-alpha → 0.3.8-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,10 @@
1
1
  package workflow
2
2
 
3
3
  import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
4
8
  "fmt"
5
9
  "os"
6
10
  "path/filepath"
@@ -17,12 +21,117 @@ type Store struct {
17
21
  Dir string
18
22
  }
19
23
 
24
+ const (
25
+ acceptedHashSuffix = ".sha256"
26
+ transactionSuffix = ".txn"
27
+
28
+ transactionForward = "forward"
29
+ transactionRollback = "rollback"
30
+ )
31
+
20
32
  func (s *Store) path(name string) string {
21
33
  return filepath.Join(s.Dir, name+".yaml")
22
34
  }
23
35
 
24
- // LoadAll reads and parses every stored workflow file.
36
+ func (s *Store) hashPath(name string) string {
37
+ return filepath.Join(s.Dir, name+acceptedHashSuffix)
38
+ }
39
+
40
+ func (s *Store) transactionPath(name string) string {
41
+ return filepath.Join(s.Dir, name+transactionSuffix)
42
+ }
43
+
44
+ // StoredWorkflow is one workflow file plus the startup trust result derived
45
+ // from its accepted hash and local definition. A malformed file still gets a
46
+ // record with a placeholder Workflow so it remains visible for repair.
47
+ type StoredWorkflow struct {
48
+ Name string
49
+ Raw []byte
50
+ AcceptedHash string
51
+ Workflow *Workflow
52
+ Status HealthStatus
53
+ StatusReason string
54
+ RepairCommand string
55
+ }
56
+
57
+ type fileSnapshot struct {
58
+ yaml []byte
59
+ yamlExist bool
60
+ hash []byte
61
+ hashExist bool
62
+ }
63
+
64
+ // transactionRecord makes the two-file definition/hash replacement
65
+ // recoverable. The journal is written before either target changes. On the
66
+ // next startup, a complete new pair is finalized; otherwise the old pair is
67
+ // restored. This prevents a crash from leaving a permanently mixed pair.
68
+ type transactionRecord struct {
69
+ Name string `json:"name"`
70
+ Kind string `json:"kind"`
71
+ OldYAML []byte `json:"oldYaml,omitempty"`
72
+ OldYAMLExists bool `json:"oldYamlExists"`
73
+ OldHash []byte `json:"oldHash,omitempty"`
74
+ OldHashExists bool `json:"oldHashExists"`
75
+ NewYAML []byte `json:"newYaml,omitempty"`
76
+ NewYAMLExists bool `json:"newYamlExists"`
77
+ NewHash []byte `json:"newHash,omitempty"`
78
+ NewHashExists bool `json:"newHashExists"`
79
+ }
80
+
81
+ func newTransaction(name string, old fileSnapshot, yamlBytes, hashBytes []byte) transactionRecord {
82
+ return transactionRecord{
83
+ Name: name, Kind: transactionForward,
84
+ OldYAML: old.yaml, OldYAMLExists: old.yamlExist,
85
+ OldHash: old.hash, OldHashExists: old.hashExist,
86
+ NewYAML: yamlBytes, NewYAMLExists: true,
87
+ NewHash: hashBytes, NewHashExists: true,
88
+ }
89
+ }
90
+
91
+ func (t transactionRecord) oldSnapshot() fileSnapshot {
92
+ return fileSnapshot{yaml: t.OldYAML, yamlExist: t.OldYAMLExists, hash: t.OldHash, hashExist: t.OldHashExists}
93
+ }
94
+
95
+ func (t transactionRecord) newSnapshot() fileSnapshot {
96
+ return fileSnapshot{yaml: t.NewYAML, yamlExist: t.NewYAMLExists, hash: t.NewHash, hashExist: t.NewHashExists}
97
+ }
98
+
99
+ // LoadAll reads every stored workflow file independently. Per-file parse or
100
+ // integrity failures are represented by a non-routable workflow record so a
101
+ // caller that only needs definitions cannot accidentally make startup global.
25
102
  func (s *Store) LoadAll() ([]*Workflow, error) {
103
+ records, err := s.LoadAllRecords()
104
+ if err != nil {
105
+ return nil, err
106
+ }
107
+ out := make([]*Workflow, 0, len(records))
108
+ for _, record := range records {
109
+ if record.Workflow != nil {
110
+ out = append(out, record.Workflow)
111
+ }
112
+ }
113
+ return out, nil
114
+ }
115
+
116
+ // Get reads and parses one stored workflow by name. It intentionally keeps
117
+ // the original definition-only API; startup callers that need trust metadata
118
+ // use LoadAllRecords.
119
+ func (s *Store) Get(name string) (*Workflow, error) {
120
+ raw, err := os.ReadFile(s.path(name))
121
+ if err != nil {
122
+ return nil, fmt.Errorf("read workflow %q: %w", name, err)
123
+ }
124
+ return Parse(name, raw)
125
+ }
126
+
127
+ // LoadAllRecords reads every stored workflow independently. A malformed,
128
+ // tampered, or legacy file becomes a blocked/unverified record rather than
129
+ // aborting the entire load, allowing the server and other workflows to start.
130
+ func (s *Store) LoadAllRecords() ([]StoredWorkflow, error) {
131
+ recoveryIssues, err := s.recoverTransactions()
132
+ if err != nil {
133
+ return nil, err
134
+ }
26
135
  entries, err := os.ReadDir(s.Dir)
27
136
  if err != nil {
28
137
  if os.IsNotExist(err) {
@@ -30,45 +139,330 @@ func (s *Store) LoadAll() ([]*Workflow, error) {
30
139
  }
31
140
  return nil, fmt.Errorf("read workflow dir %s: %w", s.Dir, err)
32
141
  }
33
- var out []*Workflow
34
- for _, e := range entries {
35
- if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
142
+ nameSet := map[string]bool{}
143
+ for _, entry := range entries {
144
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
36
145
  continue
37
146
  }
38
- name := strings.TrimSuffix(e.Name(), ".yaml")
39
- wf, err := s.Get(name)
40
- if err != nil {
41
- return nil, err
147
+ nameSet[strings.TrimSuffix(entry.Name(), ".yaml")] = true
148
+ }
149
+ for name := range recoveryIssues {
150
+ nameSet[name] = true
151
+ }
152
+ names := make([]string, 0, len(nameSet))
153
+ for name := range nameSet {
154
+ names = append(names, name)
155
+ }
156
+ sort.Strings(names)
157
+ records := make([]StoredWorkflow, 0, len(names))
158
+ for _, name := range names {
159
+ record := s.loadRecord(name)
160
+ if reason, ok := recoveryIssues[name]; ok {
161
+ record.Status = HealthBlocked
162
+ record.StatusReason = fmt.Sprintf("recover workflow storage transaction: %v", reason)
163
+ if record.Workflow == nil {
164
+ record.Workflow = &Workflow{Name: name}
165
+ }
166
+ record.Workflow.MarkBlocked(record.StatusReason, s.repairCommand(name))
42
167
  }
43
- out = append(out, wf)
168
+ records = append(records, record)
44
169
  }
45
- return out, nil
170
+ return records, nil
46
171
  }
47
172
 
48
- // Get reads and parses one stored workflow by name.
49
- func (s *Store) Get(name string) (*Workflow, error) {
173
+ // loadRecord never returns a per-file error. The record is retained with a
174
+ // diagnostic so one bad workflow cannot prevent management startup.
175
+ func (s *Store) loadRecord(name string) StoredWorkflow {
176
+ record := StoredWorkflow{
177
+ Name: name,
178
+ Status: HealthBlocked,
179
+ RepairCommand: s.repairCommand(name),
180
+ Workflow: &Workflow{Name: name},
181
+ }
50
182
  raw, err := os.ReadFile(s.path(name))
51
183
  if err != nil {
52
- return nil, fmt.Errorf("read workflow %q: %w", name, err)
184
+ record.StatusReason = fmt.Sprintf("read workflow definition: %v", err)
185
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
186
+ return record
53
187
  }
54
- return Parse(name, raw)
188
+ record.Raw = raw
189
+
190
+ wf, parseErr := Parse(name, raw)
191
+ if parseErr != nil {
192
+ record.StatusReason = parseErr.Error()
193
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
194
+ return record
195
+ }
196
+ record.Workflow = wf
197
+ if wf.Name != name {
198
+ record.StatusReason = fmt.Sprintf("workflow file %q contains definition named %q", name+".yaml", wf.Name)
199
+ wf.MarkBlocked(record.StatusReason, record.RepairCommand)
200
+ return record
201
+ }
202
+
203
+ hashBytes, hashErr := os.ReadFile(s.hashPath(name))
204
+ switch {
205
+ case os.IsNotExist(hashErr):
206
+ record.Status = HealthUnverified
207
+ record.StatusReason = "accepted SHA-256 hash is missing"
208
+ record.Workflow.MarkUnverified(record.StatusReason, record.RepairCommand)
209
+ return record
210
+ case hashErr != nil:
211
+ record.StatusReason = fmt.Sprintf("read accepted SHA-256 hash: %v", hashErr)
212
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
213
+ return record
214
+ }
215
+ record.AcceptedHash = strings.TrimSpace(string(hashBytes))
216
+ if len(record.AcceptedHash) != sha256.Size*2 {
217
+ record.StatusReason = "accepted SHA-256 hash has invalid length"
218
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
219
+ return record
220
+ }
221
+ if _, err := hex.DecodeString(record.AcceptedHash); err != nil {
222
+ record.StatusReason = fmt.Sprintf("accepted SHA-256 hash is invalid: %v", err)
223
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
224
+ return record
225
+ }
226
+ actual := sha256.Sum256(raw)
227
+ if !strings.EqualFold(record.AcceptedHash, hex.EncodeToString(actual[:])) {
228
+ record.Status = HealthOutdated
229
+ record.StatusReason = fmt.Sprintf("workflow YAML hash mismatch (accepted %s, actual %s)", record.AcceptedHash, hex.EncodeToString(actual[:]))
230
+ record.Workflow.MarkOutdated(record.StatusReason, record.RepairCommand)
231
+ return record
232
+ }
233
+ if err := wf.Validate(); err != nil {
234
+ record.StatusReason = fmt.Sprintf("local workflow validation failed: %v", err)
235
+ record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
236
+ return record
237
+ }
238
+ record.Status = HealthHealthy
239
+ record.Workflow.MarkHealthy()
240
+ return record
241
+ }
242
+
243
+ func (s *Store) repairCommand(name string) string {
244
+ return fmt.Sprintf("relay-flow workflow submit --file %s", s.path(name))
245
+ }
246
+
247
+ // snapshot captures both files so Service can restore the previous accepted
248
+ // definition if a later in-memory rebind fails.
249
+ func (s *Store) snapshot(name string) (fileSnapshot, error) {
250
+ var snapshot fileSnapshot
251
+ yamlBytes, err := os.ReadFile(s.path(name))
252
+ if err == nil {
253
+ snapshot.yaml, snapshot.yamlExist = yamlBytes, true
254
+ } else if !os.IsNotExist(err) {
255
+ return fileSnapshot{}, fmt.Errorf("read workflow %q for rollback: %w", name, err)
256
+ }
257
+ hashBytes, err := os.ReadFile(s.hashPath(name))
258
+ if err == nil {
259
+ snapshot.hash, snapshot.hashExist = hashBytes, true
260
+ } else if !os.IsNotExist(err) {
261
+ return fileSnapshot{}, fmt.Errorf("read workflow hash %q for rollback: %w", name, err)
262
+ }
263
+ return snapshot, nil
264
+ }
265
+
266
+ func (s *Store) restore(name string, snapshot fileSnapshot) error {
267
+ if snapshot.yamlExist {
268
+ if err := config.WriteAtomic(s.path(name), snapshot.yaml, 0o644); err != nil {
269
+ return err
270
+ }
271
+ } else if err := removeIfExists(s.path(name)); err != nil {
272
+ return err
273
+ }
274
+ if snapshot.hashExist {
275
+ if err := config.WriteAtomic(s.hashPath(name), snapshot.hash, 0o644); err != nil {
276
+ return err
277
+ }
278
+ } else if err := removeIfExists(s.hashPath(name)); err != nil {
279
+ return err
280
+ }
281
+ return nil
55
282
  }
56
283
 
57
- // Put atomically writes the workflow file with mode 0644.
284
+ // restorePair applies a rollback target through its own journal. This keeps a
285
+ // failed rebind from turning the rollback itself into a mixed YAML/hash pair.
286
+ func (s *Store) restorePair(name string, desired fileSnapshot) error {
287
+ current, err := s.snapshot(name)
288
+ if err != nil {
289
+ return err
290
+ }
291
+ transaction := transactionRecord{
292
+ Name: name, Kind: transactionRollback,
293
+ OldYAML: current.yaml, OldYAMLExists: current.yamlExist,
294
+ OldHash: current.hash, OldHashExists: current.hashExist,
295
+ NewYAML: desired.yaml, NewYAMLExists: desired.yamlExist,
296
+ NewHash: desired.hash, NewHashExists: desired.hashExist,
297
+ }
298
+ if err := s.writeTransaction(transaction); err != nil {
299
+ return err
300
+ }
301
+ if err := s.restore(name, desired); err != nil {
302
+ // Leave the rollback journal in place. Startup recovery will retry
303
+ // the desired target rather than restoring the failed candidate.
304
+ return fmt.Errorf("restore workflow %q: %w", name, err)
305
+ }
306
+ if err := removeIfExists(s.transactionPath(name)); err != nil {
307
+ // The desired pair is already applied. Do not invoke the forward
308
+ // rollback path here; that would restore the candidate pair.
309
+ return fmt.Errorf("finish restoring workflow %q: %w", name, err)
310
+ }
311
+ return nil
312
+ }
313
+
314
+ func pairMatches(got, want fileSnapshot) bool {
315
+ return got.yamlExist == want.yamlExist && got.hashExist == want.hashExist &&
316
+ (!got.yamlExist || bytes.Equal(got.yaml, want.yaml)) &&
317
+ (!got.hashExist || bytes.Equal(got.hash, want.hash))
318
+ }
319
+
320
+ func (s *Store) writeTransaction(transaction transactionRecord) error {
321
+ raw, err := json.Marshal(transaction)
322
+ if err != nil {
323
+ return fmt.Errorf("marshal workflow storage transaction %q: %w", transaction.Name, err)
324
+ }
325
+ if err := config.WriteAtomic(s.transactionPath(transaction.Name), raw, 0o600); err != nil {
326
+ return fmt.Errorf("write workflow storage transaction %q: %w", transaction.Name, err)
327
+ }
328
+ return nil
329
+ }
330
+
331
+ // recoverTransactions resolves any journal left by a process crash. A
332
+ // complete new pair wins; any mixed or incomplete pair rolls back to the old
333
+ // snapshot. The returned per-workflow errors are surfaced as blocked health
334
+ // records instead of aborting the server globally.
335
+ func (s *Store) recoverTransactions() (map[string]error, error) {
336
+ entries, err := os.ReadDir(s.Dir)
337
+ if err != nil {
338
+ if os.IsNotExist(err) {
339
+ return nil, nil
340
+ }
341
+ return nil, fmt.Errorf("read workflow transaction dir %s: %w", s.Dir, err)
342
+ }
343
+ issues := map[string]error{}
344
+ for _, entry := range entries {
345
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), transactionSuffix) {
346
+ continue
347
+ }
348
+ name := strings.TrimSuffix(entry.Name(), transactionSuffix)
349
+ raw, readErr := os.ReadFile(filepath.Join(s.Dir, entry.Name()))
350
+ if readErr != nil {
351
+ issues[name] = readErr
352
+ continue
353
+ }
354
+ var transaction transactionRecord
355
+ if unmarshalErr := json.Unmarshal(raw, &transaction); unmarshalErr != nil {
356
+ issues[name] = unmarshalErr
357
+ continue
358
+ }
359
+ if transaction.Name == "" {
360
+ transaction.Name = name
361
+ }
362
+ if recoverErr := s.recoverTransaction(transaction, false); recoverErr != nil {
363
+ issues[transaction.Name] = recoverErr
364
+ }
365
+ }
366
+ return issues, nil
367
+ }
368
+
369
+ func (s *Store) recoverTransaction(transaction transactionRecord, forceOld bool) error {
370
+ name := transaction.Name
371
+ current, err := s.snapshot(name)
372
+ if err != nil {
373
+ return err
374
+ }
375
+ old := transaction.oldSnapshot()
376
+ newPair := transaction.newSnapshot()
377
+ if transaction.Kind == transactionRollback {
378
+ // Rollback journals have the opposite semantic: the requested prior
379
+ // pair is New and must win even when the process stopped before or
380
+ // during restoration. Never restore Old for this journal kind.
381
+ if pairMatches(current, newPair) {
382
+ return removeIfExists(s.transactionPath(name))
383
+ }
384
+ if err := s.restore(name, newPair); err != nil {
385
+ return fmt.Errorf("restore rollback target: %w", err)
386
+ }
387
+ return removeIfExists(s.transactionPath(name))
388
+ }
389
+ if !forceOld && (pairMatches(current, newPair) || pairMatches(current, old)) {
390
+ return removeIfExists(s.transactionPath(name))
391
+ }
392
+ if err := s.restore(name, old); err != nil {
393
+ return fmt.Errorf("restore prior workflow pair: %w", err)
394
+ }
395
+ return removeIfExists(s.transactionPath(name))
396
+ }
397
+
398
+ // Put replaces the exact submitted YAML and its accepted SHA-256 hash. The
399
+ // journal is committed before either target changes; a crash therefore
400
+ // recovers either the complete old pair or the complete new pair. Synchronous
401
+ // write errors force the old pair back before returning.
58
402
  func (s *Store) Put(name string, yamlBytes []byte) error {
59
403
  if err := os.MkdirAll(s.Dir, 0o755); err != nil {
60
404
  return fmt.Errorf("create workflow dir %s: %w", s.Dir, err)
61
405
  }
406
+ before, err := s.snapshot(name)
407
+ if err != nil {
408
+ return err
409
+ }
410
+ sum := sha256.Sum256(yamlBytes)
411
+ hashBytes := []byte(hex.EncodeToString(sum[:]) + "\n")
412
+ transaction := newTransaction(name, before, yamlBytes, hashBytes)
413
+ if err := s.writeTransaction(transaction); err != nil {
414
+ return err
415
+ }
62
416
  if err := config.WriteAtomic(s.path(name), yamlBytes, 0o644); err != nil {
63
- return fmt.Errorf("store workflow %q: %w", name, err)
417
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("store workflow %q: %w", name, err))
418
+ }
419
+ if err := config.WriteAtomic(s.hashPath(name), hashBytes, 0o644); err != nil {
420
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("store workflow %q accepted hash: %w", name, err))
421
+ }
422
+ if err := removeIfExists(s.transactionPath(name)); err != nil {
423
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("finish storing workflow %q: %w", name, err))
64
424
  }
65
425
  return nil
66
426
  }
67
427
 
68
- // Remove deletes the workflow file.
428
+ func (s *Store) rollbackTransaction(name string, transaction transactionRecord, cause error) error {
429
+ if rollbackErr := s.recoverTransaction(transaction, true); rollbackErr != nil {
430
+ return fmt.Errorf("%w (rollback failed: %v)", cause, rollbackErr)
431
+ }
432
+ return cause
433
+ }
434
+
435
+ // Remove deletes the workflow definition and its accepted hash using the same
436
+ // recoverable pair transaction as Put.
69
437
  func (s *Store) Remove(name string) error {
70
- if err := os.Remove(s.path(name)); err != nil {
71
- return fmt.Errorf("remove workflow %q: %w", name, err)
438
+ before, err := s.snapshot(name)
439
+ if err != nil {
440
+ return err
441
+ }
442
+ if !before.yamlExist {
443
+ return fmt.Errorf("remove workflow %q: file does not exist", name)
444
+ }
445
+ transaction := newTransaction(name, before, nil, nil)
446
+ transaction.NewYAMLExists = false
447
+ transaction.NewHashExists = false
448
+ if err := s.writeTransaction(transaction); err != nil {
449
+ return err
450
+ }
451
+ if err := removeIfExists(s.path(name)); err != nil {
452
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("remove workflow %q: %w", name, err))
453
+ }
454
+ if err := removeIfExists(s.hashPath(name)); err != nil {
455
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("remove workflow %q accepted hash: %w", name, err))
456
+ }
457
+ if err := removeIfExists(s.transactionPath(name)); err != nil {
458
+ return s.rollbackTransaction(name, transaction, fmt.Errorf("finish removing workflow %q: %w", name, err))
459
+ }
460
+ return nil
461
+ }
462
+
463
+ func removeIfExists(path string) error {
464
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
465
+ return err
72
466
  }
73
467
  return nil
74
468
  }