vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
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" |
|
c5902451
Keenan Nemetz
16d ago
|
11 | "strings" |
|
deb024d4
Keenan Nemetz
6d ago
|
12 | |
|
d37d301f
Keenan Nemetz
15h ago
|
13 | voleid "rodent.software/vole/core/id" |
| 14 | "rodent.software/vole/core/role" | |
|
c5902451
Keenan Nemetz
16d ago
|
15 | ) |
| 16 | ||
|
f5456470
Keenan Nemetz
12d ago
|
17 | // HostKeys is how this process verifies the SSH host key of the instance it talks |
| 18 | // to. The zero value is strict: ssh checks the key against the caller's known_hosts | |
| 19 | // and refuses a host it has not been told to trust. A loose known_hosts, or a | |
| 20 | // command line that disables the check, is what lets a man in the middle feed a | |
| 21 | // runner jobs — the runner executes repository code, so the host it trusts is a | |
| 22 | // security boundary, not a convenience. | |
| 23 | type HostKeys struct { | |
| 24 | // KnownHosts is a known_hosts file to check against. Empty uses ssh's own | |
| 25 | // defaults (~/.ssh/known_hosts and the system file). | |
| 26 | KnownHosts string | |
| 27 | // Insecure turns verification off. It exists for tests and for a disposable | |
| 28 | // development instance, and a caller that sets it is saying out loud that it | |
| 29 | // does not care which host answers. | |
| 30 | Insecure bool | |
| 31 | } | |
| 32 | ||
| 33 | var hostKeys HostKeys | |
| 34 | ||
| 35 | // SetHostKeys replaces the host-key policy for this process. It is expected to be | |
| 36 | // called once, before any command that talks to an instance. | |
| 37 | func SetHostKeys(h HostKeys) { hostKeys = h } | |
| 38 | ||
| 39 | // HostKeysFromEnv reads VOLE_KNOWN_HOSTS and VOLE_INSECURE_HOST_KEY, which are how | |
| 40 | // a command that has no flag of its own opts into a policy. | |
| 41 | func HostKeysFromEnv() HostKeys { | |
| 42 | h := HostKeys{KnownHosts: strings.TrimSpace(os.Getenv("VOLE_KNOWN_HOSTS"))} | |
| 43 | switch strings.ToLower(strings.TrimSpace(os.Getenv("VOLE_INSECURE_HOST_KEY"))) { | |
| 44 | case "1", "true", "yes": | |
| 45 | h.Insecure = true | |
| 46 | } | |
| 47 | return h | |
| 48 | } | |
| 49 | ||
| 50 | // HostKeyArgs are the ssh options that apply the current policy. | |
| 51 | func HostKeyArgs() []string { | |
| 52 | if hostKeys.Insecure { | |
| 53 | return []string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"} | |
| 54 | } | |
| 55 | args := []string{"-o", "StrictHostKeyChecking=yes"} | |
| 56 | if hostKeys.KnownHosts != "" { | |
| 57 | args = append(args, "-o", "UserKnownHostsFile="+hostKeys.KnownHosts) | |
| 58 | } | |
| 59 | return args | |
| 60 | } | |
| 61 | ||
| 62 | // GitSSHCommand is the GIT_SSH_COMMAND that gives git's own ssh calls the same | |
| 63 | // policy, keeping whatever program and key options the operator configured. A | |
| 64 | // command sets it for its children, which is how a checkout, a push and an | |
| 65 | // ssh-keyscan in a workflow all verify the same host. | |
| 66 | func GitSSHCommand() string { | |
| 67 | base := []string{"ssh"} | |
| 68 | if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" { | |
| 69 | base = strings.Fields(v) | |
| 70 | } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" { | |
| 71 | base = strings.Fields(v) | |
| 72 | } | |
| 73 | parts := append([]string{base[0]}, HostKeyArgs()...) | |
| 74 | parts = append(parts, base[1:]...) | |
| 75 | return strings.Join(parts, " ") | |
| 76 | } | |
| 77 | ||
|
c5902451
Keenan Nemetz
16d ago
|
78 | // SSHCommand builds an ssh invocation for host, honouring GIT_SSH_COMMAND (which |
| 79 | // git itself uses, and which a runner therefore already sets) and then | |
| 80 | // VOLE_SSH_COMMAND. The returned command's stderr is left for the caller. | |
|
f5456470
Keenan Nemetz
12d ago
|
81 | // |
| 82 | // The host-key options go in before the configured ones: ssh takes the first value | |
| 83 | // it is given for an option, so an environment variable cannot loosen the check by | |
| 84 | // adding StrictHostKeyChecking=no after it. The explicit HostKeys policy is the only | |
| 85 | // way to do that. | |
|
c5902451
Keenan Nemetz
16d ago
|
86 | func SSHCommand(host, port string, args ...string) *exec.Cmd { |
| 87 | base := []string{"ssh"} | |
| 88 | if v := strings.TrimSpace(os.Getenv("GIT_SSH_COMMAND")); v != "" { | |
| 89 | base = strings.Fields(v) | |
| 90 | } else if v := strings.TrimSpace(os.Getenv("VOLE_SSH_COMMAND")); v != "" { | |
| 91 | base = strings.Fields(v) | |
| 92 | } | |
|
f5456470
Keenan Nemetz
12d ago
|
93 | argv := append([]string{}, HostKeyArgs()...) |
| 94 | argv = append(argv, base[1:]...) | |
|
c5902451
Keenan Nemetz
16d ago
|
95 | argv = append(argv, "-T", "-o", "BatchMode=yes") |
| 96 | if port != "" { | |
| 97 | argv = append(argv, "-p", port) | |
| 98 | } | |
| 99 | argv = append(argv, host) | |
| 100 | argv = append(argv, args...) | |
| 101 | return exec.Command(base[0], argv...) | |
| 102 | } | |
| 103 | ||
| 104 | // ParseSSHRemote turns a git remote URL into the pieces ssh needs. It accepts | |
| 105 | // the URL form (ssh://git@host:port/repo.git) and git's scp-like shorthand | |
| 106 | // (git@host:repo.git), because a clone URL may be either. | |
| 107 | func ParseSSHRemote(remote string) (host, port, path string, err error) { | |
| 108 | if !strings.Contains(remote, "://") { | |
| 109 | i := strings.Index(remote, ":") | |
| 110 | if i < 0 { | |
| 111 | return "", "", "", fmt.Errorf("%q is not an SSH remote", remote) | |
| 112 | } | |
| 113 | user, h := remote[:i], remote[i+1:] | |
| 114 | if user == "" || h == "" { | |
| 115 | return "", "", "", fmt.Errorf("%q is not an SSH remote", remote) | |
| 116 | } | |
| 117 | return user, "", "/" + strings.TrimPrefix(h, "/"), nil | |
| 118 | } | |
| 119 | u, err := url.Parse(remote) | |
| 120 | if err != nil { | |
| 121 | return "", "", "", fmt.Errorf("%q is not a URL: %w", remote, err) | |
| 122 | } | |
| 123 | if u.Scheme != "ssh" { | |
| 124 | return "", "", "", fmt.Errorf("%q is not an SSH remote: the instance answers over SSH only, "+ | |
| 125 | "so add an ssh one and pass --remote", remote) | |
| 126 | } | |
| 127 | if u.Hostname() == "" { | |
| 128 | return "", "", "", fmt.Errorf("%q has no host", remote) | |
| 129 | } | |
| 130 | user := u.User.Username() | |
| 131 | if user == "" { | |
| 132 | user = "git" | |
| 133 | } | |
| 134 | p := u.Path | |
| 135 | if p == "" { | |
| 136 | return "", "", "", fmt.Errorf("%q names no repository", remote) | |
| 137 | } | |
| 138 | return user + "@" + u.Hostname(), u.Port(), p, nil | |
| 139 | } | |
| 140 | ||
| 141 | // SSHTarget turns a remote name or an ssh:// URL into what ssh needs, plus the | |
| 142 | // path the remote names. Both forms have to work: `--remote ssh://…` is how a | |
| 143 | // repository with no configured remote writes at all. | |
| 144 | func SSHTarget(gitDir, remote string) (host, port, path string, err error) { | |
| 145 | target, err := RemoteURL(gitDir, remote) | |
| 146 | if err != nil { | |
| 147 | return "", "", "", err | |
| 148 | } | |
| 149 | return ParseSSHRemote(target) | |
| 150 | } | |
| 151 | ||
| 152 | // RunSSH runs one of the instance's read-only verbs over the key the caller | |
| 153 | // already pushes with. No new endpoint, no second credential. | |
| 154 | func RunSSH(host, port, verb string) (string, error) { | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
155 | return runSSH(host, port, verb, nil) |
| 156 | } | |
| 157 | ||
| 158 | // runSSH is RunSSH with an optional stdin. The secret verbs carry their value | |
| 159 | // this way: an exec line is visible to every process on both hosts, and the | |
| 160 | // instance keeps the command it ran. | |
| 161 | func runSSH(host, port, verb string, stdin io.Reader) (string, error) { | |
|
c5902451
Keenan Nemetz
16d ago
|
162 | cmd := SSHCommand(host, port, verb) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
163 | cmd.Stdin = stdin |
|
c5902451
Keenan Nemetz
16d ago
|
164 | var out, stderr strings.Builder |
| 165 | cmd.Stdout, cmd.Stderr = &out, &stderr | |
| 166 | if err := cmd.Run(); err != nil { | |
| 167 | // A refusal is written to the channel by the server, so the useful reason | |
| 168 | // is on stdout with a "vole:" prefix; ssh's own complaints are on stderr. | |
| 169 | if reason, ok := strings.CutPrefix(strings.TrimSpace(out.String()), "vole:"); ok { | |
| 170 | return "", errors.New(strings.TrimSpace(reason)) | |
| 171 | } | |
| 172 | return "", fmt.Errorf("%s: %w: %s", host, err, strings.TrimSpace(stderr.String())) | |
| 173 | } | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
174 | // The output is returned as it arrived. A verb that answers with a value must |
| 175 | // not have a byte trimmed off it, and a verb that answers with lines has its | |
| 176 | // whitespace handled by the caller that knows what the lines are. | |
|
c5902451
Keenan Nemetz
16d ago
|
177 | return out.String(), nil |
| 178 | } | |
| 179 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
180 | // Roster is the client's read of the instance's user roster: the active account |
| 181 | // names. The roster is a server-side SQLite database, so the client asks the | |
| 182 | // instance for the one view it needs over the same key-authenticated SSH channel | |
| 183 | // it already pushes with (`ssh host vole-roster`). | |
|
c5902451
Keenan Nemetz
16d ago
|
184 | type Roster struct { |
|
62576fa8
Keenan Nemetz
14d ago
|
185 | names map[string]bool |
|
c5902451
Keenan Nemetz
16d ago
|
186 | } |
| 187 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
188 | // LoadRoster asks the instance for its roster and indexes the names. |
|
c5902451
Keenan Nemetz
16d ago
|
189 | func LoadRoster(gitDir, remote string) (*Roster, error) { |
| 190 | host, port, _, err := SSHTarget(gitDir, remote) | |
| 191 | if err != nil { | |
| 192 | return nil, err | |
| 193 | } | |
| 194 | out, err := RunSSH(host, port, "vole-roster") | |
| 195 | if err != nil { | |
| 196 | return nil, fmt.Errorf("cannot read the instance roster from %s: %w", host, err) | |
| 197 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
198 | r := &Roster{names: map[string]bool{}} |
|
c5902451
Keenan Nemetz
16d ago
|
199 | for _, line := range strings.Split(strings.TrimSpace(out), "\n") { |
|
62576fa8
Keenan Nemetz
14d ago
|
200 | line = strings.TrimSpace(line) |
|
c5902451
Keenan Nemetz
16d ago
|
201 | if line == "" { |
| 202 | continue | |
| 203 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
204 | // A roster row is one name. Taking the last field also accepts a line |
| 205 | // from an instance that still prints two columns. | |
| 206 | f := strings.Fields(line) | |
| 207 | if len(f) == 0 { | |
| 208 | continue | |
|
c5902451
Keenan Nemetz
16d ago
|
209 | } |
|
62576fa8
Keenan Nemetz
14d ago
|
210 | r.names[f[len(f)-1]] = true |
|
c5902451
Keenan Nemetz
16d ago
|
211 | } |
| 212 | return r, nil | |
| 213 | } | |
| 214 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
215 | // Has reports whether name is an active account. |
| 216 | func (r *Roster) Has(name string) bool { | |
| 217 | return r != nil && r.names[name] | |
|
c5902451
Keenan Nemetz
16d ago
|
218 | } |
| 219 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
220 | // Actor is who a write will be attributed to. The name and URL come from the |
|
c5902451
Keenan Nemetz
16d ago
|
221 | // instance; Repo comes from the remote the command was given. |
| 222 | type Actor struct { | |
|
62576fa8
Keenan Nemetz
14d ago
|
223 | Name, URL, Repo string |
|
c5902451
Keenan Nemetz
16d ago
|
224 | // Guessed means the instance could not be asked and the name came from git |
| 225 | // config. It is never silent: the caller warns, and commands that need an | |
|
62576fa8
Keenan Nemetz
14d ago
|
226 | // active account (`vole change new`) refuse instead of writing a random name. |
|
c5902451
Keenan Nemetz
16d ago
|
227 | Guessed bool |
| 228 | } | |
| 229 | ||
| 230 | // AskIdentity runs `vole-whoami` and returns the account behind the key that | |
| 231 | // just authenticated, plus where the instance is served (empty when public_url | |
| 232 | // is unset). This is the only correct source of "who am I": git config holds a | |
| 233 | // name somebody typed. | |
| 234 | func AskIdentity(gitDir, remote string) (*Actor, error) { | |
| 235 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 236 | if err != nil { | |
| 237 | return nil, err | |
| 238 | } | |
| 239 | out, err := RunSSH(host, port, "vole-whoami") | |
| 240 | if err != nil { | |
| 241 | return nil, fmt.Errorf("cannot ask %s who you are: %w", host, err) | |
| 242 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
243 | f := strings.SplitN(strings.TrimSpace(out), "\t", 2) |
| 244 | if len(f) < 1 || f[0] == "" { | |
|
c5902451
Keenan Nemetz
16d ago
|
245 | return nil, fmt.Errorf("whoami: cannot parse %q", strings.TrimSpace(out)) |
| 246 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
247 | a := &Actor{Name: f[0], Repo: RepoNameFromPath(path)} |
| 248 | if len(f) == 2 { | |
| 249 | a.URL = strings.TrimRight(f[1], "/") | |
|
c5902451
Keenan Nemetz
16d ago
|
250 | } |
| 251 | return a, nil | |
| 252 | } | |
| 253 | ||
|
eec0e603
Keenan Nemetz
5d ago
|
254 | // AskRole asks the instance for the effective role the key holds on the |
| 255 | // repository named by remote. The default whoami answer stays the account name | |
| 256 | // alone; this is the explicit query for the role that decides where the account | |
| 257 | // may push and whether it may approve. | |
| 258 | func AskRole(gitDir, remote string) (role.Role, error) { | |
| 259 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 260 | if err != nil { | |
| 261 | return role.None, err | |
| 262 | } | |
| 263 | if RepoNameFromPath(path) == "" { | |
| 264 | return role.None, fmt.Errorf("%s does not name a repository", remote) | |
| 265 | } | |
| 266 | out, err := RunSSH(host, port, "vole-whoami "+quoteSSHArg(path)) | |
| 267 | if err != nil { | |
| 268 | return role.None, err | |
| 269 | } | |
| 270 | fields := strings.Split(strings.TrimSpace(out), "\t") | |
| 271 | if len(fields) < 3 { | |
| 272 | return role.None, fmt.Errorf("whoami: the instance did not answer with a role; it may be older than this client") | |
| 273 | } | |
| 274 | r, err := role.Parse(fields[2]) | |
| 275 | if err != nil { | |
| 276 | return role.None, fmt.Errorf("whoami: %w", err) | |
| 277 | } | |
| 278 | return r, nil | |
| 279 | } | |
| 280 | ||
|
c5902451
Keenan Nemetz
16d ago
|
281 | // ResolveActor asks the instance who the pushing key is, falling back to the name |
| 282 | // git config suggests so that `--no-push` on a train still commits something. The | |
| 283 | // fallback is loudly reported: a wrong author is worse than a slow command. | |
| 284 | func ResolveActor(b *Branch) (*Actor, error) { | |
| 285 | a, err := AskIdentity(b.Repo.Dir, b.Remote) | |
| 286 | if err == nil { | |
| 287 | return a, nil | |
| 288 | } | |
| 289 | fmt.Fprintf(os.Stderr, "vole: %v\nvole: committing as %q from git config; run `vole whoami` once you are online\n", | |
| 290 | err, b.Author) | |
| 291 | return &Actor{Name: b.Author, Repo: b.RepoName(), Guessed: true}, err | |
| 292 | } | |
| 293 | ||
| 294 | // Page is where a browser reads what a command just wrote, or "" when the | |
| 295 | // operator has not told the instance where it is served. | |
| 296 | func (a *Actor) Page(kind, id, anchor string) string { | |
| 297 | if a == nil || a.URL == "" || a.Repo == "" { | |
| 298 | return "" | |
| 299 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
300 | u := fmt.Sprintf("%s/%s/%s/%s", a.URL, url.PathEscape(a.Repo), kind, id) |
|
c5902451
Keenan Nemetz
16d ago
|
301 | if anchor != "" { |
| 302 | u += "#" + anchor | |
| 303 | } | |
| 304 | return u | |
| 305 | } | |
|
b14a7819
Keenan Nemetz
13d ago
|
306 | |
|
e5478085
Keenan Nemetz
11d ago
|
307 | // ChangeStatus asks the instance whether the combined result for a change request |
| 308 | // has been tested. It answers one line per fact ("combined: ...", "merge: ..."), | |
| 309 | // which the show command prints as it received them. | |
| 310 | func ChangeStatus(gitDir, remote, id string) (string, error) { | |
| 311 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 312 | if err != nil { | |
| 313 | return "", err | |
| 314 | } | |
| 315 | if RepoNameFromPath(path) == "" { | |
| 316 | return "", fmt.Errorf("%s does not name a repository", remote) | |
| 317 | } | |
| 318 | out, err := RunSSH(host, port, "vole-change-status "+path+" "+id) | |
| 319 | if err != nil { | |
| 320 | return "", err | |
| 321 | } | |
| 322 | return strings.TrimSpace(out), nil | |
| 323 | } | |
| 324 | ||
|
b14a7819
Keenan Nemetz
13d ago
|
325 | // MergeChange asks the instance to land a change request, and returns the server's |
| 326 | // one-line answer. | |
| 327 | // | |
| 328 | // Landing moves a branch, so it cannot be done from a clone: the rules that decide | |
| 329 | // it — the repository's ACL, the approvals filed against the request, and the run | |
| 330 | // results — live in the instance. The CLI asks over the key it already pushes with, | |
| 331 | // and the server runs the same operation the browser's merge button runs. | |
| 332 | func MergeChange(gitDir, remote, id string) (string, error) { | |
| 333 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 334 | if err != nil { | |
| 335 | return "", err | |
| 336 | } | |
| 337 | if RepoNameFromPath(path) == "" { | |
| 338 | return "", fmt.Errorf("%s does not name a repository", remote) | |
| 339 | } | |
| 340 | // The path goes as the remote names it ("/demo.git"), which is the shape the | |
| 341 | // server's own repository-path check accepts for the git verbs. One check, one | |
| 342 | // shape, so a name that would be refused on a push is refused here too. | |
| 343 | out, err := RunSSH(host, port, "vole-change-merge "+path+" "+id) | |
| 344 | if err != nil { | |
| 345 | return "", err | |
| 346 | } | |
| 347 | return strings.TrimSpace(out), nil | |
| 348 | } | |
|
eb07ff56
Keenan Nemetz
8d ago
|
349 | |
|
deb024d4
Keenan Nemetz
6d ago
|
350 | // CancelRun cancels one queued run, or asks a running one to stop. The queue lives |
| 351 | // on the instance, so the CLI asks over the key it pushes with; the server checks the | |
| 352 | // maintainer capability, records the audit line, and says which of the two happened. | |
| 353 | // The id is the 26-character one the run page shows in its URL. | |
| 354 | func CancelRun(gitDir, remote, runID string) (string, error) { | |
|
70291860
Keenan Nemetz
6d ago
|
355 | return runVerb(gitDir, remote, runID, "vole-run-cancel") |
| 356 | } | |
| 357 | ||
| 358 | // RerunRun queues a finished run again with the same pinned job. Attempts keep their | |
| 359 | // history; the run is a rerun of the job it already named, not a new row. The server | |
| 360 | // checks the maintainer capability and records the audit line. | |
| 361 | func RerunRun(gitDir, remote, runID string) (string, error) { | |
| 362 | return runVerb(gitDir, remote, runID, "vole-run-rerun") | |
| 363 | } | |
| 364 | ||
| 365 | // runVerb resolves the instance and repository for one run-management verb and runs | |
| 366 | // it. The caller has already decided which verb; this is only the wire. | |
| 367 | func runVerb(gitDir, remote, runID, verb string) (string, error) { | |
|
deb024d4
Keenan Nemetz
6d ago
|
368 | runID = strings.ToUpper(strings.TrimSpace(runID)) |
| 369 | if !voleid.Valid(runID) { | |
| 370 | return "", fmt.Errorf("%q is not a run id; use the 26-character id from the run page", runID) | |
| 371 | } | |
| 372 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 373 | if err != nil { | |
| 374 | return "", err | |
| 375 | } | |
| 376 | if RepoNameFromPath(path) == "" { | |
| 377 | return "", fmt.Errorf("%s does not name a repository", remote) | |
| 378 | } | |
|
70291860
Keenan Nemetz
6d ago
|
379 | out, err := RunSSH(host, port, verb+" "+quoteSSHArg(path)+" "+quoteSSHArg(runID)) |
|
deb024d4
Keenan Nemetz
6d ago
|
380 | if err != nil { |
| 381 | return "", err | |
| 382 | } | |
| 383 | return strings.TrimSpace(out), nil | |
| 384 | } | |
| 385 | ||
|
eb07ff56
Keenan Nemetz
8d ago
|
386 | // DispatchWorkflow queues a manual run of a workflow that declares |
| 387 | // workflow_dispatch. It is a write to the instance's queue, so the CLI asks over | |
| 388 | // the same key-authenticated channel it pushes with; the server resolves the | |
| 389 | // workflow, validates the inputs and checks the maintainer capability. | |
| 390 | func DispatchWorkflow(gitDir, remote, workflow, ref string, inputs map[string]string) (string, error) { | |
| 391 | host, port, path, err := SSHTarget(gitDir, remote) | |
| 392 | if err != nil { | |
| 393 | return "", err | |
| 394 | } | |
| 395 | if RepoNameFromPath(path) == "" { | |
| 396 | return "", fmt.Errorf("%s does not name a repository", remote) | |
| 397 | } | |
| 398 | if ref == "" { | |
| 399 | ref = "-" | |
| 400 | } | |
| 401 | args := []string{"vole-workflow-dispatch", path, workflow, ref} | |
| 402 | names := make([]string, 0, len(inputs)) | |
| 403 | for name := range inputs { | |
| 404 | names = append(names, name) | |
| 405 | } | |
| 406 | sort.Strings(names) | |
| 407 | for _, name := range names { | |
| 408 | args = append(args, name+"="+inputs[name]) | |
| 409 | } | |
| 410 | quoted := make([]string, len(args)) | |
| 411 | for i, arg := range args { | |
| 412 | quoted[i] = quoteSSHArg(arg) | |
| 413 | } | |
| 414 | out, err := RunSSH(host, port, strings.Join(quoted, " ")) | |
| 415 | if err != nil { | |
| 416 | return "", err | |
| 417 | } | |
| 418 | return strings.TrimSpace(out), nil | |
| 419 | } |