package registry import ( "strings" "testing" ) // TestUpsertPreservesReplication is the fence that makes the new door worth // anything. The caller it stands against is `carlos add` re-run over a host // that already exists (cli.go's runAdd, which prints "updated ... (existing // route)"): it builds its Route literal from flags alone, and it has no // replication flag, so with a plain excluded.replication a re-add to restamp // a unit or repoint an addr would return an operator's `daily` route to the // every-second default — silently, and at roughly 100x the S3 traffic the // setting was chosen to avoid (discussion #10). Same shape, same reason, as // `environment`. // // NOT the provisioning reconciler, whatever the first draft of this comment // said: its only reg.Upsert is the mint branch (reconcile.go, `case !ok`, // where there is no existing row to clobber), and the owned branch converges // through narrow setters. The domains reconciler's two Upserts are mint-only // as well. The re-Upsert below is therefore a stand-in for `carlos add`'s // shape — a full-row write that knows nothing about this column — which is // the shape any future full-row caller will have too. func TestUpsertPreservesReplication(t *testing.T) { r := openTestRegistry(t) host := "seapointish.example.com" if err := r.Upsert(Route{Host: host, Kind: "instance", App: "seapointish", Hibernate: true}); err != nil { t.Fatalf("upsert: %v", err) } if err := r.SetReplication(host, ReplicationDaily); err != nil { t.Fatalf("SetReplication: %v", err) } // A full-row Upsert built by a caller that knows nothing about this // column — `carlos add`'s shape, and the reconciler's mint's shape. if err := r.Upsert(Route{Host: host, Kind: "instance", App: "seapointish", Hibernate: true, Channel: "edge"}); err != nil { t.Fatalf("re-upsert: %v", err) } rt, err := r.Get(host) if err != nil || rt == nil { t.Fatalf("get: %+v %v", rt, err) } if rt.Replication != ReplicationDaily { t.Fatalf("replication = %q after a full-row re-Upsert, want %q — a re-add silently returned the route to the most expensive rung", rt.Replication, ReplicationDaily) } if rt.Channel != "edge" { t.Fatalf("channel = %q, want the re-Upsert's own value: preserving replication must not freeze the columns a full-row caller DOES own", rt.Channel) } } // TestUpsertHonoursAnExplicitReplication: preservation is for the zero value // only. A caller that carries a class still wins, exactly as with environment. // Both rows hibernate, because a rung on a row that does not is a state this // table refuses to hold — see TestReplicationImpliesHibernation. func TestUpsertHonoursAnExplicitReplication(t *testing.T) { r := openTestRegistry(t) host := "a.example.com" if err := r.Upsert(Route{Host: host, Hibernate: true, Replication: ReplicationDaily}); err != nil { t.Fatalf("upsert: %v", err) } if err := r.Upsert(Route{Host: host, Hibernate: true, Replication: ReplicationBatched}); err != nil { t.Fatalf("re-upsert: %v", err) } rt, _ := r.Get(host) if rt.Replication != ReplicationBatched { t.Fatalf("replication = %q, want %q", rt.Replication, ReplicationBatched) } } // TestReplicationImpliesHibernation is the invariant that stops the column // from lying after the fact. `carlos route --replication` refuses a route // that does not hibernate, because the rung is read on the activator's wake // path and nowhere else — but that rail is point-in-time, and hibernation is // flipped afterwards by three writers: `carlos route --hibernate=false`, the // provisioning converge reading an instance record, and a full-row Upsert // from `carlos add`. Without this invariant a `daily` route becomes a // non-hibernating route that `carlos routes` still reports as `daily`, while // box-wide /etc/litestream.yml replicates its database at its own interval: // the lying column the rail exists to prevent, relocated to a later moment. // // Both halves are pinned here because they are two different statements — // one in SetHibernate's UPDATE, one in Upsert's INSERT and ON CONFLICT — and // a fix to either alone leaves a door open. func TestReplicationImpliesHibernation(t *testing.T) { r := openTestRegistry(t) // The narrow setter: `carlos route --hibernate=false` and the converge // pass both arrive here. host := "sleeper.example.com" if err := r.Upsert(Route{Host: host, Kind: "instance", App: "sleeper", Hibernate: true, DBPath: "/data/sleeper.example.com.db"}); err != nil { t.Fatalf("upsert: %v", err) } if err := r.SetReplication(host, ReplicationDaily); err != nil { t.Fatalf("SetReplication: %v", err) } if err := r.SetHibernate(host, false); err != nil { t.Fatalf("SetHibernate off: %v", err) } rt, err := r.Get(host) if err != nil || rt == nil { t.Fatalf("get: %+v %v", rt, err) } if rt.Replication != "" { t.Fatalf("replication = %q on a route that stopped hibernating — box-wide litestream owns this database now, and the column claims otherwise", rt.Replication) } // Turning hibernation back ON does not resurrect the rung: a preserved // class is invisible state that changes behaviour weeks later, and the // operator is at a prompt where naming it again costs one command. if err := r.SetHibernate(host, true); err != nil { t.Fatalf("SetHibernate on: %v", err) } if rt, _ = r.Get(host); rt.Replication != "" { t.Fatalf("re-hibernating restored %q — the rung is set, not remembered", rt.Replication) } // The full-row write: `carlos add` with no --hibernate over an existing // tuned row. It turns hibernation off (hibernate is a plain overwrite), // so the preserved-when-empty CASE must not hand the new row a rung. if err := r.SetReplication(host, ReplicationBatched); err != nil { t.Fatalf("SetReplication batched: %v", err) } if err := r.Upsert(Route{Host: host, Kind: "instance", App: "sleeper", DBPath: "/data/sleeper.example.com.db"}); err != nil { t.Fatalf("re-upsert awake: %v", err) } if rt, _ = r.Get(host); rt.Replication != "" { t.Fatalf("replication = %q after an Upsert that turned hibernation off", rt.Replication) } // And on INSERT, where there is no ON CONFLICT clause to do it. fresh := "awake.example.com" if err := r.Upsert(Route{Host: fresh, Kind: "instance", App: "awake", Replication: ReplicationDaily}); err != nil { t.Fatalf("insert: %v", err) } if rt, _ = r.Get(fresh); rt.Replication != "" { t.Fatalf("a fresh non-hibernating row was inserted carrying %q", rt.Replication) } } // TestSetReplicationValidates: the value reaches internal/activator's // replicationSyncInterval, which answers anything it does not recognise with // litestream's default — so an unvalidated write is a route that quietly // never leaves the most expensive rung. SetUnit validates for the same // reason; SetBacking does not need to, because its two legal values are // compared at every door. func TestSetReplicationValidates(t *testing.T) { r := openTestRegistry(t) host := "a.example.com" if err := r.Upsert(Route{Host: host}); err != nil { t.Fatalf("upsert: %v", err) } err := r.SetReplication(host, "dayly") if err == nil { t.Fatal("SetReplication accepted an unknown class") } for _, want := range ReplicationClasses() { if !strings.Contains(err.Error(), want) { t.Errorf("refusal %q does not list %q", err, want) } } rt, _ := r.Get(host) if rt.Replication != "" { t.Fatalf("a refused write still landed: %q", rt.Replication) } // "" is legal: it is the column's default and the state a cleared route // returns to. if err := r.SetReplication(host, ReplicationBatched); err != nil { t.Fatalf("SetReplication batched: %v", err) } if err := r.SetReplication(host, ReplicationDefault); err != nil { t.Fatalf("SetReplication to the default: %v", err) } rt, _ = r.Get(host) if rt.Replication != "" { t.Fatalf("clear left %q", rt.Replication) } } // TestSetReplicationUnknownHost: like SetChannel and SetUnit, this is a // setter a human types a hostname into, so an unknown host is an error rather // than a silent no-op. func TestSetReplicationUnknownHost(t *testing.T) { r := openTestRegistry(t) err := r.SetReplication("nope.example.com", ReplicationDaily) if err == nil || !strings.Contains(err.Error(), "nope.example.com") { t.Fatalf("SetReplication(unknown host) = %v, want an error naming the host", err) } } // TestValidReplicationVocabulary pins the set itself. It lives here now // rather than unexported in internal/activator, because a setter and a reader // with two independent spellings of the same three strings is how they drift // into disagreeing about what `daily` means. func TestValidReplicationVocabulary(t *testing.T) { for _, ok := range []string{"", "stream", "batched", "daily"} { if !ValidReplication(ok) { t.Errorf("ValidReplication(%q) = false", ok) } } for _, bad := range []string{"dayly", "STREAM", "hourly", "none", "5m"} { if ValidReplication(bad) { t.Errorf("ValidReplication(%q) = true", bad) } } // The default is deliberately absent from the offered list: it is what a // route has before anyone chooses, and the way back to it is the CLI's // clear sentinel, not a name to type. for _, c := range ReplicationClasses() { if c == ReplicationDefault { t.Errorf("ReplicationClasses() offers the default as a rung to type") } } if len(ReplicationClasses()) != 3 { t.Errorf("ReplicationClasses() = %v, want the three rungs", ReplicationClasses()) } }