Public release release-e570b80c3a0f
Source commit: e570b80c3a0f4b152a9aa0e7bb8635b3eebcec27 Public tree identity: sha256:cff3388143eb2e9476804cfa1c1246e3a46d485d8aef90f2cdd4df45f2b69abf
This commit is contained in:
commit
0cf66aaa65
210 changed files with 78651 additions and 0 deletions
377
crates/clusterflux-core/src/operator_panel.rs
Normal file
377
crates/clusterflux-core/src/operator_panel.rs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskInstanceId, TenantId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelWidgetKind {
|
||||
Text {
|
||||
value: String,
|
||||
},
|
||||
Progress {
|
||||
current: u64,
|
||||
total: u64,
|
||||
},
|
||||
Button {
|
||||
action: String,
|
||||
},
|
||||
Toggle {
|
||||
value: bool,
|
||||
},
|
||||
Select {
|
||||
options: Vec<String>,
|
||||
selected: String,
|
||||
},
|
||||
ArtifactDownload {
|
||||
artifact: ArtifactId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelWidget {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub kind: PanelWidgetKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ControlPlaneAction {
|
||||
RestartTask(TaskInstanceId),
|
||||
CancelProcess,
|
||||
DebugProcess,
|
||||
DownloadArtifact(ArtifactId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelState {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub widgets: BTreeMap<String, PanelWidget>,
|
||||
pub program_ui_events_enabled: bool,
|
||||
pub control_plane_actions: Vec<ControlPlaneAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelEventKind {
|
||||
ButtonClicked,
|
||||
ToggleChanged(bool),
|
||||
SelectChanged(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelEvent {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub widget_id: String,
|
||||
pub kind: PanelEventKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimit {
|
||||
pub max_events: u64,
|
||||
pub used_events: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum PanelError {
|
||||
#[error("custom HTML or JavaScript is not supported in operator panels")]
|
||||
CustomContentDenied,
|
||||
#[error(
|
||||
"operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials"
|
||||
)]
|
||||
CredentialCollectionDenied(String),
|
||||
#[error("panel event scope does not match tenant/project/process")]
|
||||
ScopeMismatch,
|
||||
#[error("program UI events are disabled while debug process is stopped")]
|
||||
ProgramEventsDisabled,
|
||||
#[error("panel event rate limit exceeded")]
|
||||
RateLimited,
|
||||
#[error("unknown panel widget `{0}`")]
|
||||
UnknownWidget(String),
|
||||
#[error("artifact download action is unavailable: {0}")]
|
||||
DownloadUnavailable(String),
|
||||
}
|
||||
|
||||
impl PanelState {
|
||||
pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self {
|
||||
Self {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
widgets: BTreeMap::new(),
|
||||
program_ui_events_enabled: true,
|
||||
control_plane_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> {
|
||||
validate_widget(&widget)?;
|
||||
self.widgets.insert(widget.id.clone(), widget);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_download_widget_from_action(
|
||||
&mut self,
|
||||
widget_id: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
action: Result<DownloadAction, DownloadError>,
|
||||
) -> Result<(), PanelError> {
|
||||
let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?;
|
||||
let artifact = action.artifact;
|
||||
self.add_widget(PanelWidget {
|
||||
id: widget_id.into(),
|
||||
label: label.into(),
|
||||
kind: PanelWidgetKind::ArtifactDownload {
|
||||
artifact: artifact.clone(),
|
||||
},
|
||||
})?;
|
||||
self.control_plane_actions
|
||||
.push(ControlPlaneAction::DownloadArtifact(artifact));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> {
|
||||
Err(PanelError::CustomContentDenied)
|
||||
}
|
||||
|
||||
pub fn freeze_program_ui_events(&mut self) {
|
||||
self.program_ui_events_enabled = false;
|
||||
}
|
||||
|
||||
pub fn set_control_plane_actions(&mut self, actions: Vec<ControlPlaneAction>) {
|
||||
self.control_plane_actions = actions;
|
||||
}
|
||||
|
||||
pub fn accept_event(
|
||||
&self,
|
||||
event: &PanelEvent,
|
||||
limit: &mut RateLimit,
|
||||
) -> Result<(), PanelError> {
|
||||
if !self.program_ui_events_enabled {
|
||||
return Err(PanelError::ProgramEventsDisabled);
|
||||
}
|
||||
if self.tenant != event.tenant
|
||||
|| self.project != event.project
|
||||
|| self.process != event.process
|
||||
{
|
||||
return Err(PanelError::ScopeMismatch);
|
||||
}
|
||||
if !self.widgets.contains_key(&event.widget_id) {
|
||||
return Err(PanelError::UnknownWidget(event.widget_id.clone()));
|
||||
}
|
||||
if limit.used_events >= limit.max_events {
|
||||
return Err(PanelError::RateLimited);
|
||||
}
|
||||
limit.used_events += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] {
|
||||
&self.control_plane_actions
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> {
|
||||
let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()];
|
||||
match &widget.kind {
|
||||
PanelWidgetKind::Button { action } => checked_text.push(action),
|
||||
PanelWidgetKind::Select { options, selected } => {
|
||||
checked_text.push(selected);
|
||||
checked_text.extend(options.iter().map(String::as_str));
|
||||
}
|
||||
PanelWidgetKind::Text { .. }
|
||||
| PanelWidgetKind::Progress { .. }
|
||||
| PanelWidgetKind::Toggle { .. }
|
||||
| PanelWidgetKind::ArtifactDownload { .. } => {}
|
||||
}
|
||||
|
||||
let combined = checked_text.join(" ").to_ascii_lowercase();
|
||||
if combined.contains("password")
|
||||
|| combined.contains("token")
|
||||
|| combined.contains("oauth")
|
||||
|| combined.contains("secret")
|
||||
{
|
||||
return Err(PanelError::CredentialCollectionDenied(widget.id.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn panel() -> PanelState {
|
||||
PanelState::new(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_uses_typed_widgets_and_rejects_custom_content() {
|
||||
let mut panel = panel();
|
||||
panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "progress".to_owned(),
|
||||
label: "Build".to_owned(),
|
||||
kind: PanelWidgetKind::Progress {
|
||||
current: 1,
|
||||
total: 2,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(PanelState::reject_custom_content("<script>alert(1)</script>").is_err());
|
||||
assert!(panel.widgets.contains_key("progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rejects_password_or_oauth_collection_widgets() {
|
||||
let mut panel = panel();
|
||||
let error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "oauth_token".to_owned(),
|
||||
label: "OAuth Token".to_owned(),
|
||||
kind: PanelWidgetKind::Text {
|
||||
value: String::new(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PanelError::CredentialCollectionDenied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rejects_credential_collection_in_interactive_fields() {
|
||||
let mut panel = panel();
|
||||
let button_error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "continue".to_owned(),
|
||||
label: "Continue".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "collect-secret".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
button_error,
|
||||
PanelError::CredentialCollectionDenied(_)
|
||||
));
|
||||
|
||||
let select_error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "auth-mode".to_owned(),
|
||||
label: "Auth Mode".to_owned(),
|
||||
kind: PanelWidgetKind::Select {
|
||||
options: vec!["password".to_owned(), "public key".to_owned()],
|
||||
selected: "public key".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
select_error,
|
||||
PanelError::CredentialCollectionDenied(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_events_are_scoped_and_rate_limited() {
|
||||
let mut panel = panel();
|
||||
panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "restart".to_owned(),
|
||||
label: "Restart".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "restart".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let event = PanelEvent {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: ProcessId::from("process"),
|
||||
widget_id: "restart".to_owned(),
|
||||
kind: PanelEventKind::ButtonClicked,
|
||||
};
|
||||
let mut limit = RateLimit {
|
||||
max_events: 1,
|
||||
used_events: 0,
|
||||
};
|
||||
|
||||
panel.accept_event(&event, &mut limit).unwrap();
|
||||
assert_eq!(
|
||||
panel.accept_event(&event, &mut limit),
|
||||
Err(PanelError::RateLimited)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_debug_process_keeps_control_plane_actions_available() {
|
||||
let mut panel = panel();
|
||||
panel.freeze_program_ui_events();
|
||||
panel.set_control_plane_actions(vec![
|
||||
ControlPlaneAction::RestartTask(TaskInstanceId::from("task")),
|
||||
ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")),
|
||||
]);
|
||||
|
||||
let event = PanelEvent {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: ProcessId::from("process"),
|
||||
widget_id: "missing".to_owned(),
|
||||
kind: PanelEventKind::ButtonClicked,
|
||||
};
|
||||
let mut limit = RateLimit {
|
||||
max_events: 1,
|
||||
used_events: 0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
panel.accept_event(&event, &mut limit),
|
||||
Err(PanelError::ProgramEventsDisabled)
|
||||
);
|
||||
assert_eq!(panel.control_plane_actions_available().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_widget_is_only_created_from_available_action() {
|
||||
let mut panel = panel();
|
||||
let action = Ok(DownloadAction {
|
||||
artifact: ArtifactId::from("artifact"),
|
||||
source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")),
|
||||
scoped_token_subject: "tenant/project/process/artifact".to_owned(),
|
||||
});
|
||||
|
||||
panel
|
||||
.add_download_widget_from_action("download-artifact", "Download", action)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
panel.widgets["download-artifact"].kind,
|
||||
PanelWidgetKind::ArtifactDownload { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
panel.control_plane_actions_available()[0],
|
||||
ControlPlaneAction::DownloadArtifact(_)
|
||||
));
|
||||
|
||||
let before = panel.widgets.len();
|
||||
let error = panel
|
||||
.add_download_widget_from_action(
|
||||
"missing-download",
|
||||
"Download",
|
||||
Err(DownloadError::Unavailable),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(panel.widgets.len(), before);
|
||||
assert!(matches!(error, PanelError::DownloadUnavailable(_)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue