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
8 changes: 7 additions & 1 deletion cmd/spinloop/fleet.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,13 @@ func deployOneNode(cfg *fleet.Config, name string, opts deployOpts) fleetDeployR
if err != nil {
return fleetDeployResult{node: name, outcome: deployRowFailed, detail: err.Error()}
}
outcome, err := runDeploy(spinloopPath, env, dc, opts)
// The node's own instance type is per-node. opts is shared across the
// concurrent deploy, so one node's machine must not leak to another —
// copy it for this call and set the node's type (a node naming none
// leaves it empty, i.e. the control plane's default).
nodeOpts := opts
nodeOpts.instanceType = entry.InstanceType
outcome, err := runDeploy(spinloopPath, env, dc, nodeOpts)
if err != nil {
var guarded *errDeployGuarded
if errors.As(err, &guarded) {
Expand Down
51 changes: 51 additions & 0 deletions cmd/spinloop/fleet_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,57 @@ func TestCmdFleetDeployDryRunTouchesNothing(t *testing.T) {
}
}

// A node's instance-type is per-node: `fleet deploy` threads each targeted
// node's own value into its deploy, so one node's machine never leaks to a
// sibling's plan — a node naming a type shows it, one naming none shows the
// control plane's default.
func TestCmdFleetDeployInstanceTypePerNode(t *testing.T) {
isolateConfig(t)
dir := writeFleetFile(t, `
nodes:
- name: typed
kind: remote
file: ./typed.Spinloop
instance-type: g6e.2xlarge
- name: untyped
kind: remote
file: ./untyped.Spinloop
`)
writeSpinloop := func(name, env string) {
t.Helper()
body := fmt.Sprintf("PROVIDER llamacpp\nMODEL org/m:Q4\nCONTEXT 8192\nREMOTE %s\n", env)
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
writeSpinloop("typed.Spinloop", "typed")
writeSpinloop("untyped.Spinloop", "untyped")

deployDiscoverFn = func(context.Context, aws.Config, string) (remote.ControlPlane, error) {
return remote.ControlPlane{}, fmt.Errorf("must not be called")
}
t.Cleanup(func() { deployDiscoverFn = remote.DiscoverControlPlane })

// --dry-run touches nothing, so no deploy seams need stubbing.
outTyped := captureStdout(t, func() {
if err := cmdFleet([]string{"deploy", "typed", "--dry-run"}); err != nil {
t.Errorf("deploy typed --dry-run: %v", err)
}
})
if !strings.Contains(outTyped, "instance: g6e.2xlarge") {
t.Errorf("typed node's plan should name its instance type, got:\n%s", outTyped)
}

outUntyped := captureStdout(t, func() {
if err := cmdFleet([]string{"deploy", "untyped", "--dry-run"}); err != nil {
t.Errorf("deploy untyped --dry-run: %v", err)
}
})
if !strings.Contains(outUntyped, "instance: the control plane's default") {
t.Errorf("untyped node's plan should state the default machine, got:\n%s", outUntyped)
}
}

// mustEnvConfigPath resolves where a deployed environment would be
// registered, under the isolated config dir this test's HOME points at.
func mustEnvConfigPath(t *testing.T, env string) string {
Expand Down
36 changes: 33 additions & 3 deletions cmd/spinloop/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,7 @@ func remoteDeployCmd() *cobra.Command {
allowedCidr string
region string
spinloopVersion string
instanceType string
apiKeyEnv string
)
c := &cobra.Command{
Expand All @@ -1443,14 +1444,17 @@ func remoteDeployCmd() *cobra.Command {
address, API key and allowed CIDR — and says what it serves (PROVIDER picks
the engine, just as it does for serve). --spinloop-version pins the spinloop
release a fresh boot of the environment installs; without it, a boot
installs the latest published release.`,
installs the latest published release. --instance-type names the EC2 instance
type the environment's instances launch as; without it, they launch as the
control plane's default. The type is recorded on the environment and applies
from its next fresh launch.`,
Args: cobra.ArbitraryArgs,
SilenceErrors: true,
SilenceUsage: true,
ValidArgsFunction: aliasSlot,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return runRemoteDeploy(args, dryRun, overwrite, reseed, allowedCidr, region, spinloopVersion, apiKeyEnv)
return runRemoteDeploy(args, dryRun, overwrite, reseed, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv)
},
}
fs := c.Flags()
Expand All @@ -1460,12 +1464,13 @@ installs the latest published release.`,
fs.StringVar(&allowedCidr, "allowed-cidr", "", "who may reach this environment's instance (default: your public IP as a /32, on first deploy)")
fs.StringVar(&region, "region", "", "AWS region of the control plane (default: AWS_REGION or us-east-1)")
fs.StringVar(&spinloopVersion, "spinloop-version", "", "spinloop release the environment's instances install at boot (default: latest)")
fs.StringVar(&instanceType, "instance-type", "", "EC2 instance type the environment's instances launch as (e.g. g6e.xlarge; default: the control plane's default type)")
fs.StringVar(&apiKeyEnv, "api-key-env", "", "the environment variable holding the API key to store for this environment (a variable name, never the key itself)")
return c
}

// runRemoteDeploy is the body of `spinloop remote deploy`.
func runRemoteDeploy(args []string, dryRun, overwrite, reseed bool, allowedCidr, region, spinloopVersion, apiKeyEnv string) error {
func runRemoteDeploy(args []string, dryRun, overwrite, reseed bool, allowedCidr, region, spinloopVersion, instanceType, apiKeyEnv string) error {
_, spinloopPath, dc, env, err := deriveDeployTarget("spinloop remote deploy <file>", spinloopArg(args))
if err != nil {
return err
Expand All @@ -1477,6 +1482,7 @@ func runRemoteDeploy(args []string, dryRun, overwrite, reseed bool, allowedCidr,
allowedCidr: allowedCidr,
region: region,
spinloopVersion: spinloopVersion,
instanceType: instanceType,
apiKeyEnv: apiKeyEnv,
})
if err != nil {
Expand Down Expand Up @@ -1537,6 +1543,11 @@ type deployOpts struct {
allowedCidr string
region string
spinloopVersion string
// instanceType names the EC2 instance type the environment's instances
// launch as. Empty means launch as the control plane's default. It is
// recorded on the environment at deploy time and read back on the next
// fresh launch.
instanceType string
// apiKeyEnv names the environment variable holding an externally
// supplied key to store as the environment's engine key, never a
// literal on the command line. Empty means the control plane manages
Expand Down Expand Up @@ -1592,6 +1603,18 @@ func runDeploy(spinloopPath, env string, dc remote.DeployConfig, opts deployOpts
}
dc.SpinloopVersion = pin
}
// The EC2 instance type the environment's instances launch as: empty (or
// whitespace) means the control plane's own default, a name means exactly
// that machine. Checked here, so a typo is named now rather than as a
// RunInstances failure inside a deploy nobody is watching.
if name := strings.TrimSpace(opts.instanceType); name != "" {
if !remote.IsInstanceType(name) {
return deployOutcome{}, fmt.Errorf(
"--instance-type must be an EC2 instance type (a family and size separated by a dot, e.g. g6e.xlarge), got %q",
opts.instanceType)
}
dc.InstanceType = name
}
// A supplied key arrives the way every other secret does: as a reference
// to an environment variable, never a literal on the command line. By
// this point deriveDeployTarget has already applied the Spinloop's local
Expand Down Expand Up @@ -1642,6 +1665,13 @@ func runDeploy(spinloopPath, env string, dc remote.DeployConfig, opts deployOpts
spinloopVer = "latest"
}
fmt.Fprintf(&buf, " spinloop: %s\n", spinloopVer)
// A machine is worth stating too: a name is a promise, the default a
// statement — so the plan always says what the environment launches as.
instanceType := dc.InstanceType
if instanceType == "" {
instanceType = "the control plane's default"
}
fmt.Fprintf(&buf, " instance: %s\n", instanceType)
// A key is worth stating too — it rotates — but the value is never
// printed, in the dry run or the report.
if opts.apiKeyEnv != "" {
Expand Down
85 changes: 85 additions & 0 deletions cmd/spinloop/remote_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,91 @@ func TestRemoteDeploy_SpinloopVersion(t *testing.T) {
})
}

// An instance type is recorded on the environment: it reaches the signed body
// so the deploy Lambda persists it, and the plan names the machine the
// environment will launch as — a promise when named, the default otherwise.
func TestRemoteDeploy_InstanceType(t *testing.T) {
t.Run("the type reaches the deploy body and the plan", func(t *testing.T) {
isolateConfig(t)
stubAWSEnv(t)

var got remote.DeployConfig
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRaw, _ = io.ReadAll(r.Body)
_ = json.Unmarshal(gotRaw, &got)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"deployed":true,"environment":"testenv","base_url":"http://198.51.100.9:8000/v1"}`))
}))
defer server.Close()
stubDeploySeams(t, server.URL, "undeployed")
writeDeployEnvSpinloop(t, "testenv")

