Skip to content
Open
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
42 changes: 42 additions & 0 deletions CubeAPI/src/cubemaster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ impl CubeMasterClient {
parse_response(resp).await
}

/// POST /cube/sandbox/network — replace a running sandbox's egress policy.
pub async fn update_sandbox_network(
&self,
req: &SandboxNetworkRequest,
) -> Result<SandboxNetworkResponse, CubeMasterError> {
let url = format!("{}/cube/sandbox/network", self.base_url);
let resp = self
.inner
.post(&url)
.json(req)
.send()
.await
.map_err(CubeMasterError::Http)?;
parse_response(resp).await
}

/// POST /cube/sandbox/refresh — extend TTL by a delta (seconds).
/// ❌ New API required on CubeMaster.
pub async fn refresh_sandbox(
Expand Down Expand Up @@ -1430,6 +1446,32 @@ pub struct SandboxUpdateResponse {
pub ret: RetCode,
}

// ─── Update sandbox network policy ────────────────────────────────────────
// ✅ Implemented: POST /cube/sandbox/network

#[derive(Debug, Serialize)]
pub struct SandboxNetworkRequest {
#[serde(rename = "RequestID", alias = "requestID")]
pub request_id: String,
#[serde(rename = "sandboxID")]
pub sandbox_id: String,
#[serde(rename = "instanceType")]
pub instance_type: String,
/// Complete desired policy. Always sent, even when empty, because an empty
/// policy is a meaningful request: it clears every egress rule.
pub cube_network_config: CubeNetworkConfig,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SandboxNetworkResponse {
#[serde(rename = "RequestID", alias = "requestID")]
pub request_id: String,
#[serde(rename = "sandboxID", default)]
pub sandbox_id: String,
pub ret: RetCode,
}

// ─── Set sandbox timeout (absolute) ───────────────────────────────────────
// ✅ Implemented: POST /cube/sandbox/timeout

Expand Down
54 changes: 53 additions & 1 deletion CubeAPI/src/handlers/sandboxes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{
models::{
ApiError, ConnectSandbox, ListSandboxesQuery, ListSandboxesV2Query, NewSandbox,
RefreshRequest, ResumedSandbox, Sandbox, SandboxDetail, SandboxLogsQuery,
SandboxLogsV2Query, SandboxLogsV2Response, SetTimeoutRequest,
SandboxLogsV2Query, SandboxLogsV2Response, SetTimeoutRequest, UpdateSandboxNetworkRequest,
},
state::AppState,
};
Expand Down Expand Up @@ -519,6 +519,58 @@ pub async fn set_sandbox_timeout(
Ok(StatusCode::NO_CONTENT)
}

// ─── PUT /sandboxes/:sandboxID/network ────────────────────────────────────────

#[utoipa::path(
put,
path = "/sandboxes/{sandboxID}/network",
params(
("sandboxID" = String, Path, description = "Sandbox identifier")
),
request_body = UpdateSandboxNetworkRequest,
responses(
(status = 204, description = "Network policy updated"),
(status = 400, description = "Invalid network policy", body = ApiError),
(status = 404, description = "Sandbox not found", body = ApiError),
(status = 409, description = "Sandbox is not running", body = ApiError),
(status = 500, description = "Unexpected backend error", body = ApiError)
)
)]
pub async fn update_sandbox_network(
State(state): State<AppState>,
Path(sandbox_id): Path<String>,
Json(body): Json<UpdateSandboxNetworkRequest>,
) -> AppResult<impl IntoResponse> {
state
.logger
.log(
LogEvent::new(LogLevel::Debug, "api.request")
.field("handler", "update_sandbox_network")
.field("sandbox_id", &sandbox_id),
)
.await;

state
.services
.sandboxes
.update_network(
&sandbox_id,
body.allow_internet_access,
body.network.as_ref(),
)
.await?;

tracing::info!(sandbox_id = %sandbox_id, "update_sandbox_network: success");
state
.logger
.log(
LogEvent::new(LogLevel::Info, "sandbox.network.updated")
.field("sandbox_id", &sandbox_id),
)
.await;
Ok(StatusCode::NO_CONTENT)
}

// ─── POST /sandboxes/:sandboxID/refreshes ─────────────────────────────────────

#[utoipa::path(
Expand Down
15 changes: 15 additions & 0 deletions CubeAPI/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,21 @@ fn default_log_limit() -> i32 {
1000
}

