Unverified Commit c1796bea authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(servers): 新建服务器默认空闲 10 分钟自动停服,面板可调,converge 回填旧服,系统服不休眠

parent bd909d58
Loading
Loading
Loading
Loading
+13 −0
Changes for cmd/felis/apply.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -243,6 +243,19 @@ func buildMinecraftServerFromApplyRequest(req applyRequest, namespace string) (*
			AutostartPolicy: policy,
			Storage:         v1alpha1.StorageSpec{Size: storageQ.String()},
			Resources:       corev1.ResourceRequirements{Limits: limits, Requests: requests},
			// The rest matches what felis-api's create writes (K8sCluster.CreateServer):
			// fall back to the login gate while stopped, RCON on (readiness, the
			// player count and the console all ride it; the operator mints the
			// password), and the default idle stop.
			FallbackServer: naming.SystemLoginServer,
			Rcon: v1alpha1.RconSpec{
				Enabled: true,
				SecretRef: v1alpha1.SecretKeyRef{
					Name: naming.RconSecretName(req.Name),
					Key:  naming.RconSecretKey,
				},
			},
			Idle: v1alpha1.DefaultIdle(),
		},
	}, nil
}
+13 −0
Changes for cmd/felis/apply_test.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ import (
	"testing"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/naming"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
)
@@ -167,6 +168,18 @@ func TestBuildMinecraftServerFromApplyRequest_Valid(t *testing.T) {
	if ms.Spec.Storage.Size != "20Gi" {
		t.Errorf("Storage.Size = %q, want 20Gi", ms.Spec.Storage.Size)
	}
	// Same operational defaults as the API create path: without RCON the server
	// never reports players and the console answers 503; without spec.idle it
	// never stops on its own.
	if !ms.Spec.Rcon.Enabled || ms.Spec.Rcon.SecretRef.Name != naming.RconSecretName("test-server") {
		t.Errorf("Rcon = %+v, want enabled with the operator-minted secret", ms.Spec.Rcon)
	}
	if ms.Spec.Idle != v1alpha1.DefaultIdle() {
		t.Errorf("Idle = %+v, want the default %+v", ms.Spec.Idle, v1alpha1.DefaultIdle())
	}
	if ms.Spec.FallbackServer != naming.SystemLoginServer {
		t.Errorf("FallbackServer = %q, want the login gate", ms.Spec.FallbackServer)
	}
	mem, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory]
	if !ok {
		t.Fatal("memory limit missing")
+34 −2
Changes for cmd/felis/converge.go: 34 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,12 +9,14 @@ import (
	"os"
	"strings"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/platform"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

// cmdConverge is the explicit convergence pass over already-installed system
// servers (#1). Provisioning is create-if-absent, so a field the desired spec
// servers (#1), plus the idle-stop default for user servers that predate it. Provisioning is create-if-absent, so a field the desired spec
// gained after an install (spec.rcon, spec.startup.healthHTTPPort, a derived env
// key) never reaches the existing CR — and nothing says so. This command fills
// exactly those zero-value fields; see convergeSystemServers for the full contract
@@ -56,7 +58,9 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int {
		platform.InternalAPIBaseURL(controlNS), cfg.Server.RootDomain,
		defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname))

	fmt.Fprintln(stdout, "felis converge: filling fields an installed system server predates (operator-set values are never overwritten):")
	outcomes = append(outcomes, convergeUserServerIdle(context.Background(), cl, cfg.K8s.Namespace)...)

	fmt.Fprintln(stdout, "felis converge: filling fields an installed server predates (operator-set values are never overwritten):")
	exit := 0
	for _, o := range outcomes {
		switch {
@@ -71,3 +75,31 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int {
	}
	return exit
}

// convergeUserServerIdle gives every user server that predates the idle default
// (spec.idle entirely unset) the default idle stop. A server whose idle stop was
// turned off keeps a duration on its spec, so it is not "unset" and is left
// alone; system servers never idle out and are skipped. Servers that already
// carry a value produce no line, so a converged fleet prints nothing here.
func convergeUserServerIdle(ctx context.Context, cl client.Client, namespace string) []systemServerOutcome {
	var list v1alpha1.MinecraftServerList
	if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil {
		return []systemServerOutcome{{name: "user servers", err: fmt.Errorf("list servers: %w", err)}}
	}
	var out []systemServerOutcome
	for i := range list.Items {
		ms := &list.Items[i]
		if ms.Labels[v1alpha1.LabelSystemRole] != "" || ms.Spec.Idle != (v1alpha1.IdleSpec{}) {
			continue
		}
		patch := client.MergeFrom(ms.DeepCopy())
		ms.Spec.Idle = v1alpha1.DefaultIdle()
		if err := cl.Patch(ctx, ms, patch); err != nil {
			out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)})
			continue
		}
		out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true,
			changes: []string{fmt.Sprintf("spec.idle (stop after %ds empty)", v1alpha1.DefaultEmptySecondsBeforeStop)}})
	}
	return out
}
+46 −0
Changes for cmd/felis/converge_test.go: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -183,3 +183,49 @@ func TestConvergeSystemServersGuards(t *testing.T) {
		}
	})
}

