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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ spinloop harness [<spinloop>] [-H <name>] [--spinloop[=<path>]] [args...]
# launch the harness (a leading Spinloop or alias is
# applied first; --get shows it; --set stores it)
spinloop completion <shell> # tab completion (bash, zsh, powershell)
spinloop remote <bootstrap|start|pause|stop|restart|status|metrics|logs|deploy|env|ls|keep|seed> [path]
spinloop remote <bootstrap|bake|start|pause|stop|restart|status|metrics|logs|deploy|env|ls|keep|seed> [path]
# control the remote GPU inference instance
# (bootstrap does the once-per-account setup;
# bake bakes the runner AMI(s) it launches from;
# deploy sets what it serves, from the Spinloop;
# pause stops it while keeping it re-wakeable;
# restart gives a fresh engine at the same address;
Expand Down
46 changes: 26 additions & 20 deletions cmd/spinloop/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spinloop-ai/spinloop/internal/fleet"
"github.com/spinloop-ai/spinloop/internal/harness"
"github.com/spinloop-ai/spinloop/internal/opencode"
Expand Down Expand Up @@ -290,8 +291,22 @@ func versionCmd() *cobra.Command {
}
}

// fleetCmd builds the fleet parent and its subcommands. The parent runs when
// no subcommand is named and reports the usage, as the old dispatch did.
// groupFallback is the RunE a command group (fleet, remote, seed) gets: bare,
// it shows the group's own help — the one cobra generates from the tree, so
// its subcommand list cannot drift from the tree — and a word that is not a
// subcommand is cobra's own unknown-command error. The help sentinel is
// pflag's, not the stdlib one: cobra's ExecuteC checks pflag.ErrHelp (cobra
// imports pflag as its flag package), and the stdlib twin would surface as a
// bare "flag: help requested" error instead of the help.
func groupFallback(c *cobra.Command, args []string) error {
if len(args) == 0 {
return pflag.ErrHelp
}
return cobra.NoArgs(c, args)
}

// fleetCmd builds the fleet parent and its subcommands. The parent does
// nothing itself — see groupFallback.
func fleetCmd() *cobra.Command {
fleet := &cobra.Command{
Use: "fleet",
Expand All @@ -302,14 +317,9 @@ logs, and dashboard — the live tiled view); start, stop and route act on a
single node, and with no node they list the fleet and touch nothing. A node
that fails is a rendered row, never an error — only a problem with the fleet
file itself fails a command.`,
Args: cobra.ArbitraryArgs,
DisableFlagParsing: true,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return fleetParentFallback(args)
},
SilenceErrors: true,
SilenceUsage: true,
RunE: groupFallback,
}
fleet.AddCommand(
fleetStatusCmd(),
Expand All @@ -323,8 +333,8 @@ file itself fails a command.`,
return fleet
}

