detached d928a74

feat(runner): add the init, exec and sandbox-check subcommands

commit
d928a74b842d6b7029c024046570b0ce7b864d97
author
brice · · 2026-10-12 15:35 UTC
parent
ecb0362
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +139 -4
M main.go +66 -4
@@ -3,21 +3,83 @@
33 // Command ermite-runner runs CI jobs for an ermite server. Each job runs in
44 // its own Firecracker micro-VM, or in its own container when the host has no
55 // KVM, and executes workflows compatible with GitHub Actions.
6+//
7+// Subcommands: version; init and exec run inside a sandbox (guest PID 1, and
8+// the executor); sandbox-check runs one stub job through the selected isolation
9+// mode and prints its output (phase D of the CI-1 plan: the host loop that
10+// fetches jobs from ermite comes later).
611 package main
712
813 import (
14+ "context"
915 "fmt"
1016 "os"
17+ "os/signal"
18+ "syscall"
19+
20+ "github.com/bnema/zerowrap"
21+
22+ "bnema.dev/ermite-runner/internal/guest"
1123 )
1224
1325 // version is set at build time with -ldflags "-X main.version=...".
1426 var version = "dev"
1527
1628 func main() {
17- if len(os.Args) > 1 && os.Args[1] == "version" {
29+ os.Exit(run(os.Args[1:]))
30+}
31+
32+func run(args []string) int {
33+ if len(args) == 0 {
34+ usage()
35+ return 2
36+ }
37+ log := zerowrap.New(zerowrap.Config{Level: envOr("ERMITE_RUNNER_LOG_LEVEL", "info"), Format: envOr("ERMITE_RUNNER_LOG_FORMAT", "console")})
38+ ctx := zerowrap.WithCtx(context.Background(), log)
39+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
40+ defer stop()
41+
42+ switch args[0] {
43+ case "version":
1844 fmt.Println("ermite-runner", version)
19- return
45+ return 0
46+ case "init":
47+ return guest.Init(ctx, guest.InitConfig{Version: version})
48+ case "exec":
49+ return execCmd(ctx)
50+ case "sandbox-check":
51+ return sandboxCheck(ctx, args[1:])
52+ }
53+ usage()
54+ return 2
55+}
56+
57+func usage() {
58+ fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
59+}
60+
61+func envOr(key, def string) string {
62+ if v := os.Getenv(key); v != "" {
63+ return v
64+ }
65+ return def
66+}
67+
68+// execCmd is `ermite-runner exec`, the stub executor.
69+func execCmd(ctx context.Context) int {
70+ log := zerowrap.FromCtx(ctx)
71+ target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))
72+ if err != nil {
73+ log.Error().Err(err).Msg("exec")
74+ return 1
75+ }
76+ code, err := guest.Exec(ctx, guest.ExecConfig{
77+ Target: target,
78+ Sandbox: envOr("ERMITE_SANDBOX", "container"),
79+ Version: envOr("ERMITE_VERSION", version),
80+ })
81+ if err != nil {
82+ log.Error().Err(err).Msg("exec")
2083 }
21- fmt.Fprintln(os.Stderr, "usage: ermite-runner version")
22- os.Exit(2)
84+ return code
2385 }
@@ -3,21 +3,83 @@
3 // Command ermite-runner runs CI jobs for an ermite server. Each job runs in3 // Command ermite-runner runs CI jobs for an ermite server. Each job runs in
4 // its own Firecracker micro-VM, or in its own container when the host has no4 // its own Firecracker micro-VM, or in its own container when the host has no
5 // KVM, and executes workflows compatible with GitHub Actions.5 // KVM, and executes workflows compatible with GitHub Actions.
6+//
7+// Subcommands: version; init and exec run inside a sandbox (guest PID 1, and
8+// the executor); sandbox-check runs one stub job through the selected isolation
9+// mode and prints its output (phase D of the CI-1 plan: the host loop that
10+// fetches jobs from ermite comes later).
6 package main11 package main
7 12
8 import (13 import (
14+ "context"
9 "fmt"15 "fmt"
10 "os"16 "os"
17+ "os/signal"
18+ "syscall"
19+
20+ "github.com/bnema/zerowrap"
21+
22+ "bnema.dev/ermite-runner/internal/guest"
11 )23 )
12 24
13 // version is set at build time with -ldflags "-X main.version=...".25 // version is set at build time with -ldflags "-X main.version=...".
14 var version = "dev"26 var version = "dev"
15 27
16 func main() {28 func main() {
17- if len(os.Args) > 1 && os.Args[1] == "version" {29+ os.Exit(run(os.Args[1:]))
30+}
31+
32+func run(args []string) int {
33+ if len(args) == 0 {
34+ usage()
35+ return 2
36+ }
37+ log := zerowrap.New(zerowrap.Config{Level: envOr("ERMITE_RUNNER_LOG_LEVEL", "info"), Format: envOr("ERMITE_RUNNER_LOG_FORMAT", "console")})
38+ ctx := zerowrap.WithCtx(context.Background(), log)
39+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
40+ defer stop()
41+
42+ switch args[0] {
43+ case "version":
18 fmt.Println("ermite-runner", version)44 fmt.Println("ermite-runner", version)
19- return45+ return 0
46+ case "init":
47+ return guest.Init(ctx, guest.InitConfig{Version: version})
48+ case "exec":
49+ return execCmd(ctx)
50+ case "sandbox-check":
51+ return sandboxCheck(ctx, args[1:])
52+ }
53+ usage()
54+ return 2
55+}
56+
57+func usage() {
58+ fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
59+}
60+
61+func envOr(key, def string) string {
62+ if v := os.Getenv(key); v != "" {
63+ return v
64+ }
65+ return def
66+}
67+
68+// execCmd is `ermite-runner exec`, the stub executor.
69+func execCmd(ctx context.Context) int {
70+ log := zerowrap.FromCtx(ctx)
71+ target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))
72+ if err != nil {
73+ log.Error().Err(err).Msg("exec")
74+ return 1
75+ }
76+ code, err := guest.Exec(ctx, guest.ExecConfig{
77+ Target: target,
78+ Sandbox: envOr("ERMITE_SANDBOX", "container"),
79+ Version: envOr("ERMITE_VERSION", version),
80+ })
81+ if err != nil {
82+ log.Error().Err(err).Msg("exec")
20 }83 }
21- fmt.Fprintln(os.Stderr, "usage: ermite-runner version")84+ return code
22- os.Exit(2)
23 }85 }
A sandbox_check.go +73 -0
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: MIT
2+
3+package main
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite-runner/internal/execstream"
15+ "bnema.dev/ermite-runner/internal/execstream/execpb"
16+ "bnema.dev/ermite-runner/internal/sandbox"
17+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
18+)
19+
20+// sandboxCheck runs one stub job in the sandbox chosen by
21+// ERMITE_RUNNER_ISOLATION: reap leftovers, start, stream the output to stdout
22+// and stderr, stop. Its exit code is the script's. It is the manual counterpart
23+// of the integration tests and the smoke test of an installation.
24+func sandboxCheck(ctx context.Context, args []string) int {
25+ log := zerowrap.FromCtx(ctx)
26+ cfg, err := sandbox.LoadConfig(os.Getenv)
27+ if err != nil {
28+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
29+ return 2
30+ }
31+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
32+ if err != nil {
33+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
34+ return 1
35+ }
36+ defer func() { _ = drv.Close() }()
37+ if err := drv.Reap(ctx); err != nil {
38+ log.Warn().Err(err).Msg("reaping leftovers of a previous run")
39+ }
40+ sb, err := drv.New()
41+ if err != nil {
42+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
43+ return 1
44+ }
45+ // Stop runs even when the job context was cancelled.
46+ defer func() {
47+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
48+ defer cancel()
49+ if err := sb.Stop(sctx); err != nil {
50+ fmt.Fprintln(os.Stderr, "ermite-runner: cleanup:", err)
51+ }
52+ }()
53+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})
54+ if err != nil {
55+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
56+ return 1
57+ }
58+ _, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: strings.Join(args, " ")}, func(o *execpb.Output) {
59+ f := os.Stdout
60+ if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
61+ f = os.Stderr
62+ }
63+ _, _ = f.Write(o.Data)
64+ })
65+ if err != nil {
66+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
67+ return 1
68+ }
69+ if exit.Error != "" {
70+ fmt.Fprintln(os.Stderr, "ermite-runner: executor:", exit.Error)
71+ }
72+ return int(exit.Code)
73+}
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: MIT
2+
3+package main
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite-runner/internal/execstream"
15+ "bnema.dev/ermite-runner/internal/execstream/execpb"
16+ "bnema.dev/ermite-runner/internal/sandbox"
17+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
18+)
19+
20+// sandboxCheck runs one stub job in the sandbox chosen by
21+// ERMITE_RUNNER_ISOLATION: reap leftovers, start, stream the output to stdout
22+// and stderr, stop. Its exit code is the script's. It is the manual counterpart
23+// of the integration tests and the smoke test of an installation.
24+func sandboxCheck(ctx context.Context, args []string) int {
25+ log := zerowrap.FromCtx(ctx)
26+ cfg, err := sandbox.LoadConfig(os.Getenv)
27+ if err != nil {
28+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
29+ return 2
30+ }
31+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
32+ if err != nil {
33+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
34+ return 1
35+ }
36+ defer func() { _ = drv.Close() }()
37+ if err := drv.Reap(ctx); err != nil {
38+ log.Warn().Err(err).Msg("reaping leftovers of a previous run")
39+ }
40+ sb, err := drv.New()
41+ if err != nil {
42+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
43+ return 1
44+ }
45+ // Stop runs even when the job context was cancelled.
46+ defer func() {
47+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
48+ defer cancel()
49+ if err := sb.Stop(sctx); err != nil {
50+ fmt.Fprintln(os.Stderr, "ermite-runner: cleanup:", err)
51+ }
52+ }()
53+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "sandbox-check"})
54+ if err != nil {
55+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
56+ return 1
57+ }
58+ _, exit, err := execstream.Drive(ctx, conn, &execpb.Run{Script: strings.Join(args, " ")}, func(o *execpb.Output) {
59+ f := os.Stdout
60+ if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
61+ f = os.Stderr
62+ }
63+ _, _ = f.Write(o.Data)
64+ })
65+ if err != nil {
66+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
67+ return 1
68+ }
69+ if exit.Error != "" {
70+ fmt.Fprintln(os.Stderr, "ermite-runner: executor:", exit.Error)
71+ }
72+ return int(exit.Code)
73+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close