v0.1.0 cbb8ad6

config.go RawHistoryFolder

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}

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