relay-flow 0.2.7-alpha → 0.2.9-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 +76 -37
- package/cmd/relay-flow/commands_test.go +199 -19
- package/cmd/relay-flow/main.go +183 -144
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +454 -0
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/config-reference.yaml +7 -0
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +47 -3
- package/internal/repo/service_test.go +36 -1
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +65 -0
- package/internal/server/client.go +40 -2
- package/internal/server/fixture_test.go +27 -16
- package/internal/server/server.go +80 -4
- package/internal/task/beads/beads.go +7 -2
- package/internal/task/beads/beads_test.go +13 -0
- package/internal/task/factory.go +9 -7
- package/internal/task/jira/auth_test.go +9 -1
- package/internal/task/jira/filters_test.go +2 -2
- package/internal/task/jira/helpers_test.go +13 -0
- package/internal/task/jira/jira.go +233 -44
- package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
- package/internal/task/jira/rest/client.go +73 -31
- package/internal/task/jira/rest/client_test.go +25 -0
- package/internal/task/jira/status_defaults_test.go +134 -0
- package/internal/task/jira/templates_test.go +2 -2
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/task/jira/transition_defaults_test.go +8 -12
- package/internal/task/jira/validation_test.go +3 -1
- package/internal/task/registration.go +59 -0
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"io"
|
|
7
|
+
"net"
|
|
8
|
+
"net/http"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"sync"
|
|
13
|
+
"syscall"
|
|
14
|
+
"testing"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/paths"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
21
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
func onboardingPaths(root string) paths.Paths {
|
|
25
|
+
return paths.Paths{
|
|
26
|
+
Root: root, Config: filepath.Join(root, "config.yaml"),
|
|
27
|
+
Credentials: filepath.Join(root, "credentials.yaml"),
|
|
28
|
+
Database: filepath.Join(root, "state.db"),
|
|
29
|
+
Socket: filepath.Join(root, "server.sock"),
|
|
30
|
+
Lock: filepath.Join(root, "server.lock"),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func setInteractiveInitTestSeams(t *testing.T, picker func() ([]string, error), prompt func() (bool, error), setup func(paths.Paths, io.Reader) error) {
|
|
35
|
+
t.Helper()
|
|
36
|
+
oldTTY := interactiveInitTTY
|
|
37
|
+
oldPicker := interactiveInitPluginPick
|
|
38
|
+
oldPrompt := interactiveInitRepoPrompt
|
|
39
|
+
oldSetup := interactiveInitRepoSetup
|
|
40
|
+
interactiveInitTTY = func(io.Reader) bool { return true }
|
|
41
|
+
interactiveInitPluginPick = picker
|
|
42
|
+
interactiveInitRepoPrompt = prompt
|
|
43
|
+
interactiveInitRepoSetup = setup
|
|
44
|
+
t.Cleanup(func() {
|
|
45
|
+
interactiveInitTTY = oldTTY
|
|
46
|
+
interactiveInitPluginPick = oldPicker
|
|
47
|
+
interactiveInitRepoPrompt = oldPrompt
|
|
48
|
+
interactiveInitRepoSetup = oldSetup
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestInteractiveFirstRunSkipAndRegistration(t *testing.T) {
|
|
53
|
+
registerGuidedAuthPlugin()
|
|
54
|
+
for _, tc := range []struct {
|
|
55
|
+
name string
|
|
56
|
+
register bool
|
|
57
|
+
}{
|
|
58
|
+
{name: "skip", register: false},
|
|
59
|
+
{name: "register", register: true},
|
|
60
|
+
} {
|
|
61
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
62
|
+
guidedFirstRunAuthCalls = 0
|
|
63
|
+
home := t.TempDir()
|
|
64
|
+
root := filepath.Join(home, ".relay-flow")
|
|
65
|
+
var setupCalls int
|
|
66
|
+
setInteractiveInitTestSeams(t,
|
|
67
|
+
func() ([]string, error) {
|
|
68
|
+
return []string{guidedFirstRunAuthPlugin, "orca", "opencode", executorGoworkflows}, nil
|
|
69
|
+
},
|
|
70
|
+
func() (bool, error) { return tc.register, nil },
|
|
71
|
+
func(got paths.Paths, _ io.Reader) error {
|
|
72
|
+
setupCalls++
|
|
73
|
+
if got.Config != filepath.Join(root, "config.yaml") {
|
|
74
|
+
t.Fatalf("setup config path = %q", got.Config)
|
|
75
|
+
}
|
|
76
|
+
return nil
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
80
|
+
if code := run([]string{"init"}, strings.NewReader("")); code != exitOK {
|
|
81
|
+
t.Fatalf("interactive init exit = %d", code)
|
|
82
|
+
}
|
|
83
|
+
if guidedFirstRunAuthCalls != 1 {
|
|
84
|
+
t.Fatalf("auth calls = %d, want 1", guidedFirstRunAuthCalls)
|
|
85
|
+
}
|
|
86
|
+
if got := setupCalls; got != map[bool]int{false: 0, true: 1}[tc.register] {
|
|
87
|
+
t.Fatalf("repository setup calls = %d, want %d", got, map[bool]int{false: 0, true: 1}[tc.register])
|
|
88
|
+
}
|
|
89
|
+
for _, path := range []string{filepath.Join(root, "config.yaml"), filepath.Join(root, "state.db")} {
|
|
90
|
+
if _, err := os.Stat(path); err != nil {
|
|
91
|
+
t.Fatalf("missing initialized artifact %s: %v", path, err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if _, err := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(err) {
|
|
95
|
+
t.Fatalf("unexpected credentials file: %v", err)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestInteractiveFirstRunAuthenticationCancellationCleansHome(t *testing.T) {
|
|
102
|
+
registerCanceledFirstRunAuth.Do(func() {
|
|
103
|
+
task.Register(canceledFirstRunAuthPlugin, task.Factory{
|
|
104
|
+
Auth: func(context.Context, []string, io.Reader) error {
|
|
105
|
+
return errCanceledFirstRunAuth
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
110
|
+
setInteractiveInitTestSeams(t,
|
|
111
|
+
func() ([]string, error) {
|
|
112
|
+
return []string{canceledFirstRunAuthPlugin, "orca", "opencode", executorGoworkflows}, nil
|
|
113
|
+
},
|
|
114
|
+
func() (bool, error) { return false, errors.New("repository prompt should not run") },
|
|
115
|
+
func(paths.Paths, io.Reader) error { return errors.New("repository setup should not run") },
|
|
116
|
+
)
|
|
117
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
118
|
+
if code := run([]string{"init"}, strings.NewReader("")); code != exitFail {
|
|
119
|
+
t.Fatalf("canceled interactive init exit = %d, want %d", code, exitFail)
|
|
120
|
+
}
|
|
121
|
+
for _, path := range []string{filepath.Join(root, "config.yaml"), filepath.Join(root, "credentials.yaml"), filepath.Join(root, "state.db")} {
|
|
122
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
123
|
+
t.Fatalf("canceled auth left %s: %v", path, err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func TestNonInteractiveInitAndForceDoNotAuthenticateOrOnboard(t *testing.T) {
|
|
129
|
+
registerGuidedAuthPlugin()
|
|
130
|
+
guidedFirstRunAuthCalls = 0
|
|
131
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
132
|
+
setInteractiveInitTestSeams(t,
|
|
133
|
+
func() ([]string, error) { return nil, errors.New("plugin picker should not run") },
|
|
134
|
+
func() (bool, error) { return false, errors.New("repository prompt should not run") },
|
|
135
|
+
func(paths.Paths, io.Reader) error { return errors.New("repository setup should not run") },
|
|
136
|
+
)
|
|
137
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
138
|
+
args := []string{"init", "--task-plugin", guidedFirstRunAuthPlugin, "--runner-plugin", "orca", "--harness-plugin", "opencode"}
|
|
139
|
+
if code := run(args, strings.NewReader("")); code != exitOK {
|
|
140
|
+
t.Fatalf("non-interactive init exit = %d", code)
|
|
141
|
+
}
|
|
142
|
+
if code := run(append([]string{"init", "--force"}, args[1:]...), strings.NewReader("")); code != exitOK {
|
|
143
|
+
t.Fatalf("forced non-interactive init exit = %d", code)
|
|
144
|
+
}
|
|
145
|
+
if guidedFirstRunAuthCalls != 0 {
|
|
146
|
+
t.Fatalf("non-interactive auth calls = %d, want 0", guidedFirstRunAuthCalls)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestCommitFirstRunPublishesStagedState(t *testing.T) {
|
|
151
|
+
parent := t.TempDir()
|
|
152
|
+
p := onboardingPaths(filepath.Join(parent, ".relay-flow"))
|
|
153
|
+
stage := filepath.Join(parent, "stage")
|
|
154
|
+
if err := os.Mkdir(stage, 0o700); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
if err := config.SaveMachine(filepath.Join(stage, "config.yaml"), &config.Machine{
|
|
158
|
+
TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
|
|
159
|
+
}); err != nil {
|
|
160
|
+
t.Fatal(err)
|
|
161
|
+
}
|
|
162
|
+
if err := os.WriteFile(filepath.Join(stage, "credentials.yaml"), []byte("plugin-owned\n"), 0o600); err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if err := commitFirstRun(p, stage, projection.ExecutorIdentity{ExecutorPlugin: executorGoworkflows}); err != nil {
|
|
167
|
+
t.Fatal(err)
|
|
168
|
+
}
|
|
169
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database} {
|
|
170
|
+
if _, err := os.Stat(path); err != nil {
|
|
171
|
+
t.Fatalf("committed artifact %s: %v", path, err)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database} {
|
|
175
|
+
info, err := os.Stat(path)
|
|
176
|
+
if err != nil {
|
|
177
|
+
t.Fatal(err)
|
|
178
|
+
}
|
|
179
|
+
if info.Mode().Perm() != 0o600 {
|
|
180
|
+
t.Fatalf("%s mode = %o, want 600", path, info.Mode().Perm())
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if _, err := os.Stat(firstRunPendingPath(p)); !os.IsNotExist(err) {
|
|
184
|
+
t.Fatalf("initialization marker remains: %v", err)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const (
|
|
189
|
+
canceledFirstRunAuthPlugin = "canceled-first-run-auth-test"
|
|
190
|
+
guidedFirstRunAuthPlugin = "guided-first-run-auth-test"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
var (
|
|
194
|
+
registerCanceledFirstRunAuth sync.Once
|
|
195
|
+
registerGuidedFirstRunAuth sync.Once
|
|
196
|
+
errCanceledFirstRunAuth = errors.New("authentication canceled")
|
|
197
|
+
guidedFirstRunAuthCalls int
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
func registerGuidedAuthPlugin() {
|
|
201
|
+
registerGuidedFirstRunAuth.Do(func() {
|
|
202
|
+
task.Register(guidedFirstRunAuthPlugin, task.Factory{
|
|
203
|
+
Auth: func(context.Context, []string, io.Reader) error {
|
|
204
|
+
guidedFirstRunAuthCalls++
|
|
205
|
+
return nil
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func TestRecoverFirstRunPublicationRemovesIncompleteArtifacts(t *testing.T) {
|
|
212
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
213
|
+
p := onboardingPaths(root)
|
|
214
|
+
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
215
|
+
t.Fatal(err)
|
|
216
|
+
}
|
|
217
|
+
if err := os.WriteFile(p.Config, []byte("partial\n"), 0o600); err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if err := os.WriteFile(p.Database, []byte("partial\n"), 0o600); err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
|
|
224
|
+
t.Fatal(err)
|
|
225
|
+
}
|
|
226
|
+
if err := recoverFirstRunPublication(p); err != nil {
|
|
227
|
+
t.Fatal(err)
|
|
228
|
+
}
|
|
229
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database, firstRunPendingPath(p)} {
|
|
230
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
231
|
+
t.Fatalf("incomplete publication left %s: %v", path, err)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func TestRecoverFirstRunPublicationKeepsCompleteArtifacts(t *testing.T) {
|
|
237
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
238
|
+
p := onboardingPaths(root)
|
|
239
|
+
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
240
|
+
t.Fatal(err)
|
|
241
|
+
}
|
|
242
|
+
if err := config.SaveMachine(p.Config, &config.Machine{TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode"}); err != nil {
|
|
243
|
+
t.Fatal(err)
|
|
244
|
+
}
|
|
245
|
+
if err := projection.InitDatabase(p.Database); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
if err := os.WriteFile(p.Credentials, []byte("plugin-owned\n"), 0o600); err != nil {
|
|
249
|
+
t.Fatal(err)
|
|
250
|
+
}
|
|
251
|
+
if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
|
|
252
|
+
t.Fatal(err)
|
|
253
|
+
}
|
|
254
|
+
if err := recoverFirstRunPublication(p); err != nil {
|
|
255
|
+
t.Fatal(err)
|
|
256
|
+
}
|
|
257
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database} {
|
|
258
|
+
if _, err := os.Stat(path); err != nil {
|
|
259
|
+
t.Fatalf("complete publication lost %s: %v", path, err)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if _, err := os.Stat(firstRunPendingPath(p)); !os.IsNotExist(err) {
|
|
263
|
+
t.Fatalf("completed publication marker remains: %v", err)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func TestCleanupFailureLeavesFirstRunMarkerForRecovery(t *testing.T) {
|
|
268
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
269
|
+
p := onboardingPaths(root)
|
|
270
|
+
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
if err := os.WriteFile(p.Config, []byte("partial\n"), 0o600); err != nil {
|
|
274
|
+
t.Fatal(err)
|
|
275
|
+
}
|
|
276
|
+
if err := os.WriteFile(p.Database, []byte("partial\n"), 0o600); err != nil {
|
|
277
|
+
t.Fatal(err)
|
|
278
|
+
}
|
|
279
|
+
if err := os.Mkdir(p.Credentials, 0o700); err != nil {
|
|
280
|
+
t.Fatal(err)
|
|
281
|
+
}
|
|
282
|
+
if err := os.WriteFile(filepath.Join(p.Credentials, "held"), []byte("secret\n"), 0o600); err != nil {
|
|
283
|
+
t.Fatal(err)
|
|
284
|
+
}
|
|
285
|
+
if err := config.WriteAtomic(firstRunPendingPath(p), []byte(`{"credentials":true}`), 0o600); err != nil {
|
|
286
|
+
t.Fatal(err)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if err := removeFirstRunArtifacts(p); err == nil {
|
|
290
|
+
t.Fatal("cleanup unexpectedly removed a non-empty credential directory")
|
|
291
|
+
}
|
|
292
|
+
if _, err := os.Stat(firstRunPendingPath(p)); err != nil {
|
|
293
|
+
t.Fatalf("cleanup failure removed recovery marker: %v", err)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if err := os.Remove(filepath.Join(p.Credentials, "held")); err != nil {
|
|
297
|
+
t.Fatal(err)
|
|
298
|
+
}
|
|
299
|
+
if err := os.Remove(p.Credentials); err != nil {
|
|
300
|
+
t.Fatal(err)
|
|
301
|
+
}
|
|
302
|
+
if err := recoverFirstRunPublication(p); err != nil {
|
|
303
|
+
t.Fatal(err)
|
|
304
|
+
}
|
|
305
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database, firstRunPendingPath(p)} {
|
|
306
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
307
|
+
t.Fatalf("recovery left %s: %v", path, err)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func TestStageFirstRunAuthenticationLeavesFinalHomeUntouchedOnFailure(t *testing.T) {
|
|
313
|
+
registerCanceledFirstRunAuth.Do(func() {
|
|
314
|
+
task.Register(canceledFirstRunAuthPlugin, task.Factory{
|
|
315
|
+
Auth: func(context.Context, []string, io.Reader) error {
|
|
316
|
+
return errCanceledFirstRunAuth
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
root := filepath.Join(t.TempDir(), "nested", ".relay-flow")
|
|
321
|
+
p := onboardingPaths(root)
|
|
322
|
+
_, stage, err := stageFirstRunAuthentication(p, &config.Machine{TaskPlugin: canceledFirstRunAuthPlugin}, canceledFirstRunAuthPlugin, nil)
|
|
323
|
+
if !errors.Is(err, errCanceledFirstRunAuth) {
|
|
324
|
+
t.Fatalf("authentication error = %v, want cancellation", err)
|
|
325
|
+
}
|
|
326
|
+
if stage != "" {
|
|
327
|
+
t.Fatalf("failed authentication returned staging path %q", stage)
|
|
328
|
+
}
|
|
329
|
+
for _, path := range []string{p.Config, p.Credentials, p.Database} {
|
|
330
|
+
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
|
331
|
+
t.Fatalf("failed authentication left %s: %v", path, statErr)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func TestEnsureFirstRunServerReusesReadySocket(t *testing.T) {
|
|
337
|
+
root := t.TempDir()
|
|
338
|
+
p := onboardingPaths(filepath.Join(root, ".relay-flow"))
|
|
339
|
+
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
340
|
+
t.Fatal(err)
|
|
341
|
+
}
|
|
342
|
+
listener, err := net.Listen("unix", p.Socket)
|
|
343
|
+
if err != nil {
|
|
344
|
+
t.Fatal(err)
|
|
345
|
+
}
|
|
346
|
+
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
347
|
+
if r.URL.Path != "/repos" {
|
|
348
|
+
http.NotFound(w, r)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
w.Header().Set("Content-Type", "application/json")
|
|
352
|
+
_, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
|
|
353
|
+
})}
|
|
354
|
+
go func() { _ = server.Serve(listener) }()
|
|
355
|
+
defer server.Close()
|
|
356
|
+
|
|
357
|
+
if err := ensureFirstRunServer(p); err != nil {
|
|
358
|
+
t.Fatalf("reuse ready server: %v", err)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
func TestEnsureFirstRunServerWaitsForExistingOwner(t *testing.T) {
|
|
363
|
+
root := t.TempDir()
|
|
364
|
+
p := onboardingPaths(filepath.Join(root, ".relay-flow"))
|
|
365
|
+
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
366
|
+
t.Fatal(err)
|
|
367
|
+
}
|
|
368
|
+
lock, err := os.OpenFile(p.Lock, os.O_CREATE|os.O_RDWR, 0o600)
|
|
369
|
+
if err != nil {
|
|
370
|
+
t.Fatal(err)
|
|
371
|
+
}
|
|
372
|
+
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
373
|
+
lock.Close()
|
|
374
|
+
t.Fatal(err)
|
|
375
|
+
}
|
|
376
|
+
defer func() {
|
|
377
|
+
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
378
|
+
_ = lock.Close()
|
|
379
|
+
}()
|
|
380
|
+
|
|
381
|
+
oldTimeout, oldPoll := backgroundServeStartupTimeout, backgroundServePollInterval
|
|
382
|
+
backgroundServeStartupTimeout = 2 * time.Second
|
|
383
|
+
backgroundServePollInterval = 10 * time.Millisecond
|
|
384
|
+
defer func() {
|
|
385
|
+
backgroundServeStartupTimeout = oldTimeout
|
|
386
|
+
backgroundServePollInterval = oldPoll
|
|
387
|
+
}()
|
|
388
|
+
|
|
389
|
+
started := make(chan *http.Server, 1)
|
|
390
|
+
go func() {
|
|
391
|
+
time.Sleep(300 * time.Millisecond)
|
|
392
|
+
listener, listenErr := net.Listen("unix", p.Socket)
|
|
393
|
+
if listenErr != nil {
|
|
394
|
+
started <- nil
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
398
|
+
w.Header().Set("Content-Type", "application/json")
|
|
399
|
+
_, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
|
|
400
|
+
})}
|
|
401
|
+
started <- srv
|
|
402
|
+
_ = srv.Serve(listener)
|
|
403
|
+
}()
|
|
404
|
+
if err := ensureFirstRunServer(p); err != nil {
|
|
405
|
+
t.Fatalf("wait for existing owner: %v", err)
|
|
406
|
+
}
|
|
407
|
+
if srv := <-started; srv != nil {
|
|
408
|
+
_ = srv.Close()
|
|
409
|
+
} else {
|
|
410
|
+
t.Fatal("existing owner test server failed to start")
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
func TestEnsureFirstRunServerTimesOutForUnreadyOwner(t *testing.T) {
|
|
415
|
+
root := t.TempDir()
|
|
416
|
+
p := onboardingPaths(filepath.Join(root, ".relay-flow"))
|
|
417
|
+
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
418
|
+
t.Fatal(err)
|
|
419
|
+
}
|
|
420
|
+
lock, err := os.OpenFile(p.Lock, os.O_CREATE|os.O_RDWR, 0o600)
|
|
421
|
+
if err != nil {
|
|
422
|
+
t.Fatal(err)
|
|
423
|
+
}
|
|
424
|
+
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
425
|
+
lock.Close()
|
|
426
|
+
t.Fatal(err)
|
|
427
|
+
}
|
|
428
|
+
defer func() {
|
|
429
|
+
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
430
|
+
_ = lock.Close()
|
|
431
|
+
}()
|
|
432
|
+
oldTimeout, oldPoll := backgroundServeStartupTimeout, backgroundServePollInterval
|
|
433
|
+
backgroundServeStartupTimeout = 300 * time.Millisecond
|
|
434
|
+
backgroundServePollInterval = 10 * time.Millisecond
|
|
435
|
+
defer func() {
|
|
436
|
+
backgroundServeStartupTimeout = oldTimeout
|
|
437
|
+
backgroundServePollInterval = oldPoll
|
|
438
|
+
}()
|
|
439
|
+
|
|
440
|
+
if err := ensureFirstRunServer(p); err == nil || !strings.Contains(err.Error(), "startup timed out") {
|
|
441
|
+
t.Fatalf("unready owner error = %v, want startup timeout", err)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
func TestRunFirstRunRepositorySetupDelegatesRegistration(t *testing.T) {
|
|
446
|
+
root := t.TempDir()
|
|
447
|
+
p := onboardingPaths(filepath.Join(root, ".relay-flow"))
|
|
448
|
+
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
449
|
+
t.Fatal(err)
|
|
450
|
+
}
|
|
451
|
+
listener, err := net.Listen("unix", p.Socket)
|
|
452
|
+
if err != nil {
|
|
453
|
+
t.Fatal(err)
|
|
454
|
+
}
|
|
455
|
+
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
456
|
+
if r.URL.Path != "/repos" {
|
|
457
|
+
http.NotFound(w, r)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
w.Header().Set("Content-Type", "application/json")
|
|
461
|
+
_, _ = io.WriteString(w, `{"ok":true,"data":[]}`)
|
|
462
|
+
})}
|
|
463
|
+
go func() { _ = srv.Serve(listener) }()
|
|
464
|
+
defer srv.Close()
|
|
465
|
+
|
|
466
|
+
oldRegistration := firstRunRegistration
|
|
467
|
+
firstRunRegistration = func(c *server.Client, name, path string, sets kvFlags, stdin io.Reader) int {
|
|
468
|
+
if c == nil || name != "" || path != "" || sets != nil || stdin == nil {
|
|
469
|
+
t.Errorf("registration delegation = client=%v name=%q path=%q sets=%v stdin=%v", c != nil, name, path, sets, stdin)
|
|
470
|
+
}
|
|
471
|
+
return exitOK
|
|
472
|
+
}
|
|
473
|
+
defer func() { firstRunRegistration = oldRegistration }()
|
|
474
|
+
if err := runFirstRunRepositorySetup(p, strings.NewReader("input")); err != nil {
|
|
475
|
+
t.Fatal(err)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
func TestRunOnboardingSpinner(t *testing.T) {
|
|
480
|
+
called := false
|
|
481
|
+
if err := runOnboardingSpinner("test", func() error {
|
|
482
|
+
called = true
|
|
483
|
+
return nil
|
|
484
|
+
}); err != nil {
|
|
485
|
+
t.Fatalf("spinner success = %v", err)
|
|
486
|
+
}
|
|
487
|
+
if !called {
|
|
488
|
+
t.Fatal("spinner action was not called")
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
wantErr := errors.New("startup failed")
|
|
492
|
+
if err := runOnboardingSpinner("test", func() error { return wantErr }); !errors.Is(err, wantErr) {
|
|
493
|
+
t.Fatalf("spinner error = %v, want %v", err, wantErr)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
func TestStandaloneRepoServerHint(t *testing.T) {
|
|
498
|
+
err := standaloneRepoServerHint(errors.New("server call GET /repos: dial unix /tmp/server.sock: connect: no such file or directory"))
|
|
499
|
+
if err == nil || !containsAll(err.Error(), "serve --background", "guided init") {
|
|
500
|
+
t.Fatalf("server hint = %v", err)
|
|
501
|
+
}
|
|
502
|
+
original := errors.New("server bad request")
|
|
503
|
+
if got := standaloneRepoServerHint(original); got != original {
|
|
504
|
+
t.Fatalf("non-connectivity error changed: %v", got)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
func containsAll(value string, parts ...string) bool {
|
|
509
|
+
for _, part := range parts {
|
|
510
|
+
if !strings.Contains(value, part) {
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return true
|
|
515
|
+
}
|