Skip to content

feat: cloud instances (plain VMs) in the SDK and CLI - #123

Open
rusenask wants to merge 2 commits into
mainfrom
feat/cloud-instances
Open

feat: cloud instances (plain VMs) in the SDK and CLI#123
rusenask wants to merge 2 commits into
mainfrom
feat/cloud-instances

Conversation

@rusenask

Copy link
Copy Markdown
Collaborator

Adds SDK and CLI support for CloudInstancesService — the external API for plain VMs that Lightning provisions, injects your account's SSH keys into, and otherwise stays out of the way of.

SDK

from lightning_sdk import CloudInstance

vm = CloudInstance.create(name="my-vm", instance_type="cpu-4", ports=[8080], wait=True)
print(vm.ssh_command)
vm.ssh("uname -a")
vm.delete()
  • create / list / get (by ID or name) / delete, wait_until_running, refresh, to_dict
  • ssh() and ssh_args() build on the Lightning-managed SSH key, and refuse to run before the instance reports an endpoint
  • instance_types() lists what a cloud account can provision (cheapest first) and images() wraps ListInstanceImages
  • Organization resolution: explicit orgLIGHTNING_ORG → configured default organization → owner of the configured default teamspace → sole membership, otherwise a message naming the candidates
  • Cloud account defaults to the only MACHINE-driver account, since only those can host instances

CLI

lightning instance {create,list,get,delete,ssh,types,images} (plus a hidden instances alias), every command with --json. --port is repeatable, --cloud-init takes a file or - for stdin, ssh propagates the remote exit code and has --print. Shell completion covers organizations and instance names.

Vendored client

CloudInstancesServiceApi and the six V1* models are generated in the existing swagger-codegen style from external_cloud_instances.swagger.json and registered in the OpenAPI __init__s and GridRestClient. Labelled vendoring so the vendored-cloud guard passes; a real vendor run from Grid should reproduce these files.

Tested against production

Working end to end: create (name, instance type, ports, volume size, spot), list, get by ID and by name, types, delete via both CLI and SDK, and idempotent re-delete.

Two things could not be verified because the backend does not support them yet:

  • Provisioning never starts. Three instances across two organizations stayed at status=pending with updatedAt never set — two of them for 56 and 43 minutes — while the cluster advertises cpu-4 availability in ~45s. The best candidate in grid-backend is priority.getPriority(), which calls store.GetProject(server.Metadata.ProjectId); CreateInstance sets only {Id, Name, OrgId}, so AddServer errors and the server never lands on the queue Allocate() drains when LIGHTNING_ENGINE_ENABLE_PRIORITY_QUEUE is on. Sandboxes are unaffected because they carry a project id. Not confirmed against the prod flag value. Consequently SSH, port exposure, and running something inside a VM are covered only by unit tests and the client-side guards.
  • image, cloud_init and ListInstanceImages return 501. The create guards only fire on non-empty fields, which confirms the SDK and CLI transmit them correctly (tested from both a file and stdin). Surfaced as Not supported by Lightning yet: <server message>.

One API gap worth a follow-up: machine clusters map each requested VM port to an allocated host port (PortForwardRule{VmPort, HostPort}), but Instance only returns the SSH mapping, so a user exposing 8080 has no way to learn its public address. ports in the response is just the requested VM ports.

Checks

2298 tests pass (98 new), mypy clean, ruff clean, and both sphinx builds pass with -W.

Also scopes the stock Flask instance/ gitignore rule to the repo root — it was silently ignoring lightning_sdk/cli/instance/ and tests/cli/instance/.

🤖 Generated with Claude Code

Adds support for CloudInstancesService, the external API for plain VMs that
Lightning provisions and hands over via SSH.

SDK: CloudInstance with create/list/get/delete, wait_until_running, ssh and
ssh_args, plus instance_types and images lookups. Organizations are resolved
from --org, LIGHTNING_ORG, the configured default organization, the owner of
the configured default teamspace, and finally a sole membership.

