feat: add aws image bake commands
This commit is contained in:
parent
b28674d527
commit
c4832416b7
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0 - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added trusted-operator `crabbox image create` and `crabbox image promote` commands for baking AWS leases into AMIs and promoting a broker default image.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added responsive padding to the generated docs-site frontpage body content.
|
||||
|
||||
## 0.2.0 - 2026-05-01
|
||||
|
||||
Crabbox 0.2.0 hardens the brokered runner path after real AWS and Blacksmith Testbox use: browser login is safer, AWS SSH ingress is no longer world-open by default, SSH readiness waits for the Crabbox bootstrap marker, and fallback SSH ports are configurable instead of being hidden port-22 magic.
|
||||
|
||||
@ -164,6 +164,13 @@ crabbox admin release blue-lobster
|
||||
crabbox admin delete cbx_abcdef123456 --force
|
||||
```
|
||||
|
||||
Trusted operator image controls:
|
||||
|
||||
```sh
|
||||
crabbox image create --id cbx_abcdef123456 --name openclaw-crabbox-20260501-1246 --wait
|
||||
crabbox image promote ami-1234567890abcdef0
|
||||
```
|
||||
|
||||
## `run`
|
||||
|
||||
`crabbox run` is the main command.
|
||||
|
||||
@ -18,6 +18,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [status](status.md)
|
||||
- [list](list.md)
|
||||
- [usage](usage.md)
|
||||
- [image](image.md)
|
||||
- [admin](admin.md)
|
||||
- [ssh](ssh.md)
|
||||
- [inspect](inspect.md)
|
||||
|
||||
46
docs/commands/image.md
Normal file
46
docs/commands/image.md
Normal file
@ -0,0 +1,46 @@
|
||||
# image
|
||||
|
||||
`crabbox image` contains trusted operator controls for AWS runner images.
|
||||
|
||||
```sh
|
||||
crabbox image create --id cbx_... --name openclaw-crabbox-20260501-1246 --wait
|
||||
crabbox image promote ami-...
|
||||
```
|
||||
|
||||
Image commands require a configured coordinator and shared-token admin auth.
|
||||
They are intentionally not available to normal GitHub browser-login users.
|
||||
|
||||
## create
|
||||
|
||||
Create an AWS AMI from an active AWS lease.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <cbx_id> source lease; must be a canonical AWS lease ID
|
||||
--name <name> AMI name
|
||||
--wait poll until the AMI is available
|
||||
--wait-timeout <d> default 45m
|
||||
--no-reboot default true
|
||||
--json print JSON
|
||||
```
|
||||
|
||||
The source lease must still be active in the coordinator. The Worker calls AWS
|
||||
`CreateImage` from the backing instance ID and tags the image as Crabbox-owned.
|
||||
|
||||
## promote
|
||||
|
||||
Promote an available AMI as the coordinator's default AWS image:
|
||||
|
||||
```sh
|
||||
crabbox image promote ami-1234567890abcdef0
|
||||
```
|
||||
|
||||
Future brokered AWS leases use the promoted image when the request does not set
|
||||
an explicit `awsAMI` or `CRABBOX_AWS_AMI` override. Promotion stores coordinator
|
||||
metadata only; it does not copy or modify the AMI.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Infrastructure](../infrastructure.md)
|
||||
- [Runner bootstrap](../features/runner-bootstrap.md)
|
||||
@ -16,6 +16,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
- `crabbox init` generated repo files: `internal/cli/init.go`
|
||||
- Login/logout/whoami/config commands: `internal/cli/auth.go`, `internal/cli/config_cmd.go`
|
||||
- Doctor checks: `internal/cli/doctor.go`
|
||||
- AWS image bake/promote commands: `internal/cli/image.go`, `internal/cli/coordinator.go`
|
||||
|
||||
## Leases, Slugs, Claims, And Expiry
|
||||
|
||||
@ -34,6 +35,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
- Blacksmith Testbox CLI wrapper: `internal/cli/blacksmith.go`
|
||||
- Worker Hetzner provider: `worker/src/hetzner.ts`
|
||||
- Worker AWS EC2 Spot provider: `worker/src/aws.ts`
|
||||
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
|
||||
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
|
||||
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
|
||||
|
||||
|
||||
@ -62,6 +62,8 @@ func (a App) Run(ctx context.Context, args []string) error {
|
||||
return a.config(ctx, args[1:])
|
||||
case "init":
|
||||
return a.initProject(ctx, args[1:])
|
||||
case "image":
|
||||
return a.image(ctx, args[1:])
|
||||
case "pool":
|
||||
return a.pool(ctx, args[1:])
|
||||
case "machine":
|
||||
@ -127,6 +129,7 @@ Commands:
|
||||
cache Inspect, purge, or warm remote caches
|
||||
status Show lease state; add --wait to block until ready
|
||||
list List Crabbox machines
|
||||
image Create or promote brokered AWS runner images
|
||||
usage Show cost and usage estimates by user, org, or fleet
|
||||
admin Lease admin controls for trusted operators
|
||||
actions Register GitHub Actions runners or dispatch workflows
|
||||
|
||||
@ -72,6 +72,14 @@ type CoordinatorWhoami struct {
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
|
||||
type CoordinatorImage struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Region string `json:"region,omitempty"`
|
||||
PromotedAt string `json:"promotedAt,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorGitHubLoginStart struct {
|
||||
LoginID string `json:"loginID"`
|
||||
URL string `json:"url"`
|
||||
@ -405,6 +413,34 @@ func (c *CoordinatorClient) AdminDeleteLease(ctx context.Context, id string) (Co
|
||||
return res.Lease, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) CreateImage(ctx context.Context, leaseID, name string, noReboot bool) (CoordinatorImage, error) {
|
||||
var res struct {
|
||||
Image CoordinatorImage `json:"image"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/v1/images", map[string]any{
|
||||
"leaseID": leaseID,
|
||||
"name": name,
|
||||
"noReboot": noReboot,
|
||||
}, &res)
|
||||
return res.Image, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) Image(ctx context.Context, imageID string) (CoordinatorImage, error) {
|
||||
var res struct {
|
||||
Image CoordinatorImage `json:"image"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, "/v1/images/"+url.PathEscape(imageID), nil, &res)
|
||||
return res.Image, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) PromoteImage(ctx context.Context, imageID string) (CoordinatorImage, error) {
|
||||
var res struct {
|
||||
Image CoordinatorImage `json:"image"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/v1/images/"+url.PathEscape(imageID)+"/promote", map[string]any{}, &res)
|
||||
return res.Image, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) CreateRun(ctx context.Context, leaseID string, cfg Config, command []string) (CoordinatorRun, error) {
|
||||
var res CoordinatorRunResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/runs", map[string]any{
|
||||
|
||||
@ -149,6 +149,52 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorImageCreateAndPromote(t *testing.T) {
|
||||
var createBody struct {
|
||||
LeaseID string `json:"leaseID"`
|
||||
Name string `json:"name"`
|
||||
NoReboot bool `json:"noReboot"`
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/images":
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method=%s", r.Method)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"pending","region":"eu-west-1"}}`))
|
||||
case "/v1/images/ami-12345678":
|
||||
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"available","region":"eu-west-1"}}`))
|
||||
case "/v1/images/ami-12345678/promote":
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method=%s", r.Method)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"available","region":"eu-west-1","promotedAt":"2026-05-01T12:46:00Z"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
|
||||
created, err := client.CreateImage(context.Background(), "cbx_123", "openclaw-crabbox-test", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID != "ami-12345678" || createBody.LeaseID != "cbx_123" || createBody.Name != "openclaw-crabbox-test" || !createBody.NoReboot {
|
||||
t.Fatalf("created=%#v body=%#v", created, createBody)
|
||||
}
|
||||
if image, err := client.Image(context.Background(), "ami-12345678"); err != nil || image.State != "available" {
|
||||
t.Fatalf("image=%#v err=%v", image, err)
|
||||
}
|
||||
if promoted, err := client.PromoteImage(context.Background(), "ami-12345678"); err != nil || promoted.PromotedAt == "" {
|
||||
t.Fatalf("promoted=%#v err=%v", promoted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseStatusRequiresSSHReadiness(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/leases/cbx_123" {
|
||||
|
||||
128
internal/cli/image.go
Normal file
128
internal/cli/image.go
Normal file
@ -0,0 +1,128 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a App) image(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return exit(2, "usage: crabbox image <create|promote> [flags]")
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprintln(a.Stdout, `Usage:
|
||||
crabbox image create --id <cbx_id> --name <ami-name> [--wait]
|
||||
crabbox image promote <ami-id>`)
|
||||
return nil
|
||||
case "create":
|
||||
return a.imageCreate(ctx, args[1:])
|
||||
case "promote":
|
||||
return a.imagePromote(ctx, args[1:])
|
||||
default:
|
||||
return exit(2, "unknown image command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) imageCreate(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("image create", a.Stderr)
|
||||
id := fs.String("id", "", "AWS lease id to image")
|
||||
name := fs.String("name", "", "AMI name")
|
||||
wait := fs.Bool("wait", false, "wait until the AMI is available")
|
||||
waitTimeout := fs.Duration("wait-timeout", 45*time.Minute, "maximum wait duration")
|
||||
noReboot := fs.Bool("no-reboot", true, "avoid rebooting the source instance while creating the AMI")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *id == "" || *name == "" {
|
||||
return exit(2, "usage: crabbox image create --id <cbx_id> --name <ami-name> [--wait]")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coord, ok, err := newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return exit(2, "image create requires a coordinator")
|
||||
}
|
||||
image, err := coord.CreateImage(ctx, *id, *name, *noReboot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *wait {
|
||||
image, err = waitForImage(ctx, coord, image.ID, *waitTimeout, a.Stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if *jsonOut {
|
||||
return json.NewEncoder(a.Stdout).Encode(image)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "image=%s name=%s state=%s region=%s\n", image.ID, image.Name, image.State, blank(image.Region, "-"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) imagePromote(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("image promote", a.Stderr)
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return exit(2, "usage: crabbox image promote <ami-id>")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coord, ok, err := newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return exit(2, "image promote requires a coordinator")
|
||||
}
|
||||
image, err := coord.PromoteImage(ctx, fs.Arg(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *jsonOut {
|
||||
return json.NewEncoder(a.Stdout).Encode(image)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "promoted image=%s name=%s state=%s region=%s\n", image.ID, image.Name, image.State, blank(image.Region, "-"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForImage(ctx context.Context, coord *CoordinatorClient, imageID string, timeout time.Duration, stderr io.Writer) (CoordinatorImage, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var last CoordinatorImage
|
||||
for {
|
||||
image, err := coord.Image(ctx, imageID)
|
||||
if err != nil {
|
||||
return CoordinatorImage{}, err
|
||||
}
|
||||
last = image
|
||||
if image.State == "available" {
|
||||
return image, nil
|
||||
}
|
||||
if image.State == "failed" {
|
||||
return CoordinatorImage{}, exit(5, "image %s failed", imageID)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return CoordinatorImage{}, exit(5, "timed out waiting for image %s; last state=%s", imageID, last.State)
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "waiting image=%s state=%s\n", imageID, blank(image.State, "pending"))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return CoordinatorImage{}, ctx.Err()
|
||||
case <-time.After(15 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -406,7 +406,7 @@ main{min-width:0;padding:28px clamp(20px,4.5vw,60px) 72px;max-width:1180px;margi
|
||||
/* layout: doc + toc */
|
||||
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:36px;margin-top:30px}
|
||||
.doc-grid-home{margin-top:14px}
|
||||
.doc-home{background:transparent;box-shadow:none;border:0;padding:8px 0 0;max-width:74ch;margin-inline:auto;width:100%}
|
||||
.doc-home{background:transparent;box-shadow:none;border:0;padding:8px clamp(18px,3vw,30px) 0;max-width:74ch;margin-inline:auto;width:100%}
|
||||
.doc-home>:first-child{margin-top:0}
|
||||
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,74ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,1fr)}}
|
||||
.doc{min-width:0;max-width:74ch;background:rgba(255,251,244,.78);box-shadow:var(--shadow);border:1px solid var(--line-soft);border-radius:10px;padding:clamp(22px,3.6vw,44px);overflow-wrap:break-word}
|
||||
@ -483,13 +483,14 @@ main{min-width:0;padding:28px clamp(20px,4.5vw,60px) 72px;max-width:1180px;margi
|
||||
.hero-snippet{font-size:.78rem;padding:16px 16px}
|
||||
.features{grid-template-columns:1fr;margin-top:22px}
|
||||
.doc{padding:20px;border-radius:8px}
|
||||
.doc-home{padding:0}
|
||||
.doc-home{padding:0 18px}
|
||||
.doc-grid{margin-top:22px;gap:24px}
|
||||
.doc :is(h2,h3,h4) .anchor{display:none}
|
||||
}
|
||||
@media(max-width:520px){
|
||||
main{padding:60px 14px 48px}
|
||||
.doc{padding:18px 16px}
|
||||
.doc-home{padding-inline:16px}
|
||||
.doc pre{margin-left:-16px;margin-right:-16px;border-radius:0;border-left:0;border-right:0}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from "./config";
|
||||
import { leaseProviderLabels } from "./provider-labels";
|
||||
import { leaseProviderName } from "./slug";
|
||||
import type { Env, ProviderMachine } from "./types";
|
||||
import type { Env, ProviderImage, ProviderMachine } from "./types";
|
||||
|
||||
const awsUbuntuOwner = "099720109477";
|
||||
const ec2Version = "2016-11-15";
|
||||
@ -159,6 +159,44 @@ export class EC2SpotClient {
|
||||
await this.ec2("TerminateInstances", { "InstanceId.1": instanceID });
|
||||
}
|
||||
|
||||
async createImage(instanceID: string, name: string, noReboot: boolean): Promise<ProviderImage> {
|
||||
const params: Record<string, string> = {
|
||||
InstanceId: instanceID,
|
||||
Name: name,
|
||||
NoReboot: noReboot ? "true" : "false",
|
||||
"TagSpecification.1.ResourceType": "image",
|
||||
"TagSpecification.1.Tag.1.Key": "crabbox",
|
||||
"TagSpecification.1.Tag.1.Value": "true",
|
||||
"TagSpecification.1.Tag.2.Key": "created_by",
|
||||
"TagSpecification.1.Tag.2.Value": "crabbox",
|
||||
"TagSpecification.1.Tag.3.Key": "Name",
|
||||
"TagSpecification.1.Tag.3.Value": name,
|
||||
};
|
||||
const root = await this.ec2("CreateImage", params);
|
||||
const imageID = asString(root["imageId"]);
|
||||
if (!imageID) {
|
||||
throw new Error("aws returned no image id");
|
||||
}
|
||||
return { id: imageID, name, state: "pending", region: this.region };
|
||||
}
|
||||
|
||||
async getImage(imageID: string): Promise<ProviderImage> {
|
||||
const root = await this.ec2("DescribeImages", {
|
||||
"ImageId.1": imageID,
|
||||
});
|
||||
const image = record(items(record(root["imagesSet"])["item"])[0]);
|
||||
const id = asString(image["imageId"]);
|
||||
if (!id) {
|
||||
throw new Error(`aws image not found: ${imageID}`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: asString(image["name"]),
|
||||
state: asString(image["imageState"]),
|
||||
region: this.region,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSSHKey(name: string): Promise<void> {
|
||||
await this.ec2("DeleteKeyPair", { KeyName: name }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@ -10,7 +10,9 @@ import type {
|
||||
LeaseRecord,
|
||||
LeaseRequest,
|
||||
Provider,
|
||||
ProviderImage,
|
||||
ProviderMachine,
|
||||
PromotedImageRecord,
|
||||
RunCreateRequest,
|
||||
RunFinishRequest,
|
||||
RunRecord,
|
||||
@ -71,6 +73,18 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (parts[0] === "v1" && parts[1] === "runs" && parts[2]) {
|
||||
return await this.runRoute(request, parts[2], parts[3]);
|
||||
}
|
||||
if (method === "POST" && parts.join("/") === "v1/images") {
|
||||
if (!isAdminRequest(request)) {
|
||||
return json({ error: "forbidden", message: "admin token required" }, { status: 403 });
|
||||
}
|
||||
return await this.createImage(request);
|
||||
}
|
||||
if (parts[0] === "v1" && parts[1] === "images" && parts[2]) {
|
||||
if (!isAdminRequest(request)) {
|
||||
return json({ error: "forbidden", message: "admin token required" }, { status: 403 });
|
||||
}
|
||||
return await this.imageRoute(request, parts[2], parts[3]);
|
||||
}
|
||||
if (method === "GET" && parts.join("/") === "v1/leases") {
|
||||
return await this.listLeases(request);
|
||||
}
|
||||
@ -99,6 +113,9 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (config.provider === "aws" && config.awsSSHCIDRs.length === 0) {
|
||||
config.awsSSHCIDRs = requestSourceCIDRs(request);
|
||||
}
|
||||
if (config.provider === "aws" && !config.awsAMI) {
|
||||
config.awsAMI = (await this.promotedAWSImage())?.id ?? "";
|
||||
}
|
||||
const leaseID = validLeaseID(input.leaseID) ? input.leaseID : newLeaseID();
|
||||
const leases = await this.leaseRecords();
|
||||
const slug = allocateLeaseSlug(
|
||||
@ -456,6 +473,63 @@ export class FleetDurableObject implements DurableObject {
|
||||
return json({ usage, limits: costLimits(this.env) });
|
||||
}
|
||||
|
||||
private async createImage(request: Request): Promise<Response> {
|
||||
const input = await readJson<{
|
||||
leaseID?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
noReboot?: boolean;
|
||||
}>(request);
|
||||
const leaseID = input.leaseID ?? input.id ?? "";
|
||||
const name = input.name ?? "";
|
||||
if (!validLeaseID(leaseID)) {
|
||||
return json({ error: "invalid_lease_id" }, { status: 400 });
|
||||
}
|
||||
if (!validImageName(name)) {
|
||||
return json({ error: "invalid_image_name" }, { status: 400 });
|
||||
}
|
||||
const lease = await this.resolveLease(leaseID, request, true);
|
||||
if (!lease) {
|
||||
return notFound();
|
||||
}
|
||||
if (lease.provider !== "aws" || !lease.cloudID) {
|
||||
return json(
|
||||
{ error: "unsupported_provider", message: "only AWS leases can be imaged" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const image = await this.provider("aws", lease.region).createImage(
|
||||
lease.cloudID,
|
||||
name,
|
||||
input.noReboot ?? true,
|
||||
);
|
||||
return json({ image }, { status: 201 });
|
||||
}
|
||||
|
||||
private async imageRoute(request: Request, imageID: string, action?: string): Promise<Response> {
|
||||
const method = request.method.toUpperCase();
|
||||
if (!validImageID(imageID)) {
|
||||
return json({ error: "invalid_image_id" }, { status: 400 });
|
||||
}
|
||||
if (method === "GET" && action === undefined) {
|
||||
const image = await this.provider("aws").getImage(imageID);
|
||||
return json({ image });
|
||||
}
|
||||
if (method === "POST" && action === "promote") {
|
||||
const image = await this.provider("aws").getImage(imageID);
|
||||
if (image.state !== "available") {
|
||||
return json(
|
||||
{ error: "image_not_available", message: `image ${imageID} is ${image.state}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const promoted: PromotedImageRecord = { ...image, promotedAt: new Date().toISOString() };
|
||||
await this.state.storage.put(promotedAWSImageKey(), promoted);
|
||||
return json({ image: promoted });
|
||||
}
|
||||
return json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
private async expireLeases(): Promise<void> {
|
||||
const leases = await this.state.storage.list<LeaseRecord>({ prefix: "lease:" });
|
||||
const now = Date.now();
|
||||
@ -560,6 +634,10 @@ export class FleetDurableObject implements DurableObject {
|
||||
await this.state.storage.put(leaseKey(lease.id), lease);
|
||||
}
|
||||
|
||||
private async promotedAWSImage(): Promise<PromotedImageRecord | undefined> {
|
||||
return this.state.storage.get<PromotedImageRecord>(promotedAWSImageKey());
|
||||
}
|
||||
|
||||
private async getRun(runID: string): Promise<RunRecord | undefined> {
|
||||
return this.state.storage.get<RunRecord>(runKey(runID));
|
||||
}
|
||||
@ -606,6 +684,10 @@ function runLogKey(runID: string): string {
|
||||
return `runlog:${runID}`;
|
||||
}
|
||||
|
||||
function promotedAWSImageKey(): string {
|
||||
return "image:aws:promoted";
|
||||
}
|
||||
|
||||
function newLeaseID(): string {
|
||||
const bytes = new Uint8Array(6);
|
||||
crypto.getRandomValues(bytes);
|
||||
@ -622,6 +704,14 @@ function validLeaseID(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^cbx_[a-f0-9]{12}$/.test(value);
|
||||
}
|
||||
|
||||
function validImageID(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^ami-[a-f0-9]{8,32}$/.test(value);
|
||||
}
|
||||
|
||||
function validImageName(value: string): boolean {
|
||||
return /^[A-Za-z0-9()[\]./_ -]{3,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validCrabboxProviderKey(value: string | undefined): value is string {
|
||||
return typeof value === "string" && /^crabbox-cbx-[a-f0-9]{12}$/.test(value);
|
||||
}
|
||||
@ -807,6 +897,8 @@ interface CloudProvider {
|
||||
owner: string,
|
||||
): Promise<{ server: ProviderMachine; serverType: string }>;
|
||||
deleteServer(id: string): Promise<void>;
|
||||
createImage(instanceID: string, name: string, noReboot: boolean): Promise<ProviderImage>;
|
||||
getImage(imageID: string): Promise<ProviderImage>;
|
||||
deleteSSHKey(name: string): Promise<void>;
|
||||
hourlyPriceUSD(
|
||||
serverType: string,
|
||||
@ -845,6 +937,14 @@ class HetznerProvider implements CloudProvider {
|
||||
await this.client.deleteServer(Number(id));
|
||||
}
|
||||
|
||||
createImage(): Promise<ProviderImage> {
|
||||
throw new Error("hetzner images are not supported");
|
||||
}
|
||||
|
||||
getImage(): Promise<ProviderImage> {
|
||||
throw new Error("hetzner images are not supported");
|
||||
}
|
||||
|
||||
async deleteSSHKey(name: string): Promise<void> {
|
||||
await this.client.deleteSSHKey(name);
|
||||
}
|
||||
@ -887,6 +987,14 @@ class AWSProvider implements CloudProvider {
|
||||
await this.client.deleteServer(id);
|
||||
}
|
||||
|
||||
createImage(instanceID: string, name: string, noReboot: boolean): Promise<ProviderImage> {
|
||||
return this.client.createImage(instanceID, name, noReboot);
|
||||
}
|
||||
|
||||
getImage(imageID: string): Promise<ProviderImage> {
|
||||
return this.client.getImage(imageID);
|
||||
}
|
||||
|
||||
async deleteSSHKey(name: string): Promise<void> {
|
||||
await this.client.deleteSSHKey(name);
|
||||
}
|
||||
|
||||
@ -99,6 +99,17 @@ export interface LeaseRecord {
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProviderImage {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface PromotedImageRecord extends ProviderImage {
|
||||
promotedAt: string;
|
||||
}
|
||||
|
||||
export interface RunRecord {
|
||||
id: string;
|
||||
leaseID: string;
|
||||
|
||||
@ -292,6 +292,52 @@ describe("fleet lease identity and idle", () => {
|
||||
);
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("creates, waits, and promotes AWS images through admin routes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage, {
|
||||
aws: fakeProvider(),
|
||||
});
|
||||
storage.seed(
|
||||
"lease:cbx_000000000001",
|
||||
testLease({
|
||||
id: "cbx_000000000001",
|
||||
provider: "aws",
|
||||
cloudID: "i-123",
|
||||
region: "eu-west-1",
|
||||
}),
|
||||
);
|
||||
|
||||
const denied = await fleet.fetch(
|
||||
request("POST", "/v1/images", {
|
||||
body: { leaseID: "cbx_000000000001", name: "openclaw-crabbox-test" },
|
||||
}),
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
|
||||
const created = await fleet.fetch(
|
||||
request("POST", "/v1/images", {
|
||||
headers: { "x-crabbox-admin": "true" },
|
||||
body: { leaseID: "cbx_000000000001", name: "openclaw-crabbox-test" },
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const createdBody = (await created.json()) as { image: { id: string; state: string } };
|
||||
expect(createdBody.image).toEqual(
|
||||
expect.objectContaining({ id: "ami-000000000001", state: "pending" }),
|
||||
);
|
||||
|
||||
const promoted = await fleet.fetch(
|
||||
request("POST", "/v1/images/ami-000000000001/promote", {
|
||||
headers: { "x-crabbox-admin": "true" },
|
||||
body: {},
|
||||
}),
|
||||
);
|
||||
expect(promoted.status).toBe(200);
|
||||
expect(storage.value("image:aws:promoted")).toEqual(
|
||||
expect.objectContaining({ id: "ami-000000000001", state: "available" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fleet run history", () => {
|
||||
@ -739,6 +785,17 @@ function fakeProvider(onCreate?: (config: { awsSSHCIDRs: string[] }) => void) {
|
||||
};
|
||||
},
|
||||
async deleteServer() {},
|
||||
async createImage(_instanceID: string, name: string) {
|
||||
return { id: "ami-000000000001", name, state: "pending", region: "eu-west-1" };
|
||||
},
|
||||
async getImage(imageID: string) {
|
||||
return {
|
||||
id: imageID,
|
||||
name: "openclaw-crabbox-test",
|
||||
state: "available",
|
||||
region: "eu-west-1",
|
||||
};
|
||||
},
|
||||
async deleteSSHKey() {},
|
||||
async hourlyPriceUSD() {
|
||||
return 0.1;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user