From 115e371b2974e5b9817d381346585a1511a8dc8c Mon Sep 17 00:00:00 2001 From: James Gilbert Date: Tue, 30 Jun 2026 12:22:06 +0000 Subject: [PATCH] feat(graph-proxy): add mutation to create Triggers --- backend/Cargo.lock | 2 + backend/graph-proxy/Cargo.toml | 2 + backend/graph-proxy/src/graphql/mod.rs | 18 +- backend/graph-proxy/src/graphql/triggers.rs | 223 ++++++++++++++++++ .../graph-proxy/test-assets/make-trigger.json | 32 +++ .../test-assets/named-trigger.json | 32 +++ .../test-assets/namespaced-trigger.json | 32 +++ charts/graph-proxy/Chart.yaml | 2 +- charts/graph-proxy/templates/clusterrole.yaml | 10 + 9 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 backend/graph-proxy/src/graphql/triggers.rs create mode 100644 backend/graph-proxy/test-assets/make-trigger.json create mode 100644 backend/graph-proxy/test-assets/named-trigger.json create mode 100644 backend/graph-proxy/test-assets/namespaced-trigger.json diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c6c6b19e6..337ad76c0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2765,10 +2765,12 @@ dependencies = [ "reqwest 0.12.28", "rstest", "rustls 0.23.38", + "schemars 1.2.1", "secrecy", "serde", "serde_json", "telemetry", + "tempfile", "testcontainers", "thiserror 2.0.18", "tokio", diff --git a/backend/graph-proxy/Cargo.toml b/backend/graph-proxy/Cargo.toml index c050cf961..7b55331e6 100644 --- a/backend/graph-proxy/Cargo.toml +++ b/backend/graph-proxy/Cargo.toml @@ -44,6 +44,8 @@ opentelemetry_sdk = { workspace = true } openidconnect = { workspace = true } rstest = "0.26.1" jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] } +schemars = "1.2.1" +tempfile = "3.27.0" [dev-dependencies] graphql-ws-client = { version = "0.12.0", features = ["client-graphql-client", "tungstenite-0.28"] } diff --git a/backend/graph-proxy/src/graphql/mod.rs b/backend/graph-proxy/src/graphql/mod.rs index 43363b6d9..f7a53f845 100644 --- a/backend/graph-proxy/src/graphql/mod.rs +++ b/backend/graph-proxy/src/graphql/mod.rs @@ -1,5 +1,13 @@ +/// Workflow/Template filters +mod filters; /// Workflow Template Paramer Schema mod parameter_schema; +/// GraphQL operations requiring subscriptions +mod subscription; +/// Axum-specific websocket handling to support subscriptions +pub mod subscription_integration; +/// GraphQL operations related to Triggers +mod triggers; /// Workflow Template JSON Forms UI Schema mod ui_schema; /// GraphQL operations related to workflow templates @@ -7,18 +15,12 @@ mod workflow_templates; /// GraphQL operations related to workflows mod workflows; -/// Workflow/Template filters -mod filters; -/// GraphQL operations requiring subscriptions -mod subscription; -/// Axum-specific websocket handling to support subscriptions -pub mod subscription_integration; - use crate::RouterState; use crate::{graphql::auth_guard::AuthGuard, validate_token::ValidationMethod}; use self::{ subscription::WorkflowsSubscription, + triggers::TriggerMutation, workflow_templates::WorkflowTemplatesQuery, workflows::{Workflow, WorkflowsQuery}, }; @@ -75,7 +77,7 @@ impl NodeQuery { /// The root mutation of the service #[derive(Debug, Clone, Default, MergedObject)] -pub struct Mutation(WorkflowTemplatesMutation); +pub struct Mutation(WorkflowTemplatesMutation, TriggerMutation); /// Represents Relay Node types #[derive(Union)] diff --git a/backend/graph-proxy/src/graphql/triggers.rs b/backend/graph-proxy/src/graphql/triggers.rs new file mode 100644 index 000000000..43818e86c --- /dev/null +++ b/backend/graph-proxy/src/graphql/triggers.rs @@ -0,0 +1,223 @@ +use std::ops::Deref; + +use crate::{ + graphql::{auth_guard::AuthGuard, VisitInput}, + KubernetesApiUrl, +}; +use async_graphql::{Context, Object, SimpleObject}; +use kube::{ + api::{ObjectMeta, PostParams}, + Api, Client, Config, CustomResource, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The contents of the `spec` field of the Trigger custom resource. Used to generate the Trigger root object +#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[kube( + group = "workflows.diamond.ac.uk", + version = "v1alpha1", + kind = "Trigger", + namespaced +)] +struct TriggerSpec { + /// The name of a ClusterTriggerTemplate that the Trigger is created from + #[serde(rename = "templateRef")] + template_ref: String, +} + +/// A Trigger for creating automated workflows +#[derive(Debug, Serialize, Deserialize, SimpleObject)] +#[graphql(name = "Trigger")] +struct TriggerGQL { + /// The name of the Trigger + name: Option, + /// The name of a ClusterTriggerTemplate that the Trigger is created from + #[graphql(name = "templateRef")] + template_ref: String, +} + +impl From for TriggerGQL { + fn from(t: Trigger) -> Self { + Self { + name: t.metadata.name, + template_ref: t.spec.template_ref, + } + } +} + +/// Mutations related to [`Trigger`]s +#[derive(Debug, Clone, Default)] +pub struct TriggerMutation; + +#[Object(guard = "AuthGuard")] +impl TriggerMutation { + /// Create a Trigger from a template + async fn create_trigger( + &self, + ctx: &Context<'_>, + template_ref: String, + name: Option, + visit: Option, + ) -> anyhow::Result { + let kubernetes_api_url = ctx.data_unchecked::(); + let mut config = Config::infer().await?; + config.cluster_url = kubernetes_api_url.deref().clone(); + let client = Client::try_from(config)?; + let namespace = visit.map_or(String::from("events"), |v| v.to_string()); + let api: Api = Api::namespaced(client.clone(), &namespace); + let trigger = Trigger { + metadata: ObjectMeta { + generate_name: Some(format!("{}-", name.as_deref().unwrap_or(&template_ref))), + name: None, + ..Default::default() + }, + spec: TriggerSpec { template_ref }, + }; + + let creation: Trigger = api.create(&PostParams::default(), &trigger).await?; + Ok(creation.into()) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + graphql::{triggers::TriggerMutation, workflow_templates::WorkflowTemplatesQuery}, + validate_token::ValidatedAuthToken, + KubernetesApiUrl, + }; + use async_graphql::{EmptySubscription, Schema}; + use axum_extra::headers::Authorization; + use serde_json::Value; + + fn test_token() -> ValidatedAuthToken { + let token = Authorization::bearer("test-token").expect("token always valid"); + ValidatedAuthToken::Valid(token) + } + + async fn trigger_mutation( + query: &str, + mock_file: &str, + namespace: &str, + expected: Value, + ) -> anyhow::Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut server = mockito::Server::new_async().await; + let kubeconfig = format!( + r#" + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: {} + name: test + contexts: + - context: + cluster: test + user: test + name: test + current-context: test + users: + - name: test + user: {{}} + "#, + server.url() + ); + + let file = tempfile::NamedTempFile::new()?; + std::fs::write(file.path(), kubeconfig)?; + + std::env::set_var("KUBECONFIG", file.path()); + let mut response_file_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + response_file_path.push("test-assets"); + response_file_path.push(mock_file); + let trigger_endpoint = server + .mock( + "POST", + &format!("/apis/workflows.diamond.ac.uk/v1alpha1/namespaces/{namespace}/triggers?") + [..], + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body_from_file(response_file_path) + .create_async() + .await; + let kubernetes_server_url = server.url().parse()?; + let schema = Schema::build(WorkflowTemplatesQuery, TriggerMutation, EmptySubscription) + .data(KubernetesApiUrl(kubernetes_server_url)) + .data(test_token()) + .finish(); + let response = schema.execute(query).await; + + println!("Errors: {:#?}", response.errors); + trigger_endpoint.assert_async().await; + let actual = response.data.into_json().unwrap(); + assert_eq!(expected, actual); + Ok(()) + } + + #[tokio::test] + async fn simple_trigger_mutation() -> anyhow::Result<()> { + let query = r#" + mutation { + createTrigger(templateRef: "test-trigger") { + name + } + } + "#; + let expected = serde_json::json!( + { + "createTrigger": { + "name": "test-trigger-s6qzl" + } + } + ); + trigger_mutation(query, "make-trigger.json", "events", expected).await + } + + #[tokio::test] + async fn named_trigger_mutation() -> anyhow::Result<()> { + let query = r#" + mutation { + createTrigger(templateRef: "test-trigger", name: "custom-name") { + name + } + } + "#; + let expected = serde_json::json!( + { + "createTrigger": { + "name": "custom-name-s6qzl" + } + } + ); + trigger_mutation(query, "named-trigger.json", "events", expected).await + } + + #[tokio::test] + async fn namespaced_trigger_mutation() -> anyhow::Result<()> { + let query = r#" + mutation { + createTrigger( + templateRef: "test-trigger", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1 + } + ) { + name + } + } + "#; + let expected = serde_json::json!( + { + "createTrigger": { + "name": "custom-name-s6qzl" + } + } + ); + trigger_mutation(query, "named-trigger.json", "mg36964-1", expected).await + } +} diff --git a/backend/graph-proxy/test-assets/make-trigger.json b/backend/graph-proxy/test-assets/make-trigger.json new file mode 100644 index 000000000..561cdd11d --- /dev/null +++ b/backend/graph-proxy/test-assets/make-trigger.json @@ -0,0 +1,32 @@ +{ + "apiVersion": "workflows.diamond.ac.uk/v1alpha1", + "kind": "Trigger", + "metadata": { + "creationTimestamp": "2026-07-01T13:20:59Z", + "generateName": "test-trigger-", + "generation": 1, + "labels": { + "workflows.diamond.ac.uk/beamline": "b01-1", + "workflows.diamond.ac.uk/source": "generic" + }, + "name": "test-trigger-s6qzl", + "namespace": "events", + "resourceVersion": "35319052", + "uid": "e4eb1a59-935d-445b-b859-91a7cb555126" + }, + "spec": { + "enabled": true, + "eventName": "test-example", + "templateRef": "test-trigger", + "workflow": { + "parameters": [ + { + "name": "message", + "path": "name" + } + ], + "template": "echo-workflow-template", + "triggerOnMessageType": "start" + } + } +} diff --git a/backend/graph-proxy/test-assets/named-trigger.json b/backend/graph-proxy/test-assets/named-trigger.json new file mode 100644 index 000000000..22a309d92 --- /dev/null +++ b/backend/graph-proxy/test-assets/named-trigger.json @@ -0,0 +1,32 @@ +{ + "apiVersion": "workflows.diamond.ac.uk/v1alpha1", + "kind": "Trigger", + "metadata": { + "creationTimestamp": "2026-07-01T13:20:59Z", + "generateName": "custom-name-", + "generation": 1, + "labels": { + "workflows.diamond.ac.uk/beamline": "b01-1", + "workflows.diamond.ac.uk/source": "generic" + }, + "name": "custom-name-s6qzl", + "namespace": "events", + "resourceVersion": "35319052", + "uid": "e4eb1a59-935d-445b-b859-91a7cb555126" + }, + "spec": { + "enabled": true, + "eventName": "test-example", + "templateRef": "test-trigger", + "workflow": { + "parameters": [ + { + "name": "message", + "path": "name" + } + ], + "template": "echo-workflow-template", + "triggerOnMessageType": "start" + } + } +} diff --git a/backend/graph-proxy/test-assets/namespaced-trigger.json b/backend/graph-proxy/test-assets/namespaced-trigger.json new file mode 100644 index 000000000..0fce62e9d --- /dev/null +++ b/backend/graph-proxy/test-assets/namespaced-trigger.json @@ -0,0 +1,32 @@ +{ + "apiVersion": "workflows.diamond.ac.uk/v1alpha1", + "kind": "Trigger", + "metadata": { + "creationTimestamp": "2026-07-01T13:20:59Z", + "generateName": "test-trigger-", + "generation": 1, + "labels": { + "workflows.diamond.ac.uk/beamline": "b01-1", + "workflows.diamond.ac.uk/source": "generic" + }, + "name": "test-trigger-s6qzl", + "namespace": "mg36964-1", + "resourceVersion": "35319052", + "uid": "e4eb1a59-935d-445b-b859-91a7cb555126" + }, + "spec": { + "enabled": true, + "eventName": "test-example", + "templateRef": "test-trigger", + "workflow": { + "parameters": [ + { + "name": "message", + "path": "name" + } + ], + "template": "echo-workflow-template", + "triggerOnMessageType": "start" + } + } +} diff --git a/charts/graph-proxy/Chart.yaml b/charts/graph-proxy/Chart.yaml index dc18bfb4e..377a62f6c 100644 --- a/charts/graph-proxy/Chart.yaml +++ b/charts/graph-proxy/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: graph-proxy description: A GraphQL proxy for the Argo Workflows Server type: application -version: 0.2.38 +version: 0.2.39 appVersion: 0.1.19 dependencies: - name: common diff --git a/charts/graph-proxy/templates/clusterrole.yaml b/charts/graph-proxy/templates/clusterrole.yaml index 7466a60cc..e1ec9db07 100644 --- a/charts/graph-proxy/templates/clusterrole.yaml +++ b/charts/graph-proxy/templates/clusterrole.yaml @@ -11,4 +11,14 @@ rules: verbs: - get - list + - apiGroups: + - workflows.diamond.ac.uk + resources: + - triggers + verbs: + - get + - list + - patch + - create + - delete {{- end }}