| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/rootstate" |
| 13 | ) |
| 14 | |
| 15 | func TestPlan14GenerationRetirementReleasesExactPin(t *testing.T) { |
| 16 | f := newReuseFixture(t) |
| 17 | g, r := generationPinFixture(t, f.opt, "bab", "myapp", "v2") |
| 18 | pending := r.Binding |
| 19 | pending.Attempt, pending.Invocation = "pending-v3", "pending-v3" |
| 20 | pending.Version = "v3" |
| 21 | pending.Path = VersionPath(f.opt, "bap", "v3", "other") |
| 22 | pending.Binding.Account, pending.Binding.App = "bap", "other" |
| 23 | pending.Tree = filepath.Dir(pending.Path) |
| 24 | if err := StageArtifact(f.opt, "bap", "v3", "other", []byte("pending")); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | for path, dst := range map[string]*rootstate.FileIdentity{pending.Path: &pending.Executable, pending.Tree: &pending.TreeIdentity} { |
| 28 | file, err := os.Open(path) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | *dst, err = rootstate.Identity(file) |
| 33 | file.Close() |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | } |
| 38 | p, err := g.Reserve(pending) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | closed, verified := 0, 0 |
| 43 | err = g.Retire(t.Context(), r, func(context.Context, GenerationReservation) error { closed++; return nil }, func(_ context.Context, got GenerationReservation) (string, error) { |
| 44 | verified++ |
| 45 | if !got.Closed || got.ID != r.ID { |
| 46 | t.Fatal("kernel checked before durable close") |
| 47 | } |
| 48 | if err := g.Check(r); err == nil { |
| 49 | t.Fatal("closed authority can launch") |
| 50 | } |
| 51 | return strings.Repeat("a", 64), nil |
| 52 | }) |
| 53 | if err != nil || closed != 1 || verified != 1 { |
| 54 | t.Fatalf("retire: %v %d %d", err, closed, verified) |
| 55 | } |
| 56 | source := RootGenerationPins{Ledger: g, Verify: func(context.Context, []GenerationReservation) error { return nil }} |
| 57 | pins, err := source.StagedPins(t.Context(), f.opt) |
| 58 | if err != nil || pins.Has(r.Binding.Path) || !pins.Has(p.Binding.Path) { |
| 59 | t.Fatalf("wrong pins: %v %v", pins, err) |
| 60 | } |
| 61 | if got, err := g.Read(p.Binding); err != nil || got != p { |
| 62 | t.Fatalf("pending reservation changed: %+v %v", got, err) |
| 63 | } |
| 64 | if _, err := g.Reserve(r.Binding); err == nil { |
| 65 | t.Fatal("retired request replay authorized launch") |
| 66 | } |
| 67 | if err := g.Started(r, GenerationProcess{PID: 51, TenantPID: 52, Start: "51", TenantStart: "52", Group: "system.slice/run.service", Invocation: "unit", GroupIdentity: rootstate.FileIdentity{Device: 1, Inode: 2}}); err == nil { |
| 68 | t.Fatal("retired reservation recorded a late launch") |
| 69 | } |
| 70 | f.pass(t, WithRootGenerationPins(source)) |
| 71 | if got := f.stagedV2(t); got != "new v2" { |
| 72 | t.Fatalf("exact retirement failed to release restage: %q", got) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestPlan14GenerationRetirementRefusals(t *testing.T) { |
| 77 | for _, attack := range []string{"join", "kernel", "close-sync", "retire-sync", "wrong-id", "wrong-binding", "no-close", "no-kernel", "bad-evidence"} { |
| 78 | t.Run(attack, func(t *testing.T) { |
| 79 | g, s, b := generationFixture(t) |
| 80 | r, err := g.Reserve(b) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | expected := r |
| 85 | close := func(context.Context, GenerationReservation) error { return nil } |
| 86 | verify := func(context.Context, GenerationReservation) (string, error) { return strings.Repeat("b", 64), nil } |
| 87 | switch attack { |
| 88 | case "join": |
| 89 | close = func(context.Context, GenerationReservation) error { return os.ErrPermission } |
| 90 | case "kernel": |
| 91 | verify = func(context.Context, GenerationReservation) (string, error) { return "", os.ErrPermission } |
| 92 | case "close-sync": |
| 93 | close = func(context.Context, GenerationReservation) error { s.fail = 1; return nil } |
| 94 | case "retire-sync": |
| 95 | verify = func(context.Context, GenerationReservation) (string, error) { |
| 96 | s.fail = 1 |
| 97 | return strings.Repeat("b", 64), nil |
| 98 | } |
| 99 | case "wrong-id": |
| 100 | expected.ID = strings.Repeat("c", 64) |
| 101 | case "wrong-binding": |
| 102 | expected.Binding.ControllerStart = "other" |
| 103 | case "no-close": |
| 104 | close = nil |
| 105 | case "no-kernel": |
| 106 | verify = nil |
| 107 | case "bad-evidence": |
| 108 | verify = func(context.Context, GenerationReservation) (string, error) { return "complete", nil } |
| 109 | } |
| 110 | if err := g.Retire(t.Context(), expected, close, verify); err == nil { |
| 111 | t.Fatal("retirement accepted incomplete evidence") |
| 112 | } |
| 113 | // A failed final sync may have made the evidence visible. It must be |
| 114 | // recommitted before a reader can release its pin. |
| 115 | if attack == "retire-sync" { |
| 116 | s.fail = 1 |
| 117 | if _, err := g.Read(b); err == nil { |
| 118 | t.Fatal("uncommitted retirement granted read") |
| 119 | } |
| 120 | } |
| 121 | got, err := g.Read(b) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if attack != "retire-sync" && got.Retirement != "" { |
| 126 | t.Fatal("unverified retirement released pin") |
| 127 | } |
| 128 | if got.ID != r.ID || got.Binding != r.Binding { |
| 129 | t.Fatal("retirement changed identity") |
| 130 | } |
| 131 | if got.Closed && !errors.Is(g.Check(r), os.ErrPermission) { |
| 132 | t.Fatal("closed request remained launchable") |
| 133 | } |
| 134 | }) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestPlan14GenerationRetirementKeepsIndependentReference(t *testing.T) { |
| 139 | f := newReuseFixture(t) |
| 140 | g, r := generationPinFixture(t, f.opt, "bab", "myapp", "v2") |
| 141 | b := r.Binding |
| 142 | b.Attempt = "independent" |
| 143 | b.Invocation = "independent" |
| 144 | other, err := g.Reserve(b) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if err := g.Retire(t.Context(), r, func(context.Context, GenerationReservation) error { return nil }, func(context.Context, GenerationReservation) (string, error) { return strings.Repeat("a", 64), nil }); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | source := RootGenerationPins{Ledger: g, Verify: func(context.Context, []GenerationReservation) error { return nil }} |
| 152 | f.pass(t, WithRootGenerationPins(source)) |
| 153 | if got := f.stagedV2(t); got != "old v2" { |
| 154 | t.Fatalf("independent reference overwritten: %q", got) |
| 155 | } |
| 156 | if got, err := g.Read(other.Binding); err != nil || got != other { |
| 157 | t.Fatal("other reference changed", err) |
| 158 | } |
| 159 | source.Verify = nil |
| 160 | if _, err := source.StagedPins(t.Context(), f.opt); err == nil { |
| 161 | t.Fatal("retirement supplied completeness") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestPlan14GenerationClaimsOffRestageMatchesMain(t *testing.T) { |
| 166 | for _, pinned := range []bool{false, true} { |
| 167 | type result struct { |
| 168 | bytes, live, version, previous string |
| 169 | restarts int |
| 170 | } |
| 171 | run := func(withRoot bool) result { |
| 172 | f := newReuseFixture(t) |
| 173 | if pinned { |
| 174 | if err := f.reg.Upsert(registry.Route{Host: "pending.example", Account: "bab", App: "myapp", Version: "v2", Status: "creating"}); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | } |
| 178 | var opts []PassOption |
| 179 | if withRoot { |
| 180 | opts = append(opts, WithRootGenerationPins(PinSourceFunc(func(context.Context, string) (StagedPaths, error) { return StagedPaths{}, nil }))) |
| 181 | } |
| 182 | restarts := 0 |
| 183 | if err := Pass(t.Context(), f.reg, f.s, f.pub, f.opt, func(string) error { restarts++; return nil }, opts...); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | live, err := LiveVersion(f.opt, "h1.example.com") |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | row, err := f.reg.Get("h1.example.com") |
| 191 | if err != nil || row == nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | return result{f.stagedV2(t), live, row.Version, row.PrevVersion, restarts} |
| 195 | } |
| 196 | main, branch := run(false), run(true) |
| 197 | if main != branch { |
| 198 | t.Fatalf("claims-off changed pinned=%v: main=%+v branch=%+v", pinned, main, branch) |
| 199 | } |
| 200 | if !pinned && (branch.bytes != "new v2" || branch.live != "v2" || branch.restarts != 1) { |
| 201 | t.Fatalf("positive restage refused: %+v", branch) |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | func TestPlan14GenerationClosedReplayRefused(t *testing.T) { |
| 207 | for _, started := range []bool{false, true} { |
| 208 | g, _, b := generationFixture(t) |
| 209 | r, err := g.Reserve(b) |
| 210 | if err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | process := GenerationProcess{PID: 51, TenantPID: 52, Start: "51", TenantStart: "52", Group: "system.slice/run.service", Invocation: "unit", GroupIdentity: rootstate.FileIdentity{Device: 1, Inode: 2}} |
| 214 | if started { |
| 215 | if err := g.Started(r, process); err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | r, err = g.Read(b) |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | } |
| 223 | if err := g.Retire(t.Context(), r, func(context.Context, GenerationReservation) error { return nil }, func(_ context.Context, closed GenerationReservation) (string, error) { |
| 224 | if err := g.Check(closed); err == nil { |
| 225 | t.Fatal("closed reservation granted a spawn") |
| 226 | } |
| 227 | if err := g.Started(r, process); err == nil { |
| 228 | t.Fatal("closed reservation accepted a started replay") |
| 229 | } |
| 230 | return strings.Repeat("a", 64), nil |
| 231 | }); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |