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
43 changes: 41 additions & 2 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,12 +439,33 @@ def _wait_abort_then_destroy(attack_range_id: str, config_path: str) -> None:
pass


def _set_terraform_running(attack_range_id: str, running: bool) -> None:
"""Track whether Terraform is currently provisioning infrastructure for a build."""
with operations_lock:
op = running_operations.get(attack_range_id)
if op is not None:
op["terraform_running"] = running


def _is_terraform_running(attack_range_id: str) -> bool:
with operations_lock:
op = running_operations.get(attack_range_id)
return bool(op and op.get("terraform_running"))


def _is_abort_allowed(attack_range_id: str, status: str) -> bool:
build_statuses = ("queued", "build_vpn", "build_lab")
return status in build_statuses and not _is_terraform_running(attack_range_id)


def _check_abort_and_set_aborted(attack_range_id: str, config_path: Optional[str] = None) -> bool:
"""If abort_requested is set for this attack_range_id, set status to aborted and return True. Else return False."""
with operations_lock:
op = running_operations.get(attack_range_id)
if not op or not op.get("abort_requested"):
return False
if op.get("terraform_running"):
return False
running_operations[attack_range_id]["status"] = "aborted"
running_operations[attack_range_id]["end_time"] = datetime.now().isoformat()
path = config_path or op.get("config_path") or get_config_path_from_attack_range_id(attack_range_id)
Expand Down Expand Up @@ -474,7 +495,11 @@ def run_build_vpn_phase(config: Dict[str, Any], config_path: str, attack_range_i
controller = AttackRangeController(config, config_path=config_path)

# Build VPN phase (handles all steps including status updates; checks abort between steps)
router_public_ip, wireguard_config = controller.build_vpn_phase(attack_range_id, abort_check=lambda: _check_abort_and_set_aborted(attack_range_id, config_path))
router_public_ip, wireguard_config = controller.build_vpn_phase(
attack_range_id,
abort_check=lambda: _check_abort_and_set_aborted(attack_range_id, config_path),
terraform_running_callback=lambda running: _set_terraform_running(attack_range_id, running),
)

wireguard_config_path = os.path.join(WIREGUARD_CONFIG_DIR, f"{attack_range_id}.conf")

Expand Down Expand Up @@ -822,6 +847,11 @@ def destroy_attack_range(body: DestroyRequest):
status = config_for_status.get("general", {}).get("status") or ""

if status in build_statuses:
if _is_terraform_running(attack_range_id):
return jsonify(ErrorResponse(
message="Cannot destroy while Terraform is provisioning infrastructure",
details="Wait for Terraform to finish, then retry destroy or abort"
).model_dump()), 400
with operations_lock:
op = running_operations.get(attack_range_id)
if op:
Expand Down Expand Up @@ -886,7 +916,7 @@ def destroy_attack_range(body: DestroyRequest):
tags=[attack_range_tag],
responses={200: DestroyResponse, 400: ErrorResponse, 404: ErrorResponse, 500: ErrorResponse},
summary="Abort attack range build",
description="Abort a build operation in progress. Sets status to 'aborted'."
description="Abort a build operation in progress. Sets status to 'aborted'. Not allowed while Terraform is provisioning infrastructure."
)
def abort_attack_range(body: DestroyRequest):
"""Abort a build operation by setting abort_requested flag and status to aborted."""
Expand Down Expand Up @@ -921,6 +951,12 @@ def abort_attack_range(body: DestroyRequest):
details="Abort can only be called during build (queued, build_vpn, build_lab)"
).model_dump()), 400

if _is_terraform_running(attack_range_id):
return jsonify(ErrorResponse(
message="Cannot abort while Terraform is provisioning infrastructure",
details="Abort is disabled during Terraform init/apply to avoid leaving broken cloud resources. Retry after provisioning completes."
).model_dump()), 400

# Set abort_requested flag
with operations_lock:
op = running_operations.get(attack_range_id)
Expand Down Expand Up @@ -1025,6 +1061,9 @@ def get_attack_range_status(path: AttackRangeIdPath):
if "result" in operation and "config_file" not in operation["result"]:
operation["result"]["config_file"] = config_path

build_statuses = ("queued", "build_vpn", "build_lab")
operation["abort_allowed"] = operation.get("status") in build_statuses and not operation.get("terraform_running", False)

# Validate and return as OperationStatusResponse
return jsonify(OperationStatusResponse(**operation).model_dump()), 200

Expand Down
2 changes: 2 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class OperationStatusResponse(BaseModel):
error: Optional[str] = Field(None, description="Error message (for failed operations)")
error_phase: Optional[str] = Field(None, description="Build phase where error occurred")
traceback: Optional[str] = Field(None, description="Error traceback (for failed operations)")
terraform_running: Optional[bool] = Field(None, description="True while Terraform init/apply is in progress")
abort_allowed: Optional[bool] = Field(None, description="True when abort is permitted for the current build state")


