[management] Fold casing on agent network identity the legacy schema kept

The legacy bootstrap stored the cluster as the caller spelled it — trimmed,
never folded — and the reshape that shipped in 0.78 copied it into
proxy_address, and subdomain.cluster into domain, verbatim. Everything that
reads those columns compares against canonical lowercase: proxies
canonicalise their address at connect, the gateway-pin check a proxy
registration runs matches proxy_address exactly, cluster-scoped synthesis
finds an account's row by proxy_address, and the proxy folds the SNI host
before matching a mapping's domain. A row that kept capitals is invisible
to all of them — its pin protects nothing and its endpoint never routes.

The reshape now writes LOWER() for both columns, and an idempotent
normaliser lowercases rows a released reshape already copied, registered
right after it. The predicate selects only rows that would change, so a
normalised table costs one pass over one row per account; on MySQL the
default collation compares case-insensitively already and it is a no-op.

Reported by cubic on #7402: the exact proxy_address match a proxy
registration relies on misses a migrated pin with capitals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
This commit is contained in:
mlsmaycon
2026-09-12 12:53:15 +00:00
co-authored by Claude Fable 5.1
parent 138cb3b3e0
commit 39f8ea3f70
2 changed files with 111 additions and 6 deletions
@@ -66,12 +66,18 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
}
if hasCluster {
concat := "subdomain || '.' || cluster"
// The legacy bootstrap stored the cluster as the caller spelled
// it (trimmed, never folded), while every path that reads these
// columns now compares against canonical lowercase: proxy
// addresses are canonicalised at connect and the proxy folds the
// SNI host it routes on. Fold here so the reshaped row is
// addressable, rather than copying a spelling nothing will match.
concat := "LOWER(subdomain || '.' || cluster)"
if tx.Name() == "mysql" {
concat = "CONCAT(subdomain, '.', cluster)"
concat = "LOWER(CONCAT(subdomain, '.', cluster))"
}
res := tx.Exec(fmt.Sprintf(
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
"UPDATE agent_network_settings SET domain = %s, proxy_address = LOWER(cluster) WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
concat,
))
if res.Error != nil {
@@ -110,3 +116,56 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
return nil
})
}
// agentNetworkSettingsIdentity is the post-reshape view of the two identity
// columns, enough for the normaliser to address the table without importing
// the current model.
type agentNetworkSettingsIdentity struct {
AccountID string `gorm:"primaryKey"`
Domain string `gorm:"type:varchar(255)"`
ProxyAddress string `gorm:"type:varchar(255)"`
}
func (agentNetworkSettingsIdentity) TableName() string { return "agent_network_settings" }
// NormalizeAgentNetworkSettingsIdentity lowercases domain and proxy_address
// on rows already reshaped by a release whose backfill copied the legacy
// cluster spelling verbatim.
//
// Both columns are compared exactly against canonical lowercase values: a
// proxy registering at a host asks whether another account's gateway is
// pinned there by proxy_address, cluster-scoped mapping synthesis finds the
// accounts a proxy serves the same way, and the proxy itself folds the SNI
// host before matching a mapping's domain. A row that kept capitals from the
// legacy schema is invisible to all three — its pin does not protect the
// host, and its endpoint is never matched — so the value is repaired where it
// is stored rather than folded on every read.
//
// Idempotent: the WHERE clause selects only rows that would change, so a
// normalised table costs one pass over a table holding one row per account.
// On MySQL the default collation already compares case-insensitively, so the
// predicate never matches there and the statement is a no-op, which is the
// right answer: nothing on MySQL was invisible to begin with. Runs after the
// reshape, so the columns exist whenever the table does. Two rows that differ
// only by case would collapse onto one domain, which the unique index
// refuses; that state is unreachable through the API and the migration fails
// loudly rather than guessing which endpoint to keep.
func NormalizeAgentNetworkSettingsIdentity(ctx context.Context, db *gorm.DB) error {
model := &agentNetworkSettingsIdentity{}
migrator := db.Migrator()
if !migrator.HasTable(model) || !migrator.HasColumn(model, "Domain") || !migrator.HasColumn(model, "ProxyAddress") {
return nil
}
res := db.Exec("UPDATE agent_network_settings SET domain = LOWER(domain), proxy_address = LOWER(proxy_address) " +
"WHERE domain <> LOWER(domain) OR proxy_address <> LOWER(proxy_address)")
if res.Error != nil {
return fmt.Errorf("normalize agent_network_settings identity casing: %w", res.Error)
}
if res.RowsAffected > 0 {
log.WithContext(ctx).Infof("normalized casing on %d agent_network_settings row(s)", res.RowsAffected)
}
return nil
}