feat: manage distributed node operations from Owner panel
This commit is contained in:
33 files changed
+2417
-26
No files matched your search
@@ -32,6 +32,7 @@ import (
|
||||
// API holds the dependencies shared by every handler.
|
||||
type API struct {
|
||||
Distribution Distribution
|
||||
NodeControl NodeControl
|
||||
Repo Repo
|
||||
Cluster Cluster
|
||||
Internal InternalAuth
|
||||
@@ -650,6 +651,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// Staff can designate their own game identity after panel setup. Players
|
||||
// retain the in-game proof flow above.
|
||||
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
||||
{Method: "GET", Pattern: "/api/v1/settings/node-control", Owner: true, Admin: true, h: a.handleNodeTasks},
|
||||
{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks", Owner: true, Admin: true, h: a.handleStartNodeTask},
|
||||
{Method: "GET", Pattern: "/api/v1/settings/node-control/tasks/{id}", Owner: true, Admin: true, h: a.handleNodeTask},
|
||||
{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks/{id}/retry", Owner: true, Admin: true, h: a.handleRetryNodeTask},
|
||||
{Method: "GET", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleGetWakePolicy},
|
||||
{Method: "PUT", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleSetWakePolicy},
|
||||
{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
|
||||
|
||||
@@ -39,6 +39,13 @@ func (a *API) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) handleMigration(w http.ResponseWriter, r *http.Request) {
|
||||
if a.NodeControl != nil {
|
||||
if err := NodeMaintenanceGuard(a.NodeControl)(r.Context()); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !a.distributedReady(w, r) {
|
||||
return
|
||||
}
|
||||
@@ -88,6 +95,13 @@ func (a *API) handleMigrationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) handleMigrationRetry(w http.ResponseWriter, r *http.Request) {
|
||||
if a.NodeControl != nil {
|
||||
if err := NodeMaintenanceGuard(a.NodeControl)(r.Context()); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !a.distributedReady(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"felis.lolicon.best/internal/nodecontrol"
|
||||
)
|
||||
|
||||
type NodeControl interface {
|
||||
List(context.Context) ([]nodecontrol.Task, error)
|
||||
Get(context.Context, string) (nodecontrol.Task, error)
|
||||
Start(context.Context, nodecontrol.Request, string) (nodecontrol.Task, error)
|
||||
}
|
||||
|
||||
func (a *API) handleNodeTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if a.NodeControl == nil {
|
||||
writeJSON(w, 200, map[string]any{"available": false, "tasks": []nodecontrol.Task{}})
|
||||
return
|
||||
}
|
||||
tasks, err := a.NodeControl.List(r.Context())
|
||||
if err != nil {
|
||||
a.writeNodeControlError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"available": true, "tasks": tasks})
|
||||
}
|
||||
func (a *API) handleNodeTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.nodeControlReady(w, r) {
|
||||
return
|
||||
}
|
||||
task, err := a.NodeControl.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
a.writeNodeControlError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, task)
|
||||
}
|
||||
func (a *API) handleStartNodeTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireReauth(w, r, principalFromContext(r.Context())) || !a.nodeControlReady(w, r) {
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var req nodecontrol.Request
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.startNodeTask(w, r, req)
|
||||
}
|
||||
func (a *API) handleRetryNodeTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireReauth(w, r, principalFromContext(r.Context())) || !a.nodeControlReady(w, r) {
|
||||
return
|
||||
}
|
||||
task, err := a.NodeControl.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
a.writeNodeControlError(w, r, err)
|
||||
return
|
||||
}
|
||||
if task.State != "failed" {
|
||||
writeError(w, r, newError(409, "conflict", "only failed node tasks can be retried"))
|
||||
return
|
||||
}
|
||||
a.startNodeTask(w, r, task.Request)
|
||||
}
|
||||
func (a *API) startNodeTask(w http.ResponseWriter, r *http.Request, req nodecontrol.Request) {
|
||||
if err := req.Validate(); err != nil {
|
||||
writeError(w, r, newError(400, "bad_request", "%v", err))
|
||||
return
|
||||
}
|
||||
task, err := a.NodeControl.Start(r.Context(), req, principalFromContext(r.Context()).UserID)
|
||||
if err != nil {
|
||||
a.writeNodeControlError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "platform.node."+req.Action, task.ID)
|
||||
writeJSON(w, http.StatusAccepted, task)
|
||||
}
|
||||
func (a *API) nodeControlReady(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.NodeControl == nil {
|
||||
a.writeNodeControlError(w, r, errors.New("node-control not configured"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (a *API) writeNodeControlError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, nodecontrol.ErrBusy):
|
||||
writeError(w, r, newError(409, "node_operation_busy", "a node operation is already running"))
|
||||
case errors.Is(err, nodecontrol.ErrNotFound):
|
||||
writeError(w, r, newError(404, "not_found", "node task not found"))
|
||||
default:
|
||||
writeError(w, r, newError(503, "node_control_unavailable", "host node service is unavailable; inspect felis-node-control.service"))
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed for starts when the configured host service cannot report its maintenance state.
|
||||
func NodeMaintenanceGuard(control NodeControl) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
tasks, err := control.List(ctx)
|
||||
if err != nil {
|
||||
return newError(503, "node_control_unavailable", "host node service is unavailable")
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.State == "running" {
|
||||
return newError(409, "node_operation_busy", "node maintenance is in progress")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/nodecontrol"
|
||||
)
|
||||
|
||||
type fakeNodeControl struct {
|
||||
tasks []nodecontrol.Task
|
||||
calls int
|
||||
err error
|
||||
request nodecontrol.Request
|
||||
actor string
|
||||
}
|
||||
|
||||
func (f *fakeNodeControl) List(context.Context) ([]nodecontrol.Task, error) { return f.tasks, f.err }
|
||||
func (f *fakeNodeControl) Get(context.Context, string) (nodecontrol.Task, error) {
|
||||
if f.err != nil {
|
||||
return nodecontrol.Task{}, f.err
|
||||
}
|
||||
if len(f.tasks) == 0 {
|
||||
return nodecontrol.Task{}, nodecontrol.ErrNotFound
|
||||
}
|
||||
return f.tasks[0], nil
|
||||
}
|
||||
func (f *fakeNodeControl) Start(_ context.Context, r nodecontrol.Request, actor string) (nodecontrol.Task, error) {
|
||||
f.calls++
|
||||
f.request = r
|
||||
f.actor = actor
|
||||
return nodecontrol.Task{ID: "task", Request: r, State: "running"}, f.err
|
||||
}
|
||||
|
||||
const nodeTaskPath = "/api/v1/settings/node-control/tasks"
|
||||
const nodeTaskBody = `{"action":"approve","name":"worker-01","sshTarget":"worker-01","confirmMaintenance":true}`
|
||||
|
||||
func TestNodeControlOwnerBoundaryAndRetries(t *testing.T) {
|
||||
for _, role := range []string{"user", "admin", "owner"} {
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.External = staticExternal{p: &Principal{UserID: "actor", Role: role, ViaAdminAccess: role != "user"}}
|
||||
control := &fakeNodeControl{tasks: []nodecontrol.Task{{ID: "task", State: "failed", Request: nodecontrol.Request{Action: "approve", Name: "worker-01", SSHTarget: "worker-01", ConfirmMaintenance: true}}}}
|
||||
a.NodeControl = control
|
||||
for _, tc := range []struct {
|
||||
method, path, body string
|
||||
want int
|
||||
}{{"GET", "/api/v1/settings/node-control", "", 200}, {"GET", nodeTaskPath + "/task", "", 200}, {"POST", nodeTaskPath, nodeTaskBody, 202}, {"POST", nodeTaskPath + "/task/retry", "", 202}} {
|
||||
w := do(a.ExternalHandler(), tc.method, tc.path, tc.body, jsonHeader)
|
||||
want := tc.want
|
||||
if role != "owner" {
|
||||
want = 403
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s %s: %d %s", role, tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if role != "owner" && control.calls != 0 {
|
||||
t.Fatal("nonowner reached root operations")
|
||||
}
|
||||
if role == "owner" && (control.actor != "actor" || control.request.Name != "worker-01") {
|
||||
t.Fatal("actor/scope not preserved")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNodeControlErrorsAndStartGuard(t *testing.T) {
|
||||
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
||||
control := &fakeNodeControl{}
|
||||
a.NodeControl = control
|
||||
w := do(a.ExternalHandler(), "POST", nodeTaskPath, `{"action":"approve","name":"x","sshTarget":"-exec","confirmMaintenance":true}`, jsonHeader)
|
||||
if w.Code != 400 || control.calls != 0 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
code int
|
||||
}{{nodecontrol.ErrBusy, 409}, {errors.New("private host detail"), 503}} {
|
||||
control.err = tc.err
|
||||
w := do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
||||
if w.Code != tc.code {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
guard := NodeMaintenanceGuard(control)
|
||||
if guard(context.Background()) == nil {
|
||||
t.Fatal("host failure permitted start")
|
||||
}
|
||||
control.err = nil
|
||||
control.tasks = []nodecontrol.Task{{State: "running"}}
|
||||
if guard(context.Background()) == nil {
|
||||
t.Fatal("maintenance permitted start")
|
||||
}
|
||||
control.tasks[0].State = "failed"
|
||||
if err := guard(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control.tasks[0].State = "succeeded"
|
||||
w = do(a.ExternalHandler(), "POST", nodeTaskPath+"/task/retry", "", jsonHeader)
|
||||
if w.Code != 409 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControlRequiresFreshOwnerProof(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
a := newTestAPI(repo, newFakeCluster())
|
||||
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true}
|
||||
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: "owner", UserVerified: true}
|
||||
a.External = staticExternal{p: p}
|
||||
control := &fakeNodeControl{}
|
||||
a.NodeControl = control
|
||||
w := do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
||||
if w.Code != 403 || decodeErr(t, w) != "reauth_required" || control.calls != 0 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
p.ReauthAt = a.now()
|
||||
w = do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
||||
if w.Code != 202 || control.calls != 1 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,12 @@ import (
|
||||
// the direct client c, so a write never works from a copy the watch has not caught
|
||||
// up with yet.
|
||||
type K8sCluster struct {
|
||||
distributed bool
|
||||
controller string
|
||||
c client.Client
|
||||
namespace string
|
||||
// NodeMaintenanceGuard checks host maintenance for all manual and scheduled starts.
|
||||
NodeMaintenanceGuard func(context.Context) error
|
||||
distributed bool
|
||||
controller string
|
||||
c client.Client
|
||||
namespace string
|
||||
// servers serves the fleet-wide reads; nil means c.
|
||||
servers client.Reader
|
||||
// synced reports whether servers has its first full list; nil means no cache.
|
||||
@@ -308,6 +310,11 @@ func (k *K8sCluster) RestartServer(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
func (k *K8sCluster) startWith(ctx context.Context, name, annotation string) error {
|
||||
if k.NodeMaintenanceGuard != nil {
|
||||
if err := k.NodeMaintenanceGuard(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := k.getServer(ctx, name, &ms); err != nil {
|
||||
@@ -350,6 +357,11 @@ func (k *K8sCluster) startWith(ctx context.Context, name, annotation string) err
|
||||
}
|
||||
ms.Annotations[annotation] = k.clock().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if k.NodeMaintenanceGuard != nil {
|
||||
if err := k.NodeMaintenanceGuard(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return k.c.Patch(ctx, &ms, patch)
|
||||
})
|
||||
}
|
||||
@@ -362,6 +374,12 @@ func (k *K8sCluster) startWith(ctx context.Context, name, annotation string) err
|
||||
// checked; the caller creates its Job and then calls ReleaseMaintenance, after
|
||||
// which the Job itself is the lock.
|
||||
func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string) error {
|
||||
if k.NodeMaintenanceGuard != nil {
|
||||
if err := k.NodeMaintenanceGuard(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := k.getServer(ctx, name, &ms); err != nil {
|
||||
|
||||
@@ -459,3 +459,26 @@ func TestResourcePatchesKeepWhatTheyLeaveOut(t *testing.T) {
|
||||
t.Fatalf("cleared cpu: limits %v, want the CPU limit gone and memory 8Gi kept", after.Resources.Limits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNodeMaintenanceBlocksStartsAndWorldJobs(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
v1alpha1.AddToScheme(scheme)
|
||||
server := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft"}, Spec: v1alpha1.MinecraftServerSpec{DesiredState: v1alpha1.DesiredStopped}, Status: v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseStopped}}
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(server).Build()
|
||||
k := NewK8sCluster(c, server.Namespace)
|
||||
blocked := newError(409, "node_operation_busy", "host task running")
|
||||
k.NodeMaintenanceGuard = func(context.Context) error { return blocked }
|
||||
ctx := context.Background()
|
||||
for _, operation := range []func() error{func() error { return k.SetDesiredState(ctx, server.Name, v1alpha1.DesiredRunning) }, func() error { return k.RetryStart(ctx, server.Name) }, func() error { return k.AcquireMaintenance(ctx, server.Name, maintenance.KindBackup) }} {
|
||||
if err := operation(); err != blocked {
|
||||
t.Fatal("host maintenance bypassed", err)
|
||||
}
|
||||
}
|
||||
if err := k.SetDesiredState(ctx, server.Name, v1alpha1.DesiredStopped); err != nil {
|
||||
t.Fatal("stop entry blocked", err)
|
||||
}
|
||||
var got v1alpha1.MinecraftServer
|
||||
if err := c.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &got); err != nil || got.Spec.DesiredState != v1alpha1.DesiredStopped || len(got.Annotations) != 0 {
|
||||
t.Fatal("state changed during host maintenance", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package nodecontrol exposes fixed host operations over a local Unix socket.
|
||||
package nodecontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Socket = "/run/felis-node-control/control.sock"
|
||||
const MaxLog = 64 << 10
|
||||
|
||||
var ErrBusy = errors.New("a node operation is already running")
|
||||
var ErrNotFound = errors.New("node operation not found")
|
||||
var namePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
var targetPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,252}$`)
|
||||
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
|
||||
|
||||
func validNodeName(name string) bool {
|
||||
if len(name) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if !namePattern.MatchString(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
Name string `json:"name,omitempty"`
|
||||
SSHTarget string `json:"sshTarget,omitempty"`
|
||||
ExternalIP string `json:"externalIP,omitempty"`
|
||||
Peers []string `json:"peers,omitempty"`
|
||||
ConfirmMaintenance bool `json:"confirmMaintenance"`
|
||||
}
|
||||
|
||||
func (r Request) Validate() error {
|
||||
if !r.ConfirmMaintenance {
|
||||
return errors.New("maintenance impact confirmation is required")
|
||||
}
|
||||
switch r.Action {
|
||||
case "join", "approve":
|
||||
if !validNodeName(r.Name) || !targetPattern.MatchString(r.SSHTarget) {
|
||||
return errors.New("invalid worker name or SSH target")
|
||||
}
|
||||
case "enable":
|
||||
default:
|
||||
return errors.New("unsupported node operation")
|
||||
}
|
||||
if r.Action != "approve" {
|
||||
if a, err := netip.ParseAddr(r.ExternalIP); err != nil || a.IsUnspecified() || a.IsLoopback() || a.IsMulticast() || a.Zone() != "" {
|
||||
return errors.New("a fixed node IP is required")
|
||||
}
|
||||
}
|
||||
for _, peer := range r.Peers {
|
||||
p, err := netip.ParsePrefix(peer)
|
||||
if err != nil || p.Bits() != p.Addr().BitLen() || p.Addr().IsLoopback() || p.Addr().IsMulticast() || p.Addr().IsUnspecified() {
|
||||
return errors.New("peer addresses must use exact /32 or /128 prefixes")
|
||||
}
|
||||
}
|
||||
if len(r.Peers) > 100 {
|
||||
return errors.New("too many peer addresses")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Request Request `json:"request"`
|
||||
Actor string `json:"actor"`
|
||||
State string `json:"state"`
|
||||
Stage string `json:"stage"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
}
|
||||
|
||||
type Executor func(context.Context, Request, func(string) error, io.Writer) error
|
||||
|
||||
type Manager struct {
|
||||
dir string
|
||||
execute Executor
|
||||
mu sync.Mutex
|
||||
tasks map[string]Task
|
||||
active string
|
||||
ctx context.Context
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, dir string, execute Executor) (*Manager, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &Manager{dir: dir, execute: execute, tasks: map[string]Task{}, ctx: ctx}
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t Task
|
||||
if err = json.Unmarshal(raw, &t); err != nil {
|
||||
return nil, fmt.Errorf("read node task: %w", err)
|
||||
}
|
||||
if !idPattern.MatchString(t.ID) || filepath.Base(path) != t.ID+".json" {
|
||||
return nil, errors.New("invalid stored task ID")
|
||||
}
|
||||
if t.State == "running" {
|
||||
t.State, t.Error = "failed", "Host execution service restarted. Verify the host state before retrying."
|
||||
now := time.Now().UTC()
|
||||
t.FinishedAt = &now
|
||||
if err = m.persist(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
m.tasks[t.ID] = t
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
func (m *Manager) persist(t Task) error {
|
||||
t.Log = ""
|
||||
raw, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(m.dir, t.ID+".json")
|
||||
f, err := os.OpenFile(path+".tmp", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = f.Write(raw); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if err = os.Rename(path+".tmp", path); err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := os.Open(m.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
func (m *Manager) List() []Task {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Task, 0, len(m.tasks))
|
||||
for _, t := range m.tasks {
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StartedAt.After(out[j].StartedAt) })
|
||||
if len(out) > 100 {
|
||||
out = out[:100]
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (m *Manager) Get(id string) (Task, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[id]
|
||||
if !ok {
|
||||
return Task{}, ErrNotFound
|
||||
}
|
||||
f, err := os.Open(filepath.Join(m.dir, id+".log"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return t, nil
|
||||
}
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if info.Size() > MaxLog {
|
||||
if _, err = f.Seek(-MaxLog, io.SeekEnd); err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
raw, err := io.ReadAll(f)
|
||||
t.Log = string(raw)
|
||||
return t, err
|
||||
}
|
||||
func (m *Manager) Start(r Request, actor string) (Task, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active != "" {
|
||||
return Task{}, ErrBusy
|
||||
}
|
||||
if err := m.ctx.Err(); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
var id [16]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t := Task{ID: hex.EncodeToString(id[:]), Request: r, Actor: actor, State: "running", Stage: "preflight", StartedAt: time.Now().UTC()}
|
||||
if err := m.persist(t); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
m.tasks[t.ID] = t
|
||||
m.active = t.ID
|
||||
m.wg.Add(1)
|
||||
go m.run(t)
|
||||
return t, nil
|
||||
}
|
||||
func (m *Manager) run(t Task) {
|
||||
defer m.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 45*time.Minute)
|
||||
defer cancel()
|
||||
log, err := os.OpenFile(filepath.Join(m.dir, t.ID+".log"), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)
|
||||
if err == nil {
|
||||
stage := func(s string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t.Stage = s
|
||||
if err := m.persist(t); err != nil {
|
||||
return err
|
||||
}
|
||||
m.tasks[t.ID] = t
|
||||
_, err := fmt.Fprintln(log, "[felis]", s)
|
||||
return err
|
||||
}
|
||||
err = m.execute(ctx, t.Request, stage, &boundedLog{file: log})
|
||||
if ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
if closeErr := log.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
t.FinishedAt = &now
|
||||
t.State = "succeeded"
|
||||
if err != nil {
|
||||
t.State = "failed"
|
||||
t.Error = err.Error()
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Error = "Host operation exceeded the 45-minute execution limit. Verify the host state before retrying."
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
t.Error = "Host operation was interrupted. Verify the host state before retrying."
|
||||
}
|
||||
}
|
||||
if persistErr := m.persist(t); persistErr != nil {
|
||||
t.State = "failed"
|
||||
t.Error = "Task result persistence failed: " + persistErr.Error()
|
||||
}
|
||||
m.tasks[t.ID] = t
|
||||
m.active = ""
|
||||
for len(m.tasks) > 100 {
|
||||
oldest := ""
|
||||
for id, candidate := range m.tasks {
|
||||
if id == t.ID {
|
||||
continue
|
||||
}
|
||||
if oldest == "" || candidate.StartedAt.Before(m.tasks[oldest].StartedAt) {
|
||||
oldest = id
|
||||
}
|
||||
}
|
||||
if oldest != "" {
|
||||
if err := os.Remove(filepath.Join(m.dir, oldest+".json")); err == nil {
|
||||
os.Remove(filepath.Join(m.dir, oldest+".log"))
|
||||
delete(m.tasks, oldest)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (m *Manager) Wait() { m.wg.Wait() }
|
||||
|
||||
// Handler is reachable only through the root-owned socket mounted into felis-api.
|
||||
func (m *Manager) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(m.List()) })
|
||||
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := m.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(t)
|
||||
})
|
||||
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Request Request `json:"request"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
http.Error(w, "invalid task request", 400)
|
||||
return
|
||||
}
|
||||
if err := dec.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid trailing task data", 400)
|
||||
return
|
||||
}
|
||||
if err := body.Request.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
t, err := m.Start(body.Request, body.Actor)
|
||||
if err != nil {
|
||||
code := 500
|
||||
if errors.Is(err, ErrBusy) {
|
||||
code = 409
|
||||
}
|
||||
http.Error(w, err.Error(), code)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(t)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type Client struct{ http *http.Client }
|
||||
|
||||
func NewClient(socket string) *Client {
|
||||
return &Client{http: &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}}}}
|
||||
}
|
||||
func (c *Client) call(ctx context.Context, method, path string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = strings.NewReader(string(raw))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://node-control"+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == 409 {
|
||||
return ErrBusy
|
||||
}
|
||||
if res.StatusCode == 404 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if res.StatusCode >= 400 {
|
||||
return fmt.Errorf("host service returned HTTP %d", res.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(out)
|
||||
}
|
||||
func (c *Client) List(ctx context.Context) ([]Task, error) {
|
||||
var out []Task
|
||||
err := c.call(ctx, "GET", "/tasks", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) Get(ctx context.Context, id string) (Task, error) {
|
||||
if !idPattern.MatchString(id) {
|
||||
return Task{}, ErrNotFound
|
||||
}
|
||||
var out Task
|
||||
err := c.call(ctx, "GET", "/tasks/"+id, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) Start(ctx context.Context, r Request, actor string) (Task, error) {
|
||||
var out Task
|
||||
err := c.call(ctx, "POST", "/tasks", map[string]any{"request": r, "actor": actor}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Bound disk use while retaining the most recent output of long builds.
|
||||
type boundedLog struct {
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *boundedLog) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
const limit = 4 << 20
|
||||
info, err := w.file.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if info.Size()+int64(len(p)) > limit {
|
||||
n := int64(MaxLog)
|
||||
if info.Size() < n {
|
||||
n = info.Size()
|
||||
}
|
||||
tail := make([]byte, n)
|
||||
if _, err = w.file.ReadAt(tail, info.Size()-n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = w.file.Truncate(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = w.file.Write(tail); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
original := len(p)
|
||||
if len(p) > limit {
|
||||
p = p[len(p)-limit:]
|
||||
}
|
||||
_, err = w.file.Write(p)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return original, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package nodecontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validRequest() Request {
|
||||
return Request{Action: "join", Name: "worker-01", SSHTarget: "[email protected]", ExternalIP: "192.0.2.10", ConfirmMaintenance: true}
|
||||
}
|
||||
func TestRequestRejectsUnsafeInputs(t *testing.T) {
|
||||
for _, change := range []func(*Request){func(r *Request) { r.ConfirmMaintenance = false }, func(r *Request) { r.SSHTarget = "-oProxyCommand=sh" }, func(r *Request) { r.SSHTarget = "root@host;id" }, func(r *Request) { r.Name = "../../escape" }, func(r *Request) { r.ExternalIP = "127.0.0.1" }, func(r *Request) { r.Peers = []string{"10.0.0.0/8"} }, func(r *Request) { r.Action = "exec" }} {
|
||||
r := validRequest()
|
||||
change(&r)
|
||||
if r.Validate() == nil {
|
||||
t.Fatalf("accepted %+v", r)
|
||||
}
|
||||
}
|
||||
if err := validRequest().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestNodeNames(t *testing.T) {
|
||||
for _, name := range []string{"worker-01", "worker.localdomain"} {
|
||||
r := validRequest()
|
||||
r.Name = name
|
||||
if err := r.Validate(); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"worker..localdomain", "worker.-domain", "worker.", strings.Repeat("a", 64)} {
|
||||
r := validRequest()
|
||||
r.Name = name
|
||||
if r.Validate() == nil {
|
||||
t.Fatalf("accepted %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentSerializedTasksAndLogs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entered := make(chan struct{})
|
||||
finish := make(chan struct{})
|
||||
manager, err := Open(context.Background(), dir, func(ctx context.Context, r Request, stage func(string) error, out io.Writer) error {
|
||||
if err := stage("approval"); err != nil {
|
||||
return err
|
||||
}
|
||||
io.WriteString(out, "probe failed\n")
|
||||
close(entered)
|
||||
<-finish
|
||||
return errors.New("quarantine retained")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := manager.Start(validRequest(), "owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-entered
|
||||
if _, err = manager.Start(validRequest(), "owner"); !errors.Is(err, ErrBusy) {
|
||||
t.Fatal("concurrent task", err)
|
||||
}
|
||||
live, err := manager.Get(task.ID)
|
||||
if err != nil || live.Stage != "approval" || !strings.Contains(live.Log, "probe failed") {
|
||||
t.Fatal(live, err)
|
||||
}
|
||||
close(finish)
|
||||
manager.Wait()
|
||||
reopened, err := Open(context.Background(), dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := reopened.Get(task.ID)
|
||||
if err != nil || got.State != "failed" || got.Error != "quarantine retained" || got.FinishedAt == nil || got.Actor != "owner" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
if reopened.List()[0].Log != "" {
|
||||
t.Fatal("task list leaked logs")
|
||||
}
|
||||
}
|
||||
func TestInterruptedTaskIsNotReportedRunning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
task := Task{ID: strings.Repeat("a", 32), Request: validRequest(), State: "running", StartedAt: time.Now()}
|
||||
raw, _ := json.Marshal(task)
|
||||
if err := os.WriteFile(filepath.Join(dir, task.ID+".json"), raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := Open(context.Background(), dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := manager.Get(task.ID)
|
||||
if err != nil || got.State != "failed" || got.FinishedAt == nil || !strings.Contains(got.Error, "restarted") {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
func TestLocalClientAndBoundedOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manager, err := Open(context.Background(), dir, func(ctx context.Context, r Request, stage func(string) error, out io.Writer) error {
|
||||
for range 90 {
|
||||
if _, err := io.WriteString(out, strings.Repeat("x", MaxLog)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
io.WriteString(out, "last output")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Avoid macOS' short Unix socket path limit under t.TempDir.
|
||||
sock, err := os.CreateTemp("/tmp", "felis-node-test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := sock.Name()
|
||||
sock.Close()
|
||||
os.Remove(path)
|
||||
defer os.Remove(path)
|
||||
listener, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http.Server{Handler: manager.Handler()}
|
||||
go server.Serve(listener)
|
||||
defer server.Close()
|
||||
client := NewClient(path)
|
||||
task, err := client.Start(context.Background(), validRequest(), "owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.Wait()
|
||||
got, err := client.Get(context.Background(), task.ID)
|
||||
if err != nil || got.State != "succeeded" || len(got.Log) > MaxLog || !strings.HasSuffix(got.Log, "last output") {
|
||||
t.Fatal("task/log", got.State, len(got.Log), err)
|
||||
}
|
||||
stat, err := os.Stat(filepath.Join(dir, task.ID+".log"))
|
||||
if err != nil || stat.Size() > 4<<20 {
|
||||
t.Fatal("unbounded file", stat, err)
|
||||
}
|
||||
if _, err := client.Get(context.Background(), "../secret"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestCancellationFinishesTask(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entered := make(chan struct{})
|
||||
manager, err := Open(ctx, t.TempDir(), func(ctx context.Context, r Request, stage func(string) error, out io.Writer) error {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := manager.Start(validRequest(), "owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-entered
|
||||
cancel()
|
||||
manager.Wait()
|
||||
got, err := manager.Get(task.ID)
|
||||
if err != nil || got.State != "failed" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional live check under the API container's UID and SELinux domain.
|
||||
func TestHostSocketConnectivity(t *testing.T) {
|
||||
socket := os.Getenv("FELIS_TEST_NODE_CONTROL_SOCKET")
|
||||
if socket == "" {
|
||||
t.Skip("live host socket not supplied")
|
||||
}
|
||||
tasks, err := NewClient(socket).List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tasks == nil {
|
||||
t.Fatal("host returned no task list")
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,43 @@ func TestDistributedControllerPinningAndArchivePowers(t *testing.T) {
|
||||
t.Fatal("broad Velocity source accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControlSocketIsAPIOnly(t *testing.T) {
|
||||
p := testParams()
|
||||
p.NodeControlSocket = "/run/felis-node-control/control.sock"
|
||||
p.NodeControlNode = "controller"
|
||||
for _, object := range Objects(p) {
|
||||
d, ok := object.(*appsv1.Deployment)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, volume := range d.Spec.Template.Spec.Volumes {
|
||||
if volume.Name == "node-control" && d.Name != SAAPI {
|
||||
t.Fatal("host socket leaked to", d.Name)
|
||||
}
|
||||
}
|
||||
if d.Name != SAAPI {
|
||||
continue
|
||||
}
|
||||
if d.Spec.Template.Spec.NodeSelector["kubernetes.io/hostname"] != "controller" {
|
||||
t.Fatal("API can run away from socket")
|
||||
}
|
||||
found := false
|
||||
for _, container := range d.Spec.Template.Spec.Containers {
|
||||
for _, mount := range container.VolumeMounts {
|
||||
if mount.Name == "node-control" {
|
||||
found = true
|
||||
if !mount.ReadOnly {
|
||||
t.Fatal("host directory writable")
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Spec.Template.Spec.SecurityContext.RunAsUser == nil || *d.Spec.Template.Spec.SecurityContext.RunAsUser == 0 {
|
||||
t.Fatal("API elevated")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("API has no node socket")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ const (
|
||||
// Params parameterises the install bundle. Namespaces and the registry location
|
||||
// have safe defaults; VelocityCIDRs has none — see the field comment.
|
||||
type Params struct {
|
||||
NodeControlSocket string
|
||||
NodeControlNode string
|
||||
Distributed bool
|
||||
ControllerNode string
|
||||
EgressProbe string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/naming"
|
||||
@@ -480,7 +481,18 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
},
|
||||
}
|
||||
|
||||
return controlPlaneDeployment(p, SAAPI, container, volumes)
|
||||
if p.NodeControlSocket != "" {
|
||||
directory := filepath.Dir(p.NodeControlSocket)
|
||||
hostType := corev1.HostPathDirectory
|
||||
volumes = append(volumes, corev1.Volume{Name: "node-control", VolumeSource: corev1.VolumeSource{HostPath: &corev1.HostPathVolumeSource{Path: directory, Type: &hostType}}})
|
||||
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: "node-control", MountPath: directory, ReadOnly: true})
|
||||
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_NODE_CONTROL_SOCKET", Value: p.NodeControlSocket})
|
||||
}
|
||||
deployment := controlPlaneDeployment(p, SAAPI, container, volumes)
|
||||
if p.NodeControlNode != "" && p.ControllerNode == "" {
|
||||
deployment.Spec.Template.Spec.NodeSelector = map[string]string{"kubernetes.io/hostname": p.NodeControlNode}
|
||||
}
|
||||
return deployment
|
||||
}
|
||||
|
||||
// apiService exposes the built-in HTTPS panel/API origin as a stable NodePort.
|
||||
|
||||
Reference in new issue
Block a user