v0.2.0 43088fd

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	// GuestForgeOrigin is the origin (scheme://host[:port]) of ERMITE_RUNNER_URL
66	// when ERMITE_RUNNER_GUEST_FORGE=1, "" otherwise. The server URLs of every
67	// job are rewritten to it, see RewriteServerURLs. Set by the caller after
68	// sandbox.ResolveGuestForge.
69	GuestForgeOrigin string
70}
71
72// Client returns the client for a token, honouring InsecureHTTP.
73func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
74	if c.InsecureHTTP {
75		return NewInsecureClient(url, token, http.DefaultTransport)
76	}
77	return NewClient(url, token, http.DefaultTransport)
78}
79
80// environment is Environment, the default when a Config was built without
81// LoadConfig.
82func (c Config) environment() string {
83	if c.Environment == "" {
84		return EnvGitHubHosted
85	}
86	return c.Environment
87}
88
89var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
90
91// LoadConfig reads the host settings. hostname is the default runner name.
92// stateDir and capacity come from the sandbox configuration, so that both
93// agree.
94func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity int, version string) (Config, error) {
95	c := Config{URL: strings.TrimSpace(getenv(EnvURL)), Name: strings.TrimSpace(getenv(EnvName)), StateDir: stateDir, Capacity: capacity, Version: version}
96	if c.Name == "" {
97		c.Name = sanitizeName(hostname)
98	}
99	switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
100	case "":
101		c.Environment = EnvGitHubHosted
102	case EnvGitHubHosted, EnvSelfHosted:
103		c.Environment = v
104	default:
105		return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
106	}
107	switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
108	case "", "0", "false", "no":
109	case "1", "true", "yes":
110		c.InsecureHTTP = true
111	default:
112		return c, fmt.Errorf("%s: %q is not 0 or 1", EnvInsecureHTTP, getenv(EnvInsecureHTTP))
113	}
114	if c.URL != "" {
115		if err := CheckURL(c.URL, c.InsecureHTTP); err != nil {
116			return c, fmt.Errorf("%s: %w", EnvURL, err)
117		}
118	}
119	if !nameRe.MatchString(c.Name) {
120		return c, fmt.Errorf("%s: %q is not a valid runner name (1 to 64 letters, digits, '.', '_' and '-')", EnvName, c.Name)
121	}
122	seen := map[string]bool{}
123	for _, l := range strings.Split(getenv(EnvLabels), ",") {
124		l = strings.TrimSpace(l)
125		if l == "" {
126			continue
127		}
128		if !nameRe.MatchString(l) {
129			return c, fmt.Errorf("%s: %q is not a valid label (1 to 64 letters, digits, '.', '_' and '-')", EnvLabels, l)
130		}
131		if k := strings.ToLower(l); !seen[k] {
132			seen[k] = true
133			c.Labels = append(c.Labels, l)
134		}
135	}
136	return c, nil
137}
138
139func sanitizeName(s string) string {
140	b := []byte(s)
141	for i, ch := range b {
142		if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
143			b[i] = '-'
144		}
145	}
146	if len(b) > 64 {
147		b = b[:64]
148	}
149	if len(b) == 0 {
150		return "runner"
151	}
152	return string(b)
153}
154
155// State is what register stores: the runner token and who it belongs to. The
156// file is mode 0600 and the token is never logged.
157type State struct {
158	URL         string `json:"url"`
159	RunnerID    string `json:"runner_id"`
160	Name        string `json:"name"`
161	RunnerToken string `json:"runner_token"`
162}
163
164// StateFile is the path of the state file in dir.
165func StateFile(dir string) string { return filepath.Join(dir, "runner.json") }
166
167// ErrNotRegistered is returned by LoadState when no runner is registered.
168var ErrNotRegistered = errors.New("this runner is not registered: run `ermite-runner register` first")
169
170// SaveState writes the state atomically with mode 0600.
171func SaveState(dir string, s State) error {
172	if err := os.MkdirAll(dir, 0o700); err != nil {
173		return err
174	}
175	// MkdirAll leaves an existing directory as it is; the directory holds the
176	// runner token, so it is made private (the VM images live in it too, and
177	// the runner runs as root: nobody else needs to enter).
178	if err := os.Chmod(dir, 0o700); err != nil {
179		return fmt.Errorf("make the state directory private: %w", err)
180	}
181	b, err := json.MarshalIndent(s, "", "  ")
182	if err != nil {
183		return err
184	}
185	tmp, err := os.CreateTemp(dir, ".runner-*.json")
186	if err != nil {
187		return err
188	}
189	defer func() { _ = os.Remove(tmp.Name()) }()
190	if err := tmp.Chmod(0o600); err != nil {
191		_ = tmp.Close()
192		return err
193	}
194	if _, err := tmp.Write(append(b, '\n')); err != nil {
195		_ = tmp.Close()
196		return err
197	}
198	if err := tmp.Sync(); err != nil {
199		_ = tmp.Close()
200		return err
201	}
202	if err := tmp.Close(); err != nil {
203		return err
204	}
205	return os.Rename(tmp.Name(), StateFile(dir))
206}
207
208// LoadState reads the state; it refuses a file readable by others, because the
209// runner token is a secret.
210func LoadState(dir string) (State, error) {
211	p := StateFile(dir)
212	st, err := os.Stat(p)
213	if errors.Is(err, os.ErrNotExist) {
214		return State{}, ErrNotRegistered
215	}
216	if err != nil {
217		return State{}, err
218	}
219	if st.Mode().Perm()&0o077 != 0 {
220		return State{}, fmt.Errorf("%s is accessible by other users (mode %o): chmod 600 it", p, st.Mode().Perm())
221	}
222	b, err := os.ReadFile(p)
223	if err != nil {
224		return State{}, err
225	}
226	var s State
227	if err := json.Unmarshal(b, &s); err != nil {
228		return State{}, fmt.Errorf("%s: %w", p, err)
229	}
230	if s.RunnerToken == "" {
231		return State{}, fmt.Errorf("%s has no runner token", p)
232	}
233	return s, nil
234}
235
236// ResolveRegisterToken returns the token for `register`, from the first source
237// that has one: standard input (--token-stdin, read to the end and trimmed),
238// the environment (ERMITE_RUNNER_REGISTER_TOKEN), then the --token flag. The
239// flag is last because the arguments of a process are visible to every user of
240// the host (ps, /proc/<pid>/cmdline); the other two are not.
241func ResolveRegisterToken(flagValue string, fromStdin bool, stdin io.Reader, getenv func(string) string) (string, error) {
242	if fromStdin {
243		b, err := io.ReadAll(io.LimitReader(stdin, 4096))
244		if err != nil {
245			return "", fmt.Errorf("reading the token from standard input: %w", err)
246		}
247		if t := strings.TrimSpace(string(b)); t != "" {
248			return t, nil
249		}
250		return "", errors.New("standard input has no token")
251	}
252	if t := strings.TrimSpace(getenv(EnvRegisterToken)); t != "" {
253		return t, nil
254	}
255	if t := strings.TrimSpace(flagValue); t != "" {
256		return t, nil
257	}
258	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)
259}

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