| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/rootstate" |
| 11 | ) |
| 12 | |
| 13 | func generationFixture(t *testing.T) (*RootGenerations, *healthyRetryStore, GenerationBinding) { |
| 14 | t.Helper() |
| 15 | s := &healthyRetryStore{dir: t.TempDir(), fault: errors.New("directory sync failed")} |
| 16 | b := GenerationBinding{Binding: HistoryBinding{Host: "shop.example", Account: "bab", App: "shop"}, |
| 17 | Installation: "installation", Record: "record", RecordETag: "etag", Attempt: "attempt:main", Inputs: "inputs", Invocation: "invocation", |
| 18 | Target: GenerationOwner{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 2, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller"}, |
| 19 | ControllerPID: 42, ControllerStart: "start", Version: "v1", Digest: strings.Repeat("a", 64), |
| 20 | Path: "/opt/carlos/versions/v1/bab/shop", Tree: "/opt/carlos/versions/v1/bab", |
| 21 | Executable: rootstate.FileIdentity{Device: 1, Inode: 2}, TreeIdentity: rootstate.FileIdentity{Device: 1, Inode: 3}} |
| 22 | return &RootGenerations{Store: s}, s, b |
| 23 | } |
| 24 | |
| 25 | func TestPlan14GenerationReservationDurability(t *testing.T) { |
| 26 | g, s, b := generationFixture(t) |
| 27 | s.fail = 1 |
| 28 | if _, err := g.Reserve(b); err == nil { |
| 29 | t.Fatal("directory sync failure granted a reservation") |
| 30 | } |
| 31 | raw, err := s.Read(generationKey(b)) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | s.fail = 1 |
| 36 | if _, err := g.Reserve(b); err == nil { |
| 37 | t.Fatal("visible reservation bypassed directory sync retry") |
| 38 | } |
| 39 | r, err := g.Reserve(b) |
| 40 | if err != nil || r.ID == "" || r.Binding != b || r.Revision != 1 { |
| 41 | t.Fatalf("reserve: %+v %v", r, err) |
| 42 | } |
| 43 | after, err := s.Read(generationKey(b)) |
| 44 | if err != nil || string(raw) != string(after) { |
| 45 | t.Fatalf("retry changed reservation identity: %v", err) |
| 46 | } |
| 47 | for _, attack := range []string{"empty", "missing", "rename"} { |
| 48 | t.Run(attack, func(t *testing.T) { |
| 49 | dir := t.TempDir() |
| 50 | path := GenerationsPath(dir) |
| 51 | if err := WriteGenerations(path, Generations{Hosts: map[string][]string{b.Binding.Host: {b.Version}}}); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | switch attack { |
| 55 | case "empty": |
| 56 | if err := WriteGenerations(path, Generations{}); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | case "missing": |
| 60 | if err := os.Remove(path); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := os.Remove(GenerationsSeqPath(path)); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | case "rename": |
| 67 | if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | } |
| 71 | got, err := g.Read(b) |
| 72 | if err != nil || got != r { |
| 73 | t.Fatalf("legacy edit changed root reservation: %+v %v", got, err) |
| 74 | } |
| 75 | }) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPlan14GenerationReservationConflicts(t *testing.T) { |
| 80 | g, _, b := generationFixture(t) |
| 81 | r, err := g.Reserve(b) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | for name, change := range map[string]func(*GenerationBinding){ |
| 86 | "account": func(b *GenerationBinding) { b.Binding.Account = "bap" }, |
| 87 | "app": func(b *GenerationBinding) { b.Binding.App = "other" }, |
| 88 | "version": func(b *GenerationBinding) { b.Version = "v2" }, |
| 89 | "digest": func(b *GenerationBinding) { b.Digest = strings.Repeat("b", 64) }, |
| 90 | "inode": func(b *GenerationBinding) { b.Executable.Inode++ }, |
| 91 | "tree": func(b *GenerationBinding) { b.TreeIdentity.Inode++ }, |
| 92 | "installation": func(b *GenerationBinding) { b.Installation = "replacement" }, |
| 93 | "process": func(b *GenerationBinding) { b.ControllerStart = "replacement" }, |
| 94 | "inputs": func(b *GenerationBinding) { b.Inputs = "replacement" }, |
| 95 | } { |
| 96 | t.Run(name, func(t *testing.T) { |
| 97 | other := b |
| 98 | change(&other) |
| 99 | if _, err := g.Reserve(other); err == nil { |
| 100 | t.Fatal("rebound existing reservation") |
| 101 | } |
| 102 | got, err := g.Read(b) |
| 103 | if err != nil || got != r { |
| 104 | t.Fatalf("conflict changed old pin: %+v %v", got, err) |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestPlan14GenerationObservationKeepsReservation(t *testing.T) { |
| 111 | g, s, b := generationFixture(t) |
| 112 | r, err := g.Reserve(b) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | o := GenerationProcess{PID: 51, Start: "51", TenantPID: 52, TenantStart: "52", Group: "system.slice/run.service", GroupIdentity: rootstate.FileIdentity{Device: 2, Inode: 3}, Invocation: "unit-invocation"} |
| 117 | s.fail = 1 |
| 118 | if err := g.Started(r, o); err == nil { |
| 119 | t.Fatal("unsynced started record succeeded") |
| 120 | } |
| 121 | if err := g.Started(r, o); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | got, err := g.Read(b) |
| 125 | if err != nil || got.Binding != b || got.ID != r.ID || got.Process != o || got.Revision != 2 { |
| 126 | t.Fatalf("started: %+v %v", got, err) |
| 127 | } |
| 128 | o.PID++ |
| 129 | if err := g.Started(r, o); err == nil { |
| 130 | t.Fatal("changed running lifetime") |
| 131 | } |
| 132 | if _, err := g.Reserve(b); err == nil { |
| 133 | t.Fatal("running reservation authorized another launch") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestPlan14GenerationRecordRefusals(t *testing.T) { |
| 138 | for _, attack := range []string{"missing", "malformed", "unknown field", "oversize"} { |
| 139 | t.Run(attack, func(t *testing.T) { |
| 140 | g, s, b := generationFixture(t) |
| 141 | if _, err := g.Reserve(b); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | key := generationKey(b) |
| 145 | raw, err := s.Read(key) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | switch attack { |
| 150 | case "missing": |
| 151 | if err := os.Remove(filepath.Join(s.dir, key)); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | case "malformed": |
| 155 | raw = []byte("{") |
| 156 | case "unknown field": |
| 157 | raw = append(raw[:len(raw)-1], []byte(`,"complete":true}`)...) |
| 158 | case "oversize": |
| 159 | raw = []byte(strings.Repeat(" ", rootstate.MaxRecord+1)) |
| 160 | } |
| 161 | if attack != "missing" { |
| 162 | if err := s.Write(key, raw); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | } |
| 166 | if _, err := g.Read(b); err == nil { |
| 167 | t.Fatal("unavailable reservation granted a launch") |
| 168 | } |
| 169 | }) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestPlan14GenerationOldProcessStillParses(t *testing.T) { |
| 174 | g, s, b := generationFixture(t) |
| 175 | r, err := g.Reserve(b) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | p := GenerationProcess{PID: 100, TenantPID: 200, Start: "one", TenantStart: "two", Group: "unit", Invocation: "invocation", GroupIdentity: rootstate.FileIdentity{Device: 1, Inode: 2}} |
| 180 | if err := g.Started(r, p); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | raw, err := s.Read(generationKey(b)) |
| 184 | if err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if strings.Contains(string(raw), "LauncherIdentity") || strings.Contains(string(raw), "Containment") { |
| 188 | t.Fatal("old process wire shape changed") |
| 189 | } |
| 190 | got, err := g.Read(b) |
| 191 | if err != nil || got.Process != p { |
| 192 | t.Fatalf("old process: %+v %v", got, err) |
| 193 | } |
| 194 | } |
| 195 | |