class ServerInfo(BaseModel):
Expand Down
34 changes: 31 additions & 3 deletions app/src/pages/attack-ranges/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,13 @@ if (id) {
<div class="basic-info-header">
<h3>Basic Information</h3>
{['queued', 'build_vpn', 'build_lab'].includes(attackRange.status) ? (
<button class="btn-warning abort-button" data-id={attackRange.attack_range_id}>
Abort
<button
class="btn-warning abort-button"
data-id={attackRange.attack_range_id}
disabled={attackRange.terraform_running}
title={attackRange.terraform_running ? 'Abort is not allowed while Terraform is provisioning infrastructure' : undefined}
>
{attackRange.terraform_running ? 'Provisioning...' : 'Abort'}
</button>
) : (
<button
Expand Down Expand Up @@ -626,12 +631,14 @@ if (id) {
abortButton.addEventListener('click', async () => {
const attackRangeId = (abortButton as HTMLElement).dataset.id;
if (!attackRangeId) return;

const btn = abortButton as HTMLButtonElement;
if (btn.disabled) return;

if (!confirm(`Are you sure you want to abort the build for attack range ${attackRangeId}?`)) {
return;
}

const btn = abortButton as HTMLButtonElement;
btn.disabled = true;
btn.textContent = 'Aborting...';

Expand Down Expand Up @@ -954,13 +961,28 @@ if (id) {
const attackRangeId = window.location.pathname.split('/').pop();
if (attackRangeId) {
let lastStatus: string | null = null;
let lastTerraformRunning: boolean | null = null;
let pollingInterval: number | null = null;

// Get initial status from the page
const statusElement = document.querySelector('.status-badge');
if (statusElement) {
lastStatus = statusElement.textContent?.trim() || null;
}
const initialAbortButton = document.querySelector('.abort-button') as HTMLButtonElement | null;
if (initialAbortButton) {
lastTerraformRunning = initialAbortButton.disabled;
}

const updateAbortButton = (terraformRunning: boolean) => {
const btn = document.querySelector('.abort-button') as HTMLButtonElement | null;
if (!btn) return;
btn.disabled = terraformRunning;
btn.textContent = terraformRunning ? 'Provisioning...' : 'Abort';
btn.title = terraformRunning
? 'Abort is not allowed while Terraform is provisioning infrastructure'
: '';
};

// Only poll if status is not in a final state
const finalStates = ['running', 'error'];
Expand All @@ -973,6 +995,12 @@ if (id) {
if (response.ok) {
const data = await response.json();
const currentStatus = data.status;
const terraformRunning = Boolean(data.terraform_running);

if (terraformRunning !== lastTerraformRunning) {
updateAbortButton(terraformRunning);
lastTerraformRunning = terraformRunning;
}

// Only reload if status changed
if (currentStatus !== lastStatus) {
Expand Down
2 changes: 2 additions & 0 deletions app/src/utils/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface AttackRange {
error?: string;
error_phase?: string;
traceback?: string;
terraform_running?: boolean;
abort_allowed?: boolean;
}

export async function fetchTemplates(): Promise<{ templates: Template[]; providerAvailability: Record<string, ProviderAvailability> }> {
Expand Down
32 changes: 29 additions & 3 deletions attack_range/attack_range_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,32 @@ def build(self, two_phase: bool = False) -> None:
self.logger.info(f"Configuration saved to: {config_file_path}")
self.logger.info("="*80 + "\n")

def build_vpn_phase(self, attack_range_id: str, abort_check: Optional[Callable[[], bool]] = None) -> tuple:
def _run_terraform_step(
self,
terraform_running_callback: Optional[Callable[[bool], None]],
step: Callable[[], None],
) -> None:
"""Run a Terraform step and report running state via callback."""
if terraform_running_callback:
terraform_running_callback(True)
try:
step()
finally:
if terraform_running_callback:
terraform_running_callback(False)

def build_vpn_phase(
self,
attack_range_id: str,
abort_check: Optional[Callable[[], bool]] = None,
terraform_running_callback: Optional[Callable[[bool], None]] = None,
) -> tuple:
"""
Build VPN infrastructure (Phase 1).

:param attack_range_id: Attack range ID
:param abort_check: Optional callable(); if it returns True, build is aborted (raises RuntimeError).
:param terraform_running_callback: Optional callable(bool); called with True before and False after Terraform operations.
:return: Tuple of (router_public_ip, wireguard_config)
"""
if abort_check and abort_check():
Expand Down Expand Up @@ -268,12 +288,18 @@ def build_vpn_phase(self, attack_range_id: str, abort_check: Optional[Callable[[
raise RuntimeError("Build aborted")

# Initialize terraform
self.terraform_manager.init(backend_was_created)
self._run_terraform_step(
terraform_running_callback,
lambda: self.terraform_manager.init(backend_was_created),
)
if abort_check and abort_check():
raise RuntimeError("Build aborted")

# Apply terraform
self.terraform_manager.apply()
self._run_terraform_step(
terraform_running_callback,
lambda: self.terraform_manager.apply(),
)
if abort_check and abort_check():
raise RuntimeError("Build aborted")

Expand Down
Loading