Skip to content
Draft
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
123 changes: 97 additions & 26 deletions PLAN-cli-lambda.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
- Documentation authority: this file is authoritative for phase 1. Any older standalone CLI migration spec is advisory only until it is reconciled back into this document.
- Dependencies: `requests` may be added to runtime dependencies; `responses` may be added to test dependencies.
- End-of-phase validation: real local CLI invocation against deployed AWS, not just local mocks or container smoke tests.
- Streaming handlers return lazy event iterables so progress is sent while long-running actions execute and iteration failures become terminal `error` events.
- Remote requests use separate connect and read timeouts. The connect timeout is 10 seconds, existing commands retain a 30-second read timeout, and long-running actions may opt into a longer read timeout.
- Phase 3 `launch` uses a 930-second read timeout against a Lambda configured for the 900-second maximum. The workflow polls instance state every 15 seconds for at most 40 attempts and emits progress between polls.
- The CLI Lambda is pinned to Terraform's `DEVBOX_PARAM_PREFIX`. `launch` rejects a request whose envelope prefix does not match that configured value and never uses a caller-selected prefix for SSM or DynamoDB access.
- EC2 userdata is read by the local CLI, sent inline, and limited to 16,384 UTF-8 bytes on both sides of the wire.
- `new` is deferred but remains in scope, even though the current CLI references a missing implementation.

## Public Contract
Expand Down Expand Up @@ -226,7 +231,7 @@ Milestone: `devbox terminate <instance-id-or-project>` runs from the local CLI t
- [x] transport failure behavior
- [x] Run `pixi run -e dev python -m pytest` for touched tests.
- [x] Run `tofu fmt` and `tofu validate`.
- [ ] Record the local end-to-end termination validation steps and outcome in this file.
- [x] Record the local end-to-end termination validation steps and outcome in this file.

### Phase 2 `terminate` Contract

Expand Down Expand Up @@ -268,38 +273,104 @@ Milestone: `devbox terminate <instance-id-or-project>` runs from the local CLI t
- `2026-05-29`: Terraform validation passed.
- Command: `tofu validate`
- Result: passed
- End-to-end commands (must be run by DWHS in devbox-deploy/ repo, not for agents)
- `tofu plan -out tfplan && tofu apply tfplan` to deploy the updated CLI Lambda and IAM policy
- `devbox terminate i-0123456789abcdef0` against the deployed CLI Lambda, expecting the existing success message
- `devbox terminate my-project` against the deployed CLI Lambda, expecting project-name resolution to match current behavior
- Observed outcome: automated validation passed locally; deployed-AWS end-to-end termination validation is still pending operator run.
- `2026-07-16`: deployed-AWS end-to-end validation was completed by DWHS.
- Command: `devbox terminate <instance-id-or-project>` against the deployed CLI Lambda.
- Result: the termination command successfully ran through the Lambda and terminated the target instance.

### Phase 2 Handoff Notes

- Automated coverage verifies both supported identifier forms (instance ID and project name), along with not-found, ambiguity, terminal-error, and transport-failure behavior. The deployed-AWS run supplies the required real CLI/Lambda-path validation; another live termination is not required to close this phase.
- Remaining phase 2 blockers: none. Phase 2 is complete.
- Next session starts here: begin phase 3 `launch`, starting with the wire contract and HTTP timeout-policy decision.

## Phase 3: `launch`

Milestone: `devbox launch ...` runs from the local CLI through the deployed CLI Lambda, including userdata handling and current DNS flags.

