diff --git a/Cargo.lock b/Cargo.lock index cdc90721d..834da196c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "cfg-if", "chacha20poly1305", "devolutions-agent-shared", + "devolutions-gateway-ai", "devolutions-gateway-generators", "devolutions-gateway-task", "devolutions-log", @@ -1880,6 +1881,7 @@ dependencies = [ "picky-krb", "pin-project-lite 0.2.17", "proptest", + "provisioner-task-store-libsql", "rand 0.10.2", "reqwest", "rstest", @@ -5991,6 +5993,18 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "provisioner-task-store-libsql" +version = "0.0.0" +dependencies = [ + "anyhow", + "libsql", + "tempfile", + "tokio 1.52.3", + "tracing", + "uuid", +] + [[package]] name = "proxy-generators" version = "0.0.0" diff --git a/crates/job-queue-libsql/src/lib.rs b/crates/job-queue-libsql/src/lib.rs index 0efbfd23f..ff62e7da3 100644 --- a/crates/job-queue-libsql/src/lib.rs +++ b/crates/job-queue-libsql/src/lib.rs @@ -402,6 +402,27 @@ impl JobQueue for LibSqlJobQueue { Ok(Some(scheduled_for)) } + + async fn job_defs(&self, name: &str) -> anyhow::Result> { + let sql_query = "SELECT json(def) FROM job_queue WHERE name = $1"; + let params = [name]; + + trace!(%sql_query, ?params, "Listing job definitions"); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + let mut defs = Vec::new(); + + while let Some(row) = rows.next().await.context("failed to read the row")? { + defs.push(row.get::(0).context("failed to read def value")?); + } + + Ok(defs) + } } // Typically, migrations should not be modified once released, and we should only be appending to this list. diff --git a/crates/job-queue/src/lib.rs b/crates/job-queue/src/lib.rs index 93232bd86..1f1e42111 100644 --- a/crates/job-queue/src/lib.rs +++ b/crates/job-queue/src/lib.rs @@ -62,6 +62,9 @@ pub trait JobQueue: Send + Sync { /// Retrieves the closest future scheduled date async fn next_scheduled_date(&self) -> anyhow::Result>; + + /// Returns the JSON definition of every job with this name still in the queue + async fn job_defs(&self, name: &str) -> anyhow::Result>; } pub struct JobCtx { diff --git a/crates/provisioner-task-store-libsql/Cargo.toml b/crates/provisioner-task-store-libsql/Cargo.toml new file mode 100644 index 000000000..defbe6e19 --- /dev/null +++ b/crates/provisioner-task-store-libsql/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "provisioner-task-store-libsql" +version = "0.0.0" +edition = "2024" +authors = ["Devolutions Inc. "] +publish = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +libsql = { version = "0.9", default-features = false, features = ["core"] } +tracing = "0.1" +uuid = "1.23" + +[dev-dependencies] +tempfile = "3.24" +tokio = { version = "1", features = ["time", "macros", "rt", "rt-multi-thread"] } +uuid = { version = "1.23", features = ["v4"] } diff --git a/crates/provisioner-task-store-libsql/src/lib.rs b/crates/provisioner-task-store-libsql/src/lib.rs new file mode 100644 index 000000000..ff7fb64b2 --- /dev/null +++ b/crates/provisioner-task-store-libsql/src/lib.rs @@ -0,0 +1,387 @@ +//! Records of the background tasks started by the provisioner (DVLS), stored in a libSQL database. +//! +//! Rows are never deleted, so the records can be audited later. + +#[macro_use] +extern crate tracing; + +use anyhow::Context as _; +use libsql::{Connection, Row}; +use uuid::Uuid; + +// Released migrations are never modified; new ones are appended. +// The job queue in the same database owns `PRAGMA user_version`, so this schema keeps its version in a table. +const MIGRATIONS: &[&str] = &[ + // Migration 0 + "CREATE TABLE task ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + target BLOB NOT NULL, + params BLOB NOT NULL, + state INT NOT NULL CHECK (state IN (0, 1, 2, 3)), + substate BLOB NULL, + result BLOB NULL, + error TEXT NULL, + attempts INT NOT NULL DEFAULT 0, + token_jti TEXT NOT NULL, + created_at INT NOT NULL DEFAULT (unixepoch()), + started_at INT NULL, + finished_at INT NULL, + updated_at INT NOT NULL DEFAULT (unixepoch()) + ) STRICT; + + CREATE TRIGGER update_task_updated_at_on_update AFTER UPDATE ON task + BEGIN + UPDATE task SET updated_at = unixepoch() WHERE id == NEW.id; + END; + + CREATE INDEX idx_task_kind_created_at ON task(kind, created_at);", +]; + +const SELECT_COLUMNS: &str = "id, kind, json(target), json(params), state, json(substate), json(result), error, \ + attempts, token_jti, created_at, started_at, finished_at, updated_at"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + NotStarted, + Running, + Success, + Failed, +} + +impl TaskState { + fn to_db(self) -> i64 { + match self { + TaskState::NotStarted => 0, + TaskState::Running => 1, + TaskState::Success => 2, + TaskState::Failed => 3, + } + } + + fn from_db(value: i64) -> anyhow::Result { + match value { + 0 => Ok(TaskState::NotStarted), + 1 => Ok(TaskState::Running), + 2 => Ok(TaskState::Success), + 3 => Ok(TaskState::Failed), + _ => anyhow::bail!("unknown task state {value}"), + } + } +} + +/// A task to record; `target` and `params` are JSON documents. +#[derive(Debug, Clone, Copy)] +pub struct NewTask<'a> { + pub id: Uuid, + pub kind: &'a str, + pub target: &'a str, + pub params: &'a str, + pub token_jti: Uuid, +} + +/// A stored task; JSON columns are returned as JSON text, timestamps as UNIX seconds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskRecord { + pub id: Uuid, + pub kind: String, + pub target: String, + pub params: String, + pub state: TaskState, + pub substate: Option, + pub result: Option, + pub error: Option, + pub attempts: u32, + pub token_jti: Uuid, + pub created_at: i64, + pub started_at: Option, + pub finished_at: Option, + pub updated_at: i64, +} + +pub struct LibSqlProvisionerTaskStore { + conn: Connection, +} + +impl LibSqlProvisionerTaskStore { + /// Applies the pending migrations on `conn`, which the caller has opened and configured. + /// + /// The database may hold other tables, such as a job queue. + pub async fn init(conn: Connection) -> anyhow::Result { + let store = Self { conn }; + store.migrate().await?; + + Ok(store) + } + + pub async fn insert(&self, task: NewTask<'_>) -> anyhow::Result<()> { + let sql_query = "INSERT INTO task (id, kind, target, params, state, token_jti) + VALUES (:id, :kind, jsonb(:target), jsonb(:params), :state, :token_jti)"; + + let params = ( + (":id", task.id.to_string()), + (":kind", task.kind), + (":target", task.target), + (":params", task.params), + (":state", TaskState::NotStarted.to_db()), + (":token_jti", task.token_jti.to_string()), + ); + + trace!(%sql_query, task.id = %task.id, task.kind = task.kind, "Insert task"); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + pub async fn get(&self, id: Uuid) -> anyhow::Result> { + let sql_query = format!("SELECT {SELECT_COLUMNS} FROM task WHERE id = :id"); + + let mut rows = self + .conn + .query(&sql_query, [(":id", id.to_string())]) + .await + .context("failed to execute SQL query")?; + + match rows.next().await.context("failed to read the row")? { + Some(row) => read_record(&row).map(Some), + None => Ok(None), + } + } + + /// Starts a new attempt of an unfinished task and returns its attempt number, or `None` if the task is finished. + pub async fn start_attempt(&self, id: Uuid, substate: &str) -> anyhow::Result> { + let sql_query = "UPDATE task + SET + state = :running, + substate = jsonb(:substate), + attempts = attempts + 1, + started_at = coalesce(started_at, unixepoch()) + WHERE id = :id AND state IN (:not_started, :running) + RETURNING attempts"; + + let params = ( + (":running", TaskState::Running.to_db()), + (":substate", substate), + (":id", id.to_string()), + (":not_started", TaskState::NotStarted.to_db()), + ); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + match rows.next().await.context("failed to read the row")? { + Some(row) => Ok(Some(row.get::(0).context("failed to read attempts")?)), + None => Ok(None), + } + } + + /// Updates the substate of a running task. + pub async fn set_substate(&self, id: Uuid, substate: &str) -> anyhow::Result<()> { + let sql_query = "UPDATE task SET substate = jsonb(:substate) WHERE id = :id AND state = :running"; + + let params = ( + (":substate", substate), + (":id", id.to_string()), + (":running", TaskState::Running.to_db()), + ); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + /// Puts a running task back to `NotStarted` until its next attempt, keeping the error of the failed attempt. + pub async fn retry_later(&self, id: Uuid, error: &str) -> anyhow::Result<()> { + let sql_query = "UPDATE task + SET state = :not_started, substate = NULL, error = :error + WHERE id = :id AND state = :running"; + + let params = ( + (":not_started", TaskState::NotStarted.to_db()), + (":error", error), + (":id", id.to_string()), + (":running", TaskState::Running.to_db()), + ); + + self.conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } + + /// Marks an unfinished task as successful; returns `false` if it was already finished. + pub async fn succeed(&self, id: Uuid, result: &str) -> anyhow::Result { + self.finish(id, TaskState::Success, Some(result), None).await + } + + /// Marks an unfinished task as failed; returns `false` if it was already finished. + pub async fn fail(&self, id: Uuid, error: &str) -> anyhow::Result { + self.finish(id, TaskState::Failed, None, Some(error)).await + } + + async fn finish( + &self, + id: Uuid, + state: TaskState, + result: Option<&str>, + error: Option<&str>, + ) -> anyhow::Result { + let sql_query = "UPDATE task + SET + state = :state, + substate = NULL, + result = jsonb(:result), + error = :error, + finished_at = unixepoch() + WHERE id = :id AND state IN (:not_started, :running)"; + + let params = ( + (":state", state.to_db()), + (":result", result), + (":error", error), + (":id", id.to_string()), + (":not_started", TaskState::NotStarted.to_db()), + (":running", TaskState::Running.to_db()), + ); + + let changed = self + .conn + .execute(sql_query, params) + .await + .context("failed to execute SQL query")?; + + Ok(changed > 0) + } + + /// IDs of the tasks that are not finished yet. + pub async fn unfinished(&self) -> anyhow::Result> { + let sql_query = "SELECT id FROM task WHERE state IN (:not_started, :running) ORDER BY created_at"; + + let params = ( + (":not_started", TaskState::NotStarted.to_db()), + (":running", TaskState::Running.to_db()), + ); + + let mut rows = self + .conn + .query(sql_query, params) + .await + .context("failed to execute SQL query")?; + + let mut ids = Vec::new(); + + while let Some(row) = rows.next().await.context("failed to read the row")? { + ids.push(read_uuid(&row, 0)?); + } + + Ok(ids) + } + + async fn migrate(&self) -> anyhow::Result<()> { + let schema_version = self.query_schema_version().await?; + + match MIGRATIONS.get(schema_version..) { + Some(remaining) if !remaining.is_empty() => { + info!( + schema_version, + migration_count = MIGRATIONS.len() - schema_version, + "Start migration" + ); + + for (sql_query, migration_id) in remaining.iter().zip(schema_version..MIGRATIONS.len()) { + trace!(migration_id, %sql_query, "Apply migration"); + + self.conn + .execute_batch(sql_query) + .await + .with_context(|| format!("failed to execute migration {migration_id}"))?; + + self.update_schema_version(migration_id + 1) + .await + .context("failed to update the schema version")?; + } + + info!("Migration complete"); + } + None => { + warn!(schema_version, "Task schema version is set to an unexpected value"); + } + _ => { + debug!(schema_version, "Database is already up to date"); + } + } + + Ok(()) + } + + async fn query_schema_version(&self) -> anyhow::Result { + self.conn + .execute( + "CREATE TABLE IF NOT EXISTS task_schema_version (version INT NOT NULL) STRICT", + (), + ) + .await + .context("failed to create the schema version table")?; + + let row = self + .conn + .query("SELECT coalesce(max(version), 0) FROM task_schema_version", ()) + .await + .context("failed to execute SQL query")? + .next() + .await + .context("failed to read the row")? + .context("no row returned")?; + + let value = row.get::(0).context("failed to read the schema version")?; + + usize::try_from(value).context("schema version is too big") + } + + async fn update_schema_version(&self, value: usize) -> anyhow::Result<()> { + let value = i64::try_from(value).context("schema version is too big")?; + + self.conn + .execute("INSERT INTO task_schema_version (version) VALUES (?1)", [value]) + .await + .context("failed to execute SQL query")?; + + Ok(()) + } +} + +fn read_uuid(row: &Row, idx: i32) -> anyhow::Result { + let text = row.get::(idx).context("failed to read UUID column")?; + Uuid::parse_str(&text).context("invalid UUID") +} + +fn read_record(row: &Row) -> anyhow::Result { + Ok(TaskRecord { + id: read_uuid(row, 0)?, + kind: row.get(1).context("failed to read kind")?, + target: row.get(2).context("failed to read target")?, + params: row.get(3).context("failed to read params")?, + state: TaskState::from_db(row.get(4).context("failed to read state")?)?, + substate: row.get(5).context("failed to read substate")?, + result: row.get(6).context("failed to read result")?, + error: row.get(7).context("failed to read error")?, + attempts: row.get(8).context("failed to read attempts")?, + token_jti: read_uuid(row, 9)?, + created_at: row.get(10).context("failed to read created_at")?, + started_at: row.get(11).context("failed to read started_at")?, + finished_at: row.get(12).context("failed to read finished_at")?, + updated_at: row.get(13).context("failed to read updated_at")?, + }) +} diff --git a/crates/provisioner-task-store-libsql/tests/store.rs b/crates/provisioner-task-store-libsql/tests/store.rs new file mode 100644 index 000000000..6b1022c9d --- /dev/null +++ b/crates/provisioner-task-store-libsql/tests/store.rs @@ -0,0 +1,195 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +use provisioner_task_store_libsql::{LibSqlProvisionerTaskStore, NewTask, TaskState}; +use uuid::Uuid; + +fn new_task<'a>(id: Uuid, token_jti: Uuid) -> NewTask<'a> { + NewTask { + id, + kind: "ai-log", + target: r#"{"sessionId":"3e2b1d6c-5d1a-4a8c-9a8c-1d6f9b2a4c11"}"#, + params: r#"{"provider":"openai","model":"gpt-test"}"#, + token_jti, + } +} + +async fn connect(path: &str) -> libsql::Connection { + libsql::Builder::new_local(path) + .build() + .await + .unwrap() + .connect() + .unwrap() +} + +async fn open(path: &str) -> LibSqlProvisionerTaskStore { + LibSqlProvisionerTaskStore::init(connect(path).await).await.unwrap() +} + +async fn memory_store() -> LibSqlProvisionerTaskStore { + open(":memory:").await +} + +async fn query_u64(conn: &libsql::Connection, sql_query: &str) -> u64 { + let row = conn.query(sql_query, ()).await.unwrap().next().await.unwrap().unwrap(); + row.get::(0).unwrap() +} + +#[tokio::test] +async fn migrations_leave_user_version_alone_and_are_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("provisioner_tasks.db"); + let path = path.to_str().unwrap(); + + // Another schema in the same database, such as the job queue, owns `user_version`. + let conn = connect(path).await; + conn.execute("PRAGMA user_version = 7", ()).await.unwrap(); + + let id = Uuid::new_v4(); + { + let store = LibSqlProvisionerTaskStore::init(conn.clone()).await.unwrap(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + + let store = open(path).await; + assert!(store.get(id).await.unwrap().is_some()); + + assert_eq!(query_u64(&conn, "PRAGMA user_version").await, 7); + assert_eq!( + query_u64(&conn, "SELECT max(version) FROM task_schema_version").await, + 1 + ); + assert_eq!(query_u64(&conn, "SELECT count(*) FROM task_schema_version").await, 1); +} + +#[tokio::test] +async fn insert_then_read_back() { + let store = memory_store().await; + let id = Uuid::new_v4(); + let jti = Uuid::new_v4(); + + store.insert(new_task(id, jti)).await.unwrap(); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.id, id); + assert_eq!(record.kind, "ai-log"); + assert_eq!(record.target, r#"{"sessionId":"3e2b1d6c-5d1a-4a8c-9a8c-1d6f9b2a4c11"}"#); + assert_eq!(record.params, r#"{"provider":"openai","model":"gpt-test"}"#); + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.attempts, 0); + assert_eq!(record.token_jti, jti); + assert!(record.substate.is_none() && record.result.is_none() && record.error.is_none()); + assert!(record.started_at.is_none() && record.finished_at.is_none()); + assert!(record.created_at > 0); + + assert!(store.get(Uuid::new_v4()).await.unwrap().is_none()); +} + +#[tokio::test] +async fn attempts_substate_retry_and_success() { + let store = memory_store().await; + let id = Uuid::new_v4(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + + assert_eq!( + store.start_attempt(id, r#"{"step":"preparing"}"#).await.unwrap(), + Some(1) + ); + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Running); + assert_eq!(record.substate.as_deref(), Some(r#"{"step":"preparing"}"#)); + assert!(record.started_at.is_some()); + + store.set_substate(id, r#"{"step":"reading"}"#).await.unwrap(); + assert_eq!( + store.get(id).await.unwrap().unwrap().substate.as_deref(), + Some(r#"{"step":"reading"}"#) + ); + + store.retry_later(id, "rate limited").await.unwrap(); + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.error.as_deref(), Some("rate limited")); + assert!(record.substate.is_none()); + + assert_eq!(store.start_attempt(id, "null").await.unwrap(), Some(2)); + assert!(store.succeed(id, r#"{"log":"log-1.slog"}"#).await.unwrap()); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Success); + assert_eq!(record.result.as_deref(), Some(r#"{"log":"log-1.slog"}"#)); + assert!(record.error.is_none()); + assert!(record.finished_at.is_some()); + assert_eq!(record.attempts, 2); +} + +#[tokio::test] +async fn finished_tasks_are_not_changed_again() { + let store = memory_store().await; + let id = Uuid::new_v4(); + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + + assert!(store.fail(id, "boom").await.unwrap()); + assert!(!store.fail(id, "again").await.unwrap()); + assert!(!store.succeed(id, "1").await.unwrap()); + assert_eq!(store.start_attempt(id, "null").await.unwrap(), None); + store.set_substate(id, "2").await.unwrap(); + + let record = store.get(id).await.unwrap().unwrap(); + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("boom")); + assert!(record.substate.is_none() && record.result.is_none()); +} + +#[tokio::test] +async fn unfinished_lists_not_started_and_running_tasks() { + let store = memory_store().await; + let [not_started, running, succeeded, failed] = [(); 4].map(|()| Uuid::new_v4()); + + for id in [not_started, running, succeeded, failed] { + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + + store.start_attempt(running, "null").await.unwrap(); + store.succeed(succeeded, "null").await.unwrap(); + store.fail(failed, "boom").await.unwrap(); + + let mut unfinished = store.unfinished().await.unwrap(); + unfinished.sort(); + let mut expected = vec![not_started, running]; + expected.sort(); + + assert_eq!(unfinished, expected); +} + +#[tokio::test] +async fn rows_are_never_deleted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("provisioner_tasks.db"); + let path = path.to_str().unwrap(); + + let ids = [(); 3].map(|()| Uuid::new_v4()); + { + let store = open(path).await; + for id in ids { + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + } + store.succeed(ids[0], "null").await.unwrap(); + store.fail(ids[1], "boom").await.unwrap(); + } + + let store = open(path).await; + for id in ids { + assert!(store.get(id).await.unwrap().is_some(), "{id}"); + } +} + +#[tokio::test] +async fn duplicate_id_is_rejected() { + let store = memory_store().await; + let id = Uuid::new_v4(); + + store.insert(new_task(id, Uuid::new_v4())).await.unwrap(); + assert!(store.insert(new_task(id, Uuid::new_v4())).await.is_err()); +} diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 6d68ec6e8..1d3adb78e 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -24,9 +24,11 @@ transport.path = "../crates/transport" jmux-proxy.path = "../crates/jmux-proxy" devolutions-agent-shared.path = "../crates/devolutions-agent-shared" devolutions-gateway-task.path = "../crates/devolutions-gateway-task" +devolutions-gateway-ai.path = "../crates/devolutions-gateway-ai" devolutions-log.path = "../crates/devolutions-log" job-queue.path = "../crates/job-queue" job-queue-libsql.path = "../crates/job-queue-libsql" +provisioner-task-store-libsql.path = "../crates/provisioner-task-store-libsql" traffic-audit.path = "../crates/traffic-audit" traffic-audit-libsql.path = "../crates/traffic-audit-libsql" network-scanner.path = "../crates/network-scanner" diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 04e0934d0..8e24cb2d0 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -47,6 +47,11 @@ Protocol-aware fine-grained relay server + +* *Bearer* Authentication `task_token` + + + * *HTTP Basic* Authentication `web_app_custom_auth` @@ -1278,6 +1283,10 @@ Retrieves a recording file for a given session ===== Content Type +* video/webm +* application/x-asciicast +* application/x-ndjson +* application/json * application/octet-stream ===== Responses @@ -2517,6 +2526,222 @@ ifdef::internal-generation[] endif::internal-generation[] +[.Tasks] +=== Tasks + + +[.getTask] +==== getTask + +`GET /jet/tasks/{id}` + +Gets the status of a background task. + +===== Description + +Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + + +// markup not found, no include::{specDir}jet/tasks/\{id\}/GET/spec.adoc[opts=optional] + + + +===== Security + +[cols="2,1,1"] +|=== +| Name | Type | Scheme + +| `scope_token` +| http +| bearer +|=== + +===== Parameters + +====== Path Parameters + +[cols="2,3,1,1,1"] +|=== +|Name| Description| Required| Default| Pattern + +| id +| Task ID +| X +| null +| + +|=== + + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 200 +| Task status +| <> + + +| 400 +| Bad request +| <<>> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 404 +| No task with this ID +| <> + + +| 500 +| Unexpected server error +| <> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/\{id\}/GET/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + +[.startTask] +==== startTask + +`POST /jet/tasks` + +Starts a background task. + +===== Description + +The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + + +// markup not found, no include::{specDir}jet/tasks/POST/spec.adoc[opts=optional] + + + +===== Security + +[cols="2,1,1"] +|=== +| Name | Type | Scheme + +| `task_token` +| http +| bearer +|=== + +===== Parameters + + +====== Body Parameter + +[cols="2,3,1,1,1"] +|=== +|Name| Description| Required| Default| Pattern + +| body +| Kind-specific task parameters, such as `AiLogParams` for `ai-log` <> +| X +| +| + +|=== + + + + + +===== Return Type + +<> + + +===== Content Type + +* application/json + +===== Responses + +.HTTP Response Codes +[cols="2,3,1"] +|=== +| Code | Message | Datatype + + +| 202 +| Task was accepted and runs in the background +| <> + + +| 400 +| Invalid task parameters +| <> + + +| 401 +| Invalid or missing authorization token +| <<>> + + +| 403 +| Insufficient permissions +| <<>> + + +| 409 +| The task target is busy, such as a session that is still recording +| <> + + +| 500 +| Unexpected server error +| <> + +|=== + + +ifdef::internal-generation[] +===== Implementation + +// markup not found, no include::{specDir}jet/tasks/POST/implementation.adoc[opts=optional] + + +endif::internal-generation[] + + [.Traffic] === Traffic @@ -3371,6 +3596,7 @@ endif::internal-generation[] | gateway.net.monitor.drain | gateway.agent.delete | gateway.agent.read +| gateway.tasks.read |=== @@ -3544,6 +3770,122 @@ endif::internal-generation[] |=== +[#AiLogParams] +=== _AiLogParams_ + +AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. + + +[.fields-AiLogParams] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| apiKey +| X +| +| String +| Kept in memory for this task only. +| + +| baseUrl +| +| X +| String +| Overrides the provider default; required for `openai-compatible`. +| + +| maxOutputTokens +| +| X +| Integer +| Upper bound of tokens in each AI answer. +| int32 + +| model +| X +| +| String +| Model identifier, passed to the provider as is. +| + +| provider +| X +| +| <> +| +| openai, anthropic, mistral, gemini, openai-compatible, + +|=== + + + +[#AiLogSubstate] +=== _AiLogSubstate_ + +Progress of a running `ai-log` task. + + +[.fields-AiLogSubstate] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| step +| X +| +| <> +| +| _Enum:_ preparing, + +|=== + + + +[#AiLogSubstateOneOf] +=== _AiLogSubstateOneOf_ + + + + +[.fields-AiLogSubstateOneOf] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| step +| X +| +| <> +| +| _Enum:_ preparing, + +|=== + + + +[#AiProvider] +=== _AiProvider_ + + + + + + +[.fields-AiProvider] +[cols="1"] +|=== +| Enum Values + +| openai +| anthropic +| mistral +| gemini +| openai-compatible + +|=== + + [#AppCredential] === _AppCredential_ @@ -5564,6 +5906,134 @@ Subscriber configuration +[#TaskErrorCode] +=== _TaskErrorCode_ + +Stable code telling a client why a task request failed; safe to show. + + + + +[.fields-TaskErrorCode] +[cols="1"] +|=== +| Enum Values + +| invalid_params +| missing_model +| missing_api_key +| missing_base_url +| invalid_ai_settings +| recording_active +| task_not_found +| internal + +|=== + + +[#TaskErrorResponse] +=== _TaskErrorResponse_ + +Why a task request failed. + + +[.fields-TaskErrorResponse] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| error +| X +| +| <> +| +| invalid_params, missing_model, missing_api_key, missing_base_url, invalid_ai_settings, recording_active, task_not_found, internal, + +|=== + + + +[#TaskInfo] +=== _TaskInfo_ + +A background task and its status. + +`substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. +Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + + +[.fields-TaskInfo] +[cols="2,1,1,2,4,1"] +|=== +| Field Name| Required| Nullable | Type| Description | Format + +| error +| +| X +| String +| Why the task failed. +| + +| id +| X +| +| UUID +| Task ID. +| uuid + +| kind +| X +| +| String +| Task kind, as in the `jet_tk` claim of the TASK token. +| + +| result +| +| X +| Object +| Result of a successful task. +| + +| state +| X +| +| <> +| +| not-started, running, success, failed, + +| substate +| +| X +| Object +| Progress of a running task. +| + +|=== + + + +[#TaskState] +=== _TaskState_ + + + + + + +[.fields-TaskState] +[cols="1"] +|=== +| Enum Values + +| not-started +| running +| success +| failed + +|=== + + [#TrafficEventResponse] === _TrafficEventResponse_ diff --git a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES index b49a09154..bf6f2ac19 100644 --- a/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/dotnet-client/.openapi-generator/FILES @@ -9,6 +9,10 @@ docs/AgentApi.md docs/AgentDomainAdvertisement.md docs/AgentInfo.md docs/AgentStatus.md +docs/AiLogParams.md +docs/AiLogSubstate.md +docs/AiLogSubstateOneOf.md +docs/AiProvider.md docs/AppCredential.md docs/AppCredentialKind.md docs/AppTokenContentType.md @@ -70,6 +74,11 @@ docs/SetUpdateScheduleRequest.md docs/SubProvisionerKey.md docs/Subscriber.md docs/TargetConnectionOptions.md +docs/TaskErrorCode.md +docs/TaskErrorResponse.md +docs/TaskInfo.md +docs/TaskState.md +docs/TasksApi.md docs/TrafficApi.md docs/TrafficEventResponse.md docs/TransportProtocolResponse.md @@ -88,6 +97,7 @@ src/Devolutions.Gateway.Client/Api/NetApi.cs src/Devolutions.Gateway.Client/Api/NetworkMonitoringApi.cs src/Devolutions.Gateway.Client/Api/PreflightApi.cs src/Devolutions.Gateway.Client/Api/SessionsApi.cs +src/Devolutions.Gateway.Client/Api/TasksApi.cs src/Devolutions.Gateway.Client/Api/TrafficApi.cs src/Devolutions.Gateway.Client/Api/UpdateApi.cs src/Devolutions.Gateway.Client/Api/WebAppApi.cs @@ -117,6 +127,10 @@ src/Devolutions.Gateway.Client/Model/AddressFamily.cs src/Devolutions.Gateway.Client/Model/AgentDomainAdvertisement.cs src/Devolutions.Gateway.Client/Model/AgentInfo.cs src/Devolutions.Gateway.Client/Model/AgentStatus.cs +src/Devolutions.Gateway.Client/Model/AiLogParams.cs +src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs +src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs +src/Devolutions.Gateway.Client/Model/AiProvider.cs src/Devolutions.Gateway.Client/Model/AppCredential.cs src/Devolutions.Gateway.Client/Model/AppCredentialKind.cs src/Devolutions.Gateway.Client/Model/AppTokenContentType.cs @@ -168,6 +182,10 @@ src/Devolutions.Gateway.Client/Model/SetUpdateScheduleRequest.cs src/Devolutions.Gateway.Client/Model/SubProvisionerKey.cs src/Devolutions.Gateway.Client/Model/Subscriber.cs src/Devolutions.Gateway.Client/Model/TargetConnectionOptions.cs +src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs +src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs +src/Devolutions.Gateway.Client/Model/TaskInfo.cs +src/Devolutions.Gateway.Client/Model/TaskState.cs src/Devolutions.Gateway.Client/Model/TrafficEventResponse.cs src/Devolutions.Gateway.Client/Model/TransportProtocolResponse.cs src/Devolutions.Gateway.Client/Model/UpdateProductInfo.cs diff --git a/devolutions-gateway/openapi/dotnet-client/README.md b/devolutions-gateway/openapi/dotnet-client/README.md index c60cd47e2..f90a874e1 100644 --- a/devolutions-gateway/openapi/dotnet-client/README.md +++ b/devolutions-gateway/openapi/dotnet-client/README.md @@ -166,6 +166,8 @@ Class | Method | HTTP request | Description *PreflightApi* | [**PostPreflight**](docs/PreflightApi.md#postpreflight) | **POST** /jet/preflight | Performs a batch of preflight operations *SessionsApi* | [**GetSessions**](docs/SessionsApi.md#getsessions) | **GET** /jet/sessions | Lists running sessions *SessionsApi* | [**TerminateSession**](docs/SessionsApi.md#terminatesession) | **POST** /jet/session/{id}/terminate | Terminate forcefully a running session +*TasksApi* | [**GetTask**](docs/TasksApi.md#gettask) | **GET** /jet/tasks/{id} | Gets the status of a background task. +*TasksApi* | [**StartTask**](docs/TasksApi.md#starttask) | **POST** /jet/tasks | Starts a background task. *TrafficApi* | [**AckTrafficEvents**](docs/TrafficApi.md#acktrafficevents) | **POST** /jet/traffic/ack | Acknowledge traffic audit events and remove them from the queue *TrafficApi* | [**ClaimTrafficEvents**](docs/TrafficApi.md#claimtrafficevents) | **POST** /jet/traffic/claim | Claim traffic audit events for processing *UpdateApi* | [**GetUpdateProducts**](docs/UpdateApi.md#getupdateproducts) | **GET** /jet/update | Retrieve the currently installed version of each Devolutions product. @@ -186,6 +188,10 @@ Class | Method | HTTP request | Description - [Model.AgentDomainAdvertisement](docs/AgentDomainAdvertisement.md) - [Model.AgentInfo](docs/AgentInfo.md) - [Model.AgentStatus](docs/AgentStatus.md) + - [Model.AiLogParams](docs/AiLogParams.md) + - [Model.AiLogSubstate](docs/AiLogSubstate.md) + - [Model.AiLogSubstateOneOf](docs/AiLogSubstateOneOf.md) + - [Model.AiProvider](docs/AiProvider.md) - [Model.AppCredential](docs/AppCredential.md) - [Model.AppCredentialKind](docs/AppCredentialKind.md) - [Model.AppTokenContentType](docs/AppTokenContentType.md) @@ -237,6 +243,10 @@ Class | Method | HTTP request | Description - [Model.SubProvisionerKey](docs/SubProvisionerKey.md) - [Model.Subscriber](docs/Subscriber.md) - [Model.TargetConnectionOptions](docs/TargetConnectionOptions.md) + - [Model.TaskErrorCode](docs/TaskErrorCode.md) + - [Model.TaskErrorResponse](docs/TaskErrorResponse.md) + - [Model.TaskInfo](docs/TaskInfo.md) + - [Model.TaskState](docs/TaskState.md) - [Model.TrafficEventResponse](docs/TrafficEventResponse.md) - [Model.TransportProtocolResponse](docs/TransportProtocolResponse.md) - [Model.UpdateProductInfo](docs/UpdateProductInfo.md) @@ -273,6 +283,11 @@ Authentication schemes defined for the API: - **Type**: Bearer Authentication + +### task_token + +- **Type**: Bearer Authentication + ### web_app_custom_auth diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md new file mode 100644 index 000000000..752eb6045 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogParams.md @@ -0,0 +1,15 @@ +# Devolutions.Gateway.Client.Model.AiLogParams +AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**ApiKey** | **string** | Kept in memory for this task only. | +**BaseUrl** | **string** | Overrides the provider default; required for `openai-compatible`. | [optional] +**MaxOutputTokens** | **int?** | Upper bound of tokens in each AI answer. | [optional] +**Model** | **string** | Model identifier, passed to the provider as is. | +**Provider** | **AiProvider** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md new file mode 100644 index 000000000..ec54cc1b0 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstate.md @@ -0,0 +1,11 @@ +# Devolutions.Gateway.Client.Model.AiLogSubstate +Progress of a running `ai-log` task. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Step** | **string** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md new file mode 100644 index 000000000..5a6951a48 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiLogSubstateOneOf.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.AiLogSubstateOneOf + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Step** | **string** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md b/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md new file mode 100644 index 000000000..18c707aca --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/AiProvider.md @@ -0,0 +1,9 @@ +# Devolutions.Gateway.Client.Model.AiProvider + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md new file mode 100644 index 000000000..2151e535f --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorCode.md @@ -0,0 +1,10 @@ +# Devolutions.Gateway.Client.Model.TaskErrorCode +Stable code telling a client why a task request failed; safe to show. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md new file mode 100644 index 000000000..7bd398a7b --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskErrorResponse.md @@ -0,0 +1,11 @@ +# Devolutions.Gateway.Client.Model.TaskErrorResponse +Why a task request failed. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Error** | **TaskErrorCode** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md new file mode 100644 index 000000000..39b90c3f7 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskInfo.md @@ -0,0 +1,16 @@ +# Devolutions.Gateway.Client.Model.TaskInfo +A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**Error** | **string** | Why the task failed. | [optional] +**Id** | **Guid** | Task ID. | +**Kind** | **string** | Task kind, as in the `jet_tk` claim of the TASK token. | +**Result** | **Object** | Result of a successful task. | [optional] +**State** | **TaskState** | | +**Substate** | **Object** | Progress of a running task. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md b/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md new file mode 100644 index 000000000..40328b8ec --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TaskState.md @@ -0,0 +1,9 @@ +# Devolutions.Gateway.Client.Model.TaskState + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md new file mode 100644 index 000000000..e3644008e --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/docs/TasksApi.md @@ -0,0 +1,215 @@ +# Devolutions.Gateway.Client.Api.TasksApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|--------|--------------|-------------| +| [**GetTask**](TasksApi.md#gettask) | **GET** /jet/tasks/{id} | Gets the status of a background task. | +| [**StartTask**](TasksApi.md#starttask) | **POST** /jet/tasks | Starts a background task. | + + +# **GetTask** +> TaskInfo GetTask (Guid id) + +Gets the status of a background task. + +Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + +### Example +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using Devolutions.Gateway.Client.Api; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Example +{ + public class GetTaskExample + { + public static void Main() + { + Configuration config = new Configuration(); + config.BasePath = "http://localhost"; + // Configure Bearer token for authorization: scope_token + config.AccessToken = "YOUR_BEARER_TOKEN"; + + // create instances of HttpClient, HttpClientHandler to be reused later with different Api classes + HttpClient httpClient = new HttpClient(); + HttpClientHandler httpClientHandler = new HttpClientHandler(); + var apiInstance = new TasksApi(httpClient, config, httpClientHandler); + var id = "id_example"; // Guid | Task ID + + try + { + // Gets the status of a background task. + TaskInfo result = apiInstance.GetTask(id); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.GetTask: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the GetTaskWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Gets the status of a background task. + ApiResponse response = apiInstance.GetTaskWithHttpInfo(id); + Debug.Write("Status Code: " + response.StatusCode); + Debug.Write("Response Headers: " + response.Headers); + Debug.Write("Response Body: " + response.Data); +} +catch (ApiException e) +{ + Debug.Print("Exception when calling TasksApi.GetTaskWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **id** | **Guid** | Task ID | | + +### Return type + +[**TaskInfo**](TaskInfo.md) + +### Authorization + +[scope_token](../README.md#scope_token) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | Task status | - | +| **400** | Bad request | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **404** | No task with this ID | - | +| **500** | Unexpected server error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +# **StartTask** +> TaskInfo StartTask (Object body) + +Starts a background task. + +The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + +### Example +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using Devolutions.Gateway.Client.Api; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Example +{ + public class StartTaskExample + { + public static void Main() + { + Configuration config = new Configuration(); + config.BasePath = "http://localhost"; + // Configure Bearer token for authorization: task_token + config.AccessToken = "YOUR_BEARER_TOKEN"; + + // create instances of HttpClient, HttpClientHandler to be reused later with different Api classes + HttpClient httpClient = new HttpClient(); + HttpClientHandler httpClientHandler = new HttpClientHandler(); + var apiInstance = new TasksApi(httpClient, config, httpClientHandler); + var body = null; // Object | Kind-specific task parameters, such as `AiLogParams` for `ai-log` + + try + { + // Starts a background task. + TaskInfo result = apiInstance.StartTask(body); + Debug.WriteLine(result); + } + catch (ApiException e) + { + Debug.Print("Exception when calling TasksApi.StartTask: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); + } + } + } +} +``` + +#### Using the StartTaskWithHttpInfo variant +This returns an ApiResponse object which contains the response data, status code and headers. + +```csharp +try +{ + // Starts a background task. + ApiResponse response = apiInstance.StartTaskWithHttpInfo(body); + Debug.Write("Status Code: " + response.StatusCode); + Debug.Write("Response Headers: " + response.Headers); + Debug.Write("Response Body: " + response.Data); +} +catch (ApiException e) +{ + Debug.Print("Exception when calling TasksApi.StartTaskWithHttpInfo: " + e.Message); + Debug.Print("Status Code: " + e.ErrorCode); + Debug.Print(e.StackTrace); +} +``` + +### Parameters + +| Name | Type | Description | Notes | +|------|------|-------------|-------| +| **body** | **Object** | Kind-specific task parameters, such as `AiLogParams` for `ai-log` | | + +### Return type + +[**TaskInfo**](TaskInfo.md) + +### Authorization + +[task_token](../README.md#task_token) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **202** | Task was accepted and runs in the background | - | +| **400** | Invalid task parameters | - | +| **401** | Invalid or missing authorization token | - | +| **403** | Insufficient permissions | - | +| **409** | The task target is busy, such as a session that is still recording | - | +| **500** | Unexpected server error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs new file mode 100644 index 000000000..1afbd8981 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Api/TasksApi.cs @@ -0,0 +1,590 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using Devolutions.Gateway.Client.Client; +using Devolutions.Gateway.Client.Model; + +namespace Devolutions.Gateway.Client.Api +{ + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiSync : IApiAccessor + { + #region Synchronous Operations + /// + /// Gets the status of a background task. + /// + /// + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// TaskInfo + TaskInfo GetTask(Guid id); + + /// + /// Gets the status of a background task. + /// + /// + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// ApiResponse of TaskInfo + ApiResponse GetTaskWithHttpInfo(Guid id); + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// TaskInfo + TaskInfo StartTask(Object body); + + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// ApiResponse of TaskInfo + ApiResponse StartTaskWithHttpInfo(Object body); + #endregion Synchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApiAsync : IApiAccessor + { + #region Asynchronous Operations + /// + /// Gets the status of a background task. + /// + /// + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + System.Threading.Tasks.Task GetTaskAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Gets the status of a background task. + /// + /// + /// Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + System.Threading.Tasks.Task> GetTaskWithHttpInfoAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + System.Threading.Tasks.Task StartTaskAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + + /// + /// Starts a background task. + /// + /// + /// The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)); + #endregion Asynchronous Operations + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public interface ITasksApi : ITasksApiSync, ITasksApiAsync + { + + } + + /// + /// Represents a collection of functions to interact with the API endpoints + /// + public partial class TasksApi : IDisposable, ITasksApi + { + private Devolutions.Gateway.Client.Client.ExceptionFactory _exceptionFactory = (name, response) => null; + + /// + /// Initializes a new instance of the class. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// + public TasksApi() : this((string)null) + { + } + + /// + /// Initializes a new instance of the class. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// The target service's base path in URL format. + /// + /// + public TasksApi(string basePath) + { + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + new Devolutions.Gateway.Client.Client.Configuration { BasePath = basePath } + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(this.Configuration.BasePath); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class using Configuration object. + /// **IMPORTANT** This will also create an instance of HttpClient, which is less than ideal. + /// It's better to reuse the HttpClient and HttpClientHandler. + /// + /// An instance of Configuration. + /// + /// + public TasksApi(Devolutions.Gateway.Client.Client.Configuration configuration) + { + if (configuration == null) throw new ArgumentNullException("configuration"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + configuration + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(this.Configuration.BasePath); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of HttpClient. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, HttpClientHandler handler = null) : this(client, (string)null, handler) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of HttpClient. + /// The target service's base path in URL format. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, string basePath, HttpClientHandler handler = null) + { + if (client == null) throw new ArgumentNullException("client"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + new Devolutions.Gateway.Client.Client.Configuration { BasePath = basePath } + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(client, this.Configuration.BasePath, handler); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class using Configuration object. + /// + /// An instance of HttpClient. + /// An instance of Configuration. + /// An optional instance of HttpClientHandler that is used by HttpClient. + /// + /// + /// + /// Some configuration settings will not be applied without passing an HttpClientHandler. + /// The features affected are: Setting and Retrieving Cookies, Client Certificates, Proxy settings. + /// + public TasksApi(HttpClient client, Devolutions.Gateway.Client.Client.Configuration configuration, HttpClientHandler handler = null) + { + if (configuration == null) throw new ArgumentNullException("configuration"); + if (client == null) throw new ArgumentNullException("client"); + + this.Configuration = Devolutions.Gateway.Client.Client.Configuration.MergeConfigurations( + Devolutions.Gateway.Client.Client.GlobalConfiguration.Instance, + configuration + ); + this.ApiClient = new Devolutions.Gateway.Client.Client.ApiClient(client, this.Configuration.BasePath, handler); + this.Client = this.ApiClient; + this.AsynchronousClient = this.ApiClient; + ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Initializes a new instance of the class + /// using a Configuration object and client instance. + /// + /// The client interface for synchronous API access. + /// The client interface for asynchronous API access. + /// The configuration object. + /// + public TasksApi(Devolutions.Gateway.Client.Client.ISynchronousClient client, Devolutions.Gateway.Client.Client.IAsynchronousClient asyncClient, Devolutions.Gateway.Client.Client.IReadableConfiguration configuration) + { + if (client == null) throw new ArgumentNullException("client"); + if (asyncClient == null) throw new ArgumentNullException("asyncClient"); + if (configuration == null) throw new ArgumentNullException("configuration"); + + this.Client = client; + this.AsynchronousClient = asyncClient; + this.Configuration = configuration; + this.ExceptionFactory = Devolutions.Gateway.Client.Client.Configuration.DefaultExceptionFactory; + } + + /// + /// Disposes resources if they were created by us + /// + public void Dispose() + { + this.ApiClient?.Dispose(); + } + + /// + /// Holds the ApiClient if created + /// + public Devolutions.Gateway.Client.Client.ApiClient ApiClient { get; set; } = null; + + /// + /// The client for accessing this underlying API asynchronously. + /// + public Devolutions.Gateway.Client.Client.IAsynchronousClient AsynchronousClient { get; set; } + + /// + /// The client for accessing this underlying API synchronously. + /// + public Devolutions.Gateway.Client.Client.ISynchronousClient Client { get; set; } + + /// + /// Gets the base path of the API client. + /// + /// The base path + public string GetBasePath() + { + return this.Configuration.BasePath; + } + + /// + /// Gets or sets the configuration object + /// + /// An instance of the Configuration + public Devolutions.Gateway.Client.Client.IReadableConfiguration Configuration { get; set; } + + /// + /// Provides a factory method hook for the creation of exceptions. + /// + public Devolutions.Gateway.Client.Client.ExceptionFactory ExceptionFactory + { + get + { + if (_exceptionFactory != null && _exceptionFactory.GetInvocationList().Length > 1) + { + throw new InvalidOperationException("Multicast delegate for ExceptionFactory is unsupported."); + } + return _exceptionFactory; + } + set { _exceptionFactory = value; } + } + + /// + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// TaskInfo + public TaskInfo GetTask(Guid id) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = GetTaskWithHttpInfo(id); + return localVarResponse.Data; + } + + /// + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// ApiResponse of TaskInfo + public Devolutions.Gateway.Client.Client.ApiResponse GetTaskWithHttpInfo(Guid id) + { + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.PathParameters.Add("id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(id)); // path parameter + + // authentication (scope_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + var localVarResponse = this.Client.Get("/jet/tasks/{id}", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + public async System.Threading.Tasks.Task GetTaskAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await GetTaskWithHttpInfoAsync(id, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Gets the status of a background task. Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Task ID + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + public async System.Threading.Tasks.Task> GetTaskWithHttpInfoAsync(Guid id, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.PathParameters.Add("id", Devolutions.Gateway.Client.Client.ClientUtils.ParameterToString(id)); // path parameter + + // authentication (scope_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + + var localVarResponse = await this.AsynchronousClient.GetAsync("/jet/tasks/{id}", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("GetTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// TaskInfo + public TaskInfo StartTask(Object body) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = StartTaskWithHttpInfo(body); + return localVarResponse.Data; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// ApiResponse of TaskInfo + public Devolutions.Gateway.Client.Client.ApiResponse StartTaskWithHttpInfo(Object body) + { + // verify the required parameter 'body' is set + if (body == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'body' when calling TasksApi->StartTask"); + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + "application/json" + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.Data = body; + + // authentication (task_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + var localVarResponse = this.Client.Post("/jet/tasks", localVarRequestOptions, this.Configuration); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// Cancellation Token to cancel the request. + /// Task of TaskInfo + public async System.Threading.Tasks.Task StartTaskAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + Devolutions.Gateway.Client.Client.ApiResponse localVarResponse = await StartTaskWithHttpInfoAsync(body, cancellationToken).ConfigureAwait(false); + return localVarResponse.Data; + } + + /// + /// Starts a background task. The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + /// + /// Thrown when fails to make API call + /// Kind-specific task parameters, such as `AiLogParams` for `ai-log` + /// Cancellation Token to cancel the request. + /// Task of ApiResponse (TaskInfo) + public async System.Threading.Tasks.Task> StartTaskWithHttpInfoAsync(Object body, System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + // verify the required parameter 'body' is set + if (body == null) + throw new Devolutions.Gateway.Client.Client.ApiException(400, "Missing required parameter 'body' when calling TasksApi->StartTask"); + + + Devolutions.Gateway.Client.Client.RequestOptions localVarRequestOptions = new Devolutions.Gateway.Client.Client.RequestOptions(); + + string[] _contentTypes = new string[] { + "application/json" + }; + + // to determine the Accept header + string[] _accepts = new string[] { + "application/json" + }; + + + var localVarContentType = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderContentType(_contentTypes); + if (localVarContentType != null) localVarRequestOptions.HeaderParameters.Add("Content-Type", localVarContentType); + + var localVarAccept = Devolutions.Gateway.Client.Client.ClientUtils.SelectHeaderAccept(_accepts); + if (localVarAccept != null) localVarRequestOptions.HeaderParameters.Add("Accept", localVarAccept); + + localVarRequestOptions.Data = body; + + // authentication (task_token) required + // bearer authentication required + if (!string.IsNullOrEmpty(this.Configuration.AccessToken) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) + { + localVarRequestOptions.HeaderParameters.Add("Authorization", "Bearer " + this.Configuration.AccessToken); + } + + // make the HTTP request + + var localVarResponse = await this.AsynchronousClient.PostAsync("/jet/tasks", localVarRequestOptions, this.Configuration, cancellationToken).ConfigureAwait(false); + + if (this.ExceptionFactory != null) + { + Exception _exception = this.ExceptionFactory("StartTask", localVarResponse); + if (_exception != null) throw _exception; + } + + return localVarResponse; + } + + } +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs index 98c7ac937..ccebaafa4 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AccessScope.cs @@ -145,7 +145,13 @@ public enum AccessScope /// Enum GatewayAgentRead for value: gateway.agent.read /// [EnumMember(Value = "gateway.agent.read")] - GatewayAgentRead = 19 + GatewayAgentRead = 19, + + /// + /// Enum GatewayTasksRead for value: gateway.tasks.read + /// + [EnumMember(Value = "gateway.tasks.read")] + GatewayTasksRead = 20 } public static class AccessScopeExtensions @@ -195,6 +201,8 @@ public static string ToValue(this AccessScope variant) return "gateway.agent.delete"; case AccessScope.GatewayAgentRead: return "gateway.agent.read"; + case AccessScope.GatewayTasksRead: + return "gateway.tasks.read"; default: throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); } diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs new file mode 100644 index 000000000..7205cd3cb --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogParams.cs @@ -0,0 +1,145 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. + /// + [DataContract(Name = "AiLogParams")] + public partial class AiLogParams : IValidatableObject + { + + /// + /// Gets or Sets Provider + /// + [DataMember(Name = "provider", IsRequired = true, EmitDefaultValue = true)] + public AiProvider Provider { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiLogParams() { } + /// + /// Initializes a new instance of the class. + /// + /// Kept in memory for this task only. (required). + /// Overrides the provider default; required for `openai-compatible`.. + /// Upper bound of tokens in each AI answer.. + /// Model identifier, passed to the provider as is. (required). + /// provider (required). + public AiLogParams(string apiKey = default(string), string baseUrl = default(string), int? maxOutputTokens = default(int?), string model = default(string), AiProvider provider = default(AiProvider)) + { + // to ensure "apiKey" is required (not null) + if (apiKey == null) + { + throw new ArgumentNullException("apiKey is a required property for AiLogParams and cannot be null"); + } + this.ApiKey = apiKey; + // to ensure "model" is required (not null) + if (model == null) + { + throw new ArgumentNullException("model is a required property for AiLogParams and cannot be null"); + } + this.Model = model; + this.Provider = provider; + this.BaseUrl = baseUrl; + this.MaxOutputTokens = maxOutputTokens; + } + + /// + /// Kept in memory for this task only. + /// + /// Kept in memory for this task only. + [DataMember(Name = "apiKey", IsRequired = true, EmitDefaultValue = true)] + public string ApiKey { get; set; } + + /// + /// Overrides the provider default; required for `openai-compatible`. + /// + /// Overrides the provider default; required for `openai-compatible`. + [DataMember(Name = "baseUrl", EmitDefaultValue = true)] + public string BaseUrl { get; set; } + + /// + /// Upper bound of tokens in each AI answer. + /// + /// Upper bound of tokens in each AI answer. + [DataMember(Name = "maxOutputTokens", EmitDefaultValue = true)] + public int? MaxOutputTokens { get; set; } + + /// + /// Model identifier, passed to the provider as is. + /// + /// Model identifier, passed to the provider as is. + [DataMember(Name = "model", IsRequired = true, EmitDefaultValue = true)] + public string Model { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class AiLogParams {\n"); + sb.Append(" ApiKey: ").Append(ApiKey).Append("\n"); + sb.Append(" BaseUrl: ").Append(BaseUrl).Append("\n"); + sb.Append(" MaxOutputTokens: ").Append(MaxOutputTokens).Append("\n"); + sb.Append(" Model: ").Append(Model).Append("\n"); + sb.Append(" Provider: ").Append(Provider).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + // MaxOutputTokens (int?) minimum + if (this.MaxOutputTokens < (int?)0) + { + yield return new ValidationResult("Invalid value for MaxOutputTokens, must be a value greater than or equal to 0.", new [] { "MaxOutputTokens" }); + } + + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs new file mode 100644 index 000000000..dd005cf5b --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstate.cs @@ -0,0 +1,216 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; +using System.Reflection; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Progress of a running `ai-log` task. + /// + [JsonConverter(typeof(AiLogSubstateJsonConverter))] + [DataContract(Name = "AiLogSubstate")] + public partial class AiLogSubstate : AbstractOpenAPISchema, IValidatableObject + { + /// + /// Initializes a new instance of the class + /// with the class + /// + /// An instance of AiLogSubstateOneOf. + public AiLogSubstate(AiLogSubstateOneOf actualInstance) + { + this.IsNullable = false; + this.SchemaType= "oneOf"; + this.ActualInstance = actualInstance ?? throw new ArgumentException("Invalid instance found. Must not be null."); + } + + + private Object _actualInstance; + + /// + /// Gets or Sets ActualInstance + /// + public override Object ActualInstance + { + get + { + return _actualInstance; + } + set + { + if (value.GetType() == typeof(AiLogSubstateOneOf) || value is AiLogSubstateOneOf) + { + this._actualInstance = value; + } + else + { + throw new ArgumentException("Invalid instance found. Must be the following types: AiLogSubstateOneOf"); + } + } + } + + /// + /// Get the actual instance of `AiLogSubstateOneOf`. If the actual instance is not `AiLogSubstateOneOf`, + /// the InvalidClassException will be thrown + /// + /// An instance of AiLogSubstateOneOf + public AiLogSubstateOneOf GetAiLogSubstateOneOf() + { + return (AiLogSubstateOneOf)this.ActualInstance; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("class AiLogSubstate {\n"); + sb.Append(" ActualInstance: ").Append(this.ActualInstance).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public override string ToJson() + { + return JsonConvert.SerializeObject(this.ActualInstance, AiLogSubstate.SerializerSettings); + } + + /// + /// Converts the JSON string into an instance of AiLogSubstate + /// + /// JSON string + /// An instance of AiLogSubstate + public static AiLogSubstate FromJson(string jsonString) + { + AiLogSubstate newAiLogSubstate = null; + + if (string.IsNullOrEmpty(jsonString)) + { + return newAiLogSubstate; + } + int match = 0; + List matchedTypes = new List(); + + try + { + // if it does not contains "AdditionalProperties", use SerializerSettings to deserialize + if (typeof(AiLogSubstateOneOf).GetProperty("AdditionalProperties") == null) + { + newAiLogSubstate = new AiLogSubstate(JsonConvert.DeserializeObject(jsonString, AiLogSubstate.SerializerSettings)); + } + else + { + newAiLogSubstate = new AiLogSubstate(JsonConvert.DeserializeObject(jsonString, AiLogSubstate.AdditionalPropertiesSerializerSettings)); + } + matchedTypes.Add("AiLogSubstateOneOf"); + match++; + } + catch (Exception exception) + { + // deserialization failed, try the next one + System.Diagnostics.Debug.WriteLine(string.Format("Failed to deserialize `{0}` into AiLogSubstateOneOf: {1}", jsonString, exception.ToString())); + } + + if (match == 0) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` cannot be deserialized into any schema defined."); + } + else if (match > 1) + { + throw new InvalidDataException("The JSON string `" + jsonString + "` incorrectly matches more than one schema (should be exactly one match): " + String.Join(",", matchedTypes)); + } + + // deserialization is considered successful at this point if no exception has been thrown. + return newAiLogSubstate; + } + + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + + /// + /// Custom JSON converter for AiLogSubstate + /// + public class AiLogSubstateJsonConverter : JsonConverter + { + /// + /// To write the JSON string + /// + /// JSON writer + /// Object to be converted into a JSON string + /// JSON Serializer + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteRawValue((string)(typeof(AiLogSubstate).GetMethod("ToJson").Invoke(value, null))); + } + + /// + /// To convert a JSON string into an object + /// + /// JSON reader + /// Object type + /// Existing value + /// JSON Serializer + /// The object converted from the JSON string + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + switch(reader.TokenType) + { + case JsonToken.StartObject: + return AiLogSubstate.FromJson(JObject.Load(reader).ToString(Formatting.None)); + case JsonToken.StartArray: + return AiLogSubstate.FromJson(JArray.Load(reader).ToString(Formatting.None)); + default: + return null; + } + } + + /// + /// Check if the object can be converted + /// + /// Object type + /// True if the object can be converted + public override bool CanConvert(Type objectType) + { + return false; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs new file mode 100644 index 000000000..f0019c489 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiLogSubstateOneOf.cs @@ -0,0 +1,102 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// AiLogSubstateOneOf + /// + [DataContract(Name = "AiLogSubstate_oneOf")] + public partial class AiLogSubstateOneOf : IValidatableObject + { + /// + /// Defines Step + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum StepEnum + { + /// + /// Enum Preparing for value: preparing + /// + [EnumMember(Value = "preparing")] + Preparing = 1 + } + + + /// + /// Gets or Sets Step + /// + [DataMember(Name = "step", IsRequired = true, EmitDefaultValue = true)] + public StepEnum Step { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected AiLogSubstateOneOf() { } + /// + /// Initializes a new instance of the class. + /// + /// step (required). + public AiLogSubstateOneOf(StepEnum step = default(StepEnum)) + { + this.Step = step; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class AiLogSubstateOneOf {\n"); + sb.Append(" Step: ").Append(Step).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs new file mode 100644 index 000000000..fe67c8516 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/AiProvider.cs @@ -0,0 +1,92 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Defines AiProvider + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum AiProvider + { + /// + /// Enum Openai for value: openai + /// + [EnumMember(Value = "openai")] + Openai = 1, + + /// + /// Enum Anthropic for value: anthropic + /// + [EnumMember(Value = "anthropic")] + Anthropic = 2, + + /// + /// Enum Mistral for value: mistral + /// + [EnumMember(Value = "mistral")] + Mistral = 3, + + /// + /// Enum Gemini for value: gemini + /// + [EnumMember(Value = "gemini")] + Gemini = 4, + + /// + /// Enum OpenaiCompatible for value: openai-compatible + /// + [EnumMember(Value = "openai-compatible")] + OpenaiCompatible = 5 + } + + public static class AiProviderExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this AiProvider variant) + { + switch (variant) + { + case AiProvider.Openai: + return "openai"; + case AiProvider.Anthropic: + return "anthropic"; + case AiProvider.Mistral: + return "mistral"; + case AiProvider.Gemini: + return "gemini"; + case AiProvider.OpenaiCompatible: + return "openai-compatible"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs new file mode 100644 index 000000000..f94171e13 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorCode.cs @@ -0,0 +1,117 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Stable code telling a client why a task request failed; safe to show. + /// + /// Stable code telling a client why a task request failed; safe to show. + [JsonConverter(typeof(StringEnumConverter))] + public enum TaskErrorCode + { + /// + /// Enum InvalidParams for value: invalid_params + /// + [EnumMember(Value = "invalid_params")] + InvalidParams = 1, + + /// + /// Enum MissingModel for value: missing_model + /// + [EnumMember(Value = "missing_model")] + MissingModel = 2, + + /// + /// Enum MissingApiKey for value: missing_api_key + /// + [EnumMember(Value = "missing_api_key")] + MissingApiKey = 3, + + /// + /// Enum MissingBaseUrl for value: missing_base_url + /// + [EnumMember(Value = "missing_base_url")] + MissingBaseUrl = 4, + + /// + /// Enum InvalidAiSettings for value: invalid_ai_settings + /// + [EnumMember(Value = "invalid_ai_settings")] + InvalidAiSettings = 5, + + /// + /// Enum RecordingActive for value: recording_active + /// + [EnumMember(Value = "recording_active")] + RecordingActive = 6, + + /// + /// Enum TaskNotFound for value: task_not_found + /// + [EnumMember(Value = "task_not_found")] + TaskNotFound = 7, + + /// + /// Enum Internal for value: internal + /// + [EnumMember(Value = "internal")] + Internal = 8 + } + + public static class TaskErrorCodeExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this TaskErrorCode variant) + { + switch (variant) + { + case TaskErrorCode.InvalidParams: + return "invalid_params"; + case TaskErrorCode.MissingModel: + return "missing_model"; + case TaskErrorCode.MissingApiKey: + return "missing_api_key"; + case TaskErrorCode.MissingBaseUrl: + return "missing_base_url"; + case TaskErrorCode.InvalidAiSettings: + return "invalid_ai_settings"; + case TaskErrorCode.RecordingActive: + return "recording_active"; + case TaskErrorCode.TaskNotFound: + return "task_not_found"; + case TaskErrorCode.Internal: + return "internal"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs new file mode 100644 index 000000000..e74f0451b --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskErrorResponse.cs @@ -0,0 +1,89 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Why a task request failed. + /// + [DataContract(Name = "TaskErrorResponse")] + public partial class TaskErrorResponse : IValidatableObject + { + + /// + /// Gets or Sets Error + /// + [DataMember(Name = "error", IsRequired = true, EmitDefaultValue = true)] + public TaskErrorCode Error { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected TaskErrorResponse() { } + /// + /// Initializes a new instance of the class. + /// + /// error (required). + public TaskErrorResponse(TaskErrorCode error = default(TaskErrorCode)) + { + this.Error = error; + } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class TaskErrorResponse {\n"); + sb.Append(" Error: ").Append(Error).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs new file mode 100644 index 000000000..bf47d9540 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskInfo.cs @@ -0,0 +1,144 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + /// + [DataContract(Name = "TaskInfo")] + public partial class TaskInfo : IValidatableObject + { + + /// + /// Gets or Sets State + /// + [DataMember(Name = "state", IsRequired = true, EmitDefaultValue = true)] + public TaskState State { get; set; } + /// + /// Initializes a new instance of the class. + /// + [JsonConstructorAttribute] + protected TaskInfo() { } + /// + /// Initializes a new instance of the class. + /// + /// Why the task failed.. + /// Task ID. (required). + /// Task kind, as in the `jet_tk` claim of the TASK token. (required). + /// Result of a successful task.. + /// state (required). + /// Progress of a running task.. + public TaskInfo(string error = default(string), Guid id = default(Guid), string kind = default(string), Object result = default(Object), TaskState state = default(TaskState), Object substate = default(Object)) + { + this.Id = id; + // to ensure "kind" is required (not null) + if (kind == null) + { + throw new ArgumentNullException("kind is a required property for TaskInfo and cannot be null"); + } + this.Kind = kind; + this.State = state; + this.Error = error; + this.Result = result; + this.Substate = substate; + } + + /// + /// Why the task failed. + /// + /// Why the task failed. + [DataMember(Name = "error", EmitDefaultValue = true)] + public string Error { get; set; } + + /// + /// Task ID. + /// + /// Task ID. + [DataMember(Name = "id", IsRequired = true, EmitDefaultValue = true)] + public Guid Id { get; set; } + + /// + /// Task kind, as in the `jet_tk` claim of the TASK token. + /// + /// Task kind, as in the `jet_tk` claim of the TASK token. + [DataMember(Name = "kind", IsRequired = true, EmitDefaultValue = true)] + public string Kind { get; set; } + + /// + /// Result of a successful task. + /// + /// Result of a successful task. + [DataMember(Name = "result", EmitDefaultValue = true)] + public Object Result { get; set; } + + /// + /// Progress of a running task. + /// + /// Progress of a running task. + [DataMember(Name = "substate", EmitDefaultValue = true)] + public Object Substate { get; set; } + + /// + /// Returns the string presentation of the object + /// + /// String presentation of the object + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + sb.Append("class TaskInfo {\n"); + sb.Append(" Error: ").Append(Error).Append("\n"); + sb.Append(" Id: ").Append(Id).Append("\n"); + sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" Result: ").Append(Result).Append("\n"); + sb.Append(" State: ").Append(State).Append("\n"); + sb.Append(" Substate: ").Append(Substate).Append("\n"); + sb.Append("}\n"); + return sb.ToString(); + } + + /// + /// Returns the JSON string presentation of the object + /// + /// JSON string presentation of the object + public virtual string ToJson() + { + return Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// To validate all properties of the instance + /// + /// Validation context + /// Validation Result + IEnumerable IValidatableObject.Validate(ValidationContext validationContext) + { + yield break; + } + } + +} diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs new file mode 100644 index 000000000..795cbe379 --- /dev/null +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/TaskState.cs @@ -0,0 +1,84 @@ +/* + * devolutions-gateway + * + * Protocol-aware fine-grained relay server + * + * The version of the OpenAPI document: 2026.2.4 + * Contact: infos@devolutions.net + * Generated by: https://github.com/openapitools/openapi-generator.git + */ + + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.ComponentModel.DataAnnotations; +using FileParameter = Devolutions.Gateway.Client.Client.FileParameter; +using OpenAPIDateConverter = Devolutions.Gateway.Client.Client.OpenAPIDateConverter; + +namespace Devolutions.Gateway.Client.Model +{ + /// + /// Defines TaskState + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum TaskState + { + /// + /// Enum NotStarted for value: not-started + /// + [EnumMember(Value = "not-started")] + NotStarted = 1, + + /// + /// Enum Running for value: running + /// + [EnumMember(Value = "running")] + Running = 2, + + /// + /// Enum Success for value: success + /// + [EnumMember(Value = "success")] + Success = 3, + + /// + /// Enum Failed for value: failed + /// + [EnumMember(Value = "failed")] + Failed = 4 + } + + public static class TaskStateExtensions + { + /// + /// Returns the value as string for a given variant + /// + public static string ToValue(this TaskState variant) + { + switch (variant) + { + case TaskState.NotStarted: + return "not-started"; + case TaskState.Running: + return "running"; + case TaskState.Success: + return "success"; + case TaskState.Failed: + return "failed"; + default: + throw new ArgumentOutOfRangeException(nameof(variant), $"Unexpected variant: {variant}"); + } + } + } + +} diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index a2c1b1b2b..723c99bd6 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -899,6 +899,101 @@ paths: security: - scope_token: - gateway.sessions.read + /jet/tasks: + post: + tags: + - Tasks + summary: Starts a background task. + description: |- + The task kind and its target come from the TASK token. + The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. + + This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + operationId: StartTask + requestBody: + description: Kind-specific task parameters, such as `AiLogParams` for `ai-log` + content: + application/json: + schema: + type: object + required: true + responses: + '202': + description: Task was accepted and runs in the background + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '400': + description: Invalid task parameters + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '409': + description: The task target is busy, such as a session that is still recording + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '500': + description: Unexpected server error + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + security: + - task_token: [] + /jet/tasks/{id}: + get: + tags: + - Tasks + summary: Gets the status of a background task. + description: |- + Task records are kept forever, including across Gateway restarts. + + This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + operationId: GetTask + parameters: + - name: id + in: path + description: Task ID + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Task status + content: + application/json: + schema: + $ref: '#/components/schemas/TaskInfo' + '400': + description: Bad request + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '404': + description: No task with this ID + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + '500': + description: Unexpected server error + content: + application/json: + schema: + $ref: '#/components/schemas/TaskErrorResponse' + security: + - scope_token: + - gateway.tasks.read /jet/traffic/ack: post: tags: @@ -1326,6 +1421,7 @@ components: - gateway.net.monitor.drain - gateway.agent.delete - gateway.agent.read + - gateway.tasks.read AckRequest: type: object required: @@ -1402,6 +1498,52 @@ components: - offline - online - unresponsive + AiLogParams: + type: object + description: 'AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`.' + required: + - provider + - model + - apiKey + properties: + apiKey: + type: string + description: Kept in memory for this task only. + baseUrl: + type: string + description: Overrides the provider default; required for `openai-compatible`. + nullable: true + maxOutputTokens: + type: integer + format: int32 + description: Upper bound of tokens in each AI answer. + nullable: true + minimum: 0 + model: + type: string + description: Model identifier, passed to the provider as is. + provider: + $ref: '#/components/schemas/AiProvider' + additionalProperties: false + AiLogSubstate: + oneOf: + - type: object + required: + - step + properties: + step: + type: string + enum: + - preparing + description: Progress of a running `ai-log` task. + AiProvider: + type: string + enum: + - openai + - anthropic + - mistral + - gemini + - openai-compatible AppCredential: type: object required: @@ -2392,6 +2534,66 @@ components: Format: `://:` (port is required). Supported schemes are `tcp` and `udp`. nullable: true + TaskErrorCode: + type: string + description: Stable code telling a client why a task request failed; safe to show. + enum: + - invalid_params + - missing_model + - missing_api_key + - missing_base_url + - invalid_ai_settings + - recording_active + - task_not_found + - internal + TaskErrorResponse: + type: object + description: Why a task request failed. + required: + - error + properties: + error: + $ref: '#/components/schemas/TaskErrorCode' + TaskInfo: + type: object + description: |- + A background task and its status. + + `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. + Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + required: + - id + - kind + - state + properties: + error: + type: string + description: Why the task failed. + nullable: true + id: + type: string + format: uuid + description: Task ID. + kind: + type: string + description: Task kind, as in the `jet_tk` claim of the TASK token. + result: + type: object + description: Result of a successful task. + nullable: true + state: + $ref: '#/components/schemas/TaskState' + substate: + type: object + description: Progress of a running task. + nullable: true + TaskState: + type: string + enum: + - not-started + - running + - success + - failed TrafficEventResponse: type: object required: @@ -2510,6 +2712,11 @@ components: scheme: bearer bearerFormat: JWT description: Token allowing a single HTTP request for a specific scope + task_token: + type: http + scheme: bearer + bearerFormat: JWT + description: Token authorizing one kind of background task on a specific target web_app_custom_auth: type: http scheme: basic diff --git a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES index 84eba61ed..beb588571 100644 --- a/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES +++ b/devolutions-gateway/openapi/ts-angular-client/.openapi-generator/FILES @@ -13,6 +13,7 @@ api/net.service.ts api/networkMonitoring.service.ts api/preflight.service.ts api/sessions.service.ts +api/tasks.service.ts api/traffic.service.ts api/update.service.ts api/webApp.service.ts @@ -26,6 +27,10 @@ model/addressFamily.ts model/agentDomainAdvertisement.ts model/agentInfo.ts model/agentStatus.ts +model/aiLogParams.ts +model/aiLogSubstate.ts +model/aiLogSubstateOneOf.ts +model/aiProvider.ts model/appCredential.ts model/appCredentialKind.ts model/appTokenContentType.ts @@ -78,6 +83,10 @@ model/setUpdateScheduleRequest.ts model/subProvisionerKey.ts model/subscriber.ts model/targetConnectionOptions.ts +model/taskErrorCode.ts +model/taskErrorResponse.ts +model/taskInfo.ts +model/taskState.ts model/trafficEventResponse.ts model/transportProtocolResponse.ts model/updateProductInfo.ts diff --git a/devolutions-gateway/openapi/ts-angular-client/api/api.ts b/devolutions-gateway/openapi/ts-angular-client/api/api.ts index a7eb029c9..97736f498 100644 --- a/devolutions-gateway/openapi/ts-angular-client/api/api.ts +++ b/devolutions-gateway/openapi/ts-angular-client/api/api.ts @@ -20,10 +20,12 @@ export * from './preflight.service'; import { PreflightService } from './preflight.service'; export * from './sessions.service'; import { SessionsService } from './sessions.service'; +export * from './tasks.service'; +import { TasksService } from './tasks.service'; export * from './traffic.service'; import { TrafficService } from './traffic.service'; export * from './update.service'; import { UpdateService } from './update.service'; export * from './webApp.service'; import { WebAppService } from './webApp.service'; -export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TrafficService, UpdateService, WebAppService]; +export const APIS = [AgentService, ConfigService, DiagnosticsService, HealthService, HeartbeatService, JrecService, JrlService, NetService, NetworkMonitoringService, PreflightService, SessionsService, TasksService, TrafficService, UpdateService, WebAppService]; diff --git a/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts new file mode 100644 index 000000000..804f65e36 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/api/tasks.service.ts @@ -0,0 +1,249 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { TaskErrorResponse } from '../model/taskErrorResponse'; +// @ts-ignore +import { TaskInfo } from '../model/taskInfo'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class TasksService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Gets the status of a background task. + * Task records are kept forever, including across Gateway restarts. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * @param id Task ID + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getTask(id: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getTask(id: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getTask(id: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getTask(id: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (id === null || id === undefined) { + throw new Error('Required parameter id was null or undefined when calling getTask.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (scope_token) required + localVarCredential = this.configuration.lookupCredential('scope_token'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jet/tasks/${this.configuration.encodeParam({name: "id", value: id, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + + /** + * Starts a background task. + * The task kind and its target come from the TASK token. The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. + * @param body Kind-specific task parameters, such as `AiLogParams` for `ai-log` + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public startTask(body: object, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public startTask(body: object, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(body: object, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public startTask(body: object, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (body === null || body === undefined) { + throw new Error('Required parameter body was null or undefined when calling startTask.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (task_token) required + localVarCredential = this.configuration.lookupCredential('task_token'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + // to determine the Content-Type header + const consumes: string[] = [ + 'application/json' + ]; + const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); + if (httpContentTypeSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected); + } + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/jet/tasks`; + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + body: body, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/devolutions-gateway/openapi/ts-angular-client/configuration.ts b/devolutions-gateway/openapi/ts-angular-client/configuration.ts index f11d4c083..ddb356684 100644 --- a/devolutions-gateway/openapi/ts-angular-client/configuration.ts +++ b/devolutions-gateway/openapi/ts-angular-client/configuration.ts @@ -132,6 +132,15 @@ export class Configuration { }; } + // init default task_token credential + if (!this.credentials['task_token']) { + this.credentials['task_token'] = () => { + return typeof this.accessToken === 'function' + ? this.accessToken() + : this.accessToken; + }; + } + // init default web_app_custom_auth credential if (!this.credentials['web_app_custom_auth']) { this.credentials['web_app_custom_auth'] = () => { diff --git a/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts index 0827c35b7..58da5f6a9 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/accessScope.ts @@ -9,7 +9,7 @@ */ -export type AccessScope = '*' | 'gateway.sessions.read' | 'gateway.session.terminate' | 'gateway.associations.read' | 'gateway.diagnostics.read' | 'gateway.jrl.read' | 'gateway.config.write' | 'gateway.heartbeat.read' | 'gateway.recording.delete' | 'gateway.recordings.read' | 'gateway.update' | 'gateway.update.read' | 'gateway.preflight' | 'gateway.traffic.claim' | 'gateway.traffic.ack' | 'gateway.net.monitor.config' | 'gateway.net.monitor.drain' | 'gateway.agent.delete' | 'gateway.agent.read'; +export type AccessScope = '*' | 'gateway.sessions.read' | 'gateway.session.terminate' | 'gateway.associations.read' | 'gateway.diagnostics.read' | 'gateway.jrl.read' | 'gateway.config.write' | 'gateway.heartbeat.read' | 'gateway.recording.delete' | 'gateway.recordings.read' | 'gateway.update' | 'gateway.update.read' | 'gateway.preflight' | 'gateway.traffic.claim' | 'gateway.traffic.ack' | 'gateway.net.monitor.config' | 'gateway.net.monitor.drain' | 'gateway.agent.delete' | 'gateway.agent.read' | 'gateway.tasks.read'; export const AccessScope = { Star: '*' as AccessScope, @@ -30,6 +30,7 @@ export const AccessScope = { GatewayNetMonitorConfig: 'gateway.net.monitor.config' as AccessScope, GatewayNetMonitorDrain: 'gateway.net.monitor.drain' as AccessScope, GatewayAgentDelete: 'gateway.agent.delete' as AccessScope, - GatewayAgentRead: 'gateway.agent.read' as AccessScope + GatewayAgentRead: 'gateway.agent.read' as AccessScope, + GatewayTasksRead: 'gateway.tasks.read' as AccessScope }; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts new file mode 100644 index 000000000..4e7e99ba3 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogParams.ts @@ -0,0 +1,38 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { AiProvider } from './aiProvider'; + + +/** + * AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. + */ +export interface AiLogParams { + /** + * Kept in memory for this task only. + */ + apiKey: string; + /** + * Overrides the provider default; required for `openai-compatible`. + */ + baseUrl?: string | null; + /** + * Upper bound of tokens in each AI answer. + */ + maxOutputTokens?: number | null; + /** + * Model identifier, passed to the provider as is. + */ + model: string; + provider: AiProvider; +} +export namespace AiLogParams { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts new file mode 100644 index 000000000..9e4f39ec7 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstate.ts @@ -0,0 +1,22 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { AiLogSubstateOneOf } from './aiLogSubstateOneOf'; + + +/** + * Progress of a running `ai-log` task. + */ +/** + * @type AiLogSubstate + * Progress of a running `ai-log` task. + * @export + */ +export type AiLogSubstate = AiLogSubstateOneOf; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts new file mode 100644 index 000000000..baa68006c --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiLogSubstateOneOf.ts @@ -0,0 +1,22 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface AiLogSubstateOneOf { + step: AiLogSubstateOneOf.Step; +} +export namespace AiLogSubstateOneOf { + export type Step = 'preparing'; + export const Step = { + Preparing: 'preparing' as Step + }; +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts new file mode 100644 index 000000000..0d8087222 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/aiProvider.ts @@ -0,0 +1,21 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export type AiProvider = 'openai' | 'anthropic' | 'mistral' | 'gemini' | 'openai-compatible'; + +export const AiProvider = { + Openai: 'openai' as AiProvider, + Anthropic: 'anthropic' as AiProvider, + Mistral: 'mistral' as AiProvider, + Gemini: 'gemini' as AiProvider, + OpenaiCompatible: 'openai-compatible' as AiProvider +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/models.ts b/devolutions-gateway/openapi/ts-angular-client/model/models.ts index 7d7eb4f0f..2a42acc64 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/models.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/models.ts @@ -5,6 +5,10 @@ export * from './addressFamily'; export * from './agentDomainAdvertisement'; export * from './agentInfo'; export * from './agentStatus'; +export * from './aiLogParams'; +export * from './aiLogSubstate'; +export * from './aiLogSubstateOneOf'; +export * from './aiProvider'; export * from './appCredential'; export * from './appCredentialKind'; export * from './appTokenContentType'; @@ -56,6 +60,10 @@ export * from './setUpdateScheduleRequest'; export * from './subProvisionerKey'; export * from './subscriber'; export * from './targetConnectionOptions'; +export * from './taskErrorCode'; +export * from './taskErrorResponse'; +export * from './taskInfo'; +export * from './taskState'; export * from './trafficEventResponse'; export * from './transportProtocolResponse'; export * from './updateProductInfo'; diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts new file mode 100644 index 000000000..5ea39274e --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorCode.ts @@ -0,0 +1,27 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +/** + * Stable code telling a client why a task request failed; safe to show. + */ +export type TaskErrorCode = 'invalid_params' | 'missing_model' | 'missing_api_key' | 'missing_base_url' | 'invalid_ai_settings' | 'recording_active' | 'task_not_found' | 'internal'; + +export const TaskErrorCode = { + InvalidParams: 'invalid_params' as TaskErrorCode, + MissingModel: 'missing_model' as TaskErrorCode, + MissingApiKey: 'missing_api_key' as TaskErrorCode, + MissingBaseUrl: 'missing_base_url' as TaskErrorCode, + InvalidAiSettings: 'invalid_ai_settings' as TaskErrorCode, + RecordingActive: 'recording_active' as TaskErrorCode, + TaskNotFound: 'task_not_found' as TaskErrorCode, + Internal: 'internal' as TaskErrorCode +}; + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts new file mode 100644 index 000000000..ffa0b559f --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskErrorResponse.ts @@ -0,0 +1,22 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { TaskErrorCode } from './taskErrorCode'; + + +/** + * Why a task request failed. + */ +export interface TaskErrorResponse { + error: TaskErrorCode; +} +export namespace TaskErrorResponse { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts new file mode 100644 index 000000000..e2d2c14ae --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskInfo.ts @@ -0,0 +1,42 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { TaskState } from './taskState'; + + +/** + * A background task and its status. `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. + */ +export interface TaskInfo { + /** + * Why the task failed. + */ + error?: string | null; + /** + * Task ID. + */ + id: string; + /** + * Task kind, as in the `jet_tk` claim of the TASK token. + */ + kind: string; + /** + * Result of a successful task. + */ + result?: object | null; + state: TaskState; + /** + * Progress of a running task. + */ + substate?: object | null; +} +export namespace TaskInfo { +} + + diff --git a/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts b/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts new file mode 100644 index 000000000..7cadf44e1 --- /dev/null +++ b/devolutions-gateway/openapi/ts-angular-client/model/taskState.ts @@ -0,0 +1,20 @@ +/** + * devolutions-gateway + * + * Contact: infos@devolutions.net + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export type TaskState = 'not-started' | 'running' | 'success' | 'failed'; + +export const TaskState = { + NotStarted: 'not-started' as TaskState, + Running: 'running' as TaskState, + Success: 'success' as TaskState, + Failed: 'failed' as TaskState +}; + diff --git a/devolutions-gateway/src/api/mod.rs b/devolutions-gateway/src/api/mod.rs index c7b222ebc..d87131828 100644 --- a/devolutions-gateway/src/api/mod.rs +++ b/devolutions-gateway/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod preflight; pub mod rdp; pub mod session; pub mod sessions; +pub mod tasks; pub mod traffic; pub mod tunnel; pub mod update; @@ -49,5 +50,9 @@ pub fn make_router(state: crate::DgwState) -> axum::Router { router = router.nest("/jet/net/monitor", monitoring::make_router(state.clone())); } + if let Some(task_service) = state.tasks.clone() { + router = router.nest("/jet/tasks", tasks::make_router(state.clone(), task_service)); + } + router.with_state(state) } diff --git a/devolutions-gateway/src/api/tasks.rs b/devolutions-gateway/src/api/tasks.rs new file mode 100644 index 000000000..fc8ae7f71 --- /dev/null +++ b/devolutions-gateway/src/api/tasks.rs @@ -0,0 +1,188 @@ +use axum::body::Bytes; +use axum::extract::{self, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, routing}; +use uuid::Uuid; + +use crate::DgwState; +use crate::extract::{TaskToken, TasksReadScope}; +use crate::tasks::ai_log::{AiLogTarget, AiLogTask}; +use crate::tasks::{TaskErrorCode, TaskService, TaskSnapshot, TaskStatus}; +use crate::token::TaskKind; + +#[derive(Clone)] +pub(crate) struct TasksState { + gateway: DgwState, + tasks: TaskService, +} + +pub fn make_router(state: DgwState, tasks: TaskService) -> Router { + Router::new() + .route("/", routing::post(start_task)) + .route("/{id}", routing::get(get_task)) + .with_state(TasksState { gateway: state, tasks }) +} + +/// Starts a background task. +/// +/// The task kind and its target come from the TASK token. +/// The request body is a JSON object holding the kind-specific parameters: `AiLogParams` for `ai-log`. +/// +/// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "StartTask", + tag = "Tasks", + path = "/jet/tasks", + request_body(content = Object, description = "Kind-specific task parameters, such as `AiLogParams` for `ai-log`", content_type = "application/json"), + responses( + (status = 202, description = "Task was accepted and runs in the background", body = TaskInfo), + (status = 400, description = "Invalid task parameters", body = TaskErrorResponse), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 409, description = "The task target is busy, such as a session that is still recording", body = TaskErrorResponse), + (status = 500, description = "Unexpected server error", body = TaskErrorResponse), + ), + security(("task_token" = [])), +))] +pub(crate) async fn start_task( + State(state): State, + TaskToken(claims): TaskToken, + body: Bytes, +) -> Result<(StatusCode, Json), TaskErrorCode> { + let snapshot = match claims.kind { + TaskKind::AiLog { jet_aid } => { + state + .tasks + .start_ephemeral::(AiLogTarget { session_id: jet_aid }, &body, claims.jti, &state.gateway) + .await? + } + }; + + Ok((StatusCode::ACCEPTED, Json(TaskInfo::from(snapshot)))) +} + +/// Gets the status of a background task. +/// +/// Task records are kept forever, including across Gateway restarts. +/// +/// This endpoint is unstable: it is only available when `__debug__.enable_unstable` is set. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + operation_id = "GetTask", + tag = "Tasks", + path = "/jet/tasks/{id}", + params( + ("id" = Uuid, Path, description = "Task ID"), + ), + responses( + (status = 200, description = "Task status", body = TaskInfo), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "No task with this ID", body = TaskErrorResponse), + (status = 500, description = "Unexpected server error", body = TaskErrorResponse), + ), + security(("scope_token" = ["gateway.tasks.read"])), +))] +pub(crate) async fn get_task( + State(state): State, + _scope: TasksReadScope, + extract::Path(id): extract::Path, +) -> Result, TaskErrorCode> { + let snapshot = state.tasks.get(id).await.map_err(|error| { + error!(task.id = %id, error = format!("{error:#}"), "Failed to read the task"); + TaskErrorCode::Internal + })?; + + snapshot + .map(|snapshot| Json(TaskInfo::from(snapshot))) + .ok_or(TaskErrorCode::TaskNotFound) +} + +/// A background task and its status. +/// +/// `substate` is set only when `state` is `running`, `result` only when it is `success`, and `error` only when it is `failed`. +/// Both `substate` and `result` are kind-specific: for `ai-log`, `substate` is an `AiLogSubstate`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TaskInfo { + /// Task ID. + id: Uuid, + /// Task kind, as in the `jet_tk` claim of the TASK token. + kind: String, + state: TaskState, + /// Progress of a running task. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(skip_serializing_if = "Option::is_none")] + substate: Option, + /// Result of a successful task. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + /// Why the task failed. + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum TaskState { + /// The task waits for a free slot or for its next attempt. + NotStarted, + Running, + Success, + Failed, +} + +impl From for TaskInfo { + fn from(snapshot: TaskSnapshot) -> Self { + let (state, substate, result, error) = match snapshot.status { + TaskStatus::NotStarted => (TaskState::NotStarted, None, None, None), + TaskStatus::Running { substate } => (TaskState::Running, Some(substate), None, None), + TaskStatus::Success { result } => (TaskState::Success, None, Some(result), None), + TaskStatus::Failed { error } => (TaskState::Failed, None, None, Some(error)), + }; + + Self { + id: snapshot.id, + kind: snapshot.kind, + state, + substate, + result, + error, + } + } +} + +/// Why a task request failed. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Serialize)] +pub(crate) struct TaskErrorResponse { + error: TaskErrorCode, +} + +impl IntoResponse for TaskErrorCode { + fn into_response(self) -> Response { + let status = match self { + TaskErrorCode::InvalidParams + | TaskErrorCode::MissingModel + | TaskErrorCode::MissingApiKey + | TaskErrorCode::MissingBaseUrl + | TaskErrorCode::InvalidAiSettings => StatusCode::BAD_REQUEST, + TaskErrorCode::RecordingActive => StatusCode::CONFLICT, + TaskErrorCode::TaskNotFound => StatusCode::NOT_FOUND, + TaskErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + + // Server errors are logged where they happen. + if status.is_client_error() { + debug!(%status, error = ?self, "Task request rejected"); + } + + (status, Json(TaskErrorResponse { error: self })).into_response() + } +} diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index 98810bd8a..0c142401e 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -162,6 +162,7 @@ pub struct Conf { pub log_file: Utf8PathBuf, pub job_queue_database: Utf8PathBuf, pub traffic_audit_database: Utf8PathBuf, + pub provisioner_tasks_database: Utf8PathBuf, pub tls: Option, pub credssp_tls: CredsspTls, pub provisioner_public_key: PublicKey, @@ -371,6 +372,12 @@ impl Conf { .unwrap_or_else(|| Utf8PathBuf::from("traffic_audit.db")) .pipe_ref(|path| normalize_data_path(path, &data_dir)); + let provisioner_tasks_database = conf_file + .provisioner_tasks_database + .clone() + .unwrap_or_else(|| Utf8PathBuf::from("provisioner_tasks.db")) + .pipe_ref(|path| normalize_data_path(path, &data_dir)); + let jrl_file = conf_file .jrl_file .clone() @@ -429,6 +436,7 @@ impl Conf { log_file, job_queue_database, traffic_audit_database, + provisioner_tasks_database, tls, credssp_tls, provisioner_public_key, @@ -1248,6 +1256,10 @@ pub mod dto { #[serde(skip_serializing_if = "Option::is_none")] pub traffic_audit_database: Option, + /// (Unstable) Path to the SQLite database file for the records of the tasks started by the provisioner + #[serde(skip_serializing_if = "Option::is_none")] + pub provisioner_tasks_database: Option, + /// HTTP/SOCKS proxy configuration for outbound requests #[serde(skip_serializing_if = "Option::is_none")] pub proxy: Option, @@ -1311,6 +1323,7 @@ pub mod dto { web_app: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, agent_tunnel: None, proxy: None, debug: None, diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index b22ee0461..cfb620c54 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -8,7 +8,7 @@ use crate::DgwState; use crate::http::HttpError; use crate::token::{ AccessScope, AccessTokenClaims, AssociationTokenClaims, BridgeTokenClaims, EnrollmentTokenClaims, JmuxTokenClaims, - JrecTokenClaims, JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, WebAppTokenClaims, + JrecTokenClaims, JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, TaskTokenClaims, WebAppTokenClaims, }; #[derive(Clone)] @@ -449,6 +449,24 @@ where } } +#[derive(Clone, Copy)] +pub struct TasksReadScope; + +impl FromRequestParts for TasksReadScope +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match ScopeToken::from_request_parts(parts, state).await?.0.scope { + AccessScope::Wildcard => Ok(Self), + AccessScope::TasksRead => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + /// Grants read access to agent management endpoints. /// /// Accepts a scope token with `AgentRead` or `Wildcard` scope. @@ -580,6 +598,24 @@ where } } +#[derive(Clone)] +pub struct TaskToken(pub TaskTokenClaims); + +impl FromRequestParts for TaskToken +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + if let AccessTokenClaims::Task(claims) = AccessToken::from_request_parts(parts, state).await?.0 { + Ok(Self(claims)) + } else { + Err(HttpError::forbidden().msg("token not allowed (expected TASK)")) + } + } +} + pub struct RepeatQuery(pub(crate) T); impl FromRequest for RepeatQuery diff --git a/devolutions-gateway/src/job_queue.rs b/devolutions-gateway/src/job_queue.rs index 8985f3184..e2b7875fa 100644 --- a/devolutions-gateway/src/job_queue.rs +++ b/devolutions-gateway/src/job_queue.rs @@ -218,7 +218,7 @@ impl Task for JobRunnerTask { } #[instrument(skip_all)] -async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { +async fn job_runner_task(ctx: JobRunnerTask, shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { debug!("Task started"); let JobRunnerTask { @@ -227,8 +227,22 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal queue, } = ctx; - let reader = DgwJobReader; + run_jobs(queue, &DgwJobReader, notify_runner, runner_waker, 16, shutdown_signal).await; + debug!("Task terminated"); + + Ok(()) +} + +/// Runs the jobs of `queue`, at most `max_batch_size` at the same time, until shutdown. +pub(crate) async fn run_jobs( + queue: DynJobQueue, + reader: &dyn JobReader, + notify_runner: Arc, + runner_waker: RunnerWaker, + max_batch_size: usize, + mut shutdown_signal: ShutdownSignal, +) { let spawn = |mut ctx: JobCtx, callback: job_queue::SpawnCallback| { tokio::spawn(async move { let result = ctx.job.run().await; @@ -260,23 +274,19 @@ async fn job_runner_task(ctx: JobRunnerTask, mut shutdown_signal: ShutdownSignal let runner = JobRunner { queue, - reader: &reader, + reader, spawn: &spawn, sleep: &sleep, wait_notified: &wait_notified, wait_notified_timeout: &wait_notified_timeout, waker: runner_waker, - max_batch_size: 16, + max_batch_size, }; tokio::select! { () = runner.run() => {} () = shutdown_signal.wait() => {} } - - debug!("Task terminated"); - - Ok(()) } struct DgwJobReader; diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index cd53900b2..53fe395f3 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -41,6 +41,7 @@ pub mod streaming; pub mod subscriber; pub mod target_addr; pub(crate) mod target_connection_options; +pub mod tasks; pub mod tls; pub mod token; pub mod traffic_audit; @@ -68,6 +69,8 @@ pub struct DgwState { pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, + /// Set only when the unstable task system is enabled. + pub tasks: Option, } #[doc(hidden)] @@ -110,6 +113,7 @@ impl DgwState { synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, + tasks: None, }; let handles = MockHandles { diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 5052f6704..6ca704fe2 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -42,6 +42,8 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tunnel::list_agents, crate::api::tunnel::get_agent, crate::api::tunnel::delete_agent, + crate::api::tasks::start_task, + crate::api::tasks::get_task, ), components(schemas( crate::api::health::Identity, @@ -108,6 +110,13 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::tunnel::AgentDomainAdvertisement, crate::api::tunnel::AgentStatus, crate::api::tunnel::AgentInfo, + crate::api::tasks::TaskInfo, + crate::api::tasks::TaskState, + crate::api::tasks::TaskErrorResponse, + crate::tasks::TaskErrorCode, + crate::tasks::ai_log::AiLogParams, + crate::tasks::ai::AiProvider, + crate::tasks::ai_log::AiLogSubstate, )), modifiers(&SecurityAddon), )] @@ -237,6 +246,19 @@ impl Modify for SecurityAddon { .build(), ), ); + + components.add_security_scheme( + "task_token", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "Token authorizing one kind of background task on a specific target".to_owned(), + )) + .build(), + ), + ); } } diff --git a/devolutions-gateway/src/recording.rs b/devolutions-gateway/src/recording.rs index 59905df5b..f57061dfa 100644 --- a/devolutions-gateway/src/recording.rs +++ b/devolutions-gateway/src/recording.rs @@ -220,7 +220,7 @@ impl ActiveRecordings { self.0.lock().clone() } - fn insert(&self, id: Uuid) -> usize { + pub(crate) fn insert(&self, id: Uuid) -> usize { let mut guard = self.0.lock(); guard.insert(id); guard.len() diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 97b733f17..c5da19001 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -267,6 +267,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; + let provisioner_tasks = devolutions_gateway::tasks::TaskService::open_if_enabled(&conf) + .await + .context("failed to initialize provisioner tasks")?; + let provisioning = devolutions_gateway::provisioning::ProvisioningStore::new(); let synthetic_kdc_registry = devolutions_gateway::credential_injection::SyntheticKdcRegistry::new(); @@ -340,6 +344,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, + tasks: provisioner_tasks.clone(), }; for listener in &conf.listeners { @@ -405,6 +410,14 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { )); tasks.register(devolutions_gateway::job_queue::JobRunnerTask::new(&job_queue_ctx)); + + if let Some(provisioner_tasks) = provisioner_tasks { + tasks.register(devolutions_gateway::tasks::TaskRunnerTask::new( + provisioner_tasks, + state.clone(), + )); + } + tasks.register(devolutions_gateway::job_queue::JobQueueTask::new(job_queue_ctx)); tasks.register(traffic_audit_task); diff --git a/devolutions-gateway/src/tasks/ai.rs b/devolutions-gateway/src/tasks/ai.rs new file mode 100644 index 000000000..09e50d07e --- /dev/null +++ b/devolutions-gateway/src/tasks/ai.rs @@ -0,0 +1,213 @@ +//! AI settings shared by the task kinds that call an AI provider. +//! +//! The provisioner sends them with the API key in the body of `POST /jet/tasks`. +//! [`AiSettings`] is the part persisted with the task; the API key stays in memory as the task secret. + +use devolutions_gateway_ai::{AiClient, BuildError, Provider}; +use secrecy::SecretString; +use url::Url; + +use super::{TaskError, TaskErrorCode}; +use crate::DgwState; + +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AiProvider { + #[serde(rename = "openai")] + OpenAi, + #[serde(rename = "anthropic")] + Anthropic, + #[serde(rename = "mistral")] + Mistral, + #[serde(rename = "gemini")] + Gemini, + #[serde(rename = "openai-compatible")] + OpenAiCompatible, +} + +impl From for Provider { + fn from(provider: AiProvider) -> Self { + match provider { + AiProvider::OpenAi => Provider::OpenAi, + AiProvider::Anthropic => Provider::Anthropic, + AiProvider::Mistral => Provider::Mistral, + AiProvider::Gemini => Provider::Gemini, + AiProvider::OpenAiCompatible => Provider::OpenAiCompatible, + } + } +} + +/// AI settings of a task, persisted with it: everything but the API key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiSettings { + pub provider: AiProvider, + pub model: String, + pub base_url: Option, + /// Upper bound of tokens in each AI answer. + pub max_output_tokens: Option, +} + +impl AiSettings { + /// Checks the settings before the task is recorded. + pub fn check(&self, api_key: &SecretString, state: &DgwState) -> Result<(), TaskErrorCode> { + match self.build_client(api_key, state) { + Ok(_) => Ok(()), + Err(ClientError::Build(error)) => { + let code = build_error_code(&error); + debug!(%error, ?code, "Invalid AI settings"); + Err(code) + } + Err(ClientError::HttpClient(error)) => { + error!(%error, "Failed to build the HTTP client for the AI provider"); + Err(TaskErrorCode::Internal) + } + } + } + + /// Builds the client of a task run, through the proxy configured for Gateway. + pub fn client(&self, api_key: &SecretString, state: &DgwState) -> Result { + self.build_client(api_key, state) + .map_err(|error| TaskError::Permanent(error.message())) + } + + fn build_client(&self, api_key: &SecretString, state: &DgwState) -> Result { + let provider = Provider::from(self.provider); + + let mut builder = AiClient::builder() + .provider(provider) + .model(self.model.clone()) + .api_key(api_key.clone()); + + let endpoint = self.base_url.clone().or_else(|| provider.default_base_url()); + + if let Some(base_url) = self.base_url.clone() { + builder = builder.base_url(base_url); + } + + // Without an endpoint, `build` reports the missing base URL before it needs the HTTP client. + if let Some(endpoint) = endpoint { + let proxy_config = state.conf_handle.get_conf().proxy.to_proxy_config(); + + let http_client = + http_client_proxy::get_or_create_cached_client(reqwest::Client::builder(), &endpoint, &proxy_config) + .map_err(ClientError::HttpClient)?; + + builder = builder.http_client(http_client); + } + + builder.build().map_err(ClientError::Build) + } +} + +/// Retries only the AI errors that may pass later, such as a rate limit or a network error. +impl From for TaskError { + fn from(error: devolutions_gateway_ai::Error) -> Self { + if error.is_transient() { + TaskError::Transient(error.to_string()) + } else { + TaskError::Permanent(error.to_string()) + } + } +} + +enum ClientError { + Build(BuildError), + HttpClient(reqwest::Error), +} + +impl ClientError { + fn message(&self) -> String { + match self { + ClientError::Build(error) => error.to_string(), + ClientError::HttpClient(error) => format!("failed to build the HTTP client: {error}"), + } + } +} + +fn build_error_code(error: &BuildError) -> TaskErrorCode { + match error { + BuildError::MissingModel => TaskErrorCode::MissingModel, + BuildError::MissingApiKey(_) => TaskErrorCode::MissingApiKey, + BuildError::MissingBaseUrl(_) => TaskErrorCode::MissingBaseUrl, + _ => TaskErrorCode::InvalidAiSettings, + } +} + +#[cfg(test)] +mod tests { + use devolutions_gateway_ai::Error; + + use super::*; + + #[test] + fn build_errors_map_to_stable_codes() { + assert_eq!(build_error_code(&BuildError::MissingModel), TaskErrorCode::MissingModel); + assert_eq!( + build_error_code(&BuildError::MissingApiKey(Provider::OpenAi)), + TaskErrorCode::MissingApiKey + ); + assert_eq!( + build_error_code(&BuildError::MissingBaseUrl(Provider::OpenAiCompatible)), + TaskErrorCode::MissingBaseUrl + ); + assert_eq!( + build_error_code(&BuildError::InvalidApiKey), + TaskErrorCode::InvalidAiSettings + ); + } + + #[test] + fn rate_limits_server_and_network_errors_are_transient() { + let status = |status| Error::Status { + status, + message: "failed".to_owned(), + }; + + let network = Error::Transport { + message: "connection refused".to_owned(), + }; + assert!(matches!(TaskError::from(network), TaskError::Transient(_))); + + for code in [429, 500, 503] { + assert!( + matches!(TaskError::from(status(code)), TaskError::Transient(_)), + "{code}" + ); + } + + for code in [400, 401, 403, 404] { + assert!( + matches!(TaskError::from(status(code)), TaskError::Permanent(_)), + "{code}" + ); + } + + let invalid = Error::InvalidOutput { + reason: "no valid action line".to_owned(), + }; + assert!(matches!(TaskError::from(invalid), TaskError::Permanent(_))); + } + + #[test] + fn providers_keep_their_wire_names() { + for (provider, name) in [ + (AiProvider::OpenAi, "openai"), + (AiProvider::Anthropic, "anthropic"), + (AiProvider::Mistral, "mistral"), + (AiProvider::Gemini, "gemini"), + (AiProvider::OpenAiCompatible, "openai-compatible"), + ] { + assert_eq!(serde_json::to_value(provider).expect("serializable"), name); + assert_eq!( + serde_json::from_value::(serde_json::Value::from(name)).expect("known name"), + provider + ); + } + } + + #[test] + fn gemini_has_a_default_base_url() { + assert!(Provider::from(AiProvider::Gemini).default_base_url().is_some()); + } +} diff --git a/devolutions-gateway/src/tasks/ai_log.rs b/devolutions-gateway/src/tasks/ai_log.rs new file mode 100644 index 000000000..58e4a80af --- /dev/null +++ b/devolutions-gateway/src/tasks/ai_log.rs @@ -0,0 +1,171 @@ +//! `ai-log` task: describes what the user did in one session and stores the result as a new log of that session. + +use secrecy::SecretString; +use url::Url; +use uuid::Uuid; + +use super::ai::{AiProvider, AiSettings}; +use super::{EphemeralTask, RetryPolicy, SECRETS_LOST_ERROR, TaskCtx, TaskError, TaskErrorCode, TaskKind}; +use crate::DgwState; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiLogTarget { + pub session_id: Uuid, +} + +/// AI settings used by an `ai-log` task: the body of `POST /jet/tasks` for a TASK token of kind `ai-log`. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AiLogParams { + pub provider: AiProvider, + /// Model identifier, passed to the provider as is. + pub model: String, + /// Kept in memory for this task only. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub api_key: SecretString, + /// Overrides the provider default; required for `openai-compatible`. + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub base_url: Option, + /// Upper bound of tokens in each AI answer. + pub max_output_tokens: Option, +} + +/// Progress of a running `ai-log` task. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "kebab-case", tag = "step")] +pub enum AiLogSubstate { + #[default] + Preparing, +} + +#[derive(Debug, Serialize)] +pub enum AiLogOutput {} + +pub enum AiLogTask {} + +impl TaskKind for AiLogTask { + const KIND: &'static str = "ai-log"; + const RETRY: RetryPolicy = RetryPolicy::JOB_QUEUE; + + type Target = AiLogTarget; + type Params = AiSettings; + type Substate = AiLogSubstate; + type Output = AiLogOutput; + + async fn run(ctx: TaskCtx) -> Result { + let Some(api_key) = ctx.secrets() else { + return Err(TaskError::Permanent(SECRETS_LOST_ERROR.to_owned())); + }; + + let _client = ctx.params.client(api_key, &ctx.state)?; + + Err(TaskError::Permanent("ai-log task not implemented yet".to_owned())) + } +} + +impl EphemeralTask for AiLogTask { + type Secrets = SecretString; + type Request = AiLogParams; + + fn prepare( + target: &AiLogTarget, + request: AiLogParams, + state: &DgwState, + ) -> Result<(AiSettings, SecretString), TaskErrorCode> { + if state.recordings.active_recordings.contains(target.session_id) { + return Err(TaskErrorCode::RecordingActive); + } + + let AiLogParams { + provider, + model, + api_key, + base_url, + max_output_tokens, + } = request; + + let settings = AiSettings { + provider, + model, + base_url, + max_output_tokens, + }; + + settings.check(&api_key, state)?; + + Ok((settings, api_key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const API_KEY: &str = "sk-ai-log-test-secret"; + + const CONFIG: &str = r#"{ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }], + "Proxy": { "Mode": "Off" } + }"#; + + fn params() -> AiLogParams { + serde_json::from_value(serde_json::json!({ + "provider": "openai", + "model": "gpt-test", + "apiKey": API_KEY, + })) + .expect("valid params") + } + + fn target() -> AiLogTarget { + AiLogTarget { + session_id: Uuid::new_v4(), + } + } + + #[tokio::test] + async fn refuses_a_session_that_is_still_recording() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + let target = target(); + state.recordings.active_recordings.insert(target.session_id); + + let error = AiLogTask::prepare(&target, params(), &state).expect_err("session is busy"); + + assert_eq!(error, TaskErrorCode::RecordingActive); + } + + #[tokio::test] + async fn persisted_settings_never_hold_the_api_key() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + + let params = params(); + assert!(!format!("{params:?}").contains(API_KEY)); + + let (settings, api_key) = AiLogTask::prepare(&target(), params, &state).expect("valid task"); + + let persisted = serde_json::to_string(&settings).expect("serializable settings"); + assert_eq!( + persisted, + r#"{"provider":"openai","model":"gpt-test","baseUrl":null,"maxOutputTokens":null}"# + ); + assert!(!format!("{settings:?}").contains(API_KEY)); + assert!(!format!("{api_key:?}").contains(API_KEY)); + } + + #[tokio::test] + async fn invalid_ai_settings_are_refused_with_a_code() { + let (state, _handles) = DgwState::mock(CONFIG).expect("mock state"); + let mut params = params(); + params.model = " ".to_owned(); + + let error = AiLogTask::prepare(&target(), params, &state).expect_err("empty model"); + + assert_eq!(error, TaskErrorCode::MissingModel); + } +} diff --git a/devolutions-gateway/src/tasks/mod.rs b/devolutions-gateway/src/tasks/mod.rs new file mode 100644 index 000000000..90e1b1cfb --- /dev/null +++ b/devolutions-gateway/src/tasks/mod.rs @@ -0,0 +1,691 @@ +//! Background tasks started by the provisioner through `POST /jet/tasks` and polled through `GET /jet/tasks/{id}`. +//! +//! Every task has a record in the provisioner task database, kept forever so it can be audited. +//! Each task runs as a job of a job queue stored in that same database, with its own runner: +//! tasks never take a slot from the other Gateway jobs, and other jobs never delay a task. +//! The job definition holds only the persisted, non-secret parameters, so a [`DurableTask`] resumes after a restart. +//! The secrets of an [`EphemeralTask`] stay in memory only: when Gateway restarts, the task fails instead. +//! +//! The task system is unstable: it starts only when `__debug__.enable_unstable` is set. + +pub mod ai; +pub mod ai_log; + +use core::marker::PhantomData; +use std::any::Any; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use async_trait::async_trait; +use devolutions_gateway_task::{ShutdownSignal, Task}; +use job_queue::{DynJob, DynJobQueue, JobQueue as _, JobReader, RunnerWaker}; +use job_queue_libsql::{LibSqlJobQueue, libsql}; +use parking_lot::Mutex; +use provisioner_task_store_libsql::{LibSqlProvisionerTaskStore, NewTask, TaskRecord, TaskState}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::Notify; +use uuid::Uuid; + +use crate::DgwState; +use crate::config::Conf; + +/// Number of tasks running at the same time; other tasks wait in the `NotStarted` state. +pub const MAX_CONCURRENT_TASKS: usize = 2; + +/// Longest time one attempt of a task may run. +pub const TASK_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +/// Attempts of a task job before the task job queue gives up on it. +pub const TASK_MAX_ATTEMPTS: u32 = 5; + +pub const SECRETS_LOST_ERROR: &str = "gateway restarted, API key no longer available"; + +pub const JOB_LOST_ERROR: &str = "gateway restarted, task job no longer exists"; + +/// Why a run of a task failed; the message is stored in the task record and returned by the API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskError { + /// Worth another attempt later, such as a rate limit or a network error. + Transient(String), + /// Another attempt would fail the same way. + Permanent(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RetryPolicy { + /// Attempts in total, capped by the task job queue. + pub max_attempts: u32, +} + +impl RetryPolicy { + pub const NO_RETRY: Self = Self { max_attempts: 1 }; + + pub const JOB_QUEUE: Self = Self { + max_attempts: TASK_MAX_ATTEMPTS, + }; +} + +/// A kind of one-shot background task. +pub trait TaskKind: Sized + Send + Sync + 'static { + /// Value of the TASK token `jet_tk` claim. + const KIND: &'static str; + + /// How many times a run ending with [`TaskError::Transient`] is attempted. + const RETRY: RetryPolicy; + + /// What the task works on, taken from the TASK token. + type Target: Serialize + DeserializeOwned + Send + Sync + 'static; + + /// Persisted parameters; they must never hold a secret. + type Params: Serialize + DeserializeOwned + Send + Sync + 'static; + + /// Progress reported while the task is running. + type Substate: Serialize + Default + Send + Sync + 'static; + + type Output: Serialize + Send + 'static; + + fn run(ctx: TaskCtx) -> impl Future> + Send; +} + +/// A task whose inputs are all persisted, so it resumes after a restart. +pub trait DurableTask: TaskKind { + /// Checks the request before the task is recorded. + fn prepare(target: &Self::Target, params: &Self::Params, state: &DgwState) -> Result<(), TaskErrorCode>; +} + +/// A task that needs secrets, kept in memory only until the task finishes. +pub trait EphemeralTask: TaskKind { + type Secrets: Send + Sync + 'static; + + /// Body of `POST /jet/tasks`, holding both the parameters and the secrets. + type Request: DeserializeOwned; + + /// Checks the request and splits it into the persisted parameters and the secrets. + fn prepare( + target: &Self::Target, + request: Self::Request, + state: &DgwState, + ) -> Result<(Self::Params, Self::Secrets), TaskErrorCode>; +} + +/// Stable code telling a client why a task request failed; safe to show. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskErrorCode { + /// The body is not valid JSON or does not match the parameters of the task kind. + InvalidParams, + /// `ai-log`: the model is empty. + MissingModel, + /// `ai-log`: the API key is empty. + MissingApiKey, + /// `ai-log`: the provider has no default base URL, so the request must give one. + MissingBaseUrl, + /// `ai-log`: the AI settings are invalid for another reason. + InvalidAiSettings, + /// `ai-log`: the session is still recording. + RecordingActive, + /// No task has this ID. + TaskNotFound, + /// Unexpected server error. + Internal, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TaskStatus { + NotStarted, + Running { substate: serde_json::Value }, + Success { result: serde_json::Value }, + Failed { error: String }, +} + +#[derive(Debug, Clone)] +pub struct TaskSnapshot { + pub id: Uuid, + pub kind: String, + pub status: TaskStatus, +} + +impl From for TaskSnapshot { + fn from(record: TaskRecord) -> Self { + let status = match record.state { + TaskState::NotStarted => TaskStatus::NotStarted, + TaskState::Running => TaskStatus::Running { + substate: parse_json(record.substate.as_deref()), + }, + TaskState::Success => TaskStatus::Success { + result: parse_json(record.result.as_deref()), + }, + TaskState::Failed => TaskStatus::Failed { + error: record.error.unwrap_or_default(), + }, + }; + + Self { + id: record.id, + kind: record.kind, + status, + } + } +} + +/// What a run of a task gets. +pub struct TaskCtx { + pub id: Uuid, + /// Starts at 1. + pub attempt: u32, + pub target: K::Target, + pub params: K::Params, + pub state: DgwState, + pub progress: Progress, + secrets: Option>, +} + +impl TaskCtx { + pub fn secrets(&self) -> Option<&K::Secrets> { + self.secrets.as_deref().and_then(|secrets| secrets.downcast_ref()) + } +} + +/// Lets a running task update its substate. +pub struct Progress { + id: Uuid, + tasks: TaskService, + _substate: PhantomData, +} + +impl Progress { + pub async fn set(&self, substate: &S) { + if let Err(error) = self.tasks.store().set_substate(self.id, &to_json(substate)).await { + warn!(task.id = %self.id, error = format!("{error:#}"), "Failed to store the task substate"); + } + } +} + +type SecretsMap = HashMap>; + +struct TaskServiceInner { + store: LibSqlProvisionerTaskStore, + /// Stored in the same database as the task records. + queue: DynJobQueue, + notify_runner: Arc, + runner_waker: RunnerWaker, + secrets: Mutex, + timeout: Duration, +} + +/// Starts background tasks, queues their jobs and reads their records. +#[derive(Clone)] +pub struct TaskService { + inner: Arc, +} + +impl TaskService { + /// Opens the provisioner task database when `enable_unstable` is set; otherwise, never touches it. + /// + /// Call it at startup, before the task runner starts. + pub async fn open_if_enabled(conf: &Conf) -> anyhow::Result> { + if !conf.debug.enable_unstable { + return Ok(None); + } + + Self::open(conf.provisioner_tasks_database.as_str(), TASK_TIMEOUT) + .await + .map(Some) + } + + /// Opens the database at `path`, then fails every unfinished task whose job is gone. + async fn open(path: &str, timeout: Duration) -> anyhow::Result { + let conn = libsql::Builder::new_local(path) + .build() + .await + .context("failed to open the provisioner task database")? + .connect() + .context("failed to connect to the provisioner task database")?; + + let notify_runner = Arc::new(Notify::new()); + + let runner_waker = RunnerWaker::new({ + let notify_runner = Arc::clone(¬ify_runner); + move || notify_runner.notify_one() + }); + + let queue = LibSqlJobQueue::builder() + .runner_waker(runner_waker.clone()) + .conn(conn.clone()) + .max_attempts(TASK_MAX_ATTEMPTS) + .build(); + + queue.setup().await.context("failed to set up the task job queue")?; + + queue + .reset_claimed_jobs() + .await + .context("failed to reset the claimed task jobs")?; + + queue + .clear_failed() + .await + .context("failed to clear the failed task jobs")?; + + let store = LibSqlProvisionerTaskStore::init(conn) + .await + .context("failed to set up the provisioner task records")?; + + let service = Self { + inner: Arc::new(TaskServiceInner { + store, + queue: Arc::new(queue), + notify_runner, + runner_waker, + secrets: Mutex::new(HashMap::new()), + timeout, + }), + }; + + service + .reconcile() + .await + .context("failed to reconcile the provisioner tasks")?; + + Ok(service) + } + + fn store(&self) -> &LibSqlProvisionerTaskStore { + &self.inner.store + } + + pub async fn get(&self, id: Uuid) -> anyhow::Result> { + Ok(self.store().get(id).await?.map(TaskSnapshot::from)) + } + + /// Fails every unfinished task that has no job left in the queue. + async fn reconcile(&self) -> anyhow::Result<()> { + let defs = self + .inner + .queue + .job_defs(TaskJob::NAME) + .await + .context("failed to list the task jobs")?; + + self.reconcile_with_job_defs(&defs).await + } + + async fn reconcile_with_job_defs(&self, defs: &[String]) -> anyhow::Result<()> { + let queued = defs + .iter() + .filter_map(|def| serde_json::from_str::(def).ok()) + .map(|def| def.task_id) + .collect::>(); + + let store = self.store(); + + for id in store.unfinished().await? { + if !queued.contains(&id) && store.fail(id, JOB_LOST_ERROR).await? { + warn!(task.id = %id, "Background task has no job left; marked as failed"); + } + } + + Ok(()) + } + + /// Parses the request of an ephemeral task, records the task and queues its job. + pub async fn start_ephemeral( + &self, + target: K::Target, + body: &[u8], + token_jti: Uuid, + state: &DgwState, + ) -> Result { + let request = parse_body::(body)?; + let (params, secrets) = K::prepare(&target, request, state)?; + self.create::(&target, ¶ms, token_jti, Some(Arc::new(secrets)), state) + .await + } + + /// Parses the parameters of a durable task, records the task and queues its job. + pub async fn start_durable( + &self, + target: K::Target, + body: &[u8], + token_jti: Uuid, + state: &DgwState, + ) -> Result { + let params = parse_body::(body)?; + K::prepare(&target, ¶ms, state)?; + self.create::(&target, ¶ms, token_jti, None, state).await + } + + async fn create( + &self, + target: &K::Target, + params: &K::Params, + token_jti: Uuid, + secrets: Option>, + state: &DgwState, + ) -> Result { + let id = Uuid::new_v4(); + + let (target, params) = match (serde_json::to_value(target), serde_json::to_value(params)) { + (Ok(target), Ok(params)) => (target, params), + (Err(error), _) | (_, Err(error)) => { + error!(%error, task.kind = K::KIND, "Failed to serialize the task definition"); + return Err(TaskErrorCode::Internal); + } + }; + + let def = TaskJobDef { + task_id: id, + kind: K::KIND.to_owned(), + target, + params, + }; + + // The job may run as soon as it is queued, so the secrets must be in place first. + if let Some(secrets) = secrets { + self.inner.secrets.lock().insert(id, secrets); + } + + let inserted = self + .store() + .insert(NewTask { + id, + kind: K::KIND, + target: &def.target.to_string(), + params: &def.params.to_string(), + token_jti, + }) + .await; + + if let Err(error) = inserted { + error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to record the task"); + self.forget_secrets(id); + return Err(TaskErrorCode::Internal); + } + + let job: DynJob = Box::new(TaskJob { + def, + tasks: self.clone(), + state: state.clone(), + }); + + if let Err(error) = self.inner.queue.push_job(&job, None).await { + error!(task.id = %id, task.kind = K::KIND, error = format!("{error:#}"), "Failed to queue the task"); + self.fail(id, "failed to queue the task").await; + return Err(TaskErrorCode::Internal); + } + + info!(task.id = %id, task.kind = K::KIND, %token_jti, "Background task created"); + + Ok(TaskSnapshot { + id, + kind: K::KIND.to_owned(), + status: TaskStatus::NotStarted, + }) + } + + async fn execute_ephemeral(&self, def: TaskJobDef, state: &DgwState) -> anyhow::Result<()> { + let secrets = self.inner.secrets.lock().get(&def.task_id).cloned(); + + match secrets { + Some(secrets) => self.execute::(def, state, Some(secrets)).await, + None => { + warn!(task.id = %def.task_id, task.kind = K::KIND, "Background task secrets are gone"); + self.fail(def.task_id, SECRETS_LOST_ERROR).await; + Ok(()) + } + } + } + + #[cfg_attr(not(test), expect(dead_code, reason = "no durable task kind exists yet"))] + async fn execute_durable(&self, def: TaskJobDef, state: &DgwState) -> anyhow::Result<()> { + self.execute::(def, state, None).await + } + + /// Runs one attempt; an error asks the job queue to try again later. + async fn execute( + &self, + def: TaskJobDef, + state: &DgwState, + secrets: Option>, + ) -> anyhow::Result<()> { + let id = def.task_id; + + let (target, params) = match ( + serde_json::from_value::(def.target), + serde_json::from_value::(def.params), + ) { + (Ok(target), Ok(params)) => (target, params), + (Err(error), _) | (_, Err(error)) => { + error!(task.id = %id, task.kind = K::KIND, %error, "Invalid task definition"); + self.fail(id, "invalid task definition").await; + return Ok(()); + } + }; + + let substate = to_json(&K::Substate::default()); + + let store = self.store(); + + let Some(attempt) = store.start_attempt(id, &substate).await? else { + debug!(task.id = %id, task.kind = K::KIND, "Background task is already finished"); + self.forget_secrets(id); + return Ok(()); + }; + + info!(task.id = %id, task.kind = K::KIND, attempt, "Background task running"); + + let ctx = TaskCtx { + id, + attempt, + target, + params, + state: state.clone(), + progress: Progress { + id, + tasks: self.clone(), + _substate: PhantomData, + }, + secrets, + }; + + let max_attempts = K::RETRY.max_attempts.min(TASK_MAX_ATTEMPTS); + + match run_attempt::(ctx, self.inner.timeout).await { + // An error returned to the job queue would run the task again, so a failure to store is only logged. + Ok(output) => match store.succeed(id, &to_json(&output)).await { + Ok(_) => info!(task.id = %id, task.kind = K::KIND, attempt, "Background task succeeded"), + Err(error) => error!( + task.id = %id, + task.kind = K::KIND, + attempt, + error = format!("{error:#}"), + "Background task succeeded but its result was not stored" + ), + }, + Err(TaskError::Transient(error)) if attempt < max_attempts => { + warn!(task.id = %id, task.kind = K::KIND, attempt, max_attempts, %error, "Background task attempt failed"); + store.retry_later(id, &error).await?; + anyhow::bail!("background task attempt failed: {error}"); + } + Err(TaskError::Transient(error) | TaskError::Permanent(error)) => { + warn!(task.id = %id, task.kind = K::KIND, attempt, %error, "Background task failed"); + store.fail(id, &error).await?; + } + } + + self.forget_secrets(id); + + Ok(()) + } + + async fn fail(&self, id: Uuid, error: &str) { + self.forget_secrets(id); + + if let Err(store_error) = self.store().fail(id, error).await { + error!(task.id = %id, error = format!("{store_error:#}"), "Failed to record the task failure"); + } + } + + fn forget_secrets(&self, id: Uuid) { + self.inner.secrets.lock().remove(&id); + } +} + +async fn run_attempt(ctx: TaskCtx, timeout: Duration) -> Result { + // The run has its own Tokio task so a panic ends as a failure instead of a task stuck in `Running`. + let mut handle = tokio::spawn(K::run(ctx)); + + match tokio::time::timeout(timeout, &mut handle).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(TaskError::Permanent("task panicked".to_owned())), + Err(_) => { + handle.abort(); + Err(TaskError::Permanent("task timed out".to_owned())) + } + } +} + +fn parse_body(body: &[u8]) -> Result { + // The serde error is not logged because it may quote the rejected value, which could be a secret. + serde_json::from_slice::(body).map_err(|error| { + debug!( + task.kind = K::KIND, + category = ?error.classify(), + line = error.line(), + column = error.column(), + "Invalid task parameters" + ); + TaskErrorCode::InvalidParams + }) +} + +fn to_json(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|error| { + error!(%error, "Failed to serialize a task value"); + "null".to_owned() + }) +} + +fn parse_json(json: Option<&str>) -> serde_json::Value { + json.and_then(|json| serde_json::from_str(json).ok()) + .unwrap_or(serde_json::Value::Null) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskJobDef { + task_id: Uuid, + kind: String, + target: serde_json::Value, + params: serde_json::Value, +} + +/// Job running one attempt of a background task. +struct TaskJob { + def: TaskJobDef, + tasks: TaskService, + state: DgwState, +} + +impl TaskJob { + const NAME: &'static str = "provisioner-task"; + + fn read_json(json: &str, tasks: TaskService, state: DgwState) -> anyhow::Result { + let def = serde_json::from_str(json).context("failed to deserialize the task job")?; + Ok(Self { def, tasks, state }) + } +} + +#[async_trait] +impl job_queue::Job for TaskJob { + fn name(&self) -> &str { + Self::NAME + } + + fn write_json(&self) -> anyhow::Result { + serde_json::to_string(&self.def).context("failed to serialize the task job") + } + + async fn run(&mut self) -> anyhow::Result<()> { + let def = self.def.clone(); + + match def.kind.as_str() { + ai_log::AiLogTask::KIND => { + self.tasks + .execute_ephemeral::(def, &self.state) + .await + } + kind => { + error!(task.id = %def.task_id, task.kind = kind, "Unknown task kind"); + self.tasks.fail(def.task_id, "unknown task kind").await; + Ok(()) + } + } + } +} + +struct TaskJobReader { + tasks: TaskService, + state: DgwState, +} + +impl JobReader for TaskJobReader { + fn read_json(&self, name: &str, json: &str) -> anyhow::Result { + match name { + TaskJob::NAME => { + let job = TaskJob::read_json(json, self.tasks.clone(), self.state.clone())?; + Ok(Box::new(job)) + } + _ => anyhow::bail!("unknown job name: {name}"), + } + } +} + +/// Runs the jobs of the provisioner tasks. +pub struct TaskRunnerTask { + tasks: TaskService, + state: DgwState, +} + +impl TaskRunnerTask { + pub fn new(tasks: TaskService, state: DgwState) -> Self { + Self { tasks, state } + } +} + +#[async_trait] +impl Task for TaskRunnerTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "provisioner task runner"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + let inner = Arc::clone(&self.tasks.inner); + + let reader = TaskJobReader { + tasks: self.tasks, + state: self.state, + }; + + // The runner claims no more jobs than may run at once, so a claimed job never waits for a slot. + crate::job_queue::run_jobs( + Arc::clone(&inner.queue), + &reader, + Arc::clone(&inner.notify_runner), + inner.runner_waker.clone(), + MAX_CONCURRENT_TASKS, + shutdown_signal, + ) + .await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/devolutions-gateway/src/tasks/tests.rs b/devolutions-gateway/src/tasks/tests.rs new file mode 100644 index 000000000..ba420b02b --- /dev/null +++ b/devolutions-gateway/src/tasks/tests.rs @@ -0,0 +1,389 @@ +use job_queue::Job as _; +use provisioner_task_store_libsql::TaskState; + +use super::ai_log::{AiLogTarget, AiLogTask}; +use super::*; +use crate::MockHandles; + +const API_KEY: &str = "sk-task-unit-test-secret"; + +const CONFIG: &str = r#"{ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [{ "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" }], + "Proxy": { "Mode": "Off" } +}"#; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +enum Outcome { + Succeed, + Transient, + Permanent, + Hang, + Panic, +} + +#[derive(Default, Serialize)] +struct Step { + step: u32, +} + +/// Durable test task doing what its parameters say, attempted at most `N` times. +struct Scripted; + +impl TaskKind for Scripted { + const KIND: &'static str = "scripted"; + const RETRY: RetryPolicy = RetryPolicy { max_attempts: N }; + + type Target = (); + type Params = Outcome; + type Substate = Step; + type Output = u32; + + async fn run(ctx: TaskCtx) -> Result { + ctx.progress.set(&Step { step: ctx.attempt }).await; + + match ctx.params { + Outcome::Succeed => Ok(42), + Outcome::Transient => Err(TaskError::Transient("rate limited".to_owned())), + Outcome::Permanent => Err(TaskError::Permanent("unauthorized".to_owned())), + Outcome::Hang => std::future::pending().await, + Outcome::Panic => panic!("scripted panic"), + } + } +} + +impl DurableTask for Scripted { + fn prepare(_: &(), _: &Outcome, _: &DgwState) -> Result<(), TaskErrorCode> { + Ok(()) + } +} + +/// Durable test task that succeeds after making the task records unwritable. +struct LosesStore; + +impl TaskKind for LosesStore { + const KIND: &'static str = "loses-store"; + const RETRY: RetryPolicy = RetryPolicy::JOB_QUEUE; + + /// Path of the task database. + type Target = String; + type Params = (); + type Substate = Step; + type Output = u32; + + async fn run(ctx: TaskCtx) -> Result { + let conn = libsql::Builder::new_local(ctx.target.as_str()) + .build() + .await + .expect("database") + .connect() + .expect("connection"); + + conn.execute("ALTER TABLE task RENAME TO task_gone", ()) + .await + .expect("rename"); + + Ok(42) + } +} + +impl DurableTask for LosesStore { + fn prepare(_: &String, _: &(), _: &DgwState) -> Result<(), TaskErrorCode> { + Ok(()) + } +} + +struct Harness { + state: DgwState, + tasks: TaskService, + _handles: MockHandles, + _dir: tempfile::TempDir, + db_path: String, +} + +impl Harness { + async fn new() -> Self { + Self::with_timeout(TASK_TIMEOUT).await + } + + async fn with_timeout(timeout: Duration) -> Self { + let dir = tempfile::tempdir().expect("temp dir"); + let db_path = dir + .path() + .join("provisioner_tasks.db") + .to_str() + .expect("UTF-8") + .to_owned(); + + let (state, handles) = DgwState::mock(CONFIG).expect("mock state"); + let tasks = TaskService::open(&db_path, timeout).await.expect("task service"); + + Self { + state, + tasks, + _handles: handles, + _dir: dir, + db_path, + } + } + + /// Reads back the job of a task from the task job queue. + async fn queued_job(&self, id: Uuid) -> TaskJob { + let json = self + .tasks + .inner + .queue + .job_defs(TaskJob::NAME) + .await + .expect("job definitions") + .into_iter() + .find(|json| serde_json::from_str::(json).is_ok_and(|def| def.task_id == id)) + .expect("queued job"); + + TaskJob::read_json(&json, self.tasks.clone(), self.state.clone()).expect("valid job") + } + + async fn start_scripted(&self, outcome: Outcome) -> TaskJob { + let body = serde_json::to_vec(&outcome).expect("JSON"); + + let snapshot = self + .tasks + .start_durable::>((), &body, Uuid::new_v4(), &self.state) + .await + .expect("task starts"); + + self.queued_job(snapshot.id).await + } + + async fn run_scripted(&self, job: &TaskJob) -> anyhow::Result<()> { + self.tasks + .execute_durable::>(job.def.clone(), &self.state) + .await + } + + async fn start_ai_log(&self) -> TaskSnapshot { + let body = serde_json::json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }).to_string(); + let target = AiLogTarget { + session_id: Uuid::new_v4(), + }; + + self.tasks + .start_ephemeral::(target, body.as_bytes(), Uuid::new_v4(), &self.state) + .await + .expect("task starts") + } + + async fn record(&self, id: Uuid) -> TaskRecord { + self.tasks.store().get(id).await.expect("read").expect("record exists") + } +} + +#[tokio::test] +async fn success_stores_the_result() { + let harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.kind, "scripted"); + + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Success); + assert_eq!(record.result.as_deref(), Some("42")); + assert_eq!(record.attempts, 1); +} + +#[tokio::test] +async fn transient_error_asks_the_job_queue_for_a_retry() { + let harness = Harness::new().await; + let job = harness.start_scripted::<3>(Outcome::Transient).await; + let id = job.def.task_id; + + for attempt in 1..3 { + assert!( + harness.run_scripted::<3>(&job).await.is_err(), + "attempt {attempt} is retried" + ); + + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::NotStarted); + assert_eq!(record.attempts, attempt); + assert_eq!(record.error.as_deref(), Some("rate limited")); + } + + harness + .run_scripted::<3>(&job) + .await + .expect("last attempt is not retried"); + + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.attempts, 3); + assert_eq!(record.error.as_deref(), Some("rate limited")); +} + +#[tokio::test] +async fn retry_policy_is_capped_by_the_job_queue() { + let harness = Harness::new().await; + let job = harness.start_scripted::<100>(Outcome::Transient).await; + + let mut retried = 0; + while harness.run_scripted::<100>(&job).await.is_err() { + retried += 1; + } + + assert_eq!(retried, TASK_MAX_ATTEMPTS - 1); + assert_eq!(harness.record(job.def.task_id).await.state, TaskState::Failed); +} + +#[tokio::test] +async fn permanent_error_fails_without_retry() { + let harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Permanent).await; + + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("unauthorized")); + assert_eq!(record.attempts, 1); + assert!(record.finished_at.is_some()); +} + +#[tokio::test] +async fn finished_task_is_not_run_again() { + let harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + + harness.run_scripted::<5>(&job).await.expect("first run"); + harness.run_scripted::<5>(&job).await.expect("second run"); + + assert_eq!(harness.record(job.def.task_id).await.attempts, 1); +} + +#[tokio::test] +async fn timeout_and_panic_are_permanent_failures() { + let harness = Harness::with_timeout(Duration::from_millis(20)).await; + + for (outcome, error) in [(Outcome::Hang, "task timed out"), (Outcome::Panic, "task panicked")] { + let job = harness.start_scripted::<5>(outcome).await; + harness.run_scripted::<5>(&job).await.expect("no retry"); + + let record = harness.record(job.def.task_id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(error)); + } +} + +#[tokio::test] +async fn ephemeral_task_fails_without_retry_after_a_restart() { + let harness = Harness::new().await; + let snapshot = harness.start_ai_log().await; + + let json = harness.queued_job(snapshot.id).await.write_json().expect("job JSON"); + assert!(!json.contains(API_KEY), "{json}"); + + // A restart keeps the database but loses the secrets held in memory. + let restarted = TaskService::open(&harness.db_path, TASK_TIMEOUT) + .await + .expect("task service"); + let mut job = TaskJob::read_json(&json, restarted, harness.state.clone()).expect("valid job"); + + job.run().await.expect("no retry"); + + let record = harness.record(snapshot.id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(SECRETS_LOST_ERROR)); + assert_eq!(record.attempts, 0); +} + +#[tokio::test] +async fn secrets_are_dropped_when_the_task_finishes() { + let harness = Harness::new().await; + let snapshot = harness.start_ai_log().await; + + assert!(harness.tasks.inner.secrets.lock().contains_key(&snapshot.id)); + + let mut job = harness.queued_job(snapshot.id).await; + job.run().await.expect("no retry"); + + assert!(harness.tasks.inner.secrets.lock().is_empty()); + + let record = harness.record(snapshot.id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some("ai-log task not implemented yet")); + assert!(!record.params.contains(API_KEY), "{}", record.params); +} + +#[tokio::test] +async fn success_is_not_run_again_when_its_record_cannot_be_written() { + let harness = Harness::new().await; + + let snapshot = harness + .tasks + .start_durable::(harness.db_path.clone(), b"null", Uuid::new_v4(), &harness.state) + .await + .expect("task starts"); + let job = harness.queued_job(snapshot.id).await; + + harness + .tasks + .execute_durable::(job.def.clone(), &harness.state) + .await + .expect("a succeeded run is not retried"); +} + +#[tokio::test] +async fn reconcile_fails_unfinished_tasks_without_a_job() { + let harness = Harness::new().await; + + let queued = harness.start_scripted::<5>(Outcome::Succeed).await; + let lost = harness.start_scripted::<5>(Outcome::Succeed).await; + let running_lost = harness.start_scripted::<5>(Outcome::Succeed).await; + let finished = harness.start_scripted::<5>(Outcome::Succeed).await; + + let store = harness.tasks.store(); + store + .start_attempt(running_lost.def.task_id, "null") + .await + .expect("start"); + harness.run_scripted::<5>(&finished).await.expect("run"); + + let defs = vec![queued.write_json().expect("JSON"), "not a task job".to_owned()]; + harness.tasks.reconcile_with_job_defs(&defs).await.expect("reconcile"); + + assert_eq!(harness.record(queued.def.task_id).await.state, TaskState::NotStarted); + assert_eq!(harness.record(finished.def.task_id).await.state, TaskState::Success); + + for id in [lost.def.task_id, running_lost.def.task_id] { + let record = harness.record(id).await; + assert_eq!(record.state, TaskState::Failed); + assert_eq!(record.error.as_deref(), Some(JOB_LOST_ERROR)); + } +} + +#[tokio::test] +async fn snapshot_reflects_the_record() { + let harness = Harness::new().await; + let job = harness.start_scripted::<5>(Outcome::Succeed).await; + let id = job.def.task_id; + + let snapshot = harness.tasks.get(id).await.expect("read").expect("exists"); + assert_eq!(snapshot.kind, "scripted"); + assert_eq!(snapshot.status, TaskStatus::NotStarted); + + let store = harness.tasks.store(); + store.start_attempt(id, r#"{"step":1}"#).await.expect("start"); + assert_eq!( + harness.tasks.get(id).await.expect("read").expect("exists").status, + TaskStatus::Running { + substate: serde_json::json!({ "step": 1 }) + } + ); + + assert!(harness.tasks.get(Uuid::new_v4()).await.expect("read").is_none()); +} diff --git a/devolutions-gateway/tests/config.rs b/devolutions-gateway/tests/config.rs index 4128ae6ba..dc82f9f4d 100644 --- a/devolutions-gateway/tests/config.rs +++ b/devolutions-gateway/tests/config.rs @@ -94,6 +94,7 @@ fn hub_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: Some(VerbosityProfile::Tls), web_app: None, @@ -144,6 +145,7 @@ fn legacy_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, @@ -193,6 +195,7 @@ fn system_store_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, @@ -267,6 +270,7 @@ fn standalone_custom_auth_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: Some(WebAppConf { @@ -348,6 +352,7 @@ fn standalone_no_auth_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: Some(WebAppConf { @@ -429,6 +434,7 @@ fn proxy_sample() -> Sample { min_recording_storage_free_space: None, job_queue_database: None, traffic_audit_database: None, + provisioner_tasks_database: None, ngrok: None, verbosity_profile: None, web_app: None, diff --git a/devolutions-gateway/tests/tasks.rs b/devolutions-gateway/tests/tasks.rs new file mode 100644 index 000000000..cc993e51a --- /dev/null +++ b/devolutions-gateway/tests/tasks.rs @@ -0,0 +1,550 @@ +#![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used)] + +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use axum::extract::connect_info::MockConnectInfo; +use axum::http::{self, Request, StatusCode}; +use base64::Engine as _; +use devolutions_gateway::tasks::{SECRETS_LOST_ERROR, TaskRunnerTask, TaskService}; +use devolutions_gateway::{DgwState, MockHandles}; +use devolutions_gateway_task::{ChildTask, ShutdownHandle, Task as _}; +use http_body_util::BodyExt as _; +use serde_json::{Value, json}; +use tower::ServiceExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; +use uuid::Uuid; + +const API_KEY: &str = "sk-task-api-test-secret"; + +/// Gateway configuration keeping the task database in `dir`, so a later start on the same `dir` acts as a restart. +fn config(dir: &Path, enable_unstable: bool) -> String { + json!({ + "ProvisionerPublicKeyData": { + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + }, + "Listeners": [ + { + "InternalUrl": "http://*:7171", + "ExternalUrl": "https://*:7171" + } + ], + "Proxy": { "Mode": "Off" }, + "ProvisionerTasksDatabase": tasks_db(dir), + "__debug__": { + "disable_token_validation": true, + "enable_unstable": enable_unstable + } + }) + .to_string() +} + +struct Gateway { + app: Router, + shutdown_handle: ShutdownHandle, + job_tasks: Vec>>, + _mock_handles: Box, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Jobs { + Run, + QueueOnly, +} + +impl Gateway { + /// Starts a Gateway with the task system enabled, keeping the task database in `dir`. + async fn start(dir: &Path, jobs: Jobs) -> anyhow::Result { + Self::start_with_config(&config(dir, true), jobs).await + } + + async fn start_with_config(config: &str, jobs: Jobs) -> anyhow::Result { + let (mut state, handles) = DgwState::mock(config)?; + let MockHandles { + session_manager_rx, + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + shutdown_handle: mock_shutdown_handle, + } = handles; + + // The auth middleware asks the session manager about any token carrying `jet_aid`; nothing answers in the mock. + drop(session_manager_rx); + + state.tasks = TaskService::open_if_enabled(&state.conf_handle.get_conf()).await?; + + let (shutdown_handle, shutdown_signal) = ShutdownHandle::new(); + let mut job_tasks = Vec::new(); + + if let (Some(tasks), Jobs::Run) = (state.tasks.clone(), jobs) { + let runner = TaskRunnerTask::new(tasks, state.clone()); + job_tasks.push(ChildTask::spawn(runner.run(shutdown_signal))); + } + + let app = devolutions_gateway::make_http_service(state) + .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); + + Ok(Self { + app, + shutdown_handle, + job_tasks, + _mock_handles: Box::new(( + recording_manager_rx, + subscriber_rx, + job_queue_rx, + traffic_audit_rx, + mock_shutdown_handle, + )), + }) + } + + async fn stop(self) { + self.shutdown_handle.signal(); + + for task in self.job_tasks { + task.join().await.unwrap().unwrap(); + } + } +} + +fn tasks_db(dir: &Path) -> PathBuf { + dir.join("provisioner_tasks.db") +} + +/// Jobs in the task job queue, which lives in the task database. +async fn queued_job_count(dir: &Path) -> u64 { + let conn = job_queue_libsql::libsql::Builder::new_local(tasks_db(dir)) + .build() + .await + .unwrap() + .connect() + .unwrap(); + + let row = conn + .query("SELECT count(*) FROM job_queue", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap(); + + row.get::(0).unwrap() +} + +async fn wait_for_queued_jobs(dir: &Path, expected: u64) { + tokio::time::timeout(Duration::from_secs(10), async { + while queued_job_count(dir).await != expected { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("job queue reaches the expected size"); +} + +/// Every file of the task database, WAL included, since SQLite may not have checkpointed yet. +fn database_bytes(dir: &Path) -> Vec<(PathBuf, Vec)> { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.to_str().unwrap().contains(".db")) + .map(|path| { + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }) + .collect() +} + +fn contains(haystack: &[u8], needle: &str) -> bool { + haystack.windows(needle.len()).any(|window| window == needle.as_bytes()) +} + +fn assert_database_holds_settings_but_not_the_key(dir: &Path) { + let files = database_bytes(dir); + assert!(files.iter().any(|(path, _)| path.ends_with("provisioner_tasks.db"))); + + let all = files + .iter() + .flat_map(|(_, bytes)| bytes.iter().copied()) + .collect::>(); + assert!(contains(&all, "gpt-test"), "the scan sees the persisted settings"); + + for (path, bytes) in &files { + assert!(!contains(bytes, API_KEY), "{} holds the API key", path.display()); + } +} + +fn unsigned_jws(cty: &str, payload: &Value) -> String { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(json!({ "alg": "RS256", "cty": cty }).to_string()); + let payload = engine.encode(payload.to_string()); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") +} + +fn now() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn task_token() -> String { + unsigned_jws( + "TASK", + &json!({ + "jet_tk": "ai-log", + "jet_aid": Uuid::new_v4(), + "nbf": now(), + "exp": now() + 600, + "jti": Uuid::new_v4(), + }), + ) +} + +fn scope_token(scope: &str) -> String { + unsigned_jws( + "SCOPE", + &json!({ "scope": scope, "exp": now() + 600, "jti": Uuid::new_v4() }), + ) +} + +fn ai_params() -> Value { + json!({ "provider": "openai", "model": "gpt-test", "apiKey": API_KEY }) +} + +fn start_request(token: Option<&str>, params: &Value) -> Request { + let mut request = Request::builder() + .method("POST") + .uri("/jet/tasks") + .header(http::header::CONTENT_TYPE, "application/json"); + + if let Some(token) = token { + request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}")); + } + + request.body(Body::from(params.to_string())).unwrap() +} + +fn status_request(token: Option<&str>, id: Uuid) -> Request { + let mut request = Request::builder().method("GET").uri(format!("/jet/tasks/{id}")); + + if let Some(token) = token { + request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}")); + } + + request.body(Body::empty()).unwrap() +} + +async fn send(app: &Router, request: Request) -> (StatusCode, String) { + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(body.to_vec()).unwrap()) +} + +async fn start_task(app: &Router) -> Value { + let (status, body) = send(app, start_request(Some(&task_token()), &ai_params())).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + serde_json::from_str(&body).unwrap() +} + +async fn wait_until_finished(app: &Router, id: Uuid) -> Value { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let (status, body) = send(app, status_request(Some(&scope_token("gateway.tasks.read")), id)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let info: Value = serde_json::from_str(&body).unwrap(); + if info["state"] == "success" || info["state"] == "failed" { + return info; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("task finishes") +} + +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl io::Write for CapturedLogs { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl CapturedLogs { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +fn capture_logs() -> (CapturedLogs, impl Sized) { + let logs = CapturedLogs::default(); + let writer = logs.clone(); + let guard = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .set_default(); + + // With one registered dispatcher, tracing takes callsite interest from the thread that hits it first, + // so a parallel test thread without a subscriber would disable these events for everyone. + let second_dispatcher = tracing::Dispatch::new(tracing_subscriber::registry()); + + (logs, (guard, second_dispatcher)) +} + +#[tokio::test] +async fn ai_log_task_is_accepted_then_fails_as_not_implemented() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + + let started = start_task(&app).await; + assert_eq!(started["kind"], "ai-log"); + assert_eq!(started["state"], "not-started"); + + let id = started["id"].as_str().unwrap().parse::().unwrap(); + let finished = wait_until_finished(&app, id).await; + + assert_eq!( + finished, + json!({ + "id": id, + "kind": "ai-log", + "state": "failed", + "error": "ai-log task not implemented yet", + }) + ); +} + +#[tokio::test] +async fn start_requires_a_task_token() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + + let (status, _) = send(&app, start_request(None, &ai_params())).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, _) = send(&app, start_request(Some(&scope_token("*")), &ai_params())).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn status_requires_the_tasks_read_scope() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + let id = start_task(&app).await["id"].as_str().unwrap().parse::().unwrap(); + + let (status, _) = send(&app, status_request(None, id)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, _) = send(&app, status_request(Some(&task_token()), id)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, _) = send(&app, status_request(Some(&scope_token("gateway.sessions.read")), id)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, _) = send(&app, status_request(Some(&scope_token("*")), id)).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn unknown_task_is_not_found() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + + let (status, body) = send( + &app, + status_request(Some(&scope_token("gateway.tasks.read")), Uuid::new_v4()), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ "error": "task_not_found" }) + ); +} + +#[tokio::test] +async fn invalid_ai_settings_are_typed_bad_requests() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + + for (params, expected) in [ + (json!({ "provider": "openai", "model": "gpt-test" }), "invalid_params"), + ( + json!({ "provider": "openai", "model": "gpt-test", "apiKey": "" }), + "missing_api_key", + ), + ( + json!({ "provider": "openai", "model": " ", "apiKey": API_KEY }), + "missing_model", + ), + ( + json!({ "provider": "openai-compatible", "model": "gpt-test", "apiKey": API_KEY }), + "missing_base_url", + ), + ( + json!({ "provider": "ollama", "model": "llama", "baseUrl": "http://localhost:11434/" }), + "invalid_params", + ), + (json!({ "model": "gpt-test", "apiKey": API_KEY }), "invalid_params"), + ] { + let (status, body) = send(&app, start_request(Some(&task_token()), ¶ms)).await; + + assert_eq!(status, StatusCode::BAD_REQUEST, "{params}"); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ "error": expected }) + ); + } +} + +#[tokio::test] +async fn api_key_never_appears_in_responses_or_logs() { + let (logs, _guard) = capture_logs(); + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let app = gateway.app.clone(); + + let started = start_task(&app).await; + assert!(!started.to_string().contains(API_KEY)); + + let id = started["id"].as_str().unwrap().parse::().unwrap(); + let finished = wait_until_finished(&app, id).await; + assert!(!finished.to_string().contains(API_KEY)); + + // A key sent in the wrong field must not be echoed by the parameter error either. + let misplaced = json!({ "provider": "openai", "model": "gpt-test", "apiKey": "sk", "maxOutputTokens": API_KEY }); + let (status, body) = send(&app, start_request(Some(&task_token()), &misplaced)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!body.contains(API_KEY)); + + let logs = logs.text(); + assert!(logs.contains("Background task failed"), "{logs}"); + assert!(!logs.contains(API_KEY), "{logs}"); +} + +#[tokio::test] +async fn stable_gateway_never_touches_the_task_database() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start_with_config(&config(dir.path(), false), Jobs::Run) + .await + .unwrap(); + let app = gateway.app.clone(); + + let (status, _) = send(&app, start_request(Some(&task_token()), &ai_params())).await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let (status, _) = send( + &app, + status_request(Some(&scope_token("gateway.tasks.read")), Uuid::new_v4()), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + gateway.stop().await; + + assert!( + database_bytes(dir.path()).is_empty(), + "no task database file is created" + ); +} + +#[tokio::test] +async fn unstable_gateway_opens_the_task_database_at_startup() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + + assert!(tasks_db(dir.path()).exists()); + assert_eq!(queued_job_count(dir.path()).await, 0); + + gateway.stop().await; +} + +#[tokio::test] +async fn neither_database_ever_holds_the_api_key() { + let dir = tempfile::tempdir().unwrap(); + let gateway = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + + let started = start_task(&gateway.app).await; + let id = started["id"].as_str().unwrap().parse::().unwrap(); + assert_eq!(wait_until_finished(&gateway.app, id).await["state"], "failed"); + wait_for_queued_jobs(dir.path(), 0).await; + + gateway.stop().await; + + assert_database_holds_settings_but_not_the_key(dir.path()); +} + +#[tokio::test] +async fn after_a_restart_the_ephemeral_task_fails_without_retry() { + let dir = tempfile::tempdir().unwrap(); + + let before = Gateway::start(dir.path(), Jobs::QueueOnly).await.unwrap(); + let started = start_task(&before.app).await; + let id = started["id"].as_str().unwrap().parse::().unwrap(); + wait_for_queued_jobs(dir.path(), 1).await; + before.stop().await; + + assert_database_holds_settings_but_not_the_key(dir.path()); + + let after = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let finished = wait_until_finished(&after.app, id).await; + + assert_eq!( + finished, + json!({ + "id": id, + "kind": "ai-log", + "state": "failed", + "error": SECRETS_LOST_ERROR, + }) + ); + + wait_for_queued_jobs(dir.path(), 0).await; + after.stop().await; + + assert_database_holds_settings_but_not_the_key(dir.path()); +} + +#[tokio::test] +async fn task_records_survive_a_restart() { + let dir = tempfile::tempdir().unwrap(); + + let before = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let id = start_task(&before.app).await["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + let finished = wait_until_finished(&before.app, id).await; + before.stop().await; + + let after = Gateway::start(dir.path(), Jobs::Run).await.unwrap(); + let (status, body) = send(&after.app, status_request(Some(&scope_token("gateway.tasks.read")), id)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(serde_json::from_str::(&body).unwrap(), finished); + + after.stop().await; +}