CLI: lightning instance {create,list,get,delete,ssh,types,images}, all with
--json, shell completion for organizations and instance names, repeatable
--port, and --cloud-init reading a file or stdin.

The generated client for the service is vendored alongside the existing
lightning_cloud OpenAPI code.

Also scopes the stock Flask "instance/" gitignore rule to the repo root; it
was silently ignoring lightning_sdk/cli/instance/ and tests/cli/instance/.
@rusenask rusenask added the vendoring Intentionally updates vendored files and bypasses the vendored Cloud guard label Aug 17, 2026
Verified the lifecycle end to end against a dev stack, where the VM only
trusts the keys registered with that deployment. The SDK could already take
a key_path on ssh_args, but ssh() and the CLI always used the
Lightning-managed key, so there was no way to reach such an instance.

Adds key_path to CloudInstance.ssh and --identity/-i to lightning instance
ssh.
@rusenask

Copy link
Copy Markdown
Collaborator Author

Lifecycle now verified end to end against a dev stack

Ran the whole flow on a local grid-backend docker-compose stack with a real machine cluster (baremetal, host 52.87.224.237). Everything in this PR works:

step result
instance create (with and without --wait) --wait returned a connectable VM in ~70s
instance list / get (by name and ID)
instance types
instance ssh (CLI and SDK) Ubuntu 24.04, 2 CPU as requested
run something inside ✅ started python3 -m http.server on the exposed port
exposed port reachable from the internet ✅ served over the host's forwarded port
instance delete (CLI and SDK) ✅ both VMs destroyed on the host
--cloud-init / --image 501, as expected on a build without 4/9

One gap the test surfaced, fixed in 7be8a8c: ssh_args accepted a key_path, but CloudInstance.ssh() and the CLI always used the Lightning-managed key, so an instance that trusts a different key was unreachable. Added key_path to ssh() and --identity/-i to lightning instance ssh.

Why prod instances hang at pending

Two backend blockers, both outside this PR:

  1. Host mode switch fails on CPU-only hosts. ConfigureMachineVMMode hard-sets UseHugepages = true on a runtime switch (machine_mode_vm.go:50), overriding the host's USE_HUGEPAGES=false. On a GPU-less host with no reservation, handleHugePages returns on cpu node, we expect some hugepages → agent 500 → the host drops out of the candidate pool → no suitable nodes found → retry forever. Reserving hugepages on the host cleared it.
  2. Instances are created with no VM image. In machine_instance.go, ServerResourceTypeCloudSpaceInstance falls back to MachineImageCloudspaceNameCPU/GPU, but ServerResourceTypeInstance uses req.MachineImage with no fallback — and nothing populates it for on-demand VMs, since image is still rejected at the API. The agent then builds the blob key cloudspace-cpu/v5/image.qcow2 (missing the image-name segment) and 404s. Setting machine_image on the server row made the VM boot immediately, so this is the only thing standing between the current API and a working create. It also panics the agent (nil deref) while cleaning up the failed VM.

Two API bugs worth fixing alongside

  • The failure reason is recorded but never exposed. The server row carries error = 'cloud provider can''t create the server ("cpu-2"): no suitable nodes found', but instanceStatusReason is only consulted when status == "failed"; a retrying create stays pending, so statusReason is always empty. That is precisely why prod looks like an eternal pending with no explanation.
  • Instance.updatedAt is always null. convertServerToInstance reads server.Metadata.UpdatedAt, but the pg store only populates Status.UpdatedAt (Metadata gets CreationTimestamp only). Confirmed live: the row's updated_at is set in Postgres while the API returns null.

Separately, the host-side port mapping still isn't exposed: a VM requesting port 8080 was reachable on the host's port 20000, and nothing in the Instance response says so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vendoring Intentionally updates vendored files and bypasses the vendored Cloud guard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant