package appconfig import ( "encoding/json" "errors" "testing" "github.com/carlosframework/platform/internal/registry" ) // addrRoute is the shape this whole feature is about: an instance carlos // proxies to over TCP and neither spawns nor owns — the one SyncExec writes // a file for and deliberately never bounces. func addrRoute(host, app, acct, env string) registry.Route { return registry.Route{ Host: host, Kind: "instance", App: app, Account: acct, Status: "ready", Addr: "127.0.0.1:8080", Environment: env, } } // TestAwaitingRestartNamesOptedInAddrRoute is the base case, and the one // the notice exists for: config written, file delivered, process none the // wiser. func TestAwaitingRestartNamesOptedInAddrRoute(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"CLIENT_ID"}) if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" { t.Fatalf("Hosts = %+v, want just connect.example.com", got.Hosts) } if got.Hosts[0].Unit != "" { t.Fatalf("Unit = %q, want empty for a route carlos cannot restart", got.Hosts[0].Unit) } if len(got.Unresolved) != 0 { t.Fatalf("Unresolved = %v, want none", got.Unresolved) } } // TestAwaitingRestartSkipsUnoptedAndSocketRoutes pins the two shapes that // must never appear in the notice, for opposite reasons: an un-opted addr // route never gets a file at all, and a socket route gets one AND gets // bounced, so telling someone to restart it by hand would be noise. func TestAwaitingRestartSkipsUnoptedAndSocketRoutes(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } socket := registry.Route{ Host: "sock.example.com", Kind: "instance", App: "relay", Account: "bab", Status: "ready", Socket: "/run/carlos/sock.sock", } unit := registry.Route{ Host: "unit.example.com", Kind: "instance", App: "relay", Account: "bab", Status: "ready", Addr: "127.0.0.1:9090", Backing: registry.BackingUnit, } got := AwaitingRestart(ctx, s, []registry.Route{socket, unit}, "bab", "relay", "", []string{"K"}) if !got.Empty() { t.Fatalf("got %+v, want nothing: a socket route is bounced and a unit route is the root pass's", got) } // The same addr route, with the app never opted in: no file was ever // written for it, so there is nothing to restart over. ctx2, s2, _, _, _, _ := execKit(t) got = AwaitingRestart(ctx2, s2, []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}, "bab", "relay", "", []string{"K"}) if !got.Empty() { t.Fatalf("got %+v, want nothing for an app that never opted in", got) } } // TestAwaitingRestartCarriesUnit pins the field that decides which remedy // the CLI offers. An addr route naming its own unit (issue #110's consoles) // is the one addr shape `carlos restart` can actually cycle. func TestAwaitingRestartCarriesUnit(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } rt := addrRoute("console.example.com", "relay", "bab", "") rt.Unit = "console.service" got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil) if len(got.Hosts) != 1 || got.Hosts[0].Unit != "console.service" { t.Fatalf("Hosts = %+v, want console.service carried through", got.Hosts) } } // TestAwaitingRestartDefaultWriteReachesBoundRoute: a named bundle is an // overlay ON the default, not a replacement for it, so a default-bundle // write does reach a route bound to an environment — for any key that // environment does not itself state. func TestAwaitingRestartDefaultWriteReachesBoundRoute(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod", map[string]Var{"OWN_KEY": {Kind: "env", Value: "v"}}, nil); err != nil { t.Fatalf("ApplyEnvironment: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")} // A key the bound bundle does NOT state: the merge changes, the file // changes, the process is behind. got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"}) if len(got.Hosts) != 1 { t.Fatalf("Hosts = %+v, want the bound route for an unshadowed key", got.Hosts) } // A key it DOES state: the overlay wins, the merged manifest is // byte-identical, syncExecHost writes nothing. Naming the route here // would send someone to restart a process for a value it was never // going to see. got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"}) if !got.Empty() { t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by the bound bundle", got) } // Mixed: one shadowed, one not — still affected. got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"}) if len(got.Hosts) != 1 { t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts) } } // TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes: the other // direction. A write to one environment must not name routes bound to // another, or to none. func TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } for _, env := range []string{"prod", "staging"} { if _, err := ApplyEnvironment(ctx, s, "bab", "relay", env, map[string]Var{"K": {Kind: "env", Value: env}}, nil); err != nil { t.Fatalf("ApplyEnvironment(%s): %v", env, err) } } routes := []registry.Route{ addrRoute("prod.example.com", "relay", "bab", "prod"), addrRoute("staging.example.com", "relay", "bab", "staging"), addrRoute("plain.example.com", "relay", "bab", ""), } got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"}) if len(got.Hosts) != 1 || got.Hosts[0].Host != "prod.example.com" { t.Fatalf("Hosts = %+v, want only prod.example.com", got.Hosts) } } // TestAwaitingRestartOtherAppsAndAccounts is the tenant boundary: routes // belong to one app of one account, and a notice that leaked a neighbour's // hostname would be a disclosure, not a nicety. func TestAwaitingRestartOtherAppsAndAccounts(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) for _, app := range []string{"relay", "other"} { if err := SetExecDelivery(ctx, s, "bab", app, ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery(%s): %v", app, err) } } if err := SetExecDelivery(ctx, s, "zzz", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery(zzz): %v", err) } routes := []registry.Route{ addrRoute("mine.example.com", "relay", "bab", ""), addrRoute("otherapp.example.com", "other", "bab", ""), addrRoute("otheracct.example.com", "relay", "zzz", ""), } got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) if len(got.Hosts) != 1 || got.Hosts[0].Host != "mine.example.com" { t.Fatalf("Hosts = %+v, want only this account's route for this app", got.Hosts) } } // TestAwaitingRestartUnresolvedOnMissingBoundBundle: a route bound to a // bundle that does not exist resolves to an ERROR, not to the app default — // LoadMerged refuses it and SyncExec leaves the file exactly as it was. // Nothing was delivered, so "delivered" would be a lie; but the route is // not fine either, so it is not a skip. func TestAwaitingRestartUnresolvedOnMissingBoundBundle(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "ghost")} got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) if len(got.Hosts) != 0 { t.Fatalf("Hosts = %+v, want none: nothing was delivered", got.Hosts) } if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved) } } // TestAwaitingRestartUnresolvedOnFetchFailure is the same distinction for a // transient blip. A read that failed is not a determination either way, and // reporting it as "nothing to restart" would be discussion #16's own bug // reappearing inside its fix. func TestAwaitingRestartUnresolvedOnFetchFailure(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } blip := &failingKeyStore{Store: s, failKey: Key("bab", "relay"), err: errors.New("503 slow down")} routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil) if len(got.Hosts) != 0 { t.Fatalf("Hosts = %+v, want none when the bundle would not read", got.Hosts) } if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved) } } // TestAwaitingRestartUnknownExecDeliveryIsOff pins the additive-discipline // case: a value a NEWER carlos wrote decides "not opted in" here, exactly as // it does in SyncExec, so the notice and the sweep agree about who gets a // file even across versions. func TestAwaitingRestartUnknownExecDeliveryIsOff(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) // Written straight to the bucket rather than through a setter: the // setters refuse this value by design, and the case being pinned is a // manifest a FUTURE carlos wrote that this binary has to read without // choking on. raw, err := json.Marshal(&Manifest{Schema: 1, ExecDelivery: "socket-someday"}) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := s.Put(ctx, Key("bab", "relay"), raw, ""); err != nil { t.Fatalf("Put: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil); !got.Empty() { t.Fatalf("got %+v, want nothing for an exec_delivery value this binary does not understand", got) } } // TestAwaitingRestartInstanceOverlayShadowsADefaultWrite: the third layer // shadows a default-bundle write the same way a named bundle does, and for // the same reason. The merged manifest does not move, so syncExecHost's // bytes.Equal writes nothing and there is no file for anyone to restart over. func TestAwaitingRestartInstanceOverlayShadowsADefaultWrite(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { t.Fatalf("ApplyInstance: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"}); !got.Empty() { t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by this instance's own layer", got) } got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"}) if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" { t.Fatalf("Hosts = %+v, want the route for a key its overlay does not state", got.Hosts) } got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"}) if len(got.Hosts) != 1 { t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts) } } // TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite is the case // that did not exist before the third layer: a write to the route's OWN // bound bundle used to be unshadowable, because nothing sat above it. // Something does now. func TestAwaitingRestartInstanceOverlayShadowsANamedBundleWrite(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod", map[string]Var{"K": {Kind: "env", Value: "environment"}}, nil); err != nil { t.Fatalf("ApplyEnvironment: %v", err) } if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", map[string]Var{"K": {Kind: "env", Value: "instance"}}, nil); err != nil { t.Fatalf("ApplyInstance: %v", err) } routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")} if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"}); !got.Empty() { t.Fatalf("got %+v, want nothing: K is shadowed by this instance's own layer", got) } if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"OTHER"}); len(got.Hosts) != 1 { t.Fatalf("Hosts = %+v, want the route for an unshadowed key", got.Hosts) } } // TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead: a read this // function could not make is not a determination, on this layer as on the // other two — the route is named as unresolved rather than reported either // way. func TestAwaitingRestartUnresolvedWhenTheOverlayCannotBeRead(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { t.Fatalf("ApplyInstance: %v", err) } blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"), err: errors.New("s3: transient 503 SlowDown")} routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", []string{"OWN_KEY"}) if len(got.Hosts) != 0 || len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" { t.Fatalf("got %+v, want the host as unresolved and nothing claimed either way", got) } } // TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay pins that an empty // keys (a write with no vars, e.g. flipping exec_delivery on) never asks the // instance layer anything: there is nothing for it to shadow, per keys' own // doc comment, so a blip reading it must not downgrade a route this call was // certain about into Unresolved over a question it was never going to ask. func TestAwaitingRestartEmptyKeysNeverConsultsTheOverlay(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } if _, _, err := ApplyInstance(ctx, s, "bab", "relay", "connect.example.com", map[string]Var{"OWN_KEY": {Kind: "env", Value: "instance"}}, nil); err != nil { t.Fatalf("ApplyInstance: %v", err) } blip := &failingKeyStore{Store: s, failKey: InstanceConfigKey("bab", "relay", "connect.example.com"), err: errors.New("s3: transient 503 SlowDown")} routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")} got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil) if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" || len(got.Unresolved) != 0 { t.Fatalf("got %+v, want the route named in Hosts: an unreadable overlay is irrelevant when keys is empty", got) } } // TestAwaitingRestartSorted pins determinism: registry order is not stable, // and a notice whose lines reshuffle between identical runs is one people // stop reading. func TestAwaitingRestartSorted(t *testing.T) { ctx, s, _, _, _, _ := execKit(t) if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil { t.Fatalf("SetExecDelivery: %v", err) } routes := []registry.Route{ addrRoute("zebra.example.com", "relay", "bab", ""), addrRoute("alpha.example.com", "relay", "bab", ""), addrRoute("middle.example.com", "relay", "bab", ""), } got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil) want := []string{"alpha.example.com", "middle.example.com", "zebra.example.com"} if len(got.Hosts) != len(want) { t.Fatalf("Hosts = %+v, want %d", got.Hosts, len(want)) } for i, w := range want { if got.Hosts[i].Host != w { t.Fatalf("Hosts[%d] = %q, want %q", i, got.Hosts[i].Host, w) } } }