EC2 resize primitives and compatibility verdict
Part of the epic "Hosting: let users change their products". Plan = Task 5740, PRD = Task 5738, architecture = Task 5739. Use `task_read` on them for anything this body does not carry.
## Original ask (verbatim, operator Task 5722)
> "user should be able to change his products: dedicated hosting can change to any other instance type and increase the storage to any other offered option. From billing prospective it means that the old product ended and the new started. But technically it is done without reprovisioning the machine. however some of the instance types might be incompatible, in that case ask the user for confirmation and reprovision"
> Follow-up: "Instance type change: no draining the node. change instantly."
## Requirements this child serves (verbatim)
- R3 "**Storage increase:** done live, with no reprovision and no reboot. The CP calls ModifyVolume, and the node grows its partition and filesystem itself (growpart + resize2fs/xfs_growfs, via jaahd or the node helper). The new size is reported back to the CP."
- R4 "**Instance type change:** instant, with NO draining. Stop the instance immediately, ModifyInstanceAttribute to the new type, start it, and the node rejoins. No reprovision, same instance id. Running sessions are interrupted. Say so in the dialog; don't wait for them."
- R5 "**Incompatible types:** e.g. a CPU architecture change (arm64 ↔ x86_64) against the AMI, or other EC2 constraints. Detect these BEFORE acting, show the user that the change needs a reprovision (new machine; local state not carried over beyond what reprovision normally preserves), and proceed only on explicit confirmation. Then reprovision through the existing launch path."
## Acceptance this child contributes to (PRD, verbatim)
- AC-5.1 "Incompatibility is detected before acting. The picker labels a type "Needs a new machine" when its architecture differs from the running image's (for example, t4g ↔ t3), or when EC2 rules out in-place modify (DEC-9). No EC2 mutating call is made in order to discover this."
- DEC-9 "A type is incompatible with in-place change when either: (a) its CPU architecture differs from the architecture of the image the instance is running. This means the instance's actual AMI architecture, not the family regex, because a connection may pin an `ami_id`; or (b) EC2 reports a constraint that rules out in-place modify for the running instance. This covers, at least: the type is not offered in the instance's Availability Zone (DescribeInstanceTypeOfferings), and virtualization, ENA or NVMe support differs (DescribeInstanceTypes)."
- This child builds the primitives for AC-3.1 and AC-4.1. The live verification child closes those.
## What to build: architecture §3.2, D-F and D-L
This contract is pinned: C2 (API) and C3 (workflow) build against these exact names.
In `apps/api/app/aws/ec2.py`, every call goes through `_query_action` with shape-checked ids and bounded regex parsing, as the rest of the module does.
```python
@dataclass(frozen=True)
class InstanceFacts:
instance_id: str; instance_type: str; state: str # pending|running|stopping|stopped|...
architecture: str # 'arm64' | 'x86_64' (the running image's)
availability_zone: str; ena_support: bool
virtualization_type: str # 'hvm' | 'paravirtual'
root_device_type: str # 'ebs' | 'instance-store'
root_volume_id: str; root_device_name: str
@dataclass(frozen=True)
class InstanceTypeFacts:
instance_type: str; architectures: tuple[str, ...]; virtualization_types: tuple[str, ...]
ena: str # 'unsupported'|'supported'|'required'
nvme: str # 'unsupported'|'supported'|'required'
root_device_types: tuple[str, ...] # SupportedRootDeviceTypes
@dataclass(frozen=True)
class VolumeFacts:
volume_id: str; size_gb: int
modifications_24h: tuple[tuple[datetime, str], ...] # (startTime, modificationState), last 24 h, newest first
def describe_instance(*, <signing>, region, instance_id) -> InstanceFacts # DescribeInstances
def describe_instance_types(*, <signing>, region, instance_types: list[str]) -> dict[str, InstanceTypeFacts] # ≤100 per call
def offered_in_az(*, <signing>, region, az, instance_types: list[str]) -> set[str] # DescribeInstanceTypeOfferings location-type=availability-zone
def describe_volume(*, <signing>, region, volume_id) -> VolumeFacts # DescribeVolumes + DescribeVolumesModifications
def modify_volume_size(*, <signing>, region, volume_id, size_gb: int) -> None # ModifyVolume; refuses size_gb <= current (NC-1)
def modify_instance_type(*, <signing>, region, instance_id, instance_type) -> None # ModifyInstanceAttribute InstanceType.Value
```
**Planner correction from the critic gate.** DescribeInstances returns the root volume's id but NOT its size. The size comes from DescribeVolumes, inside `describe_volume`. A verdict therefore makes at most **5** read calls:
1. DescribeInstances
2. DescribeInstanceTypes (batched)
3. DescribeInstanceTypeOfferings
4. DescribeVolumes
5. DescribeVolumesModifications
The architecture's "at most 4" is superseded. Never take the size from `node_rentals.storage_gb`.
**Error mapping.** `apps/api/app/cloud_node_launch.py` wraps each primitive with the existing error mapping (`CloudNodeError` / `CloudNodeUpstreamError` / `InstanceGone`), exactly as `stop_cloud_node` does (`cloud_node_launch.py:986-1019`). `hosting_provisioning._instance_call` keeps its signature, but its `verb` may now return a value, so the facts pass through.
**New module `apps/api/app/hosting_compat.py`:**
```python
@dataclass(frozen=True)
class TypeVerdict:
in_place: bool
reason: Optional[str] # None only when in_place; else a sentence ("Needs a new machine: Arm to x86")
unavailable_reason: Optional[str] = None # planner addition PL-1
@dataclass(frozen=True)
class MachineVerdict:
current_type: str
types: dict[str, TypeVerdict] # every catalogue type except current
storage_blocked_reason: Optional[str] # EBS modification limit (D-L); None = may grow now
root_volume_gb: int # from DescribeVolumes
def verdict(session: Session, hosting: NodeHosting, catalogue_types: list[str]) -> MachineVerdict
```
**When a target type cannot change in place.** A target is `in_place = False` when any of these holds:
- the instance's architecture is not in the target's `architectures`;
- the target is not offered in the instance's AZ;
- the target's `SupportedRootDeviceTypes` lacks `ebs`;
- `hvm` is not in the target's `virtualization_types`;
- the target's `ena == 'required'` and the instance's `ena_support` is false;
- the target's `nvme == 'required'` and the CURRENT type's `nvme == 'unsupported'`. The current type's facts come from the same `describe_instance_types` call.
**No cache.** The verdict is computed on every call and makes no mutating call. An EC2 read failure raises with a user-facing sentence. It is NEVER treated as "compatible".
**D-L, the EBS modification limit:**
- While the newest modification is `modifying` or `optimizing`, `storage_blocked_reason` is "Storage can grow again once the last resize finishes".
- When 4 modifications started in the last 24 h, it is "Storage can grow again after <oldest start + 24 h>". Render the time so the API and web can show local time.
**PL-1, planner decision (pinned).** When the hosting's connection pins an `ami_id` (`cloud_node_launch.py:803`) and the target's architecture differs from the instance's, return `TypeVerdict(in_place=False, reason="Not available on this hosting", unavailable_reason="Not available on this hosting")`. This is the FU-6 scope-down: such a type is offered as unavailable, not as "Needs a new machine". C2 maps `unavailable_reason` to `available=false` and refuses that type at quote.
**C-3.** EC2 calls use the operator's hosting connection credential, exactly as stop, start and terminate do (`hosting_provisioning._instance_call`).
Credentials & reach: "All unit/API/component tests (C1–C6) | none | worktree (`./dc check`) | **verified**: needs nothing beyond the worktree."
Out of scope:
- Do NOT touch the hosting routes, the billing engine, migrations, the web, the docs, jaahd or the DBOS workflow. Other children own them.
- Do NOT cache verdicts.
- Epic (verbatim): "Out of scope: shrinking storage. Shared (co-located) hosting tier changes. Customer-owned cloud connections (non-hosting cloud nodes). Auto-tagging and SSM instance profiles (Task 5725). Any existing hosting's record or billing reconciliation."
- PRD (verbatim): "NC-1 No storage SHRINK path anywhere. This covers UI, API, and any EBS call that reduces size." "NC-3 No change to customer-owned cloud connections or non-hosting cloud nodes (`routes/nodes.py` cloud launch, Dynamic node groups). The resize primitives may be generic, but no UI or route exposes them for those." "NC-4 No auto-tagging and no SSM instance profile work (Task 5725). `IamInstanceProfile` / tag code in `apps/api/app/aws/ec2.py` is not modified for this."
Done means:
- Each new ec2 primitive parses an EC2 XML fixture into its dataclass and rejects a malformed id. Copy each fixture from the AWS EC2 API Reference sample response for that action, and cite the source in the fixture. Unit tests pass under `./dc check quick`.
- `modify_volume_size` refuses `size_gb <= current` without calling AWS (NC-1). Unit test.
- AC-5.1, verdict half: "Run the API test asserting the compatibility verdict for an arch mismatch, an AZ-unavailable type, and a compatible type." Also test: ENA required, NVMe required when moving off a Xen-era type, a non-ebs root, a cross-arch type on a pinned AMI (`unavailable_reason` set), and an EC2 read failure raising (never treated as compatible).
- The verdict test asserts that no mutating EC2 action is issued, that at most 5 read calls are made, and that `root_volume_gb` comes from DescribeVolumes.
- D-L tests:
- a newest modification in `modifying` or `optimizing` gives the pinned sentence;
- 4 modifications in 24 h give the pinned sentence;
- a `completed` history with fewer than 4 gives `None`.
- `./dc check full` is green. The PR is merged to develop, and the Jaah Deploy of the merge succeeds in GitHub.
Outcome
Built 6 EC2 resize primitives + hosting_compat.verdict (≤5 reads, PL-1, D-L); full gate green, PR #4998 merged, Jaah Deploy success. Known limit: AWS returns only the latest volume modification, so the 4/24h branch rarely fires (C3 must map ModifyVolume rate limits).
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)