| 1 | package appconfig |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | ) |
| 10 | |
| 11 | // addrRoute is the shape this whole feature is about: an instance carlos |
| 12 | // proxies to over TCP and neither spawns nor owns — the one SyncExec writes |
| 13 | // a file for and deliberately never bounces. |
| 14 | func addrRoute(host, app, acct, env string) registry.Route { |
| 15 | return registry.Route{ |
| 16 | Host: host, Kind: "instance", App: app, Account: acct, |
| 17 | Status: "ready", Addr: "127.0.0.1:8080", Environment: env, |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | // TestAwaitingRestartNamesOptedInAddrRoute is the base case, and the one |
| 22 | // the notice exists for: config written, file delivered, process none the |
| 23 | // wiser. |
| 24 | func TestAwaitingRestartNamesOptedInAddrRoute(t *testing.T) { |
| 25 | ctx, s, _, _, _, _ := execKit(t) |
| 26 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 27 | t.Fatalf("SetExecDelivery: %v", err) |
| 28 | } |
| 29 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 30 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"CLIENT_ID"}) |
| 31 | if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" { |
| 32 | t.Fatalf("Hosts = %+v, want just connect.example.com", got.Hosts) |
| 33 | } |
| 34 | if got.Hosts[0].Unit != "" { |
| 35 | t.Fatalf("Unit = %q, want empty for a route carlos cannot restart", got.Hosts[0].Unit) |
| 36 | } |
| 37 | if len(got.Unresolved) != 0 { |
| 38 | t.Fatalf("Unresolved = %v, want none", got.Unresolved) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // TestAwaitingRestartSkipsUnoptedAndSocketRoutes pins the two shapes that |
| 43 | // must never appear in the notice, for opposite reasons: an un-opted addr |
| 44 | // route never gets a file at all, and a socket route gets one AND gets |
| 45 | // bounced, so telling someone to restart it by hand would be noise. |
| 46 | func TestAwaitingRestartSkipsUnoptedAndSocketRoutes(t *testing.T) { |
| 47 | ctx, s, _, _, _, _ := execKit(t) |
| 48 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 49 | t.Fatalf("SetExecDelivery: %v", err) |
| 50 | } |
| 51 | socket := registry.Route{ |
| 52 | Host: "sock.example.com", Kind: "instance", App: "relay", Account: "bab", |
| 53 | Status: "ready", Socket: "/run/carlos/sock.sock", |
| 54 | } |
| 55 | unit := registry.Route{ |
| 56 | Host: "unit.example.com", Kind: "instance", App: "relay", Account: "bab", |
| 57 | Status: "ready", Addr: "127.0.0.1:9090", Backing: registry.BackingUnit, |
| 58 | } |
| 59 | got := AwaitingRestart(ctx, s, []registry.Route{socket, unit}, "bab", "relay", "", []string{"K"}) |
| 60 | if !got.Empty() { |
| 61 | t.Fatalf("got %+v, want nothing: a socket route is bounced and a unit route is the root pass's", got) |
| 62 | } |
| 63 | |
| 64 | // The same addr route, with the app never opted in: no file was ever |
| 65 | // written for it, so there is nothing to restart over. |
| 66 | ctx2, s2, _, _, _, _ := execKit(t) |
| 67 | got = AwaitingRestart(ctx2, s2, []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}, |
| 68 | "bab", "relay", "", []string{"K"}) |
| 69 | if !got.Empty() { |
| 70 | t.Fatalf("got %+v, want nothing for an app that never opted in", got) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // TestAwaitingRestartCarriesUnit pins the field that decides which remedy |
| 75 | // the CLI offers. An addr route naming its own unit (issue #110's consoles) |
| 76 | // is the one addr shape `carlos restart` can actually cycle. |
| 77 | func TestAwaitingRestartCarriesUnit(t *testing.T) { |
| 78 | ctx, s, _, _, _, _ := execKit(t) |
| 79 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 80 | t.Fatalf("SetExecDelivery: %v", err) |
| 81 | } |
| 82 | rt := addrRoute("console.example.com", "relay", "bab", "") |
| 83 | rt.Unit = "console.service" |
| 84 | got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil) |
| 85 | if len(got.Hosts) != 1 || got.Hosts[0].Unit != "console.service" { |
| 86 | t.Fatalf("Hosts = %+v, want console.service carried through", got.Hosts) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // TestAwaitingRestartDefaultWriteReachesBoundRoute: a named bundle is an |
| 91 | // overlay ON the default, not a replacement for it, so a default-bundle |
| 92 | // write does reach a route bound to an environment — for any key that |
| 93 | // environment does not itself state. |
| 94 | func TestAwaitingRestartDefaultWriteReachesBoundRoute(t *testing.T) { |
| 95 | ctx, s, _, _, _, _ := execKit(t) |
| 96 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 97 | t.Fatalf("SetExecDelivery: %v", err) |
| 98 | } |
| 99 | if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod", |
| 100 | map[string]Var{"OWN_KEY": {Kind: "env", Value: "v"}}, nil); err != nil { |
| 101 | t.Fatalf("ApplyEnvironment: %v", err) |
| 102 | } |
| 103 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")} |
| 104 | |
| 105 | // A key the bound bundle does NOT state: the merge changes, the file |
| 106 | // changes, the process is behind. |
| 107 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"}) |
| 108 | if len(got.Hosts) != 1 { |
| 109 | t.Fatalf("Hosts = %+v, want the bound route for an unshadowed key", got.Hosts) |
| 110 | } |
| 111 | |
| 112 | // A key it DOES state: the overlay wins, the merged manifest is |
| 113 | // byte-identical, syncExecHost writes nothing. Naming the route here |
| 114 | // would send someone to restart a process for a value it was never |
| 115 | // going to see. |
| 116 | got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"}) |
| 117 | if !got.Empty() { |
| 118 | t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by the bound bundle", got) |
| 119 | } |
| 120 | |
| 121 | // Mixed: one shadowed, one not — still affected. |
| 122 | got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"}) |
| 123 | if len(got.Hosts) != 1 { |
| 124 | t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes: the other |
| 129 | // direction. A write to one environment must not name routes bound to |
| 130 | // another, or to none. |
| 131 | func TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes(t *testing.T) { |
| 132 | ctx, s, _, _, _, _ := execKit(t) |
| 133 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 134 | t.Fatalf("SetExecDelivery: %v", err) |
| 135 | } |
| 136 | for _, env := range []string{"prod", "staging"} { |
| 137 | if _, err := ApplyEnvironment(ctx, s, "bab", "relay", env, |
| 138 | map[string]Var{"K": {Kind: "env", Value: env}}, nil); err != nil { |
| 139 | t.Fatalf("ApplyEnvironment(%s): %v", env, err) |
| 140 | } |
| 141 | } |
| 142 | routes := []registry.Route{ |
| 143 | addrRoute("prod.example.com", "relay", "bab", "prod"), |
| 144 | addrRoute("staging.example.com", "relay", "bab", "staging"), |
| 145 | addrRoute("plain.example.com", "relay", "bab", ""), |
| 146 | } |
| 147 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"}) |
| 148 | if len(got.Hosts) != 1 || got.Hosts[0].Host != "prod.example.com" { |
| 149 | t.Fatalf("Hosts = %+v, want only prod.example.com", got.Hosts) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // TestAwaitingRestartOtherAppsAndAccounts is the tenant boundary: routes |
| 154 | // belong to one app of one account, and a notice that leaked a neighbour's |
| 155 | // hostname would be a disclosure, not a nicety. |
| 156 | func TestAwaitingRestartOtherAppsAndAccounts(t *testing.T) { |
| 157 | ctx, s, _, _, _, _ := execKit(t) |
| 158 | for _, app := range []string{"relay", "other"} { |
| 159 | if err := SetExecDelivery(ctx, s, "bab", app, ExecDeliveryFile); err != nil { |
| 160 | t.Fatalf("SetExecDelivery(%s): %v", app, err) |
| 161 | } |
| 162 | } |
| 163 | if err := SetExecDelivery(ctx, s, "zzz", "relay", ExecDeliveryFile); err != nil { |
| 164 | t.Fatalf("SetExecDelivery(zzz): %v", err) |
| 165 | } |
| 166 | routes := []registry.Route{ |
| 167 | addrRoute("mine.example.com", "relay", "bab", ""), |
| 168 | addrRoute("otherapp.example.com", "other", "bab", ""), |
| 169 | addrRoute("otheracct.example.com", "relay", "zzz", ""), |
| 170 | } |
| 171 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) |
| 172 | if len(got.Hosts) != 1 || got.Hosts[0].Host != "mine.example.com" { |
| 173 | t.Fatalf("Hosts = %+v, want only this account's route for this app", got.Hosts) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // TestAwaitingRestartUnresolvedOnMissingBoundBundle: a route bound to a |
| 178 | // bundle that does not exist resolves to an ERROR, not to the app default — |
| 179 | // LoadMerged refuses it and SyncExec leaves the file exactly as it was. |
| 180 | // Nothing was delivered, so "delivered" would be a lie; but the route is |
| 181 | // not fine either, so it is not a skip. |
| 182 | func TestAwaitingRestartUnresolvedOnMissingBoundBundle(t *testing.T) { |
| 183 | ctx, s, _, _, _, _ := execKit(t) |
| 184 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 185 | t.Fatalf("SetExecDelivery: %v", err) |
| 186 | } |
| 187 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "ghost")} |
| 188 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) |
| 189 | if len(got.Hosts) != 0 { |
| 190 | t.Fatalf("Hosts = %+v, want none: nothing was delivered", got.Hosts) |
| 191 | } |
| 192 | if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { |
| 193 | t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // TestAwaitingRestartUnresolvedOnFetchFailure is the same distinction for a |
| 198 | // transient blip. A read that failed is not a determination either way, and |
| 199 | // reporting it as "nothing to restart" would be discussion #16's own bug |
| 200 | // reappearing inside its fix. |
| 201 | func TestAwaitingRestartUnresolvedOnFetchFailure(t *testing.T) { |
| 202 | ctx, s, _, _, _, _ := execKit(t) |
| 203 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 204 | t.Fatalf("SetExecDelivery: %v", err) |
| 205 | } |
| 206 | blip := &failingKeyStore{Store: s, failKey: Key("bab", "relay"), err: errors.New("503 slow down")} |
| 207 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 208 | got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil) |
| 209 | if len(got.Hosts) != 0 { |
| 210 | t.Fatalf("Hosts = %+v, want none when the bundle would not read", got.Hosts) |
| 211 | } |
| 212 | if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { |
| 213 | t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // TestAwaitingRestartUnknownExecDeliveryIsOff pins the additive-discipline |
| 218 | // case: a value a NEWER carlos wrote decides "not opted in" here, exactly as |
| 219 | // it does in SyncExec, so the notice and the sweep agree about who gets a |
| 220 | // file even across versions. |
| 221 | func TestAwaitingRestartUnknownExecDeliveryIsOff(t *testing.T) { |
| 222 | ctx, s, _, _, _, _ := execKit(t) |
| 223 | // Written straight to the bucket rather than through a setter: the |
| 224 | // setters refuse this value by design, and the case being pinned is a |
| 225 | // manifest a FUTURE carlos wrote that this binary has to read without |
| 226 | // choking on. |
| 227 | raw, err := json.Marshal(&Manifest{Schema: 1, ExecDelivery: "socket-someday"}) |
| 228 | if err != nil { |
| 229 | t.Fatalf("marshal: %v", err) |
| 230 | } |
| 231 | if _, err := s.Put(ctx, Key("bab", "relay"), raw, ""); err != nil { |
| 232 | t.Fatalf("Put: %v", err) |
| 233 | } |
| 234 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 235 | if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil); !got.Empty() { |
| 236 | t.Fatalf("got %+v, want nothing for an exec_delivery value this binary does not understand", got) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // TestAwaitingRestartInstanceOverlayShadowsADefaultWrite: the third layer |
| 241 | // shadows a default-bundle write the same way a named bundle does, and for |
| 242 | // the same reason. The merged manifest does not move, so syncExecHost's |
| 243 | // bytes.Equal writes nothing and there is no file for anyone to restart over. |
| 244 | func TestAwaitingRestartInstanceOverlayShadowsADefaultWrite(t *testing.T) { |
| 245 | ctx, s, _, _, _, _ := execKit(t) |
| 246 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 247 | t.Fatalf("SetExecDelivery: %v", err) |
| 248 | } |
| 249 | if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", |
| 250 | map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { |
| 251 | t.Fatalf("ApplyInstance: %v", err) |
| 252 | } |
| 253 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 254 | |
| 255 | if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"}); !got.Empty() { |
| 256 | t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by this instance's own layer", got) |
| 257 | } |
| 258 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"}) |
| 259 | if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" { |
| 260 | t.Fatalf("Hosts = %+v, want the route for a key its overlay does not state", got.Hosts) |
| 261 | } |
| 262 | got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"}) |
| 263 | if len(got.Hosts) != 1 { |
| 264 | t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite is the case |
| 269 | // that did not exist before the third layer: a write to the route's OWN |
| 270 | // bound bundle used to be unshadowable, because nothing sat above it. |
| 271 | // Something does now. |
| 272 | func TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite(t *testing.T) { |
| 273 | ctx, s, _, _, _, _ := execKit(t) |
| 274 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 275 | t.Fatalf("SetExecDelivery: %v", err) |
| 276 | } |
| 277 | if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod", |
| 278 | map[string]Var{"K": {Kind: "env", Value: "environment"}}, nil); err != nil { |
| 279 | t.Fatalf("ApplyEnvironment: %v", err) |
| 280 | } |
| 281 | if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", |
| 282 | map[string]Var{"K": {Kind: "env", Value: "instance"}}, nil); err != nil { |
| 283 | t.Fatalf("ApplyInstance: %v", err) |
| 284 | } |
| 285 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")} |
| 286 | |
| 287 | if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"}); !got.Empty() { |
| 288 | t.Fatalf("got %+v, want nothing: K is shadowed by this instance's own layer", got) |
| 289 | } |
| 290 | if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"OTHER"}); len(got.Hosts) != 1 { |
| 291 | t.Fatalf("Hosts = %+v, want the route for an unshadowed key", got.Hosts) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead: a read this |
| 296 | // function could not make is not a determination, on this layer as on the |
| 297 | // other two — the route is named as unresolved rather than reported either |
| 298 | // way. |
| 299 | func TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead(t *testing.T) { |
| 300 | ctx, s, _, _, _, _ := execKit(t) |
| 301 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 302 | t.Fatalf("SetExecDelivery: %v", err) |
| 303 | } |
| 304 | if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", |
| 305 | map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { |
| 306 | t.Fatalf("ApplyInstance: %v", err) |
| 307 | } |
| 308 | blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"), |
| 309 | err: errors.New("s3: transient 503 SlowDown")} |
| 310 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 311 | got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", []string{"OWN_KEY"}) |
| 312 | if len(got.Hosts) != 0 || len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { |
| 313 | t.Fatalf("got %+v, want the host as unresolved and nothing claimed either way", got) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay pins that an empty |
| 318 | // keys (a write with no vars, e.g. flipping exec_delivery on) never asks the |
| 319 | // instance layer anything: there is nothing for it to shadow, per keys' own |
| 320 | // doc comment, so a blip reading it must not downgrade a route this call was |
| 321 | // certain about into Unresolved over a question it was never going to ask. |
| 322 | func TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay(t *testing.T) { |
| 323 | ctx, s, _, _, _, _ := execKit(t) |
| 324 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 325 | t.Fatalf("SetExecDelivery: %v", err) |
| 326 | } |
| 327 | if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", |
| 328 | map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { |
| 329 | t.Fatalf("ApplyInstance: %v", err) |
| 330 | } |
| 331 | blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"), |
| 332 | err: errors.New("s3: transient 503 SlowDown")} |
| 333 | routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} |
| 334 | got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil) |
| 335 | if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" || len(got.Unresolved) != 0 { |
| 336 | t.Fatalf("got %+v, want the route named in Hosts: an unreadable overlay is irrelevant when keys is empty", got) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // TestAwaitingRestartSorted pins determinism: registry order is not stable, |
| 341 | // and a notice whose lines reshuffle between identical runs is one people |
| 342 | // stop reading. |
| 343 | func TestAwaitingRestartSorted(t *testing.T) { |
| 344 | ctx, s, _, _, _, _ := execKit(t) |
| 345 | if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { |
| 346 | t.Fatalf("SetExecDelivery: %v", err) |
| 347 | } |
| 348 | routes := []registry.Route{ |
| 349 | addrRoute("zebra.example.com", "relay", "bab", ""), |
| 350 | addrRoute("alpha.example.com", "relay", "bab", ""), |
| 351 | addrRoute("middle.example.com", "relay", "bab", ""), |
| 352 | } |
| 353 | got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) |
| 354 | want := []string{"alpha.example.com", "middle.example.com", "zebra.example.com"} |
| 355 | if len(got.Hosts) != len(want) { |
| 356 | t.Fatalf("Hosts = %+v, want %d", got.Hosts, len(want)) |
| 357 | } |
| 358 | for i, w := range want { |
| 359 | if got.Hosts[i].Host != w { |
| 360 | t.Fatalf("Hosts[%d] = %q, want %q", i, got.Hosts[i].Host, w) |
| 361 | } |
| 362 | } |
| 363 | } |
| 364 | |