diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 845c720..1ad001e 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -185,9 +185,14 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine server.Status.EmptySince = &t } else if r.now().Time.Sub(server.Status.EmptySince.Time).Seconds() >= float64(server.Spec.Idle.EmptySecondsBeforeStop) { + // Merge patch, not Update: an unrelated reconcile writes status + // concurrently, and shipping the whole object back risks + // clobbering it (the reaper's Stop uses the same pattern for + // the same reason). EmptySince is deliberately left for + // markStopped to clear once the scale-down completes. + patch := client.MergeFrom(server.DeepCopy()) server.Spec.DesiredState = v1alpha1.DesiredStopped - server.Status.EmptySince = nil - if err := r.Update(ctx, server); err != nil { + if err := r.Patch(ctx, server, patch); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/internal/platform/rbac.go b/internal/platform/rbac.go index 270d13c..a915e62 100644 --- a/internal/platform/rbac.go +++ b/internal/platform/rbac.go @@ -136,12 +136,15 @@ func APIBuildRole(p Params) *rbacv1.Role { // the manager's cache is namespace-scoped (see cmd/felis/operator.go), so a // namespaced Role is sufficient. The operator owns StatefulSets and Services // (Get/Create/Update — never patch or delete), writes only minecraftservers -// status (Status().Update — `update` only), and reads RCON Secrets. It never -// touches pods, PVCs, Events, or finalizers, so none appear here. +// status (Status().Update — `update` only) and patches spec.desiredState to +// Stopped for idle auto-stop (spec §8 — the one spec field it may write, using +// the same merge patch as the reaper's Stop: without the grant the auto-stop +// call fails closed with a 403), and reads RCON Secrets. It never touches pods, +// PVCs, Events, or finalizers, so none appear here. func OperatorRole(p Params) *rbacv1.Role { p = p.withDefaults() return role(p.MinecraftNamespace, "felis-operator", ComponentOperator, []rbacv1.PolicyRule{ - rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch"}), + rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "patch"}), rule([]string{groupFelis}, []string{"minecraftservers/status"}, []string{"update"}), rule([]string{groupApps}, []string{"statefulsets"}, []string{"get", "list", "watch", "create", "update"}), rule([]string{groupCore}, []string{"services"}, []string{"get", "list", "watch", "create", "update"}), diff --git a/internal/platform/rbac_test.go b/internal/platform/rbac_test.go index 8811827..f78ce36 100644 --- a/internal/platform/rbac_test.go +++ b/internal/platform/rbac_test.go @@ -149,6 +149,11 @@ func TestOperatorRole_ScopeExact(t *testing.T) { t.Errorf("operator must have minecraftservers:%s (cached client)", v) } } + // Idle auto-stop patches spec.desiredState=Stopped (spec §8). Found live: + // without this grant the auto-stop fails closed with a 403. + if !hasRule(op, groupFelis, "minecraftservers", "patch") { + t.Error("operator must have minecraftservers:patch (idle auto-stop writes spec.desiredState)") + } for _, v := range []string{"get", "list", "watch", "create", "update"} { if !hasRule(op, "apps", "statefulsets", v) { t.Errorf("operator must have statefulsets:%s", v)