- [ ] Extend the wire contract for `launch`, including all current CLI options and the inline userdata payload shape.
- [ ] Refactor launch logic so Lambda can emit structured progress events instead of relying on raw `print` output.
- [ ] Revisit the CLI HTTP timeout policy before `launch` goes remote. Phase 1 wraps `requests` transport failures cleanly, but still uses a single `timeout=30`; decide whether streamed commands need separate connect/read timeouts or a longer read timeout.
- [ ] Preserve shared business logic; do not fork a second launch implementation just for the Lambda path.
- [ ] Keep local preprocessing in the CLI for:
- [ ] reading `--userdata-file`
- [ ] embedding file contents into the request payload
- [ ] rejecting oversized request bodies before transmission if needed
- [ ] Add the remote `launch` action to the Lambda router.
- [ ] Expand CLI Lambda IAM only for launch-related operations.
- [ ] Migrate only `launch` in the CLI to the remote path.
- [ ] Preserve current flags for DNS behavior and ensure the request contract carries the same semantics.
- [ ] Add tests for:
- [ ] payload construction for all launch options
- [ ] userdata inlining
- [ ] progress-event rendering
- [ ] launch success and failure mapping
- [ ] DNS option propagation
- [ ] Run `pixi run -e dev python -m pytest` for touched tests.
- [ ] Run `tofu fmt` and `tofu validate`.
- [x] Extend the wire contract for `launch`, including all current CLI options and the inline userdata payload shape.
- [x] Refactor launch logic so Lambda can emit structured progress events instead of relying on raw `print` output.
- [x] Revisit the CLI HTTP timeout policy before `launch` goes remote. Phase 1 wraps `requests` transport failures cleanly, but still uses a single `timeout=30`; decide whether streamed commands need separate connect/read timeouts or a longer read timeout.
- [x] Preserve shared business logic; do not fork a second launch implementation just for the Lambda path.
- [x] Keep local preprocessing in the CLI for:
- [x] reading `--userdata-file`
- [x] embedding file contents into the request payload
- [x] rejecting oversized request bodies before transmission if needed
- [x] Add the remote `launch` action to the Lambda router.
- [x] Expand CLI Lambda IAM only for launch-related operations.
- [x] Migrate only `launch` in the CLI to the remote path.
- [x] Preserve current flags for DNS behavior and ensure the request contract carries the same semantics.
- [x] Add tests for:
- [x] payload construction for all launch options
- [x] userdata inlining
- [x] progress-event rendering
- [x] launch success and failure mapping
- [x] DNS option propagation
- [x] Run `pixi run -e dev python -m pytest` for touched tests.
- [x] Run `tofu fmt` and `tofu validate`.
- [ ] Record the local end-to-end launch validation steps and outcome in this file.

### Phase 3 `launch` Contract

- Request payload:

```json
{
"project": "my-project",
"instance_type": "t3.medium",
"key_pair": "my-key",
"volume_size": 100,
"base_ami": "ami-0123456789abcdef0",
"userdata": "#!/bin/bash\necho ready",
"assign_dns": true,
"dns_subdomain": "my-project"
}
```

- Optional string fields are `null` when omitted. `volume_size` defaults to `0`, and `assign_dns` defaults to `true`.
- `userdata` contains file contents, never a local path, and may be `null`; non-null content is limited to 16,384 UTF-8 bytes.
- Success path event sequence:
- zero or more `progress` and `warning` events
- exactly one `result` event
- exactly one terminal `success` event
- The result contains `project`, `instance_id`, `state`, `instance_type`, `image_id`, `availability_zone`, `private_ip`, `public_ip`, `ssh_username`, and `dns_name`. Fields unavailable from EC2 or optional configuration are `null`.

### Phase 3 Validation Log

- `2026-07-17`: full Python test suite passed.
- Command: `pixi run -e dev python -m pytest -q`
- Result: `471 passed, 1 warning`
- `2026-07-17`: Ruff passed for all touched Python files.
- `2026-07-17`: Terraform formatting check passed.
- Command: `tofu fmt -check -recursive`
- `2026-07-17`: Terraform validation passed.
- Command: `tofu validate`
- `2026-07-28`: launch follow-up restored structured volume-adjustment
progress and made best-effort launch-template metadata failures visible as
structured warnings, with an application-log fallback outside the workflow.
The request and event wire formats are unchanged.
- `2026-07-28`: focused launch tests passed.
- Command: `pixi run -e dev python -m pytest tests/test_launch.py tests/test_launch_workflow.py tests/commands/test_launch_command.py -q`
- Result: `93 passed, 1 warning`
- `2026-07-28`: Ruff passed for the follow-up files.
- Command: `pixi run -e dev ruff check src/devbox/launch.py tests/test_launch.py tests/test_launch_workflow.py`
- `2026-07-28`: full Python test suite passed after the follow-up.
- Command: `pixi run -e dev python -m pytest -q`
- Result: `479 passed, 1 warning`
- Deployed-AWS validation to be run by DWHS from the deployment environment:
- Deploy the reviewed Terraform plan containing the CLI Lambda image, timeout, environment, and IAM changes.
- Run `devbox launch <project> --instance-type <type> --key-pair <key> --userdata-file <file>` and include `--dns-subdomain <label>` when DNS is configured.
- Confirm volume-adjustment and instance-state progress events arrive before
completion, `devbox status <project>` shows the instance, userdata ran, and
the optional DNS name resolves.
- Clean up with `devbox terminate <project>`.

