refactor(deploy)!: TUI

This commit is contained in:
Lemon-miaow committed 2026-06-29 20:17:17 +08:00
1 parent 318a724f98
commit e5f1682898
37 files changed
+3622 -1303

No files matched your search

+3 -5
View File
@@ -35,11 +35,9 @@ type Object interface {
// make the SAs and NetworkPolicy peers refer to something real (see workloads.go).
// The reaper CronJob is also part of Workloads, rendered only when the retention
// storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath —
// workloads.go documents the gate and the shape-asserted hostPath caveat). Still
// deliberately NOT rendered: a felis-api Service (its exposure is an out-of-band
// deployment choice and nothing in-tree dials it). The per-server StatefulSet is
// never a static manifest — the operator renders it at reconcile time
// (internal/operator).
// workloads.go documents the gate and the shape-asserted hostPath caveat). The
// per-server StatefulSet is never a static manifest — the operator renders it at
// reconcile time (internal/operator).
func Objects(p Params) []Object {
p = p.withDefaults()
var objs []Object
+7
View File
@@ -53,6 +53,7 @@ const (
DefaultControlNamespace = "felis"
DefaultMinecraftNamespace = "minecraft"
DefaultBuildNamespace = "felis-build"
DefaultPanelNodePort = int32(30443)
defaultRegistryPort int32 = 5000
@@ -88,6 +89,9 @@ type Params struct {
// control namespace (registry co-located with the control plane).
RegistryNamespace string
RegistryPort int32
// PanelNodePort exposes the built-in HTTPS panel/API origin from the node.
// It defaults to 30443 so a fresh setup can finish with a concrete browser URL.
PanelNodePort int32
// PackageSourceCIDRs is the explicit package-mirror egress allowlist for build
// Pods (spec §16). Empty means no internet egress at all — the locked-down
// default the build subsystem already enforces.
@@ -159,6 +163,9 @@ func (p Params) withDefaults() Params {
if p.RegistryPort == 0 {
p.RegistryPort = defaultRegistryPort
}
if p.PanelNodePort == 0 {
p.PanelNodePort = DefaultPanelNodePort
}
if p.RegistryImage == "" {
p.RegistryImage = defaultRegistryImage
}
+40 -12
View File
@@ -40,15 +40,6 @@ import (
// the worlds to one node implicitly; a multi-node deployment MUST add one (or the
// CronJob could schedule on a node where the hostPath is empty) — a hazard left on
// record here until multi-node retention is built.
//
// Deliberately NOT rendered:
// - A Service for felis-api. Its external face (8080) is exposed out-of-band
// (Ingress/LoadBalancer is a deployment choice) and its internal face's only
// consumer is the Velocity plugin; nothing in-tree dials a felis-api Service
// name, so rendering one would be a speculative selector. The registry Service
// IS rendered because registry.<ns>.svc:5000 is a pinned consumer hardcoded
// across the build subsystem and config.
const (
// configSecretName / serviceTokenSecretName are referenced BY NAME and NEVER
// rendered into the bundle: felis.toml carries the database URL (a credential)
@@ -59,6 +50,7 @@ const (
configSecretKey = "felis.toml"
configMountPath = "/etc/felis"
configFilePath = "/etc/felis/felis.toml"
felisBinaryPath = "/usr/local/bin/felis"
serviceTokenSecretName = "felis-service-token"
serviceTokenSecretKey = "token"
@@ -67,7 +59,10 @@ const (
// agreement lives in the out-of-band config Secret and cannot be enforced here.
apiExternalPort int32 = 8080
apiInternalPort int32 = 8081
apiHTTPSPort int32 = 8443
operatorMetricsPort int32 = 8080
apiTLSSecretName = "felis-api-tls"
apiTLSMountPath = "/etc/felis/tls"
registryName = "registry"
registryDataPath = "/var/lib/registry"
@@ -113,6 +108,7 @@ func Workloads(p Params) []Object {
p = p.withDefaults()
objs := []Object{
APIDeployment(p),
apiService(p),
OperatorDeployment(p),
registryDeployment(p),
registryService(p),
@@ -169,18 +165,23 @@ func APIDeployment(p Params) *appsv1.Deployment {
container := corev1.Container{
Name: ComponentAPI,
Image: p.FelisImage,
Command: []string{"felis", "api"},
Command: []string{felisBinaryPath, "api"},
Args: []string{
"--config", configFilePath,
"--internal-addr", fmt.Sprintf(":%d", apiInternalPort),
"--https-addr", fmt.Sprintf(":%d", apiHTTPSPort),
"--tls-cert", apiTLSMountPath + "/tls.crt",
"--tls-key", apiTLSMountPath + "/tls.key",
},
Env: env,
Ports: []corev1.ContainerPort{
{Name: "external", ContainerPort: apiExternalPort, Protocol: corev1.ProtocolTCP},
{Name: "https", ContainerPort: apiHTTPSPort, Protocol: corev1.ProtocolTCP},
{Name: "internal", ContainerPort: apiInternalPort, Protocol: corev1.ProtocolTCP},
},
VolumeMounts: []corev1.VolumeMount{
{Name: configVolume, MountPath: configMountPath, ReadOnly: true},
{Name: "tls", MountPath: apiTLSMountPath, ReadOnly: true},
{Name: tmpVolume, MountPath: "/tmp"},
},
Resources: controlPlaneResources(),
@@ -194,12 +195,39 @@ func APIDeployment(p Params) *appsv1.Deployment {
Secret: &corev1.SecretVolumeSource{SecretName: configSecretName},
},
},
{
Name: "tls",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: apiTLSSecretName},
},
},
{Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
}
return controlPlaneDeployment(p, SAAPI, container, volumes)
}
// apiService exposes the built-in HTTPS panel/API origin as a stable NodePort.
func apiService(p Params) *corev1.Service {
p = p.withDefaults()
labels := controlPlanePodLabels(ComponentAPI)
return &corev1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: SAAPI, Namespace: p.ControlNamespace, Labels: labels},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeNodePort,
Selector: labels,
Ports: []corev1.ServicePort{{
Name: "https",
Port: 443,
TargetPort: intstr.FromString("https"),
NodePort: p.PanelNodePort,
Protocol: corev1.ProtocolTCP,
}},
},
}
}
// OperatorDeployment renders the felis-operator Deployment (spec §5). It runs as
// the felis-operator SA and carries controlPlanePodLabels(operator), the second
// pod the allow-rcon peer admits (the readiness prober dials RCON). It takes NO
@@ -213,7 +241,7 @@ func OperatorDeployment(p Params) *appsv1.Deployment {
container := corev1.Container{
Name: ComponentOperator,
Image: p.FelisImage,
Command: []string{"felis", "operator"},
Command: []string{felisBinaryPath, "operator"},
Args: []string{
"--namespace", p.MinecraftNamespace,
"--metrics-bind-address", fmt.Sprintf(":%d", operatorMetricsPort),
@@ -271,7 +299,7 @@ func reaperCronJob(p Params) *batchv1.CronJob {
container := corev1.Container{
Name: ComponentReaper,
Image: p.FelisImage,
Command: []string{"felis", "reaper"},
Command: []string{felisBinaryPath, "reaper"},
Args: []string{
"--config", configFilePath,
"--worlds-root", worldsMountPath,
+49 -11
View File
@@ -153,8 +153,8 @@ func TestAPIDeployment_Wiring(t *testing.T) {
d := APIDeployment(p)
ps, c := podSpec(t, d)
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "api"}) {
t.Errorf("api command/args = %v, want it to start `felis api`", got)
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "api"}) {
t.Errorf("api command/args = %v, want it to start `%s api`", got, felisBinaryPath)
}
if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) {
t.Errorf("api args must mount config at %s, got %v", configFilePath, c.Args)
@@ -162,6 +162,13 @@ func TestAPIDeployment_Wiring(t *testing.T) {
if !contains(c.Args, "--internal-addr") {
t.Errorf("api args must set --internal-addr, got %v", c.Args)
}
if !contains(c.Args, "--https-addr") || !contains(c.Args, ":8443") {
t.Errorf("api args must set HTTPS listener, got %v", c.Args)
}
if !contains(c.Args, "--tls-cert") || !contains(c.Args, apiTLSMountPath+"/tls.crt") ||
!contains(c.Args, "--tls-key") || !contains(c.Args, apiTLSMountPath+"/tls.key") {
t.Errorf("api args must point at mounted TLS secret, got %v", c.Args)
}
if c.Image != p.FelisImage {
t.Errorf("api image = %q, want FelisImage %q", c.Image, p.FelisImage)
}
@@ -194,6 +201,13 @@ func TestAPIDeployment_Wiring(t *testing.T) {
if m := mountByName(c.VolumeMounts, configVolume); m == nil || !m.ReadOnly {
t.Error("config volume must be mounted read-only")
}
tlsVol := volumeByName(ps.Volumes, "tls")
if tlsVol == nil || tlsVol.Secret == nil || tlsVol.Secret.SecretName != apiTLSSecretName {
t.Fatalf("tls volume must mount Secret %q, got %#v", apiTLSSecretName, tlsVol)
}
if m := mountByName(c.VolumeMounts, "tls"); m == nil || !m.ReadOnly || m.MountPath != apiTLSMountPath {
t.Errorf("tls volume mount = %#v, want read-only at %s", m, apiTLSMountPath)
}
// No backup PVC in testParams ⇒ no FELIS_BACKUP_PVC env (restore degrades to 503).
if envVar(c.Env, "FELIS_BACKUP_PVC") != nil {
@@ -201,6 +215,30 @@ func TestAPIDeployment_Wiring(t *testing.T) {
}
}
func TestAPIService_NodePort(t *testing.T) {
p := testParams()
p.PanelNodePort = 30445
svc := apiService(p)
dep := APIDeployment(p)
if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace {
t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI)
}
if svc.Spec.Type != corev1.ServiceTypeNodePort {
t.Errorf("api Service type = %s, want NodePort", svc.Spec.Type)
}
if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) {
t.Errorf("api Service selector %v does not select api pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels)
}
if len(svc.Spec.Ports) != 1 {
t.Fatalf("api Service ports = %v, want one", svc.Spec.Ports)
}
port := svc.Spec.Ports[0]
if port.Port != 443 || port.TargetPort.StrVal != "https" || port.NodePort != p.PanelNodePort {
t.Errorf("api Service port = %#v, want 443 -> https NodePort %d", port, p.PanelNodePort)
}
}
// TestAPIDeployment_BackupPVC proves the FELIS_BACKUP_PVC env appears only when a
// backup PVC is named.
func TestAPIDeployment_BackupPVC(t *testing.T) {
@@ -219,8 +257,8 @@ func TestOperatorDeployment_Wiring(t *testing.T) {
d := OperatorDeployment(p)
ps, c := podSpec(t, d)
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "operator"}) {
t.Errorf("operator command/args = %v, want it to start `felis operator`", got)
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "operator"}) {
t.Errorf("operator command/args = %v, want it to start `%s operator`", got, felisBinaryPath)
}
if !contains(c.Args, "--namespace") || !contains(c.Args, p.MinecraftNamespace) {
t.Errorf("operator must watch --namespace %s, got %v", p.MinecraftNamespace, c.Args)
@@ -299,12 +337,12 @@ func TestRegistry_DeploymentServicePVC(t *testing.T) {
}
// TestWorkloads_BundleContents sanity-checks the slice Workloads returns: the two
// control-plane Deployments + the registry Deployment/Service/PVC, every one with
// TypeMeta (so its YAML header renders).
// control-plane Deployments, the api Service, and the registry Deployment/Service/PVC,
// every one with TypeMeta (so its YAML header renders).
func TestWorkloads_BundleContents(t *testing.T) {
objs := Workloads(testParams())
if len(objs) != 5 {
t.Fatalf("Workloads returned %d objects, want 5", len(objs))
if len(objs) != 6 {
t.Fatalf("Workloads returned %d objects, want 6", len(objs))
}
for _, o := range objs {
gvk := o.GetObjectKind().GroupVersionKind()
@@ -444,9 +482,9 @@ func TestReaperCronJob_Shape(t *testing.T) {
t.Error("reaper container must drop ALL capabilities")
}
// Entrypoint: `felis reaper --config <cfg> --worlds-root /worlds`.
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "reaper"}) {
t.Errorf("reaper command/args = %v, want it to start `felis reaper`", got)
// Entrypoint: `/usr/local/bin/felis reaper --config <cfg> --worlds-root /worlds`.
if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "reaper"}) {
t.Errorf("reaper command/args = %v, want it to start `%s reaper`", got, felisBinaryPath)
}
if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) {
t.Errorf("reaper must read config at %s, got %v", configFilePath, c.Args)