// remoteCmd builds the remote parent and its subcommands. The parent runs
// when no subcommand is named and reports the usage, as the old dispatch did.
// remoteCmd builds the remote parent and its subcommands. The parent does
// nothing itself — see groupFallback.
func remoteCmd() *cobra.Command {
remote := &cobra.Command{
Use: "remote",
Expand All @@ -334,17 +344,13 @@ the same Spinloop. The endpoint's URLs come from the Spinloop's REMOTE — a bar
name selects an environment under ~/.config/spinloop/remotes/<name>/, a path
names a file — falling back to the default environment. Each subcommand's
--help says what that step does.`,
Args: cobra.ArbitraryArgs,
DisableFlagParsing: true,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return remoteParentFallback(args)
},
SilenceErrors: true,
SilenceUsage: true,
RunE: groupFallback,
}
remote.AddCommand(
remoteBootstrapCmd(),
remoteBakeCmd(),
remoteStartCmd(),
remotePauseCmd(),
remoteRestartCmd(),
Expand Down
12 changes: 0 additions & 12 deletions cmd/spinloop/fleet.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,6 @@ import (
"github.com/spinloop-ai/spinloop/internal/fleet"
)

// fleetParentFallback reports the usage when no (named) subcommand is given,
// and rejects the ones that are not named — the subcommands themselves are real
// commands in the tree, so only the unknown and the bare cases reach here.
func fleetParentFallback(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: spinloop fleet <status|metrics|logs|dashboard|route|start|stop> [node] [--fleet <path>]")
}
return fmt.Errorf(
"unknown fleet subcommand %q (expected status, metrics, logs, dashboard, route, start or stop)",
args[0])
}

// cmdFleet runs the fleet subcommands through the tree — the seam the suite
// calls directly.
func cmdFleet(args []string) error {
Expand Down
15 changes: 11 additions & 4 deletions cmd/spinloop/fleet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,18 @@ func TestCmdFleetExplicitPath(t *testing.T) {
}

func TestCmdFleetUnknownSubcommand(t *testing.T) {
if err := cmdFleet([]string{"wat"}); err == nil {
t.Fatal("unknown subcommand accepted")
if err := cmdFleet([]string{"wat"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("unknown subcommand should error, got %v", err)
}
if err := cmdFleet(nil); err == nil {
t.Fatal("no subcommand accepted")
// Bare: no error — cobra shows the group's own help, with the
// subcommand list generated from the tree.
out := captureStdout(t, func() {
if err := cmdFleet([]string{}); err != nil {
t.Fatalf("bare fleet should show its help, got %v", err)
}
})
if !strings.Contains(out, "Available Commands") {
t.Errorf("bare fleet should list its subcommands:\n%s", out)
}
}

Expand Down
16 changes: 2 additions & 14 deletions cmd/spinloop/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,8 @@ import (
// retention deadline to prevent the sweep from terminating the instance
// early, and deploy sets what the instance will serve from the Spinloop itself.
// Each subcommand takes an optional Spinloop path; see resolveRemoteConfig for
// how the remote config is found.
// cmdRemote reports the usage when no (named) subcommand is given, and rejects
// the ones that are not named — the subcommands themselves are real commands
// in the tree, so only the unknown and the bare cases reach here.
// remoteParentFallback reports the usage when no (named) subcommand is given,
// and rejects the ones that are not named — the subcommands themselves are real
// commands in the tree, so only the unknown and the bare cases reach here.
func remoteParentFallback(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: spinloop remote <bootstrap|start|pause|restart|stop|status|metrics|logs|deploy|seed|env|ls|keep> [path]")
}
return fmt.Errorf(
"unknown remote subcommand %q (expected bootstrap, start, pause, restart, stop, status, metrics, logs, deploy, seed, env, ls or keep)", args[0])
}
// how the remote config is found. The group itself does nothing — see
// groupFallback for what a bare or mistyped invocation gets.

// cmdRemote runs the remote subcommands through the tree — the seam the suite
// calls directly.
Expand Down
161 changes: 161 additions & 0 deletions cmd/spinloop/remote_bake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package main

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"

"github.com/spf13/cobra"
)

// defaultBakeRunners is what `spinloop remote bake` bakes when no runners are
// named, and the full accepted set — the two engines an environment can run.
var defaultBakeRunners = []string{"llamacpp", "vllm"}

// isBakeRunner reports whether r is a runner bake accepts.
func isBakeRunner(r string) bool {
for _, v := range defaultBakeRunners {
if r == v {
return true
}
}
return false
}

// bakeRunnerSlot is the `bake [runner...]` completion: the accepted runners
// not already on the line.
func bakeRunnerSlot(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
taken := map[string]bool{}
for _, a := range args {
taken[a] = true
}
var out []string
for _, r := range defaultBakeRunners {
if !taken[r] {
out = append(out, r)
}
}
return out, cobra.ShellCompDirectiveNoFileComp
}

// remoteBakeCmd starts an AMI bake for each named runner. It drives the same
// CDK project bootstrap deploys but deploys nothing — the control plane (and
// its Image Builder pipelines) must already exist, so a missing one fails fast
// naming bootstrap rather than deploying implicitly. The wait is the default:
// the step after a bake is `spinloop remote deploy`, which needs the AMI.
func remoteBakeCmd() *cobra.Command {
var (
noWait bool
ref string
dir string
region string
pkgMgr string
)
c := &cobra.Command{
Use: "bake [runner...]",
Short: "bake the runner AMIs an environment runs from",
Long: `starts an AMI bake for each named runner (llamacpp and vllm when
none are named) and waits until the AMI(s) are available (~20-40 min). It
drives the same CDK project bootstrap deploys, deploys nothing — the control
plane must already exist — and --no-wait returns as soon as the bakes are
queued, reporting how to check on them.`,
Args: cobra.ArbitraryArgs,
SilenceErrors: true,
SilenceUsage: true,
ValidArgsFunction: bakeRunnerSlot,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return runRemoteBake(args, noWait, ref, dir, region, pkgMgr)
},
}
fs := c.Flags()
fs.BoolVar(&noWait, "no-wait", false, "return as soon as the bakes are queued, rather than waiting for the AMI(s)")
fs.StringVar(&ref, "ref", "", "git ref of remote/ to download (default: matches this binary)")
fs.StringVar(&dir, "dir", "", "where to find the downloaded remote/ sources")
fs.StringVar(&region, "region", "", "AWS region of the control plane (default: AWS_REGION or us-east-1)")
fs.StringVar(&pkgMgr, "package-manager", "", "package manager to use: pnpm or npm (default: auto-detect, preferring pnpm)")
compRegister(c, "dir", compFiles)
return c
}

// runRemoteBake is the body of `spinloop remote bake`.
func runRemoteBake(args []string, noWait bool, ref, dir, region string, pkgMgr string) error {
runners := defaultBakeRunners
if len(args) > 0 {
runners = args
for _, r := range args {
if !isBakeRunner(r) {
return fmt.Errorf("unknown runner %q — bake accepts llamacpp and vllm", r)
}
}
}

pmName, pmPinned, err := resolvePackageManagerName(pkgMgr)
if err != nil {
return err
}

loc, err := resolveSourceLocation(ref, dir)
if err != nil {
return err
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

pm, err := preflightFn(pmName, pmPinned)
if err != nil {
return err
}

resolvedRegion := resolveRegion(region)
cfg, err := loadCreds(ctx, resolvedRegion)
if err != nil {
return fmt.Errorf(
"resolving AWS credentials: %w (configure env credentials, a profile or an SSO session)", err)
}

deployed, err := stackDeployedFn(ctx, cfg, controlPlaneStackName)
if err != nil {
return err
}
if !deployed {
return fmt.Errorf(
"the control plane is not deployed in %s — run `spinloop remote bootstrap` first", resolvedRegion)
}

if err := downloadFn(ctx, loc.ref, loc.dir); err != nil {
return err
}

fmt.Fprintf(os.Stderr, "\nUsing %s to run the CDK project.\n", pm.name)
run := func(name string, argv ...string) error {
return runStep(ctx, name, argv, loc.dir)
}
if !dirExists(filepath.Join(loc.dir, "node_modules")) {
if err := run("install", pm.install...); err != nil {
return err
}
}
for _, r := range runners {
if err := run("bake "+r, pm.script("bake", r)...); err != nil {
return err
}
}

if noWait {
fmt.Fprintln(os.Stderr, "\nThe AMI bake(s) run in the background (~20-40 min) — the commands above say how to check on them.")
} else {
fmt.Fprintln(os.Stderr, "\nWaiting for the AMI bake(s) to finish (this can take 20-40 minutes)...")
if err := waitForBake(ctx, cfg, runners); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "AMI(s) available.")
}

return pruneSourceCaches(loc)
}

func cmdRemoteBake(args []string) error { return execCmd(remoteBakeCmd(), args) }
Loading