Publish Clusterflux release candidate 6e83783
This commit is contained in:
parent
4d75257c6e
commit
f3640643bd
16 changed files with 4667 additions and 106 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"kind": "clusterflux-filtered-public-tree",
|
"kind": "clusterflux-filtered-public-tree",
|
||||||
"source_commit": "fcace1d13a4b4100c5fe95267d06de68ee6675af",
|
"source_commit": "6e83783fb080282a73f46216ade64ccf8712ee92",
|
||||||
"release_name": "release-fcace1d13a4b",
|
"release_name": "release-6e83783fb080",
|
||||||
"filtered_out": [
|
"filtered_out": [
|
||||||
"private/**",
|
"private/**",
|
||||||
"internal/**",
|
"internal/**",
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ use std::collections::BTreeMap;
|
||||||
use clusterflux_core::{
|
use clusterflux_core::{
|
||||||
AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind,
|
AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind,
|
||||||
DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements,
|
DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements,
|
||||||
LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, NodeSignedRequest,
|
LaunchAttemptId, LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId,
|
||||||
PanelEventKind, PanelState, Placement, ProcessId, ProjectId, ResourceLimits, SourcePreparation,
|
NodeSignedRequest, PanelEventKind, PanelState, Placement, ProcessId, ProjectId, RequestId,
|
||||||
SourceProviderKind, TaskBoundaryValue, TaskInstanceId, TaskJoinResult, TaskSpec, TenantId,
|
ResourceLimits, SourcePreparation, SourceProviderKind, TaskBoundaryValue, TaskDefinitionId,
|
||||||
UserId, VfsPath,
|
TaskInstanceId, TaskJoinResult, TaskSpec, TenantId, UserId, VfsPath,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -531,6 +531,13 @@ pub enum CoordinatorRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoordinatorRequest {
|
impl CoordinatorRequest {
|
||||||
|
pub fn validate_external_identifiers(&self) -> Result<(), String> {
|
||||||
|
let value = serde_json::to_value(self).map_err(|error| {
|
||||||
|
format!("failed to validate coordinator request identifiers: {error}")
|
||||||
|
})?;
|
||||||
|
validate_identifier_tree(&value, "request")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn operation(&self) -> Result<String, String> {
|
pub fn operation(&self) -> Result<String, String> {
|
||||||
serde_json::to_value(self)
|
serde_json::to_value(self)
|
||||||
.map_err(|err| format!("failed to encode coordinator request operation: {err}"))
|
.map_err(|err| format!("failed to encode coordinator request operation: {err}"))
|
||||||
|
|
@ -538,6 +545,118 @@ impl CoordinatorRequest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ExternalIdentifierKind {
|
||||||
|
Agent,
|
||||||
|
Artifact,
|
||||||
|
LaunchAttempt,
|
||||||
|
Node,
|
||||||
|
Process,
|
||||||
|
Project,
|
||||||
|
Request,
|
||||||
|
TaskDefinition,
|
||||||
|
TaskInstance,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identifier_kind(field: &str) -> Option<ExternalIdentifierKind> {
|
||||||
|
match field {
|
||||||
|
"tenant" | "target_tenant" => Some(ExternalIdentifierKind::Tenant),
|
||||||
|
"project" => Some(ExternalIdentifierKind::Project),
|
||||||
|
"actor_user" | "user" => Some(ExternalIdentifierKind::User),
|
||||||
|
"agent" | "actor_agent" => Some(ExternalIdentifierKind::Agent),
|
||||||
|
"node" | "prefer_node" | "receiver_node" => Some(ExternalIdentifierKind::Node),
|
||||||
|
"process" => Some(ExternalIdentifierKind::Process),
|
||||||
|
"task" | "parent_task" | "stopped_task" | "task_instance" => {
|
||||||
|
Some(ExternalIdentifierKind::TaskInstance)
|
||||||
|
}
|
||||||
|
"task_definition" => Some(ExternalIdentifierKind::TaskDefinition),
|
||||||
|
"artifact" | "required_artifacts" | "artifact_locations" => {
|
||||||
|
Some(ExternalIdentifierKind::Artifact)
|
||||||
|
}
|
||||||
|
"launch_attempt" => Some(ExternalIdentifierKind::LaunchAttempt),
|
||||||
|
"request_id" | "session_secret" | "transaction_id" | "polling_secret" | "transfer_id"
|
||||||
|
| "enrollment_grant" | "widget_id" | "environment_id" | "admin_nonce" => {
|
||||||
|
Some(ExternalIdentifierKind::Request)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier_tree(value: &serde_json::Value, path: &str) -> Result<(), String> {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(fields) => {
|
||||||
|
for (field, value) in fields {
|
||||||
|
let field_path = format!("{path}.{field}");
|
||||||
|
if let Some(kind) = identifier_kind(field) {
|
||||||
|
validate_identifier_value(kind, value, &field_path)?;
|
||||||
|
}
|
||||||
|
validate_identifier_tree(value, &field_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(values) => {
|
||||||
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
validate_identifier_tree(value, &format!("{path}[{index}]"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier_value(
|
||||||
|
kind: ExternalIdentifierKind,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(values) = value.as_array() {
|
||||||
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
validate_identifier_value(kind, value, &format!("{path}[{index}]"))?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let value = value
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| format!("external identifier {path} must be a string"))?
|
||||||
|
.to_owned();
|
||||||
|
let result = match kind {
|
||||||
|
ExternalIdentifierKind::Agent => AgentId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Artifact => ArtifactId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::LaunchAttempt => LaunchAttemptId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Node => NodeId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Process => ProcessId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Project => ProjectId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Request => RequestId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::TaskDefinition => TaskDefinitionId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::TaskInstance => TaskInstanceId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::Tenant => TenantId::try_new(value).map(|_| ()),
|
||||||
|
ExternalIdentifierKind::User => UserId::try_new(value).map(|_| ()),
|
||||||
|
};
|
||||||
|
result.map_err(|error| format!("malformed external identifier {path}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod external_identifier_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_authenticated_identifiers_are_validated() {
|
||||||
|
let request = CoordinatorRequest::Authenticated {
|
||||||
|
session_secret: "cli_session_valid".to_owned(),
|
||||||
|
request: AuthenticatedCoordinatorRequest::AbortProcess {
|
||||||
|
process: "bad process".to_owned(),
|
||||||
|
launch_attempt: Some("attempt".to_owned()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error = request.validate_external_identifiers().unwrap_err();
|
||||||
|
assert!(error.contains("request.request.process"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum AuthenticatedCoordinatorRequest {
|
pub enum AuthenticatedCoordinatorRequest {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ impl CoordinatorService {
|
||||||
&mut self,
|
&mut self,
|
||||||
request: CoordinatorRequest,
|
request: CoordinatorRequest,
|
||||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||||
|
request
|
||||||
|
.validate_external_identifiers()
|
||||||
|
.map_err(CoordinatorServiceError::Protocol)?;
|
||||||
self.pump_main_runtime_commands();
|
self.pump_main_runtime_commands();
|
||||||
let request_payload = serde_json::to_value(&request).map_err(|error| {
|
let request_payload = serde_json::to_value(&request).map_err(|error| {
|
||||||
CoordinatorServiceError::Protocol(format!(
|
CoordinatorServiceError::Protocol(format!(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use clusterflux_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE};
|
use clusterflux_core::{RequestId, COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
@ -44,9 +44,9 @@ impl CoordinatorRequestEnvelope {
|
||||||
self.protocol_version, COORDINATOR_PROTOCOL_VERSION
|
self.protocol_version, COORDINATOR_PROTOCOL_VERSION
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.request_id.trim().is_empty() {
|
RequestId::try_new(self.request_id.clone())
|
||||||
return Err("coordinator wire request_id must be non-empty".to_owned());
|
.map_err(|error| format!("malformed coordinator wire request_id: {error}"))?;
|
||||||
}
|
self.payload.validate_external_identifiers()?;
|
||||||
let payload_operation = self.payload.operation()?;
|
let payload_operation = self.payload.operation()?;
|
||||||
if self.operation != payload_operation {
|
if self.operation != payload_operation {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,73 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const MAX_EXTERNAL_ID_BYTES: usize = 255;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct IdParseError {
|
||||||
|
id_type: &'static str,
|
||||||
|
reason: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdParseError {
|
||||||
|
fn new(id_type: &'static str, reason: &'static str) -> Self {
|
||||||
|
Self { id_type, reason }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IdParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} is invalid: {}", self.id_type, self.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for IdParseError {}
|
||||||
|
|
||||||
|
fn validate_id(value: &str, id_type: &'static str) -> Result<(), IdParseError> {
|
||||||
|
if value.is_empty() || value.trim().is_empty() {
|
||||||
|
return Err(IdParseError::new(
|
||||||
|
id_type,
|
||||||
|
"value must not be empty or whitespace-only",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() > MAX_EXTERNAL_ID_BYTES {
|
||||||
|
return Err(IdParseError::new(
|
||||||
|
id_type,
|
||||||
|
"value exceeds the 255-byte limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.chars().any(char::is_control) {
|
||||||
|
return Err(IdParseError::new(
|
||||||
|
id_type,
|
||||||
|
"control characters are forbidden",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !value.bytes().all(|byte| {
|
||||||
|
byte.is_ascii_alphanumeric()
|
||||||
|
|| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'@' | b'+')
|
||||||
|
}) {
|
||||||
|
return Err(IdParseError::new(
|
||||||
|
id_type,
|
||||||
|
"only ASCII letters, digits, '-', '_', '.', ':', '/', '@', and '+' are allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! id_type {
|
macro_rules! id_type {
|
||||||
($name:ident) => {
|
($name:ident) => {
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct $name(String);
|
pub struct $name(String);
|
||||||
|
|
||||||
impl $name {
|
impl $name {
|
||||||
pub fn new(value: impl Into<String>) -> Self {
|
pub fn try_new(value: impl Into<String>) -> Result<Self, IdParseError> {
|
||||||
let value = value.into();
|
let value = value.into();
|
||||||
assert!(
|
validate_id(&value, stringify!($name))?;
|
||||||
!value.trim().is_empty(),
|
Ok(Self(value))
|
||||||
concat!(stringify!($name), " cannot be empty")
|
}
|
||||||
);
|
|
||||||
Self(value)
|
/// Constructs an identifier from a trusted, internally generated value.
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Self::try_new(value).unwrap_or_else(|error| panic!("{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
|
|
@ -44,3 +99,39 @@ id_type!(TaskDefinitionId);
|
||||||
id_type!(TaskInstanceId);
|
id_type!(TaskInstanceId);
|
||||||
id_type!(TenantId);
|
id_type!(TenantId);
|
||||||
id_type!(UserId);
|
id_type!(UserId);
|
||||||
|
|
||||||
|
pub type DebugSessionId = ProcessId;
|
||||||
|
pub type RequestId = LaunchAttemptId;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_hostile_values<T>(parse: impl Fn(String) -> Result<T, IdParseError>) {
|
||||||
|
for value in [
|
||||||
|
String::new(),
|
||||||
|
" ".to_owned(),
|
||||||
|
"bad\0id".to_owned(),
|
||||||
|
"bad id".to_owned(),
|
||||||
|
"x".repeat(MAX_EXTERNAL_ID_BYTES + 1),
|
||||||
|
] {
|
||||||
|
assert!(parse(value).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_external_identifier_type_rejects_hostile_values() {
|
||||||
|
assert_hostile_values(AgentId::try_new);
|
||||||
|
assert_hostile_values(ArtifactId::try_new);
|
||||||
|
assert_hostile_values(DebugSessionId::try_new);
|
||||||
|
assert_hostile_values(NodeId::try_new);
|
||||||
|
assert_hostile_values(ProcessId::try_new);
|
||||||
|
assert_hostile_values(LaunchAttemptId::try_new);
|
||||||
|
assert_hostile_values(ProjectId::try_new);
|
||||||
|
assert_hostile_values(RequestId::try_new);
|
||||||
|
assert_hostile_values(TaskDefinitionId::try_new);
|
||||||
|
assert_hostile_values(TaskInstanceId::try_new);
|
||||||
|
assert_hostile_values(TenantId::try_new);
|
||||||
|
assert_hostile_values(UserId::try_new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,9 @@ pub use execution::{
|
||||||
WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION,
|
WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION,
|
||||||
};
|
};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, ArtifactId, LaunchAttemptId, NodeId, ProcessId, ProjectId, TaskDefinitionId,
|
AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId, NodeId, ProcessId,
|
||||||
TaskInstanceId, TenantId, UserId,
|
ProjectId, RequestId, TaskDefinitionId, TaskInstanceId, TenantId, UserId,
|
||||||
|
MAX_EXTERNAL_ID_BYTES,
|
||||||
};
|
};
|
||||||
pub use limits::{
|
pub use limits::{
|
||||||
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
|
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ enum AdapterEvent {
|
||||||
},
|
},
|
||||||
BreakpointsUpdated {
|
BreakpointsUpdated {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
revision: u64,
|
||||||
result: std::result::Result<(), String>,
|
result: std::result::Result<(), String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +63,7 @@ enum AdapterEvent {
|
||||||
struct LaunchCompletion {
|
struct LaunchCompletion {
|
||||||
state: AdapterState,
|
state: AdapterState,
|
||||||
local_runtime_session: Option<LocalRuntimeSession>,
|
local_runtime_session: Option<LocalRuntimeSession>,
|
||||||
|
breakpoint_revision: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run_adapter() -> Result<()> {
|
pub(crate) fn run_adapter() -> Result<()> {
|
||||||
|
|
@ -93,8 +95,13 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
runtime_starting = false;
|
runtime_starting = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(mut completion) => {
|
Ok(mut completion) => {
|
||||||
completion.state.breakpoints = state.breakpoints.clone();
|
let requested_breakpoints = state.breakpoints.clone();
|
||||||
completion.state.breakpoints_installed = true;
|
let requested_revision = state.breakpoint_revision;
|
||||||
|
let installed_revision = completion.breakpoint_revision;
|
||||||
|
completion.state.breakpoints = requested_breakpoints;
|
||||||
|
completion.state.breakpoint_revision = requested_revision;
|
||||||
|
completion.state.breakpoints_installed =
|
||||||
|
installed_revision == requested_revision;
|
||||||
let previous_threads = state.threads.clone();
|
let previous_threads = state.threads.clone();
|
||||||
state = completion.state;
|
state = completion.state;
|
||||||
emit_thread_lifecycle(&mut writer, &previous_threads, &state.threads)?;
|
emit_thread_lifecycle(&mut writer, &previous_threads, &state.threads)?;
|
||||||
|
|
@ -102,7 +109,24 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
_local_runtime_session = Some(session);
|
_local_runtime_session = Some(session);
|
||||||
}
|
}
|
||||||
runtime_started = true;
|
runtime_started = true;
|
||||||
|
if state.breakpoints_installed {
|
||||||
emit_verified_breakpoints(&mut writer, &state)?;
|
emit_verified_breakpoints(&mut writer, &state)?;
|
||||||
|
} else {
|
||||||
|
let breakpoint_state = state.clone();
|
||||||
|
let breakpoint_tx = event_tx.clone();
|
||||||
|
let revision = state.breakpoint_revision;
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = set_services_debug_breakpoints(&breakpoint_state)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("coordinator breakpoint update failed: {error:#}")
|
||||||
|
});
|
||||||
|
let _ = breakpoint_tx.send(AdapterEvent::BreakpointsUpdated {
|
||||||
|
generation,
|
||||||
|
revision,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
writer.output(
|
writer.output(
|
||||||
"console",
|
"console",
|
||||||
if state.session_mode == DapSessionMode::Attach {
|
if state.session_mode == DapSessionMode::Attach {
|
||||||
|
|
@ -127,15 +151,7 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
runtime_started = false;
|
runtime_started = false;
|
||||||
writer.output("stderr", format!("{message}\n"))?;
|
writer.output("stderr", format!("{message}\n"))?;
|
||||||
writer.event(
|
writer.event("terminated", json!({ "restart": false }))?;
|
||||||
"stopped",
|
|
||||||
json!({
|
|
||||||
"reason": "exception",
|
|
||||||
"description": message,
|
|
||||||
"threadId": MAIN_THREAD,
|
|
||||||
"allThreadsStopped": false,
|
|
||||||
}),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -231,8 +247,12 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
AdapterEvent::BreakpointsUpdated { generation, result } => {
|
AdapterEvent::BreakpointsUpdated {
|
||||||
if generation == runtime_generation {
|
generation,
|
||||||
|
revision,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if generation == runtime_generation && revision == state.breakpoint_revision {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
state.breakpoints_installed = true;
|
state.breakpoints_installed = true;
|
||||||
|
|
@ -306,7 +326,16 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if let Some(process_id) = args.get("processId").and_then(Value::as_str) {
|
if let Some(process_id) = args.get("processId").and_then(Value::as_str) {
|
||||||
state.process = clusterflux_core::ProcessId::new(process_id);
|
match clusterflux_core::ProcessId::try_new(process_id.to_owned()) {
|
||||||
|
Ok(process) => state.process = process,
|
||||||
|
Err(error) => {
|
||||||
|
writer.error_response(
|
||||||
|
&request,
|
||||||
|
format!("invalid DAP processId: {error}"),
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if command == "attach" {
|
if command == "attach" {
|
||||||
state.session_mode = DapSessionMode::Attach;
|
state.session_mode = DapSessionMode::Attach;
|
||||||
|
|
@ -338,6 +367,8 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
requested_source_path,
|
requested_source_path,
|
||||||
requested_lines,
|
requested_lines,
|
||||||
);
|
);
|
||||||
|
state.breakpoint_revision = state.breakpoint_revision.saturating_add(1);
|
||||||
|
let breakpoint_revision = state.breakpoint_revision;
|
||||||
let coordinator_backed = matches!(
|
let coordinator_backed = matches!(
|
||||||
state.runtime_backend,
|
state.runtime_backend,
|
||||||
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
|
RuntimeBackend::LocalServices | RuntimeBackend::LiveServices
|
||||||
|
|
@ -371,8 +402,11 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
set_services_debug_breakpoints(&breakpoint_state).map_err(|error| {
|
set_services_debug_breakpoints(&breakpoint_state).map_err(|error| {
|
||||||
format!("coordinator breakpoint update failed: {error:#}")
|
format!("coordinator breakpoint update failed: {error:#}")
|
||||||
});
|
});
|
||||||
let _ = breakpoint_tx
|
let _ = breakpoint_tx.send(AdapterEvent::BreakpointsUpdated {
|
||||||
.send(AdapterEvent::BreakpointsUpdated { generation, result });
|
generation,
|
||||||
|
revision: breakpoint_revision,
|
||||||
|
result,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -433,11 +467,17 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
let mut completed_state = launch_state;
|
let mut completed_state = launch_state;
|
||||||
let result = if completed_state.session_mode == DapSessionMode::Attach {
|
let result = if completed_state.session_mode == DapSessionMode::Attach {
|
||||||
attach_services_runtime(&completed_state)
|
attach_services_runtime(&completed_state)
|
||||||
.map(|record| {
|
.and_then(|record| {
|
||||||
completed_state.apply_attach_record(record);
|
completed_state.apply_attach_record(record);
|
||||||
|
set_services_debug_breakpoints(&completed_state)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map(|()| {
|
||||||
|
let breakpoint_revision = completed_state.breakpoint_revision;
|
||||||
LaunchCompletion {
|
LaunchCompletion {
|
||||||
state: completed_state,
|
state: completed_state,
|
||||||
local_runtime_session: None,
|
local_runtime_session: None,
|
||||||
|
breakpoint_revision,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("services runtime attach failed: {error:#}"))
|
.map_err(|error| format!("services runtime attach failed: {error:#}"))
|
||||||
|
|
@ -447,9 +487,12 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
run_local_services_runtime(&completed_state)
|
run_local_services_runtime(&completed_state)
|
||||||
.map(|(record, session)| {
|
.map(|(record, session)| {
|
||||||
completed_state.apply_runtime_record(record);
|
completed_state.apply_runtime_record(record);
|
||||||
|
let breakpoint_revision =
|
||||||
|
completed_state.breakpoint_revision;
|
||||||
LaunchCompletion {
|
LaunchCompletion {
|
||||||
state: completed_state,
|
state: completed_state,
|
||||||
local_runtime_session: Some(session),
|
local_runtime_session: Some(session),
|
||||||
|
breakpoint_revision,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
|
|
@ -460,9 +503,12 @@ pub(crate) fn run_adapter() -> Result<()> {
|
||||||
run_live_services_runtime(&completed_state)
|
run_live_services_runtime(&completed_state)
|
||||||
.map(|record| {
|
.map(|record| {
|
||||||
completed_state.apply_runtime_record(record);
|
completed_state.apply_runtime_record(record);
|
||||||
|
let breakpoint_revision =
|
||||||
|
completed_state.breakpoint_revision;
|
||||||
LaunchCompletion {
|
LaunchCompletion {
|
||||||
state: completed_state,
|
state: completed_state,
|
||||||
local_runtime_session: None,
|
local_runtime_session: None,
|
||||||
|
breakpoint_revision,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
|
|
|
||||||
|
|
@ -422,8 +422,31 @@ fn launch_services_debug_entrypoint(
|
||||||
}
|
}
|
||||||
// The launch acknowledgement is the commit point. Failures while fetching
|
// The launch acknowledgement is the commit point. Failures while fetching
|
||||||
// observation state after this line must not abort a process that is running.
|
// observation state after this line must not abort a process that is running.
|
||||||
let task_snapshots = fetch_task_snapshots(coordinator, state)?;
|
let mut observation_diagnostics = Vec::new();
|
||||||
let (process_statuses, process_status) = fetch_current_process_status(coordinator, state)?;
|
let inject_post_commit_observation_failure =
|
||||||
|
std::env::var_os("CLUSTERFLUX_TEST_DAP_POST_COMMIT_OBSERVATION_FAILURE").is_some();
|
||||||
|
let task_snapshots = (if inject_post_commit_observation_failure {
|
||||||
|
Err(anyhow!("injected post-commit task observation failure"))
|
||||||
|
} else {
|
||||||
|
fetch_task_snapshots(coordinator, state)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
observation_diagnostics.push(format!(
|
||||||
|
"initial task observation failed after main_launched: {error:#}"
|
||||||
|
));
|
||||||
|
json!({ "snapshots": [] })
|
||||||
|
});
|
||||||
|
let (process_statuses, process_status) = (if inject_post_commit_observation_failure {
|
||||||
|
Err(anyhow!("injected post-commit process observation failure"))
|
||||||
|
} else {
|
||||||
|
fetch_current_process_status(coordinator, state)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
observation_diagnostics.push(format!(
|
||||||
|
"initial process observation failed after main_launched: {error:#}"
|
||||||
|
));
|
||||||
|
(json!({ "processes": [] }), None)
|
||||||
|
});
|
||||||
let node = process_status
|
let node = process_status
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|status| status.get("connected_nodes"))
|
.and_then(|status| status.get("connected_nodes"))
|
||||||
|
|
@ -441,6 +464,7 @@ fn launch_services_debug_entrypoint(
|
||||||
"process_status": process_status,
|
"process_status": process_status,
|
||||||
"process_statuses": process_statuses,
|
"process_statuses": process_statuses,
|
||||||
"task_snapshots": task_snapshots,
|
"task_snapshots": task_snapshots,
|
||||||
|
"observation_diagnostics": observation_diagnostics,
|
||||||
}),
|
}),
|
||||||
task_events: json!({ "events": [] }),
|
task_events: json!({ "events": [] }),
|
||||||
placed_task_launched: true,
|
placed_task_launched: true,
|
||||||
|
|
@ -1002,6 +1026,12 @@ pub(crate) fn observe_services_runtime(
|
||||||
let inject_connection_loss =
|
let inject_connection_loss =
|
||||||
std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1");
|
std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1");
|
||||||
let mut connection_loss_injected = false;
|
let mut connection_loss_injected = false;
|
||||||
|
let inject_fallback_failure =
|
||||||
|
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE").is_some();
|
||||||
|
let mut fallback_failure_stage = 0_u8;
|
||||||
|
let inject_debug_epoch_wait_failure =
|
||||||
|
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_DEBUG_EPOCH_WAIT_FAILURE").is_some();
|
||||||
|
let mut debug_epoch_wait_failure_injected = false;
|
||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Acquire) {
|
if cancelled.load(Ordering::Acquire) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -1191,7 +1221,11 @@ pub(crate) fn observe_services_runtime(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let breakpoint = match current_session.request(client_user_request(
|
let breakpoint_request = if inject_fallback_failure && fallback_failure_stage == 0 {
|
||||||
|
fallback_failure_stage = 1;
|
||||||
|
Err(anyhow!("injected breakpoint inspection transport failure"))
|
||||||
|
} else {
|
||||||
|
current_session.request(client_user_request(
|
||||||
state,
|
state,
|
||||||
json!({
|
json!({
|
||||||
"type": "inspect_debug_breakpoints",
|
"type": "inspect_debug_breakpoints",
|
||||||
|
|
@ -1200,7 +1234,9 @@ pub(crate) fn observe_services_runtime(
|
||||||
"actor_user": state.actor_user,
|
"actor_user": state.actor_user,
|
||||||
"process": state.process.as_str(),
|
"process": state.process.as_str(),
|
||||||
}),
|
}),
|
||||||
)) {
|
))
|
||||||
|
};
|
||||||
|
let breakpoint = match breakpoint_request {
|
||||||
Ok(breakpoint) => breakpoint,
|
Ok(breakpoint) => breakpoint,
|
||||||
Err(inspect_error) => {
|
Err(inspect_error) => {
|
||||||
// Main completion records its terminal event and clears ephemeral
|
// Main completion records its terminal event and clears ephemeral
|
||||||
|
|
@ -1208,7 +1244,11 @@ pub(crate) fn observe_services_runtime(
|
||||||
// the event read above and this inspection request. Re-read the
|
// the event read above and this inspection request. Re-read the
|
||||||
// durable event stream before treating the missing debug state as
|
// durable event stream before treating the missing debug state as
|
||||||
// an adapter failure.
|
// an adapter failure.
|
||||||
let events = current_session.request(client_user_request(
|
let fallback_request = if inject_fallback_failure && fallback_failure_stage == 1 {
|
||||||
|
fallback_failure_stage = 2;
|
||||||
|
Err(anyhow!("injected fallback event transport failure"))
|
||||||
|
} else {
|
||||||
|
current_session.request(client_user_request(
|
||||||
state,
|
state,
|
||||||
json!({
|
json!({
|
||||||
"type": "list_task_events",
|
"type": "list_task_events",
|
||||||
|
|
@ -1217,7 +1257,23 @@ pub(crate) fn observe_services_runtime(
|
||||||
"actor_user": state.actor_user,
|
"actor_user": state.actor_user,
|
||||||
"process": state.process.as_str(),
|
"process": state.process.as_str(),
|
||||||
}),
|
}),
|
||||||
))?;
|
))
|
||||||
|
};
|
||||||
|
let events = match fallback_request {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(fallback_error) => {
|
||||||
|
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
|
||||||
|
"runtime breakpoint and fallback event observation failed: {inspect_error:#}; {fallback_error:#}; reconnecting in {} ms",
|
||||||
|
reconnect_delay.as_millis()
|
||||||
|
))) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
session = None;
|
||||||
|
std::thread::sleep(reconnect_delay);
|
||||||
|
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
|
if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
|
||||||
emit(outcome);
|
emit(outcome);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -1236,7 +1292,28 @@ pub(crate) fn observe_services_runtime(
|
||||||
};
|
};
|
||||||
if let Some(epoch) = breakpoint.get("hit_epoch").and_then(Value::as_u64) {
|
if let Some(epoch) = breakpoint.get("hit_epoch").and_then(Value::as_u64) {
|
||||||
if epoch > previous_debug_epoch {
|
if epoch > previous_debug_epoch {
|
||||||
let frozen = wait_for_debug_epoch_state_at(&coordinator, state, epoch, true)?;
|
let frozen_request =
|
||||||
|
if inject_debug_epoch_wait_failure && !debug_epoch_wait_failure_injected {
|
||||||
|
debug_epoch_wait_failure_injected = true;
|
||||||
|
Err(anyhow!("injected Debug Epoch wait transport failure"))
|
||||||
|
} else {
|
||||||
|
wait_for_debug_epoch_state_at(&coordinator, state, epoch, true)
|
||||||
|
};
|
||||||
|
let frozen = match frozen_request {
|
||||||
|
Ok(frozen) => frozen,
|
||||||
|
Err(error) => {
|
||||||
|
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
|
||||||
|
"runtime Debug Epoch observation failed: {error:#}; reconnecting in {} ms",
|
||||||
|
reconnect_delay.as_millis()
|
||||||
|
))) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
session = None;
|
||||||
|
std::thread::sleep(reconnect_delay);
|
||||||
|
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let node = frozen
|
let node = frozen
|
||||||
.get("acknowledgements")
|
.get("acknowledgements")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ pub(crate) struct AdapterState {
|
||||||
pub(crate) debug_probes: Vec<BundleDebugProbe>,
|
pub(crate) debug_probes: Vec<BundleDebugProbe>,
|
||||||
pub(crate) breakpoints: Vec<i64>,
|
pub(crate) breakpoints: Vec<i64>,
|
||||||
pub(crate) breakpoints_installed: bool,
|
pub(crate) breakpoints_installed: bool,
|
||||||
|
pub(crate) breakpoint_revision: u64,
|
||||||
pub(crate) threads: BTreeMap<i64, VirtualThread>,
|
pub(crate) threads: BTreeMap<i64, VirtualThread>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +134,7 @@ impl Default for AdapterState {
|
||||||
debug_probes: Vec::new(),
|
debug_probes: Vec::new(),
|
||||||
breakpoints: Vec::new(),
|
breakpoints: Vec::new(),
|
||||||
breakpoints_installed: false,
|
breakpoints_installed: false,
|
||||||
|
breakpoint_revision: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,18 +200,24 @@ impl AdapterState {
|
||||||
}
|
}
|
||||||
self.coordinator_endpoint =
|
self.coordinator_endpoint =
|
||||||
crate::view_state::normalize_coordinator_endpoint(&session.coordinator);
|
crate::view_state::normalize_coordinator_endpoint(&session.coordinator);
|
||||||
self.tenant = TenantId::new(session.tenant);
|
self.tenant = TenantId::try_new(session.tenant)
|
||||||
self.project_id = ProjectId::new(session.project);
|
.map_err(|error| anyhow!("invalid tenant in CLI session: {error}"))?;
|
||||||
self.actor_user = UserId::new(session.user);
|
self.project_id = ProjectId::try_new(session.project)
|
||||||
|
.map_err(|error| anyhow!("invalid project in CLI session: {error}"))?;
|
||||||
|
self.actor_user = UserId::try_new(session.user)
|
||||||
|
.map_err(|error| anyhow!("invalid user in CLI session: {error}"))?;
|
||||||
self.client_session_secret = Some(session.session_secret);
|
self.client_session_secret = Some(session.session_secret);
|
||||||
} else {
|
} else {
|
||||||
self.coordinator_endpoint = crate::view_state::normalize_coordinator_endpoint(
|
self.coordinator_endpoint = crate::view_state::normalize_coordinator_endpoint(
|
||||||
coordinator_endpoint.as_deref().unwrap_or("127.0.0.1:0"),
|
coordinator_endpoint.as_deref().unwrap_or("127.0.0.1:0"),
|
||||||
);
|
);
|
||||||
if let Some(scope) = project_scope {
|
if let Some(scope) = project_scope {
|
||||||
self.tenant = TenantId::new(scope.tenant);
|
self.tenant = TenantId::try_new(scope.tenant)
|
||||||
self.project_id = ProjectId::new(scope.project);
|
.map_err(|error| anyhow!("invalid tenant in project scope: {error}"))?;
|
||||||
self.actor_user = UserId::new(scope.user);
|
self.project_id = ProjectId::try_new(scope.project)
|
||||||
|
.map_err(|error| anyhow!("invalid project in project scope: {error}"))?;
|
||||||
|
self.actor_user = UserId::try_new(scope.user)
|
||||||
|
.map_err(|error| anyhow!("invalid user in project scope: {error}"))?;
|
||||||
}
|
}
|
||||||
self.client_session_secret = None;
|
self.client_session_secret = None;
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +238,7 @@ impl AdapterState {
|
||||||
self.epoch = 0;
|
self.epoch = 0;
|
||||||
self.breakpoints.clear();
|
self.breakpoints.clear();
|
||||||
self.breakpoints_installed = self.runtime_backend == RuntimeBackend::Simulated;
|
self.breakpoints_installed = self.runtime_backend == RuntimeBackend::Simulated;
|
||||||
|
self.breakpoint_revision = 0;
|
||||||
self.threads = if self.runtime_backend == RuntimeBackend::Simulated {
|
self.threads = if self.runtime_backend == RuntimeBackend::Simulated {
|
||||||
launch_threads(&self.entry)
|
launch_threads(&self.entry)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ use std::time::{Duration, Instant};
|
||||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||||
use clusterflux_core::{
|
use clusterflux_core::{
|
||||||
sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId,
|
sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId,
|
||||||
TaskSpec, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
|
ProcessId, ProjectId, RequestId, TaskInstanceId, TaskSpec, TenantId,
|
||||||
|
MIN_SIGNED_NODE_POLL_INTERVAL_MS,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
|
@ -222,7 +223,7 @@ fn worker_loop(
|
||||||
let expiry = Instant::now()
|
let expiry = Instant::now()
|
||||||
.checked_add(Duration::from_secs(retention_limits.restart_pin_seconds))
|
.checked_add(Duration::from_secs(retention_limits.restart_pin_seconds))
|
||||||
.unwrap_or_else(Instant::now);
|
.unwrap_or_else(Instant::now);
|
||||||
restart_pins.insert(ArtifactId::new(artifact), expiry);
|
restart_pins.insert(ArtifactId::try_new(artifact.to_owned())?, expiry);
|
||||||
}
|
}
|
||||||
println!("{}", serde_json::to_string(&report)?);
|
println!("{}", serde_json::to_string(&report)?);
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
|
|
@ -289,7 +290,7 @@ fn service_pending_artifact_transfer(
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let transfer_id = required_string(transfer, "transfer_id")?;
|
let transfer_id = required_string(transfer, "transfer_id")?;
|
||||||
let artifact = ArtifactId::new(required_string(transfer, "artifact")?);
|
let artifact = ArtifactId::try_new(required_string(transfer, "artifact")?)?;
|
||||||
let expected_digest: Digest = serde_json::from_value(
|
let expected_digest: Digest = serde_json::from_value(
|
||||||
transfer
|
transfer
|
||||||
.get("expected_digest")
|
.get("expected_digest")
|
||||||
|
|
@ -369,9 +370,11 @@ pub(crate) fn runtime_task_from_assignment(
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or("task assignment missing task_spec")?,
|
.ok_or("task assignment missing task_spec")?,
|
||||||
)?;
|
)?;
|
||||||
|
let process = ProcessId::try_new(required_string(value, "process")?)?;
|
||||||
|
let task = TaskInstanceId::try_new(required_string(value, "task")?)?;
|
||||||
Ok(RuntimeTask {
|
Ok(RuntimeTask {
|
||||||
process: required_string(value, "process")?,
|
process: process.to_string(),
|
||||||
task: required_string(value, "task")?,
|
task: task.to_string(),
|
||||||
epoch: value.get("epoch").and_then(Value::as_u64),
|
epoch: value.get("epoch").and_then(Value::as_u64),
|
||||||
bundle_digest: task_spec.bundle_digest.clone(),
|
bundle_digest: task_spec.bundle_digest.clone(),
|
||||||
task_spec: Some(task_spec),
|
task_spec: Some(task_spec),
|
||||||
|
|
@ -570,6 +573,12 @@ fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TenantId::try_new(tenant.clone())?;
|
||||||
|
ProjectId::try_new(project.clone())?;
|
||||||
|
NodeId::try_new(node.clone())?;
|
||||||
|
if let Some(grant) = enrollment_grant.as_ref() {
|
||||||
|
RequestId::try_new(grant.clone())?;
|
||||||
|
}
|
||||||
Ok(Args {
|
Ok(Args {
|
||||||
coordinator: coordinator.ok_or("--coordinator is required")?,
|
coordinator: coordinator.ok_or("--coordinator is required")?,
|
||||||
tenant,
|
tenant,
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,163 @@ function sendHostedControl(payload) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMalformedIdentifierSuite() {
|
||||||
|
const scenarioStartedAt = Date.now();
|
||||||
|
const forms = [
|
||||||
|
{ name: "empty", value: "" },
|
||||||
|
{ name: "whitespace", value: " \t" },
|
||||||
|
{ name: "control", value: "hostile\u0000id" },
|
||||||
|
{ name: "invalid_format", value: "hostile id!" },
|
||||||
|
{ name: "oversized", value: "x".repeat(256) },
|
||||||
|
];
|
||||||
|
const valid = {
|
||||||
|
tenant: "strict-tenant",
|
||||||
|
project: "strict-project",
|
||||||
|
user: "strict-user",
|
||||||
|
agent: "strict-agent",
|
||||||
|
node: "strict-node",
|
||||||
|
process: "strict-process",
|
||||||
|
task: "strict-task",
|
||||||
|
taskDefinition: "strict-task-definition",
|
||||||
|
artifact: "strict-artifact",
|
||||||
|
launchAttempt: "strict-launch-attempt",
|
||||||
|
};
|
||||||
|
const principals = [
|
||||||
|
{
|
||||||
|
name: "tenant",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "auth_status",
|
||||||
|
tenant: value,
|
||||||
|
actor_user: valid.user,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "list_processes",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: value,
|
||||||
|
actor_user: valid.user,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "auth_status",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
actor_user: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "agent",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "start_process",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_agent: value,
|
||||||
|
process: valid.process,
|
||||||
|
restart: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "node",
|
||||||
|
payload: (value) => ({ type: "node_heartbeat", node: value }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "process",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_user: valid.user,
|
||||||
|
process: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "task_instance",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "abort_task",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_user: valid.user,
|
||||||
|
process: valid.process,
|
||||||
|
task: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "task_definition",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "launch_task",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_user: valid.user,
|
||||||
|
process: valid.process,
|
||||||
|
task_spec: {
|
||||||
|
task_definition: value,
|
||||||
|
task_instance: valid.task,
|
||||||
|
environment: "linux",
|
||||||
|
dependencies: [],
|
||||||
|
required_artifacts: [],
|
||||||
|
arguments: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artifact",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "fetch_artifact",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_user: valid.user,
|
||||||
|
artifact: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "launch_attempt",
|
||||||
|
payload: (value) => ({
|
||||||
|
type: "launch_main_runtime",
|
||||||
|
tenant: valid.tenant,
|
||||||
|
project: valid.project,
|
||||||
|
actor_user: valid.user,
|
||||||
|
process: valid.process,
|
||||||
|
launch_attempt: value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rejected = [];
|
||||||
|
for (const principal of principals) {
|
||||||
|
for (const form of forms) {
|
||||||
|
const response = await sendHostedControl(principal.payload(form.value));
|
||||||
|
assertDenied(
|
||||||
|
response,
|
||||||
|
/identifier|invalid|empty|whitespace|control|format|255|length/i,
|
||||||
|
`${principal.name} ${form.name}`
|
||||||
|
);
|
||||||
|
const health = await sendHostedControl({ type: "ping" });
|
||||||
|
assert.strictEqual(
|
||||||
|
health.type,
|
||||||
|
"pong",
|
||||||
|
`valid traffic failed after hostile ${principal.name} ${form.name}`
|
||||||
|
);
|
||||||
|
rejected.push({
|
||||||
|
principal: principal.name,
|
||||||
|
form: form.name,
|
||||||
|
rejected: true,
|
||||||
|
health_after: health.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
principals: principals.map((principal) => principal.name),
|
||||||
|
forms: forms.map((form) => form.name),
|
||||||
|
rejected_requests: rejected,
|
||||||
|
valid_after_every_rejection: rejected.every(
|
||||||
|
(entry) => entry.health_after === "pong"
|
||||||
|
),
|
||||||
|
duration_ms: Date.now() - scenarioStartedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sendHostedControlDroppingResponse(payload) {
|
function sendHostedControlDroppingResponse(payload) {
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify(coordinatorWireRequest(payload, "strict-live-dropped-response"))
|
JSON.stringify(coordinatorWireRequest(payload, "strict-live-dropped-response"))
|
||||||
|
|
@ -1225,8 +1382,30 @@ async function runLiveAgentCredentialSecurity({
|
||||||
await debugClient.waitFor(
|
await debugClient.waitFor(
|
||||||
(message) => message.type === "event" && message.event === "initialized"
|
(message) => message.type === "event" && message.event === "initialized"
|
||||||
);
|
);
|
||||||
|
const attachSourcePath = path.join(projectDir, "src/lib.rs");
|
||||||
|
const attachBreakpointRequest = debugClient.send("setBreakpoints", {
|
||||||
|
source: { path: attachSourcePath },
|
||||||
|
breakpoints: [{ line: 1 }],
|
||||||
|
});
|
||||||
|
const attachBreakpointResponse = await debugClient.response(
|
||||||
|
attachBreakpointRequest,
|
||||||
|
"setBreakpoints"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
attachBreakpointResponse.body.breakpoints[0].verified,
|
||||||
|
false,
|
||||||
|
"attach breakpoint was verified before runtime installation"
|
||||||
|
);
|
||||||
const configurationDone = debugClient.send("configurationDone");
|
const configurationDone = debugClient.send("configurationDone");
|
||||||
await debugClient.response(configurationDone, "configurationDone");
|
await debugClient.response(configurationDone, "configurationDone");
|
||||||
|
const attachBreakpointInstalled = await debugClient.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "breakpoint" &&
|
||||||
|
message.body?.breakpoint?.verified === true,
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
assert.strictEqual(attachBreakpointInstalled.body.breakpoint.line, 1);
|
||||||
const attachDeadline = Date.now() + 120_000;
|
const attachDeadline = Date.now() + 120_000;
|
||||||
let attachedThreads = [];
|
let attachedThreads = [];
|
||||||
while (Date.now() < attachDeadline) {
|
while (Date.now() < attachDeadline) {
|
||||||
|
|
@ -2744,6 +2923,7 @@ async function runSameDefinitionDapIdentity({
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
|
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
|
||||||
|
CLUSTERFLUX_TEST_DAP_POST_COMMIT_OBSERVATION_FAILURE: "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let disconnected = false;
|
let disconnected = false;
|
||||||
|
|
@ -2987,6 +3167,8 @@ async function runLiveDapEditRestart({
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
|
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
|
||||||
|
CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE: "1",
|
||||||
|
CLUSTERFLUX_TEST_DAP_OBSERVER_DEBUG_EPOCH_WAIT_FAILURE: "1",
|
||||||
CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE: "1",
|
CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE: "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -3336,6 +3518,8 @@ async function runLiveDapEditRestart({
|
||||||
breakpoint_install_failure_reported: true,
|
breakpoint_install_failure_reported: true,
|
||||||
observer_idle_request_rate_bound_per_second: 0.8,
|
observer_idle_request_rate_bound_per_second: 0.8,
|
||||||
observer_reconnected_without_false_stop: true,
|
observer_reconnected_without_false_stop: true,
|
||||||
|
observer_fallback_reconnected: true,
|
||||||
|
observer_debug_epoch_wait_reconnected: true,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (!sourceRestored) fs.writeFileSync(sourcePath, originalSource);
|
if (!sourceRestored) fs.writeFileSync(sourcePath, originalSource);
|
||||||
|
|
@ -4298,6 +4482,7 @@ async function main() {
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
suffix,
|
suffix,
|
||||||
});
|
});
|
||||||
|
const malformedIdentifiers = await runMalformedIdentifierSuite();
|
||||||
const hostedLoginIsolation = await runHostedLoginIsolationBeforeRestart();
|
const hostedLoginIsolation = await runHostedLoginIsolationBeforeRestart();
|
||||||
const serviceRestart = await restartHostedServiceAndResume({
|
const serviceRestart = await restartHostedServiceAndResume({
|
||||||
clusterflux,
|
clusterflux,
|
||||||
|
|
@ -4349,32 +4534,38 @@ async function main() {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
assert(concurrentCompileTasks.length >= 2);
|
assert(concurrentCompileTasks.length >= 2);
|
||||||
|
const qualityPassed =
|
||||||
|
qualityGates?.private.status === "passed" &&
|
||||||
|
qualityGates?.public.status === "passed";
|
||||||
|
const extensionAsset = manifest.assets.find((asset) =>
|
||||||
|
asset.name.endsWith(".vsix")
|
||||||
|
);
|
||||||
const strictRequirementLedger = [
|
const strictRequirementLedger = [
|
||||||
{ id: "01_authenticated_project", passed: Boolean(sessionSecret && tenant && project) },
|
{ id: "01_formatting", passed: qualityPassed },
|
||||||
{ id: "02_project_commands", passed: projectList.project_count >= 1 && projectSelect.command === "project select" },
|
{ id: "02_clippy_warnings_denied", passed: qualityPassed },
|
||||||
{ id: "03_bundle_environment_inspection", passed: inspection.metadata.environments.some((environment) => environment.name === "linux") },
|
{ id: "03_public_workspace_tests", passed: qualityGates?.public.status === "passed" },
|
||||||
{ id: "04_launch_attempt_ownership_and_main_parking", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" && launchAttemptOwnership.existing_process_survived === true },
|
{ id: "04_private_hosted_policy_locked_tests", passed: qualityGates?.private.status === "passed" },
|
||||||
{ id: "05_enrollment_and_persisted_credential", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode },
|
{ id: "05_wasm_example_builds", passed: qualityPassed && helloBuild.executable_output === "hello from a real Clusterflux build" && recoveryBuild.original_join_completed === true },
|
||||||
{ id: "06_server_derived_node_liveness", passed: nodeLivenessTransition.initial_online === true && nodeLivenessTransition.stale_offline === false && nodeLivenessTransition.recovered_online === true },
|
{ id: "06_filtered_public_tree_build_and_tests", passed: qualityGates?.public.status === "passed" && manifest.source_tree_clean === true },
|
||||||
{ id: "07_real_flagship_and_artifact", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" },
|
{ id: "07_final_vsix_checks", passed: Boolean(extensionAsset && manifest.release_candidate.extension_sha256 && extensionAsset.sha256 === manifest.release_candidate.extension_sha256) },
|
||||||
{ id: "08_same_definition_concurrency", passed: sameDefinitionIdentity.thread_evidence.length === 2 && sameDefinitionIdentity.completion_order.length === 2 && sameDefinitionIdentity.podman_paused_container_ids.length >= 2 && sameDefinitionIdentity.terminal_state === "not_active" },
|
{ id: "08_browser_login_and_persistent_default_project", passed: Boolean(sessionSecret && tenant && project && projectSelect.command === "project select") },
|
||||||
{ id: "09_repeated_park_wake", passed: repeatedParkWake.completed_cycles >= 16 && repeatedParkWake.terminal_state === "not_active" },
|
{ id: "09_one_time_node_enrollment_and_restart", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode && serviceRestart.executed === true },
|
||||||
{ id: "10_nested_environment_rejection", passed: agentCredentialSecurity.nested_environment_mismatch.denied === true },
|
{ id: "10_real_hello_build_and_artifact_download", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" && download.local_download.bytes_written > 0 },
|
||||||
{ id: "11_partial_freeze_warning_resume", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.dap_all_threads_stopped === false && agentCredentialSecurity.partial_freeze.podman_paused_container_ids.length > 0 && agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 },
|
{ id: "11_recovery_retry_and_original_join", passed: recoveryBuild.original_join_completed === true && recoveryBuild.original_attempt !== recoveryBuild.replacement_attempt && recoveryBuild.terminal_state === "not_active" },
|
||||||
{ id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true && dapEditRestart.breakpoint_install_failure_reported === true },
|
{ id: "12_dap_launch_without_breakpoint", passed: sameDefinitionIdentity.thread_evidence.length === 2 && sameDefinitionIdentity.terminal_state === "not_active" },
|
||||||
{ id: "13_long_join_and_process_lifecycle", passed: longJoin.duration_seconds > 120 && longJoin.terminal_state === "completed" && releasedFlagshipStatus.state === "not_active" && restart.restart_request.accepted === true && cancel.cancel_request.accepted === true },
|
{ id: "13_real_breakpoint_installation_and_hit", passed: dapEditRestart.breakpoint_install_failure_reported === true && dapEditRestart.clean_vfs_boundary_used === true },
|
||||||
{ id: "14_tenant_isolation", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true },
|
{ id: "14_attach_with_preconfigured_breakpoint", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true },
|
||||||
{ id: "15_user_credential_hostility", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) },
|
{ id: "15_continue_pause_inspect_disconnect", passed: agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 && dapEditRestart.observer_reconnected_without_false_stop === true },
|
||||||
{ id: "16_node_credential_hostility", passed: securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true },
|
{ id: "16_distinct_same_definition_instances", passed: sameDefinitionIdentity.thread_evidence.length === 2 && sameDefinitionIdentity.completion_order.length === 2 && concurrentCompileTasks.length >= 2 },
|
||||||
{ id: "17_agent_workflow_and_credentials", passed: agentCredentialSecurity.workflow.flagship_completed === true && agentCredentialSecurity.workflow.authenticated_without_browser === true && agentCredentialSecurity.workflow.external_direct_task_v1.denied === true && agentCredentialSecurity.workflow.child_launch === "task_launched" && agentCredentialSecurity.revoked.denied === true },
|
{ id: "17_real_podman_partial_debug_epoch", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.dap_all_threads_stopped === false && agentCredentialSecurity.partial_freeze.podman_paused_container_ids.length > 0 },
|
||||||
{ id: "18_dropped_response_rollback", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process with matching launch_attempt" && droppedConnectionRollback.terminal_state === "not_active" },
|
{ id: "18_post_main_launched_observation_recovery", passed: sameDefinitionIdentity.thread_evidence.length === 2 && sameDefinitionIdentity.terminal_state === "not_active" },
|
||||||
{ id: "19_quota_and_bandwidth_preallocation", passed: quotaPreallocation.denied_process_allocated === false && quotaPreallocation.unrelated_tenant.unaffected_by_first_tenant_quota === true && bandwidthPreallocation.bytes_served_before_denial > 0 && bandwidthPreallocation.partial_abandon.ingress_delta > 0 && bandwidthPreallocation.partial_abandon.egress_delta > 0 && bandwidthPreallocation.partial_abandon.abandoned_delta > 0 && bandwidthPreallocation.partial_abandon.reservation_released === true && bandwidthPreallocation.restart_persistence === true && relayEmergencyDisable.disabled.denied === true && relayEmergencyDisable.runtime_drop_in_removed === true },
|
{ id: "19_fallback_and_debug_epoch_observer_reconnect", passed: dapEditRestart.observer_fallback_reconnected === true && dapEditRestart.observer_debug_epoch_wait_reconnected === true && dapEditRestart.observer_reconnected_without_false_stop === true },
|
||||||
{ id: "20_hosted_login_isolation", passed: hostedLoginIsolation.local_limited_status === 429 && hostedLoginIsolation.forwarding_spoof_status === 429 && hostedLoginIsolation.independent_vps_client_status === 200 && hostedLoginIsolation.control_route_after === "pong" && hostedLoginIsolation.after_service_restart.control_route === "pong" },
|
{ id: "20_existing_process_rejection_preserves_process", passed: launchAttemptOwnership.existing_process_survived === true },
|
||||||
{ id: "21_soak_restart_and_provenance", passed: liveSoak.executed === true && serviceRestart.executed === true && manifest.source_tree_clean === true && configProvenance.clean === true },
|
{ id: "21_precommit_launch_failure_scoped_cleanup", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process with matching launch_attempt" && droppedConnectionRollback.terminal_state === "not_active" },
|
||||||
{ id: "22_quality_gates", passed: qualityGates?.private.status === "passed" && qualityGates?.public.status === "passed" },
|
{ id: "22_malformed_identifiers_preserve_service", passed: malformedIdentifiers.valid_after_every_rejection === true && malformedIdentifiers.rejected_requests.length === malformedIdentifiers.principals.length * malformedIdentifiers.forms.length },
|
||||||
{ id: "23_hosted_hello_build", passed: helloBuild.executable_output === "hello from a real Clusterflux build" && helloBuild.terminal_state === "not_active" },
|
{ id: "23_cross_tenant_access_denied", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true },
|
||||||
{ id: "24_hosted_recovery_build", passed: recoveryBuild.original_join_completed === true && recoveryBuild.original_attempt !== recoveryBuild.replacement_attempt && recoveryBuild.terminal_state === "not_active" },
|
{ id: "24_forged_replayed_expired_revoked_credentials_denied", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) && securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true && agentCredentialSecurity.revoked.denied === true },
|
||||||
{ id: "25_observer_reconnect", passed: dapEditRestart.observer_reconnected_without_false_stop === true && dapEditRestart.observer_idle_request_rate_bound_per_second <= 0.8 },
|
{ id: "25_relay_limits_and_abandoned_transfer_charging", passed: quotaPreallocation.denied_process_allocated === false && quotaPreallocation.unrelated_tenant.unaffected_by_first_tenant_quota === true && bandwidthPreallocation.bytes_served_before_denial > 0 && bandwidthPreallocation.partial_abandon.ingress_delta > 0 && bandwidthPreallocation.partial_abandon.egress_delta > 0 && bandwidthPreallocation.partial_abandon.abandoned_delta > 0 && bandwidthPreallocation.partial_abandon.reservation_released === true && bandwidthPreallocation.restart_persistence === true && relayEmergencyDisable.disabled.denied === true && relayEmergencyDisable.runtime_drop_in_removed === true },
|
||||||
];
|
];
|
||||||
const fullReleasePassed =
|
const fullReleasePassed =
|
||||||
strictFullRelease &&
|
strictFullRelease &&
|
||||||
|
|
@ -4481,6 +4672,7 @@ async function main() {
|
||||||
quality_gates: qualityGates,
|
quality_gates: qualityGates,
|
||||||
hello_build: helloBuild,
|
hello_build: helloBuild,
|
||||||
recovery_build: recoveryBuild,
|
recovery_build: recoveryBuild,
|
||||||
|
malformed_identifiers: malformedIdentifiers,
|
||||||
named_scenarios: namedScenarios,
|
named_scenarios: namedScenarios,
|
||||||
scenario_skips: [],
|
scenario_skips: [],
|
||||||
strict_requirement_ledger: strictRequirementLedger,
|
strict_requirement_ledger: strictRequirementLedger,
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,13 @@ const hostedProtocol = maybeRead([
|
||||||
]);
|
]);
|
||||||
const hostedService =
|
const hostedService =
|
||||||
hostedServiceMain && hostedValidation && hostedWire && hostedProtocol
|
hostedServiceMain && hostedValidation && hostedWire && hostedProtocol
|
||||||
? [hostedServiceMain, hostedValidation, hostedWire, hostedProtocol].join("\n")
|
? [
|
||||||
|
hostedServiceMain,
|
||||||
|
hostedValidation,
|
||||||
|
hostedWire,
|
||||||
|
hostedProtocol,
|
||||||
|
maybeRead(["crates", "clusterflux-core", "src", "ids.rs"]),
|
||||||
|
].join("\n")
|
||||||
: null;
|
: null;
|
||||||
const hostedTests = [
|
const hostedTests = [
|
||||||
maybeRead(["private", "hosted-policy", "src", "bin", "clusterflux-hosted-service", "tests.rs"]),
|
maybeRead(["private", "hosted-policy", "src", "bin", "clusterflux-hosted-service", "tests.rs"]),
|
||||||
|
|
@ -159,10 +165,10 @@ const hostedTests = [
|
||||||
if (hostedService && hostedTests) {
|
if (hostedService && hostedTests) {
|
||||||
for (const [name, pattern] of [
|
for (const [name, pattern] of [
|
||||||
["hosted service turns malformed JSON into error responses", /decode_incoming_request[\s\S]*HostedServiceResponse::Error/],
|
["hosted service turns malformed JSON into error responses", /decode_incoming_request[\s\S]*HostedServiceResponse::Error/],
|
||||||
["tenant ids are validated", /fn tenant_id\(value: String\)[\s\S]*validate_identifier\("tenant", &value\)\?/],
|
["tenant ids are validated", /fn tenant_id\(value: String\)[\s\S]*TenantId::try_new\(value\)/],
|
||||||
["node ids are validated", /fn node_id\(value: String\)[\s\S]*validate_identifier\("node", &value\)\?/],
|
["node ids are validated", /fn node_id\(value: String\)[\s\S]*NodeId::try_new\(value\)/],
|
||||||
["process ids are validated", /fn process_id\(value: String\)[\s\S]*validate_identifier\("process", &value\)\?/],
|
["process ids are validated", /fn process_id\(value: String\)[\s\S]*ProcessId::try_new\(value\)/],
|
||||||
["identifiers reject empty and control/path characters", /fn validate_identifier[\s\S]*trim\(\)\.is_empty\(\)[\s\S]*ch\.is_control\(\) \|\| ch == '\/' \|\| ch == '\\\\'/],
|
["identifiers reject empty, oversized, control, and invalid format values", /MAX_EXTERNAL_ID_BYTES[\s\S]*trim\(\)\.is_empty\(\)[\s\S]*char::is_control[\s\S]*is_ascii_alphanumeric\(\)/],
|
||||||
["OIDC text fields are bounded", /fn validate_text[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
["OIDC text fields are bounded", /fn validate_text[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||||
["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||||
["control request bodies are bounded", /MAX_CONTROL_FRAME_BYTES[\s\S]*control request too large/],
|
["control request bodies are bounded", /MAX_CONTROL_FRAME_BYTES[\s\S]*control request too large/],
|
||||||
|
|
|
||||||
163
scripts/prepare-manual-github-release.js
Executable file
163
scripts/prepare-manual-github-release.js
Executable file
|
|
@ -0,0 +1,163 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const releaseRoot = path.resolve(
|
||||||
|
process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-final")
|
||||||
|
);
|
||||||
|
const manifestPath = path.join(releaseRoot, "public-release-manifest.json");
|
||||||
|
const resultPath = path.resolve(
|
||||||
|
process.env.CLUSTERFLUX_FINAL_RESULT ||
|
||||||
|
path.join(repo, "target/acceptance/cli-happy-path-live.json")
|
||||||
|
);
|
||||||
|
const transcriptPath = path.resolve(
|
||||||
|
process.env.CLUSTERFLUX_VALIDATION_TRANSCRIPT ||
|
||||||
|
path.join(repo, "target/acceptance/clusterflux-manual-launch-transcript.log")
|
||||||
|
);
|
||||||
|
|
||||||
|
function sha256(file) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(fs.readFileSync(file));
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(file, contents) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVerifiedAsset(asset, destinationRoot) {
|
||||||
|
const source = path.resolve(releaseRoot, asset.file);
|
||||||
|
assert(fs.existsSync(source), `missing finalized asset ${source}`);
|
||||||
|
assert.strictEqual(
|
||||||
|
sha256(source),
|
||||||
|
asset.sha256,
|
||||||
|
`finalized asset digest changed: ${asset.name}`
|
||||||
|
);
|
||||||
|
const destination = path.join(destinationRoot, "assets", asset.name);
|
||||||
|
fs.copyFileSync(source, destination);
|
||||||
|
assert.strictEqual(sha256(destination), asset.sha256);
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBinaries(archive, destinationRoot) {
|
||||||
|
const staging = path.join(destinationRoot, ".binary-extract");
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
const extracted = spawnSync("tar", ["-xzf", archive, "-C", staging], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (extracted.status !== 0) {
|
||||||
|
throw new Error(`failed to extract ${archive}: ${extracted.stderr}`);
|
||||||
|
}
|
||||||
|
const binRoot = path.join(staging, "bin");
|
||||||
|
assert(fs.existsSync(binRoot), `binary archive omitted bin/: ${archive}`);
|
||||||
|
const copied = [];
|
||||||
|
for (const name of fs.readdirSync(binRoot).sort()) {
|
||||||
|
const source = path.join(binRoot, name);
|
||||||
|
if (!fs.statSync(source).isFile()) continue;
|
||||||
|
const destination = path.join(destinationRoot, "binaries", name);
|
||||||
|
fs.copyFileSync(source, destination);
|
||||||
|
fs.chmodSync(destination, 0o755);
|
||||||
|
copied.push(destination);
|
||||||
|
}
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
assert(copied.length > 0, `binary archive contained no binaries: ${archive}`);
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
assert(fs.existsSync(manifestPath), `missing final manifest ${manifestPath}`);
|
||||||
|
assert(fs.existsSync(resultPath), `missing final result ${resultPath}`);
|
||||||
|
assert(fs.existsSync(transcriptPath), `missing validation transcript ${transcriptPath}`);
|
||||||
|
const manifest = readJson(manifestPath);
|
||||||
|
const result = readJson(resultPath);
|
||||||
|
assert.strictEqual(result.acceptance_result, "passed");
|
||||||
|
assert.strictEqual(result.source_commit, manifest.source_commit);
|
||||||
|
assert.strictEqual(result.source_tree_digest, manifest.source_tree_digest);
|
||||||
|
assert.deepStrictEqual(result.binary_digests, manifest.binary_digests);
|
||||||
|
assert.strictEqual(result.scenario_skips.length, 0);
|
||||||
|
assert.strictEqual(result.strict_requirement_ledger.length, 25);
|
||||||
|
assert(
|
||||||
|
result.strict_requirement_ledger.every((requirement) => requirement.passed === true),
|
||||||
|
"final validation contains a failed launch requirement"
|
||||||
|
);
|
||||||
|
|
||||||
|
const destinationRoot = path.resolve(
|
||||||
|
process.env.CLUSTERFLUX_GITHUB_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/github-release", manifest.release_name)
|
||||||
|
);
|
||||||
|
fs.rmSync(destinationRoot, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(path.join(destinationRoot, "assets"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(destinationRoot, "binaries"), { recursive: true });
|
||||||
|
|
||||||
|
const copiedAssets = manifest.assets.map((asset) =>
|
||||||
|
copyVerifiedAsset(asset, destinationRoot)
|
||||||
|
);
|
||||||
|
const binaryArchive = copiedAssets.find((file) =>
|
||||||
|
path.basename(file).startsWith("clusterflux-public-binaries-")
|
||||||
|
);
|
||||||
|
assert(binaryArchive, "final manifest omitted the public binary archive");
|
||||||
|
const binaries = extractBinaries(binaryArchive, destinationRoot);
|
||||||
|
const vsix = copiedAssets.find((file) => file.endsWith(".vsix"));
|
||||||
|
assert(vsix, "final manifest omitted the tested VSIX");
|
||||||
|
assert.strictEqual(
|
||||||
|
sha256(vsix),
|
||||||
|
manifest.release_candidate.extension_sha256,
|
||||||
|
"tested VSIX digest does not match final candidate binding"
|
||||||
|
);
|
||||||
|
|
||||||
|
write(path.join(destinationRoot, "SOURCE_REVISION"), `${manifest.source_commit}\n`);
|
||||||
|
write(
|
||||||
|
path.join(destinationRoot, "SOURCE_TREE_DIGEST"),
|
||||||
|
`${manifest.source_tree_digest}\n`
|
||||||
|
);
|
||||||
|
write(path.join(destinationRoot, "VSIX_SHA256"), `${sha256(vsix)} ${path.basename(vsix)}\n`);
|
||||||
|
fs.copyFileSync(manifestPath, path.join(destinationRoot, "public-release-manifest.json"));
|
||||||
|
fs.copyFileSync(resultPath, path.join(destinationRoot, "final-result.json"));
|
||||||
|
fs.copyFileSync(transcriptPath, path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log"));
|
||||||
|
write(
|
||||||
|
path.join(destinationRoot, "RELEASE_NOTES.md"),
|
||||||
|
`# Clusterflux ${manifest.release_name}\n\n` +
|
||||||
|
"First manual GitHub launch release of Clusterflux. The release includes the filtered public source, real Linux CLI/node/coordinator binaries, and the exact VSIX used by the final production-shaped validation batch.\n"
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
path.join(destinationRoot, "KNOWN_LIMITATIONS.md"),
|
||||||
|
"# Known limitations\n\n" +
|
||||||
|
"- The launch batch validates one enrolled Linux node with rootless Podman.\n" +
|
||||||
|
"- Full Windows sandbox validation is deferred.\n" +
|
||||||
|
"- Providers, billing, teams, managed compute, and operator-panel UI are not part of this release.\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
const checksummed = [
|
||||||
|
...copiedAssets,
|
||||||
|
...binaries,
|
||||||
|
path.join(destinationRoot, "SOURCE_REVISION"),
|
||||||
|
path.join(destinationRoot, "SOURCE_TREE_DIGEST"),
|
||||||
|
path.join(destinationRoot, "VSIX_SHA256"),
|
||||||
|
path.join(destinationRoot, "public-release-manifest.json"),
|
||||||
|
path.join(destinationRoot, "final-result.json"),
|
||||||
|
path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log"),
|
||||||
|
path.join(destinationRoot, "RELEASE_NOTES.md"),
|
||||||
|
path.join(destinationRoot, "KNOWN_LIMITATIONS.md"),
|
||||||
|
].sort();
|
||||||
|
write(
|
||||||
|
path.join(destinationRoot, "SHA256SUMS"),
|
||||||
|
`${checksummed
|
||||||
|
.map((file) => `${sha256(file)} ${path.relative(destinationRoot, file)}`)
|
||||||
|
.join("\n")}\n`
|
||||||
|
);
|
||||||
|
console.log(destinationRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -513,6 +513,24 @@ function reuseCandidateHostedBinary(candidate, releaseName) {
|
||||||
return archive;
|
return archive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reuseCandidateExtension(candidate) {
|
||||||
|
assertCandidateManifest(candidate);
|
||||||
|
const asset = candidate.assets.find((entry) => entry.name.endsWith(".vsix"));
|
||||||
|
if (!asset || !fs.existsSync(asset.file)) {
|
||||||
|
throw new Error("release candidate VSIX is missing");
|
||||||
|
}
|
||||||
|
const digest = sha256File(asset.file);
|
||||||
|
if (
|
||||||
|
digest !== asset.sha256 ||
|
||||||
|
digest !== candidate.release_candidate.extension_sha256
|
||||||
|
) {
|
||||||
|
throw new Error("release candidate VSIX digest changed after candidate creation");
|
||||||
|
}
|
||||||
|
const archive = path.join(assetsDir, asset.name);
|
||||||
|
fs.copyFileSync(asset.file, archive);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
function assertCandidateManifest(candidate) {
|
function assertCandidateManifest(candidate) {
|
||||||
if (candidate.kind !== "clusterflux-public-release") {
|
if (candidate.kind !== "clusterflux-public-release") {
|
||||||
throw new Error("release candidate manifest has the wrong kind");
|
throw new Error("release candidate manifest has the wrong kind");
|
||||||
|
|
@ -529,6 +547,9 @@ function assertCandidateManifest(candidate) {
|
||||||
if (!candidate.release_candidate.hosted_binary_archive_sha256) {
|
if (!candidate.release_candidate.hosted_binary_archive_sha256) {
|
||||||
throw new Error("release candidate omitted the hosted service archive digest");
|
throw new Error("release candidate omitted the hosted service archive digest");
|
||||||
}
|
}
|
||||||
|
if (!candidate.release_candidate.extension_sha256) {
|
||||||
|
throw new Error("release candidate omitted the VSIX digest");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageEvidenceAsset(
|
function stageEvidenceAsset(
|
||||||
|
|
@ -735,26 +756,30 @@ function stageSourceAsset(releaseName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageExtensionAsset() {
|
function stageExtensionAsset() {
|
||||||
|
const extensionRoot = path.join(publicTree, "vscode-extension");
|
||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
fs.readFileSync(path.join(publicTree, "vscode-extension/package.json"), "utf8")
|
fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8")
|
||||||
);
|
);
|
||||||
const archive = path.join(
|
const archive = path.join(
|
||||||
assetsDir,
|
assetsDir,
|
||||||
`${packageJson.name}-${packageJson.version}.vsix`
|
`${packageJson.name}-${packageJson.version}.vsix`
|
||||||
);
|
);
|
||||||
|
run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
||||||
|
cwd: extensionRoot,
|
||||||
|
});
|
||||||
run(
|
run(
|
||||||
"npx",
|
path.join(extensionRoot, "node_modules", ".bin", "vsce"),
|
||||||
[
|
[
|
||||||
"--yes",
|
|
||||||
"@vscode/vsce",
|
|
||||||
"package",
|
"package",
|
||||||
"--allow-missing-repository",
|
"--allow-missing-repository",
|
||||||
"--skip-license",
|
"--skip-license",
|
||||||
"--out",
|
"--out",
|
||||||
archive,
|
archive,
|
||||||
],
|
],
|
||||||
{ cwd: path.join(publicTree, "vscode-extension") }
|
{ cwd: extensionRoot }
|
||||||
);
|
);
|
||||||
|
run("node", ["scripts/vscode-extension-smoke.js"], { cwd: publicTree });
|
||||||
|
run("node", ["scripts/vscode-f5-smoke.js"], { cwd: publicTree });
|
||||||
return archive;
|
return archive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1087,11 +1112,13 @@ function main() {
|
||||||
: publishPublicTree(releaseName, sourceCommit, publicTreeIdentity);
|
: publishPublicTree(releaseName, sourceCommit, publicTreeIdentity);
|
||||||
let binaryArchive;
|
let binaryArchive;
|
||||||
let hostedBinaryArchive;
|
let hostedBinaryArchive;
|
||||||
|
let extensionArchive;
|
||||||
let binaryDigests;
|
let binaryDigests;
|
||||||
let candidateBinding;
|
let candidateBinding;
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
binaryArchive = reuseCandidateBinaries(candidate, releaseName);
|
binaryArchive = reuseCandidateBinaries(candidate, releaseName);
|
||||||
hostedBinaryArchive = reuseCandidateHostedBinary(candidate, releaseName);
|
hostedBinaryArchive = reuseCandidateHostedBinary(candidate, releaseName);
|
||||||
|
extensionArchive = reuseCandidateExtension(candidate);
|
||||||
binaryDigests = candidate.binary_digests;
|
binaryDigests = candidate.binary_digests;
|
||||||
candidateBinding = {
|
candidateBinding = {
|
||||||
mode: "finalized-exact",
|
mode: "finalized-exact",
|
||||||
|
|
@ -1099,17 +1126,20 @@ function main() {
|
||||||
manifest_sha256: sha256File(candidateManifestPath),
|
manifest_sha256: sha256File(candidateManifestPath),
|
||||||
binary_archive_sha256: sha256File(binaryArchive),
|
binary_archive_sha256: sha256File(binaryArchive),
|
||||||
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
||||||
|
extension_sha256: sha256File(extensionArchive),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
buildPublicBinaries();
|
buildPublicBinaries();
|
||||||
buildHostedBinary();
|
buildHostedBinary();
|
||||||
binaryArchive = stageBinaryAssets(releaseName);
|
binaryArchive = stageBinaryAssets(releaseName);
|
||||||
hostedBinaryArchive = stageHostedBinaryAsset(releaseName);
|
hostedBinaryArchive = stageHostedBinaryAsset(releaseName);
|
||||||
|
extensionArchive = stageExtensionAsset();
|
||||||
binaryDigests = candidateBinaryDigests();
|
binaryDigests = candidateBinaryDigests();
|
||||||
candidateBinding = {
|
candidateBinding = {
|
||||||
mode: "built-once",
|
mode: "built-once",
|
||||||
binary_archive_sha256: sha256File(binaryArchive),
|
binary_archive_sha256: sha256File(binaryArchive),
|
||||||
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
||||||
|
extension_sha256: sha256File(extensionArchive),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const candidateConfigurationIdentity =
|
const candidateConfigurationIdentity =
|
||||||
|
|
@ -1131,7 +1161,6 @@ function main() {
|
||||||
candidateProxyConfigurationIdentity
|
candidateProxyConfigurationIdentity
|
||||||
);
|
);
|
||||||
const evidenceArchive = evidence.archive;
|
const evidenceArchive = evidence.archive;
|
||||||
const extensionArchive = stageExtensionAsset();
|
|
||||||
const resolution = resolverInstructions();
|
const resolution = resolverInstructions();
|
||||||
const gettingStarted = writeGettingStartedAsset(
|
const gettingStarted = writeGettingStartedAsset(
|
||||||
releaseName,
|
releaseName,
|
||||||
|
|
|
||||||
3810
vscode-extension/package-lock.json
generated
Normal file
3810
vscode-extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,12 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.80.0"
|
"vscode": "^1.80.0"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"package:vsix": "vsce package --allow-missing-repository --skip-license"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vscode/vsce": "3.9.2"
|
||||||
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
"Debuggers",
|
"Debuggers",
|
||||||
"Other"
|
"Other"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue