Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/graph-proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
18 changes: 10 additions & 8 deletions backend/graph-proxy/src/graphql/mod.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
/// 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
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},
};
Expand Down Expand Up @@ -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)]
Expand Down
223 changes: 223 additions & 0 deletions backend/graph-proxy/src/graphql/triggers.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// The name of a ClusterTriggerTemplate that the Trigger is created from
#[graphql(name = "templateRef")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

no a request to change as this is fine, but just so that you know, that snake_case to camelCase is done automatically async-graphql see: https://docs.rs/async-graphql/latest/async_graphql/derive.SimpleObject.html so this line is redundant

template_ref: String,
}

impl From<Trigger> 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<String>,
visit: Option<VisitInput>,
) -> anyhow::Result<TriggerGQL> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

async_graphql will trung this return type into TriggerGQL! (a non-nullable type).

If you return a non-nullable type, and there is an error, GraphQL will not return null, so it must bubble up the error up the tree until it hit s a nullable type. If there is no nullable type between you and the root of the tree, it kills the whole query.

In async-graphql:

Option<T> becomes T that does not return an error.
Result<T> becomes T! that may return an error.
Result<Option<T>> becomes T that may return an error.

So, I believe this needs to return Result<Option<TriggerGQL>>.

see: https://diamondlightsource.slack.com/archives/C06N43M7JP3/p1764932359041159

let kubernetes_api_url = ctx.data_unchecked::<KubernetesApiUrl>();
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<Trigger> = Api::namespaced(client.clone(), &namespace);
let trigger = Trigger {
metadata: ObjectMeta {
generate_name: Some(format!("{}-", name.as_deref().unwrap_or(&template_ref))),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In GraphQL this turns into:

mutation createTrigger(templateRef: String!, name: String, visit: VisitInput): Option<T>

I guess generate_name and name work like Argo Workflows generateName and name? i.e. name is an exact name but generate name is <prefix>_randomstring?

If I call createTrigger(name:"foo") I would be confused when I get back an Trigger{name:"foo-bar"}.

I suggest to consider changing the behaviour or changing the name of the name parameter.

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
}
}
32 changes: 32 additions & 0 deletions backend/graph-proxy/test-assets/make-trigger.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
32 changes: 32 additions & 0 deletions backend/graph-proxy/test-assets/named-trigger.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
32 changes: 32 additions & 0 deletions backend/graph-proxy/test-assets/namespaced-trigger.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
2 changes: 1 addition & 1 deletion charts/graph-proxy/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading