Support an explicit endpoint key to decouple identity from name - #1727
Support an explicit endpoint key to decouple identity from name#1727sleeyax wants to merge 7 commits into
Conversation
The endpoint key is derived from group and name and is used as the endpoint's identity in storage and API routes, so renaming an endpoint or moving it between groups orphans its history. Add an optional `key` field to Endpoint and ExternalEndpoint that, when set, is used as the key instead (sanitized, without a group prefix), keeping identity stable across name and group changes. The explicit key is threaded through ExternalEndpoint.ToEndpoint() so pushed results are stored under it, and the memory and SQL stores now report the actual key on Status rather than re-deriving it from group and name. Fixes TwiN#1146.
The endpoints table enforced UNIQUE(endpoint_name, endpoint_group), which predates the explicit endpoint key: with an explicit key, identity is decoupled from name and group, so two endpoints may legitimately share a name and group and inserting the second one failed. Drop the constraint from the schema and migrate existing databases (Postgres via DROP CONSTRAINT, SQLite by rebuilding the table), leaving endpoint_key as the sole identity guarantee.
wahajahmed010
left a comment
There was a problem hiding this comment.
Solid feature — opt-in key field, threaded through both stores, with a proper DB migration for the legacy UNIQUE(name, group) constraint. Tests cover collision detection, propagation via ToEndpoint, sanitization, and the SQL rebuild is idempotent.
One real concern, two nits:
1. SQLite migration FK pragma scope (real issue). In dropLegacyEndpointNameGroupUniqueConstraint, the ordering is:
defer conn.Close() // line A
defer conn.ExecContext(`PRAGMA foreign_keys=ON`) // line B
Go's defer is LIFO, so conn.Close() (A) runs before the FK re-enable (B). With a closed connection, the re-enable will fail and the error is discarded (_, _ = ...). On a long-lived, pooled connection this could leave foreign_keys disabled across the pool. Fix: either re-enable FK inside the tx (PRAGMA works in a transaction on SQLite ≥ 3.6.19) or swap the defers so close happens last.
2. key.Sanitize export rename. sanitize → Sanitize is fine in-tree, but if this package is consumed by external plugins (or if anything in internal/ tests the private symbol) the rename could ripple. Worth a quick grep -r 'key\.sanitize' ./... before merge.
3. README claims _ cannot appear in an explicit key. Sanitization does replace _ with -, so the claim holds — but no test asserts ExplicitKey: "foo_bar" produces "foo-bar". A 2-line test in TestEndpoint_Key would lock that invariant.
The README states that an explicit key can never reproduce a derived group/name key because `_` is replaced by `-`, but no test covered that specific substitution.
|
Thanks for the review! Went through all three points — one produced a change, the other two are already covered. 1. SQLite migration FK pragma scope — I think the LIFO reasoning is inverted here. The defers are registered in this order: defer conn.Close() // registered 1st
if _, err = conn.ExecContext(ctx, `PRAGMA foreign_keys=OFF`); err != nil { ... }
defer func() { _, _ = conn.ExecContext(ctx, `PRAGMA foreign_keys=ON`) }() // registered 2ndLIFO means the last registered defer runs first, so on return the order is: re-enable 2. 3. Missing underscore test — good catch, fixed in f05a103. The existing {
// Guarantees an explicit key can never collide with a derived group/name key, which always contains a "_" separator.
name: "explicit-key-underscore-is-replaced",
endpoint: Endpoint{Group: "g", Name: "n", ExplicitKey: "foo_bar"},
expected: "foo-bar",
}, |
wahajahmed010
left a comment
There was a problem hiding this comment.
Solid change and well motivated — the rename-orphans-history problem is real, and threading an explicit key through both Endpoint and ExternalEndpoint is the right shape. The schema-migration guard (dropLegacyEndpointNameGroupUniqueConstraint only runs when the legacy constraint is still present) keeps the upgrade idempotent, and preserving endpoint_id across the SQLite rebuild protects the FK chains. Tests are thorough.
A few concerns worth addressing before merge:
-
explicit-key-cannot-collide-with-derived-keytest looks wrong. The test case assertsshouldError: falsefor:- name: api group: backend - name: unrelated key: backend_api
But the validation in
ValidateEndpointsConfigusesep.Key()for both — endpoint 1's derived key isbackend_api, and endpoint 2'sSanitize("backend_api")is alsobackend_api. So the duplicate check should fire. Either the test is wrong (and should beshouldError: true) or the validation is wrong (and a sanitized-explicit key that matches another endpoint's derived key should be allowed). Whichever the intent, the current state is inconsistent — I bet this is the kind of thing that produces a confusing "I added a key and my config stopped loading" issue in the field. -
Sanitization asymmetry. The derived key uses
Sanitize(group) + "_" + Sanitize(name), but the explicit key is justSanitize(key)— no group prefix. That's the right call for stability (the whole point of the feature), but it means an explicit key"backend_api"is now indistinguishable from a derived key from another endpoint, which loops back to concern #1. Worth a sentence in the docs calling out that explicit keys share the same namespace as derived keys and need to be globally unique across your config. -
SQLite migration on every startup that detects the legacy constraint. The check is cheap (a
SELECT sql FROM sqlite_master), but the rebuild is not —PRAGMA foreign_keys=OFF, a 4-statement transaction, all on a single connection. Thedeferre-enablesforeign_keys, which is good, but there's a small window where another writer hitting the same store could see inconsistent state. Probably fine in practice (this only runs once per database), but worth either (a) gating the rebuild behind a config-file flag the first time only, or (b) at minimum printing a one-timelogr.Infofso operators have a breadcrumb when their Gatus startup suddenly takes longer. -
Postgres migration is silent.
_, _ = s.db.Exec(...)forALTER TABLE endpoints DROP CONSTRAINT ...swallows errors. If a user is on an older Postgres where the constraint name is different (or absent because they have a custom schema), the migration quietly does nothing — which is fine for correctness, but alogr.Debugf("legacy constraint not present, skipping")when theALTERreturns no rows would be helpful for support. -
Minor:
status.(*endpoint.Status).Key = endpointKeyinmemory.goandendpointStatus.Key = keyinsql.goare untyped. A small helper likesetStatusKey(status, key)would make the two stores symmetric and easier to keep aligned.
Approving the feature; the collision test in #1 is the must-fix.
…ed key Addresses review point 1 of TwiN#1727 (review). The asserted behavior is correct: Sanitize replaces underscores with dashes, so a sanitized explicit key can never contain the literal underscore separator that every derived key has. A comment now spells this out so the test's shouldError: false does not read as a contradiction of its name.
… table rebuild Addresses review point 3 of TwiN#1727 (review). The rebuild only runs when the legacy UNIQUE(endpoint_name, endpoint_group) constraint is still present, but it can make that one startup slower, so operators now get an informational log explaining what is happening.
Addresses review point 4 of TwiN#1727 (review). The ALTER TABLE previously ran with its error swallowed, so a database where the constraint has a different name or was already removed gave operators no trace of what happened. The schema migration now checks pg_constraint for the legacy constraint and logs whether it was dropped, absent, or failed to drop.
…of post-hoc assignments Addresses review point 5 of TwiN#1727 (review). NewStatusWithKey builds a Status with an explicit key, so the memory and sql stores no longer overwrite the derived key after construction and stay symmetric.
|
Thanks for the thorough review @wahajahmed010! Here's how each point was addressed:
The test is actually correct, and the collision it guards against cannot happen:
Already covered in the README's "Endpoint key" section, which states that the key must be unique across all endpoints and external endpoints, and explains the sanitization guarantee above (an explicit key can never reproduce a derived
Done in f65464b: a
Done in 858e5a3: the schema migration now checks
Done in 418dd05, with a slight twist: rather than a cross-package setter, |
|
Why? From my perspective, this is a cosmetic change that has no real technical benefits relevant: #1146 (comment) |
|
@TwiN please see all other replies on that issue you linked. People including myself have real usecases for this. |
Summary
The endpoint key is derived from group and name and is used as the endpoint's identity in storage and API routes, so renaming an endpoint or moving it between groups orphans its history.
Added an optional
keyfield toEndpointandExternalEndpointthat, when set, is used as the key instead (sanitized, without a group prefix), keeping identity stable across name and group changes.The explicit key is threaded through
ExternalEndpoint.ToEndpoint()so pushed results are stored under it, and the memory and SQL stores now report the actual key onStatusrather than re-deriving it from group and name.Fixes #1146.
Checklist
README.md, if applicable.