out := captureStdout(t, func() {
if err := cmdRemoteDeploy([]string{"--instance-type", "g6e.2xlarge"}); err != nil {
t.Errorf("cmdRemoteDeploy: %v", err)
}
})

if got.InstanceType != "g6e.2xlarge" {
t.Errorf("posted instanceType = %q, want g6e.2xlarge", got.InstanceType)
}
if !strings.Contains(string(gotRaw), `"instanceType":"g6e.2xlarge"`) {
t.Errorf("instanceType did not reach the body: %s", gotRaw)
}
if !strings.Contains(out, "instance: g6e.2xlarge") {
t.Errorf("plan should name the type, got:\n%s", out)
}
})

t.Run("an untyped deploy omits the field entirely", func(t *testing.T) {
isolateConfig(t)
stubAWSEnv(t)

var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRaw, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"deployed":true,"environment":"testenv","base_url":"http://198.51.100.9:8000/v1"}`))
}))
defer server.Close()
stubDeploySeams(t, server.URL, "undeployed")
writeDeployEnvSpinloop(t, "testenv")

if err := cmdRemoteDeploy(nil); err != nil {
t.Errorf("cmdRemoteDeploy: %v", err)
}
// Absent, not null and not empty: an untyped deploy sends exactly what
// a control plane predating the field expects.
if strings.Contains(string(gotRaw), "instanceType") {
t.Errorf("instanceType should be omitted when untyped: %s", gotRaw)
}
})

t.Run("a dry run names the default machine when untyped", func(t *testing.T) {
isolateConfig(t)
writeDeployEnvSpinloop(t, "testenv")
out := captureStdout(t, func() {
if err := cmdRemoteDeploy([]string{"--dry-run"}); err != nil {
t.Errorf("cmdRemoteDeploy --dry-run: %v", err)
}
})
if !strings.Contains(out, "instance: the control plane's default") {
t.Errorf("--dry-run should state the default machine, got:\n%s", out)
}
})

t.Run("a value outside the shape is refused before anything is sent", func(t *testing.T) {
isolateConfig(t)
stubDeploySeams(t, "https://unused", "undeployed")
writeDeployEnvSpinloop(t, "testenv")
err := cmdRemoteDeploy([]string{"--instance-type", "g6exlarge"})
if err == nil || !strings.Contains(err.Error(), "--instance-type") {
t.Errorf("want a --instance-type validation error, got %v", err)
}
})
}

// A supplied key is resolved from the environment the Spinloop's local
// environment populated, reaches the signed body as a request-scoped field,
// and the report says what happened to it — the action, never the value.
Expand Down
21 changes: 21 additions & 0 deletions docs/commands/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ blanking the fleet. See
[`examples/fleet-remote`](../../examples/fleet-remote/README.md) and
[`examples/fleet-mixed`](../../examples/fleet-mixed/README.md).

A `kind: remote` node may also name the EC2 instance type its environment
launches as, with `instance-type` (a family and size separated by a dot, e.g.
`g6e.xlarge`):

```yaml
nodes:
- name: qwen
kind: remote
instance-type: g6e.2xlarge
```

It is a property of the cloud environment, not of the fleet's view of it:
`fleet deploy` records it on the environment, and the environment's next
**fresh** launch uses it. A re-wake of a stopped instance keeps the type it
was launched with — EC2 cannot resize a running or stopped box — so a changed
value takes effect only after the instance is terminated (an idle sweep or
`spinloop remote stop`) and launched again. Omitted, the environment launches
as its control plane's default type. Naming `instance-type` on a `kind: daemon`
node is a configuration error: a daemon's hardware is the operator's to choose,
not the fleet file's.

### A node's Spinloop source

Both `fleet deploy` (for a `kind: remote` node's environment) and `fleet
Expand Down
9 changes: 9 additions & 0 deletions docs/commands/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,14 @@ pin (`1.26.1`; a leading `v` is fine) it installs exactly that. The pin is
environment state, not engine state: it takes effect at the next boot, so a
running instance keeps the daemon it was deployed with.

`--instance-type` names the EC2 instance type the environment's instances
launch as (a family and size separated by a dot, e.g. `g6e.2xlarge`). Like the
pin, it is recorded on the environment at deploy time and applies from its next
**fresh** launch: a re-wake of a stopped instance keeps the type it launched
with, so a changed value takes effect only once the instance is terminated and
launched again. Without it the environment launches as the control plane's
default type.

Deploying doesn't start anything. If the shared bucket doesn't have those
weights yet it fetches them (about 15–20 minutes, entirely on its side) and
says so; wait for that before your first `start`, or the model won't be there.
Expand Down Expand Up @@ -421,6 +429,7 @@ included) for every `kind: remote` node a fleet file names — or a chosen few
| `-n`, `--dry-run` | `deploy` only: print what would be sent, without sending it |
| `--reseed` | `deploy` only: re-fetch the weights even if they are already in S3 |
| `--spinloop-version` | `deploy` only: the spinloop release fresh boots install (default: the latest published release) |
| `--instance-type` | `deploy` only: the EC2 instance type the environment's instances launch as (e.g. `g6e.xlarge`); recorded on the environment, applied on its next fresh launch (default: the control plane's default type) |
| `--api-key-env` | `deploy` only: name the environment variable holding the engine key to create or rotate; with no flag the stored key is kept |

