diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 056000415..516a74458 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6298,6 +6298,7 @@ dependencies = [ "k8s-openapi", "kube", "ldap3", + "serde", "serde_json", "sqlx", "strum 0.27.2", diff --git a/backend/sessionspaces/Cargo.toml b/backend/sessionspaces/Cargo.toml index 0471bd1ba..010e71c4d 100644 --- a/backend/sessionspaces/Cargo.toml +++ b/backend/sessionspaces/Cargo.toml @@ -17,6 +17,7 @@ kube = { workspace = true } ldap3 = { version = "0.11.5", default-features = false, features = [ "tls-rustls", ] } +serde = { workspace = true } serde_json = { workspace = true } sqlx = { version = "0.8.6", features = [ "runtime-tokio", diff --git a/backend/sessionspaces/src/instruments.rs b/backend/sessionspaces/src/instruments.rs index 5922eb023..fb1170518 100644 --- a/backend/sessionspaces/src/instruments.rs +++ b/backend/sessionspaces/src/instruments.rs @@ -161,4 +161,6 @@ pub enum Instrument { S03, #[strum(serialize = "s04")] S04, + #[strum(serialize = "t01")] + T01, } diff --git a/backend/sessionspaces/src/main.rs b/backend/sessionspaces/src/main.rs index 6ee9d7ad0..d042e4f6f 100644 --- a/backend/sessionspaces/src/main.rs +++ b/backend/sessionspaces/src/main.rs @@ -10,12 +10,12 @@ pub mod permissionables; /// Kubernetes resource templating mod resources; -use crate::permissionables::Sessions; +use crate::permissionables::{static_sessions::StaticSessions, Sessions}; use clap::Parser; use ldap3::LdapConnAsync; use resources::{create_configmap, create_namespace, delete_namespace}; use sqlx::mysql::MySqlPoolOptions; -use std::{collections::BTreeSet, time::Duration}; +use std::{collections::BTreeSet, path::PathBuf, time::Duration}; use telemetry::{setup_telemetry, TelemetryConfig}; use tokio::time::interval; use tracing::{info, instrument, warn}; @@ -36,6 +36,9 @@ struct Cli { /// The maximum allowable k8s API requests per second #[clap(long, env, default_value = "10")] request_rate: Option, + /// Optional path to a JSON file containing static sessions to always be present + #[clap(long, env)] + static_sessions: Option, /// Args to setup telemetry #[command(flatten)] telemetry_config: TelemetryConfig, @@ -66,12 +69,22 @@ async fn main() { } builder.build() }; + let static_sessions = args + .static_sessions + .as_deref() + .map(StaticSessions::from_path) + .transpose() + .unwrap(); + let mut current_sessions = Sessions::default(); let mut update_interval = interval(args.update_interval.into()); loop { update_interval.tick().await; match Sessions::fetch(&ispyb_pool, &mut ldap_connection).await { Ok(mut new_sessions) => { + if let Some(ref statics) = static_sessions { + statics.merge_into(&mut new_sessions); + } update_sessionspaces(&mut current_sessions, &mut new_sessions, &k8s_client).await; } Err(err) => warn!("Encountered error when fetching sessions: {err}"), diff --git a/backend/sessionspaces/src/permissionables/mod.rs b/backend/sessionspaces/src/permissionables/mod.rs index f973be727..8820ff58a 100644 --- a/backend/sessionspaces/src/permissionables/mod.rs +++ b/backend/sessionspaces/src/permissionables/mod.rs @@ -8,6 +8,8 @@ mod instrument_subjects; mod posix_attributes; /// Associations between proposals and subjects mod proposal_subjects; +/// Statically-configured sessions for testing +pub mod static_sessions; use self::{basic_info::BasicInfo, direct_subjects::DirectSubjects}; use crate::instruments::Instrument::{self, *}; diff --git a/backend/sessionspaces/src/permissionables/static_sessions.rs b/backend/sessionspaces/src/permissionables/static_sessions.rs new file mode 100644 index 000000000..a70a2d8a5 --- /dev/null +++ b/backend/sessionspaces/src/permissionables/static_sessions.rs @@ -0,0 +1,120 @@ +use super::Session; +use crate::{instruments::Instrument, permissionables::Sessions}; +use serde::Deserialize; +use std::{collections::BTreeSet, path::Path, str::FromStr}; +use time::{macros::format_description, PrimitiveDateTime}; + +/// Deserialises an [`Instrument`] from its string representation (e.g. `"i03"`). +fn deserialize_instrument<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + Instrument::from_str(&s).map_err(serde::de::Error::custom) +} + +/// Deserialises a [`PrimitiveDateTime`] from `"YYYY-MM-DD HH:MM:SS"` format. +fn deserialize_datetime<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + PrimitiveDateTime::parse(&s, format).map_err(serde::de::Error::custom) +} + +/// A single statically-defined visit session, deserialised from the config file. +/// Field names and semantics are identical to those of [`Session`], except `visits` accepts +/// multiple visit numbers so one entry can produce several namespaces sharing the same members. +#[derive(Debug, Deserialize)] +struct StaticSessionEntry { + /// The two-letter prefix code associated with the proposal (e.g. `"ks"`). + proposal_code: String, + /// The unique number of the proposal. + proposal_number: u32, + /// One or more visit numbers. Each produces a separate namespace (`{code}{number}-{visit}`). + visits: Vec, + /// The instrument with which the session is associated. + #[serde(deserialize_with = "deserialize_instrument")] + instrument: Instrument, + /// Set of usernames granted access. Defaults to empty if omitted. + #[serde(default)] + members: BTreeSet, + /// Posix GID of the session group. `null` if no LDAP group exists for this session. + gid: Option, + /// Session start date and time. + #[serde(deserialize_with = "deserialize_datetime")] + start_date: PrimitiveDateTime, + /// Session end date and time. + #[serde(deserialize_with = "deserialize_datetime")] + end_date: PrimitiveDateTime, +} + +impl StaticSessionEntry { + /// Expands this entry into one [`Session`] per visit number. + fn into_sessions(self) -> impl Iterator { + self.visits.into_iter().map(move |visit| { + let name = format!("{}{}-{}", self.proposal_code, self.proposal_number, visit); + let session = Session { + proposal_code: self.proposal_code.clone(), + proposal_number: self.proposal_number, + visit, + instrument: self.instrument, + members: self.members.clone(), + gid: self.gid, + start_date: self.start_date, + end_date: self.end_date, + }; + (name, session) + }) + } +} + +/// A set of statically-configured sessions that emulate ISPyB-driven visit namespaces. +/// +/// Loaded once at startup from a JSON file (an array of session objects). On each reconcile +/// tick these sessions are merged into the live [`Sessions`] map so they pass through the +/// exact same create / update / delete lifecycle as real visits. +#[derive(Debug, Default, Clone)] +pub struct StaticSessions(Sessions); + +impl StaticSessions { + /// Reads and parses a JSON file at `path`. + /// + /// The file must contain a JSON array. Each element may list multiple visit numbers; + /// one namespace is created per visit, all sharing the same members. + /// ```json + /// { + /// "proposal_code": "ks", + /// "proposal_number": 10000, + /// "visits": [1, 2, 3, 4, 5], + /// "instrument": "t01", + /// "members": ["fed12345"], + /// "gid": null, + /// "start_date": "2024-01-01 00:00:00", + /// "end_date": "2099-12-31 23:59:59" + /// } + /// ``` + pub fn from_path(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let entries: Vec = serde_json::from_str(&content)?; + let mut sessions = Sessions::default(); + for entry in entries { + for (name, session) in entry.into_sessions() { + sessions.insert(name, session); + } + } + Ok(Self(sessions)) + } + + /// Inserts each static session into `target`, using the same namespace naming scheme as + /// ISPyB sessions (`{proposal_code}{proposal_number}-{visit}`). Real ISPyB sessions with + /// the same name take precedence; static entries are skipped if the name already exists. + pub fn merge_into(&self, target: &mut Sessions) { + for (name, session) in self.0.iter() { + target + .entry(name.clone()) + .or_insert_with(|| session.clone()); + } + } +} diff --git a/charts/sessionspaces/Chart.yaml b/charts/sessionspaces/Chart.yaml index cae8dcf88..5a169d843 100644 --- a/charts/sessionspaces/Chart.yaml +++ b/charts/sessionspaces/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sessionspaces description: Namespace controller for creating session namespaces type: application -version: 0.3.23 +version: 0.3.24 appVersion: 0.1.5 dependencies: - name: common diff --git a/charts/sessionspaces/staging-values.yaml b/charts/sessionspaces/staging-values.yaml index e69de29bb..a81606fdf 100644 --- a/charts/sessionspaces/staging-values.yaml +++ b/charts/sessionspaces/staging-values.yaml @@ -0,0 +1,11 @@ +staticSessions: + enabled: true + sessions: + - proposal_code: ks + proposal_number: 10000 + visits: [1, 2, 3, 4, 5] + instrument: t01 + members: [] + gid: null + start_date: "2024-01-01 00:00:00" + end_date: "2099-12-31 23:59:59" diff --git a/charts/sessionspaces/templates/deployment.yaml b/charts/sessionspaces/templates/deployment.yaml index 5fcae3ebc..1678c874a 100644 --- a/charts/sessionspaces/templates/deployment.yaml +++ b/charts/sessionspaces/templates/deployment.yaml @@ -65,6 +65,16 @@ spec: - name: TRACING_ENDPOINT value: {{ . }} {{- end }} + {{- if $.Values.staticSessions.enabled }} + - name: STATIC_SESSIONS + value: /etc/sessionspaces/static-sessions.json + {{- end }} + {{- if $.Values.staticSessions.enabled }} + volumeMounts: + - name: static-sessions + mountPath: /etc/sessionspaces + readOnly: true + {{- end }} resources: {{- $.Values.deployment.resources | toYaml | nindent 12 }} {{- with $.Values.deployment.nodeSelector }} @@ -79,3 +89,9 @@ spec: tolerations: {{- . | toYaml | nindent 8 }} {{- end }} + {{- if $.Values.staticSessions.enabled }} + volumes: + - name: static-sessions + configMap: + name: {{ include "common.names.fullname" $ }}-static-sessions + {{- end }} diff --git a/charts/sessionspaces/templates/static-sessions-configmap.yaml b/charts/sessionspaces/templates/static-sessions-configmap.yaml new file mode 100644 index 000000000..bcd74d962 --- /dev/null +++ b/charts/sessionspaces/templates/static-sessions-configmap.yaml @@ -0,0 +1,11 @@ +{{- if $.Values.staticSessions.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "common.names.fullname" $ }}-static-sessions + namespace: {{ .Release.Namespace }} + labels: + {{- include "common.labels.standard" $ | nindent 4 }} +data: + static-sessions.json: {{ $.Values.staticSessions.sessions | toJson | quote }} +{{- end }} diff --git a/charts/sessionspaces/values.yaml b/charts/sessionspaces/values.yaml index 3844cc165..8ced86e1c 100644 --- a/charts/sessionspaces/values.yaml +++ b/charts/sessionspaces/values.yaml @@ -48,3 +48,7 @@ telemetry: level: Info metricsEndpoint: https://otelcollector.workflows.diamond.ac.uk/v1/metrics tracingEndpoint: https://otelcollector.workflows.diamond.ac.uk/v1/traces + +staticSessions: + enabled: false + sessions: []