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/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.

Add remote administration and local workflow runs

3abbe749096f4d7ff76f4d468e4427829b8a6f95 · Keenan Nemetz · 16d ago · parent 520470e1

Add remote administration and local workflow runs

Make `vole admin` the single namespace for user, repository, and access
management, runnable locally or over SSH by instance admins. Instance-admin
grants live in users.db, and the audit log names the actor separately from the
account being changed.

Add `vole workflow run` to execute workflow jobs in the working tree without a
server or runner. Positional arguments select workflows, --job selects jobs, and
the command reuses the push-time validator.

Update the README and guides for the new command shapes.

server/ssh/admin.go +740

@@ -0,0 +1,740 @@
1 package ssh
2
3 // Remote administration over SSH. The command grammar is fixed; nothing here
4 // goes through a shell. Authorization is the account id from the SSH handshake,
5 // and every successful or rejected command is recorded with that identity.
6
7 import (
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "strconv"
14 "strings"
15 "time"
16
17 xssh "golang.org/x/crypto/ssh"
18
19 "github.com/rodent-software/vole/forge/access"
20 "github.com/rodent-software/vole/forge/identity"
21 "github.com/rodent-software/vole/forge/repo"
22 )
23
24 const maxAdminKeyBytes = 1 << 20
25
26 type adminResponse struct {
27 OK bool `json:"ok"`
28 Data any `json:"data,omitempty"`
29 Error *adminError `json:"error,omitempty"`
30 }
31
32 type adminError struct {
33 Code string `json:"code"`
34 Message string `json:"message"`
35 }
36
37 type adminUsageError struct{ msg string }
38
39 func (e *adminUsageError) Error() string { return e.msg }
40
41 type adminRender func(stdout, stderr io.Writer)
42
43 // serveAdmin runs one `vole-admin` command and returns the process exit code.
44 func (s *Server) serveAdmin(ch xssh.Channel, argv []string, user, accountID, remote string) int {
45 jsonOut, args, err := splitAdminJSON(argv)
46 if err != nil {
47 return s.adminError(ch, jsonOut, "usage", err, user, accountID, remote)
48 }
49 if !s.cfg.Identity.IsAdmin(accountID) {
50 return s.adminError(ch, jsonOut, "permission_denied",
51 fmt.Errorf("instance admin required for %s", user), user, accountID, remote)
52 }
53 if s.cfg.Instance == nil {
54 return s.adminError(ch, jsonOut, "unavailable",
55 errors.New("admin API is not wired into this server"), user, accountID, remote)
56 }
57 if len(args) < 2 {
58 return s.adminError(ch, jsonOut, "usage", &adminUsageError{msg: "usage: vole-admin <resource> <action> [args]"},
59 user, accountID, remote)
60 }
61
62 data, render, err := s.runAdmin(args, user, ch)
63 if err != nil {
64 return s.adminError(ch, jsonOut, "failed", err, user, accountID, remote)
65 }
66 if jsonOut {
67 _ = json.NewEncoder(ch).Encode(adminResponse{OK: true, Data: data})
68 } else if render != nil {
69 render(ch, ch.Stderr())
70 }
71 s.auditAdmin(user, accountID, remote, strings.Join(args, " ")+" ok")
72 return 0
73 }
74
75 func (s *Server) adminError(ch xssh.Channel, jsonOut bool, code string, err error, user, accountID, remote string) int {
76 exitCode := 1
77 if _, ok := err.(*adminUsageError); ok {
78 exitCode = 129
79 }
80 if jsonOut {
81 _ = json.NewEncoder(ch).Encode(adminResponse{OK: false, Error: &adminError{Code: code, Message: err.Error()}})
82 } else {
83 fmt.Fprintf(ch.Stderr(), "vole: %v\n", err)
84 }
85 s.auditAdmin(user, accountID, remote, "failed: "+err.Error())
86 return exitCode
87 }
88
89 func splitAdminJSON(argv []string) (jsonOut bool, rest []string, err error) {
90 terminated := false
91 for _, a := range argv {
92 if terminated {
93 rest = append(rest, a)
94 continue
95 }
96 switch a {
97 case "--":
98 terminated = true
99 case "--json":
100 jsonOut = true
101 default:
102 rest = append(rest, a)
103 }
104 }
105 return
106 }
107
108 func (s *Server) runAdmin(args []string, actor string, in io.Reader) (any, adminRender, error) {
109 resource, action, rest := args[0], args[1], args[2:]
110 switch resource {
111 case "user":
112 return s.adminUser(action, rest, actor, in)
113 case "repo":
114 return s.adminRepo(action, rest, actor)
115 case "acl":
116 return s.adminACL(action, rest, actor)
117 }
118 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown admin resource %q", resource)}
119 }
120
121 func (s *Server) adminUser(action string, args []string, actor string, in io.Reader) (any, adminRender, error) {
122 switch action {
123 case "list":
124 all := s.cfg.Identity.All()
125 data := make([]map[string]any, 0, len(all))
126 for _, a := range all {
127 data = append(data, userData(a))
128 }
129 return data, func(out, _ io.Writer) {
130 if len(all) == 0 {
131 fmt.Fprintln(out, "no users")
132 return
133 }
134 for _, a := range all {
135 role := ""
136 if a.Admin {
137 role = " admin"
138 }
139 fmt.Fprintf(out, "%-16s %s %-28s%s keys=%d\n", a.Name, a.ID, a.Status, role, len(a.Keys))
140 for _, k := range a.Keys {
141 fmt.Fprintf(out, " %s %s %s\n", k.Fingerprint, k.Type, k.Comment)
142 }
143 }
144 }, nil
145
146 case "show":
147 if len(args) != 1 {
148 return nil, nil, &adminUsageError{msg: "usage: vole-admin user show <name|id>"}
149 }
150 a, err := s.cfg.Identity.Find(args[0])
151 if err != nil {
152 return nil, nil, err
153 }
154 return userData(a), func(out, _ io.Writer) {
155 fmt.Fprintf(out, "name: %s\nid: %s\nstatus: %s\nadmin: %t\ncreated: %s\n",
156 a.Name, a.ID, a.Status, a.Admin, a.Created.Format(time.RFC3339))
157 for _, k := range a.Keys {
158 fmt.Fprintf(out, "key: %s %s %s\n", k.Fingerprint, k.Type, k.Comment)
159 }
160 }, nil
161
162 case "create":
163 if len(args) != 1 {
164 return nil, nil, &adminUsageError{msg: "usage: vole-admin user create <name>"}
165 }
166 a, err := s.cfg.Identity.Create(args[0], actor)
167 if err != nil {
168 return nil, nil, err
169 }
170 return userData(a), func(out, errOut io.Writer) {
171 fmt.Fprintf(out, "%s\t%s\n", a.Name, a.ID)
172 fmt.Fprintln(errOut, "no keys yet: this account cannot authenticate until you add one")
173 }, nil
174
175 case "add-key":
176 if len(args) != 1 {
177 return nil, nil, &adminUsageError{msg: "usage: vole-admin user add-key <name|id>"}
178 }
179 key, err := readAdminKey(in)
180 if err != nil {
181 return nil, nil, err
182 }
183 fp, err := s.cfg.Identity.AddKey(args[0], key, actor)
184 if err != nil {
185 return nil, nil, err
186 }
187 a, _ := s.cfg.Identity.Find(args[0])
188 data := map[string]any{"fingerprint": fp, "account": userData(a)}
189 return data, func(out, _ io.Writer) {
190 fmt.Fprintf(out, "added key %s for %s\n", fp, a.Name)
191 }, nil
192
193 case "rm-key":
194 if len(args) != 2 {
195 return nil, nil, &adminUsageError{msg: "usage: vole-admin user rm-key <name|id> <fingerprint>"}
196 }
197 if err := s.cfg.Identity.RemoveKey(args[0], args[1], actor); err != nil {
198 return nil, nil, err
199 }
200 return map[string]any{"fingerprint": args[1]}, func(out, _ io.Writer) {
201 fmt.Fprintf(out, "revoked %s\n", args[1])
202 }, nil
203
204 case "rename":
205 if len(args) != 2 {
206 return nil, nil, &adminUsageError{msg: "usage: vole-admin user rename <name|id> <new-name>"}
207 }
208 a, err := s.cfg.Identity.Rename(args[0], args[1], actor)
209 if err != nil {
210 return nil, nil, err
211 }
212 return userData(a), func(out, errOut io.Writer) {
213 fmt.Fprintf(out, "%s\t%s\n", a.Name, a.ID)
214 fmt.Fprintln(errOut, "ACL entries and approvals refer to this id and are unaffected")
215 }, nil
216
217 case "retire", "rm", "delete":
218 if len(args) != 1 {
219 return nil, nil, &adminUsageError{msg: "usage: vole-admin user retire <name|id>"}
220 }
221 a, err := s.cfg.Identity.Retire(args[0], actor)
222 if err != nil {
223 return nil, nil, err
224 }
225 return userData(a), func(out, errOut io.Writer) {
226 fmt.Fprintf(out, "retired %s\t%s\n", a.Name, a.ID)
227 fmt.Fprintln(errOut, "keys stop working within 5s; the name is never reissued")
228 }, nil
229
230 case "backup":
231 return nil, nil, &adminUsageError{msg: "user backup writes to the server's filesystem; run it locally"}
232
233 case "grant-admin", "revoke-admin":
234 if len(args) != 1 {
235 return nil, nil, &adminUsageError{msg: "usage: vole-admin user " + action + " <name|id>"}
236 }
237 a, err := s.cfg.Identity.SetAdmin(args[0], action == "grant-admin", actor)
238 if err != nil {
239 return nil, nil, err
240 }
241 return userData(a), func(out, _ io.Writer) {
242 if action == "grant-admin" {
243 fmt.Fprintf(out, "granted instance admin to %s\t%s\n", a.Name, a.ID)
244 } else {
245 fmt.Fprintf(out, "revoked instance admin from %s\t%s\n", a.Name, a.ID)
246 }
247 }, nil
248 }
249 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown user command %q", action)}
250 }
251
252 func (s *Server) adminRepo(action string, args []string, actor string) (any, adminRender, error) {
253 switch action {
254 case "list":
255 repos, err := s.cfg.Instance.Repos.List()
256 if err != nil {
257 return nil, nil, err
258 }
259 data := make([]map[string]any, 0, len(repos))
260 views := make([]*repo.Meta, 0, len(repos))
261 for _, m := range repos {
262 d, _, err := s.repoData(m)
263 if err != nil {
264 return nil, nil, err
265 }
266 data = append(data, d)
267 views = append(views, m)
268 }
269 return data, func(out, _ io.Writer) {
270 for _, d := range data {
271 vis := "private"
272 if d["public"].(bool) {
273 vis = "public"
274 }
275 fmt.Fprintf(out, "%-32s id=%s principals=%d %s\n", d["name"], d["id"], d["principals"], vis)
276 }
277 if len(views) == 0 {
278 fmt.Fprintln(out, "no repositories")
279 }
280 }, nil
281
282 case "show":
283 if len(args) != 1 {
284 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo show <name>"}
285 }
286 m, err := s.cfg.Instance.Repos.Open(args[0])
287 if err != nil {
288 return nil, nil, fmt.Errorf("repository %q not found", args[0])
289 }
290 data, a, err := s.repoData(m)
291 if err != nil {
292 return nil, nil, err
293 }
294 return data, func(out, _ io.Writer) {
295 vis := "private"
296 if a.Can("", access.Read) {
297 vis = "public"
298 }
299 fmt.Fprintf(out, "name: %s\nid: %s\nvisibility: %s\ndefault branch: %s\ndescription: %s\n",
300 m.Name, m.ID, vis, m.DefaultBranch, m.Description)
301 }, nil
302
303 case "create":
304 f, err := parseAdminFlags(args,
305 adminFlagSpec{long: "owner", short: "o"},
306 adminFlagSpec{long: "description", short: "d"})
307 if err != nil {
308 return nil, nil, err
309 }
310 if len(f.rest) != 1 || f.one("owner") == "" {
311 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo create --owner <user> <name> [--description <text>]"}
312 }
313 owner, err := s.cfg.Identity.Find(f.one("owner"))
314 if err != nil {
315 return nil, nil, err
316 }
317 if !owner.Active() {
318 return nil, nil, fmt.Errorf("owner %q is retired", owner.Name)
319 }
320 m, err := s.cfg.Instance.Repos.Create(f.rest[0], owner.ID, f.one("description"))
321 if err != nil {
322 return nil, nil, err
323 }
324 if err := s.cfg.Instance.Index.Upsert(m); err != nil {
325 return nil, nil, err
326 }
327 data, _, err := s.repoData(m)
328 if err != nil {
329 return nil, nil, err
330 }
331 return data, func(out, errOut io.Writer) {
332 fmt.Fprintf(out, "created %s (id %s)\n", m.Name, m.ID)
333 fmt.Fprintf(errOut, "new repositories are private; publish with: vole admin acl default %s read\n", m.Name)
334 }, nil
335
336 case "rename":
337 if len(args) != 2 {
338 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo rename <old> <new>"}
339 }
340 if err := s.cfg.Instance.RenameRepo(args[0], args[1]); err != nil {
341 return nil, nil, err
342 }
343 return map[string]any{"old": args[0], "name": args[1]}, func(out, _ io.Writer) {
344 fmt.Fprintf(out, "renamed %s -> %s\n", args[0], args[1])
345 }, nil
346
347 case "delete":
348 if len(args) != 1 {
349 return nil, nil, &adminUsageError{msg: "usage: vole-admin repo delete <name>"}
350 }
351 if err := s.cfg.Instance.DeleteRepo(args[0]); err != nil {
352 return nil, nil, err
353 }
354 return map[string]any{"name": args[0]}, func(out, _ io.Writer) {
355 fmt.Fprintf(out, "deleted %s\n", args[0])
356 }, nil
357 }
358 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown repo command %q", action)}
359 }
360
361 func (s *Server) adminACL(action string, args []string, actor string) (any, adminRender, error) {
362 if len(args) == 0 {
363 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl <show|set|default|protect|approvals|checks|runners> <repo> ..."}
364 }
365 repoName := args[0]
366 m, err := s.cfg.Instance.Repos.Open(repoName)
367 if err != nil {
368 return nil, nil, fmt.Errorf("repository %q not found", repoName)
369 }
370 a, err := m.ACL()
371 if err != nil {
372 return nil, nil, err
373 }
374 finish := func() (any, adminRender, error) {
375 if err := m.SetACL(a, actor); err != nil {
376 return nil, nil, err
377 }
378 if err := s.cfg.Instance.Index.Upsert(m); err != nil {
379 return nil, nil, err
380 }
381 return s.aclResult(m, a)
382 }
383
384 switch action {
385 case "show":
386 if len(args) != 1 {
387 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl show <repo>"}
388 }
389 return s.aclResult(m, a)
390
391 case "set":
392 if len(args) != 3 {
393 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl set <repo> <user> <none|read|write|admin>"}
394 }
395 role, err := access.ParseRole(args[2])
396 if err != nil {
397 return nil, nil, err
398 }
399 account, err := s.cfg.Identity.Find(args[1])
400 if err != nil {
401 return nil, nil, err
402 }
403 if err := a.SetEntry(account.ID, role.String()); err != nil {
404 return nil, nil, err
405 }
406 return finish()
407
408 case "default":
409 if len(args) != 2 {
410 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl default <repo> <none|read|write|admin>"}
411 }
412 role, err := access.ParseRole(args[1])
413 if err != nil {
414 return nil, nil, err
415 }
416 a.Default = role.String()
417 return finish()
418
419 case "protect":
420 if len(args) < 2 {
421 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl protect <repo> <ref-pattern>..."}
422 }
423 a.Protected = append([]string{}, args[1:]...)
424 return finish()
425
426 case "approvals":
427 if len(args) != 2 {
428 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl approvals <repo> <n>"}
429 }
430 n, err := strconv.Atoi(args[1])
431 if err != nil || n < 0 {
432 return nil, nil, &adminUsageError{msg: fmt.Sprintf("%q is not a number of approvals", args[1])}
433 }
434 a.ApprovalsRequired = &n
435 return finish()
436
437 case "checks":
438 if len(args) != 2 {
439 return nil, nil, &adminUsageError{msg: "usage: vole-admin acl checks <repo> <on|off>"}
440 }
441 switch strings.ToLower(args[1]) {
442 case "on", "true", "yes", "1":
443 v := true
444 a.ChecksRequired = &v
445 case "off", "false", "no", "0":
446 v := false
447 a.ChecksRequired = &v
448 default:
449 return nil, nil, &adminUsageError{msg: fmt.Sprintf("%q is not on or off", args[1])}
450 }
451 return finish()
452
453 case "runners":
454 f, err := parseAdminFlags(args[1:],
455 adminFlagSpec{long: "add", boolean: true},
456 adminFlagSpec{long: "remove", boolean: true},
457 adminFlagSpec{long: "clear", boolean: true})
458 if err != nil {
459 return nil, nil, err
460 }
461 names := f.rest
462 edits := 0
463 for _, b := range []string{"add", "remove", "clear"} {
464 if f.has(b) {
465 edits++
466 }
467 }
468 if edits > 1 {
469 return nil, nil, &adminUsageError{msg: "use one of --add, --remove or --clear"}
470 }
471 if len(names) == 0 && !f.has("clear") && !f.has("add") && !f.has("remove") {
472 return s.aclResult(m, a)
473 }
474 var next []string
475 resolve := func(name string) (string, error) {
476 account, err := s.cfg.Identity.Find(name)
477 if err != nil {
478 return "", err
479 }
480 return account.ID, nil
481 }
482 switch {
483 case f.has("clear"):
484 if len(names) > 0 {
485 return nil, nil, &adminUsageError{msg: "--clear takes no names"}
486 }
487 case f.has("add"), f.has("remove"):
488 if len(names) == 0 {
489 return nil, nil, &adminUsageError{msg: "--add and --remove need at least one account"}
490 }
491 next = append(next, a.Runners...)
492 for _, name := range names {
493 id, err := resolve(name)
494 if err != nil {
495 return nil, nil, err
496 }
497 if !f.has("remove") {
498 next = append(next, id)
499 continue
500 }
501 kept, found := next[:0], false
502 for _, r := range next {
503 if r == id {
504 found = true
505 continue
506 }
507 kept = append(kept, r)
508 }
509 if !found {
510 return nil, nil, fmt.Errorf("%s is not a runner for %s", name, m.Name)
511 }
512 next = kept
513 }
514 default:
515 for _, name := range names {
516 id, err := resolve(name)
517 if err != nil {
518 return nil, nil, err
519 }
520 next = append(next, id)
521 }
522 }
523 a.SetRunners(next)
524 return finish()
525 }
526 return nil, nil, &adminUsageError{msg: fmt.Sprintf("unknown acl command %q", action)}
527 }
528
529 func (s *Server) repoData(m *repo.Meta) (map[string]any, *access.ACL, error) {
530 a, err := m.ACL()
531 if err != nil {
532 return nil, nil, err
533 }
534 return map[string]any{
535 "id": m.ID,
536 "name": m.Name,
537 "description": m.Description,
538 "default_branch": m.DefaultBranch,
539 "public": a.Can("", access.Read),
540 "principals": len(a.Entries),
541 "created": m.Created,
542 }, a, nil
543 }
544
545 func (s *Server) aclResult(m *repo.Meta, a *access.ACL) (any, adminRender, error) {
546 entries := make([]map[string]string, 0, len(a.Entries))
547 for _, principal := range a.Principals() {
548 entries = append(entries, map[string]string{
549 "id": principal,
550 "name": s.cfg.Identity.Name(principal),
551 "role": a.Entries[principal],
552 })
553 }
554 runners := make([]map[string]string, 0, len(a.Runners))
555 for _, runner := range a.Runners {
556 runners = append(runners, map[string]string{
557 "id": runner,
558 "name": s.cfg.Identity.Name(runner),
559 })
560 }
561 data := map[string]any{
562 "repo": m.Name,
563 "public": a.Can("", access.Read),
564 "default": a.Default,
565 "entries": entries,
566 "protected": a.Protected,
567 "approvals_required": a.RequiredApprovals(),
568 "checks_required": a.RequiresChecks(),
569 "runners": runners,
570 }
571 return data, func(out, _ io.Writer) {
572 vis := "private"
573 if a.Can("", access.Read) {
574 vis = "public"
575 }
576 fmt.Fprintf(out, "repo: %s (%s)\n", m.Name, vis)
577 fmt.Fprintf(out, "default: %s\n", a.Default)
578 for _, e := range entries {
579 fmt.Fprintf(out, " %-16s %-28s %s\n", e["name"], e["id"], e["role"])
580 }
581 if len(a.Protected) > 0 {
582 fmt.Fprintf(out, "protected: %s\n", strings.Join(a.Protected, ", "))
583 }
584 fmt.Fprintf(out, "approvals: %d required on protected branches\n", a.RequiredApprovals())
585 if a.RequiresChecks() {
586 fmt.Fprintln(out, "checks: required on protected branches")
587 } else {
588 fmt.Fprintln(out, "checks: not required")
589 }
590 if len(a.Runners) == 0 {
591 fmt.Fprintln(out, "runners: none named (admins may report CI results)")
592 return
593 }
594 for _, r := range runners {
595 fmt.Fprintf(out, "runner %-28s %s\n", r["id"], r["name"])
596 }
597 fmt.Fprintln(out, "(admins may also report CI results)")
598 }, nil
599 }
600
601 func readAdminKey(r io.Reader) (string, error) {
602 data, err := io.ReadAll(io.LimitReader(r, maxAdminKeyBytes+1))
603 if err != nil {
604 return "", err
605 }
606 if len(data) > maxAdminKeyBytes {
607 return "", fmt.Errorf("public key is larger than %d bytes", maxAdminKeyBytes)
608 }
609 if strings.TrimSpace(string(data)) == "" {
610 return "", errors.New("no public key on stdin")
611 }
612 return string(data), nil
613 }
614
615 func userData(a *identity.Account) map[string]any {
616 if a == nil {
617 return nil
618 }
619 return map[string]any{
620 "id": a.ID,
621 "name": a.Name,
622 "status": a.Status,
623 "admin": a.Admin,
624 "former": a.Former,
625 "keys": a.Keys,
626 "created": a.Created,
627 }
628 }
629
630 func (s *Server) auditAdmin(user, accountID, remote, detail string) {
631 if s.cfg.AuditPath == "" {
632 return
633 }
634 // A command argument may contain a newline or tab; keep it on one audit line.
635 detail = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(detail)
636 f, err := os.OpenFile(s.cfg.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
637 if err != nil {
638 return
639 }
640 defer f.Close()
641 fmt.Fprintf(f, "%s\tadmin\t%s\t%s\t%s\t%s\n",
642 time.Now().UTC().Format(time.RFC3339), user, accountID, remote, detail)
643 }
644
645 // adminFlagSpec and parseAdminFlags are a small local flag parser for the
646 // server side. The client CLI already parsed its own flags; this parses only the
647 // flags that cross the SSH boundary.
648 type adminFlagSpec struct {
649 long string
650 short string
651 boolean bool
652 }
653
654 type adminFlags struct {
655 vals map[string][]string
656 bools map[string]bool
657 rest []string
658 }
659
660 func parseAdminFlags(args []string, specs ...adminFlagSpec) (*adminFlags, error) {
661 f := &adminFlags{vals: map[string][]string{}, bools: map[string]bool{}}
662 byLong := map[string]adminFlagSpec{}
663 byShort := map[string]adminFlagSpec{}
664 for _, s := range specs {
665 byLong[s.long] = s
666 if s.short != "" {
667 byShort[s.short] = s
668 }
669 }
670 terminated := false
671 for i := 0; i < len(args); i++ {
672 a := args[i]
673 if terminated {
674 f.rest = append(f.rest, a)
675 continue
676 }
677 if a == "--" {
678 terminated = true
679 continue
680 }
681 switch {
682 case strings.HasPrefix(a, "--"):
683 name, value, hasValue := strings.Cut(strings.TrimPrefix(a, "--"), "=")
684 spec, ok := byLong[name]
685 if !ok {
686 return nil, &adminUsageError{msg: fmt.Sprintf("unknown option --%s", name)}
687 }
688 if spec.boolean {
689 if hasValue {
690 return nil, &adminUsageError{msg: fmt.Sprintf("option --%s takes no value", name)}
691 }
692 f.bools[spec.long] = true
693 continue
694 }
695 if !hasValue {
696 i++
697 if i >= len(args) {
698 return nil, &adminUsageError{msg: fmt.Sprintf("option --%s requires a value", name)}
699 }
700 value = args[i]
701 }
702 f.vals[spec.long] = append(f.vals[spec.long], value)
703 case strings.HasPrefix(a, "-") && a != "-":
704 short := a[1:2]
705 spec, ok := byShort[short]
706 if !ok {
707 return nil, &adminUsageError{msg: fmt.Sprintf("unknown option -%s", short)}
708 }
709 if spec.boolean {
710 if len(a) > 2 {
711 return nil, &adminUsageError{msg: fmt.Sprintf("option -%s takes no value", short)}
712 }
713 f.bools[spec.long] = true
714 continue
715 }
716 value := a[2:]
717 if value == "" {
718 i++
719 if i >= len(args) {
720 return nil, &adminUsageError{msg: fmt.Sprintf("option -%s requires a value", short)}
721 }
722 value = args[i]
723 }
724 f.vals[spec.long] = append(f.vals[spec.long], value)
725 default:
726 f.rest = append(f.rest, a)
727 }
728 }
729 return f, nil
730 }
731
732 func (f *adminFlags) one(name string) string {
733 v := f.vals[name]
734 if len(v) == 0 {
735 return ""
736 }
737 return v[len(v)-1]
738 }
739
740 func (f *adminFlags) has(name string) bool { return f.bools[name] }