carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/carlosframework/platform/internal/hostfloor"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// install-channel --policy --identity (claim trial, untagged-pointer ruling
20// R1). The channel pointer `carlos system update` writes before a host floor
21// is published carries no release tag and no moves protocol: the console's
22// door resolves the release, mirrors its artifact and calls
23// release.PromoteSystem with {Version, Source "release:<tag>", Artifacts}
24// (reserveHostUpdate tags a release only once a floor is published, which in
25// the trial is step 7). install-channel then finds the release identity
26// through --identity, the file claims run publish-identity wrote, and never
27// through the pointer's tag. The console's own test,
28// TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, pins the pointer's
29// shape these fixtures promote.
30
31// promoteAsSystemUpdate moves stable to version with the pointer shape `carlos
32// system update` writes before a floor is published. It does not run the
33// door: it calls f.store.Put for the artifact (scaffolding) and
34// release.PromoteSystem in-process with the untagged release {Version,
35// Source "release:<version>", Artifacts}. The shape is tied to the console's
36// door only through TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor.
37func (f *channelFixture) promoteAsSystemUpdate(t *testing.T, version string, body []byte) {
38 t.Helper()
39 ctx := context.Background()
40 key := release.SystemArtifactKey(version, "linux-arm64")
41 if _, err := f.store.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
42 t.Fatal(err)
43 }
44 rel := release.SystemRelease{Version: version, Source: "release:" + version, Artifacts: map[string]release.SystemArtifact{
45 "linux-arm64": {Key: key, SHA256: hostfloor.Digest(body)}}}
46 if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), release.SystemComponentRouter, "stable", rel, release.SystemPromoteOptions{Actor: "operator@example.test"}); err != nil {
47 t.Fatal(err)
48 }
49 p, _, err := release.GetSystemPointer(ctx, f.store, f.pub, release.SystemComponentRouter, "stable")
50 if err != nil || p.Current.ReleaseTag != "" || p.Current.MovesProtocol != 0 {
51 t.Fatalf("the promote is not the untagged pointer the trial found: %+v %v", p, err)
52 }
53}
54
55// identityRelease is the release claims run publish-identity signs for tag
56// (claimactivation's identityRelease): version and tag both the tag, moves
57// protocol 1, the router artifact from the signed sums and a console
58// artifact.
59func identityRelease(tag string, router []byte) release.SystemRelease {
60 return release.SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1,
61 Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(tag, "linux-arm64"), SHA256: hostfloor.Digest(router)}},
62 ConsoleArtifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: "bootstrap/console/" + tag + "/carlos-linux-arm64", SHA256: hostfloor.Digest([]byte("console " + tag))}}}
63}
64
65// publishIdentity publishes rel as publish-identity does (SignSystemIdentity,
66// then PublishSystemIdentity create-only) and returns the published bytes,
67// which are what its --out file holds.
68func (f *channelFixture) publishIdentity(t *testing.T, rel release.SystemRelease) []byte {
69 t.Helper()
70 ctx := context.Background()
71 wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), rel)
72 if err != nil {
73 t.Fatal(err)
74 }
75 if err := release.PublishSystemIdentity(ctx, f.store, f.pub, wire); err != nil {
76 t.Fatal(err)
77 }
78 key, _ := release.IdentityKey(rel.ReleaseTag)
79 stored, _, err := f.store.Get(ctx, key)
80 if err != nil {
81 t.Fatal(err)
82 }
83 return stored
84}
85
86// untaggedUpdatedBox is the trial's leo on v0.28.3: installed at v1.3.0,
87// updated by the real apply to v1.4.0 from the untagged pointer `carlos
88// system update` wrote, so applied.json names v1.4.0, carlos is its artifact
89// and carlos.prev v1.3.0, with no channel identity. v1.4.0's identity is
90// published as publish-identity publishes it, and returned.
91func untaggedUpdatedBox(t *testing.T) (f *channelFixture, old, identity []byte) {
92 t.Helper()
93 f = &channelFixture{applyFixture: newApplyFixture(t, "stable", "v1.3.0")}
94 old, err := os.ReadFile(systemBinaryPath(f.a.optDir))
95 if err != nil {
96 t.Fatal(err)
97 }
98 f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n")
99 applyChannelUpdateWith(t, f, "v1.4.0", f.body, func() { f.promoteAsSystemUpdate(t, "v1.4.0", f.body) })
100 if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, old) {
101 t.Fatalf("the update left %q and %q", cur, prev)
102 }
103 identity = f.publishIdentity(t, identityRelease("v1.4.0", f.body))
104 f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
105 f.fetches++
106 return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
107 }, own: func(path string) error {
108 f.owned = append(f.owned, path)
109 return nil
110 }}
111 return f, old, identity
112}
113
114// The trial's 6.1a on an untagged pointer: refused before R1 for want of a
115// release tag, armed with --identity.
116func TestInstallChannelPolicyIdentityArmsAnUntaggedUpdatedBox(t *testing.T) {
117 f, old, identity := untaggedUpdatedBox(t)
118 ctx := context.Background()
119 // Without --policy, exactly as before (ruling 6): no floor, the loop.
120 before := boxFiles(t, f)
121 if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" {
122 t.Fatalf("without --policy: %v", err)
123 }
124 if after := boxFiles(t, f); !equalFiles(before, after) {
125 t.Fatalf("a refused run changed the box: %v", diffFiles(before, after))
126 }
127 applied, _ := os.ReadFile(f.a.file("applied.json"))
128 seen, _ := os.ReadFile(f.a.file("seen.json"))
129 f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
130 f.in.identity = identity
131 got, err := f.in.run(ctx)
132 if err != nil {
133 t.Fatal(err)
134 }
135 // The tag the run names is the identity's: the pointer has none.
136 if !got.Replaced || got.Already || got.Version != "v1.4.0" || got.Tag != "v1.4.0" {
137 t.Fatalf("installed %+v", got)
138 }
139 if line := installedLine(got); line != "replaced the rollback copy carlos.prev with v1.4.0 (v1.4.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" {
140 t.Fatalf("line %q", line)
141 }
142 if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || bytes.Equal(prev, old) {
143 t.Fatalf("slots hold %q and %q", cur, prev)
144 }
145 if f.fetches != 0 || !prevRecorded(f.a.optDir, "v1.4.0") {
146 t.Fatalf("%d fetches, record %v", f.fetches, prevRecorded(f.a.optDir, "v1.4.0"))
147 }
148 if a, _ := os.ReadFile(f.a.file("applied.json")); !bytes.Equal(a, applied) {
149 t.Fatalf("applied.json changed: %s", a)
150 }
151 if s, _ := os.ReadFile(f.a.file("seen.json")); !bytes.Equal(s, seen) {
152 t.Fatalf("seen.json changed: %s", s)
153 }
154 // What is kept for guard-arm is the --identity file, byte for byte, and
155 // guard-arm's checks pass with it: its inputs, and both slots.
156 held, err := os.ReadFile(channelIdentityPath(f.a.optDir))
157 if err != nil || !bytes.Equal(held, identity) {
158 t.Fatalf("kept identity %v", err)
159 }
160 _, floor, err := guardArmInputs(f.in.policy, held, f.pub, "bucket", "v1.4.0", "linux-arm64", hostfloor.Digest(f.body))
161 if err != nil {
162 t.Fatalf("guard-arm's inputs: %v", err)
163 }
164 for _, slot := range []string{systemBinaryPath(f.a.optDir), systemPrevPath(f.a.optDir)} {
165 b, _ := os.ReadFile(slot)
166 if err := hostfloor.CheckImage(f.pub, floor, held, "v1.4.0", "linux-arm64", hostfloor.Digest(b)); err != nil {
167 t.Fatalf("guard-arm would refuse %s: %v", slot, err)
168 }
169 }
170 again, err := f.in.run(ctx)
171 if err != nil || !again.Already || again.Replaced || f.fetches != 0 {
172 t.Fatalf("second run %+v %v", again, err)
173 }
174 if line := installedLine(again); line != "v1.4.0 (v1.4.0) from the stable channel is already installed" {
175 t.Fatalf("again %q", line)
176 }
177 // Once the floor is published from the same plan, the run still passes.
178 if err := hostfloor.Publish(ctx, f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}); err != nil {
179 t.Fatal(err)
180 }
181 if got, err := f.in.run(ctx); err != nil || !got.Already {
182 t.Fatalf("published: %+v %v", got, err)
183 }
184}
185
186// A fresh box on an untagged pointer: --identity names the release, and the
187// install fetches the pointer's artifact into both slots.
188func TestInstallChannelPolicyIdentityInstallsAFreshBoxOnAnUntaggedPointer(t *testing.T) {
189 f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "bootstrap")}
190 if err := os.Remove(systemBinaryPath(f.a.optDir)); err != nil {
191 t.Fatal(err)
192 }
193 f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n")
194 f.promoteAsSystemUpdate(t, "v1.4.0", f.body)
195 identity := f.publishIdentity(t, identityRelease("v1.4.0", f.body))
196 f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error {
197 f.fetches++
198 return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha)
199 }, own: func(string) error { return nil }}
200 f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
201 f.in.identity = identity
202 got, err := f.in.run(context.Background())
203 if err != nil || got.Already || got.Replaced || got.Tag != "v1.4.0" {
204 t.Fatalf("%+v %v", got, err)
205 }
206 if line := installedLine(got); line != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" {
207 t.Fatalf("line %q", line)
208 }
209 if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || f.fetches != 1 {
210 t.Fatalf("slots %q %q after %d fetches", cur, prev, f.fetches)
211 }
212 if held, _ := os.ReadFile(channelIdentityPath(f.a.optDir)); !bytes.Equal(held, identity) {
213 t.Fatal("the kept identity is not the --identity file")
214 }
215}
216
217// Every refusal of --identity leaves the box as it was, fetches nothing and
218// keeps no identity.
219func TestInstallChannelPolicyIdentityRefusals(t *testing.T) {
220 t.Parallel()
221 _, otherKey, _ := ed25519.GenerateKey(nil)
222 const notSigned = "the --identity file is not a release identity signed for this deployment, so nothing was installed"
223 const notPublished = "the --identity file is not the identity published for v1.4.0, so nothing was installed"
224 for _, c := range []struct {
225 name string
226 setup func(t *testing.T, f *channelFixture, identity []byte) []byte
227 want string
228 }{
229 {"no --identity", func(t *testing.T, f *channelFixture, identity []byte) []byte {
230 return nil
231 }, "--policy and --identity go together, so nothing was installed"},
232 {"not an identity", func(t *testing.T, f *channelFixture, identity []byte) []byte {
233 return f.in.policy
234 }, notSigned},
235 {"signed by another key", func(t *testing.T, f *channelFixture, identity []byte) []byte {
236 wire, err := release.SignSystemIdentity(release.RootSigner(otherKey), identityRelease("v1.4.0", f.body))
237 if err != nil {
238 t.Fatal(err)
239 }
240 return wire
241 }, notSigned},
242 {"a channel pointer, not an identity", func(t *testing.T, f *channelFixture, identity []byte) []byte {
243 wire, _, err := f.store.Get(context.Background(), release.SystemPointerKey("router", "stable"))
244 if err != nil {
245 t.Fatal(err)
246 }
247 return wire
248 }, notSigned},
249 {"another identity under its tag", func(t *testing.T, f *channelFixture, identity []byte) []byte {
250 rel := identityRelease("v1.4.0", f.body)
251 rel.ConsoleArtifacts["linux-arm64"] = release.SystemArtifact{Key: rel.ConsoleArtifacts["linux-arm64"].Key, SHA256: hostfloor.Digest([]byte("another console"))}
252 wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), rel)
253 if err != nil {
254 t.Fatal(err)
255 }
256 return wire
257 }, notPublished},
258 {"re-formatted", func(t *testing.T, f *channelFixture, identity []byte) []byte {
259 var b bytes.Buffer
260 if err := json.Compact(&b, identity); err != nil {
261 t.Fatal(err)
262 }
263 reformatted := append([]byte(" "), b.Bytes()...)
264 if _, err := release.VerifySystemIdentity(f.pub, reformatted); err != nil || bytes.Equal(reformatted, identity) {
265 t.Fatalf("the re-formatted identity: %v", err)
266 }
267 return reformatted
268 }, notPublished},
269 {"no identity published under its tag", func(t *testing.T, f *channelFixture, identity []byte) []byte {
270 key, _ := release.IdentityKey("v1.4.0")
271 if err := f.store.Delete(context.Background(), key); err != nil {
272 t.Fatal(err)
273 }
274 return identity
275 }, "no release identity is published for v1.4.0, so nothing was installed"},
276 {"signed for a tag never published", func(t *testing.T, f *channelFixture, identity []byte) []byte {
277 wire, err := release.SignSystemIdentity(release.RootSigner(f.priv), identityRelease("v1.4.9", f.body))
278 if err != nil {
279 t.Fatal(err)
280 }
281 return wire
282 }, "no release identity is published for v1.4.9, so nothing was installed"},
283 {"another release's identity", func(t *testing.T, f *channelFixture, identity []byte) []byte {
284 return f.publishIdentity(t, identityRelease("v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n")))
285 }, "the --identity file names v1.5.0 (v1.5.0), not the release the stable channel names, so nothing was installed"},
286 {"the same artifact under another version", func(t *testing.T, f *channelFixture, identity []byte) []byte {
287 return f.publishIdentity(t, identityRelease("v1.4.1", f.body))
288 }, "the --identity file names v1.4.1 (v1.4.1), not the release the stable channel names, so nothing was installed"},
289 {"another artifact under the release", func(t *testing.T, f *channelFixture, identity []byte) []byte {
290 key, _ := release.IdentityKey("v1.4.0")
291 if err := f.store.Delete(context.Background(), key); err != nil {
292 t.Fatal(err)
293 }
294 return f.publishIdentity(t, identityRelease("v1.4.0", []byte("#!/bin/sh\necho carlos v1.4.0 rebuilt\n")))
295 }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"},
296 {"no artifact for this platform", func(t *testing.T, f *channelFixture, identity []byte) []byte {
297 key, _ := release.IdentityKey("v1.4.0")
298 if err := f.store.Delete(context.Background(), key); err != nil {
299 t.Fatal(err)
300 }
301 rel := identityRelease("v1.4.0", f.body)
302 rel.Artifacts = map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.4.0", "linux-amd64"), SHA256: hostfloor.Digest(f.body)}}
303 return f.publishIdentity(t, rel)
304 }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"},
305 {"a pointer tagged with another release", func(t *testing.T, f *channelFixture, identity []byte) []byte {
306 // The same version and artifact, but the pointer names another tag.
307 rel := release.SystemRelease{Version: "v1.4.0", Source: "release:v1.4.0", ReleaseTag: "v1.4.1", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{
308 "linux-arm64": {Key: release.SystemArtifactKey("v1.4.0", "linux-arm64"), SHA256: hostfloor.Digest(f.body)}}}
309 if _, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
310 t.Fatal(err)
311 }
312 return identity
313 }, "the --identity file names v1.4.0 (v1.4.0), not the release the stable channel names, so nothing was installed"},
314 {"an unreadable identity object", func(t *testing.T, f *channelFixture, identity []byte) []byte {
315 open := f.a.openStore
316 f.a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) {
317 s, k, err := open(ctx)
318 return identityUnreadable{s}, k, err
319 }
320 return identity
321 }, "the release identity for v1.4.0 could not be read, so nothing was installed: door restarting"},
322 {"below the policy's floor", func(t *testing.T, f *channelFixture, identity []byte) []byte {
323 f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"})
324 return identity
325 }, "v1.4.0 (v1.4.0) is below the host floor v2.0.0 or is not the release its identity names, so nothing was installed"},
326 } {
327 t.Run(c.name, func(t *testing.T) {
328 f, _, identity := untaggedUpdatedBox(t)
329 f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
330 f.in.identity = c.setup(t, f, identity)
331 before := boxFiles(t, f)
332 _, err := f.in.run(context.Background())
333 if err == nil || err.Error() != c.want {
334 t.Fatalf("err %v, want %q", err, c.want)
335 }
336 if after := boxFiles(t, f); !equalFiles(before, after) {
337 t.Fatalf("a refused run changed the box: %v", diffFiles(before, after))
338 }
339 if f.fetches != 0 {
340 t.Fatalf("%d fetches", f.fetches)
341 }
342 if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) {
343 t.Fatalf("a refused run kept an identity: %v", err)
344 }
345 })
346 }
347}
348
349// A pointer that does carry a release tag (as the console writes it once a
350// floor is published) passes when the tag names the --identity's release.
351func TestInstallChannelPolicyIdentityAcceptsAPointerTaggedWithIt(t *testing.T) {
352 f, _, identity := untaggedUpdatedBox(t)
353 ctx := context.Background()
354 rel := release.SystemRelease{Version: "v1.4.0", Source: "release:v1.4.0", ReleaseTag: "v1.4.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{
355 "linux-arm64": {Key: release.SystemArtifactKey("v1.4.0", "linux-arm64"), SHA256: hostfloor.Digest(f.body)}}}
356 if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
357 t.Fatal(err)
358 }
359 f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
360 f.in.identity = identity
361 if got, err := f.in.run(ctx); err != nil || !got.Replaced || got.Tag != "v1.4.0" {
362 t.Fatalf("%+v %v", got, err)
363 }
364 if held, _ := os.ReadFile(channelIdentityPath(f.a.optDir)); !bytes.Equal(held, identity) {
365 t.Fatal("the kept identity is not the --identity file")
366 }
367}
368
369// The flags: --policy and --identity together or not at all, both read up
370// front through the hardened open; without either nothing is read.
371func TestInstallChannelInputsAreReadUpFront(t *testing.T) {
372 dir := t.TempDir()
373 policyPath, identityPath := filepath.Join(dir, "policy.json"), filepath.Join(dir, "identity.json")
374 if p, i, err := readChannelInputs("", ""); p != nil || i != nil || err != nil {
375 t.Fatalf("no flags: %v %v %v", p, i, err)
376 }
377 for _, c := range [][2]string{{policyPath, ""}, {"", identityPath}} {
378 if _, _, err := readChannelInputs(c[0], c[1]); err == nil || err.Error() != "--policy and --identity go together, so nothing was installed" {
379 t.Fatalf("%q: %v", c, err)
380 }
381 }
382 raw := signedPolicyBytes(t)
383 if err := os.WriteFile(policyPath, raw, 0o600); err != nil {
384 t.Fatal(err)
385 }
386 if _, _, err := readChannelInputs(policyPath, identityPath); err == nil || !strings.HasPrefix(err.Error(), "the identity "+identityPath+" could not be read, so nothing was installed: ") {
387 t.Fatalf("missing identity: %v", err)
388 }
389 if err := os.WriteFile(identityPath, []byte("identity bytes"), 0o600); err != nil {
390 t.Fatal(err)
391 }
392 p, i, err := readChannelInputs(policyPath, identityPath)
393 if err != nil || !bytes.Equal(p, raw) || string(i) != "identity bytes" {
394 t.Fatalf("both: %v", err)
395 }
396 // The policy is still read and checked first.
397 if err := os.WriteFile(policyPath, []byte(`{"mode":"unpublished"}`), 0o600); err != nil {
398 t.Fatal(err)
399 }
400 if _, _, err := readChannelInputs(policyPath, filepath.Join(dir, "missing.json")); err == nil || err.Error() != "the --policy file is not an armed policy signed for this deployment, so nothing was installed" {
401 t.Fatalf("a bad policy: %v", err)
402 }
403}
404
405// identityUnreadable fails every read of a release identity object.
406type identityUnreadable struct{ release.Store }
407
408func (s identityUnreadable) Get(ctx context.Context, key string) ([]byte, string, error) {
409 if strings.HasPrefix(key, "control/system/router/releases/") {
410 return nil, "", errors.New("door restarting")
411 }
412 return s.Store.Get(ctx, key)
413}
414
415// identityOf is the release identity published for tag: what claims run
416// publish-identity's --out file holds.
417func (f *channelFixture) identityOf(t *testing.T, tag string) []byte {
418 t.Helper()
419 key, err := release.IdentityKey(tag)
420 if err != nil {
421 t.Fatal(err)
422 }
423 raw, _, err := f.store.Get(context.Background(), key)
424 if err != nil {
425 t.Fatal(err)
426 }
427 return raw
428}
429
430// installChannelCLIEnv makes this test binary run RunSystemInstallChannel
431// with the arguments it holds (one per line), so its exits can be observed.
432const installChannelCLIEnv = "CARLOS_TEST_INSTALL_CHANNEL_ARGS"
433
434// TestInstallChannelCLIChild is the child runInstallChannelCLI starts; run
435// without installChannelCLIEnv it does nothing.
436func TestInstallChannelCLIChild(t *testing.T) {
437 raw, ok := os.LookupEnv(installChannelCLIEnv)
438 if !ok {
439 t.Skip("only as runInstallChannelCLI's child")
440 }
441 var args []string
442 if raw != "" {
443 args = strings.Split(raw, "\n")
444 }
445 RunSystemInstallChannel(args)
446 os.Exit(0)
447}
448
449// runInstallChannelCLI runs `carlos system install-channel args...` in a
450// child of this test binary, not as root, and returns its combined output
451// and exit code.
452func runInstallChannelCLI(t *testing.T, args ...string) (string, int) {
453 t.Helper()
454 cmd := exec.Command(os.Args[0], "-test.run=^TestInstallChannelCLIChild$")
455 cmd.Env = append(os.Environ(), installChannelCLIEnv+"="+strings.Join(args, "\n"), "CARLOS_SYSTEM_CHANNEL=")
456 cmd.Dir = t.TempDir()
457 out, err := cmd.CombinedOutput()
458 code := 0
459 if ee, ok := err.(*exec.ExitError); ok {
460 code = ee.ExitCode()
461 } else if err != nil {
462 t.Fatal(err)
463 }
464 return string(out), code
465}
466
467// The command's both-or-neither check (untagged-pointer R1, platform-00):
468// --policy alone and --identity alone are refused with exit 2 and the pair
469// line, before the root check and before any file is read; -h lists both
470// flags.
471func TestRunSystemInstallChannelTakesPolicyAndIdentityTogether(t *testing.T) {
472 missing := filepath.Join(t.TempDir(), "absent.json")
473 for _, args := range [][]string{{"--policy", missing}, {"--identity", missing}} {
474 out, code := runInstallChannelCLI(t, args...)
475 if code != 2 || strings.TrimSpace(out) != "--policy and --identity go together, so nothing was installed" {
476 t.Fatalf("%v: exit %d, %q", args, code, out)
477 }
478 }
479 out, code := runInstallChannelCLI(t, "-h")
480 if code != 0 || !strings.HasPrefix(out, installChannelUsage) || !strings.Contains(out, " --identity <string> "+installChannelIdentityFlag+"\n") || !strings.Contains(out, " --policy <string> "+installChannelPolicyFlag+"\n") {
481 t.Fatalf("-h: exit %d\n%s", code, out)
482 }
483 if os.Geteuid() != 0 {
484 // With neither flag, unchanged: the root check is the first refusal.
485 if out, code := runInstallChannelCLI(t); code != 1 || strings.TrimSpace(out) != installChannelNotRoot {
486 t.Fatalf("no flags: exit %d, %q", code, out)
487 }
488 }
489}
490