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.
- package/README.md +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +6 -0
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
for _,
|
|
35
|
-
if
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
168
|
+
records = append(records, record)
|
|
44
169
|
}
|
|
45
|
-
return
|
|
170
|
+
return records, nil
|
|
46
171
|
}
|
|
47
172
|
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
184
|
+
record.StatusReason = fmt.Sprintf("read workflow definition: %v", err)
|
|
185
|
+
record.Workflow.MarkBlocked(record.StatusReason, record.RepairCommand)
|
|
186
|
+
return record
|
|
53
187
|
}
|
|
54
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
}
|