1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15
16 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17)
18
19// Environment variables of the host process. The sandbox settings
20// (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
21// sandbox.
22const (
23 EnvURL = "ERMITE_RUNNER_URL"
24 EnvName = "ERMITE_RUNNER_NAME"
25 EnvLabels = "ERMITE_RUNNER_LABELS"
26 // EnvEnvironment is the runner.environment value that jobs see: see
27 // Config.Environment.
28 EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
29 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
30 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
31 // runner token and the job tokens then travel in clear: tests only.
32 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
33)
34
35// The values of Config.Environment, as GitHub spells them.
36const (
37 EnvGitHubHosted = "github-hosted"
38 EnvSelfHosted = "self-hosted"
39)
40
41// Config is the host part of the runner configuration.
42type Config struct {
43 // URL is the base URL of the ermite server.
44 URL string
45 // Name is the runner name: 1 to 64 letters, digits, ".", "_" and "-".
46 Name string
47 // Labels are the extra labels of the runner.
48 Labels []string
49 // StateDir holds the runner token (mode 0600).
50 StateDir string
51 // Capacity is the number of jobs in parallel.
52 Capacity int
53 // Version is the version of the runner binary.
54 Version string
55 // Environment is the "runner.environment" value of the jobs and what decides
56 // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57 // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58 // GitHub-hosted ones: workflows written for github.com test
59 // runner.environment == 'github-hosted' and the recordings of github.com
60 // (ermite-conformance) carry that value, so it is the default; an operator
61 // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62 Environment string
63 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
64 InsecureHTTP bool
65}
66
67// Client returns the client for a token, honouring InsecureHTTP.
68func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
69 if c.InsecureHTTP {
70 return NewInsecureClient(url, token, http.DefaultTransport)
71 }
72 return NewClient(url, token, http.DefaultTransport)
73}
74
75// environment is Environment, the default when a Config was built without
76// LoadConfig.
77func (c Config) environment() string {
78 if c.Environment == "" {
79 return EnvGitHubHosted
80 }
81 return c.Environment
82}
83
84var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
85
86// LoadConfig reads the host settings. hostname is the default runner name.
87// stateDir and capacity come from the sandbox configuration, so that both
88// agree.
89func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity int, version string) (Config, error) {
90 c := Config{URL: strings.TrimSpace(getenv(EnvURL)), Name: strings.TrimSpace(getenv(EnvName)), StateDir: stateDir, Capacity: capacity, Version: version}
91 if c.Name == "" {
92 c.Name = sanitizeName(hostname)
93 }
94 switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
95 case "":
96 c.Environment = EnvGitHubHosted
97 case EnvGitHubHosted, EnvSelfHosted:
98 c.Environment = v
99 default:
100 return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
101 }
102 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
103 case "", "0", "false", "no":
104 case "1", "true", "yes":
105 c.InsecureHTTP = true
106 default:
107 return c, fmt.Errorf("%s: %q is not 0 or 1", EnvInsecureHTTP, getenv(EnvInsecureHTTP))
108 }
109 if c.URL != "" {
110 if err := CheckURL(c.URL, c.InsecureHTTP); err != nil {
111 return c, fmt.Errorf("%s: %w", EnvURL, err)
112 }
113 }
114 if !nameRe.MatchString(c.Name) {
115 return c, fmt.Errorf("%s: %q is not a valid runner name (1 to 64 letters, digits, '.', '_' and '-')", EnvName, c.Name)
116 }
117 seen := map[string]bool{}
118 for _, l := range strings.Split(getenv(EnvLabels), ",") {
119 l = strings.TrimSpace(l)
120 if l == "" {
121 continue
122 }
123 if !nameRe.MatchString(l) {
124 return c, fmt.Errorf("%s: %q is not a valid label (1 to 64 letters, digits, '.', '_' and '-')", EnvLabels, l)
125 }
126 if k := strings.ToLower(l); !seen[k] {
127 seen[k] = true
128 c.Labels = append(c.Labels, l)
129 }
130 }
131 return c, nil
132}
133
134func sanitizeName(s string) string {
135 b := []byte(s)
136 for i, ch := range b {
137 if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
138 b[i] = '-'
139 }
140 }
141 if len(b) > 64 {
142 b = b[:64]
143 }
144 if len(b) == 0 {
145 return "runner"
146 }
147 return string(b)
148}
149
150// State is what register stores: the runner token and who it belongs to. The
151// file is mode 0600 and the token is never logged.
152type State struct {
153 URL string `json:"url"`
154 RunnerID string `json:"runner_id"`
155 Name string `json:"name"`
156 RunnerToken string `json:"runner_token"`
157}
158
159// StateFile is the path of the state file in dir.
160func StateFile(dir string) string { return filepath.Join(dir, "runner.json") }
161
162// ErrNotRegistered is returned by LoadState when no runner is registered.
163var ErrNotRegistered = errors.New("this runner is not registered: run `ermite-runner register` first")
164
165// SaveState writes the state atomically with mode 0600.
166func SaveState(dir string, s State) error {
167 if err := os.MkdirAll(dir, 0o700); err != nil {
168 return err
169 }
170 // MkdirAll leaves an existing directory as it is; the directory holds the
171 // runner token, so it is made private (the VM images live in it too, and
172 // the runner runs as root: nobody else needs to enter).
173 if err := os.Chmod(dir, 0o700); err != nil {
174 return fmt.Errorf("make the state directory private: %w", err)
175 }
176 b, err := json.MarshalIndent(s, "", " ")
177 if err != nil {
178 return err
179 }
180 tmp, err := os.CreateTemp(dir, ".runner-*.json")
181 if err != nil {
182 return err
183 }
184 defer func() { _ = os.Remove(tmp.Name()) }()
185 if err := tmp.Chmod(0o600); err != nil {
186 _ = tmp.Close()
187 return err
188 }
189 if _, err := tmp.Write(append(b, '\n')); err != nil {
190 _ = tmp.Close()
191 return err
192 }
193 if err := tmp.Sync(); err != nil {
194 _ = tmp.Close()
195 return err
196 }
197 if err := tmp.Close(); err != nil {
198 return err
199 }
200 return os.Rename(tmp.Name(), StateFile(dir))
201}
202
203// LoadState reads the state; it refuses a file readable by others, because the
204// runner token is a secret.
205func LoadState(dir string) (State, error) {
206 p := StateFile(dir)
207 st, err := os.Stat(p)
208 if errors.Is(err, os.ErrNotExist) {
209 return State{}, ErrNotRegistered
210 }
211 if err != nil {
212 return State{}, err
213 }
214 if st.Mode().Perm()&0o077 != 0 {
215 return State{}, fmt.Errorf("%s is accessible by other users (mode %o): chmod 600 it", p, st.Mode().Perm())
216 }
217 b, err := os.ReadFile(p)
218 if err != nil {
219 return State{}, err
220 }
221 var s State
222 if err := json.Unmarshal(b, &s); err != nil {
223 return State{}, fmt.Errorf("%s: %w", p, err)
224 }
225 if s.RunnerToken == "" {
226 return State{}, fmt.Errorf("%s has no runner token", p)
227 }
228 return s, nil
229}
230
231// ResolveRegisterToken returns the token for `register`, from the first source
232// that has one: standard input (--token-stdin, read to the end and trimmed),
233// the environment (ERMITE_RUNNER_REGISTER_TOKEN), then the --token flag. The
234// flag is last because the arguments of a process are visible to every user of
235// the host (ps, /proc/<pid>/cmdline); the other two are not.
236func ResolveRegisterToken(flagValue string, fromStdin bool, stdin io.Reader, getenv func(string) string) (string, error) {
237 if fromStdin {
238 b, err := io.ReadAll(io.LimitReader(stdin, 4096))
239 if err != nil {
240 return "", fmt.Errorf("reading the token from standard input: %w", err)
241 }
242 if t := strings.TrimSpace(string(b)); t != "" {
243 return t, nil
244 }
245 return "", errors.New("standard input has no token")
246 }
247 if t := strings.TrimSpace(getenv(EnvRegisterToken)); t != "" {
248 return t, nil
249 }
250 if t := strings.TrimSpace(flagValue); t != "" {
251 return t, nil
252 }
253 return "", fmt.Errorf("give the token in %s or on standard input (--token-stdin); --token works too but is visible in the process list", EnvRegisterToken)
254}