| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | ) |
| 16 | |
| 17 | func TestClaimReviewFlagOffAdoptPreservesDrift(t *testing.T) { |
| 18 | etc := t.TempDir() |
| 19 | dir := filepath.Join(etc, "systemd/system") |
| 20 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | for _, name := range systemUnitFiles { |
| 24 | b, err := unitsFS.ReadFile("units/" + name) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | if name == claimControlUnit { |
| 29 | b = append(b, []byte("\n# operator customization\n")...) |
| 30 | } |
| 31 | if err := os.WriteFile(filepath.Join(dir, name), b, 0644); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | before, _ := os.ReadFile(filepath.Join(dir, claimControlUnit)) |
| 36 | calls := 0 |
| 37 | ctl := func(...string) (string, error) { calls++; return "", nil } |
| 38 | probe := func(args ...string) (string, error) { |
| 39 | // This box's host.env does not arm db reads, so its db-read socket |
| 40 | // is dark (dbReadsSocketWanted), as ensureSystemUnits keeps it. |
| 41 | if args[1] == dbReadSocketUnit { |
| 42 | if args[0] == "is-active" { |
| 43 | return "inactive", systemctlExit(3) |
| 44 | } |
| 45 | return "disabled", systemctlExit(1) |
| 46 | } |
| 47 | if args[0] == "is-active" { |
| 48 | return "active", nil |
| 49 | } |
| 50 | return "enabled", nil |
| 51 | } |
| 52 | ensureSystemUnits(etc, filepath.Join(t.TempDir(), "lock"), ctl, probe, t.Logf) |
| 53 | after, err := os.ReadFile(filepath.Join(dir, claimControlUnit)) |
| 54 | if err != nil || calls != 0 || !bytes.Equal(before, after) { |
| 55 | t.Fatalf("flag-off drift: calls=%d changed=%v err=%v", calls, !bytes.Equal(before, after), err) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestClaimReviewMalformedSettingsInstallMissingControl(t *testing.T) { |
| 60 | etc := t.TempDir() |
| 61 | if err := os.MkdirAll(filepath.Join(etc, "carlos"), 0755); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if err := os.WriteFile(filepath.Join(etc, "carlos/host.env"), []byte("CARLOS_HOST_CLAIM='unterminated\n"), 0600); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | ensureSystemUnits(etc, filepath.Join(t.TempDir(), "lock"), func(...string) (string, error) { return "", nil }, func(...string) (string, error) { return "inactive", nil }, t.Logf) |
| 68 | got, err := os.ReadFile(filepath.Join(etc, "systemd/system", claimControlUnit)) |
| 69 | want, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 70 | if err != nil || !bytes.Equal(got, want) { |
| 71 | t.Fatalf("missing control not restored to main bytes: %v", err) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestClaimReviewBootstrapToleratesHostEnvironment(t *testing.T) { |
| 76 | for _, body := range []string{"not-a-setting\n", "CARLOS_X=foo\\\n", "CARLOS_X=\"unterminated\n", "CARLOS_HOST_CLAIM='unterminated\n"} { |
| 77 | t.Run(body, func(t *testing.T) { |
| 78 | root := t.TempDir() |
| 79 | if err := os.MkdirAll(filepath.Join(root, "etc/carlos"), 0755); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if err := os.WriteFile(filepath.Join(root, "etc/carlos/host.env"), []byte(body), 0600); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if err := bootstrap(root, nil); err != nil { |
| 86 | t.Fatalf("flag-off bootstrap failed: %v", err) |
| 87 | } |
| 88 | got, err := os.ReadFile(filepath.Join(root, "etc/systemd/system", claimControlUnit)) |
| 89 | want, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 90 | if err != nil || !bytes.Equal(got, want) { |
| 91 | t.Fatalf("not main bytes: %v", err) |
| 92 | } |
| 93 | }) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestClaimReviewSystemStageUsesLiveControlSettings(t *testing.T) { |
| 98 | for _, enabled := range []bool{false, true} { |
| 99 | t.Run(map[bool]string{false: "off", true: "on"}[enabled], func(t *testing.T) { |
| 100 | f := newApplyFixture(t, "stable", "v1.0.0") |
| 101 | if err := os.MkdirAll(filepath.Join(f.a.etcDir, "carlos"), 0755); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | flag := "" |
| 105 | if enabled { |
| 106 | flag = "1" |
| 107 | } |
| 108 | if err := os.WriteFile(filepath.Join(f.a.etcDir, "carlos/host.env"), []byte("CARLOS_HOST_CLAIM="+flag+"\n"), 0600); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | legacy, _ := unitsFS.ReadFile("units/" + claimControlUnit) |
| 112 | want := claimUnitContent(claimControlUnit, legacy, claimUnitSettings{enabled: enabled, data: "/data", opt: "/opt/carlos"}) |
| 113 | livePath := filepath.Join(f.a.etcDir, "systemd/system", claimControlUnit) |
| 114 | if err := os.WriteFile(livePath, want, 0644); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | run := f.a.ops.run |
| 118 | f.a.ops.run = func(ctx context.Context, limit time.Duration, name string, args ...string) (string, error) { |
| 119 | if len(args) > 2 && args[0] == "bootstrap" { |
| 120 | if err := bootstrapWith(args[2], nil, true); err != nil { |
| 121 | return "", err |
| 122 | } |
| 123 | b, err := json.Marshal(currentBootstrapPlan()) |
| 124 | return string(b), err |
| 125 | } |
| 126 | return run(ctx, limit, name, args...) |
| 127 | } |
| 128 | stage := filepath.Join(t.TempDir(), "stage") |
| 129 | if _, _, err := f.a.stagePlan(t.Context(), "candidate", stage); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | stagedPath := filepath.Join(stage, "etc/systemd/system", claimControlUnit) |
| 133 | staged, err := os.ReadFile(stagedPath) |
| 134 | if err != nil || !bytes.Equal(staged, legacy) { |
| 135 | t.Fatalf("stage read live settings: %v", err) |
| 136 | } |
| 137 | marker := []byte("\n# candidate release setting\n") |
| 138 | want = append(want, marker...) |
| 139 | if err := os.WriteFile(stagedPath, append(staged, marker...), 0644); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if err := os.WriteFile(livePath, want, 0644); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if err := f.a.installUnits(t.Context(), stage, nil); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | got, err := os.ReadFile(livePath) |
| 149 | if err != nil || !bytes.Equal(got, want) { |
| 150 | t.Errorf("live control rendering changed: %v", err) |
| 151 | } |
| 152 | if strings.Contains(strings.Join(f.calls, "\n"), "try-restart "+claimControlUnit) { |
| 153 | t.Errorf("unchanged control restarted: %v", f.calls) |
| 154 | } |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestClaimReviewControlEntryKeepsServiceLifetime(t *testing.T) { |
| 160 | fixtureWithoutRootMigrationCensus(t) |
| 161 | service, cancel := context.WithCancel(t.Context()) |
| 162 | defer cancel() |
| 163 | var startup context.Context |
| 164 | store := release.NewDirStore(t.TempDir()) |
| 165 | ctx, err := claimEntryContext(service, func(string) string { return "1" }, func(call context.Context) (release.Store, error) { |
| 166 | startup = call |
| 167 | if _, bounded := call.Deadline(); !bounded { |
| 168 | t.Error("store startup has no deadline") |
| 169 | } |
| 170 | return store, nil |
| 171 | }) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if _, bounded := ctx.Deadline(); bounded { |
| 176 | t.Fatal("service inherited startup deadline") |
| 177 | } |
| 178 | if startup.Err() != context.Canceled { |
| 179 | t.Error("startup context not released") |
| 180 | } |
| 181 | if ctx.Err() != nil || claimStore(ctx) != store { |
| 182 | t.Fatal("startup cancellation lost service or store") |
| 183 | } |
| 184 | sentinel := errors.New("provider stop") |
| 185 | owner := &reviewClaimOwner{open: func(call context.Context) error { |
| 186 | if _, bounded := call.Deadline(); bounded { |
| 187 | t.Error("owners inherited startup deadline") |
| 188 | } |
| 189 | if call.Err() != nil { |
| 190 | t.Error("owner context canceled before shutdown") |
| 191 | } |
| 192 | cancel() |
| 193 | if call.Err() != context.Canceled { |
| 194 | t.Error("owners do not follow service shutdown") |
| 195 | } |
| 196 | return sentinel |
| 197 | }} |
| 198 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 199 | if err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | defer reg.Close() |
| 203 | _, err = OpenProductionClaimAssembly(ctx, store, ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: t.TempDir(), OptDir: t.TempDir(), UnitsDir: t.TempDir()}, owner) |
| 204 | if !errors.Is(err, sentinel) { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | type reviewClaimOwner struct{ open func(context.Context) error } |
| 210 | |
| 211 | func (p *reviewClaimOwner) OpenClaimOwners(ctx context.Context, _ release.Store, _ ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| 212 | return ClaimOwnerBindings{}, p.open(ctx) |
| 213 | } |
| 214 | |