feat(host): register and run subcommands, README, lint fixes
- commit
- b70b2119a868352b44b5eef1f3c251a50feddcc5
- author
- brice · · 2026-10-12 18:44 UTC
- parent
- 5d5c671
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 48 | 48 | ||
| 49 | 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 | 86 | ## Configuration (sandbox part) | |
| 52 | 87 | ||
| 53 | 88 | | 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 ( | |||
| 11 | 11 | github.com/google/uuid v1.6.0 | |
| 12 | 12 | github.com/moby/moby/api v1.56.1 | |
| 13 | 13 | github.com/moby/moby/client v0.6.2 | |
| 14 | - | golang.org/x/net v0.58.0 | |
| 15 | 14 | golang.org/x/sys v0.49.0 | |
| 16 | 15 | google.golang.org/protobuf v1.36.12 | |
| 17 | 16 | ) | |
| @@ -91,6 +90,7 @@ require ( | |||
| 91 | 90 | golang.org/x/crypto v0.56.0 // indirect | |
| 92 | 91 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | |
| 93 | 92 | golang.org/x/mod v0.40.0 // indirect | |
| 93 | + | golang.org/x/net v0.58.0 // indirect | |
| 94 | 94 | golang.org/x/sync v0.23.0 // indirect | |
| 95 | 95 | golang.org/x/term v0.45.0 // indirect | |
| 96 | 96 | golang.org/x/text v0.41.0 // indirect | |
| @@ -11,7 +11,6 @@ require ( | |||||
| 11 | github.com/google/uuid v1.6.0 | 11 | github.com/google/uuid v1.6.0 | ||
| 12 | github.com/moby/moby/api v1.56.1 | 12 | github.com/moby/moby/api v1.56.1 | ||
| 13 | github.com/moby/moby/client v0.6.2 | 13 | github.com/moby/moby/client v0.6.2 | ||
| 14 | - | golang.org/x/net v0.58.0 | |||
| 15 | golang.org/x/sys v0.49.0 | 14 | golang.org/x/sys v0.49.0 | ||
| 16 | google.golang.org/protobuf v1.36.12 | 15 | google.golang.org/protobuf v1.36.12 | ||
| 17 | ) | 16 | ) | ||
| @@ -91,6 +90,7 @@ require ( | |||||
| 91 | golang.org/x/crypto v0.56.0 // indirect | 90 | golang.org/x/crypto v0.56.0 // indirect | ||
| 92 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | 91 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | ||
| 93 | golang.org/x/mod v0.40.0 // indirect | 92 | 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 // indirect | 94 | golang.org/x/sync v0.23.0 // indirect | ||
| 95 | golang.org/x/term v0.45.0 // indirect | 95 | golang.org/x/term v0.45.0 // indirect | ||
| 96 | golang.org/x/text v0.41.0 // indirect | 96 | 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 | |||
| 71 | 71 | func sanitizeName(s string) string { | |
| 72 | 72 | b := []byte(s) | |
| 73 | 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 | 75 | b[i] = '-' | |
| 76 | 76 | } | |
| 77 | 77 | } | |
| @@ -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 ( | |||
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | "connectrpc.com/connect" | |
| 17 | - | "golang.org/x/net/http2" | |
| 18 | - | "golang.org/x/net/http2/h2c" | |
| 19 | 17 | ||
| 20 | 18 | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 21 | 19 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| @@ -40,10 +38,9 @@ type fakeServer struct { | |||
| 40 | 38 | pollCalls int | |
| 41 | 39 | retryAfter time.Duration | |
| 42 | 40 | ||
| 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 | |
| 47 | 44 | ||
| 48 | 45 | lines []string // log text, joined pieces are not merged: one per line | |
| 49 | 46 | 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 | 284 | func (s *fakeServer) start(t *testing.T) string { | |
| 288 | 285 | t.Helper() | |
| 289 | 286 | _, h := ermiteconnect.NewRunnerServiceHandler(s) | |
| 290 | - | srv := httptest.NewUnstartedServer(h2c.NewHandler(h, &http2.Server{})) | |
| 291 | - | srv.Start() | |
| 287 | + | srv := httptest.NewServer(h) | |
| 292 | 288 | t.Cleanup(srv.Close) | |
| 293 | 289 | return srv.URL | |
| 294 | 290 | } | |
| @@ -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 int | 38 | pollCalls int | ||
| 41 | retryAfter time.Duration | 39 | retryAfter time.Duration | ||
| 42 | 40 | ||||
| 43 | - | beats []time.Time | 41 | + | beats []time.Time |
| 44 | - | beatHook func(n int) error | 42 | + | beatHook func(n int) error |
| 45 | - | registers []*ermite.RegisterRunnerRequest | 43 | + | registers []*ermite.RegisterRunnerRequest |
| 46 | - | registerTok string | |||
| 47 | 44 | ||||
| 48 | lines []string // log text, joined pieces are not merged: one per line | 45 | lines []string // log text, joined pieces are not merged: one per line | ||
| 49 | cont []bool | 46 | 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.URL | 289 | return srv.URL | ||
| 294 | } | 290 | } | ||
M internal/host/job.go +1 -1
| @@ -551,7 +551,7 @@ func validOutputName(n string) bool { | |||
| 551 | 551 | } | |
| 552 | 552 | for i, r := range n { | |
| 553 | 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 | 555 | return false | |
| 556 | 556 | } | |
| 557 | 557 | } | |
| @@ -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 false | 555 | return false | ||
| 556 | } | 556 | } | ||
| 557 | } | 557 | } | ||
M main.go +10 -6
| @@ -4,10 +4,10 @@ | |||
| 4 | 4 | // its own Firecracker micro-VM, or in its own container when the host has no | |
| 5 | 5 | // KVM, and executes workflows compatible with GitHub Actions. | |
| 6 | 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). | |
| 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. | |
| 11 | 11 | package main | |
| 12 | 12 | ||
| 13 | 13 | import ( | |
| @@ -49,13 +49,17 @@ func run(args []string) int { | |||
| 49 | 49 | return execCmd(ctx) | |
| 50 | 50 | case "sandbox-check": | |
| 51 | 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 | 57 | usage() | |
| 54 | 58 | return 2 | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 57 | 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 | 65 | func envOr(key, def string) string { | |
| @@ -65,7 +69,7 @@ func envOr(key, def string) string { | |||
| 65 | 69 | 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 | 73 | func execCmd(ctx context.Context) int { | |
| 70 | 74 | log := zerowrap.FromCtx(ctx) | |
| 71 | 75 | 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 no | 4 | // 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, and | 7 | + | // Subcommands: version; register stores a runner token obtained from ermite; |
| 8 | - | // the executor); sandbox-check runs one stub job through the selected isolation | 8 | + | // 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 that | 9 | + | // 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 main | 11 | 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 2 | 58 | 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 def | 69 | 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")) | ||