feat: add aws image bake commands
Some checks are pending
CI / Release Check (push) Waiting to run
CI / Go (push) Waiting to run
CI / Worker (push) Waiting to run
Pages / Deploy docs (push) Waiting to run

This commit is contained in:
Peter Steinberger 2026-05-01 20:57:53 +01:00
parent b28674d527
commit c4832416b7
No known key found for this signature in database
14 changed files with 497 additions and 3 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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
View 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)

View File

@ -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`

View File

@ -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

View File

@ -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{

View File

@ -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
View 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):
}
}
}

View File

@ -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}
}
`;

View File

@ -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);

View File

@ -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);
}

View File

@ -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;

View File

@ -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;