Compare commits

..

10 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
Maxwell Becker
f9b2994d44 1.16.2 (#145)
* Env vars written using same quotes (single vs double) as the user passes

* fmt

* trim start matches '-'

* ts client version
2024-10-22 11:41:17 -07:00
mbecker20
c0d6d96b64 get username works for service users 2024-10-22 03:36:20 -04:00
mbecker20
34496b948a bump ts client to 1.16.1 2024-10-22 02:58:42 -04:00
mbecker20
90c6adf923 fix periphery installer force file recreation command 2024-10-22 02:55:39 -04:00
71 changed files with 1946 additions and 1140 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.1"
version = "1.16.3"
dependencies = [
"anyhow",
"axum",
@@ -943,7 +943,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"komodo_client",
"run_command",
@@ -1355,7 +1355,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"thiserror",
]
@@ -1439,7 +1439,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"serror",
]
@@ -1571,7 +1571,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "git"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"command",
@@ -2191,7 +2191,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"clap",
@@ -2207,7 +2207,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2238,7 +2238,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2383,7 +2383,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -3089,7 +3089,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -4863,7 +4863,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.16.1"
version = "1.16.3"
dependencies = [
"anyhow",
"komodo_client",

View File

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

View File

@@ -97,7 +97,10 @@ impl Resolve<RunAction, (User, Update)> for State {
// Keep this stage name as is, the UI will find the latest update log by matching the stage name
"Execute Action",
None,
format!("deno run --allow-read --allow-net --allow-import {}", path.display()),
format!(
"deno run --allow-read --allow-net --allow-import {}",
path.display()
),
false,
)
.await;

View File

@@ -69,15 +69,16 @@ impl Resolve<GetAlertersSummary, User> for State {
GetAlertersSummary {}: GetAlertersSummary,
user: User,
) -> anyhow::Result<GetAlertersSummaryResponse> {
let query =
match resource::get_resource_object_ids_for_user::<Alerter>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let query = match resource::get_resource_object_ids_for_user::<
Alerter,
>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.alerters
.count_documents(query)

View File

@@ -69,15 +69,16 @@ impl Resolve<GetBuildersSummary, User> for State {
GetBuildersSummary {}: GetBuildersSummary,
user: User,
) -> anyhow::Result<GetBuildersSummaryResponse> {
let query =
match resource::get_resource_object_ids_for_user::<Builder>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let query = match resource::get_resource_object_ids_for_user::<
Builder,
>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.builders
.count_documents(query)

View File

@@ -6,7 +6,7 @@ use komodo_client::{
ListApiKeysForServiceUserResponse, ListApiKeysResponse,
ListUsers, ListUsersResponse,
},
entities::user::{User, UserConfig},
entities::user::{admin_service_user, User, UserConfig},
};
use mungos::{
by_id::find_one_by_id,
@@ -26,6 +26,13 @@ impl Resolve<GetUsername, User> for State {
GetUsername { user_id }: GetUsername,
_: User,
) -> anyhow::Result<GetUsernameResponse> {
if let Some(user) = admin_service_user(&user_id) {
return Ok(GetUsernameResponse {
username: user.username,
avatar: None,
});
}
let user = find_one_by_id(&db_client().users, &user_id)
.await
.context("failed at mongo query for user")?

View File

@@ -130,7 +130,7 @@ impl Resolve<RenameRepo, User> for State {
let server =
resource::get::<Server>(&repo.config.server_id).await?;
let log = match periphery_client(&server)?
.request(api::git::RenameRepo {
curr_name: to_komodo_name(&repo.name),

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

@@ -182,11 +182,10 @@ impl AllResourcesById {
id_to_tags, match_tags,
)
.await?,
actions:
crate::resource::get_id_to_resource_map::<Action>(
id_to_tags, match_tags,
)
.await?,
actions: crate::resource::get_id_to_resource_map::<Action>(
id_to_tags, match_tags,
)
.await?,
builders: crate::resource::get_id_to_resource_map::<Builder>(
id_to_tags, match_tags,
)

View File

@@ -1,12 +1,15 @@
use anyhow::{anyhow, Context};
use command::run_komodo_command;
use formatting::format_serror;
use komodo_client::entities::{
build::{Build, BuildConfig},
environment_vars_from_str, get_image_name, optional_string,
to_komodo_name,
update::Log,
EnvironmentVar, Version,
use komodo_client::{
entities::{
build::{Build, BuildConfig},
environment_vars_from_str, get_image_name, optional_string,
to_komodo_name,
update::Log,
EnvironmentVar, Version,
},
parsers::QUOTE_PATTERN,
};
use periphery_client::api::build::{
self, PruneBuilders, PruneBuildx,
@@ -101,8 +104,9 @@ impl Resolve<build::Build> for State {
let secret_args = environment_vars_from_str(secret_args)
.context("Invalid secret_args")?;
let _secret_args =
let command_secret_args =
parse_secret_args(&secret_args, *skip_secret_interp)?;
let labels = parse_labels(
&environment_vars_from_str(labels).context("Invalid labels")?,
);
@@ -118,7 +122,7 @@ impl Resolve<build::Build> for State {
// Construct command
let command = format!(
"docker{buildx} build{build_args}{_secret_args}{extra_args}{labels}{image_tags} -f {dockerfile_path} .{push_command}",
"docker{buildx} build{build_args}{command_secret_args}{extra_args}{labels}{image_tags} -f {dockerfile_path} .{push_command}",
);
if *skip_secret_interp {
@@ -190,7 +194,16 @@ fn image_tags(
fn parse_build_args(build_args: &[EnvironmentVar]) -> String {
build_args
.iter()
.map(|p| format!(" --build-arg {}=\"{}\"", p.variable, p.value))
.map(|p| {
if p.value.starts_with(QUOTE_PATTERN)
&& p.value.ends_with(QUOTE_PATTERN)
{
// If the value already wrapped in quotes, don't wrap it again
format!(" --build-arg {}={}", p.variable, p.value)
} else {
format!(" --build-arg {}=\"{}\"", p.variable, p.value)
}
})
.collect::<Vec<_>>()
.join("")
}

View File

@@ -1,14 +1,17 @@
use anyhow::Context;
use command::run_komodo_command;
use formatting::format_serror;
use komodo_client::entities::{
deployment::{
conversions_from_str, extract_registry_domain, Conversion,
Deployment, DeploymentConfig, DeploymentImage, RestartMode,
use komodo_client::{
entities::{
deployment::{
conversions_from_str, extract_registry_domain, Conversion,
Deployment, DeploymentConfig, DeploymentImage, RestartMode,
},
environment_vars_from_str, to_komodo_name,
update::Log,
EnvironmentVar,
},
environment_vars_from_str, to_komodo_name,
update::Log,
EnvironmentVar,
parsers::QUOTE_PATTERN,
};
use periphery_client::api::container::{Deploy, RemoveContainer};
use resolver_api::Resolve;
@@ -175,7 +178,16 @@ fn parse_conversions(
fn parse_environment(environment: &[EnvironmentVar]) -> String {
environment
.iter()
.map(|p| format!(" --env {}=\"{}\"", p.variable, p.value))
.map(|p| {
if p.value.starts_with(QUOTE_PATTERN)
&& p.value.ends_with(QUOTE_PATTERN)
{
// If the value already wrapped in quotes, don't wrap it again
format!(" --env {}={}", p.variable, p.value)
} else {
format!(" --env {}=\"{}\"", p.variable, p.value)
}
})
.collect::<Vec<_>>()
.join("")
}

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

@@ -1,5 +1,8 @@
use anyhow::Context;
use komodo_client::entities::{EnvironmentVar, SearchCombinator};
use komodo_client::{
entities::{EnvironmentVar, SearchCombinator},
parsers::QUOTE_PATTERN,
};
use crate::config::periphery_config;
@@ -43,7 +46,16 @@ pub fn parse_extra_args(extra_args: &[String]) -> String {
pub fn parse_labels(labels: &[EnvironmentVar]) -> String {
labels
.iter()
.map(|p| format!(" --label {}=\"{}\"", p.variable, p.value))
.map(|p| {
if p.value.starts_with(QUOTE_PATTERN)
&& p.value.ends_with(QUOTE_PATTERN)
{
// If the value already wrapped in quotes, don't wrap it again
format!(" --label {}={}", p.variable, p.value)
} else {
format!(" --label {}=\"{}\"", p.variable, p.value)
}
})
.collect::<Vec<_>>()
.join("")
}

View File

@@ -46,7 +46,7 @@ async fn task(
request: crate::api::PeripheryRequest,
) -> anyhow::Result<String> {
let variant = request.extract_variant();
let res =
State
.resolve_request(request, ())

View File

@@ -12,7 +12,7 @@
//! - X-Api-Secret: `your_api_secret`
//! - Use either Authorization *or* X-Api-Key and X-Api-Secret to authenticate requests.
//! - Body: JSON specifying the request type (`type`) and the parameters (`params`).
//!
//!
//! You can create API keys for your user, or for a Service User with limited permissions,
//! from the Komodo UI Settings page.
//!
@@ -31,17 +31,17 @@
//!
//! The request's parent module (eg. [read], [mod@write]) determines the http path which
//! must be used for the requests. For example, requests under [read] are made using http path `/read`.
//!
//!
//! ## Curl Example
//!
//!
//! Putting it all together, here is an example `curl` for [write::UpdateBuild], to update the version:
//!
//!
//! ```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

@@ -3,7 +3,10 @@ use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{builder::{Builder, PartialBuilderConfig}, update::Update};
use crate::entities::{
builder::{Builder, PartialBuilderConfig},
update::Update,
};
use super::KomodoWriteRequest;
@@ -94,4 +97,4 @@ pub struct RenameBuilder {
pub id: String,
/// The new name.
pub name: String,
}
}

View File

@@ -3,9 +3,10 @@ use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{procedure::{
Procedure, _PartialProcedureConfig,
}, update::Update};
use crate::entities::{
procedure::{Procedure, _PartialProcedureConfig},
update::Update,
};
use super::KomodoWriteRequest;
@@ -108,4 +109,4 @@ pub struct RenameProcedure {
pub id: String,
/// The new name.
pub name: String,
}
}

View File

@@ -4,7 +4,9 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{
repo::{Repo, _PartialRepoConfig}, update::Update, NoData
repo::{Repo, _PartialRepoConfig},
update::Update,
NoData,
};
use super::KomodoWriteRequest;

View File

@@ -3,9 +3,10 @@ use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{server_template::{
PartialServerTemplateConfig, ServerTemplate,
}, update::Update};
use crate::entities::{
server_template::{PartialServerTemplateConfig, ServerTemplate},
update::Update,
};
use super::KomodoWriteRequest;
@@ -96,4 +97,4 @@ pub struct RenameServerTemplate {
pub id: String,
/// The new name.
pub name: String,
}
}

View File

@@ -46,7 +46,7 @@ pub type UpdateUserPasswordResponse = NoData;
/// **Admin only**. Delete a user.
/// Admins can delete any non-admin user.
/// Only Super Admin can delete an admin.
/// No users can delete a Super Admin user.
/// No users can delete a Super Admin user.
/// User cannot delete themselves.
/// Response: [NoData].
#[typeshare]

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

@@ -34,10 +34,10 @@ use serde::Deserialize;
pub mod api;
pub mod busy;
pub mod deserializers;
pub mod entities;
pub mod parsers;
pub mod ws;
pub mod deserializers;
mod request;

View File

@@ -1,24 +1,40 @@
use anyhow::Context;
pub const QUOTE_PATTERN: &[char] = &['"', '\''];
/// Parses a list of key value pairs from a multiline string
///
/// Example source:
/// ```text
/// # Supports comments
/// KEY_1 = value_1 # end of line comments
///
///
/// # Supports string wrapped values
/// KEY_2="value_2"
/// 'KEY_3 = value_3'
///
///
/// # Also supports yaml list formats
/// - KEY_4: 'value_4'
/// - "KEY_5=value_5"
///
/// # Wrapping outer quotes are removed while inner quotes are preserved
/// "KEY_6 = 'value_6'"
/// ```
///
/// Note this preserves the wrapping string around value.
/// Writing environment file should format the value exactly as it comes in,
/// including the given wrapping quotes.
///
/// Returns:
/// ```text
/// [("KEY_1", "value_1"), ("KEY_2", "value_2"), ("KEY_3", "value_3"), ("KEY_4", "value_4"), ("KEY_5", "value_5")]
/// [
/// ("KEY_1", "value_1"),
/// ("KEY_2", "\"value_2\""),
/// ("KEY_3", "value_3"),
/// ("KEY_4", "'value_4'"),
/// ("KEY_5", "value_5"),
/// ("KEY_6", "'value_6'"),
/// ]
/// ```
pub fn parse_key_value_list(
input: &str,
@@ -46,13 +62,6 @@ pub fn parse_key_value_list(
// Remove preceding '-' (yaml list)
.trim_start_matches('-')
.trim();
// Remove wrapping quotes (from yaml list)
let line = if let Some(line) = line.strip_prefix(['"', '\'']) {
line.strip_suffix(['"', '\'']).unwrap_or(line)
} else {
line
};
// Remove any preceding '"' (from yaml list) (wrapping quotes open)
let (key, value) = line
.split_once(['=', ':'])
.with_context(|| {
@@ -61,15 +70,23 @@ pub fn parse_key_value_list(
)
})
.map(|(key, value)| {
let key = key.trim();
let value = value.trim();
// Remove wrapping quotes around value
let value =
if let Some(value) = value.strip_prefix(['"', '\'']) {
value.strip_suffix(['"', '\'']).unwrap_or(value)
} else {
value
};
(key.trim().to_string(), value.trim().to_string())
// Remove wrapping quotes when around key AND value
let (key, value) = if key.starts_with(QUOTE_PATTERN)
&& !key.ends_with(QUOTE_PATTERN)
&& value.ends_with(QUOTE_PATTERN)
{
(
key.strip_prefix(QUOTE_PATTERN).unwrap().trim(),
value.strip_suffix(QUOTE_PATTERN).unwrap().trim(),
)
} else {
(key, value)
};
(key.to_string(), value.to_string())
})?;
anyhow::Ok((key, value))
})

View File

@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.16.0",
"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

@@ -28,7 +28,6 @@ import { Link } from "react-router-dom";
import { fmt_duration, fmt_operation, fmt_version } from "@lib/formatting";
import {
cn,
is_service_user,
updateLogToHtml,
usableResourcePath,
version_is_none,
@@ -50,44 +49,6 @@ export const UpdateUser = ({
iconSize?: number;
defaultAvatar?: boolean;
muted?: boolean;
}) => {
if (is_service_user(user_id)) {
return (
<div
className={cn(
"flex items-center gap-2 text-nowrap",
muted && "text-muted-foreground",
className
)}
>
<User className={`w-${iconSize} h-${iconSize}`} />
{user_id}
</div>
);
}
return (
<RealUpdateUser
user_id={user_id}
className={className}
iconSize={iconSize}
defaultAvatar={defaultAvatar}
muted={muted}
/>
);
};
const RealUpdateUser = ({
user_id,
className,
iconSize = 4,
defaultAvatar,
muted,
}: {
user_id: string;
className?: string;
iconSize?: number;
defaultAvatar?: boolean;
muted?: boolean;
}) => {
const res = useRead("GetUsername", { user_id }).data;
const username = res?.username;

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

@@ -231,20 +231,6 @@ export const sync_no_changes = (sync: Types.ResourceSync) => {
);
};
export const is_service_user = (user_id: string) => {
return (
user_id === "System" ||
user_id === "Procedure" ||
user_id === "Github" ||
user_id === "Git Webhook" ||
user_id === "Auto Redeploy" ||
user_id === "Resource Sync" ||
user_id === "Stack Wizard" ||
user_id === "Build Manager" ||
user_id === "Repo Manager"
);
};
export const extract_registry_domain = (image_name: string) => {
if (!image_name) return "docker.io";
const maybe_domain = image_name.split("/")[0];

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 = """

View File

@@ -61,5 +61,5 @@ to the current default.
Example:
```sh
curl -sSL https://raw.githubusercontent.com/mbecker20/komodo/main/scripts/setup-periphery.py | python3 --force-service-file
curl -sSL https://raw.githubusercontent.com/mbecker20/komodo/main/scripts/setup-periphery.py | python3 - --force-service-file
```