Skip to content
Merged
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
41 changes: 41 additions & 0 deletions alembic/versions/012_vm_customer_ipv6_prefixes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""vm customer ipv6 prefixes

Revision ID: 012
Revises: 011
Create Date: 2026-07-01
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "012"
down_revision: str | None = "011"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_columns = {column["name"] for column in inspector.get_columns("vms")}
existing_indexes = {index["name"] for index in inspector.get_indexes("vms")}

if "ipv6_prefix_index" not in existing_columns:
op.add_column("vms", sa.Column("ipv6_prefix_index", sa.Integer(), nullable=True))
if "ipv6_prefix" not in existing_columns:
op.add_column("vms", sa.Column("ipv6_prefix", sa.String(64), nullable=True))

if "ix_vms_ipv6_prefix_index" not in existing_indexes:
op.create_index("ix_vms_ipv6_prefix_index", "vms", ["ipv6_prefix_index"], unique=True)
if "ix_vms_ipv6_prefix" not in existing_indexes:
op.create_index("ix_vms_ipv6_prefix", "vms", ["ipv6_prefix"], unique=True)


def downgrade() -> None:
op.drop_index("ix_vms_ipv6_prefix", table_name="vms")
op.drop_index("ix_vms_ipv6_prefix_index", table_name="vms")
op.drop_column("vms", "ipv6_prefix")
op.drop_column("vms", "ipv6_prefix_index")
61 changes: 42 additions & 19 deletions hyrule_cloud/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
VMStatusResponse,
generate_domain_management_token,
)
from hyrule_cloud.providers.network_config import supports_static_network_config
from hyrule_cloud.services.launch_proof import build_launch_proof
from hyrule_cloud.services.quotes import (
QuoteConflictError,
Expand Down Expand Up @@ -248,6 +249,27 @@ def _vm_create_response(
)


def _real_provisioning_enabled() -> bool:
from hyrule_cloud.services.launch_proof import use_real_provisioning

return use_real_provisioning()


def _validate_vm_order(order: VMCreateRequest, cfg) -> None:
if order.domain_mode == DomainMode.CUSTOM and not order.domain:
raise HTTPException(400, "domain required when domain_mode=custom")

for port in order.open_ports:
if port in cfg.blocked_ports:
raise HTTPException(400, f"Port {port} is blocked by policy")

if _real_provisioning_enabled() and not supports_static_network_config(order.os):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply real-mode OS validation to native intents

When real provisioning is enabled, this OS support check is only reached by /v1/vm/create and /v1/vm/quote; /v1/intent/create still does its own domain/blocked-port validation and then stores any order_payload. If BTC/XMR payments are configured, a customer can open an intent for openbsd-7.8/alpine, receive a deposit address, pay, and only then have _provision_vm fail with Static network config is not supported, while the x402 paths correctly reject before payment. Please route native intent creation through the same helper before creating the intent.

Useful? React with 👍 / 👎.

raise HTTPException(
400,
f"OS template {order.os} is not supported for real VM provisioning yet",
)


# --- Block B (Wave 2): runtime metrics ---

# 20s TTL cache: the DB count + provisioned-at query is cheap but not free,
Expand Down Expand Up @@ -516,16 +538,24 @@ async def get_vm_products(request: Request, cfg=Depends(get_cfg)) -> VMProductsR

@router.get("/os/list", response_model=OSListResponse)
async def list_os_templates(cfg = Depends(get_cfg)) -> OSListResponse:
names = list(cfg.xcpng.templates)
if not names:
names = ["debian-13", "alpine-3.21", "freebsd-14"]
if _real_provisioning_enabled():
names = [name for name in names if supports_static_network_config(name)]
descriptions = {
"debian-13": "Debian 13 (Trixie)",
"alpine-3.21": "Alpine Linux 3.21",
"freebsd-14": "FreeBSD 14.2",
}
templates = [
OSTemplate(name=name, description=f"OS template: {name}", default=(name == "debian-13"))
for name in cfg.xcpng.templates
OSTemplate(
name=name,
description=descriptions.get(name, f"OS template: {name}"),
default=(name == "debian-13"),
)
for name in names
]
if not templates:
templates = [
OSTemplate(name="debian-13", description="Debian 13 (Trixie)", default=True),
OSTemplate(name="alpine-3.21", description="Alpine Linux 3.21"),
OSTemplate(name="freebsd-14", description="FreeBSD 14.2"),
]
return OSListResponse(templates=templates)


Expand Down Expand Up @@ -581,6 +611,7 @@ async def get_vm_public_status(
vm_id=row.vm_id,
status=VMStatus(row.status),
ipv6=row.ipv6,
ipv6_prefix=getattr(row, "ipv6_prefix", None),
hostname=row.hostname,
expires_at=row.expires_at,
launch_proof_status=lp["launch_proof_status"],
Expand Down Expand Up @@ -610,6 +641,7 @@ async def get_vm_status(
vm_id=row.vm_id,
status=VMStatus(row.status),
ipv6=row.ipv6,
ipv6_prefix=getattr(row, "ipv6_prefix", None),
hostname=row.hostname,
ssh=f"ssh root@{row.hostname}" if is_ready else None,
expires_at=row.expires_at,
Expand Down Expand Up @@ -677,12 +709,7 @@ async def create_vm(
raise HTTPException(422, "Order body does not match the quote")
order = VMCreateRequest(**quote_row.order_payload)

if order.domain_mode == DomainMode.CUSTOM and not order.domain:
raise HTTPException(400, "domain required when domain_mode=custom")

for port in order.open_ports:
if port in cfg.blocked_ports:
raise HTTPException(400, f"Port {port} is blocked by policy")
_validate_vm_order(order, cfg)

await _enforce_paid_vm_cap(orch, cfg)

Expand Down Expand Up @@ -755,11 +782,7 @@ async def create_vm_quote(
quote (200); same key + a different spec is a 409 conflict.
"""
order = body.order_payload
if order.domain_mode == DomainMode.CUSTOM and not order.domain:
raise HTTPException(400, "domain required when domain_mode=custom")
for port in order.open_ports:
if port in cfg.blocked_ports:
raise HTTPException(400, f"Port {port} is blocked by policy")
_validate_vm_order(order, cfg)

total, _ = await _compute_vm_price(orch, cfg, order)
app_state = get_app_state(request)
Expand Down
7 changes: 7 additions & 0 deletions hyrule_cloud/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ class HyruleConfig(BaseSettings):
# Cloud-init template directory
templates_dir: Path = Path("templates")

# Customer VM IPv6 allocation. The current customer L2 is one shared
# XCP-NG network, so Hyrule injects static guest network-config instead of
# relying on RA/DHCPv6.
customer_ipv6_supernet: str = "2a0c:b641:b51::/48"
customer_ipv6_gateway: str = "2a0c:b641:b51::1"
customer_ipv6_dns: str = "2a0c:b641:b51::1"

# Network intelligence / BGP data storage
bgp_data_enabled: bool = True
bgp_data_dir: Path = Path("/var/lib/hyrule-cloud/bgp")
Expand Down
4 changes: 4 additions & 0 deletions hyrule_cloud/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class VMRow(Base):
)
os: Mapped[str] = mapped_column(String(64), default="debian-13")
ipv6: Mapped[str | None] = mapped_column(String(64))
ipv6_prefix_index: Mapped[int | None] = mapped_column(Integer)
ipv6_prefix: Mapped[str | None] = mapped_column(String(64))
hostname: Mapped[str | None] = mapped_column(String(256))
ssh_pubkey: Mapped[str] = mapped_column(Text, default="")

Expand Down Expand Up @@ -124,6 +126,8 @@ class VMRow(Base):
__table_args__ = (
Index("ix_vms_status_expires", "status", "expires_at"),
Index("ix_vms_owner_status", "owner_wallet", "status"),
Index("ix_vms_ipv6_prefix_index", "ipv6_prefix_index", unique=True),
Index("ix_vms_ipv6_prefix", "ipv6_prefix", unique=True),
)


Expand Down
2 changes: 2 additions & 0 deletions hyrule_cloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ class VMStatusResponse(BaseModel):
vm_id: str
status: VMStatus
ipv6: str | None = None
ipv6_prefix: str | None = None
hostname: str | None = None
ssh: str | None = None
expires_at: datetime | None = None
Expand All @@ -290,6 +291,7 @@ class VMPublicStatusResponse(BaseModel):
vm_id: str
status: VMStatus
ipv6: str | None = None
ipv6_prefix: str | None = None
hostname: str | None = None
expires_at: datetime | None = None
# Launch-proof contract fields (issue #28)
Expand Down
Loading
Loading