Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · client/remote.go

Line attribution on main · 570 line(s)

c5902451
Keenan Nemetz
16d ago
1 package client
2
3 import (
4 "errors"
5 "fmt"
0c40a1d3
Keenan Nemetz
7d ago
6 "io"
c5902451
Keenan Nemetz
16d ago
7 "net/url"
8 "os"
9 "os/exec"
eb07ff56
Keenan Nemetz
8d ago
10 "sort"
92d8afc8
Keenan Nemetz
2h ago
11 "strconv"
c5902451
Keenan Nemetz
16d ago
12 "strings"
92d8afc8
Keenan Nemetz
2h ago
13 "time"
deb024d4
Keenan Nemetz
6d ago
14
d37d301f
Keenan Nemetz
19h ago
15 voleid "rodent.software/vole/core/id"
16 "rodent.software/vole/core/role"
c5902451
Keenan Nemetz
16d ago
17 )
18
f5456470
Keenan Nemetz
13d ago
19 // HostKeys is how this process verifies the SSH host key of the instance it talks
20 // to. The zero value is strict: ssh checks the key against the caller's known_hosts
21 // and refuses a host it has not been told to trust. A loose known_hosts, or a
22 // command line that disables the check, is what lets a man in the middle feed a
23 // runner jobs — the runner executes repository code, so the host it trusts is a
24 // security boundary, not a convenience.
25 type HostKeys struct {
26 // KnownHosts is a known_hosts file to check against. Empty uses ssh's own
27 // defaults (~/.ssh/known_hosts and the system file).
28 KnownHosts string
29 // Insecure turns verification off. It exists for tests and for a disposable
30 // development instance, and a caller that sets it is saying out loud that it
31 // does not care which host answers.
32 Insecure bool
33 }
34
35 var hostKeys HostKeys
36
37 // SetHostKeys replaces the host-key policy for this process. It is expected to be
38 // called once, before any command that talks to an instance.
39 func SetHostKeys(h HostKeys) { hostKeys = h }
40
41 // HostKeysFromEnv reads VOLE_KNOWN_HOSTS and VOLE_INSECURE_HOST_KEY, which are how
42 // a command that has no flag of its own opts into a policy.
43 func HostKeysFromEnv() HostKeys {
44 h := HostKeys{KnownHosts: strings.TrimSpace(os.Getenv("VOLE_KNOWN_HOSTS"))}
45 switch strings.ToLower(strings.TrimSpace(os.Getenv("VOLE_INSECURE_HOST_KEY"))) {
46 case "1", "true", "yes":
47 h.Insecure = true
48 }
49 return h
50 }
51
52 // HostKeyArgs are the ssh options that apply the current policy.
53 func HostKeyArgs() []string {
54 if hostKeys.Insecure {
55 return []string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}
56 }
57 args := []string{"-o", "StrictHostKeyChecking=yes"}
58 if hostKeys.KnownHosts != "" {
59 args = append(args, "-o", "UserKnownHostsFile="+hostKeys.KnownHosts)
60 }
61 return args
62 }
63
64 // GitSSHCommand is the GIT_SSH_COMMAND that gives git's own ssh calls the same
65 // policy, keeping whatever program and key options the operator configured. A
66 // command sets it for its children, which is how a checkout, a push and an
67 // ssh-keyscan in a workflow all verify the same host.
68 func GitSSHCommand() string {
69 base := []string{"ssh"}
70 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
71 base = strings.Fields(v)
72 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
73 base = strings.Fields(v)
74 }
75 parts := append([]string{base[0]}, HostKeyArgs()...)
76 parts = append(parts, base[1:]...)
77 return strings.Join(parts, " ")
78 }
79
c5902451
Keenan Nemetz
16d ago
80 // SSHCommand builds an ssh invocation for host, honouring GIT_SSH_COMMAND (which
81 // git itself uses, and which a runner therefore already sets) and then
82 // VOLE_SSH_COMMAND. The returned command's stderr is left for the caller.
f5456470
Keenan Nemetz
13d ago
83 //
84 // The host-key options go in before the configured ones: ssh takes the first value
85 // it is given for an option, so an environment variable cannot loosen the check by
86 // adding StrictHostKeyChecking=no after it. The explicit HostKeys policy is the only
87 // way to do that.
c5902451
Keenan Nemetz
16d ago
88 func SSHCommand(host, port string, args ...string) *exec.Cmd {
89 base := []string{"ssh"}
90 if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" {
91 base = strings.Fields(v)
92 } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" {
93 base = strings.Fields(v)
94 }
f5456470
Keenan Nemetz
13d ago
95 argv := append([]string{}, HostKeyArgs()...)
96 argv = append(argv, base[1:]...)
c5902451
Keenan Nemetz
16d ago
97 argv = append(argv, "-T", "-o", "BatchMode=yes")
98 if port != "" {
99 argv = append(argv, "-p", port)
100 }
101 argv = append(argv, host)
102 argv = append(argv, args...)
103 return exec.Command(base[0], argv...)
104 }
105
106 // ParseSSHRemote turns a git remote URL into the pieces ssh needs. It accepts
107 // the URL form (ssh://git@host:port/repo.git) and git's scp-like shorthand
108 // (git@host:repo.git), because a clone URL may be either.
109 func ParseSSHRemote(remote string) (host, port, path string, err error) {
110 if !strings.Contains(remote, "://") {
111 i := strings.Index(remote, ":")
112 if i < 0 {
113 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
114 }
115 user, h := remote[:i], remote[i+1:]
116 if user == "" || h == "" {
117 return "", "", "", fmt.Errorf("%q is not an SSH remote", remote)
118 }
119 return user, "", "/" + strings.TrimPrefix(h, "/"), nil
120 }
121 u, err := url.Parse(remote)
122 if err != nil {
123 return "", "", "", fmt.Errorf("%q is not a URL: %w", remote, err)
124 }
125 if u.Scheme != "ssh" {
126 return "", "", "", fmt.Errorf("%q is not an SSH remote: the instance answers over SSH only, "+
127 "so add an ssh one and pass --remote", remote)
128 }
129 if u.Hostname() == "" {
130 return "", "", "", fmt.Errorf("%q has no host", remote)
131 }
132 user := u.User.Username()
133 if user == "" {
134 user = "git"
135 }
136 p := u.Path
137 if p == "" {
138 return "", "", "", fmt.Errorf("%q names no repository", remote)
139 }
140 return user + "@" + u.Hostname(), u.Port(), p, nil
141 }
142
143 // SSHTarget turns a remote name or an ssh:// URL into what ssh needs, plus the
144 // path the remote names. Both forms have to work: `--remote ssh://…` is how a
145 // repository with no configured remote writes at all.
146 func SSHTarget(gitDir, remote string) (host, port, path string, err error) {
147 target, err := RemoteURL(gitDir, remote)
148 if err != nil {
149 return "", "", "", err
150 }
151 return ParseSSHRemote(target)
152 }
153
154 // RunSSH runs one of the instance's read-only verbs over the key the caller
155 // already pushes with. No new endpoint, no second credential.
156 func RunSSH(host, port, verb string) (string, error) {
0c40a1d3
Keenan Nemetz
7d ago
157 return runSSH(host, port, verb, nil)
158 }
159
160 // runSSH is RunSSH with an optional stdin. The secret verbs carry their value
161 // this way: an exec line is visible to every process on both hosts, and the
162 // instance keeps the command it ran.
163 func runSSH(host, port, verb string, stdin io.Reader) (string, error) {
c5902451
Keenan Nemetz
16d ago
164 cmd := SSHCommand(host, port, verb)
0c40a1d3
Keenan Nemetz
7d ago
165 cmd.Stdin = stdin
c5902451
Keenan Nemetz
16d ago
166 var out, stderr strings.Builder
167 cmd.Stdout, cmd.Stderr = &out, &stderr
168 if err := cmd.Run(); err != nil {
169 // A refusal is written to the channel by the server, so the useful reason
170 // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr.
171 if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok {
172 return "", errors.New(strings.TrimSpace(reason))
173 }
174 return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String()))
175 }
0c40a1d3
Keenan Nemetz
7d ago
176 // The output is returned as it arrived. A verb that answers with a value must
177 // not have a byte trimmed off it, and a verb that answers with lines has its
178 // whitespace handled by the caller that knows what the lines are.
c5902451
Keenan Nemetz
16d ago
179 return out.String(), nil
180 }
181
62576fa8
Keenan Nemetz
14d ago
182 // Roster is the client's read of the instance's user roster: the active account
183 // names. The roster is a server-side SQLite database, so the client asks the
184 // instance for the one view it needs over the same key-authenticated SSH channel
185 // it already pushes with (`ssh host vole-roster`).
c5902451
Keenan Nemetz
16d ago
186 type Roster struct {
62576fa8
Keenan Nemetz
14d ago
187 names map[string]bool
c5902451
Keenan Nemetz
16d ago
188 }
189
62576fa8
Keenan Nemetz
14d ago
190 // LoadRoster asks the instance for its roster and indexes the names.
c5902451
Keenan Nemetz
16d ago
191 func LoadRoster(gitDir, remote string) (*Roster, error) {
192 host, port, _, err := SSHTarget(gitDir, remote)
193 if err != nil {
194 return nil, err
195 }
196 out, err := RunSSH(host, port, "vole-roster")
197 if err != nil {
198 return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err)
199 }
62576fa8
Keenan Nemetz
14d ago
200 r := &Roster{names: map[string]bool{}}
c5902451
Keenan Nemetz
16d ago
201 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
62576fa8
Keenan Nemetz
14d ago
202 line = strings.TrimSpace(line)
c5902451
Keenan Nemetz
16d ago
203 if line == "" {
204 continue
205 }
62576fa8
Keenan Nemetz
14d ago
206 // A roster row is one name. Taking the last field also accepts a line
207 // from an instance that still prints two columns.
208 f := strings.Fields(line)
209 if len(f) == 0 {
210 continue
c5902451
Keenan Nemetz
16d ago
211 }
62576fa8
Keenan Nemetz
14d ago
212 r.names[f[len(f)-1]] = true
c5902451
Keenan Nemetz
16d ago
213 }
214 return r, nil
215 }
216
62576fa8
Keenan Nemetz
14d ago
217 // Has reports whether name is an active account.
218 func (r *Roster) Has(name string) bool {
219 return r != nil && r.names[name]
c5902451
Keenan Nemetz
16d ago
220 }
221
62576fa8
Keenan Nemetz
14d ago
222 // Actor is who a write will be attributed to. The name and URL come from the
c5902451
Keenan Nemetz
16d ago
223 // instance; Repo comes from the remote the command was given.
224 type Actor struct {
62576fa8
Keenan Nemetz
14d ago
225 Name, URL, Repo string
c5902451
Keenan Nemetz
16d ago
226 // Guessed means the instance could not be asked and the name came from git
227 // config. It is never silent: the caller warns, and commands that need an
62576fa8
Keenan Nemetz
14d ago
228 // active account (`vole change new`) refuse instead of writing a random name.
c5902451
Keenan Nemetz
16d ago
229 Guessed bool
230 }
231
232 // AskIdentity runs `vole-whoami` and returns the account behind the key that
233 // just authenticated, plus where the instance is served (empty when public_url
234 // is unset). This is the only correct source of "who am I": git config holds a
235 // name somebody typed.
236 func AskIdentity(gitDir, remote string) (*Actor, error) {
237 host, port, path, err := SSHTarget(gitDir, remote)
238 if err != nil {
239 return nil, err
240 }
241 out, err := RunSSH(host, port, "vole-whoami")
242 if err != nil {
243 return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err)
244 }
62576fa8
Keenan Nemetz
14d ago
245 f := strings.SplitN(strings.TrimSpace(out), "\t", 2)
246 if len(f) < 1 || f[0] == "" {
c5902451
Keenan Nemetz
16d ago
247 return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out))
248 }
62576fa8
Keenan Nemetz
14d ago
249 a := &Actor{Name: f[0], Repo: RepoNameFromPath(path)}
250 if len(f) == 2 {
251 a.URL = strings.TrimRight(f[1], "/")
c5902451
Keenan Nemetz
16d ago
252 }
253 return a, nil
254 }
255
eec0e603
Keenan Nemetz
6d ago
256 // AskRole asks the instance for the effective role the key holds on the
257 // repository named by remote. The default whoami answer stays the account name
258 // alone; this is the explicit query for the role that decides where the account
259 // may push and whether it may approve.
260 func AskRole(gitDir, remote string) (role.Role, error) {
261 host, port, path, err := SSHTarget(gitDir, remote)
262 if err != nil {
263 return role.None, err
264 }
265 if RepoNameFromPath(path) == "" {
266 return role.None, fmt.Errorf("%s does not name a repository", remote)
267 }
268 out, err := RunSSH(host, port, "vole-whoami "+quoteSSHArg(path))
269 if err != nil {
270 return role.None, err
271 }
272 fields := strings.Split(strings.TrimSpace(out), "\t")
273 if len(fields) < 3 {
274 return role.None, fmt.Errorf("whoami: the instance did not answer with a role; it may be older than this client")
275 }
276 r, err := role.Parse(fields[2])
277 if err != nil {
278 return role.None, fmt.Errorf("whoami: %w", err)
279 }
280 return r, nil
281 }
282
c5902451
Keenan Nemetz
16d ago
283 // ResolveActor asks the instance who the pushing key is, falling back to the name
284 // git config suggests so that `--no-push` on a train still commits something. The
285 // fallback is loudly reported: a wrong author is worse than a slow command.
286 func ResolveActor(b *Branch) (*Actor, error) {
287 a, err := AskIdentity(b.Repo.Dir, b.Remote)
288 if err == nil {
289 return a, nil
290 }
291 fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n",
292 err, b.Author)
293 return &Actor{Name: b.Author, Repo: b.RepoName(), Guessed: true}, err
294 }
295
296 // Page is where a browser reads what a command just wrote, or "" when the
297 // operator has not told the instance where it is served.
298 func (a *Actor) Page(kind, id, anchor string) string {
299 if a == nil || a.URL == "" || a.Repo == "" {
300 return ""
301 }
ed03b3d1
Keenan Nemetz
6h ago
302 u := fmt.Sprintf("%s/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id)
c5902451
Keenan Nemetz
16d ago
303 if anchor != "" {
304 u += "#" + anchor
305 }
306 return u
307 }
b14a7819
Keenan Nemetz
13d ago
308
e5478085
Keenan Nemetz
11d ago
309 // ChangeStatus asks the instance whether the combined result for a change request
310 // has been tested. It answers one line per fact ("combined: ...", "merge: ..."),
311 // which the show command prints as it received them.
312 func ChangeStatus(gitDir, remote, id string) (string, error) {
313 host, port, path, err := SSHTarget(gitDir, remote)
314 if err != nil {
315 return "", err
316 }
317 if RepoNameFromPath(path) == "" {
318 return "", fmt.Errorf("%s does not name a repository", remote)
319 }
320 out, err := RunSSH(host, port, "vole-change-status "+path+" "+id)
321 if err != nil {
322 return "", err
323 }
324 return strings.TrimSpace(out), nil
325 }
326
b14a7819
Keenan Nemetz
13d ago
327 // MergeChange asks the instance to land a change request, and returns the server's
328 // one-line answer.
329 //
330 // Landing moves a branch, so it cannot be done from a clone: the rules that decide
331 // it — the repository's ACL, the approvals filed against the request, and the run
332 // results — live in the instance. The CLI asks over the key it already pushes with,
333 // and the server runs the same operation the browser's merge button runs.
334 func MergeChange(gitDir, remote, id string) (string, error) {
335 host, port, path, err := SSHTarget(gitDir, remote)
336 if err != nil {
337 return "", err
338 }
339 if RepoNameFromPath(path) == "" {
340 return "", fmt.Errorf("%s does not name a repository", remote)
341 }
342 // The path goes as the remote names it ("/demo.git"), which is the shape the
343 // server's own repository-path check accepts for the git verbs. One check, one
344 // shape, so a name that would be refused on a push is refused here too.
345 out, err := RunSSH(host, port, "vole-change-merge "+path+" "+id)
346 if err != nil {
347 return "", err
348 }
349 return strings.TrimSpace(out), nil
350 }
eb07ff56
Keenan Nemetz
8d ago
351
deb024d4
Keenan Nemetz
6d ago
352 // CancelRun cancels one queued run, or asks a running one to stop. The queue lives
353 // on the instance, so the CLI asks over the key it pushes with; the server checks the
354 // maintainer capability, records the audit line, and says which of the two happened.
355 // The id is the 26-character one the run page shows in its URL.
356 func CancelRun(gitDir, remote, runID string) (string, error) {
70291860
Keenan Nemetz
6d ago
357 return runVerb(gitDir, remote, runID, "vole-run-cancel")
358 }
359
360 // RerunRun queues a finished run again with the same pinned job. Attempts keep their
361 // history; the run is a rerun of the job it already named, not a new row. The server
362 // checks the maintainer capability and records the audit line.
363 func RerunRun(gitDir, remote, runID string) (string, error) {
364 return runVerb(gitDir, remote, runID, "vole-run-rerun")
365 }
366
92d8afc8
Keenan Nemetz
2h ago
367 // RunSummary is one row of `vole run list`. The server answers with the fields in
368 // this order, tab-separated; the CLI formats them. Keeping the wire this small means
369 // the listing does not grow a second serialization of the queue's row.
370 type RunSummary struct {
371 ID string
372 Status string
373 // Workflow and Job are the stable keys. The titles are pinned with the run, but a
374 // line is a place for the thing a command takes, not a paragraph.
375 Workflow string
376 Job string
377 Branch string
378 SHA string
379 Trigger string
380 // TriggerTitle is Trigger in the words the web uses ("pushed to main").
381 TriggerTitle string
382 ExitCode int
383 Attempts int
384 HasLog bool
385 Created time.Time
386 }
387
388 // listRunFields is the number of tab-separated fields serveRunList writes. A change
389 // to one side without the other is a parse error rather than a silently wrong column.
390 const listRunFields = 12
391
392 // ListRuns asks the instance for the newest runs in this repository. status, branch
393 // and sha are filters; an empty string means no filter. The server bounds limit.
394 func ListRuns(gitDir, remote, status, branch, sha string, limit int) ([]RunSummary, error) {
395 host, port, path, err := SSHTarget(gitDir, remote)
396 if err != nil {
397 return nil, err
398 }
399 if RepoNameFromPath(path) == "" {
400 return nil, fmt.Errorf("%s does not name a repository", remote)
401 }
402 args := []string{"vole-run-list", path,
403 valueOrDash(status), valueOrDash(branch), valueOrDash(sha), strconv.Itoa(limit)}
404 quoted := make([]string, len(args))
405 for i, arg := range args {
406 quoted[i] = quoteSSHArg(arg)
407 }
408 out, err := RunSSH(host, port, strings.Join(quoted, " "))
409 if err != nil {
410 return nil, err
411 }
412 return parseRunSummaries(out)
413 }
414
415 // ShowRun asks the instance for one run the way its page would answer. The result is
416 // display text, like `vole change show`'s status block: the server owns the words so
417 // the CLI and the browser cannot disagree about what a run did.
418 func ShowRun(gitDir, remote, runID string) (string, error) {
419 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-show")
420 if err != nil {
421 return "", err
422 }
423 out, err := RunSSH(host, port, command)
424 if err != nil {
425 return "", err
426 }
427 return strings.TrimRight(out, "\n"), nil
428 }
429
430 // StreamRunLog copies one attempt's log to w. attempt 0 means the attempt the run is
431 // on (or its newest logged one), and step 0 means the whole attempt. The bytes are
432 // written as they arrive, so a large log is not held in memory just to be printed;
433 // a refusal arrives on stderr and never at the top of the log.
434 func StreamRunLog(gitDir, remote, runID string, attempt, step int, w io.Writer) error {
435 host, port, command, err := runCommand(gitDir, remote, runID, "vole-run-log",
436 strconv.Itoa(attempt), strconv.Itoa(step))
437 if err != nil {
438 return err
439 }
440 cmd := SSHCommand(host, port, command)
441 cmd.Stdout = w
442 var stderr strings.Builder
443 cmd.Stderr = &stderr
444 if err := cmd.Run(); err != nil {
445 reason := strings.TrimSpace(stderr.String())
446 if msg, ok := strings.CutPrefix(reason, "vole:"); ok {
447 return errors.New(strings.TrimSpace(msg))
448 }
449 if reason != "" {
450 return fmt.Errorf("%s: %w: %s", host, err, reason)
451 }
452 return fmt.Errorf("%s: %w", host, err)
453 }
454 return nil
455 }
456
457 // parseRunSummaries reads the tab-separated rows serveRunList writes. An answer with
458 // the wrong width is a protocol mismatch, not an empty listing, because dropping it
459 // would make an upgraded server look like a repository with no runs.
460 func parseRunSummaries(out string) ([]RunSummary, error) {
461 var list []RunSummary
462 for _, line := range strings.Split(out, "\n") {
463 if strings.TrimSpace(line) == "" {
464 continue
465 }
466 f := strings.Split(line, "\t")
467 if len(f) != listRunFields {
468 return nil, fmt.Errorf("the instance answered a run list with %d fields, want %d; upgrade the client and server together", len(f), listRunFields)
469 }
470 exitCode, err := strconv.Atoi(f[8])
471 if err != nil {
472 return nil, fmt.Errorf("run %s: exit %q is not a number", f[0], f[8])
473 }
474 attempts, err := strconv.Atoi(f[9])
475 if err != nil {
476 return nil, fmt.Errorf("run %s: attempts %q is not a number", f[0], f[9])
477 }
478 created, err := time.Parse(time.RFC3339, f[11])
479 if err != nil {
480 return nil, fmt.Errorf("run %s: created %q is not a time", f[0], f[11])
481 }
482 list = append(list, RunSummary{
483 ID: f[0], Status: f[1], Workflow: f[2], Job: f[3], Branch: f[4],
484 SHA: f[5], Trigger: f[6], TriggerTitle: f[7], ExitCode: exitCode,
485 Attempts: attempts, HasLog: f[10] == "yes", Created: created,
486 })
487 }
488 return list, nil
489 }
490
491 // valueOrDash keeps an empty filter in its argument position, so the server can tell
492 // "unset" from a value that starts with a dash.
493 func valueOrDash(v string) string {
494 if strings.TrimSpace(v) == "" {
495 return "-"
496 }
497 return v
498 }
499
500 // runCommand resolves the instance and repository for a run verb and builds the one
501 // command line the server parses. The caller has already decided which verb; this is
502 // only the wire.
503 func runCommand(gitDir, remote, runID, verb string, extra ...string) (host, port, command string, err error) {
deb024d4
Keenan Nemetz
6d ago
504 runID = strings.ToUpper(strings.TrimSpace(runID))
505 if !voleid.Valid(runID) {
92d8afc8
Keenan Nemetz
2h ago
506 return "", "", "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID)
deb024d4
Keenan Nemetz
6d ago
507 }
508 host, port, path, err := SSHTarget(gitDir, remote)
509 if err != nil {
92d8afc8
Keenan Nemetz
2h ago
510 return "", "", "", err
deb024d4
Keenan Nemetz
6d ago
511 }
512 if RepoNameFromPath(path) == "" {
92d8afc8
Keenan Nemetz
2h ago
513 return "", "", "", fmt.Errorf("%s does not name a repository", remote)
514 }
515 args := append([]string{verb, path, runID}, extra...)
516 quoted := make([]string, len(args))
517 for i, arg := range args {
518 quoted[i] = quoteSSHArg(arg)
519 }
520 return host, port, strings.Join(quoted, " "), nil
521 }
522
523 // runVerb resolves the instance and repository for one run-management verb and runs
524 // it. The caller has already decided which verb; this is only the wire.
525 func runVerb(gitDir, remote, runID, verb string) (string, error) {
526 host, port, command, err := runCommand(gitDir, remote, runID, verb)
527 if err != nil {
528 return "", err
deb024d4
Keenan Nemetz
6d ago
529 }
92d8afc8
Keenan Nemetz
2h ago
530 out, err := RunSSH(host, port, command)
deb024d4
Keenan Nemetz
6d ago
531 if err != nil {
532 return "", err
533 }
534 return strings.TrimSpace(out), nil
535 }
536
eb07ff56
Keenan Nemetz
8d ago
537 // DispatchWorkflow queues a manual run of a workflow that declares
538 // workflow_dispatch. It is a write to the instance's queue, so the CLI asks over
539 // the same key-authenticated channel it pushes with; the server resolves the
540 // workflow, validates the inputs and checks the maintainer capability.
541 func DispatchWorkflow(gitDir, remote, workflow, ref string, inputs map[string]string) (string, error) {
542 host, port, path, err := SSHTarget(gitDir, remote)
543 if err != nil {
544 return "", err
545 }
546 if RepoNameFromPath(path) == "" {
547 return "", fmt.Errorf("%s does not name a repository", remote)
548 }
549 if ref == "" {
550 ref = "-"
551 }
552 args := []string{"vole-workflow-dispatch", path, workflow, ref}
553 names := make([]string, 0, len(inputs))
554 for name := range inputs {
555 names = append(names, name)
556 }
557 sort.Strings(names)
558 for _, name := range names {
559 args = append(args, name+"="+inputs[name])
560 }
561 quoted := make([]string, len(args))
562 for i, arg := range args {
563 quoted[i] = quoteSSHArg(arg)
564 }
565 out, err := RunSSH(host, port, strings.Join(quoted, " "))
566 if err != nil {
567 return "", err
568 }
569 return strings.TrimSpace(out), nil
570 }