// ─── Sandbox — network policy ─────────────────────────────────────────────

/// Request body for PUT /sandboxes/{id}/network.
///
/// The body is the complete desired egress policy, not a patch: any field left
/// out clears what the sandbox currently has. This mirrors how `network` is
/// interpreted at create time, so the same object can be sent to either.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateSandboxNetworkRequest {
#[serde(rename = "allowInternetAccess", alias = "allow_internet_access")]
pub allow_internet_access: Option<bool>,
#[serde(default)]
pub network: Option<SandboxNetworkConfig>,
}

// ─── Sandbox — timeout / refresh ──────────────────────────────────────────

/// Request body for POST /sandboxes/{id}/timeout
Expand Down
5 changes: 4 additions & 1 deletion CubeAPI/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ use crate::{
SetTimeoutRequest, SnapshotInfo, SnapshotListItem, TemplateAliasLookupResponse,
TemplateBuildJob, TemplateBuildStatus, TemplateCompatAdoptResponseView,
TemplateCompatMatrixView, TemplateCompatRowView, TemplateCompatSummaryView, TemplateDetail,
TemplateNodeCompatView, TemplateSummary, Volume, VolumeAndToken,
TemplateNodeCompatView, TemplateSummary, UpdateSandboxNetworkRequest, Volume,
VolumeAndToken,
},
};

