feat(platform): add node orchestration and the felis entrypoint
The platform package that places servers across nodes and wires the operator, build, restore, and reaper subsystems, plus cmd/felis, the single binary that runs them.
This commit is contained in:
21 files changed
+3713
No files matched your search
@@ -0,0 +1,204 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// API groups used by the rules. The felis group is sourced from v1alpha1 so the
|
||||
// CRD's identity and its RBAC can never drift apart.
|
||||
const (
|
||||
groupCore = "" // core/v1: secrets, services, persistentvolumeclaims
|
||||
groupApps = "apps"
|
||||
groupBatch = "batch"
|
||||
)
|
||||
|
||||
var groupFelis = v1alpha1.GroupName // "felis.lolicon.best"
|
||||
|
||||
// RBAC is the control-plane authorization bundle: one SA per identity and the
|
||||
// namespaced Roles + RoleBindings that grant each exactly the verbs its code path
|
||||
// exercises. There is deliberately no ClusterRole or ClusterRoleBinding anywhere.
|
||||
type RBAC struct {
|
||||
ServiceAccounts []*corev1.ServiceAccount
|
||||
Roles []*rbacv1.Role
|
||||
RoleBindings []*rbacv1.RoleBinding
|
||||
}
|
||||
|
||||
// ControlPlaneRBAC assembles the full RBAC bundle for p.
|
||||
//
|
||||
// The reaper identity (felis-reaper SA + Role + RoleBinding) is rendered ONLY when
|
||||
// the retention reaper CronJob is — both gate on reaperEnabled(p), the same storage
|
||||
// trio (workloads.go). This coupling is deliberate least-privilege: the reaper's
|
||||
// Role is the one and only place persistentvolumeclaims:delete appears in the whole
|
||||
// bundle (world reclamation) — neither felis-api nor felis-operator can delete a
|
||||
// PVC. Leaving that destructive grant standing in a deployment that never runs the
|
||||
// reaper would widen the blast radius of a control-plane compromise for no benefit
|
||||
// (a control-namespace foothold could mount felis-reaper and destroy world PVCs),
|
||||
// since nothing would consume it. So the destructive identity exists exactly as
|
||||
// long as its consumer does, and the manifests command's fail-loud trio check
|
||||
// guarantees the CronJob and this RBAC are always rendered together or not at all.
|
||||
func ControlPlaneRBAC(p Params) RBAC {
|
||||
p = p.withDefaults()
|
||||
rbac := RBAC{
|
||||
ServiceAccounts: []*corev1.ServiceAccount{
|
||||
controlPlaneServiceAccount(p.ControlNamespace, SAAPI, ComponentAPI),
|
||||
controlPlaneServiceAccount(p.ControlNamespace, SAOperator, ComponentOperator),
|
||||
},
|
||||
Roles: []*rbacv1.Role{
|
||||
APIMinecraftRole(p),
|
||||
APIBuildRole(p),
|
||||
OperatorRole(p),
|
||||
},
|
||||
// Each binding lives in the Role's namespace and names the subject SA in the
|
||||
// control namespace (a RoleBinding may reference an SA from another namespace;
|
||||
// its roleRef must be a Role in the binding's own namespace).
|
||||
RoleBindings: []*rbacv1.RoleBinding{
|
||||
bindRole(p.MinecraftNamespace, "felis-api", p.ControlNamespace, SAAPI, ComponentAPI),
|
||||
bindRole(p.BuildNamespace, "felis-api-builds", p.ControlNamespace, SAAPI, ComponentAPI),
|
||||
bindRole(p.MinecraftNamespace, "felis-operator", p.ControlNamespace, SAOperator, ComponentOperator),
|
||||
},
|
||||
}
|
||||
// The destructive fourth power is conditional on its consumer (see the doc above).
|
||||
if reaperEnabled(p) {
|
||||
rbac.ServiceAccounts = append(rbac.ServiceAccounts,
|
||||
controlPlaneServiceAccount(p.ControlNamespace, SAReaper, ComponentReaper))
|
||||
rbac.Roles = append(rbac.Roles, ReaperRole(p))
|
||||
rbac.RoleBindings = append(rbac.RoleBindings,
|
||||
bindRole(p.MinecraftNamespace, "felis-reaper", p.ControlNamespace, SAReaper, ComponentReaper))
|
||||
}
|
||||
return rbac
|
||||
}
|
||||
|
||||
// APIMinecraftRole grants felis-api exactly what it does in the minecraft
|
||||
// namespace: drive MinecraftServer specs (internal/api.k8scluster — get/list/
|
||||
// create/patch, never status), read RCON passwords for console writes
|
||||
// (internal/api.console — secrets:get), create the restore Job
|
||||
// (internal/restore — jobs:create), and stream the live console for the read
|
||||
// side (internal/api.logstream — pods:list to find the server's running pod,
|
||||
// then pods/log:get to follow it; spec §8 读=pods/log follow). felis-api uses a
|
||||
// DIRECT client, so it needs no list/watch beyond the explicit List calls.
|
||||
//
|
||||
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT
|
||||
// pods:get — the streamer lists pods by the server label then reads the chosen
|
||||
// pod's log subresource, never Gets a pod object. Keeping pods:get out is the
|
||||
// least-privilege line the rbac test asserts (a pod's full object can carry more
|
||||
// than its logs).
|
||||
func APIMinecraftRole(p Params) *rbacv1.Role {
|
||||
p = p.withDefaults()
|
||||
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, []rbacv1.PolicyRule{
|
||||
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "create", "patch"}),
|
||||
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
|
||||
rule([]string{groupBatch}, []string{"jobs"}, []string{"create"}),
|
||||
// Read-side console (spec §8 读=pods/log follow): list pods to find the
|
||||
// server's running pod, then read its log subresource. Two separate rules so
|
||||
// the verbs stay tight — list on pods, get on pods/log, and nothing else.
|
||||
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
|
||||
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
|
||||
})
|
||||
}
|
||||
|
||||
// APIBuildRole grants felis-api the build-Job lifecycle in the build namespace
|
||||
// (internal/build.k8sjobs — Create/Get/Delete) plus the read-side build-log
|
||||
// stream (spec §16, §416 日志流复用 §8): list build Pods to find the build Job's
|
||||
// Pod by build-id label, then read its log subresource. This is a SEPARATE
|
||||
// namespace from the api's minecraft powers, so it is a separate Role +
|
||||
// RoleBinding; the api SA reaches across both from the control namespace. The log
|
||||
// grant mirrors felis-api's minecraft-ns console read (pods:list + pods/log:get,
|
||||
// no pods:get) — read-only and least-privilege; it does NOT touch the build SA
|
||||
// token or any secret.
|
||||
func APIBuildRole(p Params) *rbacv1.Role {
|
||||
p = p.withDefaults()
|
||||
return role(p.BuildNamespace, "felis-api-builds", ComponentAPI, []rbacv1.PolicyRule{
|
||||
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete"}),
|
||||
// Read-side build logs (spec §16): list build Pods to find the build Job's
|
||||
// Pod, then read its log subresource — and nothing wider. No pods:get (the
|
||||
// streamer lists then reads pods/log, never Gets a Pod object, whose full
|
||||
// spec carries more than its logs).
|
||||
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
|
||||
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
|
||||
})
|
||||
}
|
||||
|
||||
// OperatorRole grants felis-operator what the reconciler exercises through the
|
||||
// manager's CACHED client (internal/operator.reconciler). Because reads go
|
||||
// through informers, every watched type needs list+watch even for a single Get;
|
||||
// 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.
|
||||
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/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"}),
|
||||
rule([]string{groupCore}, []string{"secrets"}, []string{"get", "list", "watch"}),
|
||||
})
|
||||
}
|
||||
|
||||
// ReaperRole grants felis-reaper its two destructive, disjoint powers
|
||||
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it and delete its
|
||||
// world PVC. Candidate servers come from the Postgres store, not a cluster List,
|
||||
// so no list/watch is needed; the reaper uses a direct client. It can read+patch
|
||||
// minecraftservers but cannot create them, and holds no power over StatefulSets,
|
||||
// Services, or Secrets — those belong to the operator and api.
|
||||
//
|
||||
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not
|
||||
// a missing grant: reaping releases a server by flipping desiredState=Stopped and
|
||||
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in
|
||||
// place so a former owner can re-claim it within the retention window (spec §466).
|
||||
// The MinecraftServer CR is the lifecycle source of truth and is retained, never
|
||||
// hard-deleted, so the delete verb is deliberately absent from every Role.
|
||||
func ReaperRole(p Params) *rbacv1.Role {
|
||||
p = p.withDefaults()
|
||||
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{
|
||||
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}),
|
||||
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"delete"}),
|
||||
})
|
||||
}
|
||||
|
||||
// controlPlaneServiceAccount renders a control-plane SA. Unlike the weak
|
||||
// build/restore SAs, these identities legitimately call the K8s API, so the token
|
||||
// mounts (via their Deployment) — AutomountServiceAccountToken is left nil
|
||||
// (cluster default = mount) rather than false.
|
||||
func controlPlaneServiceAccount(ns, name, component string) *corev1.ServiceAccount {
|
||||
return &corev1.ServiceAccount{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
|
||||
}
|
||||
}
|
||||
|
||||
func role(ns, name, component string, rules []rbacv1.PolicyRule) *rbacv1.Role {
|
||||
return &rbacv1.Role{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
// bindRole binds the Role named roleName (in roleNS) to the ServiceAccount saName
|
||||
// in saNS. The RoleBinding lives in roleNS; the subject SA may live elsewhere.
|
||||
func bindRole(roleNS, roleName, saNS, saName, component string) *rbacv1.RoleBinding {
|
||||
return &rbacv1.RoleBinding{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: roleName, Namespace: roleNS, Labels: controlPlanePodLabels(component)},
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.ServiceAccountKind,
|
||||
Name: saName,
|
||||
Namespace: saNS,
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Kind: "Role",
|
||||
Name: roleName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rule(apiGroups, resources, verbs []string) rbacv1.PolicyRule {
|
||||
return rbacv1.PolicyRule{APIGroups: apiGroups, Resources: resources, Verbs: verbs}
|
||||
}
|
||||
Reference in new issue
Block a user