diff --git a/.dockerignore b/.dockerignore index 3d182a5..7234009 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ Dockerfile .idea/ .vscode/ .DS_Store +fc-assets/work/ +fc-assets/images/ \ No newline at end of file diff --git a/cmd/init/run_linux.go b/cmd/init/run_linux.go index ee5e85d..21f5899 100644 --- a/cmd/init/run_linux.go +++ b/cmd/init/run_linux.go @@ -253,18 +253,25 @@ func exitCode(ws syscall.WaitStatus) int { return ws.ExitStatus() } -// powerOff halts the microVM. As PID 1, returning or exiting would -// trigger a kernel panic ("Attempted to kill init"); instead we ask the -// kernel to power the machine off cleanly, which makes the Firecracker -// VMM exit. The exit code is logged for the host to correlate via the -// serial console. +// powerOff shuts the microVM down. As PID 1, returning or exiting would +// trigger a kernel panic ("Attempted to kill init"); instead we ask the kernel +// to reset, which Firecracker traps (the guest's i8042 reset — the same +// mechanism the host-side SendCtrlAltDel uses) and turns into a clean VMM exit. +// +// We deliberately use RESTART, not POWER_OFF: microVMs expose no ACPI, so +// LINUX_REBOOT_CMD_POWER_OFF finds no power-off handler and the kernel falls +// back to halting the CPU ("reboot: System halted"). That leaves the Firecracker +// process alive forever, so the dead VM keeps holding its host capacity slot and +// the host slowly wedges. A reset exits the VMM, freeing the slot. Firecracker +// does not actually reboot the guest — a guest reset terminates the process. +// The exit code is logged for the host to correlate via the serial console. func powerOff(logger *zap.Logger, code int) { logger.Info("init: powering off", zap.Int("workload_exit_code", code)) syscall.Sync() - if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF); err != nil { + if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil { // Reboot failed (no CAP_SYS_BOOT, or not really PID 1 — e.g. a // manual test run). Fall back to a normal exit. - logger.Error("init: power off failed", zap.Error(err)) + logger.Error("init: reset failed", zap.Error(err)) os.Exit(code) } // Unreachable once the kernel acts on the reboot syscall. diff --git a/cmd/server/main.go b/cmd/server/main.go index 46db6ed..dfe63e9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -84,7 +84,16 @@ func main() { hostRepo := repository.NewHostRepository() gameServerRepo := repository.NewGameServerRepository(pool) - router := handler.NewRouter(cfg, zlog, pool, hostRepo) + // The hub is the control plane's end of the persistent agent connection: + // agents dial the gRPC listener and hold a stream open, and the hub pushes VM + // commands down it. It registers hosts (reconstructing committed capacity from + // the durable server records) and tracks liveness off the stream. Built here + // (before the router and reconciler) so both the on-demand log endpoint and + // the reconciler drive agents through the same remote provisioner. + hub := agentlink.NewHub(hostRepo, gameServerRepo, zlog) + prov := provisioner.NewRemote(hub) + + router := handler.NewRouter(cfg, zlog, pool, hostRepo, prov) srv := &http.Server{ Addr: ":" + cfg.Port, @@ -94,11 +103,6 @@ func main() { IdleTimeout: 60 * time.Second, } - // The hub is the control plane's end of the persistent agent connection: - // agents dial the gRPC listener and hold a stream open, and the hub pushes VM - // commands down it. It registers hosts (reconstructing committed capacity - // from the durable server records) and tracks liveness off the stream. - hub := agentlink.NewHub(hostRepo, gameServerRepo, zlog) grpcSrv := grpc.NewServer() pb.RegisterAgentLinkServer(grpcSrv, hub) grpcLis, err := net.Listen("tcp", ":"+cfg.GRPCPort) @@ -123,7 +127,6 @@ func main() { // then drives the VM by calling the assigned host's agent (the control plane // never touches KVM itself). sched := scheduler.New(hostRepo) - prov := provisioner.NewRemote(hub) rec := reconciler.New(gameServerRepo, prov, sched, hostDeadTTL, zlog) go rec.Run(ctx, reconcileInterval) diff --git a/frontend/src/components/drawers.tsx b/frontend/src/components/drawers.tsx index 11b87ee..e0ed15e 100644 --- a/frontend/src/components/drawers.tsx +++ b/frontend/src/components/drawers.tsx @@ -1,8 +1,9 @@ /* drawers.tsx — ServerDrawer (detail) + CreateDrawer. */ -import { Fragment, useState, type ReactNode } from "react" +import { Fragment, useCallback, useEffect, useState, type ReactNode } from "react" import { Icon } from "./icon" import { Btn, CopyBtn, StatusBadge } from "./primitives" import { SIZES, MAX_HOST_CPU, MAX_HOST_MEM } from "./servers-shared" +import { api, ApiError } from "@/lib/api" import { MC_VERSIONS, fmtMem, @@ -33,6 +34,75 @@ function Row2({ k, children }: { k: string; children: ReactNode }) { ) } +// ServerLogs fetches and renders a server's captured console output on demand. +// It reads through the owner endpoint when the viewer owns the server, or the +// admin endpoint otherwise — mirroring the list views' owner/admin split. +function ServerLogs({ serverId, isOwner }: { serverId: string; isOwner: boolean }) { + const [logs, setLogs] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // All state updates happen in promise callbacks so this is safe to call from + // an effect (no synchronous setState in the effect body), matching the + // marketplace/hosts views' fetch pattern. + const load = useCallback(() => { + const fetch = isOwner ? api.getServerLogs(serverId) : api.adminGetServerLogs(serverId) + fetch + .then((text) => { + setLogs(text) + setError(null) + }) + .catch((e) => setError(e instanceof ApiError ? e.message : "could not load logs")) + .finally(() => setLoading(false)) + }, [serverId, isOwner]) + + useEffect(() => { + load() + }, [load]) + + const refresh = () => { + setLoading(true) + load() + } + + return ( +
+
+
+ Logs +
+ + Refresh + +
+ {error ? ( +
+ + {error} +
+ ) : ( +
+          {loading && logs === null ? "Loading…" : logs ? logs : "No logs yet."}
+        
+ )} +
+ ) +} + const TRANSITIONING: ServerStatus[] = ["scheduling", "provisioning", "starting", "stopping"] const CAN_START: ServerStatus[] = ["stopped", "error", "unschedulable"] const CAN_STOP: ServerStatus[] = ["running", "starting", "provisioning"] @@ -284,6 +354,9 @@ export function ServerDrawer({ + {/* logs */} + + {/* danger */}
(`/servers/${id}`) }, + // Owner-scoped: the captured console output of one's own server. The control + // plane reads it on demand from the backing VM's host. + getServerLogs(id: string): Promise { + return request<{ logs: string }>(`/servers/${id}/logs`).then((r) => r.logs ?? "") + }, + + // Admin-only: the captured console output of any server, regardless of owner. + adminGetServerLogs(id: string): Promise { + return request<{ logs: string }>(`/admin/servers/${id}/logs`).then((r) => r.logs ?? "") + }, + updateServer(id: string, input: UpdateServerInput): Promise { return request(`/servers/${id}`, { method: "PATCH", body: JSON.stringify(input) }) }, diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index adb0514..2ae95a7 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -214,12 +214,28 @@ func TestExecOpDispatch(t *testing.T) { t.Errorf("status after stop = %q, want stopped", stopped.State) } + // Logs returns the VM's console output as the raw result payload (not a VM). + logsReq, _ := json.Marshal(LogsRequest{VMID: vm.ID}) + logsPayload, errStr := execOp(ctx, rt, &pb.Command{Op: OpLogs, Payload: logsReq}) + if errStr != "" { + t.Fatalf("logs op error = %q, want none", errStr) + } + if len(logsPayload) == 0 { + t.Error("logs op returned empty payload, want console output") + } + // Starting a VM the runtime does not know surfaces an error string. ghost, _ := json.Marshal(VMRef{VMID: "vm-ghost"}) if _, errStr := execOp(ctx, rt, &pb.Command{Op: OpStart, Payload: ghost}); errStr == "" { t.Error("start unknown vm: expected error string, got none") } + // Logs for a VM the runtime does not know surfaces an error string. + ghostLogs, _ := json.Marshal(LogsRequest{VMID: "vm-ghost"}) + if _, errStr := execOp(ctx, rt, &pb.Command{Op: OpLogs, Payload: ghostLogs}); errStr == "" { + t.Error("logs unknown vm: expected error string, got none") + } + // An unrecognized op is reported, not fatal. if _, errStr := execOp(ctx, rt, &pb.Command{Op: "bogus"}); errStr == "" { t.Error("unknown op: expected error string, got none") diff --git a/internal/agent/firecracker/machine.go b/internal/agent/firecracker/machine.go index bc0fd94..cb85495 100644 --- a/internal/agent/firecracker/machine.go +++ b/internal/agent/firecracker/machine.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "syscall" "time" @@ -72,6 +73,12 @@ const ( shutdownGrace = 10 * time.Second ) +// logFileName is the per-VM file Firecracker's stdout/stderr is captured into. +// With console=ttyS0 in the boot args this also carries the guest serial +// console — kernel boot messages and the workload's own stdout/stderr — so it +// is the host-side source for a server's logs. +const logFileName = "firecracker.log" + // boot launches the Firecracker process, waits for its API socket, configures // the machine, and starts the guest. On any failure it tears the process down // so a half-built VM never lingers. @@ -79,7 +86,7 @@ func (m *machine) boot(ctx context.Context) error { // A stale socket from a prior crash would make the dialer connect to nothing. _ = os.Remove(m.socket) - logFile, err := os.Create(m.dir + "/firecracker.log") + logFile, err := os.Create(filepath.Join(m.dir, logFileName)) if err != nil { return fmt.Errorf("create log: %w", err) } diff --git a/internal/agent/firecracker/runtime.go b/internal/agent/firecracker/runtime.go index 7785e3e..fef57fb 100644 --- a/internal/agent/firecracker/runtime.go +++ b/internal/agent/firecracker/runtime.go @@ -477,6 +477,52 @@ func (r *Runtime) Snapshot(ctx context.Context, vmID string) error { return r.snapshotRunning(ctx, m) } +// Logs returns the VM's captured console/VMM output, read from its per-VM +// firecracker.log (which carries the guest serial console — kernel messages and +// the workload's own stdout/stderr — thanks to console=ttyS0). When tailLines > +// 0 only the last that many lines are returned. ErrVMNotFound for an unknown id. +// A not-yet-booted VM with no log file yet yields empty output, not an error. +func (r *Runtime) Logs(_ context.Context, vmID string, tailLines int) ([]byte, error) { + r.mu.Lock() + m, ok := r.vms[vmID] + r.mu.Unlock() + if !ok { + return nil, agent.ErrVMNotFound + } + data, err := os.ReadFile(filepath.Join(m.dir, logFileName)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("firecracker: read vm log: %w", err) + } + return tailBytes(data, tailLines), nil +} + +// tailBytes returns the last n lines of b (newline-delimited), or all of b when +// n <= 0. It counts the trailing newline's empty segment as no extra line, so a +// log ending in "\n" tails the lines a human would count. +func tailBytes(b []byte, n int) []byte { + if n <= 0 || len(b) == 0 { + return b + } + // Walk backwards over n line boundaries, skipping a single trailing newline. + end := len(b) + if b[end-1] == '\n' { + end-- + } + count := 0 + for i := end - 1; i >= 0; i-- { + if b[i] == '\n' { + count++ + if count == n { + return b[i+1:] + } + } + } + return b +} + // releaseNet returns a VM's address/port to the IPAM pool. No-op when the // dataplane is disabled or the vmNet is empty. func (r *Runtime) releaseNet(n vmNet) { diff --git a/internal/agent/firecracker/runtime_test.go b/internal/agent/firecracker/runtime_test.go index 046e73a..5ce07f5 100644 --- a/internal/agent/firecracker/runtime_test.go +++ b/internal/agent/firecracker/runtime_test.go @@ -175,6 +175,35 @@ func TestLifecycleIdempotencyNoProcess(t *testing.T) { if vm.State != agent.StateMissing { t.Errorf("status unknown state = %q, want missing", vm.State) } + // Logs distinguishes a missing VM from an empty one: it errors rather than + // returning empty output (Status returns "missing" instead, by contract). + if _, err := rt.Logs(ctx, "ghost", 0); !errors.Is(err, agent.ErrVMNotFound) { + t.Errorf("logs unknown = %v, want ErrVMNotFound", err) + } +} + +func TestTailBytes(t *testing.T) { + cases := []struct { + name string + in string + n int + want string + }{ + {"all when n<=0", "a\nb\nc\n", 0, "a\nb\nc\n"}, + {"last two lines", "a\nb\nc\n", 2, "b\nc\n"}, + {"last one line", "a\nb\nc\n", 1, "c\n"}, + {"n exceeds lines", "a\nb\n", 9, "a\nb\n"}, + {"no trailing newline", "a\nb\nc", 2, "b\nc"}, + {"single line", "only\n", 1, "only\n"}, + {"empty", "", 5, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := string(tailBytes([]byte(tc.in), tc.n)); got != tc.want { + t.Errorf("tailBytes(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want) + } + }) + } } func TestProvisionRejectsInvalidSpec(t *testing.T) { diff --git a/internal/agent/link.go b/internal/agent/link.go index 4e0abf8..ccfd042 100644 --- a/internal/agent/link.go +++ b/internal/agent/link.go @@ -24,6 +24,7 @@ const ( OpEvict = "evict" OpDeprovision = "deprovision" OpStatus = "status" + OpLogs = "logs" ) // VMRef is the JSON payload for the ops that act on an existing VM by id @@ -33,6 +34,14 @@ type VMRef struct { VMID string `json:"vm_id"` } +// LogsRequest is the JSON payload for OpLogs: the VM to read plus how many +// trailing lines to return (0 = all). The Result payload is the raw log bytes, +// not a JSON-encoded VM. +type LogsRequest struct { + VMID string `json:"vm_id"` + TailLines int `json:"tail_lines"` +} + // LinkInfo is what the agent announces about itself when it opens the stream. // It mirrors the old HTTP registration minus the advertise address: the control // plane no longer dials the agent, so there is nothing to advertise. @@ -176,6 +185,15 @@ func execOp(ctx context.Context, rt Runtime, cmd *pb.Command) (payload []byte, e case OpStatus: vm, err := rt.Status(ctx, vmRef(cmd)) return marshalVM(vm), errString(err) + case OpLogs: + var req LogsRequest + if err := json.Unmarshal(cmd.Payload, &req); err != nil { + return nil, "decode logs request: " + err.Error() + } + // The result payload is the raw log bytes; the hub returns them verbatim + // rather than decoding a VM. + out, err := rt.Logs(ctx, req.VMID, req.TailLines) + return out, errString(err) default: return nil, "unknown op " + cmd.Op } diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go index f72590a..e74f1b8 100644 --- a/internal/agent/runtime.go +++ b/internal/agent/runtime.go @@ -9,6 +9,7 @@ package agent import ( "context" "errors" + "strings" "sync" "github.com/aarani/craftling-go/internal/runspec" @@ -104,6 +105,10 @@ type Runtime interface { // world into the durable store (P5c), on demand. ErrVMNotFound for an // unknown id; an error if the runtime has no world store configured. Snapshot(ctx context.Context, vmID string) error + // Logs returns the VM's captured console/VMM output, most recent last. When + // tailLines > 0 only the last that many lines are returned; <= 0 returns all + // available output. ErrVMNotFound for an unknown id. + Logs(ctx context.Context, vmID string, tailLines int) ([]byte, error) } // FakeRuntime is an in-memory Runtime that simulates VMs. It lets the control @@ -304,6 +309,28 @@ func (r *FakeRuntime) Snapshot(_ context.Context, vmID string) error { return nil } +// Logs returns synthetic console output for a known VM so the logs path can be +// exercised end-to-end before a real driver exists. ErrVMNotFound for an +// unknown id. tailLines is honored against the synthesized lines. +func (r *FakeRuntime) Logs(_ context.Context, vmID string, tailLines int) ([]byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + + fv, ok := r.vms[vmID] + if !ok { + return nil, ErrVMNotFound + } + lines := []string{ + "[fake] vm " + vmID + " booting", + "[fake] server " + fv.vm.ServerID + " listening on " + fv.vm.Host, + "[fake] state " + fv.vm.State, + } + if tailLines > 0 && tailLines < len(lines) { + lines = lines[len(lines)-tailLines:] + } + return []byte(strings.Join(lines, "\n") + "\n"), nil +} + func clone(vm *VM) *VM { c := *vm return &c diff --git a/internal/agentlink/hub.go b/internal/agentlink/hub.go index d42a693..b1db9d8 100644 --- a/internal/agentlink/hub.go +++ b/internal/agentlink/hub.go @@ -240,10 +240,39 @@ func (h *Hub) Status(ctx context.Context, hostID, vmID string) (*agent.VM, error return h.call(ctx, hostID, agent.OpStatus, agent.VMRef{VMID: vmID}) } +// Logs fetches a VM's captured console/VMM output from the host's agent, +// returning the raw log bytes (the last tailLines lines when tailLines > 0, all +// of it otherwise). +func (h *Hub) Logs(ctx context.Context, hostID, vmID string, tailLines int) ([]byte, error) { + return h.callRaw(ctx, hostID, agent.OpLogs, agent.LogsRequest{VMID: vmID, TailLines: tailLines}) +} + // call sends one command down the host's stream and blocks for the correlated // reply (or until ctx is done). It returns the decoded VM when the op yields // one, a nil VM for ops that don't, or an error from the agent or transport. func (h *Hub) call(ctx context.Context, hostID, op string, reqPayload any) (*agent.VM, error) { + payload, err := h.callRaw(ctx, hostID, op, reqPayload) + if err != nil { + return nil, err + } + if len(payload) == 0 { + return nil, nil + } + var vm agent.VM + if err := json.Unmarshal(payload, &vm); err != nil { + return nil, fmt.Errorf("decode %s result: %w", op, err) + } + if vm.ID == "" { + return nil, nil + } + return &vm, nil +} + +// callRaw sends one command down the host's stream and blocks for the +// correlated reply (or until ctx is done), returning the result payload bytes +// verbatim. It is the shared transport under call (which decodes the payload +// into a VM) and the raw-payload ops like logs. +func (h *Hub) callRaw(ctx context.Context, hostID, op string, reqPayload any) ([]byte, error) { c := h.get(hostID) if c == nil { return nil, ErrHostNotConnected @@ -270,16 +299,6 @@ func (h *Hub) call(ctx context.Context, hostID, op string, reqPayload any) (*age if res.Error != "" { return nil, fmt.Errorf("agent %s: %s", op, res.Error) } - if len(res.Payload) == 0 { - return nil, nil - } - var vm agent.VM - if err := json.Unmarshal(res.Payload, &vm); err != nil { - return nil, fmt.Errorf("decode %s result: %w", op, err) - } - if vm.ID == "" { - return nil, nil - } - return &vm, nil + return res.Payload, nil } } diff --git a/internal/handler/admin.go b/internal/handler/admin.go index bb665e2..f8f189f 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/aarani/craftling-go/internal/logger" @@ -14,11 +15,13 @@ type AdminHandler struct { users *repository.UserRepository servers *repository.GameServerRepository hosts *repository.HostRepository + logs LogProvider } -// NewAdminHandler constructs an AdminHandler. -func NewAdminHandler(users *repository.UserRepository, servers *repository.GameServerRepository, hosts *repository.HostRepository) *AdminHandler { - return &AdminHandler{users: users, servers: servers, hosts: hosts} +// NewAdminHandler constructs an AdminHandler. logs may be nil only if the admin +// logs endpoint is never exercised. +func NewAdminHandler(users *repository.UserRepository, servers *repository.GameServerRepository, hosts *repository.HostRepository, logs LogProvider) *AdminHandler { + return &AdminHandler{users: users, servers: servers, hosts: hosts, logs: logs} } // ListUsers returns all users. Guarded by RequireRole(admin). @@ -43,6 +46,20 @@ func (h *AdminHandler) ListServers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"servers": servers}) } +// ServerLogs returns the captured console output of any server's backing VM, +// regardless of owner. Guarded by RequireRole(admin). +func (h *AdminHandler) ServerLogs(c *gin.Context) { + s, err := h.servers.GetByID(c.Request.Context(), c.Param("id")) + if err != nil { + if !errors.Is(err, repository.ErrNotFound) { + logger.FromContext(c).Error("get server", zap.Error(err)) + } + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + writeLogs(c, h.logs, s) +} + // ListHosts returns the whole fleet inventory. Guarded by RequireRole(admin). func (h *AdminHandler) ListHosts(c *gin.Context) { hosts, err := h.hosts.List(c.Request.Context()) diff --git a/internal/handler/router.go b/internal/handler/router.go index fa1662d..cfe172a 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -19,7 +19,7 @@ import ( // NewRouter builds the Gin engine with middleware and routes wired up. The host // inventory is passed in (rather than built here) so the host reaper can share // the same in-memory store. -func NewRouter(cfg *config.Config, log *zap.Logger, pool *pgxpool.Pool, hostRepo *repository.HostRepository) *gin.Engine { +func NewRouter(cfg *config.Config, log *zap.Logger, pool *pgxpool.Pool, hostRepo *repository.HostRepository, logs LogProvider) *gin.Engine { if cfg.Env == "production" { gin.SetMode(gin.ReleaseMode) } @@ -29,13 +29,13 @@ func NewRouter(cfg *config.Config, log *zap.Logger, pool *pgxpool.Pool, hostRepo refreshRepo := repository.NewRefreshTokenRepository(pool) gameServerRepo := repository.NewGameServerRepository(pool) authHandler := NewAuthHandler(userRepo, refreshRepo, jwtManager, cfg.RefreshTTL) - adminHandler := NewAdminHandler(userRepo, gameServerRepo, hostRepo) + adminHandler := NewAdminHandler(userRepo, gameServerRepo, hostRepo, logs) // One registry client backs both the template browse endpoints and the // server-side template resolution the create handler performs. registryClient := registry.New(cfg.TemplateIndexURL, &http.Client{Timeout: 10 * time.Second}) // The scheduler is stateless over the shared in-memory host inventory, so the // handler builds its own; the reconciler builds another over the same store. - serverHandler := NewServerHandler(gameServerRepo, scheduler.New(hostRepo), registryClient) + serverHandler := NewServerHandler(gameServerRepo, scheduler.New(hostRepo), registryClient, logs) templateHandler := NewTemplateHandler(registryClient) r := gin.New() @@ -67,6 +67,7 @@ func NewRouter(cfg *config.Config, log *zap.Logger, pool *pgxpool.Pool, hostRepo servers.POST("", serverHandler.Create) servers.GET("", serverHandler.List) servers.GET("/:id", serverHandler.Get) + servers.GET("/:id/logs", serverHandler.Logs) servers.PATCH("/:id", serverHandler.Update) servers.POST("/:id/snapshot", serverHandler.RequestBackup) servers.DELETE("/:id", serverHandler.Delete) @@ -86,6 +87,7 @@ func NewRouter(cfg *config.Config, log *zap.Logger, pool *pgxpool.Pool, hostRepo { admin.GET("/users", adminHandler.ListUsers) admin.GET("/servers", adminHandler.ListServers) + admin.GET("/servers/:id/logs", adminHandler.ServerLogs) admin.GET("/hosts", adminHandler.ListHosts) } diff --git a/internal/handler/servers.go b/internal/handler/servers.go index a01d442..bcd1fda 100644 --- a/internal/handler/servers.go +++ b/internal/handler/servers.go @@ -4,7 +4,9 @@ import ( "context" "errors" "net/http" + "strconv" + "github.com/aarani/craftling-go/internal/agentlink" "github.com/aarani/craftling-go/internal/logger" "github.com/aarani/craftling-go/internal/middleware" "github.com/aarani/craftling-go/internal/model" @@ -28,17 +30,26 @@ type TemplateResolver interface { ManifestParsed(ctx context.Context, id string) (*registry.Manifest, error) } +// LogProvider fetches a server's captured console output from its backing VM. +// *provisioner.RemoteProvisioner satisfies it (and provisioner.Fake in tests); +// both the owner and admin log endpoints read through it. +type LogProvider interface { + Logs(ctx context.Context, s *model.GameServer, tailLines int) ([]byte, error) +} + // ServerHandler serves the game-server CRUD endpoints. type ServerHandler struct { servers *repository.GameServerRepository sched *scheduler.Scheduler templates TemplateResolver + logs LogProvider } // NewServerHandler constructs a ServerHandler. templates may be nil only if the -// template-launch path is never exercised. -func NewServerHandler(servers *repository.GameServerRepository, sched *scheduler.Scheduler, templates TemplateResolver) *ServerHandler { - return &ServerHandler{servers: servers, sched: sched, templates: templates} +// template-launch path is never exercised; logs may be nil only if the logs +// endpoint is never exercised. +func NewServerHandler(servers *repository.GameServerRepository, sched *scheduler.Scheduler, templates TemplateResolver, logs LogProvider) *ServerHandler { + return &ServerHandler{servers: servers, sched: sched, templates: templates, logs: logs} } // createServerRequest is the create payload. A request takes one of two shapes: @@ -178,6 +189,63 @@ func (h *ServerHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, s) } +// Logs returns the captured console output of an owned server's backing VM. +func (h *ServerHandler) Logs(c *gin.Context) { + s, ok := h.ownedOr404(c) + if !ok { + return + } + writeLogs(c, h.logs, s) +} + +// defaultLogTailLines / maxLogTailLines bound how many trailing log lines a +// request returns, keeping the response payload bounded even for a long-running +// server. A caller narrows it with ?tail=N; values above the cap are clamped. +const ( + defaultLogTailLines = 1000 + maxLogTailLines = 5000 +) + +// writeLogs fetches a server's logs through the provider and writes the JSON +// response, shared by the owner and admin endpoints. It maps an unreachable +// host to 503 (transient) and anything else to 500. +func writeLogs(c *gin.Context, provider LogProvider, s *model.GameServer) { + if provider == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "logs are not available"}) + return + } + tail := logTailParam(c) + out, err := provider.Logs(c.Request.Context(), s, tail) + if err != nil { + if errors.Is(err, agentlink.ErrHostNotConnected) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "the server's host is not currently reachable"}) + return + } + logger.FromContext(c).Error("get server logs", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusOK, gin.H{"server_id": s.ID, "logs": string(out)}) +} + +// logTailParam reads the ?tail=N query, defaulting to defaultLogTailLines and +// clamping into (0, maxLogTailLines]. A non-positive or unparseable value falls +// back to the default rather than requesting the unbounded log. +func logTailParam(c *gin.Context) int { + raw := c.Query("tail") + if raw == "" { + return defaultLogTailLines + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultLogTailLines + } + if n > maxLogTailLines { + return maxLogTailLines + } + return n +} + // Update edits the spec and/or desired state of an owned server. func (h *ServerHandler) Update(c *gin.Context) { var req updateServerRequest diff --git a/internal/provisioner/provisioner.go b/internal/provisioner/provisioner.go index ecdc418..941bad7 100644 --- a/internal/provisioner/provisioner.go +++ b/internal/provisioner/provisioner.go @@ -56,6 +56,10 @@ type Provisioner interface { // running server into the durable store (P5). A no-op when the server has // no backing VM (nothing live to capture). Snapshot(ctx context.Context, s *model.GameServer) error + // Logs returns the server's captured console output from its backing VM (the + // last tailLines lines when tailLines > 0, all of it otherwise). A server + // with no backing VM has no logs, so it returns empty output and no error. + Logs(ctx context.Context, s *model.GameServer, tailLines int) ([]byte, error) } // defaultMinecraftPort is the standard Minecraft server port. @@ -106,6 +110,16 @@ func (Fake) Deprovision(_ context.Context, _ *model.GameServer) error { return n // Snapshot is a no-op for the fake backend; there is no real world to capture. func (Fake) Snapshot(_ context.Context, _ *model.GameServer) error { return nil } +// Logs returns synthetic console output for a server with a backing VM, or +// empty output for one that was never provisioned, so the logs path can be +// exercised against the fake backend. +func (Fake) Logs(_ context.Context, s *model.GameServer, _ int) ([]byte, error) { + if s.VMID == nil || *s.VMID == "" { + return nil, nil + } + return []byte("[fake] logs for server " + s.ID + " (vm " + *s.VMID + ")\n"), nil +} + // Status infers state from the server's recorded VM id, since the fake holds no // real backend state: a server with a VM is running, otherwise it is missing. func (Fake) Status(_ context.Context, s *model.GameServer) (State, error) { diff --git a/internal/provisioner/remote.go b/internal/provisioner/remote.go index ce2f4ad..d13917f 100644 --- a/internal/provisioner/remote.go +++ b/internal/provisioner/remote.go @@ -27,6 +27,7 @@ type Commander interface { Evict(ctx context.Context, hostID, vmID string) error Deprovision(ctx context.Context, hostID, vmID string) error Status(ctx context.Context, hostID, vmID string) (*agent.VM, error) + Logs(ctx context.Context, hostID, vmID string, tailLines int) ([]byte, error) } // RemoteProvisioner implements Provisioner by sending commands to the agent on @@ -150,6 +151,20 @@ func (p *RemoteProvisioner) Snapshot(ctx context.Context, s *model.GameServer) e return p.cmd.Snapshot(ctx, hostID, *s.VMID) } +// Logs asks the assigned host's agent for the server's captured console output. +// A server with no backing VM has nothing to read, so it returns empty output +// rather than erroring. +func (p *RemoteProvisioner) Logs(ctx context.Context, s *model.GameServer, tailLines int) ([]byte, error) { + if s.VMID == nil || *s.VMID == "" { + return nil, nil + } + hostID, err := assignedHost(s) + if err != nil { + return nil, err + } + return p.cmd.Logs(ctx, hostID, *s.VMID, tailLines) +} + // assignedHost returns the server's assigned host id, or ErrUnplaced. func assignedHost(s *model.GameServer) (string, error) { if s.HostID == nil || *s.HostID == "" { diff --git a/internal/provisioner/remote_test.go b/internal/provisioner/remote_test.go index 4998232..b05ca9d 100644 --- a/internal/provisioner/remote_test.go +++ b/internal/provisioner/remote_test.go @@ -35,6 +35,9 @@ func (c fakeCommander) Deprovision(ctx context.Context, _, vmID string) error { func (c fakeCommander) Status(ctx context.Context, _, vmID string) (*agent.VM, error) { return c.rt.Status(ctx, vmID) } +func (c fakeCommander) Logs(ctx context.Context, _, vmID string, tailLines int) ([]byte, error) { + return c.rt.Logs(ctx, vmID, tailLines) +} // errCommander fails loudly on every call, so a test can assert a code path is a // no-op that never reaches the command channel. @@ -75,6 +78,11 @@ func (c errCommander) Status(context.Context, string, string) (*agent.VM, error) c.t.Fatal("Status called, want no-op") return nil, errors.New("unreachable") } +func (c errCommander) Logs(context.Context, string, string, int) ([]byte, error) { + c.t.Helper() + c.t.Fatal("Logs called, want no-op") + return nil, errors.New("unreachable") +} func ptr(s string) *string { return &s } diff --git a/proto/agentlink/agentlink.proto b/proto/agentlink/agentlink.proto index 79638d1..5521a95 100644 --- a/proto/agentlink/agentlink.proto +++ b/proto/agentlink/agentlink.proto @@ -44,19 +44,21 @@ message Register { } // Command is one VM lifecycle request. payload is the JSON of the existing Go -// type for the op (agent.VMSpec for provision; {"vm_id":...} otherwise), so the -// command schema stays single-sourced in Go rather than duplicated here. +// type for the op (agent.VMSpec for provision; agent.LogsRequest for logs; +// {"vm_id":...} otherwise), so the command schema stays single-sourced in Go +// rather than duplicated here. message Command { string id = 1; // correlation id, echoed in the matching Result - string op = 2; // provision|start|stop|snapshot|deprovision|status + string op = 2; // provision|start|stop|snapshot|evict|deprovision|status|logs bytes payload = 3; // JSON request body for the op } // Result answers a Command. payload is the JSON of agent.VM when the op returns -// one (provision/start/status); error is non-empty when the op failed. +// one (provision/start/status), the raw log bytes for logs, or empty; error is +// non-empty when the op failed. message Result { string id = 1; // matches the Command id - bytes payload = 2; // JSON agent.VM, or empty + bytes payload = 2; // JSON agent.VM, raw log bytes, or empty string error = 3; // non-empty on failure } diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 3fc3174..3267274 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -109,14 +109,17 @@ func TestMain(m *testing.M) { hostRepo = repository.NewHostRepository() gameServerRepo := repository.NewGameServerRepository(pool) - srv := httptest.NewServer(handler.NewRouter(cfg, zap.NewNop(), pool, hostRepo)) - baseURL = srv.URL - // The hub is the control plane's end of the persistent agent connection: // agents dial this gRPC listener and hold a stream open, and the hub registers // hosts and pushes VM commands down the stream. This is the real P3 seam, - // replacing the old outbound HTTP client the control plane used to dial. + // replacing the old outbound HTTP client the control plane used to dial. The + // remote provisioner over it backs both the reconciler and the log endpoint. hub := agentlink.NewHub(hostRepo, gameServerRepo, zap.NewNop()) + prov := provisioner.NewRemote(hub) + + srv := httptest.NewServer(handler.NewRouter(cfg, zap.NewNop(), pool, hostRepo, prov)) + baseURL = srv.URL + grpcSrv := grpc.NewServer() pb.RegisterAgentLinkServer(grpcSrv, hub) grpcLis, err := net.Listen("tcp", "127.0.0.1:0") @@ -132,7 +135,6 @@ func TestMain(m *testing.M) { // the hub (the control plane never touches a runtime directly). recCtx, recCancel := context.WithCancel(ctx) sched := scheduler.New(hostRepo) - prov := provisioner.NewRemote(hub) rec := reconciler.New(gameServerRepo, prov, sched, 30*time.Second, zap.NewNop()) go rec.Run(recCtx, 100*time.Millisecond)