Compare commits

..

6 Commits

Author SHA1 Message Date
Maxwell Becker
d05c81864e 1.16.3 (#150)
* refactor listener api implementation for Gitlab integration

* version 1.16.3

* builder delete id link cleanup

* refactor and add "__ALL__" branch to avoid branch filtering

* frontend config the webhook url

* action webhook config

* clean up webhook url copy

* add __ALL__ branch switch for Actions / Procedures
2024-10-24 16:03:00 -07:00
mbecker20
f1a09f34ab tweak dev docs and runfile 2024-10-22 17:04:49 -04:00
mbecker20
23c6e6306d fix usage of runnables-cli in dev docs 2024-10-22 16:36:25 -04:00
mbecker20
800da90561 tweaks to dev docs 2024-10-22 15:25:33 -04:00
mbecker20
b24bf6ed89 fix docsite broken links 2024-10-22 15:17:10 -04:00
Matt Foxx
d66a781a13 docs: Add development docs (#136)
* docs: Add development POC

* docs: Flesh out full build/run steps

* feat: Add mergeable compose file to expose port and internal periphery url

* feat: Add .devcontainer and VSCode Tasks for developing Komodo

* Make cargo cache persistent in devcontainer

* Add deno to devcontainer

* Update tasks to include TS client copy to frontend before run

* Recommend extensions for used dependencies in vscode workspace

All extensions recommended are included in the devcontainer. This makes it easier for users not using devcontainer to get lang support.

* Update local `run` sequence for development docs
2024-10-22 12:09:26 -07:00
51 changed files with 1792 additions and 1005 deletions

View File

@@ -0,0 +1,33 @@
services:
dev:
image: mcr.microsoft.com/devcontainers/rust:1-1-bullseye
volumes:
# Mount the root folder that contains .git
- ../:/workspace:cached
- /var/run/docker.sock:/var/run/docker.sock
- /proc:/proc
- repos:/etc/komodo/repos
- stacks:/etc/komodo/stacks
command: sleep infinity
ports:
- "9121:9121"
environment:
KOMODO_FIRST_SERVER: http://localhost:8120
KOMODO_DATABASE_ADDRESS: db
KOMODO_ENABLE_NEW_USERS: true
KOMODO_LOCAL_AUTH: true
KOMODO_JWT_SECRET: a_random_secret
links:
- db
# ...
db:
extends:
file: ../test.compose.yaml
service: ferretdb
volumes:
data:
repo-cache:
repos:
stacks:

View File

@@ -0,0 +1,46 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
{
"name": "Komodo",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
//"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
"dockerComposeFile": ["dev.compose.yaml"],
"workspaceFolder": "/workspace",
"service": "dev",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "18.18.0"
},
"ghcr.io/devcontainers-community/features/deno:1": {
}
},
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
"mounts": [
{
"source": "devcontainer-cargo-cache-${devcontainerId}",
"target": "/usr/local/cargo",
"type": "volume"
}
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
9121
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "./.devcontainer/postCreate.sh",
"runServices": [
"db"
]
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

3
.devcontainer/postCreate.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
cargo install typeshare-cli

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
"denoland.vscode-deno"
]
}

