detached b70b211

feat(host): register and run subcommands, README, lint fixes

commit
b70b2119a868352b44b5eef1f3c251a50feddcc5
author
brice · · 2026-10-12 18:44 UTC
parent
5d5c671
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +190 -18
M README.md +35 -0
@@ -48,6 +48,41 @@ The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel chann
4848
4949 Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects.
5050
51+## Running jobs
52+
53+```
54+ermite-runner register --token <token with runner:register> # once
55+ermite-runner run # as a service
56+```
57+
58+`register` picks the isolation mode like `run` will, registers the runner with
59+ermite (name, labels, version, isolation, architecture) and stores the runner
60+token in `$ERMITE_RUNNER_STATE_DIR/runner.json` (mode 0600; a file readable by
61+others is refused). The register token can also come from
62+`ERMITE_RUNNER_REGISTER_TOKEN`; `--replace` removes a runner of the same name
63+first.
64+
65+`run` reaps what a previous run left, then polls `FetchJob` whenever a slot is
66+free (`ERMITE_RUNNER_CAPACITY`), with a `poll_id` that is kept when a response
67+is lost and obeying `retry_after`. For each job it sends the acknowledging
68+heartbeat first, then one every 30 s (a failed one is retried every 5 s), opens
69+`WatchJob` (a cancel request reaches the executor, which interrupts, terminates
70+and kills like the official runner), forwards the log with `AppendLog`
71+(batches of at most 1,000 lines and 64 KiB per step, numbered offsets, retried
72+with the same lines), sends step snapshots with `UpdateJob` and ends with an
73+idempotent `CompleteJob`. When ermite says the job ended (`JobEnded`) or revokes
74+the assignment (`unauthenticated`) the sandbox is destroyed and nothing more is
75+sent. On SIGINT or SIGTERM the running jobs are cancelled with
76+`runner_shutdown`, reported, and the sandboxes removed. The job token stays in
77+the host process; the sandbox receives the workflow and its contexts only.
78+
79+| Variable | Meaning |
80+| --- | --- |
81+| `ERMITE_RUNNER_URL` | base URL of ermite |
82+| `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
83+| `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
84+| `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` |
85+
5186 ## Configuration (sandbox part)
5287
5388 | Variable | Default | |
@@ -48,6 +48,41 @@ The host sends a `Cancel{reason}` frame (`execstream.Drive` takes a cancel chann
48 48
49 Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects.49 Everything a job creates (process, TAP or bridge, nftables rules, files, loop mounts, containers, networks, cgroups) is removed by `Stop`, which also runs with a cancelled context, aborts a `Start` in progress, and by `Reap` at start for what a crash left behind. A VM leaves a state record (`<state dir>/vms/<id>.json`) before its first resource. **The network policy and the TAP or bridge are never removed while the VMM or the container may still be running**: when a process cannot be killed or a container cannot be removed, `Stop` returns an error wrapping `sandbox.ErrLeaked`, the sandbox's slot stays taken, and `Driver.Reap` (which returns `sandbox.ErrBusy` while a sandbox is running) finishes the job and frees the slot. The host should stop taking jobs on a leaked slot until then. `Sandbox.ID()` names a sandbox's files and Docker objects.
50 50
51+## Running jobs
52+
53+```
54+ermite-runner register --token <token with runner:register> # once
55+ermite-runner run # as a service
56+```
57+
58+`register` picks the isolation mode like `run` will, registers the runner with
59+ermite (name, labels, version, isolation, architecture) and stores the runner
60+token in `$ERMITE_RUNNER_STATE_DIR/runner.json` (mode 0600; a file readable by
61+others is refused). The register token can also come from
62+`ERMITE_RUNNER_REGISTER_TOKEN`; `--replace` removes a runner of the same name
63+first.
64+
65+`run` reaps what a previous run left, then polls `FetchJob` whenever a slot is
66+free (`ERMITE_RUNNER_CAPACITY`), with a `poll_id` that is kept when a response
67+is lost and obeying `retry_after`. For each job it sends the acknowledging
68+heartbeat first, then one every 30 s (a failed one is retried every 5 s), opens
69+`WatchJob` (a cancel request reaches the executor, which interrupts, terminates
70+and kills like the official runner), forwards the log with `AppendLog`
71+(batches of at most 1,000 lines and 64 KiB per step, numbered offsets, retried
72+with the same lines), sends step snapshots with `UpdateJob` and ends with an
73+idempotent `CompleteJob`. When ermite says the job ended (`JobEnded`) or revokes
74+the assignment (`unauthenticated`) the sandbox is destroyed and nothing more is
75+sent. On SIGINT or SIGTERM the running jobs are cancelled with
76+`runner_shutdown`, reported, and the sandboxes removed. The job token stays in
77+the host process; the sandbox receives the workflow and its contexts only.
78+
79+| Variable | Meaning |
80+| --- | --- |
81+| `ERMITE_RUNNER_URL` | base URL of ermite |
82+| `ERMITE_RUNNER_NAME` | runner name (default: the host name) |
83+| `ERMITE_RUNNER_LABELS` | extra labels, comma separated (`ubuntu-latest,ubuntu-24.04`) |
84+| `ERMITE_RUNNER_REGISTER_TOKEN` | token for `register` |
85+
51 ## Configuration (sandbox part)86 ## Configuration (sandbox part)
52 87
53 | Variable | Default | |88 | Variable | Default | |
M go.mod +1 -1
@@ -11,7 +11,6 @@ require (
1111 github.com/google/uuid v1.6.0
1212 github.com/moby/moby/api v1.56.1
1313 github.com/moby/moby/client v0.6.2
14- golang.org/x/net v0.58.0
1514 golang.org/x/sys v0.49.0
1615 google.golang.org/protobuf v1.36.12
1716 )
@@ -91,6 +90,7 @@ require (
9190 golang.org/x/crypto v0.56.0 // indirect
9291 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
9392 golang.org/x/mod v0.40.0 // indirect
93+ golang.org/x/net v0.58.0 // indirect
9494 golang.org/x/sync v0.23.0 // indirect
9595 golang.org/x/term v0.45.0 // indirect
9696 golang.org/x/text v0.41.0 // indirect
@@ -11,7 +11,6 @@ require (
11 github.com/google/uuid v1.6.011 github.com/google/uuid v1.6.0
12 github.com/moby/moby/api v1.56.112 github.com/moby/moby/api v1.56.1
13 github.com/moby/moby/client v0.6.213 github.com/moby/moby/client v0.6.2
14- golang.org/x/net v0.58.0
15 golang.org/x/sys v0.49.014 golang.org/x/sys v0.49.0
16 google.golang.org/protobuf v1.36.1215 google.golang.org/protobuf v1.36.12
17 )16 )
@@ -91,6 +90,7 @@ require (
91 golang.org/x/crypto v0.56.0 // indirect90 golang.org/x/crypto v0.56.0 // indirect
92 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect91 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
93 golang.org/x/mod v0.40.0 // indirect92 golang.org/x/mod v0.40.0 // indirect
93+ golang.org/x/net v0.58.0 // indirect
94 golang.org/x/sync v0.23.0 // indirect94 golang.org/x/sync v0.23.0 // indirect
95 golang.org/x/term v0.45.0 // indirect95 golang.org/x/term v0.45.0 // indirect
96 golang.org/x/text v0.41.0 // indirect96 golang.org/x/text v0.41.0 // indirect
A host_cmd.go +137 -0
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: MIT
2+
3+package main
4+
5+import (
6+ "context"
7+ "errors"
8+ "flag"
9+ "fmt"
10+ "net/http"
11+ "os"
12+ "strings"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+
18+ "bnema.dev/ermite-runner/internal/host"
19+ "bnema.dev/ermite-runner/internal/sandbox"
20+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
21+)
22+
23+// loadHostConfig reads the sandbox settings and the host settings together, so
24+// that the state directory and the capacity are the same for both.
25+func loadHostConfig() (sandbox.Config, host.Config, error) {
26+ scfg, err := sandbox.LoadConfig(os.Getenv)
27+ if err != nil {
28+ return scfg, host.Config{}, err
29+ }
30+ name, _ := os.Hostname()
31+ hcfg, err := host.LoadConfig(os.Getenv, name, scfg.StateDir, scfg.Capacity, version)
32+ return scfg, hcfg, err
33+}
34+
35+func buildDriver(ctx context.Context, scfg sandbox.Config) (sandbox.Driver, error) {
36+ return sandbox.Select(ctx, scfg, drivers.Factories(), sandbox.Prober{})
37+}
38+
39+// registerCmd is `ermite-runner register [--token T] [--replace]`: it chooses
40+// the isolation mode like `run` will, registers the runner with it, and stores
41+// the runner token (mode 0600) in the state directory. The register token comes
42+// from --token or ERMITE_RUNNER_REGISTER_TOKEN, so that it need not appear in a
43+// process list.
44+func registerCmd(ctx context.Context, args []string) int {
45+ fs := flag.NewFlagSet("register", flag.ContinueOnError)
46+ token := fs.String("token", "", "token with the runner:register scope (default: $"+host.EnvRegisterToken+")")
47+ replace := fs.Bool("replace", false, "replace a runner of the same name")
48+ if err := fs.Parse(args); err != nil {
49+ return 2
50+ }
51+ if *token == "" {
52+ *token = os.Getenv(host.EnvRegisterToken)
53+ }
54+ scfg, hcfg, err := loadHostConfig()
55+ if err != nil {
56+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
57+ return 2
58+ }
59+ switch {
60+ case hcfg.URL == "":
61+ fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
62+ return 2
63+ case *token == "":
64+ fmt.Fprintf(os.Stderr, "ermite-runner: give a token with --token or %s\n", host.EnvRegisterToken)
65+ return 2
66+ }
67+ drv, err := buildDriver(ctx, scfg)
68+ if err != nil {
69+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
70+ return 1
71+ }
72+ iso := drv.Isolation()
73+ _ = drv.Close()
74+
75+ client := host.NewClient(hcfg.URL, *token, http.DefaultTransport)
76+ st, err := host.Register(ctx, hcfg, client, iso, *replace)
77+ if err != nil {
78+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
79+ return 1
80+ }
81+ fmt.Printf("registered runner %q (%s) with isolation %s; token stored in %s\n", st.Name, st.RunnerID, iso, host.StateFile(hcfg.StateDir))
82+ return 0
83+}
84+
85+// runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its
86+// own sandbox until SIGINT or SIGTERM, then cancel the running jobs with
87+// runner_shutdown and report them.
88+func runCmd(ctx context.Context) int {
89+ log := zerowrap.FromCtx(ctx)
90+ scfg, hcfg, err := loadHostConfig()
91+ if err != nil {
92+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
93+ return 2
94+ }
95+ st, err := host.LoadState(hcfg.StateDir)
96+ if err != nil {
97+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
98+ return 2
99+ }
100+ url := hcfg.URL
101+ if url == "" {
102+ url = st.URL
103+ }
104+ if url == "" {
105+ fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
106+ return 2
107+ }
108+ drv, err := buildDriver(ctx, scfg)
109+ if err != nil {
110+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
111+ return 1
112+ }
113+ defer func() { _ = drv.Close() }()
114+ log.Info().Str("runner", st.Name).Str("isolation", string(drv.Isolation())).Int("capacity", scfg.Capacity).Msg("runner started")
115+
116+ r := &host.Runner{
117+ Cfg: hcfg,
118+ Driver: drv,
119+ Poll: host.NewClient(url, st.RunnerToken, http.DefaultTransport),
120+ Job: func(tok string) ermiteconnect.RunnerServiceClient {
121+ return host.NewClient(url, tok, http.DefaultTransport)
122+ },
123+ Logf: func(format string, args ...any) { log.Info().Msg(strings.TrimSpace(fmt.Sprintf(format, args...))) },
124+ JobSpec: nil,
125+ }
126+ r.Cfg.URL = url
127+ if err := r.Run(ctx); err != nil {
128+ if errors.Is(err, host.ErrRemoved) {
129+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
130+ return 3
131+ }
132+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
133+ return 1
134+ }
135+ log.Info().Msg("runner stopped")
136+ return 0
137+}
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: MIT
2+
3+package main
4+
5+import (
6+ "context"
7+ "errors"
8+ "flag"
9+ "fmt"
10+ "net/http"
11+ "os"
12+ "strings"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17+
18+ "bnema.dev/ermite-runner/internal/host"
19+ "bnema.dev/ermite-runner/internal/sandbox"
20+ "bnema.dev/ermite-runner/internal/sandbox/drivers"
21+)
22+
23+// loadHostConfig reads the sandbox settings and the host settings together, so
24+// that the state directory and the capacity are the same for both.
25+func loadHostConfig() (sandbox.Config, host.Config, error) {
26+ scfg, err := sandbox.LoadConfig(os.Getenv)
27+ if err != nil {
28+ return scfg, host.Config{}, err
29+ }
30+ name, _ := os.Hostname()
31+ hcfg, err := host.LoadConfig(os.Getenv, name, scfg.StateDir, scfg.Capacity, version)
32+ return scfg, hcfg, err
33+}
34+
35+func buildDriver(ctx context.Context, scfg sandbox.Config) (sandbox.Driver, error) {
36+ return sandbox.Select(ctx, scfg, drivers.Factories(), sandbox.Prober{})
37+}
38+
39+// registerCmd is `ermite-runner register [--token T] [--replace]`: it chooses
40+// the isolation mode like `run` will, registers the runner with it, and stores
41+// the runner token (mode 0600) in the state directory. The register token comes
42+// from --token or ERMITE_RUNNER_REGISTER_TOKEN, so that it need not appear in a
43+// process list.
44+func registerCmd(ctx context.Context, args []string) int {
45+ fs := flag.NewFlagSet("register", flag.ContinueOnError)
46+ token := fs.String("token", "", "token with the runner:register scope (default: $"+host.EnvRegisterToken+")")
47+ replace := fs.Bool("replace", false, "replace a runner of the same name")
48+ if err := fs.Parse(args); err != nil {
49+ return 2
50+ }
51+ if *token == "" {
52+ *token = os.Getenv(host.EnvRegisterToken)
53+ }
54+ scfg, hcfg, err := loadHostConfig()
55+ if err != nil {
56+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
57+ return 2
58+ }
59+ switch {
60+ case hcfg.URL == "":
61+ fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
62+ return 2
63+ case *token == "":
64+ fmt.Fprintf(os.Stderr, "ermite-runner: give a token with --token or %s\n", host.EnvRegisterToken)
65+ return 2
66+ }
67+ drv, err := buildDriver(ctx, scfg)
68+ if err != nil {
69+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
70+ return 1
71+ }
72+ iso := drv.Isolation()
73+ _ = drv.Close()
74+
75+ client := host.NewClient(hcfg.URL, *token, http.DefaultTransport)
76+ st, err := host.Register(ctx, hcfg, client, iso, *replace)
77+ if err != nil {
78+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
79+ return 1
80+ }
81+ fmt.Printf("registered runner %q (%s) with isolation %s; token stored in %s\n", st.Name, st.RunnerID, iso, host.StateFile(hcfg.StateDir))
82+ return 0
83+}
84+
85+// runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its
86+// own sandbox until SIGINT or SIGTERM, then cancel the running jobs with
87+// runner_shutdown and report them.
88+func runCmd(ctx context.Context) int {
89+ log := zerowrap.FromCtx(ctx)
90+ scfg, hcfg, err := loadHostConfig()
91+ if err != nil {
92+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
93+ return 2
94+ }
95+ st, err := host.LoadState(hcfg.StateDir)
96+ if err != nil {
97+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
98+ return 2
99+ }
100+ url := hcfg.URL
101+ if url == "" {
102+ url = st.URL
103+ }
104+ if url == "" {
105+ fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
106+ return 2
107+ }
108+ drv, err := buildDriver(ctx, scfg)
109+ if err != nil {
110+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
111+ return 1
112+ }
113+ defer func() { _ = drv.Close() }()
114+ log.Info().Str("runner", st.Name).Str("isolation", string(drv.Isolation())).Int("capacity", scfg.Capacity).Msg("runner started")
115+
116+ r := &host.Runner{
117+ Cfg: hcfg,
118+ Driver: drv,
119+ Poll: host.NewClient(url, st.RunnerToken, http.DefaultTransport),
120+ Job: func(tok string) ermiteconnect.RunnerServiceClient {
121+ return host.NewClient(url, tok, http.DefaultTransport)
122+ },
123+ Logf: func(format string, args ...any) { log.Info().Msg(strings.TrimSpace(fmt.Sprintf(format, args...))) },
124+ JobSpec: nil,
125+ }
126+ r.Cfg.URL = url
127+ if err := r.Run(ctx); err != nil {
128+ if errors.Is(err, host.ErrRemoved) {
129+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
130+ return 3
131+ }
132+ fmt.Fprintln(os.Stderr, "ermite-runner:", err)
133+ return 1
134+ }
135+ log.Info().Msg("runner stopped")
136+ return 0
137+}
M internal/host/config.go +1 -1
@@ -71,7 +71,7 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
7171 func sanitizeName(s string) string {
7272 b := []byte(s)
7373 for i, ch := range b {
74- if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-') {
74+ if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
7575 b[i] = '-'
7676 }
7777 }
@@ -71,7 +71,7 @@ func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity
71 func sanitizeName(s string) string {71 func sanitizeName(s string) string {
72 b := []byte(s)72 b := []byte(s)
73 for i, ch := range b {73 for i, ch := range b {
74- if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-') {74+ if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
75 b[i] = '-'75 b[i] = '-'
76 }76 }
77 }77 }
M internal/host/fake_test.go +5 -9
@@ -14,8 +14,6 @@ import (
1414 "time"
1515
1616 "connectrpc.com/connect"
17- "golang.org/x/net/http2"
18- "golang.org/x/net/http2/h2c"
1917
2018 ermite "bnema.dev/ermite-api/gen/ermite"
2119 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
@@ -40,10 +38,9 @@ type fakeServer struct {
4038 pollCalls int
4139 retryAfter time.Duration
4240
43- beats []time.Time
44- beatHook func(n int) error
45- registers []*ermite.RegisterRunnerRequest
46- registerTok string
41+ beats []time.Time
42+ beatHook func(n int) error
43+ registers []*ermite.RegisterRunnerRequest
4744
4845 lines []string // log text, joined pieces are not merged: one per line
4946 cont []bool
@@ -283,12 +280,11 @@ func (s *fakeServer) WatchJob(ctx context.Context, r *connect.Request[ermite.Wat
283280 }
284281 }
285282
286-// start serves the handler over h2c on a local port and returns its URL.
283+// start serves the handler over HTTP/1.1 on a local port (Connect streams server messages over it) and returns its URL.
287284 func (s *fakeServer) start(t *testing.T) string {
288285 t.Helper()
289286 _, h := ermiteconnect.NewRunnerServiceHandler(s)
290- srv := httptest.NewUnstartedServer(h2c.NewHandler(h, &http2.Server{}))
291- srv.Start()
287+ srv := httptest.NewServer(h)
292288 t.Cleanup(srv.Close)
293289 return srv.URL
294290 }
@@ -14,8 +14,6 @@ import (
14 "time"14 "time"
15 15
16 "connectrpc.com/connect"16 "connectrpc.com/connect"
17- "golang.org/x/net/http2"
18- "golang.org/x/net/http2/h2c"
19 17
20 ermite "bnema.dev/ermite-api/gen/ermite"18 ermite "bnema.dev/ermite-api/gen/ermite"
21 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"19 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
@@ -40,10 +38,9 @@ type fakeServer struct {
40 pollCalls int38 pollCalls int
41 retryAfter time.Duration39 retryAfter time.Duration
42 40
43- beats []time.Time41+ beats []time.Time
44- beatHook func(n int) error42+ beatHook func(n int) error
45- registers []*ermite.RegisterRunnerRequest43+ registers []*ermite.RegisterRunnerRequest
46- registerTok string
47 44
48 lines []string // log text, joined pieces are not merged: one per line45 lines []string // log text, joined pieces are not merged: one per line
49 cont []bool46 cont []bool
@@ -283,12 +280,11 @@ func (s *fakeServer) WatchJob(ctx context.Context, r *connect.Request[ermite.Wat
283 }280 }
284 }281 }
285 282
286-// start serves the handler over h2c on a local port and returns its URL.283+// start serves the handler over HTTP/1.1 on a local port (Connect streams server messages over it) and returns its URL.
287 func (s *fakeServer) start(t *testing.T) string {284 func (s *fakeServer) start(t *testing.T) string {
288 t.Helper()285 t.Helper()
289 _, h := ermiteconnect.NewRunnerServiceHandler(s)286 _, h := ermiteconnect.NewRunnerServiceHandler(s)
290- srv := httptest.NewUnstartedServer(h2c.NewHandler(h, &http2.Server{}))287+ srv := httptest.NewServer(h)
291- srv.Start()
292 t.Cleanup(srv.Close)288 t.Cleanup(srv.Close)
293 return srv.URL289 return srv.URL
294 }290 }
M internal/host/job.go +1 -1
@@ -551,7 +551,7 @@ func validOutputName(n string) bool {
551551 }
552552 for i, r := range n {
553553 letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_'
554- if !(letter || (i > 0 && (r >= '0' && r <= '9' || r == '-'))) {
554+ if ok := letter || (i > 0 && (r >= '0' && r <= '9' || r == '-')); !ok {
555555 return false
556556 }
557557 }
@@ -551,7 +551,7 @@ func validOutputName(n string) bool {
551 }551 }
552 for i, r := range n {552 for i, r := range n {
553 letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_'553 letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_'
554- if !(letter || (i > 0 && (r >= '0' && r <= '9' || r == '-'))) {554+ if ok := letter || (i > 0 && (r >= '0' && r <= '9' || r == '-')); !ok {
555 return false555 return false
556 }556 }
557 }557 }
M main.go +10 -6
@@ -4,10 +4,10 @@
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.
66 //
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).
7+// Subcommands: version; register stores a runner token obtained from ermite;
8+// run fetches jobs from ermite and runs each in a sandbox; init and exec run
9+// inside a sandbox (guest PID 1, and the executor); sandbox-check runs one stub
10+// script through the selected isolation mode and prints its output.
1111 package main
1212
1313 import (
@@ -49,13 +49,17 @@ func run(args []string) int {
4949 return execCmd(ctx)
5050 case "sandbox-check":
5151 return sandboxCheck(ctx, args[1:])
52+ case "register":
53+ return registerCmd(ctx, args[1:])
54+ case "run":
55+ return runCmd(ctx)
5256 }
5357 usage()
5458 return 2
5559 }
5660
5761 func usage() {
58- fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
62+ fmt.Fprintln(os.Stderr, "usage: ermite-runner version | register [--token T] [--replace] | run | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
5963 }
6064
6165 func envOr(key, def string) string {
@@ -65,7 +69,7 @@ func envOr(key, def string) string {
6569 return def
6670 }
6771
68-// execCmd is `ermite-runner exec`, the stub executor.
72+// execCmd is `ermite-runner exec`, the executor of a sandbox.
6973 func execCmd(ctx context.Context) int {
7074 log := zerowrap.FromCtx(ctx)
7175 target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))
@@ -4,10 +4,10 @@
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 //6 //
7-// Subcommands: version; init and exec run inside a sandbox (guest PID 1, and7+// Subcommands: version; register stores a runner token obtained from ermite;
8-// the executor); sandbox-check runs one stub job through the selected isolation8+// run fetches jobs from ermite and runs each in a sandbox; init and exec run
9-// mode and prints its output (phase D of the CI-1 plan: the host loop that9+// inside a sandbox (guest PID 1, and the executor); sandbox-check runs one stub
10-// fetches jobs from ermite comes later).10+// script through the selected isolation mode and prints its output.
11 package main11 package main
12 12
13 import (13 import (
@@ -49,13 +49,17 @@ func run(args []string) int {
49 return execCmd(ctx)49 return execCmd(ctx)
50 case "sandbox-check":50 case "sandbox-check":
51 return sandboxCheck(ctx, args[1:])51 return sandboxCheck(ctx, args[1:])
52+ case "register":
53+ return registerCmd(ctx, args[1:])
54+ case "run":
55+ return runCmd(ctx)
52 }56 }
53 usage()57 usage()
54 return 258 return 2
55 }59 }
56 60
57 func usage() {61 func usage() {
58- fmt.Fprintln(os.Stderr, "usage: ermite-runner version | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")62+ fmt.Fprintln(os.Stderr, "usage: ermite-runner version | register [--token T] [--replace] | run | sandbox-check [script]\n (inside a sandbox: ermite-runner init | exec)")
59 }63 }
60 64
61 func envOr(key, def string) string {65 func envOr(key, def string) string {
@@ -65,7 +69,7 @@ func envOr(key, def string) string {
65 return def69 return def
66 }70 }
67 71
68-// execCmd is `ermite-runner exec`, the stub executor.72+// execCmd is `ermite-runner exec`, the executor of a sandbox.
69 func execCmd(ctx context.Context) int {73 func execCmd(ctx context.Context) int {
70 log := zerowrap.FromCtx(ctx)74 log := zerowrap.FromCtx(ctx)
71 target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))75 target, err := guest.ParseTarget(os.Getenv("ERMITE_CONNECT"))

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