`bootstrap` and `bake` have their own too (`--ref`, `--dir`, `--region`,
Expand Down
9 changes: 9 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,15 @@ components:
of a release tag is not part of the version (1.26.1, not
v1.26.1). The environment's control plane reads it when it
renders the boot script; the daemon does not act on it.
instanceType:
type: string
description: |
The EC2 instance type the environment's instances launch as — a
family and size separated by a dot (g6e.xlarge). Empty or absent
means launch as the control plane's default type. A property of
the deployment: the environment's control plane reads it when it
launches a fresh instance; a re-wake of a stopped instance keeps
the type it launched with. The daemon does not act on it.

Message:
type: object
Expand Down
1 change: 1 addition & 0 deletions examples/fleet-remote/fleet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
nodes:
- name: qwen
kind: remote
instance-type: g6e.2xlarge # EC2 type this environment launches as (default: the control plane's)

- name: llama
kind: remote
17 changes: 17 additions & 0 deletions internal/fleet/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ type NodeConfig struct {
// beside the fleet file, before either command gives up on it. Not
// read by any other fleet command.
File string `yaml:"file"`
// InstanceType names the EC2 instance type a kind: remote node's
// environment launches as, read by `spinloop fleet deploy` into the
// deploy config it derives. It is a property of the remote environment
// only — a kind: daemon node's hardware is the operator's to choose, so
// naming one there is a configuration error. Empty means the node's
// environment launches as the control plane's default type.
InstanceType string `yaml:"instance-type"`
}

// EngineOverride is a node's declared engine endpoint. Each field is optional
Expand Down Expand Up @@ -213,6 +220,11 @@ func (c *Config) validate() error {
if n.Host == "" {
return fmt.Errorf("node %q has no host", n.Name)
}
if n.InstanceType != "" {
return fmt.Errorf(
"node %q is kind %q: instance-type names the cloud environment's machine, and a daemon's hardware is the operator's to choose, not the fleet file's",
n.Name, KindDaemon)
}
case KindRemote:
// The node's name *is* the registered environment's key, so it must
// be env-shaped; a path-like name would be read as a registry
Expand All @@ -222,6 +234,11 @@ func (c *Config) validate() error {
"node %q is kind %q: its name must be a registered environment name (no /, no .json)",
n.Name, KindRemote)
}
if n.InstanceType != "" && !remote.IsInstanceType(n.InstanceType) {
return fmt.Errorf(
"node %q has instance-type %q, which is not shaped like an EC2 instance type (a family and size separated by a dot, e.g. g6e.xlarge)",
n.Name, n.InstanceType)
}
default:
return fmt.Errorf(
"node %q has kind %q: supported kinds are %q and %q",
Expand Down
Loading