179
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,179 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Core",
"command": "cargo",
"args": [
"run",
"-p",
"komodo_core",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.core.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build Core",
"command": "cargo",
"args": [
"build",
"-p",
"komodo_core",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.core.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Run Periphery",
"command": "cargo",
"args": [
"run",
"-p",
"komodo_periphery",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.periphery.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build Periphery",
"command": "cargo",
"args": [
"build",
"-p",
"komodo_periphery",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.periphery.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Run Backend",
"dependsOn": [
"Run Core",
"Run Periphery"
],
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build TS Client Types",
"type": "process",
"command": "node",
"args": [
"./client/core/ts/generate_types.mjs"
],
"problemMatcher": []
},
{
"label": "Init TS Client",
"type": "shell",
"command": "yarn && yarn build && yarn link",
"options": {
"cwd": "${workspaceFolder}/client/core/ts",
},
"problemMatcher": []
},
{
"label": "Init Frontend Client",
"type": "shell",
"command": "yarn link komodo_client && yarn install",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"problemMatcher": []
},
{
"label": "Init Frontend",
"dependsOn": [
"Build TS Client Types",
"Init TS Client",
"Init Frontend Client"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Build Frontend",
"type": "shell",
"command": "yarn build",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"problemMatcher": []
},
{
"label": "Prepare Frontend For Run",
"type": "shell",
"command": "cp -r ./client/core/ts/dist/. frontend/public/client/.",
"options": {
"cwd": "${workspaceFolder}",
},
"dependsOn": [
"Build TS Client Types",
"Build Frontend"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Run Frontend",
"type": "shell",
"command": "yarn dev",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"dependsOn": ["Prepare Frontend For Run"],
"problemMatcher": []
},
{
"label": "Init",
"dependsOn": [
"Build Backend",
"Init Frontend"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Run Komodo",
"dependsOn": [
"Run Core",
"Run Periphery",
"Run Frontend"
],
"problemMatcher": []
},
]
}

24
Cargo.lock generated
View File

@@ -41,7 +41,7 @@ dependencies = [
[[package]]
name = "alerter"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"axum",
@@ -943,7 +943,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"komodo_client",
"run_command",
@@ -1355,7 +1355,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"thiserror",
]
@@ -1439,7 +1439,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"serror",
]
@@ -1571,7 +1571,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "git"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"command",
@@ -2191,7 +2191,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"clap",
@@ -2207,7 +2207,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2238,7 +2238,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2383,7 +2383,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -3089,7 +3089,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -4863,7 +4863,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.16.2"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "1.16.2"
version = "1.16.3"
edition = "2021"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"

View File

@@ -1,4 +1,4 @@
use std::{str::FromStr, time::Duration};
use std::str::FromStr;
use anyhow::{anyhow, Context};
use futures::future::join_all;
@@ -54,10 +54,6 @@ pub fn empty_or_only_spaces(word: &str) -> bool {
true
}
pub fn random_duration(min_ms: u64, max_ms: u64) -> Duration {
Duration::from_millis(thread_rng().gen_range(min_ms..max_ms))
}
pub fn random_string(length: usize) -> String {
thread_rng()
.sample_iter(&Alphanumeric)

View File

@@ -1,66 +0,0 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use komodo_client::{
api::execute::RunBuild,
entities::{build::Build, user::git_webhook_user},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCode;
use crate::{
api::execute::ExecuteRequest,
helpers::update::init_execution_update, resource, state::State,
};
use super::{extract_branch, verify_gh_signature, ListenerLockCache};
fn build_locks() -> &'static ListenerLockCache {
static BUILD_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
BUILD_LOCKS.get_or_init(Default::default)
}
pub async fn auth_build_webhook(
build_id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<Build> {
let build = resource::get::<Build>(build_id)
.await
.status_code(StatusCode::NOT_FOUND)?;
verify_gh_signature(headers, body, &build.config.webhook_secret)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(build)
}
pub async fn handle_build_webhook(
build: Build,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = build_locks().get_or_insert_default(&build.id).await;
let _lock = lock.lock().await;
if !build.config.webhook_enabled {
return Err(anyhow!("build does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != build.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunBuild(RunBuild { build: build.id });
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunBuild(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -1,278 +0,0 @@
use std::sync::Arc;
use anyhow::{anyhow, Context};
use axum::{extract::Path, http::HeaderMap, routing::post, Router};
use hex::ToHex;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use tokio::sync::Mutex;
use tracing::Instrument;
use crate::{
config::core_config,
helpers::{cache::Cache, random_duration},
};
mod build;
mod procedure;
mod repo;
mod stack;
mod sync;
type HmacSha256 = Hmac<Sha256>;
#[derive(Deserialize)]
struct Id {
id: String,
}
#[derive(Deserialize)]
struct IdBranch {
id: String,
branch: Option<String>,
}
pub fn router() -> Router {
Router::new()
.route(
"/build/:id",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let build = build::auth_build_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("build_webhook", id);
async {
let res = build::handle_build_webhook(build, body).await;
if let Err(e) = res {
warn!("failed to run build webook for build {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/repo/:id/clone",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let repo = repo::auth_repo_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("repo_clone_webhook", id);
async {
let res = repo::handle_repo_clone_webhook(repo, body).await;
if let Err(e) = res {
warn!("failed to run repo clone webook for repo {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/repo/:id/pull",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let repo = repo::auth_repo_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("repo_pull_webhook", id);
async {
let res = repo::handle_repo_pull_webhook(repo, body).await;
if let Err(e) = res {
warn!("failed to run repo pull webook for repo {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/repo/:id/build",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let repo = repo::auth_repo_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("repo_build_webhook", id);
async {
let res = repo::handle_repo_build_webhook(repo, body).await;
if let Err(e) = res {
warn!("failed to run repo build webook for repo {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/stack/:id/refresh",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let stack = stack::auth_stack_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("stack_clone_webhook", id);
async {
let res = stack::handle_stack_refresh_webhook(stack, body).await;
if let Err(e) = res {
warn!("failed to run stack clone webook for stack {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/stack/:id/deploy",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let stack = stack::auth_stack_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("stack_pull_webhook", id);
async {
let res = stack::handle_stack_deploy_webhook(stack, body).await;
if let Err(e) = res {
warn!("failed to run stack pull webook for stack {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/procedure/:id/:branch",
post(
|Path(IdBranch { id, branch }), headers: HeaderMap, body: String| async move {
let procedure = procedure::auth_procedure_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("procedure_webhook", id, branch);
async {
let res = procedure::handle_procedure_webhook(
procedure,
branch.unwrap_or_else(|| String::from("main")),
body
).await;
if let Err(e) = res {
warn!("failed to run procedure webook for procedure {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/sync/:id/refresh",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let sync = sync::auth_sync_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("sync_refresh_webhook", id);
async {
let res = sync::handle_sync_refresh_webhook(
sync,
body
).await;
if let Err(e) = res {
warn!("failed to run sync webook for sync {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
.route(
"/sync/:id/sync",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let sync = sync::auth_sync_webhook(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("sync_execute_webhook", id);
async {
let res = sync::handle_sync_execute_webhook(
sync,
body
).await;
if let Err(e) = res {
warn!("failed to run sync webook for sync {id} | {e:#}");
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
)
)
}
#[instrument(skip_all)]
async fn verify_gh_signature(
headers: HeaderMap,
body: &str,
custom_secret: &str,
) -> anyhow::Result<()> {
// wait random amount of time
tokio::time::sleep(random_duration(0, 500)).await;
let signature = headers.get("x-hub-signature-256");
if signature.is_none() {
return Err(anyhow!("no signature in headers"));
}
let signature = signature.unwrap().to_str();
if signature.is_err() {
return Err(anyhow!("failed to unwrap signature"));
}
let signature = signature.unwrap().replace("sha256=", "");
let secret_bytes = if custom_secret.is_empty() {
core_config().webhook_secret.as_bytes()
} else {
custom_secret.as_bytes()
};
let mut mac = HmacSha256::new_from_slice(secret_bytes)
.expect("github webhook | failed to create hmac sha256");
mac.update(body.as_bytes());
let expected = mac.finalize().into_bytes().encode_hex::<String>();
if signature == expected {
Ok(())
} else {
Err(anyhow!("signature does not equal expected"))
}
}
#[derive(Deserialize)]
struct GithubWebhookBody {
#[serde(rename = "ref")]
branch: String,
}
fn extract_branch(body: &str) -> anyhow::Result<String> {
let branch = serde_json::from_str::<GithubWebhookBody>(body)
.context("failed to parse github request body")?
.branch
.replace("refs/heads/", "");
Ok(branch)
}
type ListenerLockCache = Cache<String, Arc<Mutex<()>>>;

View File

@@ -1,74 +0,0 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use komodo_client::{
api::execute::RunProcedure,
entities::{procedure::Procedure, user::git_webhook_user},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCode;
use crate::{
api::execute::ExecuteRequest,
helpers::update::init_execution_update, resource, state::State,
};
use super::{extract_branch, verify_gh_signature, ListenerLockCache};
fn procedure_locks() -> &'static ListenerLockCache {
static BUILD_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
BUILD_LOCKS.get_or_init(Default::default)
}
pub async fn auth_procedure_webhook(
procedure_id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<Procedure> {
let procedure = resource::get::<Procedure>(procedure_id)
.await
.status_code(StatusCode::NOT_FOUND)?;
verify_gh_signature(
headers,
body,
&procedure.config.webhook_secret,
)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(procedure)
}
pub async fn handle_procedure_webhook(
procedure: Procedure,
target_branch: String,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock =
procedure_locks().get_or_insert_default(&procedure.id).await;
let _lock = lock.lock().await;
if !procedure.config.webhook_enabled {
return Err(anyhow!("procedure does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != target_branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunProcedure(RunProcedure {
procedure: procedure.id,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunProcedure(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -1,133 +0,0 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use komodo_client::{
api::execute::{BuildRepo, CloneRepo, PullRepo},
entities::{repo::Repo, user::git_webhook_user},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCode;
use crate::{
helpers::update::init_execution_update, resource, state::State,
};
use super::{extract_branch, verify_gh_signature, ListenerLockCache};
fn repo_locks() -> &'static ListenerLockCache {
static REPO_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
REPO_LOCKS.get_or_init(Default::default)
}
pub async fn auth_repo_webhook(
repo_id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<Repo> {
let repo = resource::get::<Repo>(repo_id)
.await
.status_code(StatusCode::NOT_FOUND)?;
verify_gh_signature(headers, body, &repo.config.webhook_secret)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(repo)
}
pub async fn handle_repo_clone_webhook(
repo: Repo,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = repo_locks().get_or_insert_default(&repo.id).await;
let _lock = lock.lock().await;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != repo.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req =
crate::api::execute::ExecuteRequest::CloneRepo(CloneRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::CloneRepo(req) = req
else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
pub async fn handle_repo_pull_webhook(
repo: Repo,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = repo_locks().get_or_insert_default(&repo.id).await;
let _lock = lock.lock().await;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != repo.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req = crate::api::execute::ExecuteRequest::PullRepo(PullRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::PullRepo(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
pub async fn handle_repo_build_webhook(
repo: Repo,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = repo_locks().get_or_insert_default(&repo.id).await;
let _lock = lock.lock().await;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != repo.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req =
crate::api::execute::ExecuteRequest::BuildRepo(BuildRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::BuildRepo(req) = req
else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -1,112 +0,0 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use komodo_client::{
api::{
execute::{DeployStack, DeployStackIfChanged},
write::RefreshStackCache,
},
entities::{stack::Stack, user::git_webhook_user},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCode;
use crate::{
api::execute::ExecuteRequest,
helpers::update::init_execution_update, resource, state::State,
};
use super::{extract_branch, verify_gh_signature, ListenerLockCache};
fn stack_locks() -> &'static ListenerLockCache {
static STACK_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
STACK_LOCKS.get_or_init(Default::default)
}
pub async fn auth_stack_webhook(
stack_id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<Stack> {
let stack = resource::get::<Stack>(stack_id)
.await
.status_code(StatusCode::NOT_FOUND)?;
verify_gh_signature(headers, body, &stack.config.webhook_secret)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(stack)
}
pub async fn handle_stack_refresh_webhook(
stack: Stack,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through, from "action state busy".
let lock = stack_locks().get_or_insert_default(&stack.id).await;
let _lock = lock.lock().await;
if !stack.config.webhook_enabled {
return Err(anyhow!("stack does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != stack.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
State
.resolve(RefreshStackCache { stack: stack.id }, user)
.await?;
Ok(())
}
pub async fn handle_stack_deploy_webhook(
stack: Stack,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = stack_locks().get_or_insert_default(&stack.id).await;
let _lock = lock.lock().await;
if !stack.config.webhook_enabled {
return Err(anyhow!("stack does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != stack.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
if stack.config.webhook_force_deploy {
let req = ExecuteRequest::DeployStack(DeployStack {
stack: stack.id,
stop_time: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::DeployStack(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
} else {
let req =
ExecuteRequest::DeployStackIfChanged(DeployStackIfChanged {
stack: stack.id,
stop_time: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::DeployStackIfChanged(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
}
Ok(())
}

View File

@@ -1,96 +0,0 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use komodo_client::{
api::{execute::RunSync, write::RefreshResourceSyncPending},
entities::{sync::ResourceSync, user::git_webhook_user},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCode;
use crate::{
api::execute::ExecuteRequest,
helpers::update::init_execution_update, resource, state::State,
};
use super::{extract_branch, verify_gh_signature, ListenerLockCache};
fn sync_locks() -> &'static ListenerLockCache {
static SYNC_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
SYNC_LOCKS.get_or_init(Default::default)
}
pub async fn auth_sync_webhook(
sync_id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<ResourceSync> {
let sync = resource::get::<ResourceSync>(sync_id)
.await
.status_code(StatusCode::NOT_FOUND)?;
verify_gh_signature(headers, body, &sync.config.webhook_secret)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
Ok(sync)
}
pub async fn handle_sync_refresh_webhook(
sync: ResourceSync,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = sync_locks().get_or_insert_default(&sync.id).await;
let _lock = lock.lock().await;
if !sync.config.webhook_enabled {
return Err(anyhow!("sync does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != sync.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
State
.resolve(RefreshResourceSyncPending { sync: sync.id }, user)
.await?;
Ok(())
}
pub async fn handle_sync_execute_webhook(
sync: ResourceSync,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = sync_locks().get_or_insert_default(&sync.id).await;
let _lock = lock.lock().await;
if !sync.config.webhook_enabled {
return Err(anyhow!("sync does not have webhook enabled"));
}
let request_branch = extract_branch(&body)?;
if request_branch != sync.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunSync(RunSync {
sync: sync.id,
resource_type: None,
resources: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunSync(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -0,0 +1,71 @@
use anyhow::{anyhow, Context};
use axum::http::HeaderMap;
use hex::ToHex;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use crate::{
config::core_config,
listener::{VerifyBranch, VerifySecret},
};
type HmacSha256 = Hmac<Sha256>;
/// Listener implementation for Github type API, including Gitea
pub struct Github;
impl VerifySecret for Github {
#[instrument("VerifyGithubSecret", skip_all)]
fn verify_secret(
headers: HeaderMap,
body: &str,
custom_secret: &str,
) -> anyhow::Result<()> {
let signature = headers
.get("x-hub-signature-256")
.context("No github signature in headers")?;
let signature = signature
.to_str()
.context("Failed to get signature as string")?;
let signature =
signature.strip_prefix("sha256=").unwrap_or(signature);
let secret_bytes = if custom_secret.is_empty() {
core_config().webhook_secret.as_bytes()
} else {
custom_secret.as_bytes()
};
let mut mac = HmacSha256::new_from_slice(secret_bytes)
.context("Failed to create hmac sha256 from secret")?;
mac.update(body.as_bytes());
let expected = mac.finalize().into_bytes().encode_hex::<String>();
if signature == expected {
Ok(())
} else {
Err(anyhow!("Signature does not equal expected"))
}
}
}
#[derive(Deserialize)]
struct GithubWebhookBody {
#[serde(rename = "ref")]
branch: String,
}
impl VerifyBranch for Github {
fn verify_branch(
body: &str,
expected_branch: &str,
) -> anyhow::Result<()> {
let branch = serde_json::from_str::<GithubWebhookBody>(body)
.context("Failed to parse github request body")?
.branch
.replace("refs/heads/", "");
if branch == expected_branch {
Ok(())
} else {
Err(anyhow!("request branch does not match expected"))
}
}
}

View File

@@ -0,0 +1,58 @@
use anyhow::{anyhow, Context};
use serde::Deserialize;
use crate::{
config::core_config,
listener::{VerifyBranch, VerifySecret},
};
/// Listener implementation for Gitlab type API
pub struct Gitlab;
impl VerifySecret for Gitlab {
#[instrument("VerifyGitlabSecret", skip_all)]
fn verify_secret(
headers: axum::http::HeaderMap,
_body: &str,
custom_secret: &str,
) -> anyhow::Result<()> {
let token = headers
.get("x-gitlab-token")
.context("No gitlab token in headers")?;
let token =
token.to_str().context("Failed to get token as string")?;
let secret = if custom_secret.is_empty() {
core_config().webhook_secret.as_str()
} else {
custom_secret
};
if token == secret {
Ok(())
} else {
Err(anyhow!("Webhook secret does not match expected."))
}
}
}
#[derive(Deserialize)]
struct GitlabWebhookBody {
#[serde(rename = "ref")]
branch: String,
}
impl VerifyBranch for Gitlab {
fn verify_branch(
body: &str,
expected_branch: &str,
) -> anyhow::Result<()> {
let branch = serde_json::from_str::<GitlabWebhookBody>(body)
.context("Failed to parse gitlab request body")?
.branch
.replace("refs/heads/", "");
if branch == expected_branch {
Ok(())
} else {
Err(anyhow!("request branch does not match expected"))
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod github;
pub mod gitlab;

View File

@@ -1,7 +1,52 @@
use axum::Router;
use std::sync::Arc;
mod github;
use axum::{http::HeaderMap, Router};
use komodo_client::entities::resource::Resource;
use tokio::sync::Mutex;
use crate::{helpers::cache::Cache, resource::KomodoResource};
mod integrations;
mod resources;
mod router;
use integrations::*;
pub fn router() -> Router {
Router::new().nest("/github", github::router())
Router::new()
.nest("/github", router::router::<github::Github>())
.nest("/gitlab", router::router::<gitlab::Gitlab>())
}
type ListenerLockCache = Cache<String, Arc<Mutex<()>>>;
/// Implemented for all resources which can recieve webhook.
trait CustomSecret: KomodoResource {
fn custom_secret(
resource: &Resource<Self::Config, Self::Info>,
) -> &str;
}
/// Implemented on the integration struct, eg [integrations::github::Github]
trait VerifySecret {
fn verify_secret(
headers: HeaderMap,
body: &str,
custom_secret: &str,
) -> anyhow::Result<()>;
}
/// Implemented on the integration struct, eg [integrations::github::Github]
trait VerifyBranch {
/// Returns Err if the branch extracted from request
/// body does not match the expected branch.
fn verify_branch(
body: &str,
expected_branch: &str,
) -> anyhow::Result<()>;
}
/// For Procedures and Actions, incoming webhook
/// can be triggered by any branch by using `__ANY__`
/// as the branch in the webhook URL.
const ANY_BRANCH: &str = "__ANY__";

View File

@@ -0,0 +1,486 @@
use std::sync::OnceLock;
use anyhow::anyhow;
use komodo_client::{
api::{
execute::*,
write::{RefreshResourceSyncPending, RefreshStackCache},
},
entities::{
action::Action, build::Build, procedure::Procedure, repo::Repo,
stack::Stack, sync::ResourceSync, user::git_webhook_user,
},
};
use resolver_api::Resolve;
use serde::Deserialize;
use crate::{
api::execute::ExecuteRequest,
helpers::update::init_execution_update, state::State,
};
use super::{ListenerLockCache, ANY_BRANCH};
// =======
// BUILD
// =======
impl super::CustomSecret for Build {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn build_locks() -> &'static ListenerLockCache {
static BUILD_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
BUILD_LOCKS.get_or_init(Default::default)
}
pub async fn handle_build_webhook<B: super::VerifyBranch>(
build: Build,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = build_locks().get_or_insert_default(&build.id).await;
let _lock = lock.lock().await;
if !build.config.webhook_enabled {
return Err(anyhow!("build does not have webhook enabled"));
}
B::verify_branch(&body, &build.config.branch)?;
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunBuild(RunBuild { build: build.id });
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunBuild(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
// ======
// REPO
// ======
impl super::CustomSecret for Repo {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn repo_locks() -> &'static ListenerLockCache {
static REPO_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
REPO_LOCKS.get_or_init(Default::default)
}
pub trait RepoExecution {
async fn resolve(repo: Repo) -> anyhow::Result<()>;
}
impl RepoExecution for CloneRepo {
async fn resolve(repo: Repo) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
let req =
crate::api::execute::ExecuteRequest::CloneRepo(CloneRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::CloneRepo(req) = req
else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
}
impl RepoExecution for PullRepo {
async fn resolve(repo: Repo) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
let req =
crate::api::execute::ExecuteRequest::PullRepo(PullRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::PullRepo(req) = req
else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
}
impl RepoExecution for BuildRepo {
async fn resolve(repo: Repo) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
let req =
crate::api::execute::ExecuteRequest::BuildRepo(BuildRepo {
repo: repo.id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::BuildRepo(req) = req
else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
}
#[derive(Deserialize)]
pub struct RepoWebhookPath {
pub option: RepoWebhookOption,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepoWebhookOption {
Clone,
Pull,
Build,
}
pub async fn handle_repo_webhook<B: super::VerifyBranch>(
option: RepoWebhookOption,
repo: Repo,
body: String,
) -> anyhow::Result<()> {
match option {
RepoWebhookOption::Clone => {
handle_repo_webhook_inner::<B, CloneRepo>(repo, body).await
}
RepoWebhookOption::Pull => {
handle_repo_webhook_inner::<B, PullRepo>(repo, body).await
}
RepoWebhookOption::Build => {
handle_repo_webhook_inner::<B, BuildRepo>(repo, body).await
}
}
}
async fn handle_repo_webhook_inner<
B: super::VerifyBranch,
E: RepoExecution,
>(
repo: Repo,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = repo_locks().get_or_insert_default(&repo.id).await;
let _lock = lock.lock().await;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
B::verify_branch(&body, &repo.config.branch)?;
E::resolve(repo).await
}
// =======
// STACK
// =======
impl super::CustomSecret for Stack {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn stack_locks() -> &'static ListenerLockCache {
static STACK_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
STACK_LOCKS.get_or_init(Default::default)
}
pub trait StackExecution {
async fn resolve(stack: Stack) -> anyhow::Result<()>;
}
impl StackExecution for RefreshStackCache {
async fn resolve(stack: Stack) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
State
.resolve(RefreshStackCache { stack: stack.id }, user)
.await?;
Ok(())
}
}
impl StackExecution for DeployStack {
async fn resolve(stack: Stack) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
if stack.config.webhook_force_deploy {
let req = ExecuteRequest::DeployStack(DeployStack {
stack: stack.id,
stop_time: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::DeployStack(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
} else {
let req =
ExecuteRequest::DeployStackIfChanged(DeployStackIfChanged {
stack: stack.id,
stop_time: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::DeployStackIfChanged(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
}
Ok(())
}
}
#[derive(Deserialize)]
pub struct StackWebhookPath {
pub option: StackWebhookOption,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StackWebhookOption {
Refresh,
Deploy,
}
pub async fn handle_stack_webhook<B: super::VerifyBranch>(
option: StackWebhookOption,
stack: Stack,
body: String,
) -> anyhow::Result<()> {
match option {
StackWebhookOption::Refresh => {
handle_stack_webhook_inner::<B, RefreshStackCache>(stack, body)
.await
}
StackWebhookOption::Deploy => {
handle_stack_webhook_inner::<B, DeployStack>(stack, body).await
}
}
}
pub async fn handle_stack_webhook_inner<
B: super::VerifyBranch,
E: StackExecution,
>(
stack: Stack,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through, from "action state busy".
let lock = stack_locks().get_or_insert_default(&stack.id).await;
let _lock = lock.lock().await;
if !stack.config.webhook_enabled {
return Err(anyhow!("stack does not have webhook enabled"));
}
B::verify_branch(&body, &stack.config.branch)?;
E::resolve(stack).await
}
// ======
// SYNC
// ======
impl super::CustomSecret for ResourceSync {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn sync_locks() -> &'static ListenerLockCache {
static SYNC_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
SYNC_LOCKS.get_or_init(Default::default)
}
pub trait SyncExecution {
async fn resolve(sync: ResourceSync) -> anyhow::Result<()>;
}
impl SyncExecution for RefreshResourceSyncPending {
async fn resolve(sync: ResourceSync) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
State
.resolve(RefreshResourceSyncPending { sync: sync.id }, user)
.await?;
Ok(())
}
}
impl SyncExecution for RunSync {
async fn resolve(sync: ResourceSync) -> anyhow::Result<()> {
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunSync(RunSync {
sync: sync.id,
resource_type: None,
resources: None,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunSync(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
}
#[derive(Deserialize)]
pub struct SyncWebhookPath {
pub option: SyncWebhookOption,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SyncWebhookOption {
Refresh,
Sync,
}
pub async fn handle_sync_webhook<B: super::VerifyBranch>(
option: SyncWebhookOption,
sync: ResourceSync,
body: String,
) -> anyhow::Result<()> {
match option {
SyncWebhookOption::Refresh => {
handle_sync_webhook_inner::<B, RefreshResourceSyncPending>(
sync, body,
)
.await
}
SyncWebhookOption::Sync => {
handle_sync_webhook_inner::<B, RunSync>(sync, body).await
}
}
}
async fn handle_sync_webhook_inner<
B: super::VerifyBranch,
E: SyncExecution,
>(
sync: ResourceSync,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = sync_locks().get_or_insert_default(&sync.id).await;
let _lock = lock.lock().await;
if !sync.config.webhook_enabled {
return Err(anyhow!("sync does not have webhook enabled"));
}
B::verify_branch(&body, &sync.config.branch)?;
E::resolve(sync).await
}
// ===========
// PROCEDURE
// ===========
impl super::CustomSecret for Procedure {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn procedure_locks() -> &'static ListenerLockCache {
static PROCEDURE_LOCKS: OnceLock<ListenerLockCache> =
OnceLock::new();
PROCEDURE_LOCKS.get_or_init(Default::default)
}
pub async fn handle_procedure_webhook<B: super::VerifyBranch>(
procedure: Procedure,
target_branch: String,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock =
procedure_locks().get_or_insert_default(&procedure.id).await;
let _lock = lock.lock().await;
if !procedure.config.webhook_enabled {
return Err(anyhow!("procedure does not have webhook enabled"));
}
if target_branch != ANY_BRANCH {
B::verify_branch(&body, &target_branch)?;
}
let user = git_webhook_user().to_owned();
let req = ExecuteRequest::RunProcedure(RunProcedure {
procedure: procedure.id,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunProcedure(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}
// ========
// ACTION
// ========
impl super::CustomSecret for Action {
fn custom_secret(resource: &Self) -> &str {
&resource.config.webhook_secret
}
}
fn action_locks() -> &'static ListenerLockCache {
static ACTION_LOCKS: OnceLock<ListenerLockCache> = OnceLock::new();
ACTION_LOCKS.get_or_init(Default::default)
}
pub async fn handle_action_webhook<B: super::VerifyBranch>(
action: Action,
target_branch: String,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = action_locks().get_or_insert_default(&action.id).await;
let _lock = lock.lock().await;
if !action.config.webhook_enabled {
return Err(anyhow!("action does not have webhook enabled"));
}
if target_branch != ANY_BRANCH {
B::verify_branch(&body, &target_branch)?;
}
let user = git_webhook_user().to_owned();
let req =
ExecuteRequest::RunAction(RunAction { action: action.id });
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RunAction(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -0,0 +1,208 @@
use axum::{extract::Path, http::HeaderMap, routing::post, Router};
use komodo_client::entities::{
action::Action, build::Build, procedure::Procedure, repo::Repo,
resource::Resource, stack::Stack, sync::ResourceSync,
};
use serde::Deserialize;
use tracing::Instrument;
use crate::resource::KomodoResource;
use super::{
resources::{
handle_action_webhook, handle_build_webhook,
handle_procedure_webhook, handle_repo_webhook,
handle_stack_webhook, handle_sync_webhook, RepoWebhookPath,
StackWebhookPath, SyncWebhookPath,
},
CustomSecret, VerifyBranch, VerifySecret,
};
#[derive(Deserialize)]
struct Id {
id: String,
}
#[derive(Deserialize)]
struct Branch {
#[serde(default = "default_branch")]
branch: String,
}
fn default_branch() -> String {
String::from("main")
}
pub fn router<P: VerifySecret + VerifyBranch>() -> Router {
Router::new()
.route(
"/build/:id",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
let build =
auth_webhook::<P, Build>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("BuildWebhook", id);
async {
let res = handle_build_webhook::<P>(
build, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for build {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/repo/:id/:option",
post(
|Path(Id { id }), Path(RepoWebhookPath { option }), headers: HeaderMap, body: String| async move {
let repo =
auth_webhook::<P, Repo>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("RepoWebhook", id);
async {
let res = handle_repo_webhook::<P>(
option, repo, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for repo {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/stack/:id/:option",
post(
|Path(Id { id }), Path(StackWebhookPath { option }), headers: HeaderMap, body: String| async move {
let stack =
auth_webhook::<P, Stack>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("StackWebhook", id);
async {
let res = handle_stack_webhook::<P>(
option, stack, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for stack {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/sync/:id/:option",
post(
|Path(Id { id }), Path(SyncWebhookPath { option }), headers: HeaderMap, body: String| async move {
let sync =
auth_webhook::<P, ResourceSync>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("ResourceSyncWebhook", id);
async {
let res = handle_sync_webhook::<P>(
option, sync, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for resource sync {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/procedure/:id/:branch",
post(
|Path(Id { id }), Path(Branch { branch }), headers: HeaderMap, body: String| async move {
let procedure =
auth_webhook::<P, Procedure>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("ProcedureWebhook", id);
async {
let res = handle_procedure_webhook::<P>(
procedure, branch, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for procedure {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
.route(
"/action/:id/:branch",
post(
|Path(Id { id }), Path(Branch { branch }), headers: HeaderMap, body: String| async move {
let action =
auth_webhook::<P, Action>(&id, headers, &body).await?;
tokio::spawn(async move {
let span = info_span!("ActionWebhook", id);
async {
let res = handle_action_webhook::<P>(
action, branch, body,
)
.await;
if let Err(e) = res {
warn!(
"Failed at running webhook for action {id} | {e:#}"
);
}
}
.instrument(span)
.await
});
serror::Result::Ok(())
},
),
)
}
async fn auth_webhook<P, R>(
id: &str,
headers: HeaderMap,
body: &str,
) -> serror::Result<Resource<R::Config, R::Info>>
where
P: VerifySecret,
R: KomodoResource + CustomSecret,
{
let resource = crate::resource::get::<R>(id).await?;
P::verify_secret(headers, body, R::custom_secret(&resource))?;
Ok(resource)
}

View File

@@ -134,17 +134,22 @@ impl super::KomodoResource for Builder {
resource: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
// remove the builder from any attached builds
db_client()
.builds
.update_many(
doc! { "config.builder.params.builder_id": &resource.id },
mungos::update::Update::Set(
doc! { "config.builder.params.builder_id": "" },
),
doc! { "config.builder_id": &resource.id },
mungos::update::Update::Set(doc! { "config.builder_id": "" }),
)
.await
.context("failed to update_many builds on database")?;
db_client()
.repos
.update_many(
doc! { "config.builder_id": &resource.id },
mungos::update::Update::Set(doc! { "config.builder_id": "" }),
)
.await
.context("failed to update_many repos on database")?;
Ok(())
}

View File

@@ -222,7 +222,7 @@ impl Resolve<RenameRepo> for State {
)
.await;
let msg = match renamed {
Ok(_) => format!("Renamed Repo directory on Server"),
Ok(_) => String::from("Renamed Repo directory on Server"),
Err(_) => format!("No Repo cloned at {curr_name} to rename"),
};
Ok(Log::simple("Rename Repo on Server", msg))

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::{fmt::Write, path::PathBuf};
use anyhow::{anyhow, Context};
use command::run_komodo_command;
@@ -145,8 +145,10 @@ pub async fn compose_up(
.config
.additional_env_files
.iter()
.map(|file| format!(" --env-file {file}"))
.collect::<String>();
.fold(String::new(), |mut output, file| {
let _ = write!(output, " --env-file {file}");
output
});
// Build images before destroying to minimize downtime.
// If this fails, do not continue.

View File

@@ -38,10 +38,10 @@
//!
//! ```text
//! curl --header "Content-Type: application/json" \
//! --header "X-Api-Key: your_api_key" \
//! --header "X-Api-Secret: your_api_secret" \
//! --data '{ "type": "UpdateBuild", "params": { "id": "67076689ed600cfdd52ac637", "config": { "version": "1.15.9" } } }' \
//! https://komodo.example.com/write
//! --header "X-Api-Key: your_api_key" \
//! --header "X-Api-Secret: your_api_secret" \
//! --data '{ "type": "UpdateBuild", "params": { "id": "67076689ed600cfdd52ac637", "config": { "version": "1.15.9" } } }' \
//! https://komodo.example.com/write
//! ```
//!
//! ## Modules

View File

@@ -70,6 +70,22 @@ pub struct ActionConfig {
))]
#[builder(default)]
pub file_contents: String,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
#[partial_default(default_webhook_enabled())]
pub webhook_enabled: bool,
/// Optionally provide an alternate webhook secret for this procedure.
/// If its an empty string, use the default secret from the config.
#[serde(default)]
#[builder(default)]
pub webhook_secret: String,
}
fn default_webhook_enabled() -> bool {
true
}
impl ActionConfig {
@@ -82,6 +98,8 @@ impl Default for ActionConfig {
fn default() -> Self {
Self {
file_contents: Default::default(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.16.2",
"version": "1.16.3",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",

View File

@@ -54,6 +54,13 @@ export interface Resource<Config, Info> {
export interface ActionConfig {
/** Typescript file contents using pre-initialized `komodo` client. */
file_contents?: string;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
* Optionally provide an alternate webhook secret for this procedure.
* If its an empty string, use the default secret from the config.
*/
webhook_secret?: string;
}
export interface ActionInfo {

View File

@@ -0,0 +1,54 @@
# Development
If you are looking to contribute to Komodo, this page is a launching point for setting up your Komodo development environment.
## Dependencies
Running Komodo from [source](https://github.com/mbecker20/komodo) requires either [Docker](https://www.docker.com/) (and can use the included [devcontainer](https://code.visualstudio.com/docs/devcontainers/containers)), or can have the development dependencies installed locally:
* Backend (Core / Periphery APIs)
* [Rust](https://www.rust-lang.org/) stable via [rustup installer](https://rustup.rs/)
* [MongoDB](https://www.mongodb.com/) or [FerretDB](https://www.ferretdb.com/) available locally.
* On Debian/Ubuntu: `apt install build-essential pkg-config libssl-dev` required to build the rust source.
* Frontend (Web UI)
* [Node](https://nodejs.org/en) >= 18.18 + NPM
* [Yarn](https://yarnpkg.com/) - (Tip: use `corepack enable` after installing `node` to use `yarn`)
* [typeshare](https://github.com/1password/typeshare)
* [Deno](https://deno.com/) >= 2.0.2
### runnables-cli
[mbecker20/runnables-cli](https://github.com/mbecker20/runnables-cli) can be used as a convience CLI for running common project tasks found in `runfile.toml`. Otherwise, you can create your own project tasks by references the `cmd`s found in `runfile.toml`. All instructions below will use runnables-cli v1.3.7+.
## Docker
After making changes to the project, run `run -r test-compose-build` to rebuild Komodo and then `run -r test-compose-exposed` to start a Komodo container with the UI accessible at `localhost:9120`. Any changes made to source files will require re-running the `test-compose-build` and `test-compose-exposed` commands.
## Devcontainer
Use the included `.devcontainer.json` with VSCode or other compatible IDE to stand-up a full environment, including database, with one click.
[VSCode Tasks](https://code.visualstudio.com/Docs/editor/tasks) are provided for building and running Komodo.
After opening the repository with the devcontainer run the task `Init` to build the frontend/backend. Then, the task `Run Komodo` can be used to run frontend/backend. Other tasks for rebuilding/running just one component of the stack (Core API, Periphery API, Frontend) are also provided.
## Local
To run a full Komodo instance from a non-container environment run commands in this order:
* Ensure dependencies are up to date
* `rustup update` -- ensure rust toolchain is up to date
* Build and Run backend
* `run -r test-core` -- Build and run Core API
* `run -r test-periphery` -- Build and run Periphery API
* Build Frontend
* **Run this once** -- `run -r link-client` -- generates TS client and links to the frontend
* After running the above once:
* `run -r gen-client` -- Rebuild client
* `run -r start-frontend` -- Start in dev (watch) mode
* `run -r build-frontend` -- Typecheck and build
## Docsite Development
Use `run -r docsite-start` to start the [Docusaurus](https://docusaurus.io/) Komodo docs site in development mode. Changes made to files in `./docsite` will be automatically reloaded by the server.

View File

@@ -63,6 +63,7 @@ const sidebars: SidebarsConfig = {
"permissioning",
"version-upgrades",
"api",
"development"
],
};

6
expose.compose.yaml Normal file
View File

@@ -0,0 +1,6 @@
services:
core:
ports:
- 9120:9120
environment:
KOMODO_FIRST_SERVER: http://periphery:8120

View File

@@ -45,6 +45,13 @@ export interface Resource<Config, Info> {
export interface ActionConfig {
/** Typescript file contents using pre-initialized `komodo` client. */
file_contents?: string;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
* Optionally provide an alternate webhook secret for this procedure.
* If its an empty string, use the default secret from the config.
*/
webhook_secret?: string;
}
export interface ActionInfo {
/** When action was last run */

View File

@@ -16,7 +16,7 @@ import {
SelectValue,
} from "@ui/select";
import { AlertTriangle, History, Settings } from "lucide-react";
import { Fragment, ReactNode, SetStateAction, useMemo } from "react";
import { Fragment, ReactNode, SetStateAction } from "react";
const keys = <T extends Record<string, unknown>>(obj: T) =>
Object.keys(obj) as Array<keyof T>;
@@ -114,11 +114,10 @@ export type ConfigComponent<T> = {
};
export const Config = <T,>({
// resource_id,
// resource_type,
config,
update,
disabled,
disableSidebar,
set,
onSave,
components,
@@ -126,11 +125,10 @@ export const Config = <T,>({
titleOther,
file_contents_language,
}: {
resource_id: string;
resource_type: Types.ResourceTarget["type"];
config: T;
update: Partial<T>;
disabled: boolean;
disableSidebar?: boolean;
set: React.Dispatch<SetStateAction<Partial<T>>>;
onSave: () => Promise<void>;
selector?: ReactNode;
@@ -141,27 +139,6 @@ export const Config = <T,>({
>;
file_contents_language?: MonacoLanguage;
}) => {
// let component_keys = keys(components);
// const [_show, setShow] = useLocalStorage(
// `config-${resource_type}-${resource_id}`,
// component_keys[0]
// );
// const show = (components[_show] && _show) || component_keys[0];
const showSidebar = useMemo(() => {
let activeCount = 0;
for (const key in components) {
for (const component of components[key] || []) {
for (const key in component.components || {}) {
if (component.components[key]) {
activeCount++;
}
}
}
}
return activeCount > 1;
}, [components]);
const sections = keys(components).filter((section) => !!components[section]);
return (
@@ -179,7 +156,7 @@ export const Config = <T,>({
file_contents_language={file_contents_language}
>
<div className="flex gap-6">
{showSidebar && (
{!disableSidebar && (
<div className="hidden xl:block relative pr-6 border-r">
<div className="sticky top-24 hidden xl:flex flex-col gap-8 w-[140px] h-fit pb-24">
{sections.map((section) => (

View File

@@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
WebhookIdOrName,
useCtrlKeyListener,
useInvalidate,
useRead,
useWebhookIdOrName,
useWrite,
WebhookIntegration,
useWebhookIntegrations,
} from "@lib/hooks";
import { Types } from "komodo_client";
import {
@@ -1183,3 +1187,82 @@ export const RenameResource = ({
</div>
);
};
export const WebhookBuilder = ({
git_provider,
children,
}: {
git_provider: string;
children?: ReactNode;
}) => {
return (
<ConfigItem>
<div className="grid items-center grid-cols-[auto_1fr] gap-x-6 gap-y-2 w-fit">
<div className="text-muted-foreground text-sm">Auth style?</div>
<WebhookIntegrationSelector git_provider={git_provider} />
<div className="text-muted-foreground text-sm">
Resource Id or Name?
</div>
<WebhookIdOrNameSelector />
{children}
</div>
</ConfigItem>
);
};
/** Should call `useWebhookIntegrations` in util/hooks to get the current value */
export const WebhookIntegrationSelector = ({
git_provider,
}: {
git_provider: string;
}) => {
const { integrations, setIntegration } = useWebhookIntegrations();
const integration = integrations[git_provider]
? integrations[git_provider]
: git_provider === "gitlab.com"
? "Gitlab"
: "Github";
return (
<Select
value={integration}
onValueChange={(integration) =>
setIntegration(git_provider, integration as WebhookIntegration)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Github", "Gitlab"].map((integration) => (
<SelectItem key={integration} value={integration}>
{integration}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
/** Should call `useWebhookIdOrName` in util/hooks to get the current value */
export const WebhookIdOrNameSelector = () => {
const [idOrName, setIdOrName] = useWebhookIdOrName();
return (
<Select
value={idOrName}
onValueChange={(idOrName) => setIdOrName(idOrName as WebhookIdOrName)}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Id", "Name"].map((idOrName) => (
<SelectItem key={idOrName} value={idOrName}>
{idOrName}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@@ -181,23 +181,25 @@ export const Section = ({
itemsCenterTitleRow,
}: SectionProps) => (
<div className="flex flex-col gap-4">
<div
className={cn(
"flex flex-wrap gap-2 justify-between py-1",
itemsCenterTitleRow ? "items-center" : "items-start"
)}
>
{title || icon ? (
<div className="px-2 flex items-center gap-2 text-muted-foreground">
{icon}
{title && <h2 className="text-xl">{title}</h2>}
{titleRight}
</div>
) : (
titleOther
)}
{actions}
</div>
{(title || icon || titleRight || titleOther || actions) && (
<div
className={cn(
"flex flex-wrap gap-2 justify-between py-1",
itemsCenterTitleRow ? "items-center" : "items-start"
)}
>
{title || icon ? (
<div className="px-2 flex items-center gap-2 text-muted-foreground">
{icon}
{title && <h2 className="text-xl">{title}</h2>}
{titleRight}
</div>
) : (
titleOther
)}
{actions}
</div>
)}
{children}
</div>
);

View File

@@ -1,15 +1,32 @@
import { useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { Config } from "@components/config";
import { MonacoEditor } from "@components/monaco";
import { SecretsSearch } from "@components/config/env_vars";
import { Button } from "@ui/button";
import { ConfigItem, WebhookBuilder } from "@components/config/util";
import { Input } from "@ui/input";
import { useState } from "react";
import { CopyWebhook } from "../common";
import { ActionInfo } from "./info";
import { Switch } from "@ui/switch";
const ACTION_GIT_PROVIDER = "Action";
export const ActionConfig = ({ id }: { id: string }) => {
const [branch, setBranch] = useState("main");
const perms = useRead("GetPermissionLevel", {
target: { type: "Action", id },
}).data;
const config = useRead("GetAction", { action: id }).data?.config;
const action = useRead("GetAction", { action: id }).data;
const config = action?.config;
const name = action?.name;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const [update, set] = useLocalStorage<Partial<Types.ActionConfig>>(
@@ -17,16 +34,18 @@ export const ActionConfig = ({ id }: { id: string }) => {
{}
);
const { mutateAsync } = useWrite("UpdateAction");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const webhook_integration = integrations[ACTION_GIT_PROVIDER] ?? "Github";
return (
<Config
resource_id={id}
resource_type="Action"
disabled={disabled}
disableSidebar
config={config}
update={update}
set={set}
@@ -78,11 +97,63 @@ export const ActionConfig = ({ id }: { id: string }) => {
language="typescript"
readOnly={disabled}
/>
<ActionInfo id={id} />
</div>
);
},
},
},
{
label: "Webhook",
description: `Configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`,
components: {
["Builder" as any]: () => (
<WebhookBuilder git_provider={ACTION_GIT_PROVIDER}>
<div className="text-nowrap text-muted-foreground text-sm">
Listen on branch:
</div>
<div className="flex items-center gap-3">
<Input
placeholder="Branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-[200px]"
disabled={branch === "__ALL__"}
/>
<div className="flex items-center gap-2">
<div className="text-muted-foreground text-sm">
All branches:
</div>
<Switch
checked={branch === "__ALL__"}
onCheckedChange={(checked) => {
if (checked) {
setBranch("__ALL__");
} else {
setBranch("main");
}
}}
/>
</div>
</div>
</WebhookBuilder>
),
["run" as any]: () => (
<ConfigItem label="Webhook Url - Run">
<CopyWebhook
integration={webhook_integration}
path={`/action/${id_or_name === "Id" ? id : name}/${branch}`}
/>
</ConfigItem>
),
webhook_enabled: true,
webhook_secret: {
description:
"Provide a custom webhook secret for this resource, or use the global default.",
placeholder: "Input custom secret",
},
},
},
],
}}
/>

View File

@@ -16,7 +16,6 @@ import {
import { cn } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@pages/home/dashboard";
import { ActionInfo } from "./info";
import { RenameResource } from "@components/config/util";
const useAction = (id?: string) =>
@@ -28,15 +27,6 @@ const ActionIcon = ({ id, size }: { id?: string; size: number }) => {
return <Clapperboard className={cn(`w-${size} h-${size}`, state && color)} />;
};
const ConfigInfo = ({ id }: { id: string }) => {
return (
<div className="flex flex-col gap-2">
<ActionConfig id={id} />
<ActionInfo id={id} />
</div>
);
};
export const ActionComponents: RequiredResourceComponents = {
list_item: (id) => useAction(id),
resource_links: () => undefined,
@@ -112,7 +102,7 @@ export const ActionComponents: RequiredResourceComponents = {
Page: {},
Config: ConfigInfo,
Config: ActionConfig,
DangerZone: ({ id }) => (
<>

View File

@@ -20,9 +20,9 @@ export const ActionInfo = ({ id }: { id: string }) => {
const log = full_update?.logs.find((log) => log.stage === "Execute Action");
return (
<Section>
{!log?.stdout && !log?.stderr && (
if (!log?.stdout && !log?.stderr) {
return (
<Section>
<Card className="flex flex-col gap-4">
<CardHeader
className={cn(
@@ -33,17 +33,18 @@ export const ActionInfo = ({ id }: { id: string }) => {
Never run
</CardHeader>
</Card>
)}
</Section>
);
}
return (
<Section>
{/* Last run */}
{log?.stdout && (
<Card className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center pb-0",
text_color_class_by_intention("Good")
)}
>
Stdout
<CardHeader className="flex flex-row items-center gap-1 pb-0">
Last run -
<div className={text_color_class_by_intention("Good")}>Stdout</div>
</CardHeader>
<CardContent className="pr-8">
<pre
@@ -57,13 +58,11 @@ export const ActionInfo = ({ id }: { id: string }) => {
)}
{log?.stderr && (
<Card className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center pb-0",
text_color_class_by_intention("Critical")
)}
>
Stderr
<CardHeader className="flex flex-row items-center gap-1 pb-0">
Last run -
<div className={text_color_class_by_intention("Critical")}>
Stderr
</div>
</CardHeader>
<CardContent className="pr-8">
<pre

View File

@@ -23,8 +23,6 @@ export const AlerterConfig = ({ id }: { id: string }) => {
return (
<Config
resource_id={id}
resource_type="Alerter"
disabled={disabled}
config={config}
update={update}

View File

@@ -8,13 +8,22 @@ import {
InputList,
ProviderSelectorConfig,
SystemCommand,
WebhookBuilder,
} from "@components/config/util";
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
getWebhookIntegration,
useInvalidate,
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { Button } from "@ui/button";
import { Ban, CirclePlus, PlusCircle } from "lucide-react";
import { ReactNode } from "react";
import { CopyGithubWebhook, ResourceLink, ResourceSelector } from "../common";
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
import { useToast } from "@ui/use-toast";
import { text_color_class_by_intention } from "@lib/color";
import { ConfirmButton } from "@components/util";
@@ -32,7 +41,9 @@ export const BuildConfig = ({
const perms = useRead("GetPermissionLevel", {
target: { type: "Build", id },
}).data;
const config = useRead("GetBuild", { build: id }).data?.config;
const build = useRead("GetBuild", { build: id }).data;
const config = build?.config;
const name = build?.name;
const webhook = useRead("GetBuildWebhookEnabled", { build: id }).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
@@ -41,15 +52,18 @@ export const BuildConfig = ({
{}
);
const { mutateAsync } = useWrite("UpdateBuild");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const git_provider = update.git_provider ?? config.git_provider;
const webhook_integration = getWebhookIntegration(integrations, git_provider);
return (
<Config
resource_id={id}
resource_type="Build"
titleOther={titleOther}
disabled={disabled}
config={config}
@@ -360,8 +374,7 @@ export const BuildConfig = ({
},
{
label: "Webhook",
description:
"Configure your repo provider to send webhooks to Komodo",
description: `Configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`,
components: {
["Guard" as any]: () => {
if (update.branch ?? config.branch) {
@@ -373,9 +386,15 @@ export const BuildConfig = ({
</ConfigItem>
);
},
["Builder" as any]: () => (
<WebhookBuilder git_provider={git_provider} />
),
["build" as any]: () => (
<ConfigItem label="Webhook Url">
<CopyGithubWebhook path={`/build/${id}`} />
<ConfigItem label="Webhook Url - Build">
<CopyWebhook
integration={webhook_integration}
path={`/build/${id_or_name === "Id" ? id : name}`}
/>
</ConfigItem>
),
webhook_enabled: webhook !== undefined && !webhook.managed,

View File

@@ -43,8 +43,6 @@ const AwsBuilderConfig = ({ id }: { id: string }) => {
return (
<Config
resource_id={id}
resource_type="Builder"
disabled={disabled}
config={config}
update={update}
@@ -257,8 +255,6 @@ const ServerBuilderConfig = ({ id }: { id: string }) => {
return (
<Config
resource_id={id}
resource_type="Builder"
disabled={disabled}
config={config.params as Types.ServerBuilderConfig}
update={update}

View File

@@ -4,7 +4,12 @@ import {
CopyButton,
TextUpdateMenu2,
} from "@components/util";
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import {
useInvalidate,
useRead,
useWrite,
WebhookIntegration,
} from "@lib/hooks";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import {
@@ -50,8 +55,8 @@ export const ResourceDescription = ({
type === "ServerTemplate"
? "server_template"
: type === "ResourceSync"
? "sync"
: type.toLowerCase();
? "sync"
: type.toLowerCase();
const resource = useRead(`Get${type}`, {
[key]: id,
@@ -281,8 +286,8 @@ export const NewResource = ({
type === "ServerTemplate"
? "server-template"
: type === "ResourceSync"
? "resource-sync"
: type.toLowerCase();
? "resource-sync"
: type.toLowerCase();
const config: Types._PartialDeploymentConfig | Types._PartialRepoConfig =
type === "Deployment"
? {
@@ -292,12 +297,12 @@ export const NewResource = ({
: { type: "Image", params: { image: "" } },
}
: type === "Stack"
? { server_id }
: type === "Repo"
? { server_id, builder_id }
: type === "Build"
? { builder_id }
: {};
? { server_id }
: type === "Repo"
? { server_id, builder_id }
: type === "Build"
? { builder_id }
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const id = (await mutateAsync({ name, config }))._id?.$oid!;
@@ -340,8 +345,8 @@ export const DeleteResource = ({
type === "ServerTemplate"
? "server_template"
: type === "ResourceSync"
? "sync"
: type.toLowerCase();
? "sync"
: type.toLowerCase();
const resource = useRead(`Get${type}`, {
[key]: id,
} as any).data;
@@ -367,9 +372,15 @@ export const DeleteResource = ({
);
};
export const CopyGithubWebhook = ({ path }: { path: string }) => {
export const CopyWebhook = ({
integration,
path,
}: {
integration: WebhookIntegration;
path: string;
}) => {
const base_url = useRead("GetCoreInfo", {}).data?.webhook_base_url;
const url = base_url + "/listener/github" + path;
const url = base_url + "/listener/" + integration.toLowerCase() + path;
return (
<div className="flex gap-2 items-center">
<Input className="w-[400px] max-w-[70vw]" value={url} readOnly />

View File

@@ -51,8 +51,6 @@ export const DeploymentConfig = ({
return (
<Config
resource_id={id}
resource_type="Deployment"
titleOther={titleOther}
disabled={disabled}
config={config}
@@ -112,9 +110,9 @@ export const DeploymentConfig = ({
image?.type === "Image" && image.params.image
? extract_registry_domain(image.params.image)
: image?.type === "Build" && image.params.build_id
? builds?.find((b) => b.id === image.params.build_id)?.info
.image_registry_domain
: undefined;
? builds?.find((b) => b.id === image.params.build_id)
?.info.image_registry_domain
: undefined;
return (
<AccountSelectorConfig
id={update.server_id ?? config.server_id ?? undefined}

View File

@@ -1,11 +1,22 @@
import { ConfigItem } from "@components/config/util";
import {
ConfigInput,
ConfigItem,
ConfigSwitch,
WebhookBuilder,
} from "@components/config/util";
import { Section } from "@components/layouts";
import { useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { Card, CardHeader } from "@ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card";
import { Input } from "@ui/input";
import { useEffect, useState } from "react";
import { CopyGithubWebhook, ResourceSelector } from "../common";
import { CopyWebhook, ResourceSelector } from "../common";
import { ConfigLayout } from "@components/config";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { Button } from "@ui/button";
@@ -50,6 +61,8 @@ export const ProcedureConfig = ({ id }: { id: string }) => {
return <ProcedureConfigInner procedure={procedure} />;
};
const PROCEDURE_GIT_PROVIDER = "Procedure";
const ProcedureConfigInner = ({
procedure,
}: {
@@ -66,8 +79,11 @@ const ProcedureConfigInner = ({
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const { mutateAsync } = useWrite("UpdateProcedure");
const stages = config.stages || procedure.config?.stages || [];
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
const webhook_integration = integrations[PROCEDURE_GIT_PROVIDER] ?? "Github";
const stages = config.stages || procedure.config?.stages || [];
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const add_stage = () =>
@@ -196,54 +212,75 @@ const ProcedureConfigInner = ({
</ConfigLayout>
<Section>
<Card>
<CardHeader className="p-4">
<ConfigItem label="Git Webhook" className="items-start">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="text-nowrap text-muted-foreground">
Listen on branch:
</div>
<CardHeader>
<CardTitle>Webhook</CardTitle>
<CardDescription>
Trigger this Procedure with a webhook.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<ConfigItem>
<WebhookBuilder git_provider={PROCEDURE_GIT_PROVIDER}>
<div className="text-nowrap text-muted-foreground text-sm">
Listen on branch:
</div>
<div className="flex items-center gap-3">
<Input
placeholder="Branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-[200px]"
disabled={branch === "__ALL__"}
/>
<div className="flex items-center gap-2">
<div className="text-muted-foreground text-sm">
All branches:
</div>
<Switch
checked={branch === "__ALL__"}
onCheckedChange={(checked) => {
if (checked) {
setBranch("__ALL__");
} else {
setBranch("main");
}
}}
/>
</div>
</div>
<CopyGithubWebhook
path={`/procedure/${procedure._id?.$oid!}/${branch}`}
/>
</div>
<div className="flex items-center justify-end gap-4 w-full">
<div className="text-muted-foreground">Enabled:</div>
<Switch
checked={
config.webhook_enabled ??
procedure.config?.webhook_enabled
}
onCheckedChange={(webhook_enabled) =>
setConfig({ ...config, webhook_enabled })
}
disabled={disabled}
/>
</div>
<div className="flex items-center justify-end gap-4 w-full">
<div className="text-muted-foreground">Custom Secret:</div>
<Input
value={
config.webhook_secret ?? procedure.config?.webhook_secret
}
onChange={(e) =>
setConfig({ ...config, webhook_secret: e.target.value })
}
disabled={disabled}
className="w-[400px] max-w-full"
/>
</div>
</div>
</ConfigItem>
</CardHeader>
</WebhookBuilder>
</ConfigItem>
<ConfigItem label="Webhook Url - Run">
<CopyWebhook
integration={webhook_integration}
path={`/procedure/${id_or_name === "Id" ? procedure._id?.$oid! : procedure.name}/${branch}`}
/>
</ConfigItem>
<ConfigSwitch
label="Webhook Enabled"
value={
config.webhook_enabled ?? procedure.config?.webhook_enabled
}
disabled={disabled}
onChange={(webhook_enabled) =>
setConfig({ ...config, webhook_enabled })
}
/>
<ConfigInput
label="Custom Secret"
description="Provide a custom webhook secret for this resource, or use the global default."
placeholder="Input custom secret"
value={
config.webhook_secret ?? procedure.config?.webhook_secret
}
disabled={disabled}
onChange={(webhook_secret) =>
setConfig({ ...config, webhook_secret })
}
/>
</div>
</CardContent>
</Card>
</Section>
</div>
@@ -566,7 +603,7 @@ type ExecutionType = Types.Execution["type"];
type ExecutionConfigComponent<
T extends ExecutionType,
P = Extract<Types.Execution, { type: T }>["params"]
P = Extract<Types.Execution, { type: T }>["params"],
> = React.FC<{
params: P;
setParams: React.Dispatch<React.SetStateAction<P>>;

View File

@@ -5,10 +5,19 @@ import {
InputList,
ProviderSelectorConfig,
SystemCommand,
WebhookBuilder,
} from "@components/config/util";
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
getWebhookIntegration,
useInvalidate,
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { CopyGithubWebhook, ResourceLink, ResourceSelector } from "../common";
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
import { useToast } from "@ui/use-toast";
import { text_color_class_by_intention } from "@lib/color";
import { ConfirmButton } from "@components/util";
@@ -21,7 +30,9 @@ export const RepoConfig = ({ id }: { id: string }) => {
const perms = useRead("GetPermissionLevel", {
target: { type: "Repo", id },
}).data;
const config = useRead("GetRepo", { repo: id }).data?.config;
const repo = useRead("GetRepo", { repo: id }).data;
const config = repo?.config;
const name = repo?.name;
const webhooks = useRead("GetRepoWebhooksEnabled", { repo: id }).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
@@ -30,14 +41,18 @@ export const RepoConfig = ({ id }: { id: string }) => {
{}
);
const { mutateAsync } = useWrite("UpdateRepo");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const git_provider = update.git_provider ?? config.git_provider;
const webhook_integration = getWebhookIntegration(integrations, git_provider);
return (
<Config
resource_id={id}
resource_type="Repo"
disabled={disabled}
config={config}
update={update}
@@ -222,7 +237,7 @@ export const RepoConfig = ({ id }: { id: string }) => {
},
},
{
label: "Git Webhooks",
label: "Webhooks",
description:
"Configure your repo provider to send webhooks to Komodo",
components: {
@@ -236,19 +251,31 @@ export const RepoConfig = ({ id }: { id: string }) => {
</ConfigItem>
);
},
["Builder" as any]: () => (
<WebhookBuilder git_provider={git_provider} />
),
["pull" as any]: () => (
<ConfigItem label="Pull">
<CopyGithubWebhook path={`/repo/${id}/pull`} />
<ConfigItem label="Webhook Url - Pull">
<CopyWebhook
integration={webhook_integration}
path={`/repo/${id_or_name === "Id" ? id : name}/pull`}
/>
</ConfigItem>
),
["clone" as any]: () => (
<ConfigItem label="Clone">
<CopyGithubWebhook path={`/repo/${id}/clone`} />
<ConfigItem label="Webhook Url - Clone">
<CopyWebhook
integration={webhook_integration}
path={`/repo/${id_or_name === "Id" ? id : name}/clone`}
/>
</ConfigItem>
),
["build" as any]: () => (
<ConfigItem label="Build">
<CopyGithubWebhook path={`/repo/${id}/build`} />
<ConfigItem label="Webhook Url - Build">
<CopyWebhook
integration={webhook_integration}
path={`/repo/${id_or_name === "Id" ? id : name}/build`}
/>
</ConfigItem>
),
webhook_enabled: webhooks !== undefined && !webhooks.managed,

View File

@@ -4,11 +4,20 @@ import {
ConfigItem,
ConfigList,
ProviderSelectorConfig,
WebhookBuilder,
} from "@components/config/util";
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
getWebhookIntegration,
useInvalidate,
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { ReactNode, useState } from "react";
import { CopyGithubWebhook } from "../common";
import { CopyWebhook } from "../common";
import { useToast } from "@ui/use-toast";
import { text_color_class_by_intention } from "@lib/color";
import { ConfirmButton, ShowHideButton } from "@components/util";
@@ -61,7 +70,9 @@ export const ResourceSyncConfig = ({
const perms = useRead("GetPermissionLevel", {
target: { type: "ResourceSync", id },
}).data;
const config = useRead("GetResourceSync", { sync: id }).data?.config;
const sync = useRead("GetResourceSync", { sync: id }).data;
const config = sync?.config;
const name = sync?.name;
const webhooks = useRead("GetSyncWebhooksEnabled", { sync: id }).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
@@ -70,11 +81,16 @@ export const ResourceSyncConfig = ({
{}
);
const { mutateAsync } = useWrite("UpdateResourceSync");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const git_provider = update.git_provider ?? config.git_provider;
const integration = getWebhookIntegration(integrations, git_provider);
const mode = getSyncMode(update, config);
const managed = update.managed ?? config.managed;
@@ -300,20 +316,29 @@ export const ResourceSyncConfig = ({
</ConfigItem>
);
},
["refresh" as any]: () => (
["Builder" as any]: () => (
<WebhookBuilder git_provider={git_provider} />
),
["Refresh" as any]: () => (
<ConfigItem
label="Refresh Pending"
label="Webhook Url - Refresh Pending"
description="Trigger an update of the pending sync cache, to display the changes in the UI on push."
>
<CopyGithubWebhook path={`/sync/${id}/refresh`} />
<CopyWebhook
integration={integration}
path={`/sync/${id_or_name === "Id" ? id : name}/refresh`}
/>
</ConfigItem>
),
["sync" as any]: () => (
["Sync" as any]: () => (
<ConfigItem
label="Execute Sync"
label="Webhook Url - Execute Sync"
description="Trigger an execution of the sync on push."
>
<CopyGithubWebhook path={`/sync/${id}/sync`} />
<CopyWebhook
integration={integration}
path={`/sync/${id_or_name === "Id" ? id : name}/sync`}
/>
</ConfigItem>
),
webhook_enabled: webhooks !== undefined && !webhooks.managed,
@@ -480,8 +505,6 @@ export const ResourceSyncConfig = ({
return (
<Config
resource_id={id}
resource_type="ResourceSync"
titleOther={titleOther}
disabled={disabled}
config={config}

View File

@@ -42,8 +42,6 @@ export const AwsServerTemplateConfig = ({
return (
<Config
resource_id={id}
resource_type="ServerTemplate"
disabled={disabled}
config={config}
update={update}

View File

@@ -50,8 +50,6 @@ export const HetznerServerTemplateConfig = ({
return (
<Config
resource_id={id}
resource_type="ServerTemplate"
disabled={disabled}
config={config}
update={update}

View File

@@ -34,8 +34,6 @@ export const ServerConfig = ({
return (
<Config
resource_id={id}
resource_type="Server"
titleOther={titleOther}
disabled={disabled}
config={config}

View File

@@ -7,11 +7,20 @@ import {
InputList,
ProviderSelectorConfig,
SystemCommand,
WebhookBuilder,
} from "@components/config/util";
import { Types } from "komodo_client";
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
import {
getWebhookIntegration,
useInvalidate,
useLocalStorage,
useRead,
useWebhookIdOrName,
useWebhookIntegrations,
useWrite,
} from "@lib/hooks";
import { ReactNode } from "react";
import { CopyGithubWebhook, ResourceLink, ResourceSelector } from "../common";
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
import {
Select,
SelectContent,
@@ -55,7 +64,9 @@ export const StackConfig = ({
const perms = useRead("GetPermissionLevel", {
target: { type: "Stack", id },
}).data;
const config = useRead("GetStack", { stack: id }).data?.config;
const stack = useRead("GetStack", { stack: id }).data;
const config = stack?.config;
const name = stack?.name;
const webhooks = useRead("GetStackWebhooksEnabled", { stack: id }).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
@@ -64,13 +75,19 @@ export const StackConfig = ({
{}
);
const { mutateAsync } = useWrite("UpdateStack");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const run_build = update.run_build ?? config.run_build;
const mode = getStackMode(update, config);
const git_provider = update.git_provider ?? config.git_provider;
const webhook_integration = getWebhookIntegration(integrations, git_provider);
const setMode = (mode: StackMode) => {
if (mode === "Files On Server") {
set({ ...update, files_on_host: true });
@@ -561,26 +578,35 @@ export const StackConfig = ({
</ConfigItem>
);
},
["Refresh" as any]: () =>
(update.branch ?? config.branch) && (
<ConfigItem label="Refresh Cache">
<CopyGithubWebhook path={`/stack/${id}/refresh`} />
</ConfigItem>
),
["Builder" as any]: () => (
<WebhookBuilder git_provider={git_provider} />
),
// ["Refresh" as any]: () =>
// (update.branch ?? config.branch) && (
// <ConfigItem label="Refresh Cache">
// <CopyWebhook
// integration={webhook_integration}
// path={`/stack/${id_or_name === "Id" ? id : name}/refresh`}
// />
// </ConfigItem>
// ),
["Deploy" as any]: () =>
(update.branch ?? config.branch) && (
<ConfigItem label="Auto Redeploy">
<CopyGithubWebhook path={`/stack/${id}/deploy`} />
<ConfigItem label="Webhook Url - Deploy">
<CopyWebhook
integration={webhook_integration}
path={`/stack/${id_or_name === "Id" ? id : name}/deploy`}
/>
</ConfigItem>
),
webhook_enabled:
!!(update.branch ?? config.branch) &&
webhooks !== undefined &&
!webhooks.managed,
webhook_force_deploy: {
description:
"Usually the Stack won't deploy unless there are changes to the files. Use this to force deploy.",
},
webhook_enabled:
!!(update.branch ?? config.branch) &&
webhooks !== undefined &&
!webhooks.managed,
webhook_secret: {
description:
"Provide a custom webhook secret for this resource, or use the global default.",
@@ -757,8 +783,6 @@ export const StackConfig = ({
return (
<Config
resource_id={id}
resource_type="Stack"
titleOther={titleOther}
disabled={disabled}
config={config}

View File

@@ -67,7 +67,7 @@ export const useRead = <
(T | P)[]
>,
"queryFn" | "queryKey"
>
>,
>(
type: T,
params: P,
@@ -83,7 +83,7 @@ export const useInvalidate = () => {
const qc = useQueryClient();
return <
Type extends Types.ReadRequest["type"],
Params extends Extract<Types.ReadRequest, { type: Type }>["params"]
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
>(
...keys: Array<[Type] | [Type, Params]>
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
@@ -96,7 +96,7 @@ export const useManageUser = <
C extends Omit<
UseMutationOptions<UserResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>,
>(
type: T,
config?: C
@@ -130,7 +130,7 @@ export const useWrite = <
C extends Omit<
UseMutationOptions<WriteResponses[R["type"]], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>,
>(
type: T,
config?: C
@@ -164,7 +164,7 @@ export const useExecute = <
C extends Omit<
UseMutationOptions<ExecuteResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>,
>(
type: T,
config?: C
@@ -198,7 +198,7 @@ export const useAuth = <
C extends Omit<
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>
>,
>(
type: T,
config?: C
@@ -446,3 +446,49 @@ export const useNoResources = () => {
syncs === 0
);
};
export type WebhookIntegration = "Github" | "Gitlab";
export type WebhookIntegrations = {
[key: string]: WebhookIntegration;
};
const WEBHOOK_INTEGRATIONS_ATOM = atomWithStorage<WebhookIntegrations>(
"webhook-integrations-v2",
{}
);
export const useWebhookIntegrations = () => {
const [integrations, setIntegrations] = useAtom<WebhookIntegrations>(
WEBHOOK_INTEGRATIONS_ATOM
);
return {
integrations,
setIntegration: (provider: string, integration: WebhookIntegration) =>
setIntegrations({
...integrations,
[provider]: integration,
}),
};
};
export const getWebhookIntegration = (
integrations: WebhookIntegrations,
git_provider: string
) => {
return integrations[git_provider]
? integrations[git_provider]
: git_provider.includes("gitlab")
? "Gitlab"
: "Github";
};
export type WebhookIdOrName = "Id" | "Name";
const WEBHOOK_ID_OR_NAME_ATOM = atomWithStorage<WebhookIdOrName>(
"webhook-id-or-name-v1",
"Id"
);
export const useWebhookIdOrName = () => {
return useAtom<WebhookIdOrName>(WEBHOOK_ID_OR_NAME_ATOM);
};

View File

@@ -1,4 +1,5 @@
[start-frontend]
description = "starts the frontend in dev mode"
path = "frontend"
cmd = "yarn dev"
@@ -9,7 +10,16 @@ node ./client/core/ts/generate_types.mjs && \
cd ./client/core/ts && yarn build && \
cp -r dist/. ../../../frontend/public/client/."""
[link-client]
description = "yarn links the ts client to the frontend"
after = "gen-client"
cmd = """
cd ./client/core/ts && yarn link && \
cd ../../../frontend && yarn link komodo_client && yarn
"""
[build-frontend]
description = "generates fresh ts client and builds the frontend"
path = "frontend"
cmd = "yarn build"
after = "gen-client"
@@ -24,6 +34,12 @@ cmd = """
docker compose -p komodo-dev -f test.compose.yaml down --remove-orphans && \
docker compose -p komodo-dev -f test.compose.yaml up -d"""
[test-compose-exposed]
description = "deploys test.compose.yaml with exposed port and non-ssl periphery"
cmd = """
docker compose -p komodo-dev -f test.compose.yaml -f expose.compose.yaml down --remove-orphans && \
docker compose -p komodo-dev -f test.compose.yaml -f expose.compose.yaml up -d"""
[test-compose-build]
description = "builds and deploys test.compose.yaml"
cmd = """