fix(setup): stop the login gate sending players to the old console after a re-domain

Changing root_domain updated felis.toml and the panel, but the login gate kept
pointing players at the hostname it was created with. FELIS_ROOT_DOMAIN and
FELIS_PANEL_HOSTNAME are baked into the login MinecraftServer at provisioning time,
FelisLimboPlugin reads them to build the link an unauthenticated player is told to
open, and ensureSystemServers is create-if-absent — so nothing in the install ever
rewrote them. On the demo host the CR still carried
console.159.223.32.51.nip.io hours after the domain had moved to
mc.flyemoji.network: every joining player was handed a link that bypasses the
tunnel, hits the node directly and trips a certificate warning, on the one screen
someone with no account is guaranteed to see.

setup now converges these values on an existing system service instead of skipping
it. Create-if-absent stays the rule for everything else, and the comment on it is
still true — an operator's edits to a system service must survive a re-run. These
three names are the exception because they are not the operator's to own: they are a
copy of config that is wrong the moment config changes, and there is no other writer
who could notice.

The convergence is deliberately narrow. Only a name already present with a different
value is rewritten, so env the operator added by hand is untouched and the rest of
the spec — image, memory, storage — is not read at all. A derived name that is
absent from the live object is left absent rather than added back: a deliberate
removal and drift look identical from here, and re-adding it would mean fighting the
operator on every run. The outcome string reports the refresh so a setup run does not
silently rewrite the front door.

This closes one surface of a re-domain, not the whole of it. The write-once panel
certificate at deploy/bootstrap.sh keeps its old SANs, and so do the Velocity config
and the forwarding material; the warning in bootstrap.sh that says so is still
accurate. What changes is that the surface players actually walk through now catches
up when setup is re-run.

Checks: a login gate built with the old domain converges onto the new one and says
so; an env var the operator added and a hand-raised javaMemory both survive that
same run; and an install whose config already matches reports no refresh, so a
routine setup does not read like a re-domain. The middle one is the one worth having
— converging config must not turn into a licence to clobber the edits
create-if-absent exists to protect.
This commit is contained in:
flyemoji committed 2026-07-21 00:47:04 +09:00
1 parent 80a29ba653
commit a0064adfdd
2 files changed
+164 -1

No files matched your search

+59 -1
View File
@@ -308,7 +308,16 @@ func ensureSystemServers(ctx context.Context, cl client.Client, namespace, login
})
continue
}
outcomes = append(outcomes, systemServerOutcome{name: p.name, available: true, skipped: "already exists"})
refreshed, err := refreshDerivedEnv(ctx, cl, &existing, ms)
if err != nil {
outcomes = append(outcomes, systemServerOutcome{name: p.name, err: err})
continue
}
skipped := "already exists"
if refreshed {
skipped = "already exists; refreshed the console hostnames it points players at"
}
outcomes = append(outcomes, systemServerOutcome{name: p.name, available: true, skipped: skipped})
continue
}
if !apierrors.IsNotFound(getErr) {
@@ -344,6 +353,55 @@ func ensureSystemServers(ctx context.Context, cl client.Client, namespace, login
return outcomes
}
// derivedSystemEnv are the system-server env vars whose values setup computes from
// config rather than inventing. They are the exception to create-if-absent, and the
// exception is narrow on purpose.
//
// Everything else on an existing system service is left alone so an operator's edits
// survive a re-run — but these are not the operator's to own, they are a copy of
// config that goes stale the moment config changes. That is not hypothetical: after
// a root-domain change the login gate keeps handing every joining player a console
// link built from the OLD domain, which is the one screen an unauthenticated player
// is guaranteed to see. Nothing else in the install rewrites them, so a re-run of
// setup is the only chance they get to catch up.
var derivedSystemEnv = map[string]bool{
envAPIBaseURL: true,
envRootDomain: true,
envPanelHostname: true,
}
// refreshDerivedEnv converges the config-derived env of an existing system server
// onto what setup just computed, and reports whether anything actually changed.
//
// It only ever overwrites a name that is already present with a different value, and
// only for the names above: env the operator added by hand is untouched, and a name
// missing from the live object is left missing rather than added back, since a
// deliberate removal is indistinguishable from drift and re-adding it would fight the
// operator every run.
func refreshDerivedEnv(ctx context.Context, cl client.Client, existing, desired *v1alpha1.MinecraftServer) (bool, error) {
want := make(map[string]string, len(derivedSystemEnv))
for _, e := range desired.Spec.Env {
if derivedSystemEnv[e.Name] {
want[e.Name] = e.Value
}
}
changed := false
for i, e := range existing.Spec.Env {
if v, ok := want[e.Name]; ok && v != e.Value {
existing.Spec.Env[i].Value = v
changed = true
}
}
if !changed {
return false, nil
}
if err := cl.Update(ctx, existing); err != nil {
return false, fmt.Errorf("refresh %s env: %w", existing.Name, err)
}
return true, nil
}
// The login gate is a hard prerequisite of the Owner bind, so setup waits for it
// rather than racing it. The ceiling covers a cold image pull on a fresh node;
// the poll is fast enough that a warm start feels immediate.