Loading deploy/crd/felis.lolicon.best_minecraftservers.yaml +0 −4 Changes for deploy/crd/felis.lolicon.best_minecraftservers.yaml: 0 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -123,10 +123,6 @@ spec: lifecycle: description: Lifecycle tunes graceful shutdown (spec §7). properties: preStopSaveAndStop: description: PreStopSaveAndStop enables the operator-injected RCON save+stop preStop. type: boolean terminationGracePeriodSeconds: description: TerminationGracePeriodSeconds is the pod grace period (default 300). Loading docs/troubleshooting.md +1 −1 Changes for docs/troubleshooting.md: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -256,7 +256,7 @@ What holds the world, in order: ``` A restore, backup or file write refused with `409 not_stopped` although the panel shows `Stopped` means the game pod is still terminating (its preStop save panel shows `Stopped` means the game pod is still terminating (its shutdown save can take a while); retry once `kubectl -n minecraft get pods -l felis.lolicon.best/server=<name>` shows nothing. Loading internal/apis/felis/v1alpha1/minecraftserver_types.go +3 −4 Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 3 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -197,13 +197,12 @@ type StorageSpec struct { StorageClassName string `json:"storageClassName,omitempty"` } // LifecycleSpec tunes graceful shutdown (spec §7). The operator injects a // preStop RCON "save-all flush; stop" hook when PreStopSaveAndStop is set. // LifecycleSpec tunes graceful shutdown (spec §7). Before scaling a server with // RCON to zero the operator runs "save-all flush" over RCON; the grace period is // then the time the server has to finish its own shutdown save after SIGTERM. type LifecycleSpec struct { // TerminationGracePeriodSeconds is the pod grace period (default 300). TerminationGracePeriodSeconds int64 `json:"terminationGracePeriodSeconds,omitempty"` // PreStopSaveAndStop enables the operator-injected RCON save+stop preStop. PreStopSaveAndStop bool `json:"preStopSaveAndStop,omitempty"` } // StartupSpec bounds the Starting phase (spec §5). Loading internal/operator/builders.go +4 −24 Changes for internal/operator/builders.go: 4 added lines, 24 removed lines. Original line number Diff line number Diff line Loading @@ -108,18 +108,6 @@ func rconAddress(server *v1alpha1.MinecraftServer) string { return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, rconPort(server)) } // preStopScript is the operator-injected graceful-shutdown sequence (spec §7): // flush the world, then stop the server, both over RCON. It relies on rcon-cli // being present in the Felis base image and reading the RCON_* env injected // alongside it. func preStopScript(server *v1alpha1.MinecraftServer) string { port := rconPort(server) return fmt.Sprintf( `rcon-cli --port %d --password "$RCON_PASSWORD" save-all flush; rcon-cli --port %d --password "$RCON_PASSWORD" stop`, port, port, ) } // buildHeadlessService backs the StatefulSet's stable network identity. func buildHeadlessService(server *v1alpha1.MinecraftServer) *corev1.Service { svc := &corev1.Service{ Loading Loading @@ -198,9 +186,10 @@ func readinessProbe(server *v1alpha1.MinecraftServer) *corev1.Probe { return probe } // buildStatefulSet renders the workload for replicas in {0,1}. It is where // graceful shutdown is injected: the pod gets terminationGracePeriodSeconds and // (when enabled) a preStop RCON save+stop hook. // buildStatefulSet renders the workload for replicas in {0,1}. Its half of // graceful shutdown is terminationGracePeriodSeconds, the time the server gets to // save on SIGTERM; the reconciler flushes the world over RCON before it scales to // zero (saveBeforeStop). func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisImage string) (*appsv1.StatefulSet, error) { storageSize := server.Spec.Storage.Size if storageSize == "" { Loading Loading @@ -245,15 +234,6 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma container.Ports = append(container.Ports, corev1.ContainerPort{ Name: "rcon", ContainerPort: rconPort(server), Protocol: corev1.ProtocolTCP, }) if server.Spec.Lifecycle.PreStopSaveAndStop { container.Lifecycle = &corev1.Lifecycle{ PreStop: &corev1.LifecycleHandler{ Exec: &corev1.ExecAction{ Command: []string{"/bin/sh", "-c", preStopScript(server)}, }, }, } } } // Every server first hands its world volume to the game uid (prepareDataInitContainer), Loading internal/operator/prober.go +54 −15 Changes for internal/operator/prober.go: 54 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -20,21 +20,52 @@ type PlayerCount struct { Known bool } // Prober reports whether a server's RCON endpoint is reachable and accepts the // password, and best-effort returns its current player tally. A nil error is the // loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and has // Known=false (with a nil error) whenever the tally could not be sampled. It is an // interface so the reconciler can be tested without a live server. // Prober is the operator's RCON channel into a running server. It is an interface // so the reconciler can be tested without a live server. // // Probe reports whether the endpoint is reachable and accepts the password, and // best-effort returns the current player tally. A nil error is the loader-agnostic // readiness gate (spec §5); the PlayerCount is advisory and has Known=false (with // a nil error) whenever the tally could not be sampled. // // Save flushes the world to disk (`save-all flush`) and returns once the server // has answered, i.e. once the save is done. The reconciler runs it right before // scaling a server to zero (spec §7). type Prober interface { Probe(ctx context.Context, addr, password string) (PlayerCount, error) Save(ctx context.Context, addr, password string) error } // RconProber is the production Prober: a successful Dial (TCP connect + auth) // is the readiness gate; on that same connection it then runs `list` to sample // the player tally before closing. type RconProber struct { // Timeout bounds a single probe. Defaults to 5s. // Timeout bounds a single probe, and the connect+auth step of a save. // Defaults to 5s. Timeout time.Duration // SaveTimeout bounds the wait for `save-all flush` to answer. Defaults to // defaultSaveTimeout. SaveTimeout time.Duration } // defaultSaveTimeout is how long a stop waits for the pre-stop save. The server // answers `save-all flush` only after every loaded chunk is written, which takes // seconds on a large world. Past this the stop goes ahead anyway: SIGTERM runs the // server's own shutdown save within the pod's grace period, so the explicit save // only moves most of that work ahead of the kill deadline. const defaultSaveTimeout = 30 * time.Second // boundTimeout returns d (or def when d is unset), shortened to ctx's deadline. func boundTimeout(ctx context.Context, d, def time.Duration) time.Duration { if d <= 0 { d = def } if dl, ok := ctx.Deadline(); ok { if remaining := time.Until(dl); remaining > 0 && remaining < d { d = remaining } } return d } // Probe dials addr and authenticates with password, honoring the smaller of the Loading @@ -43,15 +74,7 @@ type RconProber struct { // a failed or unparseable `list` yields an unknown PlayerCount, never a probe error, // so a transient count-read hiccup can never flap a healthy server out of Ready. func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) { timeout := p.Timeout if timeout <= 0 { timeout = 5 * time.Second } if dl, ok := ctx.Deadline(); ok { if remaining := time.Until(dl); remaining > 0 && remaining < timeout { timeout = remaining } } timeout := boundTimeout(ctx, p.Timeout, 5*time.Second) conn, err := rcon.Dial(addr, password, timeout) if err != nil { return PlayerCount{}, err Loading @@ -71,6 +94,22 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCou return pc, nil } // Save runs `save-all flush` and waits for its reply. The reply text is not // checked: vanilla, Paper and the modded loaders word it differently, and any // reply at all means the command ran to completion on the server thread. func (p RconProber) Save(ctx context.Context, addr, password string) error { conn, err := rcon.Dial(addr, password, boundTimeout(ctx, p.Timeout, 5*time.Second)) if err != nil { return err } defer conn.Close() if err := conn.SetDeadline(time.Now().Add(boundTimeout(ctx, p.SaveTimeout, defaultSaveTimeout))); err != nil { return err } _, err = conn.Execute("save-all flush") return err } // listReplyPatterns match the `list` replies of the loaders Felis runs, tried in // order against the reply with § color codes stripped: // Loading Loading
deploy/crd/felis.lolicon.best_minecraftservers.yaml +0 −4 Changes for deploy/crd/felis.lolicon.best_minecraftservers.yaml: 0 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -123,10 +123,6 @@ spec: lifecycle: description: Lifecycle tunes graceful shutdown (spec §7). properties: preStopSaveAndStop: description: PreStopSaveAndStop enables the operator-injected RCON save+stop preStop. type: boolean terminationGracePeriodSeconds: description: TerminationGracePeriodSeconds is the pod grace period (default 300). Loading
docs/troubleshooting.md +1 −1 Changes for docs/troubleshooting.md: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -256,7 +256,7 @@ What holds the world, in order: ``` A restore, backup or file write refused with `409 not_stopped` although the panel shows `Stopped` means the game pod is still terminating (its preStop save panel shows `Stopped` means the game pod is still terminating (its shutdown save can take a while); retry once `kubectl -n minecraft get pods -l felis.lolicon.best/server=<name>` shows nothing. Loading
internal/apis/felis/v1alpha1/minecraftserver_types.go +3 −4 Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 3 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -197,13 +197,12 @@ type StorageSpec struct { StorageClassName string `json:"storageClassName,omitempty"` } // LifecycleSpec tunes graceful shutdown (spec §7). The operator injects a // preStop RCON "save-all flush; stop" hook when PreStopSaveAndStop is set. // LifecycleSpec tunes graceful shutdown (spec §7). Before scaling a server with // RCON to zero the operator runs "save-all flush" over RCON; the grace period is // then the time the server has to finish its own shutdown save after SIGTERM. type LifecycleSpec struct { // TerminationGracePeriodSeconds is the pod grace period (default 300). TerminationGracePeriodSeconds int64 `json:"terminationGracePeriodSeconds,omitempty"` // PreStopSaveAndStop enables the operator-injected RCON save+stop preStop. PreStopSaveAndStop bool `json:"preStopSaveAndStop,omitempty"` } // StartupSpec bounds the Starting phase (spec §5). Loading
internal/operator/builders.go +4 −24 Changes for internal/operator/builders.go: 4 added lines, 24 removed lines. Original line number Diff line number Diff line Loading @@ -108,18 +108,6 @@ func rconAddress(server *v1alpha1.MinecraftServer) string { return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, rconPort(server)) } // preStopScript is the operator-injected graceful-shutdown sequence (spec §7): // flush the world, then stop the server, both over RCON. It relies on rcon-cli // being present in the Felis base image and reading the RCON_* env injected // alongside it. func preStopScript(server *v1alpha1.MinecraftServer) string { port := rconPort(server) return fmt.Sprintf( `rcon-cli --port %d --password "$RCON_PASSWORD" save-all flush; rcon-cli --port %d --password "$RCON_PASSWORD" stop`, port, port, ) } // buildHeadlessService backs the StatefulSet's stable network identity. func buildHeadlessService(server *v1alpha1.MinecraftServer) *corev1.Service { svc := &corev1.Service{ Loading Loading @@ -198,9 +186,10 @@ func readinessProbe(server *v1alpha1.MinecraftServer) *corev1.Probe { return probe } // buildStatefulSet renders the workload for replicas in {0,1}. It is where // graceful shutdown is injected: the pod gets terminationGracePeriodSeconds and // (when enabled) a preStop RCON save+stop hook. // buildStatefulSet renders the workload for replicas in {0,1}. Its half of // graceful shutdown is terminationGracePeriodSeconds, the time the server gets to // save on SIGTERM; the reconciler flushes the world over RCON before it scales to // zero (saveBeforeStop). func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisImage string) (*appsv1.StatefulSet, error) { storageSize := server.Spec.Storage.Size if storageSize == "" { Loading Loading @@ -245,15 +234,6 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma container.Ports = append(container.Ports, corev1.ContainerPort{ Name: "rcon", ContainerPort: rconPort(server), Protocol: corev1.ProtocolTCP, }) if server.Spec.Lifecycle.PreStopSaveAndStop { container.Lifecycle = &corev1.Lifecycle{ PreStop: &corev1.LifecycleHandler{ Exec: &corev1.ExecAction{ Command: []string{"/bin/sh", "-c", preStopScript(server)}, }, }, } } } // Every server first hands its world volume to the game uid (prepareDataInitContainer), Loading
internal/operator/prober.go +54 −15 Changes for internal/operator/prober.go: 54 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -20,21 +20,52 @@ type PlayerCount struct { Known bool } // Prober reports whether a server's RCON endpoint is reachable and accepts the // password, and best-effort returns its current player tally. A nil error is the // loader-agnostic readiness gate (spec §5); the PlayerCount is advisory and has // Known=false (with a nil error) whenever the tally could not be sampled. It is an // interface so the reconciler can be tested without a live server. // Prober is the operator's RCON channel into a running server. It is an interface // so the reconciler can be tested without a live server. // // Probe reports whether the endpoint is reachable and accepts the password, and // best-effort returns the current player tally. A nil error is the loader-agnostic // readiness gate (spec §5); the PlayerCount is advisory and has Known=false (with // a nil error) whenever the tally could not be sampled. // // Save flushes the world to disk (`save-all flush`) and returns once the server // has answered, i.e. once the save is done. The reconciler runs it right before // scaling a server to zero (spec §7). type Prober interface { Probe(ctx context.Context, addr, password string) (PlayerCount, error) Save(ctx context.Context, addr, password string) error } // RconProber is the production Prober: a successful Dial (TCP connect + auth) // is the readiness gate; on that same connection it then runs `list` to sample // the player tally before closing. type RconProber struct { // Timeout bounds a single probe. Defaults to 5s. // Timeout bounds a single probe, and the connect+auth step of a save. // Defaults to 5s. Timeout time.Duration // SaveTimeout bounds the wait for `save-all flush` to answer. Defaults to // defaultSaveTimeout. SaveTimeout time.Duration } // defaultSaveTimeout is how long a stop waits for the pre-stop save. The server // answers `save-all flush` only after every loaded chunk is written, which takes // seconds on a large world. Past this the stop goes ahead anyway: SIGTERM runs the // server's own shutdown save within the pod's grace period, so the explicit save // only moves most of that work ahead of the kill deadline. const defaultSaveTimeout = 30 * time.Second // boundTimeout returns d (or def when d is unset), shortened to ctx's deadline. func boundTimeout(ctx context.Context, d, def time.Duration) time.Duration { if d <= 0 { d = def } if dl, ok := ctx.Deadline(); ok { if remaining := time.Until(dl); remaining > 0 && remaining < d { d = remaining } } return d } // Probe dials addr and authenticates with password, honoring the smaller of the Loading @@ -43,15 +74,7 @@ type RconProber struct { // a failed or unparseable `list` yields an unknown PlayerCount, never a probe error, // so a transient count-read hiccup can never flap a healthy server out of Ready. func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCount, error) { timeout := p.Timeout if timeout <= 0 { timeout = 5 * time.Second } if dl, ok := ctx.Deadline(); ok { if remaining := time.Until(dl); remaining > 0 && remaining < timeout { timeout = remaining } } timeout := boundTimeout(ctx, p.Timeout, 5*time.Second) conn, err := rcon.Dial(addr, password, timeout) if err != nil { return PlayerCount{}, err Loading @@ -71,6 +94,22 @@ func (p RconProber) Probe(ctx context.Context, addr, password string) (PlayerCou return pc, nil } // Save runs `save-all flush` and waits for its reply. The reply text is not // checked: vanilla, Paper and the modded loaders word it differently, and any // reply at all means the command ran to completion on the server thread. func (p RconProber) Save(ctx context.Context, addr, password string) error { conn, err := rcon.Dial(addr, password, boundTimeout(ctx, p.Timeout, 5*time.Second)) if err != nil { return err } defer conn.Close() if err := conn.SetDeadline(time.Now().Add(boundTimeout(ctx, p.SaveTimeout, defaultSaveTimeout))); err != nil { return err } _, err = conn.Execute("save-all flush") return err } // listReplyPatterns match the `list` replies of the loaders Felis runs, tried in // order against the reply with § color codes stripped: // Loading