### Phase 3 Handoff Notes

- Automated implementation and validation, including the launch progress/error
visibility follow-up, are complete. The remaining phase 3 item is an
operator-run deployed-AWS acceptance rerun using the flow above.
- Do not mark phase 3 complete or begin phase 4 until that outcome is recorded here.

## Phase 4: `new`

Milestone: `devbox new ...` works end-to-end through the deployed CLI Lambda and is no longer dependent on a missing local implementation.
Expand Down
7 changes: 5 additions & 2 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ module "snapshot_lambda" {
module "cli_lambda" {
source = "./modules/cli-lambda"

prefix = var.prefix
param_prefix = "/${var.prefix}"
prefix = var.prefix
param_prefix = "/${var.prefix}"
snapshot_table_arn = module.snapshot_lambda.dynamodb_table_arn
ec2_instance_role_arn = module.devbox.ec2_role_arn
dns_provider = var.dns_provider
}

module "dns_cleanup_lambda" {
Expand Down
51 changes: 48 additions & 3 deletions modules/cli-lambda/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ locals {
for relpath in sort(local.lambda_source_files) : filesha256("${local.repo_root}/${relpath}")
]))
# Keep one statement per CLI action with the full permission set that action needs.
command_policy_statements = [
command_policy_statements = concat([
{
sid = "StatusCmdPermissions"
actions = [
Expand All @@ -31,8 +31,52 @@ locals {
"ec2:TerminateInstances"
]
resources = ["*"]
},
{
sid = "LaunchCmdEc2Permissions"
actions = [
"ec2:CreateTags",
"ec2:DescribeImages",
"ec2:DescribeInstances",
"ec2:DescribeLaunchTemplates",
"ec2:DescribeLaunchTemplateVersions",
"ec2:DescribeSubnets",
"ec2:RunInstances"
]
resources = ["*"]
},
{
sid = "LaunchCmdSsmPermissions"
actions = ["ssm:GetParameter"]
resources = ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${trim(var.param_prefix, "/")}/*"]
},
{
sid = "LaunchCmdDynamoDbPermissions"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
]
resources = [var.snapshot_table_arn]
},
{
sid = "LaunchCmdPassRolePermissions"
actions = ["iam:PassRole"]
resources = [var.ec2_instance_role_arn]
}
]
],
var.dns_provider == "route53" ? [
{
sid = "LaunchCmdRoute53Permissions"
actions = [
"route53:ChangeResourceRecordSets",
"route53:ListHostedZonesByName",
"route53:ListResourceRecordSets"
]
resources = ["*"]
}
] : []
)
}

resource "aws_ecr_repository" "cli" {
Expand Down Expand Up @@ -126,13 +170,14 @@ resource "aws_lambda_function" "cli" {
package_type = "Image"
image_uri = local.image_uri
role = aws_iam_role.lambda_role.arn
timeout = 30
timeout = 900
memory_size = 256

environment {
variables = {
AWS_LWA_INVOKE_MODE = "response_stream"
AWS_LWA_READINESS_CHECK_PATH = "/healthz"
DEVBOX_PARAM_PREFIX = var.param_prefix
PORT = "8080"
}
}
Expand Down
21 changes: 21 additions & 0 deletions modules/cli-lambda/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,24 @@ variable "param_prefix" {
type = string
default = "/devbox"
}

variable "snapshot_table_arn" {
description = "ARN of the main DevBox snapshot table"
type = string
}

variable "ec2_instance_role_arn" {
description = "ARN of the IAM role attached to launched DevBox instances"
type = string
}

variable "dns_provider" {
description = "DNS provider used for optional launch-time CNAME assignment"
type = string
default = "none"

validation {
condition = contains(["none", "cloudflare", "route53"], var.dns_provider)
error_message = "dns_provider must be one of: none, cloudflare, route53."
}
}
5 changes: 5 additions & 0 deletions modules/devbox/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ output "launch_template_names" {
description = "Names of the Devbox launch templates"
value = aws_launch_template.base[*].name
}

output "ec2_role_arn" {
description = "ARN of the IAM role passed to launched DevBox instances"
value = aws_iam_role.ec2_role.arn
}
2 changes: 0 additions & 2 deletions src/devbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
# Import key components for easier access
from .devbox_manager import DevBoxManager
from .console_output import ConsoleOutput
from .launch import main as launch_instance
from .cli import cli as devbox_cli

# Alias for backward compatibility
Expand All @@ -19,7 +18,6 @@
__all__ = [
"DevBoxManager",
"ConsoleOutput",
"launch_instance",
"devbox_cli",
"main",
]
6 changes: 3 additions & 3 deletions src/devbox/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .remote_client import normalize_param_prefix
from .commands.status import run_status_command
from .commands.terminate import run_terminate_command
from .commands.launch import run_launch_command
from .devbox_manager import DevBoxManager
from .console_output import ConsoleOutput

Expand Down Expand Up @@ -179,12 +180,10 @@ def launch(
PROJECT is the name of the project to launch.
Optionally pass cloud-init user data with --userdata-file.
"""
from .launch import launch_programmatic

console = ctx.obj["console"]

try:
launch_programmatic(
run_launch_command(
project=project,
instance_type=instance_type,
key_pair=key_pair,
Expand All @@ -194,6 +193,7 @@ def launch(
userdata_file=userdata_file,
assign_dns=not no_assign_dns,
dns_subdomain=dns_subdomain,
console=console,
)
except Exception as e:
console.print_error(f"Failed to launch instance: {str(e)}")
Expand Down
12 changes: 7 additions & 5 deletions src/devbox/cli_lambda/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from ..commands.status import handle_status_action
from ..commands.terminate import handle_terminate_action
from ..commands.launch import handle_launch_action
from ..cli_protocol import NDJSON_MIME_TYPE, CliAction, CliEventType
from .contracts import (
CliRequestEnvelope,
Expand All @@ -20,15 +21,16 @@
parse_request_envelope,
)

ActionHandler = Callable[[CliRequestEnvelope], list[dict[str, Any]]]
ActionHandler = Callable[[CliRequestEnvelope], Iterable[dict[str, Any]]]

ACTION_HANDLERS: dict[CliAction, ActionHandler] = {
CliAction.STATUS: handle_status_action,
CliAction.TERMINATE: handle_terminate_action,
CliAction.LAUNCH: handle_launch_action,
}


def dispatch_action(envelope: CliRequestEnvelope) -> list[dict[str, object]]:
def dispatch_action(envelope: CliRequestEnvelope) -> Iterable[dict[str, Any]]:
"""Dispatch one validated CLI Lambda request.

Parameters
Expand All @@ -53,7 +55,7 @@ def dispatch_action(envelope: CliRequestEnvelope) -> list[dict[str, object]]:
return handler(envelope)


def execute_action(envelope: CliRequestEnvelope) -> list[dict[str, object]]:
def execute_action(envelope: CliRequestEnvelope) -> Iterable[dict[str, Any]]:
"""Execute one CLI Lambda request.

Parameters
Expand All @@ -74,11 +76,11 @@ def execute_action(envelope: CliRequestEnvelope) -> list[dict[str, object]]:
400 instead of an application ``error`` event.
"""
try:
return dispatch_action(envelope)
yield from dispatch_action(envelope)
except InvalidCliRequestError:
raise
except Exception as exc:
return [build_event(CliEventType.ERROR, envelope.action, str(exc))]
yield build_event(CliEventType.ERROR, envelope.action, str(exc))


def stream_events(events: Iterable[dict[str, object]]) -> StreamingResponse:
Expand Down
1 change: 1 addition & 0 deletions src/devbox/cli_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class CliAction(StrEnum):

STATUS = "status"
TERMINATE = "terminate"
LAUNCH = "launch"


class CliEventType(StrEnum):
Expand Down
Loading
Loading