launch.go
1package runner
2
3import (
4 "context"
5 "fmt"
6 "io/fs"
7 "maps"
8 "os"
9 "os/exec"
10 "os/user"
11 "path/filepath"
12 "slices"
13 "strconv"
14 "strings"
15 "syscall"
16
17 "git.kilimanjaro.io/ci/internal/engine"
18 "git.kilimanjaro.io/ci/internal/lex"
19)
20
21// Segment is one engine process: a fresh run, a manual run starting at an
22// action, or a gate answer.
23type Segment struct {
24 From string // ci runner-exec --from
25 Choose int // 1-based option, 0 for none
26 Dismiss bool
27 Expire bool
28 Actor string
29}
30
31func (s Segment) args() []string {
32 var a []string
33 if s.From != "" {
34 a = append(a, "--from", s.From)
35 }
36 if s.Choose > 0 {
37 a = append(a, "--choose", strconv.Itoa(s.Choose))
38 }
39 if s.Dismiss {
40 a = append(a, "--dismiss")
41 }
42 if s.Expire {
43 a = append(a, "--expire")
44 }
45 if s.Actor != "" {
46 a = append(a, "--actor", s.Actor)
47 }
48 return a
49}
50
51// Job is what a Launcher starts.
52type Job struct {
53 Run string // run id
54 Dir string // run directory
55 Repo string
56 Segment int
57 Args []string // ci runner-exec arguments, without the run dir
58}
59
60// Launcher starts and inspects engine processes.
61type Launcher interface {
62 // Start starts the job and returns a handle for Alive and Stop.
63 Start(ctx context.Context, j Job) (string, error)
64 // Alive reports whether the process behind handle is still running.
65 Alive(handle string) bool
66 // Stop terminates it.
67 Stop(handle string) error
68}
69
70// Local runs `ci runner-exec` as a child process: for development and tests.
71type Local struct {
72 Engine string // path of the ci binary
73 Shell string // devenv or bash
74 LocksDir string
75 Env []string // extra environment
76}
77
78func (l *Local) Start(ctx context.Context, j Job) (string, error) {
79 args := append([]string{"runner-exec", "--shell", orDefault(l.Shell, "devenv"), "--locks-dir", l.LocksDir}, j.Args...)
80 cmd := exec.Command(l.Engine, append(args, j.Dir)...)
81 cmd.Env = append(os.Environ(), l.Env...)
82 logf, err := os.OpenFile(filepath.Join(j.Dir, fmt.Sprintf("segment-%d.log", j.Segment)), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
83 if err != nil {
84 return "", err
85 }
86 cmd.Stdout, cmd.Stderr = logf, logf
87 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
88 if err := cmd.Start(); err != nil {
89 logf.Close()
90 return "", err
91 }
92 go func() { _ = cmd.Wait(); logf.Close() }()
93 return "pid:" + strconv.Itoa(cmd.Process.Pid), nil
94}
95
96func (l *Local) Alive(handle string) bool {
97 pid, err := strconv.Atoi(strings.TrimPrefix(handle, "pid:"))
98 if err != nil {
99 return false
100 }
101 // a zombie (exited, not yet reaped) still answers signal 0
102 if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil {
103 if f := strings.Fields(string(b)); len(f) > 2 && f[2] == "Z" {
104 return false
105 }
106 }
107 return syscall.Kill(pid, 0) == nil
108}
109
110func (l *Local) Stop(handle string) error {
111 pid, err := strconv.Atoi(strings.TrimPrefix(handle, "pid:"))
112 if err != nil {
113 return err
114 }
115 return syscall.Kill(-pid, syscall.SIGTERM)
116}
117
118// Systemd runs each segment as a transient unit in a sandbox (DESIGN.md
119// §10): its own slice, an unprivileged user, a read-only system, a private
120// /tmp, and optionally its own network namespace through pasta. Transient
121// units outlive the runner, so a runner restart (or a self-deploy) doesn't
122// kill a run.
123type Systemd struct {
124 Engine string // path of the ci binary
125 Shell string
126 LocksDir string
127 Slice string // default ci.slice
128 User string // default ci
129 // CacheDir holds per-repo HOMEs (shared state between runs of a repo).
130 CacheDir string
131 // Credentials are loaded into each segment with LoadCredential=
132 // (name -> file, read by systemd as root): age-identity becomes
133 // SECRETS_IDENTITY, ssh-key the segment's SSH identity (runner-exec).
134 Credentials map[string]string
135 // Pasta, if set, is the pasta binary: each segment gets its own network
136 // namespace with egress, so dev services can't clash with the host's.
137 Pasta string
138 // PastaHostPorts are TCP ports on the segment's localhost forwarded to
139 // the host's (e.g. the forge's SSH port, so a localhost clone URL works).
140 PastaHostPorts []int
141 // Properties are extra unit properties (e.g. MemoryMax=8G).
142 Properties []string
143 // Env is extra environment, e.g. CI_CLONE_URL.
144 Env []string
145}
146
147// UnitName is the transient unit of a run's segment.
148func UnitName(run string, segment int) string {
149 return fmt.Sprintf("ci-%s-%d", run, segment)
150}
151
152// Command returns the systemd-run command line for j.
153func (s *Systemd) Command(j Job) []string {
154 home := filepath.Join(s.CacheDir, "home", j.Repo)
155 argv := []string{"systemd-run",
156 "--unit=" + UnitName(j.Run, j.Segment),
157 "--slice=" + orDefault(s.Slice, "ci.slice"),
158 "--uid=" + orDefault(s.User, "ci"),
159 "--gid=" + orDefault(s.User, "ci"),
160 "--collect", "--quiet", "--no-block",
161 "--working-directory=" + j.Dir,
162 "--setenv=HOME=" + home,
163 "--setenv=XDG_CACHE_HOME=" + filepath.Join(home, ".cache"),
164 "-p", "ProtectSystem=strict",
165 "-p", "ProtectHome=yes",
166 "-p", "PrivateTmp=yes",
167 "-p", "NoNewPrivileges=yes",
168 "-p", "ReadWritePaths=" + strings.Join([]string{j.Dir, home, s.LocksDir, "/nix/var/nix/daemon-socket"}, " "),
169 "-p", "StandardOutput=append:" + filepath.Join(j.Dir, fmt.Sprintf("segment-%d.log", j.Segment)),
170 "-p", "StandardError=inherit",
171 }
172 for _, name := range slices.Sorted(maps.Keys(s.Credentials)) {
173 argv = append(argv, "-p", "LoadCredential="+name+":"+s.Credentials[name])
174 }
175 for _, e := range s.Env {
176 argv = append(argv, "--setenv="+e)
177 }
178 for _, p := range s.Properties {
179 argv = append(argv, "-p", p)
180 }
181 argv = append(argv, "--")
182 if s.Pasta != "" {
183 // pasta spawns the engine in new user and network namespaces:
184 // egress through user-mode NAT, nothing forwarded in, the listed
185 // ports of its localhost forwarded to the host's. Caveats: the
186 // engine runs as uid 0 of that user namespace (tools that refuse
187 // root, like postgres' initdb, won't run) and the hostname reads
188 // pasta-<host>. Opt-in; see DESIGN.md §10.
189 argv = append(argv, s.PastaCommand()...)
190 argv = append(argv, "--")
191 }
192 argv = append(argv, s.Engine, "runner-exec", "--shell", orDefault(s.Shell, "devenv"), "--locks-dir", s.LocksDir)
193 argv = append(argv, j.Args...)
194 return append(argv, j.Dir)
195}
196
197func (s *Systemd) Start(ctx context.Context, j Job) (string, error) {
198 home := filepath.Join(s.CacheDir, "home", j.Repo)
199 if err := os.MkdirAll(home, 0o755); err != nil {
200 return "", err
201 }
202 // the runner prepares as root; the segment runs as User and writes the
203 // run dir, its repo's HOME and the locks
204 if err := chownTree(orDefault(s.User, "ci"), j.Dir, home, s.LocksDir); err != nil {
205 return "", err
206 }
207
208 argv := s.Command(j)
209 out, err := exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
210 if err != nil {
211 return "", fmt.Errorf("systemd-run: %v: %s", err, strings.TrimSpace(string(out)))
212 }
213 return "unit:" + UnitName(j.Run, j.Segment), nil
214}
215
216// PastaCommand is pasta's part of a segment's command line.
217func (s *Systemd) PastaCommand() []string {
218 hostPorts := "none"
219 if len(s.PastaHostPorts) > 0 {
220 var ps []string
221 for _, p := range s.PastaHostPorts {
222 ps = append(ps, strconv.Itoa(p))
223 }
224 hostPorts = strings.Join(ps, ",")
225 }
226 return []string{s.Pasta, "--config-net", "--quiet", "-t", "none", "-u", "none", "-T", hostPorts, "-U", "none"}
227}
228
229func (s *Systemd) Alive(handle string) bool {
230 unit := strings.TrimPrefix(handle, "unit:")
231 out, _ := exec.Command("systemctl", "show", "-p", "ActiveState", "--value", unit+".service").Output()
232 st := strings.TrimSpace(string(out))
233 return st == "active" || st == "activating" || st == "deactivating" || st == "reloading"
234}
235
236func (s *Systemd) Stop(handle string) error {
237 return exec.Command("systemctl", "stop", "--no-block", strings.TrimPrefix(handle, "unit:")+".service").Run()
238}
239
240// chownTree hands paths (recursively) to user, unless we aren't root.
241func chownTree(name string, paths ...string) error {
242 if os.Geteuid() != 0 {
243 return nil
244 }
245 u, err := user.Lookup(name)
246 if err != nil {
247 return err
248 }
249 uid, _ := strconv.Atoi(u.Uid)
250 gid, _ := strconv.Atoi(u.Gid)
251 for _, p := range paths {
252 err := filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
253 if err != nil {
254 return err
255 }
256 return os.Lchown(path, uid, gid)
257 })
258 if err != nil {
259 return err
260 }
261 }
262 return nil
263}
264
265func orDefault(s, d string) string {
266 if s == "" {
267 return d
268 }
269 return s
270}
271
272// launch queues a segment of run: it starts as soon as a slot is free, and
273// its events are followed from then on. It returns at once; a segment that
274// can't be started ends the run as lost.
275func (r *Runner) launch(ctx context.Context, run string, seg Segment) error {
276 if _, err := r.GetRun(run); err != nil {
277 return err
278 }
279 ctx = context.WithoutCancel(ctx)
280 go func() {
281 r.slots <- struct{}{}
282 x, err := r.GetRun(run)
283 if err == nil {
284 n := x.segment + 1
285 var handle string
286 handle, err = r.Launcher.Start(ctx, Job{Run: run, Dir: x.Dir, Repo: x.Repo, Segment: n, Args: seg.args()})
287 if err == nil {
288 if err := r.updateRun(run, `segment = ?, handle = ?, status = ?`, n, handle, lex.StatusRunning); err != nil {
289 r.Logger.Error("update run", "run", run, "err", err)
290 }
291 r.Logger.Info("segment started", "run", run, "segment", n, "handle", handle)
292 if n == 1 && x.Parent == "" {
293 r.status(x.Pipeline, x.Workflow, lex.StatusRunning, "")
294 }
295 r.follow(ctx, run, handle) // releases the slot when the segment ends
296 return
297 }
298 }
299 r.Logger.Error("start segment", "run", run, "err", err)
300 if x.Dir != "" {
301 _ = engine.MarkLost(engine.Dir(x.Dir), "could not start the run: "+err.Error())
302 r.follow(ctx, run, "") // settles it as failed and releases the slot
303 return
304 }
305 <-r.slots
306 }()
307 return nil
308}