| 1 | //go:build linux |
| 2 | |
| 3 | package cgroup |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostbox" |
| 12 | "github.com/carlosframework/platform/internal/hostguard" |
| 13 | ) |
| 14 | |
| 15 | // The fixture has no kernel-generated control files. Preparation therefore |
| 16 | // fails after the actual mkdir and before a launch reservation can be written. |
| 17 | func TestLaunchChildFailureBeforeReservationSettlesAndRetries(t *testing.T) { |
| 18 | ctx := t.Context() |
| 19 | p := strictFixture(t) |
| 20 | journalPath := filepath.Join(t.TempDir(), "launch.db") |
| 21 | j, err := hostbox.OpenLaunchJournal(journalPath) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | defer func() { j.Close() }() |
| 26 | target := hostguard.FenceTarget{Host: p.host, Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "ctrl"} |
| 27 | parent := hostbox.LaunchGroup{RelativePath: ExecName(p.host), Device: p.identity.device, Inode: p.identity.inode} |
| 28 | if err := j.Prelaunch(ctx, hostbox.LaunchInventory{Target: target, ControllerPID: 42, ProcessStart: "start", Groups: []hostbox.LaunchGroup{parent}, NoState: true}); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | reservation := func(name string) hostbox.LaunchReservation { |
| 32 | return hostbox.LaunchReservation{Target: target, ID: name, Role: "main", InvocationID: name, Inputs: "inputs", Parent: parent, Group: hostbox.LaunchGroup{RelativePath: filepath.Join(parent.RelativePath, "role-"+name), InvocationID: name}, Executable: hostbox.LaunchFile{Device: 1, Inode: 2}} |
| 33 | } |
| 34 | r := reservation("first") |
| 35 | childPath := filepath.Join(p.root.Name(), r.Group.RelativePath) |
| 36 | called := false |
| 37 | child, err := p.PrepareRoleChildWithIntent("first", func(id ContainmentIdentity) error { |
| 38 | called = true |
| 39 | if id.Device != parent.Device || id.Inode != parent.Inode { |
| 40 | t.Fatal("wrong parent") |
| 41 | } |
| 42 | if _, err := os.Lstat(childPath); !errors.Is(err, os.ErrNotExist) { |
| 43 | t.Fatal("child exists before intention") |
| 44 | } |
| 45 | return j.IntendRole(ctx, r) |
| 46 | }) |
| 47 | if err == nil || child != nil || !called { |
| 48 | t.Fatalf("child=%v intent=%v err=%v", child, called, err) |
| 49 | } |
| 50 | if _, err := os.Stat(childPath); err != nil { |
| 51 | t.Fatal("failure did not reach mkdir", err) |
| 52 | } |
| 53 | // Discard the writer's connection to model a crash before reservation. |
| 54 | if err := j.Close(); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | j, err = hostbox.OpenLaunchJournal(journalPath) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | stop, err := j.ReadStopKernelJournal(ctx, target) |
| 62 | if err != nil || !stop.PendingRoles || stop.Settled { |
| 63 | t.Fatalf("lost intention: %+v %v", stop, err) |
| 64 | } |
| 65 | if err := j.SettleRecovered(ctx, target); err == nil { |
| 66 | t.Fatal("blind settlement accepted intended child") |
| 67 | } |
| 68 | // Kernel files are automatic on cgroupfs; supply the observation in this |
| 69 | // filesystem fixture. Root requires emptiness before resolving the intention. |
| 70 | if err := os.WriteFile(filepath.Join(childPath, "cgroup.events"), []byte("populated 1\n"), 0600); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | if err := j.ResolveRoleIntentions(ctx, target, p.root.Name(), false); err == nil { |
| 74 | t.Fatal("populated child accepted") |
| 75 | } |
| 76 | if err := os.WriteFile(filepath.Join(childPath, "cgroup.events"), []byte("populated 0\n"), 0600); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if err := j.ResolveRoleIntentions(ctx, target, p.root.Name(), false); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | stop, err = j.ReadStopKernelJournal(ctx, target) |
| 83 | if err != nil || stop.PendingRoles || !stop.Settled || len(stop.Cgroups) != 2 { |
| 84 | t.Fatalf("unsettled child: %+v %v", stop, err) |
| 85 | } |
| 86 | // Retry retains the first attempt and uses a new child and invocation. |
| 87 | next := reservation("second") |
| 88 | if err := j.IntendRole(ctx, next); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | nextPath := filepath.Join(p.root.Name(), next.Group.RelativePath) |
| 92 | if err := os.Mkdir(nextPath, 0700); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | f, err := os.Open(nextPath) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | id, err := strictID(f) |
| 100 | f.Close() |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | next.Group.Device, next.Group.Inode = id.device, id.inode |
| 105 | if err := j.Reserve(ctx, next); err != nil { |
| 106 | t.Fatal("retry reservation failed", err) |
| 107 | } |
| 108 | in, err := j.Read(ctx, target) |
| 109 | if err != nil || len(in.Reservations) != 2 || in.Reservations[0].State != "settled" || in.Reservations[1].State != "reserved" { |
| 110 | t.Fatalf("retry overwrote spent attempt: %+v %v", in, err) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestLaunchChildIntentionFailureCreatesNothing(t *testing.T) { |
| 115 | p := strictFixture(t) |
| 116 | failure := errors.New("journal unavailable") |
| 117 | _, err := p.PrepareRoleChildWithIntent("first", func(ContainmentIdentity) error { return failure }) |
| 118 | if !errors.Is(err, failure) { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if _, err := os.Stat(filepath.Join(p.dir.Name(), "role-first")); !errors.Is(err, os.ErrNotExist) { |
| 122 | t.Fatalf("child created before durable intention: %v", err) |
| 123 | } |
| 124 | } |
| 125 | |