| 1 | // canary_create_e2e_test.go |
| 2 | package main |
| 3 | |
| 4 | import ( |
| 5 | "context" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/console" |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // Discussion 207: `carlos canary` has not been able to CREATE a canary since |
| 18 | // #384, because canaryHost built <canary>.<app>.<domain> and the create door |
| 19 | // takes one label before the domain. These drive the real CLI against a real |
| 20 | // console, which is what the canary tests until now never did: every one of |
| 21 | // them either refused before the create or found the record already there. |
| 22 | // |
| 23 | // The run itself ends in a watch timeout, since nothing serves the host in |
| 24 | // a test; what is asserted is the record the create wrote. |
| 25 | func TestCanaryCreateMakesAOneLabelHost(t *testing.T) { |
| 26 | for _, region := range []string{"", "ie"} { |
| 27 | name := "no region flag" |
| 28 | if region != "" { |
| 29 | name = "region flag" |
| 30 | } |
| 31 | t.Run(name, func(t *testing.T) { |
| 32 | e := regionE2EConsole(t, "") |
| 33 | ctx := context.Background() |
| 34 | const host = "probe-titogo.bab.oncarlos.test" |
| 35 | args := []string{"canary", "deploy", "--app", "titogo", "--name", "probe", |
| 36 | "--console", e.url, "--version", "abc1234", "--timeout", "1s"} |
| 37 | if region != "" { |
| 38 | args = append(args, "--region", region) |
| 39 | } |
| 40 | out, _ := e.run(t, append(args, artifact(t))...) |
| 41 | if strings.Contains(out, "invalid host") { |
| 42 | t.Fatalf("the create door refused the canary's host:\n%s", out) |
| 43 | } |
| 44 | if n := e.creates.Load(); n != 1 { |
| 45 | t.Fatalf("%d create request(s), want 1:\n%s", n, out) |
| 46 | } |
| 47 | rec, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host) |
| 48 | if err != nil || rec.Channel != "canary/probe" { |
| 49 | t.Fatalf("record at %s = %+v %v, want it on canary/probe\n%s", host, rec, err, out) |
| 50 | } |
| 51 | if _, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", "probe.titogo.bab.oncarlos.test"); !errors.Is(err, os.ErrNotExist) { |
| 52 | t.Fatalf("a two-label record exists: %v", err) |
| 53 | } |
| 54 | if !strings.Contains(out, "canary live at") && !strings.Contains(out, host) { |
| 55 | t.Fatalf("the output never names the canary host %s:\n%s", host, out) |
| 56 | } |
| 57 | |
| 58 | // A second run finds it by that host: nothing to create. |
| 59 | // The re-run fails only on the watch, like the first: it reaches |
| 60 | // the end, finds the record, and says so, instead of dying early |
| 61 | // before a create it was never going to make. |
| 62 | out, err = e.run(t, append(args, artifact(t))...) |
| 63 | if n := e.creates.Load(); n != 1 { |
| 64 | t.Fatalf("the re-run created again (%d creates):\n%s", n, out) |
| 65 | } |
| 66 | if !strings.Contains(out, "canary host "+host+" already follows canary/probe") || !strings.Contains(out, "promoted abc1234 to canary/probe") { |
| 67 | t.Fatalf("the re-run did not reuse the host and promote (err=%v):\n%s", err, out) |
| 68 | } |
| 69 | |
| 70 | // ls reads it back, and rm removes it by name. |
| 71 | out, err = e.run(t, "canary", "ls", "--app", "titogo", "--console", e.url) |
| 72 | if err != nil || !strings.Contains(out, host) || !strings.Contains(out, "canary/probe") { |
| 73 | t.Fatalf("canary ls = %v\n%s, want the one-label host", err, out) |
| 74 | } |
| 75 | out, err = e.run(t, "canary", "rm", "--app", "titogo", "--name", "probe", "--console", e.url) |
| 76 | if err != nil || !strings.Contains(out, "removed "+host) { |
| 77 | t.Fatalf("canary rm = %v\n%s, want it to remove %s", err, out, host) |
| 78 | } |
| 79 | if _, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host); !errors.Is(err, os.ErrNotExist) { |
| 80 | t.Fatalf("the record survived rm: %v", err) |
| 81 | } |
| 82 | }) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // The canary name and the app share one 63-byte label now. A name that fits |
| 87 | // on its own but not beside the app is refused before any write, with the |
| 88 | // numbers in it. |
| 89 | func TestCanaryTooLongForOneLabelRefusesBeforeAnyWrite(t *testing.T) { |
| 90 | e := regionE2EConsole(t, "") |
| 91 | long := strings.Repeat("a", 58) // 58 + "-" + "titogo" = 65 |
| 92 | want := "canary: " + canaryLabelTooLong(long, "titogo") |
| 93 | for run := 0; run < 2; run++ { |
| 94 | out, err := e.run(t, "canary", "deploy", "--app", "titogo", "--name", long, |
| 95 | "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| 96 | if err == nil || !hasRefusalLine(out, want) { |
| 97 | t.Fatalf("run %d: err=%v out=%s, want the whole line %q", run, err, out, want) |
| 98 | } |
| 99 | } |
| 100 | if e.writes.Load() != 0 || e.releases(t) != 0 { |
| 101 | t.Fatalf("writes %d, releases %d; want nothing written", e.writes.Load(), e.releases(t)) |
| 102 | } |
| 103 | |
| 104 | // The limit itself fits: 56 + 1 + 6 = 63. |
| 105 | fits := strings.Repeat("a", 56) |
| 106 | out, _ := e.run(t, "canary", "deploy", "--app", "titogo", "--name", fits, |
| 107 | "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| 108 | if _, err := provision.GetRecord(context.Background(), e.store, release.OpsAccount, "titogo", fits+"-titogo.bab.oncarlos.test"); err != nil { |
| 109 | t.Fatalf("a 63-byte canary label was not created: %v\n%s", err, out) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // Flattening puts a canary where an ordinary instance of the same app can |
| 114 | // already be: app "titogo" with a customer instance "probe-titogo". Before |
| 115 | // the flattening a canary host was always one label deeper than any |
| 116 | // instance, so reusing "the record on this host" could only ever reuse a |
| 117 | // canary. Now it could hand a customer's instance to the canary channel, so |
| 118 | // a record that is not on a canary/* channel is refused and left as it is. |
| 119 | func TestCanaryDoesNotTakeOverAnInstanceAtItsHost(t *testing.T) { |
| 120 | e := regionE2EConsole(t, "") |
| 121 | ctx := context.Background() |
| 122 | const host = "probe-titogo.bab.oncarlos.test" |
| 123 | rec := provision.Record{Schema: 1, Host: host, App: "titogo", Account: release.OpsAccount, |
| 124 | Channel: "stable", CreatedAt: time.Now().UTC()} |
| 125 | if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | want := "canary: " + canaryHostTaken(host, "stable") |
| 129 | out, err := e.run(t, "canary", "deploy", "--app", "titogo", "--name", "probe", |
| 130 | "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| 131 | if err == nil || !hasRefusalLine(out, want) { |
| 132 | t.Fatalf("err=%v out=%s, want the whole line %q", err, out, want) |
| 133 | } |
| 134 | got, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host) |
| 135 | if err != nil || got.Channel != "stable" { |
| 136 | t.Fatalf("the instance = %+v %v, want it still on stable", got, err) |
| 137 | } |
| 138 | if e.releases(t) != 0 { |
| 139 | t.Fatal("a refused canary shipped a release") |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // App names may end in a dash and hostname labels may not, so an app called |
| 144 | // "jam-" has no canary host for ANY canary name. It is refused before any |
| 145 | // write, saying why, rather than failing at the create door with an |
| 146 | // "invalid host" no other --name can fix. |
| 147 | func TestCanaryRefusesAnAppWhoseNameEndsInADash(t *testing.T) { |
| 148 | e := regionE2EConsole(t, "") |
| 149 | if _, err := e.cst.CreateAppIn(console.OpsAccountID, "jam-", "paul@keymail.dev", time.Now()); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | e.writes.Store(0) |
| 153 | want := "canary: " + canaryAppEndsInDash("jam-") |
| 154 | for run := 0; run < 2; run++ { |
| 155 | out, err := e.run(t, "canary", "deploy", "--app", "jam-", "--name", "probe", |
| 156 | "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| 157 | if err == nil || !hasRefusalLine(out, want) { |
| 158 | t.Fatalf("run %d: err=%v out=%s, want the whole line %q", run, err, out, want) |
| 159 | } |
| 160 | } |
| 161 | if e.writes.Load() != 0 || e.creates.Load() != 0 { |
| 162 | t.Fatalf("writes %d, creates %d; want nothing written", e.writes.Load(), e.creates.Load()) |
| 163 | } |
| 164 | } |
| 165 | |