v0.6.0 aba298d

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

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