1// SPDX-License-Identifier: MIT
2
3package main
4
5import (
6 "context"
7 "errors"
8 "flag"
9 "fmt"
10 "net"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19
20 "bnema.dev/ermite-runner/internal/cachesvc"
21 "bnema.dev/ermite-runner/internal/externals"
22 "bnema.dev/ermite-runner/internal/host"
23 "bnema.dev/ermite-runner/internal/sandbox"
24 "bnema.dev/ermite-runner/internal/sandbox/drivers"
25)
26
27// loadHostConfig reads the sandbox settings and the host settings together, so
28// that the state directory and the capacity are the same for both.
29func loadHostConfig() (sandbox.Config, host.Config, error) {
30 scfg, err := sandbox.LoadConfig(os.Getenv)
31 if err != nil {
32 return scfg, host.Config{}, err
33 }
34 name, _ := os.Hostname()
35 hcfg, err := host.LoadConfig(os.Getenv, name, scfg.StateDir, scfg.Capacity, version)
36 return scfg, hcfg, err
37}
38
39// setupCache starts the cache service of the runner (README, "Cache") and hands
40// it to the sandbox drivers through scfg. ERMITE_RUNNER_CACHE=0 turns it off;
41// ERMITE_RUNNER_CACHE_MAX is the size cap (default 10 GiB).
42func setupCache(ctx context.Context, scfg *sandbox.Config) (*cachesvc.Service, error) {
43 log := zerowrap.FromCtx(ctx)
44 switch strings.ToLower(strings.TrimSpace(os.Getenv(envCache))) {
45 case "", "1", "true", "yes":
46 case "0", "false", "no":
47 log.Info().Msg("the cache service is off (" + envCache + "=0)")
48 return nil, nil
49 default:
50 return nil, fmt.Errorf("%s: %q is not 0 or 1", envCache, os.Getenv(envCache))
51 }
52 max, err := cachesvc.ParseMax(os.Getenv(cachesvc.EnvMax))
53 if err != nil {
54 return nil, err
55 }
56 svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(scfg.StateDir, "cache"), Max: max, Log: log})
57 if err != nil {
58 return nil, err
59 }
60 scfg.Cache = svc
61 log.Info().Int64("max_bytes", max).Msg("cache service on (guest loopback only, no host port)")
62 return svc, nil
63}
64
65// envCache=0 turns the cache service off.
66const envCache = "ERMITE_RUNNER_CACHE"
67
68// externalsWait is how long the start of the runner waits for the Node.js
69// downloads. A runner with a working network is then ready for its first job; one
70// without goes on after the first failure (nothing waits for a dead network), and
71// the downloads that are still running when the time is up finish in the
72// background.
73const externalsWait = 2 * time.Minute
74
75// setupExternals downloads the Node.js externals (README, "Externals") and hands
76// them to the sandbox drivers through scfg. It never fails the start: whatever
77// cannot be provided is reported to the jobs that need it.
78func setupExternals(ctx context.Context, scfg *sandbox.Config) {
79 log := zerowrap.FromCtx(ctx)
80 if !scfg.NodeExternals {
81 log.Info().Msg("Node.js externals are off (" + sandbox.EnvNode + "=0)")
82 return
83 }
84 m, ok := externals.New(scfg.StateDir)
85 if !ok {
86 log.Warn().Msg("no Node.js builds for this architecture: jobs get no externals")
87 return
88 }
89 m.Start(ctx, externalsWait)
90 scfg.Externals = m
91}
92
93func buildDriver(ctx context.Context, scfg sandbox.Config) (sandbox.Driver, error) {
94 return sandbox.Select(ctx, scfg, drivers.Factories(), sandbox.Prober{})
95}
96
97// registerCmd is `ermite-runner register [--token-stdin] [--token T] [--replace]`:
98// it chooses the isolation mode like `run` will, registers the runner with it,
99// and stores the runner token (mode 0600) in the state directory. The register
100// token is read from standard input (--token-stdin) or from
101// ERMITE_RUNNER_REGISTER_TOKEN; --token is accepted but the arguments of a
102// process are visible to every user of the host.
103func registerCmd(ctx context.Context, args []string) int {
104 fs := flag.NewFlagSet("register", flag.ContinueOnError)
105 token := fs.String("token", "", "token with the runner:register scope (visible in the process list: prefer $"+host.EnvRegisterToken+" or --token-stdin)")
106 fromStdin := fs.Bool("token-stdin", false, "read the token from standard input")
107 replace := fs.Bool("replace", false, "replace a runner of the same name")
108 if err := fs.Parse(args); err != nil {
109 return 2
110 }
111 tok, err := host.ResolveRegisterToken(*token, *fromStdin, os.Stdin, os.Getenv)
112 if err != nil {
113 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
114 return 2
115 }
116 *token = tok
117 scfg, hcfg, err := loadHostConfig()
118 if err != nil {
119 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
120 return 2
121 }
122 if hcfg.URL == "" {
123 fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
124 return 2
125 }
126 drv, err := buildDriver(ctx, scfg)
127 if err != nil {
128 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
129 return 1
130 }
131 iso := drv.Isolation()
132 _ = drv.Close()
133
134 client := hcfg.Client(hcfg.URL, *token)
135 st, err := host.Register(ctx, hcfg, client, iso, *replace)
136 if err != nil {
137 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
138 return 1
139 }
140 fmt.Printf("registered runner %q (%s) with isolation %s; token stored in %s\n", st.Name, st.RunnerID, iso, host.StateFile(hcfg.StateDir))
141 return 0
142}
143
144// runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its
145// own sandbox until SIGINT or SIGTERM, then cancel the running jobs with
146// runner_shutdown and report them.
147func runCmd(ctx context.Context) int {
148 log := zerowrap.FromCtx(ctx)
149 scfg, hcfg, err := loadHostConfig()
150 if err != nil {
151 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
152 return 2
153 }
154 st, err := host.LoadState(hcfg.StateDir)
155 if err != nil {
156 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
157 return 2
158 }
159 url := hcfg.URL
160 if url == "" {
161 url = st.URL
162 }
163 if url == "" {
164 fmt.Fprintf(os.Stderr, "ermite-runner: %s is not set\n", host.EnvURL)
165 return 2
166 }
167 if scfg.GuestForgeEnabled {
168 // The sandboxes reach the forge the way the runner does: by the host name
169 // and port of the URL the runner itself uses.
170 gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost)
171 if err != nil {
172 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
173 return 2
174 }
175 scfg.GuestForge = gf
176 hcfg.GuestForgeOrigin = gf.Origin
177 }
178 setupExternals(ctx, &scfg)
179 cache, err := setupCache(ctx, &scfg)
180 if err != nil {
181 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
182 return 2
183 }
184 if cache != nil {
185 defer func() { _ = cache.CloseAll() }()
186 }
187 drv, err := buildDriver(ctx, scfg)
188 if err != nil {
189 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
190 return 1
191 }
192 defer func() { _ = drv.Close() }()
193 log.Info().Str("runner", st.Name).Str("isolation", string(drv.Isolation())).Int("capacity", scfg.Capacity).Msg("runner started")
194
195 r := &host.Runner{
196 Cfg: hcfg,
197 Driver: drv,
198 Poll: hcfg.Client(url, st.RunnerToken),
199 Job: func(tok string) ermiteconnect.RunnerServiceClient {
200 return hcfg.Client(url, tok)
201 },
202 Features: hcfg.ServerFeaturesFunc(url),
203 Cache: cache,
204 }
205 r.Cfg.URL = url
206 if err := r.Run(ctx); err != nil {
207 if errors.Is(err, host.ErrRemoved) {
208 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
209 return 3
210 }
211 fmt.Fprintln(os.Stderr, "ermite-runner:", err)
212 return 1
213 }
214 log.Info().Msg("runner stopped")
215 return 0
216}