aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/outcome/process.go
blob: 614b22fc94fad38a494f9a0ad08d4db8423cc3ca (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
package outcome

import (
	"context"
	"encoding/gob"
	"errors"
	"math"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"syscall"
	"time"

	"hakurei.app/check"
	"hakurei.app/fhs"
	"hakurei.app/hst"
	"hakurei.app/internal/info"
	"hakurei.app/internal/store"
	"hakurei.app/internal/system"
	"hakurei.app/message"
)

const (
	// Duration to wait for shim to exit on top of container WaitDelay.
	shimWaitTimeout = 5 * time.Second
	// Timeout for writing outcomeState to the shim setup pipe.
	shimSetupTimeout = 5 * time.Second
)

// NewStore returns the address of a new instance of [store.Store].
func NewStore(sc *hst.Paths) *store.Store {
	return store.New(sc.SharePath.Append("state"))
}

// main carries out outcome and terminates. main does not return.
func (k *outcome) main(msg message.Msg, identifierFd int) {
	if k.ctx == nil || k.sys == nil || k.state == nil {
		panic("outcome: did not finalise")
	}

	// read comp value early for early failure
	hsuPath := info.MustHsuPath()

	const (
		// transitions to processCommit, or processFinal on failure
		processStart = iota
		// transitions to processServe, or processLifecycle on failure
		processCommit
		// transitions to processLifecycle only
		processServe
		// transitions to processCleanup only
		processLifecycle
		// transitions to processFinal only
		processCleanup
		// execution terminates, must be the final state
		processFinal
	)

	// for the shim process
	ctx, cancel := context.WithCancel(k.ctx)
	defer cancel()

	var (
		// state for next iteration
		processState uintptr = processStart
		// current state, must not be mutated directly
		processStateCur uintptr = math.MaxUint
		// point in time the current iteration began
		processTime time.Time

		// whether sys is currently in between a call to Commit and Revert
		isBeforeRevert bool

		// initialised during processStart if successful
		handle *store.Handle
		// initialised during processServe if state is saved
		entryHandle *store.EntryHandle

		// can be set in any state, used in processFinal
		exitCode int

		// shim process startup time,
		// populated in processStart, accessed by processServe
		startTime time.Time
		// shim process as target uid,
		// populated in processStart, accessed by processServe
		shimCmd *exec.Cmd
		// write end of shim setup pipe,
		// populated in processStart, accessed by processServe
		shimPipe *os.File

		// perror cancels ctx and prints an error message
		perror = func(err error, message string) {
			cancel()
			if shimPipe != nil {
				if closeErr := shimPipe.Close(); closeErr != nil {
					msg.Verbose(closeErr.Error())
				}
				shimPipe = nil
			}
			if exitCode == 0 {
				exitCode = 1
			}
			printMessageError(msg.GetLogger().Println, "cannot "+message+":", err)
		}

		// perrorFatal cancels ctx, prints an error message, and sets the next state
		perrorFatal = func(err error, message string, newState uintptr) {
			perror(err, message)
			processState = newState
		}
	)

	for {
		var processStatePrev uintptr
		processStatePrev, processStateCur = processStateCur, processState

		if !processTime.IsZero() && processStatePrev != processLifecycle {
			msg.Verbosef(
				"state %d took %.2f ms",
				processStatePrev,
				float64(time.Since(processTime).Nanoseconds())/1e6,
			)
		}
		processTime = time.Now()

		switch processState {
		case processStart:
			if h, err := NewStore(&k.state.sc).Handle(k.state.identity.unwrap()); err != nil {
				perrorFatal(err, "obtain store segment handle", processFinal)
				continue
			} else {
				handle = h
			}

			cmd, f, err := k.start(ctx, msg, hsuPath, &startTime)
			if err != nil {
				perrorFatal(err, "start shim", processFinal)
				continue
			} else {
				shimCmd, shimPipe = cmd, f
			}

			processState = processCommit

		case processCommit:
			if isBeforeRevert {
				perrorFatal(
					newWithMessage("invalid transition to commit state"),
					"commit", processLifecycle,
				)
				continue
			}

			unlock, err := handle.Lock()
			if err != nil {
				perrorFatal(err, "acquire lock on store segment", processLifecycle)
				continue
			}
			if entryHandle, err = handle.Save(&hst.State{
				ID:      k.state.id.unwrap(),
				PID:     os.Getpid(),
				ShimPID: shimCmd.Process.Pid,
				Config:  k.config,
				Time:    startTime,
			}); err != nil {
				unlock()
				// transition here to avoid the commit/revert cycle on the doomed instance
				perrorFatal(err, "save instance state", processLifecycle)
				continue
			}

			if f := os.NewFile(uintptr(identifierFd), "identifier"); f != nil {
				_, err = f.Write(k.state.id.v[:])
				if err != nil {
					unlock()
					// transition here to avoid the commit/revert cycle on the doomed instance
					perrorFatal(&hst.AppError{Step: "write instance identifier", Err: err},
						"write instance identifier", processLifecycle)
					continue
				}
				msg.Verbosef("wrote identifier to %d", identifierFd)
				if err = f.Close(); err != nil {
					msg.Verbose(err.Error())
				}
			}

			err = k.sys.Commit()
			unlock()
			if err != nil {
				perrorFatal(err, "commit system setup", processLifecycle)
				continue
			}
			isBeforeRevert = true

			processState = processServe

		case processServe:
			// this state transition to processLifecycle only
			processState = processLifecycle

			// this starts the container, system setup must complete before this point
			if err := serveShim(msg, shimPipe, k.state); err != nil {
				perror(err, "serve shim payload")
				continue
			} else {
				shimPipe = nil // this is already closed by serveShim
			}

		case processLifecycle:
			// this state transition to processCleanup only
			processState = processCleanup

			msg.Suspend()
			select {
			case err := <-func() chan error { w := make(chan error, 1); go func() { w <- shimCmd.Wait(); cancel() }(); return w }():
				wstatus, ok := shimCmd.ProcessState.Sys().(syscall.WaitStatus)
				if ok {
					if v := wstatus.ExitStatus(); v != 0 {
						exitCode = v
					}
				}

				if msg.IsVerbose() {
					if !ok {
						if err != nil {
							msg.Verbosef("wait: %v", err)
						}
					} else {
						switch {
						case wstatus.Exited():
							msg.Verbosef("process %d exited with code %d", shimCmd.Process.Pid, wstatus.ExitStatus())

						case wstatus.CoreDump():
							msg.Verbosef("process %d dumped core", shimCmd.Process.Pid)

						case wstatus.Signaled():
							msg.Verbosef("process %d got %s", shimCmd.Process.Pid, wstatus.Signal())

						default:
							msg.Verbosef("process %d exited with status %#x", shimCmd.Process.Pid, wstatus)
						}
					}
				}

			case <-func() chan struct{} {
				w := make(chan struct{})
				// This ties processLifecycle to ctx with the additional
				// compensated timeout duration to allow transition to the next
				// state on a locked up shim.
				go func() {
					<-ctx.Done()
					time.Sleep(k.state.Shim.WaitDelay + shimWaitTimeout)
					close(w)
				}()
				return w
			}():
				// This is only reachable when wait did not return within
				// shimWaitTimeout, after its WaitDelay has elapsed. This is
				// different from the container failing to terminate within its
				// timeout period, as that is enforced by the shim. This path is
				// instead reached when there is a lockup in shim preventing it
				// from completing.
				msg.GetLogger().Printf(
					"process %d did not terminate",
					shimCmd.Process.Pid,
				)
			}
			msg.Resume()

		case processCleanup:
			// this state transition to processFinal only
			processState = processFinal

			unlock := func() { msg.Verbose("skipping unlock as lock was not successfully acquired") }
			if f, err := handle.Lock(); err != nil {
				perror(err, "acquire lock on store segment")
			} else {
				unlock = f
			}

			if entryHandle != nil {
				if err := entryHandle.Destroy(); err != nil {
					perror(err, "destroy state entry")
				}
			}

			if isBeforeRevert {
				ec := system.Process

				if entries, _, err := handle.Entries(); err != nil {
					// it is impossible to continue from this point, per-process
					// state will be reverted to limit damage
					perror(err, "read store segment entries")
				} else {
					// accumulate enablements of remaining instances
					var (
						// alive enablement bits
						rt hst.Enablements
						// alive instance count
						n int
					)
					for eh := range entries {
						var et hst.Enablements
						if et, err = eh.Load(nil); err != nil {
							perror(err, "read state header of instance "+eh.ID.String())
						} else {
							rt |= et
							n++
						}
					}

					if n == 0 {
						ec |= system.User
					} else {
						msg.Verbosef(
							"found %d instances, cleaning up without user-scoped operations",
							n,
						)
					}
					ec |= rt ^ (hst.EWayland | hst.EX11 | hst.EDBus | hst.EPulse)
					if msg.IsVerbose() {
						if ec > 0 {
							msg.Verbose("reverting operations scope", system.TypeString(ec))
						}
					}
				}

				if err := k.sys.Revert((*system.Criteria)(&ec)); err != nil {
					joinError, ok := errors.AsType[interface {
						Unwrap() []error
						error
					}](err)
					if !ok || joinError == nil {
						perror(err, "revert system setup")
					} else {
						for _, v := range joinError.Unwrap() {
							perror(v, "revert system setup step")
						}
					}
				}
				isBeforeRevert = false
			}
			unlock()

		case processFinal:
			msg.BeforeExit()
			os.Exit(exitCode)

		default: // not reached
			k.fatalf("invalid transition from state %d to %d", processStatePrev, processState)
			panic("unreachable")
		}
	}
}

// start starts the shim via cmd/hsu.
//
// If successful, a [time.Time] value for [hst.State] is stored in the value
// pointed to by startTime.
//
// The resulting [exec.Cmd] and write end of the shim setup pipe is returned.
func (k *outcome) start(ctx context.Context, msg message.Msg,
	hsuPath *check.Absolute,
	startTime *time.Time,
) (*exec.Cmd, *os.File, error) {
	cmd := exec.CommandContext(ctx, hsuPath.String())
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	cmd.Dir = fhs.Root // container init enters final working directory
	// shim runs in the same session as monitor; see shim.go for behaviour
	cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGCONT) }

	var shimPipe [2]*os.File
	if r, w, err := os.Pipe(); err != nil {
		return cmd, nil, &hst.AppError{Step: "create shim setup pipe", Err: err}
	} else {
		cmd.Env = []string{
			// passed through to shim by hsu
			shimEnv + "=" + strconv.Itoa(3+len(cmd.ExtraFiles)),
			// interpreted by hsu
			"HAKUREI_IDENTITY=" + k.state.identity.String(),
		}
		cmd.ExtraFiles = append(cmd.ExtraFiles, r)
		shimPipe[0], shimPipe[1] = r, w
	}

	if len(k.supp) > 0 {
		msg.Verbosef("attaching supplementary group ids %s", k.supp)
		// interpreted by hsu
		cmd.Env = append(cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.supp, " "))
	}

	msg.Verbosef("setuid helper at %s", hsuPath)
	if err := cmd.Start(); err != nil {
		_, _ = shimPipe[0].Close(), shimPipe[1].Close()
		msg.Resume()
		return cmd, nil, &hst.AppError{Step: "start setuid wrapper", Err: err}
	}
	if err := shimPipe[0].Close(); err != nil {
		msg.Verbose(err)
	}

	*startTime = time.Now().UTC()
	return cmd, shimPipe[1], nil
}

// serveShim serves outcomeState through the shim setup pipe.
func serveShim(msg message.Msg, shimPipe *os.File, state *outcomeState) error {
	if shimPipe == nil {
		return newWithMessage("shim pipe not available")
	}

	if err := shimPipe.SetDeadline(time.Now().Add(shimSetupTimeout)); err != nil {
		msg.Verbose(err.Error())
	}
	if err := gob.NewEncoder(shimPipe).Encode(state); err != nil {
		_ = shimPipe.Close()
		msg.Resume()
		return &hst.AppError{Step: "transmit shim config", Err: err}
	}
	return shimPipe.Close()
}

// printMessageError prints the error message according to [message.GetMessage],
// or fallback prepended to err if an error message is not available.
func printMessageError(println func(v ...any), fallback string, err error) {
	m, ok := message.GetMessage(err)
	if !ok {
		println(fallback, err)
		return
	}

	println(m)
}