From 7cc18d271c78c453de28f6fc4b7581975ecd58d1 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:35:45 +0100 Subject: [PATCH] feat: task templates backend --- vicky/API.md | 55 ++ .../down.sql | 3 + .../up.sql | 41 ++ vicky/src/bin/vicky/main.rs | 19 +- vicky/src/bin/vicky/task_templates.rs | 120 ++++ vicky/src/lib/database/entities/mod.rs | 12 + .../lib/database/entities/task_template.rs | 641 ++++++++++++++++++ vicky/src/lib/database/schema.rs | 49 +- 8 files changed, 936 insertions(+), 4 deletions(-) create mode 100644 vicky/migrations/2026-02-11-120001-0000_task_templates/down.sql create mode 100644 vicky/migrations/2026-02-11-120001-0000_task_templates/up.sql create mode 100644 vicky/src/bin/vicky/task_templates.rs create mode 100644 vicky/src/lib/database/entities/task_template.rs diff --git a/vicky/API.md b/vicky/API.md index 7d41527..d7bdb12 100644 --- a/vicky/API.md +++ b/vicky/API.md @@ -181,3 +181,58 @@ It will return `null`, if successful. } } ``` + +## Task Templates + +### List All Task Templates + +`GET /api/v1/task-templates` returns all task templates. + +### Create A Task Template + +`POST /api/v1/task-templates` creates a template with variables. + +```json +{ + "name": "deploy-service", + "display_name_template": "Deploy {{service}} to {{environment}}", + "flake_ref": { + "flake": "https://something.cat/deployments/{{service}}/flake.nix", + "args": ["--env={{environment}}"] + }, + "locks": [ + { + "name": "deploy/{{service}}", + "type": "WRITE" + } + ], + "features": ["nix"], + "group": "{{environment}}", + "variables": [ + { + "name": "service", + "default_value": null, + "description": "Some service name" + }, + { + "name": "environment", + "default_value": "production", + "description": "Deployment environment" + } + ] +} +``` + +### Instantiate A Task Template + +`POST /api/v1/task-templates//instantiate` renders a task and adds it to the scheduler. + +```json +{ + "needs_confirmation": false, + "variables": { + "service": "vicky", + "environment": "staging" + } +} +``` diff --git a/vicky/migrations/2026-02-11-120001-0000_task_templates/down.sql b/vicky/migrations/2026-02-11-120001-0000_task_templates/down.sql new file mode 100644 index 0000000..6dc7bc2 --- /dev/null +++ b/vicky/migrations/2026-02-11-120001-0000_task_templates/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS task_template_variables; +DROP TABLE IF EXISTS task_template_locks; +DROP TABLE IF EXISTS task_templates; diff --git a/vicky/migrations/2026-02-11-120001-0000_task_templates/up.sql b/vicky/migrations/2026-02-11-120001-0000_task_templates/up.sql new file mode 100644 index 0000000..ea81320 --- /dev/null +++ b/vicky/migrations/2026-02-11-120001-0000_task_templates/up.sql @@ -0,0 +1,41 @@ +CREATE TABLE task_templates +( + id UUID PRIMARY KEY, + name VARCHAR NOT NULL UNIQUE, + display_name_template VARCHAR NOT NULL, + flake_ref_uri_template VARCHAR NOT NULL, + flake_ref_args_template TEXT[] NOT NULL, + features TEXT[] NOT NULL, + "group" VARCHAR, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE TABLE task_template_locks +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + task_template_id UUID NOT NULL, + name_template VARCHAR NOT NULL, + type "LockKind_Type" NOT NULL, + CONSTRAINT fk_task_template + FOREIGN KEY (task_template_id) + REFERENCES task_templates (id) + ON DELETE CASCADE +); + +CREATE TABLE task_template_variables +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + task_template_id UUID NOT NULL, + name VARCHAR NOT NULL, + default_value VARCHAR, + description VARCHAR, + CONSTRAINT fk_task_template_var + FOREIGN KEY (task_template_id) + REFERENCES task_templates (id) + ON DELETE CASCADE, + CONSTRAINT task_template_variable_unique + UNIQUE (task_template_id, name) +); + +CREATE INDEX task_template_locks_template_id_idx ON task_template_locks (task_template_id); +CREATE INDEX task_template_variables_template_id_idx ON task_template_variables (task_template_id); diff --git a/vicky/src/bin/vicky/main.rs b/vicky/src/bin/vicky/main.rs index 21edb9c..2879619 100644 --- a/vicky/src/bin/vicky/main.rs +++ b/vicky/src/bin/vicky/main.rs @@ -4,16 +4,19 @@ use crate::locks::{ locks_get_active, locks_get_detailed_poisoned, locks_get_poisoned, locks_unlock, }; use crate::startup::Result; +use crate::task_templates::{ + task_templates_add, task_templates_get, task_templates_get_specific, task_templates_instantiate, +}; use crate::tasks::{ - tasks_add, tasks_claim, tasks_confirm, tasks_count, tasks_download_logs, tasks_finish, - tasks_get, tasks_get_logs, tasks_get_specific, tasks_heartbeat, tasks_put_logs, tasks_cancel + tasks_add, tasks_cancel, tasks_claim, tasks_confirm, tasks_count, tasks_download_logs, + tasks_finish, tasks_get, tasks_get_logs, tasks_get_specific, tasks_heartbeat, tasks_put_logs, }; use crate::user::get_user; use crate::webconfig::get_web_config; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use errors::AppError; use jwtk::jwk::RemoteJwksVerifier; -use log::{LevelFilter, error, trace, warn, info}; +use log::{LevelFilter, error, info, trace, warn}; use rocket::fairing::AdHoc; use rocket::{Build, Ignite, Rocket, routes}; use snafu::ResultExt; @@ -32,6 +35,7 @@ mod errors; mod events; mod locks; mod startup; +mod task_templates; mod tasks; mod user; mod webconfig; @@ -202,6 +206,15 @@ async fn build_web_api( tasks_cancel ], ) + .mount( + "/api/v1/task-templates", + routes![ + task_templates_get, + task_templates_get_specific, + task_templates_add, + task_templates_instantiate + ], + ) .mount( "/api/v1/locks", routes![ diff --git a/vicky/src/bin/vicky/task_templates.rs b/vicky/src/bin/vicky/task_templates.rs new file mode 100644 index 0000000..d372254 --- /dev/null +++ b/vicky/src/bin/vicky/task_templates.rs @@ -0,0 +1,120 @@ +use crate::auth::AnyAuthGuard; +use crate::errors::AppError; +use crate::events::GlobalEvent; +use diesel::result::DatabaseErrorKind; +use diesel::result::Error::DatabaseError; +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::{State, get, post}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::sync::broadcast; +use uuid::Uuid; +use vickylib::database::entities::task::FlakeRef; +use vickylib::database::entities::task_template::{ + TaskTemplateError, TaskTemplateLock, TaskTemplateVariable, +}; +use vickylib::database::entities::{Database, Task, TaskTemplate}; +use vickylib::errors::VickyError; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoTaskTemplateNew { + name: String, + display_name_template: String, + flake_ref: FlakeRef, + #[serde(default)] + locks: Vec, + #[serde(default)] + features: Vec, + group: Option, + #[serde(default)] + variables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoTaskTemplateInstantiate { + #[serde(default)] + needs_confirmation: bool, + #[serde(default)] + variables: HashMap, +} + +fn template_error_fail(err: TaskTemplateError) -> AppError { + log::warn!("invalid task template: {err}"); + AppError::HttpError(Status::BadRequest) +} + +#[get("/")] +pub async fn task_templates_get( + db: Database, + _auth: AnyAuthGuard, +) -> Result>, AppError> { + let templates = db.get_all_task_templates().await?; + Ok(Json(templates)) +} + +#[get("/")] +pub async fn task_templates_get_specific( + id: Uuid, + db: Database, + _auth: AnyAuthGuard, +) -> Result>, AppError> { + let template = db.get_task_template(id).await?; + Ok(Json(template)) +} + +#[post("/", format = "json", data = "")] +pub async fn task_templates_add( + task_template: Json, + db: Database, + _auth: AnyAuthGuard, +) -> Result, AppError> { + let task_template = task_template.into_inner(); + + let task_template = TaskTemplate { + id: Uuid::new_v4(), + name: task_template.name, + display_name_template: task_template.display_name_template, + flake_ref: task_template.flake_ref, + locks: task_template.locks, + features: task_template.features, + group: task_template.group, + variables: task_template.variables, + created_at: chrono::Utc::now(), + }; + + task_template.validate().map_err(template_error_fail)?; + + match db.put_task_template(task_template.clone()).await { + Ok(_) => Ok(Json(task_template)), + Err(VickyError::Diesel { + source: DatabaseError(DatabaseErrorKind::UniqueViolation, _), + }) => Err(AppError::HttpError(Status::Conflict)), + Err(err) => Err(err.into()), + } +} + +#[post("//instantiate", format = "json", data = "")] +pub async fn task_templates_instantiate( + id: Uuid, + request: Json, + db: Database, + global_events: &State>, + _auth: AnyAuthGuard, +) -> Result, AppError> { + let template = db + .get_task_template(id) + .await? + .ok_or(AppError::HttpError(Status::NotFound))?; + + let request = request.into_inner(); + + let task = template + .instantiate(request.variables, request.needs_confirmation) + .map_err(template_error_fail)?; + + db.put_task(task.clone()).await?; + global_events.send(GlobalEvent::TaskAdd)?; + + Ok(Json(task)) +} diff --git a/vicky/src/lib/database/entities/mod.rs b/vicky/src/lib/database/entities/mod.rs index ac975db..8c91c2b 100644 --- a/vicky/src/lib/database/entities/mod.rs +++ b/vicky/src/lib/database/entities/mod.rs @@ -1,11 +1,13 @@ pub mod lock; pub mod task; +pub mod task_template; pub mod user; use crate::database::entities::lock::PoisonedLock; use crate::database::entities::lock::db_impl::LockDatabase; use crate::database::entities::task::TaskStatus; use crate::database::entities::task::db_impl::TaskDatabase; +use crate::database::entities::task_template::db_impl::TaskTemplateDatabase; use crate::database::entities::user::User; use crate::database::entities::user::db_impl::UserDatabase; use crate::errors::VickyError; @@ -15,6 +17,7 @@ use delegate::delegate; pub use lock::{Lock, LockKind}; use rocket_sync_db_pools::{ConnectionPool, database}; pub use task::Task; +pub use task_template::TaskTemplate; use uuid::Uuid; #[database("postgres_db")] @@ -53,6 +56,15 @@ impl Database { pub async fn timeout_task(&self, task_id: Uuid) -> Result; } + #[await(false)] + #[expr(self.run(move |conn| $).await)] + #[through(TaskTemplateDatabase)] + to conn { + pub async fn get_all_task_templates(&self) -> Result, VickyError>; + pub async fn get_task_template(&self, task_template_id: Uuid) -> Result, VickyError>; + pub async fn put_task_template(&self, task_template: TaskTemplate) -> Result; + } + #[await(false)] #[expr(self.run(move |conn| $).await)] #[through(LockDatabase)] diff --git a/vicky/src/lib/database/entities/task_template.rs b/vicky/src/lib/database/entities/task_template.rs new file mode 100644 index 0000000..41182af --- /dev/null +++ b/vicky/src/lib/database/entities/task_template.rs @@ -0,0 +1,641 @@ +use crate::database::entities::lock::{Lock, LockKind}; +use crate::database::entities::task::{FlakeRef, Task, TaskStatus}; +use crate::database::entities::task_template::db_impl::{ + DbTaskTemplate, DbTaskTemplateLock, DbTaskTemplateVariable, +}; +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskTemplateVariable { + pub name: String, + pub default_value: Option, + pub description: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskTemplateLock { + pub name: String, + #[serde(rename = "type")] + pub kind: LockKind, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskTemplate { + pub id: Uuid, + pub name: String, + pub display_name_template: String, + pub flake_ref: FlakeRef, + pub locks: Vec, + pub features: Vec, + pub group: Option, + pub variables: Vec, + + #[serde(with = "ts_seconds")] + pub created_at: DateTime, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum TaskTemplateError { + #[error("template name must not be empty")] + EmptyName, + + #[error("template variable name must not be empty")] + EmptyVariableName, + + #[error("duplicate template variable: {0}")] + DuplicateVariable(String), + + #[error("unclosed variable symbol in template value")] + UnclosedVariableMarker, + + #[error("empty variable marker in template value")] + EmptyVariableMarker, + + #[error("template references undeclared variable: {0}")] + UndeclaredVariable(String), + + #[error("missing required variable value: {0}")] + MissingVariable(String), + + #[error("unknown variable provided: {0}")] + UnknownVariable(String), + + #[error("rendered template contains conflicting locks")] + ConflictingLocks, +} + +fn parse_template_tokens(template: &str) -> Result, TaskTemplateError> { + let mut rest = template; + let mut tokens = vec![]; + + while let Some(start) = rest.find("{{") { + let after_start = &rest[(start + 2)..]; + let Some(end_rel) = after_start.find("}}") else { + return Err(TaskTemplateError::UnclosedVariableMarker); + }; + + let token = after_start[..end_rel].trim(); + + if token.is_empty() { + return Err(TaskTemplateError::EmptyVariableMarker); + } + + tokens.push(token.to_string()); + rest = &after_start[(end_rel + 2)..]; + } + + Ok(tokens) +} + +fn ensure_declared_tokens( + template: &str, + declared_variables: &HashSet, +) -> Result<(), TaskTemplateError> { + for token in parse_template_tokens(template)? { + if !declared_variables.contains(&token) { + return Err(TaskTemplateError::UndeclaredVariable(token)); + } + } + + Ok(()) +} + +fn render_template( + template: &str, + declared_variables: &HashSet, + resolved_variables: &HashMap, +) -> Result { + let mut rendered = String::with_capacity(template.len()); + let mut rest = template; + + while let Some(start) = rest.find("{{") { + rendered.push_str(&rest[..start]); + + let after_start = &rest[(start + 2)..]; + let Some(end_rel) = after_start.find("}}") else { + return Err(TaskTemplateError::UnclosedVariableMarker); + }; + + let token = after_start[..end_rel].trim(); + + if token.is_empty() { + return Err(TaskTemplateError::EmptyVariableMarker); + } + + if !declared_variables.contains(token) { + return Err(TaskTemplateError::UndeclaredVariable(token.to_string())); + } + + let Some(value) = resolved_variables.get(token) else { + return Err(TaskTemplateError::MissingVariable(token.to_string())); + }; + + rendered.push_str(value); + rest = &after_start[(end_rel + 2)..]; + } + + rendered.push_str(rest); + + Ok(rendered) +} + +impl TaskTemplate { + pub fn validate(&self) -> Result<(), TaskTemplateError> { + if self.name.trim().is_empty() { + return Err(TaskTemplateError::EmptyName); + } + + let mut declared_variables = HashSet::new(); + + for variable in &self.variables { + let variable_name = variable.name.trim(); + + if variable_name.is_empty() { + return Err(TaskTemplateError::EmptyVariableName); + } + + if !declared_variables.insert(variable_name.to_string()) { + return Err(TaskTemplateError::DuplicateVariable( + variable_name.to_string(), + )); + } + } + + ensure_declared_tokens(&self.display_name_template, &declared_variables)?; + ensure_declared_tokens(&self.flake_ref.flake, &declared_variables)?; + + for flake_arg in &self.flake_ref.args { + ensure_declared_tokens(flake_arg, &declared_variables)?; + } + + for lock in &self.locks { + ensure_declared_tokens(&lock.name, &declared_variables)?; + } + + if let Some(group) = &self.group { + ensure_declared_tokens(group, &declared_variables)?; + } + + Ok(()) + } + + pub fn instantiate( + &self, + mut variables: HashMap, + needs_confirmation: bool, + ) -> Result { + self.validate()?; + + let declared_variables: HashSet = self + .variables + .iter() + .map(|variable| variable.name.clone()) + .collect(); + + for key in variables.keys() { + if !declared_variables.contains(key) { + return Err(TaskTemplateError::UnknownVariable(key.to_string())); + } + } + + let mut resolved_variables = HashMap::new(); + + for variable in &self.variables { + if let Some(value) = variables.remove(&variable.name) { + resolved_variables.insert(variable.name.clone(), value); + continue; + } + + if let Some(default_value) = &variable.default_value { + resolved_variables.insert(variable.name.clone(), default_value.clone()); + continue; + } + + return Err(TaskTemplateError::MissingVariable(variable.name.clone())); + } + + let display_name = render_template( + &self.display_name_template, + &declared_variables, + &resolved_variables, + )?; + let flake = render_template( + &self.flake_ref.flake, + &declared_variables, + &resolved_variables, + )?; + + let flake_args = self + .flake_ref + .args + .iter() + .map(|arg| render_template(arg, &declared_variables, &resolved_variables)) + .collect::, _>>()?; + + let locks = self + .locks + .iter() + .map(|lock| { + Ok(Lock { + name: render_template(&lock.name, &declared_variables, &resolved_variables)?, + kind: lock.kind, + poisoned_by: None, + }) + }) + .collect::, TaskTemplateError>>()?; + + let group = self + .group + .as_ref() + .map(|group| render_template(group, &declared_variables, &resolved_variables)) + .transpose()?; + + let status = if needs_confirmation { + TaskStatus::NeedsUserValidation + } else { + TaskStatus::New + }; + + let task = Task::builder() + .status(status) + .display_name(display_name) + .flake(flake) + .flake_args(flake_args) + .locks(locks) + .requires_features(self.features.clone()) + .maybe_group(group) + .build() + .map_err(|_| TaskTemplateError::ConflictingLocks)?; + + Ok(task) + } +} + +impl AsRef for TaskTemplate { + fn as_ref(&self) -> &TaskTemplate { + self + } +} + +impl From for TaskTemplateLock { + fn from(lock: DbTaskTemplateLock) -> Self { + Self { + name: lock.name_template, + kind: lock.lock_type, + } + } +} + +impl From for TaskTemplateVariable { + fn from(variable: DbTaskTemplateVariable) -> Self { + Self { + name: variable.name, + default_value: variable.default_value, + description: variable.description, + } + } +} + +impl + From<( + DbTaskTemplate, + Vec, + Vec, + )> for TaskTemplate +{ + fn from( + value: ( + DbTaskTemplate, + Vec, + Vec, + ), + ) -> Self { + let (template, locks, variables) = value; + + TaskTemplate { + id: template.id, + name: template.name, + display_name_template: template.display_name_template, + flake_ref: FlakeRef { + flake: template.flake_ref_uri_template, + args: template.flake_ref_args_template, + }, + locks: locks.into_iter().map(TaskTemplateLock::from).collect(), + features: template.features, + group: template.group, + variables: variables + .into_iter() + .map(TaskTemplateVariable::from) + .collect(), + created_at: template.created_at, + } + } +} + +pub mod db_impl { + use crate::database::entities::lock::LockKind; + use crate::database::entities::task_template::{ + TaskTemplate, TaskTemplateLock, TaskTemplateVariable, + }; + use crate::database::schema::{task_template_locks, task_template_variables, task_templates}; + use crate::errors::VickyError; + use chrono::{DateTime, Utc}; + use diesel::{ + AsChangeset, Connection, ExpressionMethods, Identifiable, Insertable, OptionalExtension, + PgConnection, QueryDsl, Queryable, RunQueryDsl, Selectable, + }; + use itertools::Itertools; + use std::collections::HashMap; + use uuid::Uuid; + + #[derive(Clone, Debug, Queryable, Selectable, Insertable, AsChangeset, Identifiable)] + #[diesel(table_name = task_templates)] + #[diesel(primary_key(id))] + pub struct DbTaskTemplate { + pub id: Uuid, + pub name: String, + pub display_name_template: String, + pub flake_ref_uri_template: String, + pub flake_ref_args_template: Vec, + pub features: Vec, + pub group: Option, + pub created_at: DateTime, + } + + #[derive(Clone, Debug, Queryable, Selectable, Identifiable)] + #[diesel(table_name = task_template_locks)] + #[diesel(primary_key(id))] + pub struct DbTaskTemplateLock { + pub id: Uuid, + pub task_template_id: Uuid, + pub name_template: String, + pub lock_type: LockKind, + } + + #[derive(Debug, Insertable)] + #[diesel(table_name = task_template_locks)] + pub struct NewDbTaskTemplateLock { + pub task_template_id: Uuid, + pub name_template: String, + pub lock_type: LockKind, + } + + #[derive(Clone, Debug, Queryable, Selectable, Identifiable)] + #[diesel(table_name = task_template_variables)] + #[diesel(primary_key(id))] + pub struct DbTaskTemplateVariable { + pub id: Uuid, + pub task_template_id: Uuid, + pub name: String, + pub default_value: Option, + pub description: Option, + } + + #[derive(Debug, Insertable)] + #[diesel(table_name = task_template_variables)] + pub struct NewDbTaskTemplateVariable { + pub task_template_id: Uuid, + pub name: String, + pub default_value: Option, + pub description: Option, + } + + impl From<&TaskTemplate> for DbTaskTemplate { + fn from(template: &TaskTemplate) -> Self { + Self { + id: template.id, + name: template.name.clone(), + display_name_template: template.display_name_template.clone(), + flake_ref_uri_template: template.flake_ref.flake.clone(), + flake_ref_args_template: template.flake_ref.args.clone(), + features: template.features.clone(), + group: template.group.clone(), + created_at: template.created_at, + } + } + } + + impl NewDbTaskTemplateLock { + pub fn from_template_lock(template_id: Uuid, lock: &TaskTemplateLock) -> Self { + Self { + task_template_id: template_id, + name_template: lock.name.clone(), + lock_type: lock.kind, + } + } + } + + impl NewDbTaskTemplateVariable { + pub fn from_template_variable(template_id: Uuid, variable: &TaskTemplateVariable) -> Self { + Self { + task_template_id: template_id, + name: variable.name.clone(), + default_value: variable.default_value.clone(), + description: variable.description.clone(), + } + } + } + + pub trait TaskTemplateDatabase { + fn get_all_task_templates(&mut self) -> Result, VickyError>; + fn get_task_template( + &mut self, + task_template_id: Uuid, + ) -> Result, VickyError>; + fn put_task_template(&mut self, task_template: TaskTemplate) -> Result; + } + + fn hydrate_templates( + db_templates: Vec, + db_locks: Vec, + db_variables: Vec, + ) -> Vec { + let mut locks_by_template: HashMap<_, Vec> = db_locks + .into_iter() + .map(|lock| (lock.task_template_id, lock)) + .into_group_map(); + + let mut variables_by_template: HashMap<_, Vec> = db_variables + .into_iter() + .map(|variable| (variable.task_template_id, variable)) + .into_group_map(); + + db_templates + .into_iter() + .map(|template| { + let locks = locks_by_template.remove(&template.id).unwrap_or_default(); + let variables = variables_by_template + .remove(&template.id) + .unwrap_or_default(); + (template, locks, variables).into() + }) + .collect() + } + + impl TaskTemplateDatabase for PgConnection { + fn get_all_task_templates(&mut self) -> Result, VickyError> { + let db_templates = task_templates::table + .order(task_templates::created_at.desc()) + .load::(self)?; + + if db_templates.is_empty() { + return Ok(vec![]); + } + + let template_ids: Vec = db_templates.iter().map(|template| template.id).collect(); + + let db_locks = task_template_locks::table + .filter(task_template_locks::task_template_id.eq_any(&template_ids)) + .load::(self)?; + + let db_variables = task_template_variables::table + .filter(task_template_variables::task_template_id.eq_any(&template_ids)) + .load::(self)?; + + Ok(hydrate_templates(db_templates, db_locks, db_variables)) + } + + fn get_task_template( + &mut self, + task_template_id: Uuid, + ) -> Result, VickyError> { + let db_template = task_templates::table + .filter(task_templates::id.eq(task_template_id)) + .first::(self) + .optional()?; + + let Some(db_template) = db_template else { + return Ok(None); + }; + + let db_locks = task_template_locks::table + .filter(task_template_locks::task_template_id.eq(task_template_id)) + .load::(self)?; + + let db_variables = task_template_variables::table + .filter(task_template_variables::task_template_id.eq(task_template_id)) + .load::(self)?; + + Ok(Some((db_template, db_locks, db_variables).into())) + } + + fn put_task_template(&mut self, task_template: TaskTemplate) -> Result { + self.transaction(|conn| { + let db_task_template = DbTaskTemplate::from(&task_template); + + let rows_updated = diesel::insert_into(task_templates::table) + .values(&db_task_template) + .execute(conn)?; + + let db_locks: Vec = task_template + .locks + .iter() + .map(|lock| NewDbTaskTemplateLock::from_template_lock(task_template.id, lock)) + .collect(); + + if !db_locks.is_empty() { + diesel::insert_into(task_template_locks::table) + .values(&db_locks) + .execute(conn)?; + } + + let db_variables: Vec = task_template + .variables + .iter() + .map(|variable| { + NewDbTaskTemplateVariable::from_template_variable( + task_template.id, + variable, + ) + }) + .collect(); + + if !db_variables.is_empty() { + diesel::insert_into(task_template_variables::table) + .values(&db_variables) + .execute(conn)?; + } + + Ok(rows_updated) + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::{TaskTemplate, TaskTemplateLock, TaskTemplateVariable}; + use crate::database::entities::task::TaskStatus; + use crate::database::entities::{LockKind, task}; + use chrono::Utc; + use std::collections::HashMap; + use uuid::Uuid; + + fn example_template() -> TaskTemplate { + TaskTemplate { + id: Uuid::new_v4(), + name: "build-something-template".to_string(), + display_name_template: "Build {{project}} in {{env}}".to_string(), + flake_ref: task::FlakeRef { + flake: "nixpkgs#{{project}}".to_string(), + args: vec!["--env={{env}}".to_string()], + }, + locks: vec![TaskTemplateLock { + name: "build/{{project}}".to_string(), + kind: LockKind::Write, + }], + features: vec!["ijustbuildthings".to_string()], + group: Some("{{env}}".to_string()), + variables: vec![ + TaskTemplateVariable { + name: "project".to_string(), + default_value: None, + description: None, + }, + TaskTemplateVariable { + name: "env".to_string(), + default_value: Some("production".to_string()), + description: None, + }, + ], + created_at: Utc::now(), + } + } + + #[test] + fn instantiate_uses_values_and_defaults() { + let template = example_template(); + + let task = template + .instantiate( + HashMap::from([("project".to_string(), "chromium".to_string())]), + false, + ) + .expect("template should instantiate"); + + assert_eq!(task.display_name, "Build chromium in production"); + assert_eq!(task.flake_ref.flake, "nixpkgs#chromium"); + assert_eq!(task.flake_ref.args, vec!["--env=production"]); + assert_eq!(task.group, Some("production".to_string())); + assert_eq!(task.status, TaskStatus::New); + } + + #[test] + fn instantiate_requires_missing_variables_without_default() { + let template = example_template(); + + let err = template + .instantiate(HashMap::new(), false) + .expect_err("instantiation should fail"); + + assert_eq!( + err, + super::TaskTemplateError::MissingVariable("project".to_string()) + ); + } +} diff --git a/vicky/src/lib/database/schema.rs b/vicky/src/lib/database/schema.rs index 7bf64b5..c156d75 100644 --- a/vicky/src/lib/database/schema.rs +++ b/vicky/src/lib/database/schema.rs @@ -14,6 +14,46 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use crate::database::entities::lock::db_impl::LockKindSqlType; + + task_template_locks (id) { + id -> Uuid, + task_template_id -> Uuid, + name_template -> Varchar, + #[sql_name = "type"] + lock_type -> LockKindSqlType, + } +} + +diesel::table! { + use diesel::sql_types::*; + + task_template_variables (id) { + id -> Uuid, + task_template_id -> Uuid, + name -> Varchar, + default_value -> Nullable, + description -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + + task_templates (id) { + id -> Uuid, + name -> Varchar, + display_name_template -> Varchar, + flake_ref_uri_template -> Varchar, + flake_ref_args_template -> Array, + features -> Array, + group -> Nullable, + created_at -> Timestamptz, + } +} + diesel::table! { use diesel::sql_types::*; use crate::database::entities::task::db_impl::TaskStatusSqlType; @@ -44,4 +84,11 @@ diesel::table! { } } -diesel::allow_tables_to_appear_in_same_query!(locks, tasks, users,); +diesel::allow_tables_to_appear_in_same_query!( + locks, + task_template_locks, + task_template_variables, + task_templates, + tasks, + users, +);