// TestConvergeUserServerIdle fills the idle default only where spec.idle was
// never set: a server whose idle stop was turned off (duration kept), one with
// its own duration, and a system server all stay as they are.
func TestConvergeUserServerIdle(t *testing.T) {
	scheme := newSystemServerScheme(t)
	ctx := context.Background()
	mk := func(name string, idle v1alpha1.IdleSpec, role string) *v1alpha1.MinecraftServer {
		ms := &v1alpha1.MinecraftServer{}
		ms.Name, ms.Namespace = name, "minecraft"
		ms.Spec.Idle = idle
		if role != "" {
			ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role}
		}
		return ms
	}
	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
		mk("legacy", v1alpha1.IdleSpec{}, ""),
		mk("off", v1alpha1.IdleSpec{EmptySecondsBeforeStop: 600}, ""),
		mk("custom", v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 1800}, ""),
		mk(naming.SystemLobbyServer, v1alpha1.IdleSpec{}, naming.SystemLobbyServer),
	).Build()

	outcomes := convergeUserServerIdle(ctx, cl, "minecraft")
	if len(outcomes) != 1 || outcomes[0].name != "legacy" || outcomes[0].err != nil {
		t.Fatalf("outcomes = %+v, want exactly one fill for legacy", outcomes)
	}
	want := map[string]v1alpha1.IdleSpec{
		"legacy":                 v1alpha1.DefaultIdle(),
		"off":                    {EmptySecondsBeforeStop: 600},
		"custom":                 {AutoStopEnabled: true, EmptySecondsBeforeStop: 1800},
		naming.SystemLobbyServer: {},
	}
	for name, idle := range want {
		var ms v1alpha1.MinecraftServer
		if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil {
			t.Fatalf("get %s: %v", name, err)
		}
		if ms.Spec.Idle != idle {
			t.Errorf("%s idle = %+v, want %+v", name, ms.Spec.Idle, idle)
		}
	}
	if again := convergeUserServerIdle(ctx, cl, "minecraft"); len(again) != 0 {
		t.Fatalf("second pass = %+v, want nothing to do", again)
	}
}
+20 −4
Changes for docs/openapi.yaml: 20 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -298,6 +298,18 @@ components:
        endpointAddress: { type: string }
        playersOnline: { type: integer, format: int32 }
        playersMax: { type: integer, format: int32 }
        displayName: { type: string }
        image: { type: string }
        javaMemory: { type: string }
        storageSize: { type: string }
        cpu: { type: string }
        idleStopSeconds:
          type: integer
          format: int32
          description: Seconds the server may sit empty before idle auto-stop scales it down; 0 when it never idles out (off, RCON disabled, or a system server).
        playerCountUnknown:
          type: boolean
          description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.

    MyServerView:
      type: object
@@ -4550,8 +4562,8 @@ paths:
              type: object
              description: Only the supplied fields are patched; an empty patch is rejected.
              properties:
                display_name: { type: string }
                autostart_policy: { type: string }
                displayName: { type: string }
                autostartPolicy: { type: string }
                image: { type: string }
                memory: { type: string }
                storage:
@@ -4561,9 +4573,13 @@ paths:
                  type: object
                  properties:
                    cpu: { type: string }
                    cpu_request: { type: string }
                    cpuRequest: { type: string }
                    memory: { type: string }
                    memory_request: { type: string }
                    memoryRequest: { type: string }
                idleStopSeconds:
                  type: integer
                  format: int32
                  description: Idle auto-stop. 0 turns it off; otherwise the server stops after this many seconds with nobody online (60–86400, else 400 bad_idle_stop).
      responses:
        '200':
          description: Patched.
Loading