Expand Down Expand Up @@ -77,6 +78,7 @@ impl Modify for SecurityAddon {
handlers::sandboxes::get_sandbox_logs,
handlers::sandboxes::get_sandbox_logs_v2,
handlers::sandboxes::set_sandbox_timeout,
handlers::sandboxes::update_sandbox_network,
handlers::sandboxes::refresh_sandbox,
handlers::snapshots::create_snapshot,
handlers::snapshots::list_snapshots,
Expand Down Expand Up @@ -111,6 +113,7 @@ impl Modify for SecurityAddon {
ConnectSandbox,
ResumedSandbox,
SetTimeoutRequest,
UpdateSandboxNetworkRequest,
RefreshRequest,
SandboxLogEntry,
SandboxLogs,
Expand Down
4 changes: 4 additions & 0 deletions CubeAPI/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ fn build_sandbox_routes(state: &AppState, auth_configured: bool) -> Router<AppSt
"/v2/sandboxes/:sandboxID/logs",
get(sandboxes::get_sandbox_logs_v2),
)
.route(
"/sandboxes/:sandboxID/network",
put(sandboxes::update_sandbox_network),
)
.route(
"/sandboxes/:sandboxID/timeout",
post(sandboxes::set_sandbox_timeout),
Expand Down
40 changes: 38 additions & 2 deletions CubeAPI/src/services/sandboxes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use crate::{
datetime_from_unix_nanos, extract_template_id, CreateSandboxRequest, CubeEgressRule,
CubeEgressRuleAction, CubeEgressRuleInject, CubeEgressRuleMatch, CubeMasterClient,
CubeMasterError, CubeNetworkConfig, DeleteSandboxRequest, ListSandboxRequest, SandboxInfo,
SandboxLogsRequest, SandboxRefreshRequest, SandboxStatus, SandboxTimeoutRequest,
SandboxUpdateRequest, VolumeSpec,
SandboxLogsRequest, SandboxNetworkRequest, SandboxRefreshRequest, SandboxStatus,
SandboxTimeoutRequest, SandboxUpdateRequest, VolumeSpec,
},
error::{AppError, AppResult},
models::{
Expand Down Expand Up @@ -493,6 +493,42 @@ impl SandboxService {
Ok(())
}

/// Replace a running sandbox's egress policy.
///
/// The policy is validated and mapped by the same code as sandbox creation,
/// so an update cannot install anything create would have rejected. An
/// all-empty body is legal and clears the policy, which is why the mapper's
/// "nothing set" `None` is turned back into a default config rather than
/// treated as "no change".
pub async fn update_network(
&self,
sandbox_id: &str,
allow_internet_access: Option<bool>,
network: Option<&SandboxNetworkConfig>,
) -> AppResult<()> {
let cube_network_config =
build_cube_network_config(allow_internet_access, network)?.unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low severity — an all-empty body opens internet access.

build_cube_network_config(None, None) returns Ok(None), and .unwrap_or_default() yields a CubeNetworkConfig with allow_internet_access = None. On the Cubelet side cubeVSTapRegistration treats a nil AllowInternetAccess as true (the create-time default), so PUT /sandboxes/{id}/network with body {} clears allow_out/deny_out/rules and flips the sandbox to internet-allowed.

The request-model doc says "any field left out clears what the sandbox currently has" — but omitting allowInternetAccess clears it to default-true, which is likely not what a caller sending {} means (typically "clear restrictions", not "permit everything"). This is documented in the README (update_network(network={}, allow_internet_access=False)), so it's consistent behavior, but on an update endpoint where the natural reading of an empty body is "no change", this default is a footgun — at minimum worth a note in the OpenAPI/utoipa docs that omitting allowInternetAccess means "allow", and arguably worth requiring the field explicitly on this endpoint.


let req = SandboxNetworkRequest {
request_id: new_request_id(),
sandbox_id: sandbox_id.to_string(),
instance_type: self.instance_type.clone(),
cube_network_config,
};

let resp = self
.cubemaster
.update_sandbox_network(&req)
.await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

409 never reaches clients — a paused/not-active sandbox update surfaces as HTTP 500.

update_network maps errors through sandbox_not_found_or_internal, which special-cases only 404 (is_not_found) and 400 (is_params_error) and turns everything else into internal_error → HTTP 500. A Cubelet ErrorCode_Conflict (130409) — returned here for a paused sandbox or a sandbox with no active network — passes through CubeMaster verbatim (updateSandboxNetworkOnNode copies cubeRsp.Ret.RetCode straight into the response), reaches this .map_err, and is misclassified as a 500.

But the contract promises 409:

  • the utoipa annotation declares (status = 409, description = "Sandbox is not running");
  • examples/network-policy/README.md troubleshooting table: "update_network returns 409 | Sandbox is paused or already gone";
  • the Go SDK docstring: "including 409 when the sandbox is not running".

The sibling update/delete paths avoid exactly this by using ensure_update_result / map_update_cubemaster_err / ensure_create_result, all of which map RET_CODE_CONFLICT (130409) → AppError::Conflict. Suggest routing this call through ensure_update_result (or map_update_cubemaster_err) so paused / not-active sandboxes actually return 409.

.map_err(|e| sandbox_not_found_or_internal(e, sandbox_id))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

High severity — paused/not-running sandbox returns HTTP 500, not the documented 409.

sandbox_not_found_or_internal only maps 130404 (is_not_found()); everything else falls through params_error_or_internal, which maps only 130400/invalid-path to 400 and everything else to internal_error → 500. When the Cubelet's updateNetworkPolicy rejects because the sandbox is paused (ErrorCode_Conflict, 130409), the master forwards 130409 verbatim (updateSandboxNetworkOnNode copies cubeRsp.GetRet().GetRetCode()), and this function turns it into a 500.

That contradicts the handler's own utoipa annotation (409: Sandbox is not running), the README ("update_network returns 409 | Sandbox is paused or already gone"), and the Go SDK docstring ("including 409 when the sandbox is not running"). A deterministic, documented client-state condition also charges against server-side error-rate SLIs.

CubeMasterError::is_conflict() already exists, and the pause/resume endpoints map 130409 → AppError::Conflict via map_update_cubemaster_err/ensure_update_result. Reuse those helpers here instead of sandbox_not_found_or_internal.


resp.ret
.into_result()
.map_err(|e| sandbox_not_found_or_internal(e, sandbox_id))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity — unknown sandbox ID returns HTTP 400, not 404.

CubeMaster::UpdateNetwork responds to a missing sandbox with ErrorCode_MasterParamsError (130400), "sandbox not found" (from resolveSandboxHostIP failing) — it never returns 130404 on this path. is_not_found() matches only 130404, so this maps to AppError::BadRequest → 400.

The utoipa annotation documents 404: Sandbox not found, and the Go SDK's UpdateNetwork promises ErrSandboxNotFound (404). fetch_sandbox_detail handles this class of master response with an explicit RET_CODE_NOT_FOUND check; this endpoint has no equivalent. Either have the master return a not-found code (130404) when resolveSandboxHostIP fails here, or map the master's "sandbox not found" params error to AppError::NotFound in this function.


Ok(())
}

pub async fn refresh(&self, sandbox_id: &str, duration: i32) -> AppResult<()> {
let req = SandboxRefreshRequest {
request_id: new_request_id(),
Expand Down
Loading
Loading