Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ Dockerfile
.idea/
.vscode/
.DS_Store
fc-assets/work/
fc-assets/images/
21 changes: 14 additions & 7 deletions cmd/init/run_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 10 additions & 7 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)

Expand Down
75 changes: 74 additions & 1 deletion frontend/src/components/drawers.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div>
<div className="between" style={{ marginBottom: 9 }}>
<div className="row gap-2 t-xs upper muted">
<Icon name="activity" size={13} /> Logs
</div>
<Btn variant="outline" size="sm" onClick={refresh} disabled={loading}>
<Icon name="refresh" size={13} className={loading ? "spin" : undefined} /> Refresh
</Btn>
</div>
{error ? (
<div className="row gap-2 t-sm" style={{ color: "var(--danger-fg)", alignItems: "flex-start" }}>
<Icon name="alert" size={15} style={{ marginTop: 1, flex: "none" }} />
<span>{error}</span>
</div>
) : (
<pre
className="mono t-xs"
style={{
margin: 0,
maxHeight: 320,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
padding: "10px 12px",
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--muted)",
color: "var(--foreground)",
}}
>
{loading && logs === null ? "Loading…" : logs ? logs : "No logs yet."}
</pre>
)}
</div>
)
}

const TRANSITIONING: ServerStatus[] = ["scheduling", "provisioning", "starting", "stopping"]
const CAN_START: ServerStatus[] = ["stopped", "error", "unschedulable"]
const CAN_STOP: ServerStatus[] = ["running", "starting", "provisioning"]
Expand Down Expand Up @@ -284,6 +354,9 @@ export function ServerDrawer({
</Row2>
</div>

{/* logs */}
<ServerLogs serverId={s.id} isOwner={isOwner} />

{/* danger */}
<div
className="card pad"
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ export const api = {
return request<ApiServer>(`/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<string> {
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<string> {
return request<{ logs: string }>(`/admin/servers/${id}/logs`).then((r) => r.logs ?? "")
},

updateServer(id: string, input: UpdateServerInput): Promise<ApiServer> {
return request<ApiServer>(`/servers/${id}`, { method: "PATCH", body: JSON.stringify(input) })
},
Expand Down
16 changes: 16 additions & 0 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 8 additions & 1 deletion internal/agent/firecracker/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"

Expand Down Expand Up @@ -72,14 +73,20 @@ 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.
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)
}
Expand Down
46 changes: 46 additions & 0 deletions internal/agent/firecracker/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions internal/agent/firecracker/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions internal/agent/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading