Compare commits

..
5 Commits
Author SHA1 Message Date
Maxwell BeckerandGitHub 3e0d1befbd 1.17.5 (#472)
* API support new calling syntax

* finish /{variant} api to improve network logs in browser console

* update roadmap

* configure the shell used to start the pty

* start on ExecuteTerminal api

* Rename resources less hidden - click on name in header

* update deps

* execute terminal

* BatchPullStack

* add Types import to Actions, and don't stringify the error

* add --reload for cached deps

* type execute terminal response as AsyncIterable

* execute terminal client api

* KOMODO_EXIT_CODE

* Early exit without code

* action configurable deno dep reload

* remove ServerTemplate resource

* kept disabled

* rework exec terminal command wrapper

* debug: print lines in start sentinel loop

* edit debug / remove ref

* echo

* line compare

* log lengths

* use printf again

* check char compare

* leading \n

* works with leading \n

* extra \n after START_OF_OUTPUT

* add variables / secrets finders to ui defined stacks / builds

* isolate post-db startup procedures

* clean up server templates

* disable websocket reconnect from core config

* change periphery ssl enabled to default to true

* git provider selector config pass through disable to http/s button

* disable terminals while allowing container exec

* disable_container_exec in default config

* update ws reconnect implementation

* Don't show delete tag non admin and non owner

* 1.17.5 complete
2025-05-04 14:45:31 -07:00
mbecker20 5dc609b206 add examples for perihery config fields 2025-04-28 18:14:40 -04:00
mbecker20 f1127007c3 update intro with shell features 2025-04-27 19:21:55 -04:00
Maxwell BeckerandGitHub 765e5a0df1 1.17.4 (#446)
* add terminal (ssh) apis

* add core terminal exec method

* terminal typescript client method

* terminals WIP

* backend for pty

* add ts responses

* about wire everything

* add new blog

* credit Skyfay

* working

* regen lock

* 1.17.4-dev-1

* pty history

* replace the test terminal impl with websocket (pty)

* create api and improve frontend

* fix fe

* terminals

* disable terminal api on periphery

* implement write level terminal perms

* remove unneeded

* fix clippy

* delete unneeded

* fix waste cpu cycles

* set TERM and COLORTERM for shell environment

* fix xterm scrolling behavior

* starship promp in periphery container terminal

* kill all terminals on periphery shutdown signal

* improve starship config and enable ssl in compose

* use same scrollTop setter

* fix periphery container distribution link

* support custom command / args to init terminal

* allow fully configurable init command

* docker exec into container

* add permissioning for container exec

* add starship to core container

* add delete all terminals

* dev-2

* finished gen client

* core need curl

* hide Terminal trigger if disabled

* 1.17.4
2025-04-27 15:53:23 -07:00
mbecker20 76f2f61be5 1.17.3 fix Build pre_build functionality. 2025-04-24 22:03:46 -04:00
169 changed files with 5512 additions and 5854 deletions
Generated
+307 -189
View File
File diff suppressed because it is too large Load Diff
+22 -17
View File
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "1.17.2"
version = "1.17.5"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -44,19 +44,21 @@ mungos = "3.2.0"
svi = "1.0.1"
# ASYNC
reqwest = { version = "0.12.15", default-features = false, features = ["json", "rustls-tls-native-roots"] }
tokio = { version = "1.44.1", features = ["full"] }
tokio-util = "0.7.14"
reqwest = { version = "0.12.15", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
tokio = { version = "1.44.2", features = ["full"] }
tokio-util = { version = "0.7.15", features = ["io", "codec"] }
tokio-stream = { version = "0.1.17", features = ["sync"] }
pin-project-lite = "0.2.16"
futures = "0.3.31"
futures-util = "0.3.31"
arc-swap = "1.7.1"
# SERVER
axum-extra = { version = "0.10.0", features = ["typed-header"] }
tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-native-roots"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] }
tower-http = { version = "0.6.2", features = ["fs", "cors"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
axum = { version = "0.8.1", features = ["ws", "json", "macros"] }
tokio-tungstenite = "0.26.2"
axum = { version = "0.8.4", features = ["ws", "json", "macros"] }
# SER/DE
ordered_hash_map = { version = "0.4.0", features = ["serde"] }
@@ -64,7 +66,8 @@ serde = { version = "1.0.219", features = ["derive"] }
strum = { version = "0.27.1", features = ["derive"] }
serde_json = "1.0.140"
serde_yaml = "0.9.34"
toml = "0.8.20"
serde_qs = "0.15.0"
toml = "0.8.22"
# ERROR
anyhow = "1.0.98"
@@ -76,11 +79,11 @@ opentelemetry_sdk = { version = "0.29.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.19", features = ["json"] }
opentelemetry-semantic-conventions = "0.29.0"
tracing-opentelemetry = "0.30.0"
opentelemetry = "0.29.0"
opentelemetry = "0.29.1"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.36", features = ["derive"] }
clap = { version = "4.5.37", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
@@ -94,23 +97,24 @@ bcrypt = "0.17.0"
base64 = "0.22.1"
rustls = "0.23.26"
hmac = "0.12.1"
sha2 = "0.10.8"
rand = "0.9.0"
sha2 = "0.10.9"
rand = "0.9.1"
hex = "0.4.3"
# SYSTEM
portable-pty = "0.9.0"
bollard = "0.18.1"
sysinfo = "0.34.2"
sysinfo = "0.35.0"
# CLOUD
aws-config = "1.6.1"
aws-sdk-ec2 = "1.121.1"
aws-credential-types = "1.2.2"
aws-config = "1.6.2"
aws-sdk-ec2 = "1.124.0"
aws-credential-types = "1.2.3"
## CRON
english-to-cron = "0.1.4"
chrono-tz = "0.10.3"
chrono = "0.4.40"
chrono = "0.4.41"
croner = "2.1.0"
# MISC
@@ -121,4 +125,5 @@ dashmap = "6.1.0"
wildcard = "0.3.0"
colored = "3.0.0"
regex = "1.11.1"
bytes = "1.10.1"
bson = "2.14.0"
+7
View File
@@ -185,6 +185,9 @@ pub async fn run(execution: Execution) -> anyhow::Result<()> {
Execution::PullStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchPullStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
@@ -429,6 +432,10 @@ pub async fn run(execution: Execution) -> anyhow::Result<()> {
.execute(request)
.await
.map(ExecutionResult::Single),
Execution::BatchPullStack(request) => komodo_client()
.execute(request)
.await
.map(ExecutionResult::Batch),
Execution::StartStack(request) => komodo_client()
.execute(request)
.await
+1
View File
@@ -38,6 +38,7 @@ slack.workspace = true
svi.workspace = true
# external
aws-credential-types.workspace = true
tokio-tungstenite.workspace = true
ordered_hash_map.workspace = true
english-to-cron.workspace = true
openidconnect.workspace = true
+3 -4
View File
@@ -24,10 +24,9 @@ RUN cd frontend && yarn link komodo_client && yarn && yarn build
# Final Image
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Setup an application directory
WORKDIR /app
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
## Core deps installer
apt-get update
apt-get install -y git curl ca-certificates
rm -rf /var/lib/apt/lists/*
# Starship prompt
curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin
echo 'export STARSHIP_CONFIG=/config/starship.toml' >> /root/.bashrc
echo 'eval "$(starship init bash)"' >> /root/.bashrc
+3 -4
View File
@@ -15,10 +15,9 @@ FROM ${FRONTEND_IMAGE} AS frontend
# Final Image
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
WORKDIR /app
+3 -4
View File
@@ -16,10 +16,9 @@ RUN cd frontend && yarn link komodo_client && yarn && yarn build
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Copy
COPY ./config/core.config.toml /config/config.toml
-3
View File
@@ -260,9 +260,6 @@ fn resource_link(
ResourceTargetVariant::Action => {
format!("/actions/{id}")
}
ResourceTargetVariant::ServerTemplate => {
format!("/server-templates/{id}")
}
ResourceTargetVariant::ResourceSync => {
format!("/resource-syncs/{id}")
}
+19 -2
View File
@@ -1,11 +1,12 @@
use std::{sync::OnceLock, time::Instant};
use axum::{Router, http::HeaderMap, routing::post};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use typeshare::typeshare;
use uuid::Uuid;
@@ -22,6 +23,8 @@ use crate::{
state::jwt_client,
};
use super::Variant;
pub struct AuthArgs {
pub headers: HeaderMap,
}
@@ -45,7 +48,9 @@ pub enum AuthRequest {
}
pub fn router() -> Router {
let mut router = Router::new().route("/", post(handler));
let mut router = Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler));
if core_config().local_auth {
info!("🔑 Local Login Enabled");
@@ -69,6 +74,18 @@ pub fn router() -> Router {
router
}
async fn variant_handler(
headers: HeaderMap,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: AuthRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(headers, Json(req)).await
}
#[instrument(name = "AuthHandler", level = "debug", skip(headers))]
async fn handler(
headers: HeaderMap,
+9 -3
View File
@@ -134,12 +134,18 @@ impl Resolve<ExecuteArgs> for RunAction {
""
};
let reload = if action.config.reload_deno_deps {
" --reload"
} else {
""
};
let mut res = run_komodo_command(
// 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-all{https_cert_flag} {}",
"deno run --allow-all{https_cert_flag}{reload} {}",
path.display()
),
)
@@ -245,7 +251,7 @@ fn full_contents(contents: &str, key: &str, secret: &str) -> String {
let protocol = if *ssl_enabled { "https" } else { "http" };
let base_url = format!("{protocol}://localhost:{port}");
format!(
"import {{ KomodoClient }} from '{base_url}/client/lib.js';
"import {{ KomodoClient, Types }} from '{base_url}/client/lib.js';
import * as __YAML__ from 'jsr:@std/yaml';
import * as __TOML__ from 'jsr:@std/toml';
@@ -281,7 +287,7 @@ main()
console.error('Status:', error.status);
console.error(JSON.stringify(error.result, null, 2));
}} else {{
console.error(JSON.stringify(error, null, 2));
console.error(error);
}}
Deno.exit(1)
}});"
+20 -5
View File
@@ -1,7 +1,9 @@
use std::{pin::Pin, time::Instant};
use anyhow::Context;
use axum::{Extension, Router, middleware, routing::post};
use axum::{
Extension, Router, extract::Path, middleware, routing::post,
};
use axum_extra::{TypedHeader, headers::ContentType};
use derive_variants::{EnumVariants, ExtractVariant};
use formatting::format_serror;
@@ -18,6 +20,7 @@ use mungos::by_id::find_one_by_id;
use resolver_api::Resolve;
use response::JsonString;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use typeshare::typeshare;
use uuid::Uuid;
@@ -36,10 +39,11 @@ mod deployment;
mod procedure;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
use super::Variant;
pub use {
deployment::pull_deployment_inner, stack::pull_stack_inner,
};
@@ -100,6 +104,7 @@ pub enum ExecuteRequest {
DeployStackIfChanged(DeployStackIfChanged),
BatchDeployStackIfChanged(BatchDeployStackIfChanged),
PullStack(PullStack),
BatchPullStack(BatchPullStack),
StartStack(StartStack),
RestartStack(RestartStack),
StopStack(StopStack),
@@ -130,9 +135,6 @@ pub enum ExecuteRequest {
RunAction(RunAction),
BatchRunAction(BatchRunAction),
// ==== SERVER TEMPLATE ====
LaunchServer(LaunchServer),
// ==== ALERTER ====
TestAlerter(TestAlerter),
@@ -143,9 +145,22 @@ pub enum ExecuteRequest {
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<(TypedHeader<ContentType>, String)> {
let req: ExecuteRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
async fn handler(
Extension(user): Extension<User>,
Json(request): Json<ExecuteRequest>,
-156
View File
@@ -1,156 +0,0 @@
use anyhow::{Context, anyhow};
use formatting::format_serror;
use komodo_client::{
api::{execute::LaunchServer, write::CreateServer},
entities::{
permission::PermissionLevel,
server::PartialServerConfig,
server_template::{ServerTemplate, ServerTemplateConfig},
update::Update,
},
};
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
api::write::WriteArgs,
cloud::{
aws::ec2::launch_ec2_instance, hetzner::launch_hetzner_server,
},
helpers::update::update_update,
resource,
state::db_client,
};
use super::ExecuteArgs;
impl Resolve<ExecuteArgs> for LaunchServer {
#[instrument(name = "LaunchServer", skip(user, update), fields(user_id = user.id, update_id = update.id))]
async fn resolve(
self,
ExecuteArgs { user, update }: &ExecuteArgs,
) -> serror::Result<Update> {
// validate name isn't already taken by another server
if db_client()
.servers
.find_one(doc! {
"name": &self.name
})
.await
.context("failed to query db for servers")?
.is_some()
{
return Err(anyhow!("name is already taken").into());
}
let template = resource::get_check_permissions::<ServerTemplate>(
&self.server_template,
user,
PermissionLevel::Execute,
)
.await?;
let mut update = update.clone();
update.push_simple_log(
"launching server",
format!("{:#?}", template.config),
);
update_update(update.clone()).await?;
let config = match template.config {
ServerTemplateConfig::Aws(config) => {
let region = config.region.clone();
let use_https = config.use_https;
let port = config.port;
let instance =
match launch_ec2_instance(&self.name, config).await {
Ok(instance) => instance,
Err(e) => {
update.push_error_log(
"launch server",
format!("failed to launch aws instance\n\n{e:#?}"),
);
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
}
};
update.push_simple_log(
"launch server",
format!(
"successfully launched server {} on ip {}",
self.name, instance.ip
),
);
let protocol = if use_https { "https" } else { "http" };
PartialServerConfig {
address: format!("{protocol}://{}:{port}", instance.ip)
.into(),
region: region.into(),
..Default::default()
}
}
ServerTemplateConfig::Hetzner(config) => {
let datacenter = config.datacenter;
let use_https = config.use_https;
let port = config.port;
let server =
match launch_hetzner_server(&self.name, config).await {
Ok(server) => server,
Err(e) => {
update.push_error_log(
"launch server",
format!("failed to launch hetzner server\n\n{e:#?}"),
);
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
}
};
update.push_simple_log(
"launch server",
format!(
"successfully launched server {} on ip {}",
self.name, server.ip
),
);
let protocol = if use_https { "https" } else { "http" };
PartialServerConfig {
address: format!("{protocol}://{}:{port}", server.ip)
.into(),
region: datacenter.as_ref().to_string().into(),
..Default::default()
}
}
};
match (CreateServer {
name: self.name,
config,
})
.resolve(&WriteArgs { user: user.clone() })
.await
{
Ok(server) => {
update.push_simple_log(
"create server",
format!("created server {} ({})", server.name, server.id),
);
update.other_data = server.id;
}
Err(e) => {
update.push_error_log(
"create server",
format_serror(
&e.error.context("failed to create server").into(),
),
);
}
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
+26
View File
@@ -385,6 +385,32 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
}
}
impl super::BatchExecute for BatchPullStack {
type Resource = Stack;
fn single_request(stack: String) -> ExecuteRequest {
ExecuteRequest::PullStack(PullStack {
stack,
services: Vec::new(),
})
}
}
impl Resolve<ExecuteArgs> for BatchPullStack {
#[instrument(name = "BatchPullStack", skip(user), fields(user_id = user.id))]
async fn resolve(
self,
ExecuteArgs { user, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchPullStack>(
&self.pattern,
user,
)
.await?,
)
}
}
pub async fn pull_stack_inner(
mut stack: Stack,
services: Vec<String>,
-25
View File
@@ -16,7 +16,6 @@ use komodo_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
update::{Log, Update},
@@ -142,10 +141,6 @@ impl Resolve<ExecuteArgs> for RunSync {
.servers
.get(&name_or_id)
.map(|s| s.name.clone()),
ResourceTargetVariant::ServerTemplate => all_resources
.templates
.get(&name_or_id)
.map(|t| t.name.clone()),
ResourceTargetVariant::Stack => all_resources
.stacks
.get(&name_or_id)
@@ -332,20 +327,6 @@ impl Resolve<ExecuteArgs> for RunSync {
} else {
Default::default()
};
let server_template_deltas = if sync.config.include_resources {
get_updates_for_execution::<ServerTemplate>(
resources.server_templates,
delete,
&all_resources,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?
} else {
Default::default()
};
let resource_sync_deltas = if sync.config.include_resources {
get_updates_for_execution::<entities::sync::ResourceSync>(
resources.resource_syncs,
@@ -397,7 +378,6 @@ impl Resolve<ExecuteArgs> for RunSync {
if deploy_cache.is_empty()
&& resource_sync_deltas.no_changes()
&& server_template_deltas.no_changes()
&& server_deltas.no_changes()
&& deployment_deltas.no_changes()
&& stack_deltas.no_changes()
@@ -451,11 +431,6 @@ impl Resolve<ExecuteArgs> for RunSync {
&mut update.logs,
ResourceSync::execute_sync_updates(resource_sync_deltas).await,
);
maybe_extend(
&mut update.logs,
ServerTemplate::execute_sync_updates(server_template_deltas)
.await,
);
maybe_extend(
&mut update.logs,
Server::execute_sync_updates(server_deltas).await,
+6
View File
@@ -1,5 +1,11 @@
pub mod auth;
pub mod execute;
pub mod read;
pub mod terminal;
pub mod user;
pub mod write;
#[derive(serde::Deserialize)]
struct Variant {
variant: String,
}
+21 -8
View File
@@ -1,7 +1,9 @@
use std::{collections::HashSet, sync::OnceLock, time::Instant};
use anyhow::{Context, anyhow};
use axum::{Extension, Router, middleware, routing::post};
use axum::{
Extension, Router, extract::Path, middleware, routing::post,
};
use komodo_client::{
api::read::*,
entities::{
@@ -18,6 +20,7 @@ use komodo_client::{
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use typeshare::typeshare;
use uuid::Uuid;
@@ -27,6 +30,8 @@ use crate::{
resource,
};
use super::Variant;
mod action;
mod alert;
mod alerter;
@@ -38,7 +43,6 @@ mod procedure;
mod provider;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
mod tag;
@@ -93,12 +97,6 @@ enum ReadRequest {
ListActions(ListActions),
ListFullActions(ListFullActions),
// ==== SERVER TEMPLATE ====
GetServerTemplate(GetServerTemplate),
GetServerTemplatesSummary(GetServerTemplatesSummary),
ListServerTemplates(ListServerTemplates),
ListFullServerTemplates(ListFullServerTemplates),
// ==== SERVER ====
GetServersSummary(GetServersSummary),
GetServer(GetServer),
@@ -123,6 +121,7 @@ enum ReadRequest {
ListDockerImages(ListDockerImages),
ListDockerVolumes(ListDockerVolumes),
ListComposeProjects(ListComposeProjects),
ListTerminals(ListTerminals),
// ==== DEPLOYMENT ====
GetDeploymentsSummary(GetDeploymentsSummary),
@@ -223,9 +222,22 @@ enum ReadRequest {
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: ReadRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
#[instrument(name = "ReadHandler", level = "debug", skip(user), fields(user_id = user.id))]
async fn handler(
Extension(user): Extension<User>,
@@ -270,6 +282,7 @@ fn core_info() -> &'static GetCoreInfoResponse {
ui_write_disabled: config.ui_write_disabled,
disable_confirm_dialog: config.disable_confirm_dialog,
disable_non_admin_create: config.disable_non_admin_create,
disable_websocket_reconnect: config.disable_websocket_reconnect,
github_webhook_owners: config
.github_webhook_app
.installations
+70 -30
View File
@@ -21,9 +21,11 @@ use komodo_client::{
network::Network,
volume::Volume,
},
komodo_timestamp,
permission::PermissionLevel,
server::{
Server, ServerActionState, ServerListItem, ServerState,
TerminalInfo,
},
stack::{Stack, StackServiceNames},
stats::{SystemInformation, SystemProcess},
@@ -45,7 +47,10 @@ use resolver_api::Resolve;
use tokio::sync::Mutex;
use crate::{
helpers::{periphery_client, query::get_all_tags},
helpers::{
periphery_client,
query::{get_all_tags, get_system_info},
},
resource,
stack::compose_container_match_regex,
state::{action_states, db_client, server_status_cache},
@@ -198,16 +203,6 @@ impl Resolve<ReadArgs> for GetServerActionState {
}
}
// This protects the peripheries from spam requests
const SYSTEM_INFO_EXPIRY: u128 = FIFTEEN_SECONDS_MS;
type SystemInfoCache =
Mutex<HashMap<String, Arc<(SystemInformation, u128)>>>;
fn system_info_cache() -> &'static SystemInfoCache {
static SYSTEM_INFO_CACHE: OnceLock<SystemInfoCache> =
OnceLock::new();
SYSTEM_INFO_CACHE.get_or_init(Default::default)
}
impl Resolve<ReadArgs> for GetSystemInformation {
async fn resolve(
self,
@@ -219,25 +214,7 @@ impl Resolve<ReadArgs> for GetSystemInformation {
PermissionLevel::Read,
)
.await?;
let mut lock = system_info_cache().lock().await;
let res = match lock.get(&server.id) {
Some(cached) if cached.1 > unix_timestamp_ms() => {
cached.0.clone()
}
_ => {
let stats = periphery_client(&server)?
.request(periphery::stats::GetSystemInformation {})
.await?;
lock.insert(
server.id,
(stats.clone(), unix_timestamp_ms() + SYSTEM_INFO_EXPIRY)
.into(),
);
stats
}
};
Ok(res)
get_system_info(&server).await.map_err(Into::into)
}
}
@@ -812,3 +789,66 @@ impl Resolve<ReadArgs> for ListComposeProjects {
}
}
}
#[derive(Default)]
struct TerminalCacheItem {
list: Vec<TerminalInfo>,
ttl: i64,
}
const TERMINAL_CACHE_TIMEOUT: i64 = 30_000;
#[derive(Default)]
struct TerminalCache(
std::sync::Mutex<
HashMap<String, Arc<tokio::sync::Mutex<TerminalCacheItem>>>,
>,
);
impl TerminalCache {
fn get_or_insert(
&self,
server_id: String,
) -> Arc<tokio::sync::Mutex<TerminalCacheItem>> {
if let Some(cached) =
self.0.lock().unwrap().get(&server_id).cloned()
{
return cached;
}
let to_cache =
Arc::new(tokio::sync::Mutex::new(TerminalCacheItem::default()));
self.0.lock().unwrap().insert(server_id, to_cache.clone());
to_cache
}
}
fn terminals_cache() -> &'static TerminalCache {
static TERMINALS: OnceLock<TerminalCache> = OnceLock::new();
TERMINALS.get_or_init(Default::default)
}
impl Resolve<ReadArgs> for ListTerminals {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListTerminalsResponse> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Read,
)
.await?;
let cache = terminals_cache().get_or_insert(server.id.clone());
let mut cache = cache.lock().await;
if self.fresh || komodo_timestamp() > cache.ttl {
cache.list = periphery_client(&server)?
.request(periphery_client::api::terminal::ListTerminals {})
.await
.context("Failed to get fresh terminal list")?;
cache.ttl = komodo_timestamp() + TERMINAL_CACHE_TIMEOUT;
Ok(cache.list.clone())
} else {
Ok(cache.list.clone())
}
}
}
-97
View File
@@ -1,97 +0,0 @@
use anyhow::Context;
use komodo_client::{
api::read::*,
entities::{
permission::PermissionLevel, server_template::ServerTemplate,
},
};
use mongo_indexed::Document;
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
helpers::query::get_all_tags, resource, state::db_client,
};
use super::ReadArgs;
impl Resolve<ReadArgs> for GetServerTemplate {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetServerTemplateResponse> {
Ok(
resource::get_check_permissions::<ServerTemplate>(
&self.server_template,
user,
PermissionLevel::Read,
)
.await?,
)
}
}
impl Resolve<ReadArgs> for ListServerTemplates {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListServerTemplatesResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<ServerTemplate>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ReadArgs> for ListFullServerTemplates {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullServerTemplatesResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<ServerTemplate>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ReadArgs> for GetServerTemplatesSummary {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetServerTemplatesSummaryResponse> {
let query = match resource::get_resource_object_ids_for_user::<
ServerTemplate,
>(user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.server_templates
.count_documents(query)
.await
.context("failed to count all server template documents")?;
let res = GetServerTemplatesSummaryResponse {
total: total as u32,
};
Ok(res)
}
}
+1 -34
View File
@@ -9,8 +9,7 @@ use komodo_client::{
ResourceTarget, action::Action, alerter::Alerter, build::Build,
builder::Builder, deployment::Deployment,
permission::PermissionLevel, procedure::Procedure, repo::Repo,
resource::ResourceQuery, server::Server,
server_template::ServerTemplate, stack::Stack,
resource::ResourceQuery, server::Server, stack::Stack,
sync::ResourceSync, toml::ResourcesToml, user::User,
},
};
@@ -132,16 +131,6 @@ async fn get_all_targets(
.into_iter()
.map(|resource| ResourceTarget::Action(resource.id)),
);
targets.extend(
resource::list_for_user::<ServerTemplate>(
ResourceQuery::builder().tags(tags).build(),
user,
&all_tags,
)
.await?
.into_iter()
.map(|resource| ResourceTarget::ServerTemplate(resource.id)),
);
targets.extend(
resource::list_full_for_user::<ResourceSync>(
ResourceQuery::builder().tags(tags).build(),
@@ -241,20 +230,6 @@ impl Resolve<ReadArgs> for ExportResourcesToToml {
))
}
}
ResourceTarget::ServerTemplate(id) => {
let template = resource::get_check_permissions::<
ServerTemplate,
>(&id, user, PermissionLevel::Read)
.await?;
res.server_templates.push(
convert_resource::<ServerTemplate>(
template,
false,
vec![],
&id_to_tags,
),
)
}
ResourceTarget::Server(id) => {
let server = resource::get_check_permissions::<Server>(
&id,
@@ -503,14 +478,6 @@ fn serialize_resources_toml(
Builder::push_to_toml_string(builder, &mut toml)?;
}
for server_template in resources.server_templates {
if !toml.is_empty() {
toml.push_str("\n\n##\n\n");
}
toml.push_str("[[server_template]]\n");
ServerTemplate::push_to_toml_string(server_template, &mut toml)?;
}
for resource_sync in resources.resource_syncs {
if !toml.is_empty() {
toml.push_str("\n\n##\n\n");
-20
View File
@@ -14,7 +14,6 @@ use komodo_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
update::{Update, UpdateListItem},
@@ -132,16 +131,6 @@ impl Resolve<ReadArgs> for ListUpdates {
})
.unwrap_or_else(|| doc! { "target.type": "Alerter" });
let server_template_query =
resource::get_resource_ids_for_user::<ServerTemplate>(user)
.await?
.map(|ids| {
doc! {
"target.type": "ServerTemplate", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "ServerTemplate" });
let resource_sync_query =
resource::get_resource_ids_for_user::<ResourceSync>(
user,
@@ -166,7 +155,6 @@ impl Resolve<ReadArgs> for ListUpdates {
action_query,
alerter_query,
builder_query,
server_template_query,
resource_sync_query,
]
});
@@ -308,14 +296,6 @@ impl Resolve<ReadArgs> for GetUpdate {
)
.await?;
}
ResourceTarget::ServerTemplate(id) => {
resource::get_check_permissions::<ServerTemplate>(
id,
user,
PermissionLevel::Read,
)
.await?;
}
ResourceTarget::ResourceSync(id) => {
resource::get_check_permissions::<ResourceSync>(
id,
+75
View File
@@ -0,0 +1,75 @@
use anyhow::Context;
use axum::{Extension, Router, middleware, routing::post};
use komodo_client::{
api::terminal::ExecuteTerminalBody,
entities::{
permission::PermissionLevel, server::Server, user::User,
},
};
use serror::Json;
use uuid::Uuid;
use crate::{
auth::auth_request, helpers::periphery_client, resource,
};
pub fn router() -> Router {
Router::new()
.route("/execute", post(execute))
.layer(middleware::from_fn(auth_request))
}
async fn execute(
Extension(user): Extension<User>,
Json(request): Json<ExecuteTerminalBody>,
) -> serror::Result<axum::body::Body> {
execute_inner(Uuid::new_v4(), request, user).await
}
#[instrument(
name = "ExecuteTerminal",
skip(user),
fields(
user_id = user.id,
)
)]
async fn execute_inner(
req_id: Uuid,
ExecuteTerminalBody {
server,
terminal,
command,
}: ExecuteTerminalBody,
user: User,
) -> serror::Result<axum::body::Body> {
info!("/terminal request | user: {}", user.username);
let res = async {
let server = resource::get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
let stream = periphery
.execute_terminal(terminal, command)
.await
.context("Failed to execute command on periphery")?;
anyhow::Ok(stream)
}
.await;
let stream = match res {
Ok(stream) => stream,
Err(e) => {
warn!("/terminal request {req_id} error: {e:#}");
return Err(e.into());
}
};
Ok(axum::body::Body::from_stream(stream.into_line_stream()))
}
+19 -1
View File
@@ -1,7 +1,9 @@
use std::{collections::VecDeque, time::Instant};
use anyhow::{Context, anyhow};
use axum::{Extension, Json, Router, middleware, routing::post};
use axum::{
Extension, Json, Router, extract::Path, middleware, routing::post,
};
use derive_variants::EnumVariants;
use komodo_client::{
api::user::*,
@@ -12,6 +14,7 @@ use mungos::{by_id::update_one_by_id, mongodb::bson::to_bson};
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use typeshare::typeshare;
use uuid::Uuid;
@@ -21,6 +24,8 @@ use crate::{
state::db_client,
};
use super::Variant;
pub struct UserArgs {
pub user: User,
}
@@ -43,9 +48,22 @@ enum UserRequest {
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: UserRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
#[instrument(name = "UserHandler", level = "debug", skip(user))]
async fn handler(
Extension(user): Extension<User>,
+1 -10
View File
@@ -4,8 +4,7 @@ use komodo_client::{
entities::{
ResourceTarget, action::Action, alerter::Alerter, build::Build,
builder::Builder, deployment::Deployment, procedure::Procedure,
repo::Repo, server::Server, server_template::ServerTemplate,
stack::Stack, sync::ResourceSync,
repo::Repo, server::Server, stack::Stack, sync::ResourceSync,
},
};
use resolver_api::Resolve;
@@ -93,14 +92,6 @@ impl Resolve<WriteArgs> for UpdateDescription {
)
.await?;
}
ResourceTarget::ServerTemplate(id) => {
resource::update_description::<ServerTemplate>(
&id,
&self.description,
user,
)
.await?;
}
ResourceTarget::ResourceSync(id) => {
resource::update_description::<ResourceSync>(
&id,
+22 -13
View File
@@ -1,18 +1,23 @@
use std::time::Instant;
use anyhow::Context;
use axum::{Extension, Router, middleware, routing::post};
use axum::{
Extension, Router, extract::Path, middleware, routing::post,
};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::write::*, entities::user::User};
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use typeshare::typeshare;
use uuid::Uuid;
use crate::auth::auth_request;
use super::Variant;
mod action;
mod alerter;
mod build;
@@ -24,7 +29,6 @@ mod procedure;
mod provider;
mod repo;
mod server;
mod server_template;
mod service_user;
mod stack;
mod sync;
@@ -81,6 +85,9 @@ pub enum WriteRequest {
UpdateServer(UpdateServer),
RenameServer(RenameServer),
CreateNetwork(CreateNetwork),
CreateTerminal(CreateTerminal),
DeleteTerminal(DeleteTerminal),
DeleteAllTerminals(DeleteAllTerminals),
// ==== DEPLOYMENT ====
CreateDeployment(CreateDeployment),
@@ -108,13 +115,6 @@ pub enum WriteRequest {
UpdateBuilder(UpdateBuilder),
RenameBuilder(RenameBuilder),
// ==== SERVER TEMPLATE ====
CreateServerTemplate(CreateServerTemplate),
CopyServerTemplate(CopyServerTemplate),
DeleteServerTemplate(DeleteServerTemplate),
UpdateServerTemplate(UpdateServerTemplate),
RenameServerTemplate(RenameServerTemplate),
// ==== REPO ====
CreateRepo(CreateRepo),
CopyRepo(CopyRepo),
@@ -195,9 +195,22 @@ pub enum WriteRequest {
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: WriteRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
async fn handler(
Extension(user): Extension<User>,
Json(request): Json<WriteRequest>,
@@ -208,10 +221,6 @@ async fn handler(
.await
.context("failure in spawned task");
if let Err(e) = &res {
warn!("/write request {req_id} spawn error: {e:#}");
}
res?
}
-14
View File
@@ -406,20 +406,6 @@ async fn extract_resource_target_with_validation(
.id;
Ok((ResourceTargetVariant::Action, id))
}
ResourceTarget::ServerTemplate(ident) => {
let filter = match ObjectId::from_str(ident) {
Ok(id) => doc! { "_id": id },
Err(_) => doc! { "name": ident },
};
let id = db_client()
.server_templates
.find_one(filter)
.await
.context("failed to query db for server templates")?
.context("no matching server template found")?
.id;
Ok((ResourceTargetVariant::ServerTemplate, id))
}
ResourceTarget::ResourceSync(ident) => {
let filter = match ObjectId::from_str(ident) {
Ok(id) => doc! { "_id": id },
+80 -1
View File
@@ -1,8 +1,9 @@
use anyhow::Context;
use formatting::format_serror;
use komodo_client::{
api::write::*,
entities::{
Operation,
NoData, Operation,
permission::PermissionLevel,
server::Server,
update::{Update, UpdateStatus},
@@ -101,3 +102,81 @@ impl Resolve<WriteArgs> for CreateNetwork {
Ok(update)
}
}
impl Resolve<WriteArgs> for CreateTerminal {
#[instrument(name = "CreateTerminal", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::CreateTerminal {
name: self.name,
command: self.command,
recreate: self.recreate,
})
.await
.context("Failed to create terminal on periphery")?;
Ok(NoData {})
}
}
impl Resolve<WriteArgs> for DeleteTerminal {
#[instrument(name = "DeleteTerminal", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::DeleteTerminal {
terminal: self.terminal,
})
.await
.context("Failed to delete terminal on periphery")?;
Ok(NoData {})
}
}
impl Resolve<WriteArgs> for DeleteAllTerminals {
#[instrument(name = "DeleteAllTerminals", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::DeleteAllTerminals {})
.await
.context("Failed to delete all terminals on periphery")?;
Ok(NoData {})
}
}
-92
View File
@@ -1,92 +0,0 @@
use komodo_client::{
api::write::{
CopyServerTemplate, CreateServerTemplate, DeleteServerTemplate,
RenameServerTemplate, UpdateServerTemplate,
},
entities::{
permission::PermissionLevel, server_template::ServerTemplate,
update::Update,
},
};
use resolver_api::Resolve;
use crate::resource;
use super::WriteArgs;
impl Resolve<WriteArgs> for CreateServerTemplate {
#[instrument(name = "CreateServerTemplate", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ServerTemplate> {
Ok(
resource::create::<ServerTemplate>(
&self.name,
self.config,
user,
)
.await?,
)
}
}
impl Resolve<WriteArgs> for CopyServerTemplate {
#[instrument(name = "CopyServerTemplate", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ServerTemplate> {
let ServerTemplate { config, .. } =
resource::get_check_permissions::<ServerTemplate>(
&self.id,
user,
PermissionLevel::Write,
)
.await?;
Ok(
resource::create::<ServerTemplate>(
&self.name,
config.into(),
user,
)
.await?,
)
}
}
impl Resolve<WriteArgs> for DeleteServerTemplate {
#[instrument(name = "DeleteServerTemplate", skip(args))]
async fn resolve(
self,
args: &WriteArgs,
) -> serror::Result<ServerTemplate> {
Ok(resource::delete::<ServerTemplate>(&self.id, args).await?)
}
}
impl Resolve<WriteArgs> for UpdateServerTemplate {
#[instrument(name = "UpdateServerTemplate", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ServerTemplate> {
Ok(
resource::update::<ServerTemplate>(&self.id, self.config, user)
.await?,
)
}
}
impl Resolve<WriteArgs> for RenameServerTemplate {
#[instrument(name = "RenameServerTemplate", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
Ok(
resource::rename::<ServerTemplate>(&self.id, &self.name, user)
.await?,
)
}
}
-12
View File
@@ -19,7 +19,6 @@ use komodo_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::{
PartialResourceSyncConfig, ResourceSync, ResourceSyncInfo,
@@ -686,17 +685,6 @@ impl Resolve<WriteArgs> for RefreshResourceSyncPending {
&mut diffs,
)
.await?;
push_updates_for_view::<ServerTemplate>(
resources.server_templates,
delete,
&all_resources,
None,
None,
&id_to_tags,
&sync.config.match_tags,
&mut diffs,
)
.await?;
push_updates_for_view::<ResourceSync>(
resources.resource_syncs,
delete,
-12
View File
@@ -17,7 +17,6 @@ use komodo_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
tag::{Tag, TagColor},
@@ -131,7 +130,6 @@ impl Resolve<WriteArgs> for DeleteTag {
resource::remove_tag_from_all::<Builder>(&self.id),
resource::remove_tag_from_all::<Alerter>(&self.id),
resource::remove_tag_from_all::<Procedure>(&self.id),
resource::remove_tag_from_all::<ServerTemplate>(&self.id),
)?;
delete_one_by_id(&db_client().tags, &self.id, None).await?;
@@ -225,16 +223,6 @@ impl Resolve<WriteArgs> for UpdateTagsOnResource {
.await?;
resource::update_tags::<Action>(&id, self.tags, args).await?
}
ResourceTarget::ServerTemplate(id) => {
resource::get_check_permissions::<ServerTemplate>(
&id,
user,
PermissionLevel::Write,
)
.await?;
resource::update_tags::<ServerTemplate>(&id, self.tags, args)
.await?
}
ResourceTarget::ResourceSync(id) => {
resource::get_check_permissions::<ResourceSync>(
&id,
+23 -29
View File
@@ -1,4 +1,4 @@
use std::{str::FromStr, time::Duration};
use std::time::Duration;
use anyhow::{Context, anyhow};
use aws_config::{BehaviorVersion, Region};
@@ -8,15 +8,15 @@ use aws_sdk_ec2::{
BlockDeviceMapping, EbsBlockDevice,
InstanceNetworkInterfaceSpecification, InstanceStateChange,
InstanceStateName, InstanceStatus, InstanceType, ResourceType,
Tag, TagSpecification, VolumeType,
Tag, TagSpecification,
},
};
use base64::Engine;
use komodo_client::entities::{
ResourceTarget,
alert::{Alert, AlertData, SeverityLevel},
builder::AwsBuilderConfig,
komodo_timestamp,
server_template::aws::AwsServerTemplateConfig,
};
use crate::{alert::send_alerts, config::core_config};
@@ -71,12 +71,12 @@ async fn create_ec2_client(region: String) -> Client {
#[instrument]
pub async fn launch_ec2_instance(
name: &str,
config: AwsServerTemplateConfig,
config: &AwsBuilderConfig,
) -> anyhow::Result<Ec2Instance> {
let AwsServerTemplateConfig {
let AwsBuilderConfig {
region,
instance_type,
volumes,
volume_gb,
ami_id,
subnet_id,
security_group_ids,
@@ -86,19 +86,22 @@ pub async fn launch_ec2_instance(
user_data,
port: _,
use_https: _,
git_providers: _,
docker_registries: _,
secrets: _,
} = config;
let instance_type = handle_unknown_instance_type(
InstanceType::from(instance_type.as_str()),
)?;
let client = create_ec2_client(region.clone()).await;
let mut req = client
let req = client
.run_instances()
.image_id(ami_id)
.instance_type(instance_type)
.network_interfaces(
InstanceNetworkInterfaceSpecification::builder()
.subnet_id(subnet_id)
.associate_public_ip_address(assign_public_ip)
.associate_public_ip_address(*assign_public_ip)
.set_groups(security_group_ids.to_vec().into())
.device_index(0)
.build(),
@@ -110,6 +113,17 @@ pub async fn launch_ec2_instance(
.resource_type(ResourceType::Instance)
.build(),
)
.block_device_mappings(
BlockDeviceMapping::builder()
.set_device_name("/dev/sda1".to_string().into())
.set_ebs(
EbsBlockDevice::builder()
.volume_size(*volume_gb)
.build()
.into(),
)
.build(),
)
.min_count(1)
.max_count(1)
.user_data(
@@ -117,26 +131,6 @@ pub async fn launch_ec2_instance(
.encode(user_data),
);
for volume in volumes {
let ebs = EbsBlockDevice::builder()
.volume_size(volume.size_gb)
.volume_type(
VolumeType::from_str(volume.volume_type.as_ref())
.context("invalid volume type")?,
)
.set_iops((volume.iops != 0).then_some(volume.iops))
.set_throughput(
(volume.throughput != 0).then_some(volume.throughput),
)
.build();
req = req.block_device_mappings(
BlockDeviceMapping::builder()
.set_device_name(volume.device_name.into())
.set_ebs(ebs.into())
.build(),
)
}
let res = req
.send()
.await
@@ -156,7 +150,7 @@ pub async fn launch_ec2_instance(
let state_name =
get_ec2_instance_state_name(&client, &instance_id).await?;
if state_name == Some(InstanceStateName::Running) {
let ip = if use_public_ip {
let ip = if *use_public_ip {
get_ec2_instance_public_ip(&client, &instance_id).await?
} else {
instance
-157
View File
@@ -1,157 +0,0 @@
use anyhow::{Context, anyhow};
use axum::http::{HeaderName, HeaderValue};
use reqwest::{RequestBuilder, StatusCode};
use serde::{Serialize, de::DeserializeOwned};
use super::{
common::{
HetznerActionResponse, HetznerDatacenterResponse,
HetznerServerResponse, HetznerVolumeResponse,
},
create_server::{CreateServerBody, CreateServerResponse},
create_volume::{CreateVolumeBody, CreateVolumeResponse},
};
const BASE_URL: &str = "https://api.hetzner.cloud/v1";
pub struct HetznerClient(reqwest::Client);
impl HetznerClient {
pub fn new(token: &str) -> HetznerClient {
HetznerClient(
reqwest::ClientBuilder::new()
.default_headers(
[(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&format!("Bearer {token}"))
.unwrap(),
)]
.into_iter()
.collect(),
)
.build()
.context("failed to build Hetzner request client")
.unwrap(),
)
}
pub async fn get_server(
&self,
id: i64,
) -> anyhow::Result<HetznerServerResponse> {
self.get(&format!("/servers/{id}")).await
}
pub async fn create_server(
&self,
body: &CreateServerBody,
) -> anyhow::Result<CreateServerResponse> {
self.post("/servers", body).await
}
#[allow(unused)]
pub async fn delete_server(
&self,
id: i64,
) -> anyhow::Result<HetznerActionResponse> {
self.delete(&format!("/servers/{id}")).await
}
pub async fn get_volume(
&self,
id: i64,
) -> anyhow::Result<HetznerVolumeResponse> {
self.get(&format!("/volumes/{id}")).await
}
pub async fn create_volume(
&self,
body: &CreateVolumeBody,
) -> anyhow::Result<CreateVolumeResponse> {
self.post("/volumes", body).await
}
#[allow(unused)]
pub async fn delete_volume(&self, id: i64) -> anyhow::Result<()> {
let res = self
.0
.delete(format!("{BASE_URL}/volumes/{id}"))
.send()
.await
.context("failed at request to delete volume")?;
let status = res.status();
if status == StatusCode::NO_CONTENT {
Ok(())
} else {
let text = res
.text()
.await
.context("failed to get response body as text")?;
Err(anyhow!("{status} | {text}"))
}
}
#[allow(unused)]
pub async fn list_datacenters(
&self,
) -> anyhow::Result<HetznerDatacenterResponse> {
self.get("/datacenters").await
}
async fn get<Res: DeserializeOwned>(
&self,
path: &str,
) -> anyhow::Result<Res> {
let req = self.0.get(format!("{BASE_URL}{path}"));
handle_req(req).await.with_context(|| {
format!("failed at GET request to Hetzner | path: {path}")
})
}
async fn post<Body: Serialize, Res: DeserializeOwned>(
&self,
path: &str,
body: &Body,
) -> anyhow::Result<Res> {
let req = self.0.post(format!("{BASE_URL}{path}")).json(&body);
handle_req(req).await.with_context(|| {
format!("failed at POST request to Hetzner | path: {path}")
})
}
#[allow(unused)]
async fn delete<Res: DeserializeOwned>(
&self,
path: &str,
) -> anyhow::Result<Res> {
let req = self.0.delete(format!("{BASE_URL}{path}"));
handle_req(req).await.with_context(|| {
format!("failed at DELETE request to Hetzner | path: {path}")
})
}
}
async fn handle_req<Res: DeserializeOwned>(
req: RequestBuilder,
) -> anyhow::Result<Res> {
let res = req.send().await?;
let status = res.status();
if status.is_success() {
res.json().await.context("failed to parse response to json")
} else {
let text = res
.text()
.await
.context("failed to get response body as text")?;
if let Ok(json_error) =
serde_json::from_str::<serde_json::Value>(&text)
{
return Err(anyhow!("{status} | {json_error:?}"));
}
Err(anyhow!("{status} | {text}"))
}
}
-280
View File
@@ -1,280 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerServerResponse {
pub server: HetznerServer,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerServer {
pub id: i64,
pub name: String,
pub primary_disk_size: f64,
pub image: Option<HetznerImage>,
pub private_net: Vec<HetznerPrivateNet>,
pub public_net: HetznerPublicNet,
pub server_type: HetznerServerTypeDetails,
pub status: HetznerServerStatus,
#[serde(default)]
pub volumes: Vec<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerServerTypeDetails {
pub architecture: String,
pub cores: i64,
pub cpu_type: String,
pub description: String,
pub disk: f64,
pub id: i64,
pub memory: f64,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerPrivateNet {
pub alias_ips: Vec<String>,
pub ip: String,
pub mac_address: String,
pub network: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerPublicNet {
#[serde(default)]
pub firewalls: Vec<HetznerFirewall>,
pub floating_ips: Vec<i64>,
pub ipv4: Option<HetznerIpv4>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerFirewall {
pub id: i64,
pub status: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerIpv4 {
pub id: Option<i64>,
pub blocked: bool,
pub dns_ptr: String,
pub ip: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerImage {
pub id: i64,
pub description: String,
pub name: Option<String>,
pub os_flavor: String,
pub os_version: Option<String>,
pub rapid_deploy: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerActionResponse {
pub action: HetznerAction,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerAction {
pub command: String,
pub error: Option<HetznerError>,
pub finished: Option<String>,
pub id: i64,
pub progress: i32,
pub resources: Vec<HetznerResource>,
pub started: String,
pub status: HetznerActionStatus,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerResource {
pub id: i64,
#[serde(rename = "type")]
pub ty: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerVolumeResponse {
pub volume: HetznerVolume,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerVolume {
/// Name of the Resource. Must be unique per Project.
pub name: String,
/// Point in time when the Resource was created (in ISO-8601 format).
pub created: String,
/// Filesystem of the Volume if formatted on creation, null if not formatted on creation
pub format: Option<HetznerVolumeFormat>,
/// ID of the Volume.
pub id: i64,
/// User-defined labels ( key/value pairs) for the Resource
pub labels: HashMap<String, String>,
/// Device path on the file system for the Volume
pub linux_device: String,
/// Protection configuration for the Resource.
pub protection: HetznerProtection,
/// ID of the Server the Volume is attached to, null if it is not attached at all
pub server: Option<i64>,
/// Size in GB of the Volume
pub size: i64,
/// Current status of the Volume. Allowed: `creating`, `available`
pub status: HetznerVolumeStatus,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerProtection {
/// Prevent the Resource from being deleted.
pub delete: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerDatacenterResponse {
pub datacenters: Vec<HetznerDatacenterDetails>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HetznerDatacenterDetails {
pub id: i64,
pub name: String,
pub location: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HetznerLocation {
#[serde(rename = "nbg1")]
Nuremberg1,
#[serde(rename = "hel1")]
Helsinki1,
#[serde(rename = "fsn1")]
Falkenstein1,
#[serde(rename = "ash")]
Ashburn,
#[serde(rename = "hil")]
Hillsboro,
#[serde(rename = "sin")]
Singapore,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum HetznerDatacenter {
#[serde(rename = "nbg1-dc3")]
Nuremberg1Dc3,
#[serde(rename = "hel1-dc2")]
Helsinki1Dc2,
#[serde(rename = "fsn1-dc14")]
Falkenstein1Dc14,
#[serde(rename = "ash-dc1")]
AshburnDc1,
#[serde(rename = "hil-dc1")]
HillsboroDc1,
#[serde(rename = "sin-dc1")]
SingaporeDc1,
}
impl From<HetznerDatacenter> for HetznerLocation {
fn from(value: HetznerDatacenter) -> Self {
match value {
HetznerDatacenter::Nuremberg1Dc3 => HetznerLocation::Nuremberg1,
HetznerDatacenter::Helsinki1Dc2 => HetznerLocation::Helsinki1,
HetznerDatacenter::Falkenstein1Dc14 => {
HetznerLocation::Falkenstein1
}
HetznerDatacenter::AshburnDc1 => HetznerLocation::Ashburn,
HetznerDatacenter::HillsboroDc1 => HetznerLocation::Hillsboro,
HetznerDatacenter::SingaporeDc1 => HetznerLocation::Singapore,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HetznerVolumeFormat {
Xfs,
Ext4,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HetznerVolumeStatus {
Creating,
Available,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HetznerServerStatus {
Running,
Initializing,
Starting,
Stopping,
Off,
Deleting,
Migrating,
Rebuilding,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HetznerActionStatus {
Running,
Success,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[allow(clippy::enum_variant_names)]
pub enum HetznerServerType {
// Shared
#[serde(rename = "cpx11")]
SharedAmd2Core2Ram40Disk,
#[serde(rename = "cax11")]
SharedArm2Core4Ram40Disk,
#[serde(rename = "cx22")]
SharedIntel2Core4Ram40Disk,
#[serde(rename = "cpx21")]
SharedAmd3Core4Ram80Disk,
#[serde(rename = "cax21")]
SharedArm4Core8Ram80Disk,
#[serde(rename = "cx32")]
SharedIntel4Core8Ram80Disk,
#[serde(rename = "cpx31")]
SharedAmd4Core8Ram160Disk,
#[serde(rename = "cax31")]
SharedArm8Core16Ram160Disk,
#[serde(rename = "cx42")]
SharedIntel8Core16Ram160Disk,
#[serde(rename = "cpx41")]
SharedAmd8Core16Ram240Disk,
#[serde(rename = "cax41")]
SharedArm16Core32Ram320Disk,
#[serde(rename = "cx52")]
SharedIntel16Core32Ram320Disk,
#[serde(rename = "cpx51")]
SharedAmd16Core32Ram360Disk,
// Dedicated
#[serde(rename = "ccx13")]
DedicatedAmd2Core8Ram80Disk,
#[serde(rename = "ccx23")]
DedicatedAmd4Core16Ram160Disk,
#[serde(rename = "ccx33")]
DedicatedAmd8Core32Ram240Disk,
#[serde(rename = "ccx43")]
DedicatedAmd16Core64Ram360Disk,
#[serde(rename = "ccx53")]
DedicatedAmd32Core128Ram600Disk,
#[serde(rename = "ccx63")]
DedicatedAmd48Core192Ram960Disk,
}
@@ -1,75 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::common::{
HetznerAction, HetznerDatacenter, HetznerLocation, HetznerServer,
HetznerServerType,
};
#[derive(Debug, Clone, Serialize)]
pub struct CreateServerBody {
/// Name of the Server to create (must be unique per Project and a valid hostname as per RFC 1123)
pub name: String,
/// Auto-mount Volumes after attach
#[serde(skip_serializing_if = "Option::is_none")]
pub automount: Option<bool>,
/// ID or name of Datacenter to create Server in (must not be used together with location)
#[serde(skip_serializing_if = "Option::is_none")]
pub datacenter: Option<HetznerDatacenter>,
/// ID or name of Location to create Server in (must not be used together with datacenter)
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<HetznerLocation>,
/// Firewalls which should be applied on the Server's public network interface at creation time
pub firewalls: Vec<Firewall>,
/// ID or name of the Image the Server is created from
pub image: String,
/// User-defined labels (key-value pairs) for the Resource
pub labels: HashMap<String, String>,
/// Network IDs which should be attached to the Server private network interface at the creation time
pub networks: Vec<i64>,
/// ID of the Placement Group the server should be in
#[serde(skip_serializing_if = "Option::is_none")]
pub placement_group: Option<i64>,
/// Public Network options
pub public_net: PublicNet,
/// ID or name of the Server type this Server should be created with
pub server_type: HetznerServerType,
/// SSH key IDs ( integer ) or names ( string ) which should be injected into the Server at creation time
pub ssh_keys: Vec<String>,
/// This automatically triggers a Power on a Server-Server Action after the creation is finished and is returned in the next_actions response object.
pub start_after_create: bool,
/// Cloud-Init user data to use during Server creation. This field is limited to 32KiB.
#[serde(skip_serializing_if = "Option::is_none")]
pub user_data: Option<String>,
/// Volume IDs which should be attached to the Server at the creation time. Volumes must be in the same Location.
pub volumes: Vec<i64>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct Firewall {
/// ID of the Firewall
pub firewall: i64,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct PublicNet {
/// Attach an IPv4 on the public NIC. If false, no IPv4 address will be attached.
pub enable_ipv4: bool,
/// Attach an IPv6 on the public NIC. If false, no IPv6 address will be attached.
pub enable_ipv6: bool,
/// ID of the ipv4 Primary IP to use. If omitted and enable_ipv4 is true, a new ipv4 Primary IP will automatically be created.
#[serde(skip_serializing_if = "Option::is_none")]
pub ipv4: Option<i64>,
/// ID of the ipv6 Primary IP to use. If omitted and enable_ipv6 is true, a new ipv6 Primary IP will automatically be created.
#[serde(skip_serializing_if = "Option::is_none")]
pub ipv6: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateServerResponse {
pub action: HetznerAction,
pub next_actions: Vec<HetznerAction>,
pub root_password: Option<String>,
pub server: HetznerServer,
}
@@ -1,36 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::common::{
HetznerAction, HetznerLocation, HetznerVolume, HetznerVolumeFormat,
};
#[derive(Debug, Clone, Serialize)]
pub struct CreateVolumeBody {
/// Name of the volume
pub name: String,
/// Auto-mount Volume after attach. server must be provided.
#[serde(skip_serializing_if = "Option::is_none")]
pub automount: Option<bool>,
/// Format Volume after creation. One of: xfs, ext4
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<HetznerVolumeFormat>,
/// User-defined labels (key-value pairs) for the Resource
pub labels: HashMap<String, String>,
/// Location to create the Volume in (can be omitted if Server is specified)
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<HetznerLocation>,
/// Server to which to attach the Volume once it's created (Volume will be created in the same Location as the server)
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<i64>,
/// Size of the Volume in GB
pub size: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateVolumeResponse {
pub action: HetznerAction,
pub next_actions: Vec<HetznerAction>,
pub volume: HetznerVolume,
}
-281
View File
@@ -1,281 +0,0 @@
use std::{
sync::{Arc, Mutex, OnceLock},
time::Duration,
};
use anyhow::{Context, anyhow};
use futures::future::join_all;
use komodo_client::entities::server_template::hetzner::{
HetznerDatacenter, HetznerServerTemplateConfig, HetznerServerType,
HetznerVolumeFormat,
};
use crate::{
cloud::hetzner::{
common::HetznerServerStatus, create_server::CreateServerBody,
create_volume::CreateVolumeBody,
},
config::core_config,
};
use self::{client::HetznerClient, common::HetznerVolumeStatus};
mod client;
mod common;
mod create_server;
mod create_volume;
fn hetzner() -> Option<&'static HetznerClient> {
static HETZNER_CLIENT: OnceLock<Option<HetznerClient>> =
OnceLock::new();
HETZNER_CLIENT
.get_or_init(|| {
let token = &core_config().hetzner.token;
(!token.is_empty()).then(|| HetznerClient::new(token))
})
.as_ref()
}
pub struct HetznerServerMinimal {
pub id: i64,
pub ip: String,
}
const POLL_RATE_SECS: u64 = 3;
const MAX_POLL_TRIES: usize = 100;
#[instrument]
pub async fn launch_hetzner_server(
name: &str,
config: HetznerServerTemplateConfig,
) -> anyhow::Result<HetznerServerMinimal> {
let hetzner =
*hetzner().as_ref().context("Hetzner token not configured")?;
let HetznerServerTemplateConfig {
image,
datacenter,
private_network_ids,
placement_group,
enable_public_ipv4,
enable_public_ipv6,
firewall_ids,
server_type,
ssh_keys,
user_data,
use_public_ip,
labels,
volumes,
port: _,
use_https: _,
} = config;
let datacenter = hetzner_datacenter(datacenter);
// Create volumes and get their ids
let mut volume_ids = Vec::new();
for volume in volumes {
let body = CreateVolumeBody {
name: volume.name,
format: Some(hetzner_format(volume.format)),
location: Some(datacenter.into()),
labels: volume.labels,
size: volume.size_gb,
automount: None,
server: None,
};
let id = hetzner
.create_volume(&body)
.await
.context("failed to create hetzner volume")?
.volume
.id;
volume_ids.push(id);
}
// Make sure volumes are available before continue
let vol_ids_poll = Arc::new(Mutex::new(volume_ids.clone()));
for _ in 0..MAX_POLL_TRIES {
if vol_ids_poll.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(Duration::from_secs(POLL_RATE_SECS)).await;
let ids = vol_ids_poll.lock().unwrap().clone();
let futures = ids.into_iter().map(|id| {
let vol_ids = vol_ids_poll.clone();
async move {
let Ok(res) = hetzner.get_volume(id).await else {
return;
};
if matches!(res.volume.status, HetznerVolumeStatus::Available)
{
vol_ids.lock().unwrap().retain(|_id| *_id != id);
}
}
});
join_all(futures).await;
}
if !vol_ids_poll.lock().unwrap().is_empty() {
return Err(anyhow!("Volumes not ready after poll"));
}
let body = CreateServerBody {
name: name.to_string(),
automount: None,
datacenter: Some(datacenter),
location: None,
firewalls: firewall_ids
.into_iter()
.map(|firewall| create_server::Firewall { firewall })
.collect(),
image,
labels,
networks: private_network_ids,
placement_group: (placement_group > 0).then_some(placement_group),
public_net: create_server::PublicNet {
enable_ipv4: enable_public_ipv4,
enable_ipv6: enable_public_ipv6,
ipv4: None,
ipv6: None,
},
server_type: hetzner_server_type(server_type),
ssh_keys,
start_after_create: true,
user_data: (!user_data.is_empty()).then_some(user_data),
volumes: volume_ids,
};
let server_id = hetzner
.create_server(&body)
.await
.context("failed to create hetnzer server")?
.server
.id;
for _ in 0..MAX_POLL_TRIES {
tokio::time::sleep(Duration::from_secs(POLL_RATE_SECS)).await;
let Ok(res) = hetzner.get_server(server_id).await else {
continue;
};
if matches!(res.server.status, HetznerServerStatus::Running) {
let ip = if use_public_ip {
res
.server
.public_net
.ipv4
.context("instance does not have public ipv4 attached")?
.ip
} else {
res
.server
.private_net
.first()
.context("no private networks attached")?
.ip
.to_string()
};
let server = HetznerServerMinimal { id: server_id, ip };
return Ok(server);
}
}
Err(anyhow!(
"failed to verify server running after polling status"
))
}
fn hetzner_format(
format: HetznerVolumeFormat,
) -> common::HetznerVolumeFormat {
match format {
HetznerVolumeFormat::Xfs => common::HetznerVolumeFormat::Xfs,
HetznerVolumeFormat::Ext4 => common::HetznerVolumeFormat::Ext4,
}
}
fn hetzner_datacenter(
datacenter: HetznerDatacenter,
) -> common::HetznerDatacenter {
match datacenter {
HetznerDatacenter::Nuremberg1Dc3 => {
common::HetznerDatacenter::Nuremberg1Dc3
}
HetznerDatacenter::Helsinki1Dc2 => {
common::HetznerDatacenter::Helsinki1Dc2
}
HetznerDatacenter::Falkenstein1Dc14 => {
common::HetznerDatacenter::Falkenstein1Dc14
}
HetznerDatacenter::AshburnDc1 => {
common::HetznerDatacenter::AshburnDc1
}
HetznerDatacenter::HillsboroDc1 => {
common::HetznerDatacenter::HillsboroDc1
}
HetznerDatacenter::SingaporeDc1 => {
common::HetznerDatacenter::SingaporeDc1
}
}
}
fn hetzner_server_type(
server_type: HetznerServerType,
) -> common::HetznerServerType {
match server_type {
HetznerServerType::SharedAmd2Core2Ram40Disk => {
common::HetznerServerType::SharedAmd2Core2Ram40Disk
}
HetznerServerType::SharedArm2Core4Ram40Disk => {
common::HetznerServerType::SharedArm2Core4Ram40Disk
}
HetznerServerType::SharedIntel2Core4Ram40Disk => {
common::HetznerServerType::SharedIntel2Core4Ram40Disk
}
HetznerServerType::SharedAmd3Core4Ram80Disk => {
common::HetznerServerType::SharedAmd3Core4Ram80Disk
}
HetznerServerType::SharedArm4Core8Ram80Disk => {
common::HetznerServerType::SharedArm4Core8Ram80Disk
}
HetznerServerType::SharedIntel4Core8Ram80Disk => {
common::HetznerServerType::SharedIntel4Core8Ram80Disk
}
HetznerServerType::SharedAmd4Core8Ram160Disk => {
common::HetznerServerType::SharedAmd4Core8Ram160Disk
}
HetznerServerType::SharedArm8Core16Ram160Disk => {
common::HetznerServerType::SharedArm8Core16Ram160Disk
}
HetznerServerType::SharedIntel8Core16Ram160Disk => {
common::HetznerServerType::SharedIntel8Core16Ram160Disk
}
HetznerServerType::SharedAmd8Core16Ram240Disk => {
common::HetznerServerType::SharedAmd8Core16Ram240Disk
}
HetznerServerType::SharedArm16Core32Ram320Disk => {
common::HetznerServerType::SharedArm16Core32Ram320Disk
}
HetznerServerType::SharedIntel16Core32Ram320Disk => {
common::HetznerServerType::SharedIntel16Core32Ram320Disk
}
HetznerServerType::SharedAmd16Core32Ram360Disk => {
common::HetznerServerType::SharedAmd16Core32Ram360Disk
}
HetznerServerType::DedicatedAmd2Core8Ram80Disk => {
common::HetznerServerType::DedicatedAmd2Core8Ram80Disk
}
HetznerServerType::DedicatedAmd4Core16Ram160Disk => {
common::HetznerServerType::DedicatedAmd4Core16Ram160Disk
}
HetznerServerType::DedicatedAmd8Core32Ram240Disk => {
common::HetznerServerType::DedicatedAmd8Core32Ram240Disk
}
HetznerServerType::DedicatedAmd16Core64Ram360Disk => {
common::HetznerServerType::DedicatedAmd16Core64Ram360Disk
}
HetznerServerType::DedicatedAmd32Core128Ram600Disk => {
common::HetznerServerType::DedicatedAmd32Core128Ram600Disk
}
HetznerServerType::DedicatedAmd48Core192Ram960Disk => {
common::HetznerServerType::DedicatedAmd48Core192Ram960Disk
}
}
}
-3
View File
@@ -1,8 +1,5 @@
pub mod aws;
#[allow(unused)]
pub mod hetzner;
#[derive(Debug)]
pub enum BuildCleanupData {
/// Nothing to clean up
+3 -6
View File
@@ -8,7 +8,7 @@ use komodo_client::entities::{
config::core::{
AwsCredentials, CoreConfig, DatabaseConfig, Env,
GithubWebhookAppConfig, GithubWebhookAppInstallationConfig,
HetznerCredentials, OauthCredentials,
OauthCredentials,
},
logger::LogConfig,
};
@@ -120,11 +120,6 @@ pub fn core_config() -> &'static CoreConfig {
.komodo_aws_secret_access_key)
.unwrap_or(config.aws.secret_access_key),
},
hetzner: HetznerCredentials {
token: maybe_read_item_from_file(env.komodo_hetzner_token_file, env
.komodo_hetzner_token)
.unwrap_or(config.hetzner.token),
},
github_webhook_app: GithubWebhookAppConfig {
app_id: maybe_read_item_from_file(env.komodo_github_webhook_app_app_id_file, env
.komodo_github_webhook_app_app_id)
@@ -177,6 +172,8 @@ pub fn core_config() -> &'static CoreConfig {
.unwrap_or(config.ui_write_disabled),
disable_confirm_dialog: env.komodo_disable_confirm_dialog
.unwrap_or(config.disable_confirm_dialog),
disable_websocket_reconnect: env.komodo_disable_websocket_reconnect
.unwrap_or(config.disable_websocket_reconnect),
enable_new_users: env.komodo_enable_new_users
.unwrap_or(config.enable_new_users),
disable_user_registration: env.komodo_disable_user_registration
-4
View File
@@ -12,7 +12,6 @@ use komodo_client::entities::{
provider::{DockerRegistryAccount, GitProviderAccount},
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
stats::SystemStatsRecord,
sync::ResourceSync,
@@ -50,7 +49,6 @@ pub struct DbClient {
pub procedures: Collection<Procedure>,
pub actions: Collection<Action>,
pub alerters: Collection<Alerter>,
pub server_templates: Collection<ServerTemplate>,
pub resource_syncs: Collection<ResourceSync>,
pub stacks: Collection<Stack>,
//
@@ -120,8 +118,6 @@ impl DbClient {
alerters: resource_collection(&db, "Alerter").await?,
procedures: resource_collection(&db, "Procedure").await?,
actions: resource_collection(&db, "Action").await?,
server_templates: resource_collection(&db, "ServerTemplate")
.await?,
resource_syncs: resource_collection(&db, "ResourceSync")
.await?,
stacks: resource_collection(&db, "Stack").await?,
+2 -6
View File
@@ -7,7 +7,6 @@ use komodo_client::entities::{
builder::{AwsBuilderConfig, Builder, BuilderConfig},
komodo_timestamp,
server::Server,
server_template::aws::AwsServerTemplateConfig,
update::{Log, Update},
};
use periphery_client::{
@@ -88,11 +87,8 @@ async fn get_aws_builder(
let version = version.map(|v| format!("-v{v}")).unwrap_or_default();
let instance_name = format!("BUILDER-{resource_name}{version}");
let Ec2Instance { instance_id, ip } = launch_ec2_instance(
&instance_name,
AwsServerTemplateConfig::from_builder_config(&config),
)
.await?;
let Ec2Instance { instance_id, ip } =
launch_ec2_instance(&instance_name, &config).await?;
info!("ec2 instance launched");
+8 -180
View File
@@ -1,33 +1,18 @@
use std::{str::FromStr, time::Duration};
use std::time::Duration;
use anyhow::{Context, anyhow};
use futures::future::join_all;
use komodo_client::{
api::write::{CreateBuilder, CreateServer},
entities::{
ResourceTarget,
builder::{PartialBuilderConfig, PartialServerBuilderConfig},
komodo_timestamp,
permission::{Permission, PermissionLevel, UserTarget},
server::{PartialServerConfig, Server},
sync::ResourceSync,
update::Log,
user::{User, system_user},
},
use komodo_client::entities::{
ResourceTarget,
permission::{Permission, PermissionLevel, UserTarget},
server::Server,
user::User,
};
use mongo_indexed::Document;
use mungos::{
find::find_collect,
mongodb::bson::{Bson, doc, oid::ObjectId, to_document},
};
use mungos::mongodb::bson::{Bson, doc};
use periphery_client::PeripheryClient;
use rand::Rng;
use resolver_api::Resolve;
use crate::{
api::write::WriteArgs, config::core_config, resource,
state::db_client,
};
use crate::{config::core_config, state::db_client};
pub mod action_state;
pub mod builder;
@@ -203,160 +188,3 @@ pub fn flatten_document(doc: Document) -> Document {
target
}
pub async fn startup_cleanup() {
tokio::join!(
startup_in_progress_update_cleanup(),
startup_open_alert_cleanup(),
);
}
/// Run on startup, as no updates should be in progress on startup
async fn startup_in_progress_update_cleanup() {
let log = Log::error(
"Komodo shutdown",
String::from(
"Komodo shutdown during execution. If this is a build, the builder may not have been terminated.",
),
);
// This static log won't fail to serialize, unwrap ok.
let log = to_document(&log).unwrap();
if let Err(e) = db_client()
.updates
.update_many(
doc! { "status": "InProgress" },
doc! {
"$set": {
"status": "Complete",
"success": false,
},
"$push": {
"logs": log
}
},
)
.await
{
error!("failed to cleanup in progress updates on startup | {e:#}")
}
}
/// Run on startup, ensure open alerts pointing to invalid resources are closed.
async fn startup_open_alert_cleanup() {
let db = db_client();
let Ok(alerts) =
find_collect(&db.alerts, doc! { "resolved": false }, None)
.await
.inspect_err(|e| {
error!(
"failed to list all alerts for startup open alert cleanup | {e:?}"
)
})
else {
return;
};
let futures = alerts.into_iter().map(|alert| async move {
match alert.target {
ResourceTarget::Server(id) => {
resource::get::<Server>(&id)
.await
.is_err()
.then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten()
}
ResourceTarget::ResourceSync(id) => {
resource::get::<ResourceSync>(&id)
.await
.is_err()
.then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten()
}
// No other resources should have open alerts.
_ => ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok(),
}
});
let to_update_ids = join_all(futures)
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
if let Err(e) = db
.alerts
.update_many(
doc! { "_id": { "$in": to_update_ids } },
doc! { "$set": {
"resolved": true,
"resolved_ts": komodo_timestamp()
} },
)
.await
{
error!(
"failed to clean up invalid open alerts on startup | {e:#}"
)
}
}
/// Ensures a default server / builder exists with the defined address
pub async fn ensure_first_server_and_builder() {
let first_server = &core_config().first_server;
if first_server.is_empty() {
return;
}
let db = db_client();
let Ok(server) = db
.servers
.find_one(Document::new())
.await
.inspect_err(|e| error!("Failed to initialize 'first_server'. Failed to query db. {e:?}"))
else {
return;
};
let server = if let Some(server) = server {
server
} else {
match (CreateServer {
name: format!("server-{}", random_string(5)),
config: PartialServerConfig {
address: Some(first_server.to_string()),
enabled: Some(true),
..Default::default()
},
})
.resolve(&WriteArgs {
user: system_user().to_owned(),
})
.await
{
Ok(server) => server,
Err(e) => {
error!(
"Failed to initialize 'first_server'. Failed to CreateServer. {:#}",
e.error
);
return;
}
}
};
let Ok(None) = db.builders
.find_one(Document::new()).await
.inspect_err(|e| error!("Failed to initialize 'first_builder' | Failed to query db | {e:?}")) else {
return;
};
if let Err(e) = (CreateBuilder {
name: String::from("local"),
config: PartialBuilderConfig::Server(
PartialServerBuilderConfig {
server_id: Some(server.id),
},
),
})
.resolve(&WriteArgs {
user: system_user().to_owned(),
})
.await
{
error!(
"Failed to initialize 'first_builder' | Failed to CreateBuilder | {:#}",
e.error
);
}
}
+23
View File
@@ -166,6 +166,13 @@ async fn execute_stage(
)
.await?;
}
Execution::BatchPullStack(exec) => {
extend_batch_exection::<BatchPullStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDestroyStack(exec) => {
extend_batch_exection::<BatchDestroyStack>(
&exec.pattern,
@@ -985,6 +992,12 @@ async fn execute_execution(
)
.await?
}
Execution::BatchPullStack(_) => {
// All batch executions must be expanded in `execute_stage`
return Err(anyhow!(
"Batch method BatchPullStack not implemented correctly"
));
}
Execution::StartStack(req) => {
let req = ExecuteRequest::StartStack(req);
let update = init_execution_update(&req, &user).await?;
@@ -1275,6 +1288,16 @@ impl ExtendBatch for BatchDeployStackIfChanged {
}
}
impl ExtendBatch for BatchPullStack {
type Resource = Stack;
fn single_execution(stack: String) -> Execution {
Execution::PullStack(PullStack {
stack,
services: Vec::new(),
})
}
}
impl ExtendBatch for BatchDestroyStack {
type Resource = Stack;
fn single_execution(stack: String) -> Execution {
+44 -6
View File
@@ -1,6 +1,11 @@
use std::{collections::HashMap, str::FromStr};
use std::{
collections::HashMap,
str::FromStr,
sync::{Arc, OnceLock},
};
use anyhow::{Context, anyhow};
use async_timing_util::{ONE_MIN_MS, unix_timestamp_ms};
use komodo_client::entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::Action,
@@ -13,8 +18,8 @@ use komodo_client::entities::{
procedure::Procedure,
repo::Repo,
server::{Server, ServerState},
server_template::ServerTemplate,
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
@@ -29,6 +34,8 @@ use mungos::{
options::FindOneOptions,
},
};
use periphery_client::api::stats;
use tokio::sync::Mutex;
use crate::{
config::core_config,
@@ -37,6 +44,8 @@ use crate::{
state::{db_client, deployment_status_cache, stack_status_cache},
};
use super::periphery_client;
// user: Id or username
#[instrument(level = "debug")]
pub async fn get_user(user: &str) -> anyhow::Result<User> {
@@ -295,10 +304,6 @@ pub async fn get_user_permission_on_target(
ResourceTarget::Action(id) => {
get_user_permission_on_resource::<Action>(user, id).await
}
ResourceTarget::ServerTemplate(id) => {
get_user_permission_on_resource::<ServerTemplate>(user, id)
.await
}
ResourceTarget::ResourceSync(id) => {
get_user_permission_on_resource::<ResourceSync>(user, id).await
}
@@ -382,3 +387,36 @@ pub async fn get_variables_and_secrets()
Ok(VariablesAndSecrets { variables, secrets })
}
// This protects the peripheries from spam requests
const SYSTEM_INFO_EXPIRY: u128 = ONE_MIN_MS;
type SystemInfoCache =
Mutex<HashMap<String, Arc<(SystemInformation, u128)>>>;
fn system_info_cache() -> &'static SystemInfoCache {
static SYSTEM_INFO_CACHE: OnceLock<SystemInfoCache> =
OnceLock::new();
SYSTEM_INFO_CACHE.get_or_init(Default::default)
}
pub async fn get_system_info(
server: &Server,
) -> anyhow::Result<SystemInformation> {
let mut lock = system_info_cache().lock().await;
let res = match lock.get(&server.id) {
Some(cached) if cached.1 > unix_timestamp_ms() => {
cached.0.clone()
}
_ => {
let stats = periphery_client(server)?
.request(stats::GetSystemInformation {})
.await?;
lock.insert(
server.id.clone(),
(stats.clone(), unix_timestamp_ms() + SYSTEM_INFO_EXPIRY)
.into(),
);
stats
}
};
Ok(res)
}
+3 -11
View File
@@ -9,7 +9,6 @@ use komodo_client::entities::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
update::{Update, UpdateListItem},
@@ -385,16 +384,6 @@ pub async fn init_execution_update(
return Ok(Default::default());
}
// Server template
ExecuteRequest::LaunchServer(data) => (
Operation::LaunchServer,
ResourceTarget::ServerTemplate(
resource::get::<ServerTemplate>(&data.server_template)
.await?
.id,
),
),
// Resource Sync
ExecuteRequest::RunSync(data) => (
Operation::RunSync,
@@ -446,6 +435,9 @@ pub async fn init_execution_update(
resource::get::<Stack>(&data.stack).await?.id,
),
),
ExecuteRequest::BatchPullStack(_data) => {
return Ok(Default::default());
}
ExecuteRequest::RestartStack(data) => (
if !data.services.is_empty() {
Operation::RestartStackService
+14 -10
View File
@@ -25,6 +25,7 @@ mod monitor;
mod resource;
mod schedule;
mod stack;
mod startup;
mod state;
mod sync;
mod ts_client;
@@ -34,26 +35,28 @@ async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let config = core_config();
logger::init(&config.logging)?;
if let Err(e) =
rustls::crypto::aws_lc_rs::default_provider().install_default()
{
error!("Failed to install default crypto provider | {e:?}");
std::process::exit(1);
};
info!("Komodo Core version: v{}", env!("CARGO_PKG_VERSION"));
info!("{:?}", config.sanitized());
// Init jwt client to crash on failure
state::jwt_client();
tokio::join!(
// Init db_client check to crash on db init failure
state::init_db_client(),
// Manage OIDC client (defined in config / env vars / compose secret file)
auth::oidc::client::spawn_oidc_client_management()
);
tokio::join!(
// Maybe initialize first server
helpers::ensure_first_server_and_builder(),
// Cleanup open updates / invalid alerts
helpers::startup_cleanup(),
);
// init jwt client to crash on failure
state::jwt_client();
// Spawn tasks
// Run after db connection.
startup::on_startup().await;
// Spawn background tasks
monitor::spawn_monitor_loop();
resource::spawn_resource_refresh_loop();
resource::spawn_build_state_refresh_loop();
@@ -76,6 +79,7 @@ async fn app() -> anyhow::Result<()> {
.nest("/read", api::read::router())
.nest("/write", api::write::router())
.nest("/execute", api::execute::router())
.nest("/terminal", api::terminal::router())
.nest("/listener", listener::router())
.nest("/ws", ws::router())
.nest("/client", ts_client::router())
-7
View File
@@ -56,7 +56,6 @@ mod procedure;
mod refresh;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
@@ -773,9 +772,6 @@ fn resource_target<T: KomodoResource>(id: String) -> ResourceTarget {
ResourceTargetVariant::Repo => ResourceTarget::Repo(id),
ResourceTargetVariant::Alerter => ResourceTarget::Alerter(id),
ResourceTargetVariant::Procedure => ResourceTarget::Procedure(id),
ResourceTargetVariant::ServerTemplate => {
ResourceTarget::ServerTemplate(id)
}
ResourceTargetVariant::ResourceSync => {
ResourceTarget::ResourceSync(id)
}
@@ -1020,9 +1016,6 @@ where
ResourceTarget::Stack(id) => ("recents.Stack", id),
ResourceTarget::Builder(id) => ("recents.Builder", id),
ResourceTarget::Alerter(id) => ("recents.Alerter", id),
ResourceTarget::ServerTemplate(id) => {
("recents.ServerTemplate", id)
}
ResourceTarget::ResourceSync(id) => ("recents.ResourceSync", id),
ResourceTarget::System(_) => return,
};
+7
View File
@@ -641,6 +641,13 @@ async fn validate_config(
.await?;
params.stack = stack.id;
}
Execution::BatchPullStack(_params) => {
if !user.admin {
return Err(anyhow!(
"Non admin user cannot configure Batch executions"
));
}
}
Execution::StartStack(params) => {
let stack = super::get_check_permissions::<Stack>(
&params.stack,
+8
View File
@@ -13,6 +13,7 @@ use mungos::mongodb::{Collection, bson::doc};
use crate::{
config::core_config,
helpers::query::get_system_info,
monitor::update_cache_for_server,
state::{action_states, db_client, server_status_cache},
};
@@ -42,6 +43,11 @@ impl super::KomodoResource for Server {
server: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let status = server_status_cache().get(&server.id).await;
let (terminals_disabled, container_exec_disabled) =
get_system_info(&server)
.await
.map(|i| (i.terminals_disabled, i.container_exec_disabled))
.unwrap_or((true, true));
ServerListItem {
name: server.name,
id: server.id,
@@ -57,6 +63,8 @@ impl super::KomodoResource for Server {
send_cpu_alerts: server.config.send_cpu_alerts,
send_mem_alerts: server.config.send_mem_alerts,
send_disk_alerts: server.config.send_disk_alerts,
terminals_disabled,
container_exec_disabled,
},
}
}
-149
View File
@@ -1,149 +0,0 @@
use komodo_client::entities::{
MergePartial, Operation, ResourceTarget, ResourceTargetVariant,
resource::Resource,
server_template::{
PartialServerTemplateConfig, ServerTemplate,
ServerTemplateConfig, ServerTemplateConfigDiff,
ServerTemplateConfigVariant, ServerTemplateListItem,
ServerTemplateListItemInfo, ServerTemplateQuerySpecifics,
},
update::Update,
user::User,
};
use mungos::mongodb::{
Collection,
bson::{Document, to_document},
};
use crate::state::db_client;
impl super::KomodoResource for ServerTemplate {
type Config = ServerTemplateConfig;
type PartialConfig = PartialServerTemplateConfig;
type ConfigDiff = ServerTemplateConfigDiff;
type Info = ();
type ListItem = ServerTemplateListItem;
type QuerySpecifics = ServerTemplateQuerySpecifics;
fn resource_type() -> ResourceTargetVariant {
ResourceTargetVariant::ServerTemplate
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::ServerTemplate(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().server_templates
}
async fn to_list_item(
server_template: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let (template_type, instance_type) = match server_template.config
{
ServerTemplateConfig::Aws(config) => (
ServerTemplateConfigVariant::Aws.to_string(),
Some(config.instance_type),
),
ServerTemplateConfig::Hetzner(config) => (
ServerTemplateConfigVariant::Hetzner.to_string(),
Some(config.server_type.as_ref().to_string()),
),
};
ServerTemplateListItem {
name: server_template.name,
id: server_template.id,
tags: server_template.tags,
resource_type: ResourceTargetVariant::ServerTemplate,
info: ServerTemplateListItemInfo {
provider: template_type.to_string(),
instance_type,
},
}
}
async fn busy(_id: &String) -> anyhow::Result<bool> {
Ok(false)
}
// CREATE
fn create_operation() -> Operation {
Operation::CreateServerTemplate
}
fn user_can_create(user: &User) -> bool {
user.admin
}
async fn validate_create_config(
_config: &mut Self::PartialConfig,
_user: &User,
) -> anyhow::Result<()> {
Ok(())
}
async fn post_create(
_created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
Ok(())
}
// UPDATE
fn update_operation() -> Operation {
Operation::UpdateServerTemplate
}
async fn validate_update_config(
_id: &str,
_config: &mut Self::PartialConfig,
_user: &User,
) -> anyhow::Result<()> {
Ok(())
}
fn update_document(
original: Resource<Self::Config, Self::Info>,
config: Self::PartialConfig,
) -> Result<Document, mungos::mongodb::bson::ser::Error> {
let config = original.config.merge_partial(config);
to_document(&config)
}
async fn post_update(
_updated: &Self,
_update: &mut Update,
) -> anyhow::Result<()> {
Ok(())
}
// RENAME
fn rename_operation() -> Operation {
Operation::RenameServerTemplate
}
// DELETE
fn delete_operation() -> Operation {
Operation::DeleteServerTemplate
}
async fn pre_delete(
_resource: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
Ok(())
}
async fn post_delete(
_resource: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
Ok(())
}
}
+228
View File
@@ -0,0 +1,228 @@
use std::str::FromStr;
use futures::future::join_all;
use komodo_client::{
api::write::{CreateBuilder, CreateServer},
entities::{
ResourceTarget,
builder::{PartialBuilderConfig, PartialServerBuilderConfig},
komodo_timestamp,
server::{PartialServerConfig, Server},
sync::ResourceSync,
update::Log,
user::system_user,
},
};
use mungos::{
find::find_collect,
mongodb::bson::{Document, doc, oid::ObjectId, to_document},
};
use resolver_api::Resolve;
use crate::{
api::write::WriteArgs, config::core_config, helpers::random_string,
resource, state::db_client,
};
/// This function should be run on startup,
/// after the db client has been initialized
pub async fn on_startup() {
tokio::join!(
in_progress_update_cleanup(),
open_alert_cleanup(),
ensure_first_server_and_builder(),
clean_up_server_templates(),
);
}
async fn in_progress_update_cleanup() {
let log = Log::error(
"Komodo shutdown",
String::from(
"Komodo shutdown during execution. If this is a build, the builder may not have been terminated.",
),
);
// This static log won't fail to serialize, unwrap ok.
let log = to_document(&log).unwrap();
if let Err(e) = db_client()
.updates
.update_many(
doc! { "status": "InProgress" },
doc! {
"$set": {
"status": "Complete",
"success": false,
},
"$push": {
"logs": log
}
},
)
.await
{
error!("failed to cleanup in progress updates on startup | {e:#}")
}
}
/// Run on startup, ensure open alerts pointing to invalid resources are closed.
async fn open_alert_cleanup() {
let db = db_client();
let Ok(alerts) =
find_collect(&db.alerts, doc! { "resolved": false }, None)
.await
.inspect_err(|e| {
error!(
"failed to list all alerts for startup open alert cleanup | {e:?}"
)
})
else {
return;
};
let futures = alerts.into_iter().map(|alert| async move {
match alert.target {
ResourceTarget::Server(id) => {
resource::get::<Server>(&id)
.await
.is_err()
.then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten()
}
ResourceTarget::ResourceSync(id) => {
resource::get::<ResourceSync>(&id)
.await
.is_err()
.then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten()
}
// No other resources should have open alerts.
_ => ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok(),
}
});
let to_update_ids = join_all(futures)
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
if let Err(e) = db
.alerts
.update_many(
doc! { "_id": { "$in": to_update_ids } },
doc! { "$set": {
"resolved": true,
"resolved_ts": komodo_timestamp()
} },
)
.await
{
error!(
"failed to clean up invalid open alerts on startup | {e:#}"
)
}
}
/// Ensures a default server / builder exists with the defined address
async fn ensure_first_server_and_builder() {
let first_server = &core_config().first_server;
if first_server.is_empty() {
return;
}
let db = db_client();
let Ok(server) = db
.servers
.find_one(Document::new())
.await
.inspect_err(|e| error!("Failed to initialize 'first_server'. Failed to query db. {e:?}"))
else {
return;
};
let server = if let Some(server) = server {
server
} else {
match (CreateServer {
name: format!("server-{}", random_string(5)),
config: PartialServerConfig {
address: Some(first_server.to_string()),
enabled: Some(true),
..Default::default()
},
})
.resolve(&WriteArgs {
user: system_user().to_owned(),
})
.await
{
Ok(server) => server,
Err(e) => {
error!(
"Failed to initialize 'first_server'. Failed to CreateServer. {:#}",
e.error
);
return;
}
}
};
let Ok(None) = db.builders
.find_one(Document::new()).await
.inspect_err(|e| error!("Failed to initialize 'first_builder' | Failed to query db | {e:?}")) else {
return;
};
if let Err(e) = (CreateBuilder {
name: String::from("local"),
config: PartialBuilderConfig::Server(
PartialServerBuilderConfig {
server_id: Some(server.id),
},
),
})
.resolve(&WriteArgs {
user: system_user().to_owned(),
})
.await
{
error!(
"Failed to initialize 'first_builder' | Failed to CreateBuilder | {:#}",
e.error
);
}
}
/// v1.17.5 removes the ServerTemplate resource.
/// References to this resource type need to be cleaned up
/// to avoid type errors reading from the database.
async fn clean_up_server_templates() {
let db = db_client();
tokio::join!(
async {
db.permissions
.delete_many(doc! {
"resource_target.type": "ServerTemplate",
})
.await
.expect(
"Failed to clean up server template permissions on db",
);
},
async {
db.updates
.delete_many(doc! { "target.type": "ServerTemplate" })
.await
.expect("Failed to clean up server template updates on db");
},
async {
db.users
.update_many(
Document::new(),
doc! { "$unset": { "recents.ServerTemplate": 1, "all.ServerTemplate": 1 } }
)
.await
.expect("Failed to clean up server template updates on db");
},
async {
db.user_groups
.update_many(
Document::new(),
doc! { "$unset": { "all.ServerTemplate": 1 } },
)
.await
.expect("Failed to clean up server template updates on db");
},
);
}
-3
View File
@@ -270,9 +270,6 @@ pub fn extend_resources(
resources
.builders
.extend(filter_by_tag(more.builders, match_tags));
resources
.server_templates
.extend(filter_by_tag(more.server_templates, match_tags));
resources
.resource_syncs
.extend(filter_by_tag(more.resource_syncs, match_tags));
-6
View File
@@ -11,7 +11,6 @@ use komodo_client::entities::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
tag::Tag,
@@ -166,7 +165,6 @@ pub struct AllResourcesById {
pub actions: HashMap<String, Action>,
pub builders: HashMap<String, Builder>,
pub alerters: HashMap<String, Alerter>,
pub templates: HashMap<String, ServerTemplate>,
pub syncs: HashMap<String, ResourceSync>,
}
@@ -210,10 +208,6 @@ impl AllResourcesById {
id_to_tags, match_tags,
)
.await?,
templates: crate::resource::get_id_to_resource_map::<
ServerTemplate,
>(id_to_tags, match_tags)
.await?,
syncs: crate::resource::get_id_to_resource_map::<ResourceSync>(
id_to_tags, match_tags,
)
+1 -13
View File
@@ -13,7 +13,6 @@ use komodo_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
tag::Tag,
@@ -191,18 +190,6 @@ impl ResourceSyncTrait for Builder {
impl ExecuteResourceSync for Builder {}
impl ResourceSyncTrait for ServerTemplate {
fn get_diff(
original: Self::Config,
update: Self::PartialConfig,
_resources: &AllResourcesById,
) -> anyhow::Result<Self::ConfigDiff> {
Ok(original.partial_diff(update))
}
}
impl ExecuteResourceSync for ServerTemplate {}
impl ResourceSyncTrait for Action {
fn get_diff(
original: Self::Config,
@@ -614,6 +601,7 @@ impl ResourceSyncTrait for Procedure {
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::BatchPullStack(_config) => {}
Execution::StartStack(config) => {
config.stack = resources
.stacks
+1 -20
View File
@@ -13,7 +13,6 @@ use komodo_client::{
repo::Repo,
resource::Resource,
server::Server,
server_template::{PartialServerTemplateConfig, ServerTemplate},
stack::Stack,
sync::ResourceSync,
tag::Tag,
@@ -349,25 +348,6 @@ impl ToToml for Repo {
}
}
impl ToToml for ServerTemplate {
fn push_additional(
resource: ResourceToml<Self::PartialConfig>,
toml: &mut String,
) {
let empty_params = match resource.config {
PartialServerTemplateConfig::Aws(config) => config.is_none(),
PartialServerTemplateConfig::Hetzner(config) => {
config.is_none()
}
};
if empty_params {
// toml_pretty will remove empty map
// but in this case its needed to deserialize the enums.
toml.push_str("\nparams = {}");
}
}
}
impl ToToml for Builder {
fn replace_ids(
resource: &mut Resource<Self::Config, Self::Info>,
@@ -747,6 +727,7 @@ impl ToToml for Procedure {
.map(|r| &r.name)
.unwrap_or(&String::new()),
),
Execution::BatchPullStack(_exec) => {}
Execution::StartStack(exec) => exec.stack.clone_from(
all
.stacks
-27
View File
@@ -285,13 +285,6 @@ pub async fn get_updates_for_execution(
.map(|b| b.name.clone())
.unwrap_or_default()
}
ResourceTarget::ServerTemplate(id) => {
*id = all_resources
.templates
.get(id)
.map(|b| b.name.clone())
.unwrap_or_default()
}
ResourceTarget::ResourceSync(id) => {
*id = all_resources
.syncs
@@ -737,19 +730,6 @@ async fn expand_user_group_permissions(
});
expanded.extend(permissions);
}
ResourceTargetVariant::ServerTemplate => {
let permissions = all_resources
.templates
.values()
.filter(|resource| regex.is_match(&resource.name))
.map(|resource| PermissionToml {
target: ResourceTarget::ServerTemplate(
resource.name.clone(),
),
level: permission.level,
});
expanded.extend(permissions);
}
ResourceTargetVariant::ResourceSync => {
let permissions = all_resources
.syncs
@@ -903,13 +883,6 @@ pub async fn convert_user_groups(
.map(|r| r.name.clone())
.unwrap_or_default()
}
ResourceTarget::ServerTemplate(id) => {
*id = all
.templates
.get(id)
.map(|r| r.name.clone())
.unwrap_or_default()
}
ResourceTarget::ResourceSync(id) => {
*id = all
.syncs
-209
View File
@@ -1,209 +0,0 @@
use anyhow::anyhow;
use axum::{
Router,
extract::{
WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::IntoResponse,
routing::get,
};
use futures::{SinkExt, StreamExt};
use komodo_client::{
entities::{
ResourceTarget, permission::PermissionLevel, user::User,
},
ws::WsLoginMessage,
};
use serde_json::json;
use serror::serialize_error;
use tokio::select;
use tokio_util::sync::CancellationToken;
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::{
channel::update_channel,
query::{get_user, get_user_permission_on_target},
},
};
pub fn router() -> Router {
Router::new().route("/update", get(ws_handler))
}
#[instrument(level = "debug")]
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
// get a reveiver for internal update messages.
let mut receiver = update_channel().receiver.resubscribe();
// handle http -> ws updgrade
ws.on_upgrade(|socket| async move {
let Some((socket, user)) = ws_login(socket).await else {
return
};
let (mut ws_sender, mut ws_reciever) = socket.split();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
loop {
// poll for updates off the receiver / await cancel.
let update = select! {
_ = cancel_clone.cancelled() => break,
update = receiver.recv() => {update.expect("failed to recv update msg")}
};
// before sending every update, verify user is still valid.
// kill the connection is user if found to be invalid.
let user = check_user_valid(&user.id).await;
let user = match user {
Err(e) => {
let _ = ws_sender
.send(Message::text(json!({ "type": "INVALID_USER", "msg": serialize_error(&e) }).to_string()))
.await;
let _ = ws_sender.close().await;
return;
},
Ok(user) => user,
};
// Only send if user has permission on the target resource.
if user_can_see_update(&user, &update.target).await.is_ok() {
let _ = ws_sender
.send(Message::text(serde_json::to_string(&update).unwrap()))
.await;
}
}
});
// Handle messages from the client.
// After login, only handles close message.
while let Some(msg) = ws_reciever.next().await {
match msg {
Ok(msg) => {
if let Message::Close(_) = msg {
cancel.cancel();
return;
}
}
Err(_) => {
cancel.cancel();
return;
}
}
}
})
}
#[instrument(level = "debug")]
async fn ws_login(
mut socket: WebSocket,
) -> Option<(WebSocket, User)> {
let login_msg = match socket.recv().await {
Some(Ok(Message::Text(login_msg))) => {
LoginMessage::Ok(login_msg.to_string())
}
Some(Ok(msg)) => {
LoginMessage::Err(format!("invalid login message: {msg:?}"))
}
Some(Err(e)) => {
LoginMessage::Err(format!("failed to get login message: {e:?}"))
}
None => {
LoginMessage::Err("failed to get login message".to_string())
}
};
let login_msg = match login_msg {
LoginMessage::Ok(login_msg) => login_msg,
LoginMessage::Err(msg) => {
let _ = socket.send(Message::text(msg)).await;
let _ = socket.close().await;
return None;
}
};
match WsLoginMessage::from_json_str(&login_msg) {
// Login using a jwt
Ok(WsLoginMessage::Jwt { jwt }) => {
match auth_jwt_check_enabled(&jwt).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using jwt | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
// login using api keys
Ok(WsLoginMessage::ApiKeys { key, secret }) => {
match auth_api_key_check_enabled(&key, &secret).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using api keys | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to parse login message: {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
enum LoginMessage {
/// The text message
Ok(String),
/// The err message
Err(String),
}
#[instrument(level = "debug")]
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("user not enabled"));
}
Ok(user)
}
#[instrument(level = "debug")]
async fn user_can_see_update(
user: &User,
update_target: &ResourceTarget,
) -> anyhow::Result<()> {
if user.admin {
return Ok(());
}
let permissions =
get_user_permission_on_target(user, update_target).await?;
if permissions > PermissionLevel::None {
Ok(())
} else {
Err(anyhow!(
"user does not have permissions on {update_target:?}"
))
}
}
+81
View File
@@ -0,0 +1,81 @@
use axum::{
extract::{Query, WebSocketUpgrade, ws::Message},
response::IntoResponse,
};
use futures::SinkExt;
use komodo_client::{
api::terminal::ConnectContainerExecQuery,
entities::{permission::PermissionLevel, server::Server},
};
use crate::{
helpers::periphery_client, resource, ws::core_periphery_forward_ws,
};
#[instrument(name = "ConnectContainerExec", skip(ws))]
pub async fn handler(
Query(ConnectContainerExecQuery {
server,
container,
shell,
}): Query<ConnectContainerExecQuery>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|socket| async move {
let Some((mut client_socket, user)) = super::ws_login(socket).await
else {
return;
};
let server = match resource::get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Write,
)
.await
{
Ok(server) => server,
Err(e) => {
debug!("could not get server | {e:#}");
let _ =
client_socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = client_socket.close().await;
return;
}
};
let periphery = match periphery_client(&server) {
Ok(periphery) => periphery,
Err(e) => {
debug!("couldn't get periphery | {e:#}");
let _ =
client_socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = client_socket.close().await;
return;
}
};
trace!("connecting to periphery container exec websocket");
let periphery_socket = match periphery
.connect_container_exec(
container,
shell
)
.await
{
Ok(ws) => ws,
Err(e) => {
debug!("Failed connect to periphery container exec websocket | {e:#}");
let _ =
client_socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = client_socket.close().await;
return;
}
};
trace!("connected to periphery container exec websocket");
core_periphery_forward_ws(client_socket, periphery_socket).await
})
}
+243
View File
@@ -0,0 +1,243 @@
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::query::get_user,
};
use anyhow::anyhow;
use axum::{
Router,
extract::ws::{CloseFrame, Message, Utf8Bytes, WebSocket},
routing::get,
};
use futures::{SinkExt, StreamExt};
use komodo_client::{entities::user::User, ws::WsLoginMessage};
use tokio::net::TcpStream;
use tokio_tungstenite::{
MaybeTlsStream, WebSocketStream, tungstenite,
};
use tokio_util::sync::CancellationToken;
mod container;
mod terminal;
mod update;
pub fn router() -> Router {
Router::new()
.route("/update", get(update::handler))
.route("/terminal", get(terminal::handler))
.route("/container", get(container::handler))
}
#[instrument(level = "debug")]
async fn ws_login(
mut socket: WebSocket,
) -> Option<(WebSocket, User)> {
let login_msg = match socket.recv().await {
Some(Ok(Message::Text(login_msg))) => {
LoginMessage::Ok(login_msg.to_string())
}
Some(Ok(msg)) => {
LoginMessage::Err(format!("invalid login message: {msg:?}"))
}
Some(Err(e)) => {
LoginMessage::Err(format!("failed to get login message: {e:?}"))
}
None => {
LoginMessage::Err("failed to get login message".to_string())
}
};
let login_msg = match login_msg {
LoginMessage::Ok(login_msg) => login_msg,
LoginMessage::Err(msg) => {
let _ = socket.send(Message::text(msg)).await;
let _ = socket.close().await;
return None;
}
};
match WsLoginMessage::from_json_str(&login_msg) {
// Login using a jwt
Ok(WsLoginMessage::Jwt { jwt }) => {
match auth_jwt_check_enabled(&jwt).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using jwt | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
// login using api keys
Ok(WsLoginMessage::ApiKeys { key, secret }) => {
match auth_api_key_check_enabled(&key, &secret).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using api keys | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to parse login message: {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
enum LoginMessage {
/// The text message
Ok(String),
/// The err message
Err(String),
}
#[instrument(level = "debug")]
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("user not enabled"));
}
Ok(user)
}
async fn core_periphery_forward_ws(
client_socket: axum::extract::ws::WebSocket,
periphery_socket: WebSocketStream<MaybeTlsStream<TcpStream>>,
) {
let (mut periphery_send, mut periphery_receive) =
periphery_socket.split();
let (mut core_send, mut core_receive) = client_socket.split();
let cancel = CancellationToken::new();
trace!("starting ws exchange");
let core_to_periphery = async {
loop {
let res = tokio::select! {
res = core_receive.next() => res,
_ = cancel.cancelled() => {
trace!("core to periphery read: cancelled from inside");
break;
}
};
match res {
Some(Ok(msg)) => {
if let Err(e) =
periphery_send.send(axum_to_tungstenite(msg)).await
{
debug!(
"Failed to send terminal message | {e:?}",
);
cancel.cancel();
break;
};
}
Some(Err(_e)) => {
cancel.cancel();
break;
}
None => {
cancel.cancel();
break;
}
}
}
};
let periphery_to_core = async {
loop {
let res = tokio::select! {
res = periphery_receive.next() => res,
_ = cancel.cancelled() => {
trace!("periphery to core read: cancelled from inside");
break;
}
};
match res {
Some(Ok(msg)) => {
if let Err(e) =
core_send.send(tungstenite_to_axum(msg)).await
{
debug!("{e:?}");
cancel.cancel();
break;
};
}
Some(Err(e)) => {
let _ = core_send
.send(Message::text(format!(
"ERROR: Failed to receive message from periphery | {e:?}"
)))
.await;
cancel.cancel();
break;
}
None => {
let _ = core_send.send(Message::text("STREAM EOF")).await;
cancel.cancel();
break;
}
}
}
};
tokio::join!(core_to_periphery, periphery_to_core);
}
fn axum_to_tungstenite(msg: Message) -> tungstenite::Message {
match msg {
Message::Text(text) => tungstenite::Message::Text(
// TODO: improve this conversion cost from axum ws library
tungstenite::Utf8Bytes::from(text.to_string()),
),
Message::Binary(bytes) => tungstenite::Message::Binary(bytes),
Message::Ping(bytes) => tungstenite::Message::Ping(bytes),
Message::Pong(bytes) => tungstenite::Message::Pong(bytes),
Message::Close(close_frame) => {
tungstenite::Message::Close(close_frame.map(|cf| {
tungstenite::protocol::CloseFrame {
code: cf.code.into(),
reason: tungstenite::Utf8Bytes::from(cf.reason.to_string()),
}
}))
}
}
}
fn tungstenite_to_axum(msg: tungstenite::Message) -> Message {
match msg {
tungstenite::Message::Text(text) => {
Message::Text(Utf8Bytes::from(text.to_string()))
}
tungstenite::Message::Binary(bytes) => Message::Binary(bytes),
tungstenite::Message::Ping(bytes) => Message::Ping(bytes),
tungstenite::Message::Pong(bytes) => Message::Pong(bytes),
tungstenite::Message::Close(close_frame) => {
Message::Close(close_frame.map(|cf| CloseFrame {
code: cf.code.into(),
reason: Utf8Bytes::from(cf.reason.to_string()),
}))
}
tungstenite::Message::Frame(_) => {
unreachable!()
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use axum::{
extract::{Query, WebSocketUpgrade, ws::Message},
response::IntoResponse,
};
use futures::SinkExt;
use komodo_client::{
api::terminal::ConnectTerminalQuery,
entities::{permission::PermissionLevel, server::Server},
};
use crate::{
helpers::periphery_client, resource, ws::core_periphery_forward_ws,
};
#[instrument(name = "ConnectTerminal", skip(ws))]
pub async fn handler(
Query(ConnectTerminalQuery { server, terminal }): Query<
ConnectTerminalQuery,
>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|socket| async move {
let Some((mut client_socket, user)) =
super::ws_login(socket).await
else {
return;
};
let server = match resource::get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Write,
)
.await
{
Ok(server) => server,
Err(e) => {
debug!("could not get server | {e:#}");
let _ = client_socket
.send(Message::text(format!("ERROR: {e:#}")))
.await;
let _ = client_socket.close().await;
return;
}
};
let periphery = match periphery_client(&server) {
Ok(periphery) => periphery,
Err(e) => {
debug!("couldn't get periphery | {e:#}");
let _ = client_socket
.send(Message::text(format!("ERROR: {e:#}")))
.await;
let _ = client_socket.close().await;
return;
}
};
trace!("connecting to periphery terminal websocket");
let periphery_socket =
match periphery.connect_terminal(terminal).await {
Ok(ws) => ws,
Err(e) => {
debug!("Failed connect to periphery terminal | {e:#}");
let _ = client_socket
.send(Message::text(format!("ERROR: {e:#}")))
.await;
let _ = client_socket.close().await;
return;
}
};
trace!("connected to periphery terminal websocket");
core_periphery_forward_ws(client_socket, periphery_socket).await
})
}
+102
View File
@@ -0,0 +1,102 @@
use anyhow::anyhow;
use axum::{
extract::{WebSocketUpgrade, ws::Message},
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use komodo_client::entities::{
ResourceTarget, permission::PermissionLevel, user::User,
};
use serde_json::json;
use serror::serialize_error;
use tokio::select;
use tokio_util::sync::CancellationToken;
use crate::helpers::{
channel::update_channel, query::get_user_permission_on_target,
};
#[instrument(level = "debug")]
pub async fn handler(ws: WebSocketUpgrade) -> impl IntoResponse {
// get a reveiver for internal update messages.
let mut receiver = update_channel().receiver.resubscribe();
// handle http -> ws updgrade
ws.on_upgrade(|socket| async move {
let Some((socket, user)) = super::ws_login(socket).await else {
return
};
let (mut ws_sender, mut ws_reciever) = socket.split();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
loop {
// poll for updates off the receiver / await cancel.
let update = select! {
_ = cancel_clone.cancelled() => break,
update = receiver.recv() => {update.expect("failed to recv update msg")}
};
// before sending every update, verify user is still valid.
// kill the connection is user if found to be invalid.
let user = super::check_user_valid(&user.id).await;
let user = match user {
Err(e) => {
let _ = ws_sender
.send(Message::text(json!({ "type": "INVALID_USER", "msg": serialize_error(&e) }).to_string()))
.await;
let _ = ws_sender.close().await;
return;
},
Ok(user) => user,
};
// Only send if user has permission on the target resource.
if user_can_see_update(&user, &update.target).await.is_ok() {
let _ = ws_sender
.send(Message::text(serde_json::to_string(&update).unwrap()))
.await;
}
}
});
// Handle messages from the client.
// After login, only handles close message.
while let Some(msg) = ws_reciever.next().await {
match msg {
Ok(msg) => {
if let Message::Close(_) = msg {
cancel.cancel();
return;
}
}
Err(_) => {
cancel.cancel();
return;
}
}
}
})
}
#[instrument(level = "debug")]
async fn user_can_see_update(
user: &User,
update_target: &ResourceTarget,
) -> anyhow::Result<()> {
if user.admin {
return Ok(());
}
let permissions =
get_user_permission_on_target(user, update_target).await?;
if permissions > PermissionLevel::None {
Ok(())
} else {
Err(anyhow!(
"user does not have permissions on {update_target:?}"
))
}
}
+67
View File
@@ -0,0 +1,67 @@
## This is used to customize the shell prompt in Periphery container for Terminals
"$schema" = 'https://starship.rs/config-schema.json'
add_newline = true
format = "$time$hostname$container$memory_usage$all"
[character]
success_symbol = "[❯](bright-blue bold)"
error_symbol = "[❯](bright-red bold)"
[package]
disabled = true
[time]
format = "[❯$time](white dimmed) "
time_format = "%l:%M %p"
utc_time_offset = '-5'
disabled = true
[username]
format = "[❯ $user]($style) "
style_user = "bright-green"
show_always = true
[hostname]
format = "[❯ $hostname]($style) "
style = "bright-blue"
ssh_only = false
[directory]
format = "[❯ $path]($style)[$read_only]($read_only_style) "
style = "bright-cyan"
[git_branch]
format = "[❯ $symbol$branch(:$remote_branch)]($style) "
style = "bright-purple"
[git_status]
style = "bright-purple"
[rust]
format = "[❯ $symbol($version )]($style)"
symbol = "rustc "
style = "bright-red"
[nodejs]
format = "[❯ $symbol($version )]($style)"
symbol = "nodejs "
style = "bright-red"
[memory_usage]
format = "[❯ mem ${ram} ${ram_pct}]($style) "
threshold = -1
style = "white"
[cmd_duration]
format = "[❯ $duration]($style)"
style = "bright-yellow"
[container]
format = "[❯ 🦎 core container ]($style)"
style = "bright-green"
[aws]
disabled = true
+6
View File
@@ -33,9 +33,13 @@ resolver_api.workspace = true
run_command.workspace = true
svi.workspace = true
# external
pin-project-lite.workspace = true
tokio-stream.workspace = true
portable-pty.workspace = true
axum-server.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
tokio-util.workspace = true
futures.workspace = true
tracing.workspace = true
bollard.workspace = true
@@ -45,7 +49,9 @@ anyhow.workspace = true
rustls.workspace = true
tokio.workspace = true
serde.workspace = true
bytes.workspace = true
axum.workspace = true
clap.workspace = true
envy.workspace = true
uuid.workspace = true
rand.workspace = true
+1
View File
@@ -15,6 +15,7 @@ RUN cargo build -p komodo_periphery --release
# Final Image
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
+11 -1
View File
@@ -1,6 +1,10 @@
#!/bin/bash
## Periphery deps installer
apt-get update
apt-get install -y git curl wget ca-certificates
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
@@ -15,4 +19,10 @@ apt-get update
# apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
apt-get install -y docker-ce-cli docker-buildx-plugin docker-compose-plugin
rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/*
# Starship prompt
curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin
echo 'export STARSHIP_CONFIG=/config/starship.toml' >> /root/.bashrc
echo 'eval "$(starship init bash)"' >> /root/.bashrc
+1
View File
@@ -12,6 +12,7 @@ FROM ${AARCH64_BINARIES} AS aarch64
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
+1 -1
View File
@@ -8,10 +8,10 @@ FROM ${BINARIES_IMAGE} AS binaries
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
WORKDIR /app
COPY --from=binaries /periphery /usr/local/bin/periphery
EXPOSE 8120
+1 -1
View File
@@ -248,7 +248,7 @@ impl Resolve<super::Args> for build::Build {
} {
let success = log.success;
logs.push(log);
if success {
if !success {
return Ok(logs);
}
};
+13 -5
View File
@@ -8,11 +8,8 @@ use komodo_client::entities::{
update::Log,
};
use periphery_client::api::{
GetDockerLists, GetDockerListsResponse, GetHealth,
GetHealthResponse, GetVersion, GetVersionResponse,
ListDockerRegistries, ListGitProviders, ListSecrets, PruneSystem,
RunCommand, build::*, compose::*, container::*, git::*, image::*,
network::*, stats::*, volume::*,
build::*, compose::*, container::*, git::*, image::*, network::*,
stats::*, terminal::*, volume::*, *,
};
use resolver_api::Resolve;
use response::Response;
@@ -27,9 +24,13 @@ mod deploy;
mod git;
mod image;
mod network;
mod router;
mod stats;
mod terminal;
mod volume;
pub use router::router;
pub struct Args;
#[derive(
@@ -136,6 +137,13 @@ pub enum PeripheryRequest {
// All in one (Write)
PruneSystem(PruneSystem),
// Terminal
ListTerminals(ListTerminals),
CreateTerminal(CreateTerminal),
DeleteTerminal(DeleteTerminal),
DeleteAllTerminals(DeleteAllTerminals),
CreateTerminalAuthToken(CreateTerminalAuthToken),
}
//
@@ -8,7 +8,7 @@ use axum::{
http::{Request, StatusCode},
middleware::{self, Next},
response::Response,
routing::post,
routing::{get, post},
};
use derive_variants::ExtractVariant;
use resolver_api::Resolve;
@@ -19,9 +19,27 @@ use crate::config::periphery_config;
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.merge(
Router::new()
.route("/", post(handler))
.layer(middleware::from_fn(guard_request_by_passkey)),
)
.nest(
"/terminal",
Router::new()
.route("/", get(super::terminal::connect_terminal))
.route(
"/container",
get(super::terminal::connect_container_exec),
)
.nest(
"/execute",
Router::new()
.route("/", post(super::terminal::execute_terminal))
.layer(middleware::from_fn(guard_request_by_passkey)),
),
)
.layer(middleware::from_fn(guard_request_by_ip))
.layer(middleware::from_fn(guard_request_by_passkey))
}
async fn handler(
+441
View File
@@ -0,0 +1,441 @@
use std::{collections::HashMap, sync::OnceLock, task::Poll};
use anyhow::{Context, anyhow};
use axum::{
extract::{
Query, WebSocketUpgrade,
ws::{Message, Utf8Bytes},
},
http::StatusCode,
response::Response,
};
use bytes::Bytes;
use futures::{SinkExt, Stream, StreamExt, TryStreamExt};
use komodo_client::{
api::write::TerminalRecreateMode,
entities::{
KOMODO_EXIT_CODE, NoData, komodo_timestamp, server::TerminalInfo,
},
};
use periphery_client::api::terminal::*;
use pin_project_lite::pin_project;
use rand::Rng;
use resolver_api::Resolve;
use serror::{AddStatusCodeError, Json};
use tokio_util::sync::CancellationToken;
use crate::{config::periphery_config, terminal::*};
impl Resolve<super::Args> for ListTerminals {
#[instrument(name = "ListTerminals", level = "debug")]
async fn resolve(
self,
_: &super::Args,
) -> serror::Result<Vec<TerminalInfo>> {
clean_up_terminals().await;
Ok(list_terminals().await)
}
}
impl Resolve<super::Args> for CreateTerminal {
#[instrument(name = "CreateTerminal", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
create_terminal(self.name, self.command, self.recreate)
.await
.map(|_| NoData {})
.map_err(Into::into)
}
}
impl Resolve<super::Args> for DeleteTerminal {
#[instrument(name = "DeleteTerminal", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
delete_terminal(&self.terminal).await;
Ok(NoData {})
}
}
impl Resolve<super::Args> for DeleteAllTerminals {
#[instrument(name = "DeleteAllTerminals", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
delete_all_terminals().await;
Ok(NoData {})
}
}
impl Resolve<super::Args> for CreateTerminalAuthToken {
#[instrument(name = "CreateTerminalAuthToken", level = "debug")]
async fn resolve(
self,
_: &super::Args,
) -> serror::Result<CreateTerminalAuthTokenResponse> {
Ok(CreateTerminalAuthTokenResponse {
token: auth_tokens().create_auth_token(),
})
}
}
/// Tokens valid for 3 seconds
const TOKEN_VALID_FOR_MS: i64 = 3_000;
fn auth_tokens() -> &'static AuthTokens {
static AUTH_TOKENS: OnceLock<AuthTokens> = OnceLock::new();
AUTH_TOKENS.get_or_init(Default::default)
}
#[derive(Default)]
struct AuthTokens {
map: std::sync::Mutex<HashMap<String, i64>>,
}
impl AuthTokens {
pub fn create_auth_token(&self) -> String {
let mut lock = self.map.lock().unwrap();
// clear out any old tokens here (prevent unbounded growth)
let ts = komodo_timestamp();
lock.retain(|_, valid_until| *valid_until > ts);
let token: String = rand::rng()
.sample_iter(&rand::distr::Alphanumeric)
.take(30)
.map(char::from)
.collect();
lock.insert(token.clone(), ts + TOKEN_VALID_FOR_MS);
token
}
pub fn check_token(&self, token: String) -> serror::Result<()> {
let Some(valid_until) = self.map.lock().unwrap().remove(&token)
else {
return Err(
anyhow!("Terminal auth token not found")
.status_code(StatusCode::UNAUTHORIZED),
);
};
if komodo_timestamp() <= valid_until {
Ok(())
} else {
Err(
anyhow!("Terminal token is expired")
.status_code(StatusCode::UNAUTHORIZED),
)
}
}
}
pub async fn connect_terminal(
Query(query): Query<ConnectTerminalQuery>,
ws: WebSocketUpgrade,
) -> serror::Result<Response> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
handle_terminal_websocket(query, ws).await
}
pub async fn connect_container_exec(
Query(ConnectContainerExecQuery {
token,
container,
shell,
}): Query<ConnectContainerExecQuery>,
ws: WebSocketUpgrade,
) -> serror::Result<Response> {
if periphery_config().disable_container_exec {
return Err(
anyhow!("Container exec is disabled in the periphery config")
.into(),
);
}
if container.contains("&&") || shell.contains("&&") {
return Err(
anyhow!(
"The use of '&&' is forbidden in the container name or shell"
)
.into(),
);
}
// Create (recreate if shell changed)
create_terminal(
container.clone(),
format!("docker exec -it {container} {shell}"),
TerminalRecreateMode::DifferentCommand,
)
.await
.context("Failed to create terminal for container exec")?;
handle_terminal_websocket(
ConnectTerminalQuery {
token,
terminal: container,
},
ws,
)
.await
}
async fn handle_terminal_websocket(
ConnectTerminalQuery { token, terminal }: ConnectTerminalQuery,
ws: WebSocketUpgrade,
) -> serror::Result<Response> {
// Auth the connection with single use token
auth_tokens().check_token(token)?;
clean_up_terminals().await;
let terminal = get_terminal(&terminal).await?;
Ok(ws.on_upgrade(|mut socket| async move {
let init_res = async {
let (a, b) = terminal.history.bytes_parts();
if !a.is_empty() {
socket.send(Message::Binary(a)).await.context("Failed to send history part a")?;
}
if !b.is_empty() {
socket.send(Message::Binary(b)).await.context("Failed to send history part b")?;
}
anyhow::Ok(())
}.await;
if let Err(e) = init_res {
let _ = socket.send(Message::Text(format!("ERROR: {e:#}").into())).await;
let _ = socket.close().await;
return;
}
let (mut ws_write, mut ws_read) = socket.split();
let cancel = CancellationToken::new();
let ws_read = async {
loop {
let res = tokio::select! {
res = ws_read.next() => res,
_ = terminal.cancel.cancelled() => {
trace!("ws read: cancelled from outside");
break
},
_ = cancel.cancelled() => {
trace!("ws read: cancelled from inside");
break;
}
};
match res {
Some(Ok(Message::Binary(bytes)))
if bytes.first() == Some(&0x00) =>
{
// println!("Got ws read bytes - for stdin");
if let Err(e) = terminal.stdin.send(StdinMsg::Bytes(
Bytes::copy_from_slice(&bytes[1..]),
)).await {
debug!("WS -> PTY channel send error: {e:}");
terminal.cancel();
break;
};
}
Some(Ok(Message::Binary(bytes)))
if bytes.first() == Some(&0xFF) =>
{
// println!("Got ws read bytes - for resize");
if let Ok(dimensions) =
serde_json::from_slice::<ResizeDimensions>(&bytes[1..])
{
if let Err(e) =
terminal.stdin.send(StdinMsg::Resize(dimensions)).await
{
debug!("WS -> PTY channel send error: {e:}");
terminal.cancel();
break;
};
}
}
Some(Ok(Message::Text(text))) => {
trace!("Got ws read text");
if let Err(e) =
terminal.stdin.send(StdinMsg::Bytes(Bytes::from(text))).await
{
debug!("WS -> PTY channel send error: {e:?}");
terminal.cancel();
break;
};
}
Some(Ok(Message::Close(_))) => {
debug!("got ws read close");
cancel.cancel();
break;
}
Some(Ok(_)) => {
// Do nothing (ping, non-prefixed bytes, etc.)
}
Some(Err(e)) => {
debug!("Got ws read error: {e:?}");
cancel.cancel();
break;
}
None => {
debug!("Got ws read none");
cancel.cancel();
break;
}
}
}
};
let ws_write = async {
let mut stdout = terminal.stdout.resubscribe();
loop {
let res = tokio::select! {
res = stdout.recv() => res.context("Failed to get message over stdout receiver"),
_ = terminal.cancel.cancelled() => {
trace!("ws write: cancelled from outside");
let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("PTY KILLED"))).await;
if let Err(e) = ws_write.close().await {
debug!("Failed to close ws: {e:?}");
};
break
},
_ = cancel.cancelled() => {
let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("WS KILLED"))).await;
if let Err(e) = ws_write.close().await {
debug!("Failed to close ws: {e:?}");
};
break
}
};
match res {
Ok(bytes) => {
if let Err(e) =
ws_write.send(Message::Binary(bytes)).await
{
debug!("Failed to send to WS: {e:?}");
cancel.cancel();
break;
}
}
Err(e) => {
debug!("PTY -> WS channel read error: {e:?}");
let _ = ws_write.send(Message::Text(Utf8Bytes::from(format!("ERROR: {e:#}")))).await;
let _ = ws_write.close().await;
terminal.cancel();
break;
}
}
}
};
tokio::join!(ws_read, ws_write);
clean_up_terminals().await;
}))
}
/// Sentinels
const START_OF_OUTPUT: &str = "__KOMODO_START_OF_OUTPUT__";
const END_OF_OUTPUT: &str = "__KOMODO_END_OF_OUTPUT__";
pub async fn execute_terminal(
Json(ExecuteTerminalBody { terminal, command }): Json<
ExecuteTerminalBody,
>,
) -> serror::Result<axum::body::Body> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
let terminal = get_terminal(&terminal).await?;
// Read the bytes into lines
// This is done to check the lines for the EOF sentinal
let mut stdout = tokio_util::codec::FramedRead::new(
tokio_util::io::StreamReader::new(
tokio_stream::wrappers::BroadcastStream::new(
terminal.stdout.resubscribe(),
)
.map(|res| res.map_err(std::io::Error::other)),
),
tokio_util::codec::LinesCodec::new(),
);
let full_command = format!(
"printf '\n{START_OF_OUTPUT}\n\n'; {command}; rc=$? printf '\n{KOMODO_EXIT_CODE}%d\n{END_OF_OUTPUT}\n' \"$rc\"\n"
);
terminal
.stdin
.send(StdinMsg::Bytes(Bytes::from(full_command)))
.await
.context("Failed to send command to terminal stdin")?;
// Only start the response AFTER the start sentinel is printed
loop {
match stdout
.try_next()
.await
.context("Failed to read stdout line")?
{
Some(line) if line == START_OF_OUTPUT => break,
// Keep looping until the start sentinel received.
Some(_) => {}
None => {
return Err(
anyhow!(
"Stdout stream terminated before start sentinel received"
)
.into(),
);
}
}
}
Ok(axum::body::Body::from_stream(TerminalStream { stdout }))
}
pin_project! {
struct TerminalStream<S> { #[pin] stdout: S }
}
impl<S> Stream for TerminalStream<S>
where
S:
Stream<Item = Result<String, tokio_util::codec::LinesCodecError>>,
{
// Axum expects a stream of results
type Item = Result<String, String>;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
let this = self.project();
match this.stdout.poll_next(cx) {
Poll::Ready(None) => {
// This is if a None comes in before END_OF_OUTPUT.
// This probably means the terminal has exited early,
// and needs to be cleaned up
tokio::spawn(async move { clean_up_terminals().await });
Poll::Ready(None)
}
Poll::Ready(Some(line)) => {
match line {
Ok(line) if line.as_str() == END_OF_OUTPUT => {
// Stop the stream on end sentinel
Poll::Ready(None)
}
Ok(line) => Poll::Ready(Some(Ok(line + "\n"))),
Err(e) => Poll::Ready(Some(Err(format!("{e:?}")))),
}
}
Poll::Pending => Poll::Pending,
}
}
}
+6
View File
@@ -42,6 +42,12 @@ pub fn periphery_config() -> &'static PeripheryConfig {
repo_dir: env.periphery_repo_dir.or(config.repo_dir),
stack_dir: env.periphery_stack_dir.or(config.stack_dir),
build_dir: env.periphery_build_dir.or(config.build_dir),
disable_terminals: env
.periphery_disable_terminals
.unwrap_or(config.disable_terminals),
disable_container_exec: env
.periphery_disable_container_exec
.unwrap_or(config.disable_container_exec),
stats_polling_rate: env
.periphery_stats_polling_rate
.unwrap_or(config.stats_polling_rate),
+7 -4
View File
@@ -12,9 +12,9 @@ mod compose;
mod config;
mod docker;
mod helpers;
mod router;
mod ssl;
mod stats;
mod terminal;
async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
@@ -35,8 +35,8 @@ async fn app() -> anyhow::Result<()> {
let socket_addr = SocketAddr::from_str(&addr)
.context("failed to parse listen address")?;
let app = router::router()
.into_make_service_with_connect_info::<SocketAddr>();
let app =
api::router().into_make_service_with_connect_info::<SocketAddr>();
if config.ssl_enabled {
info!("🔒 Periphery SSL Enabled");
@@ -73,7 +73,10 @@ async fn main() -> anyhow::Result<()> {
tokio::select! {
res = app => return res?,
_ = term_signal.recv() => {},
_ = term_signal.recv() => {
info!("Exiting all active Terminals for shutdown");
terminal::delete_all_terminals().await;
},
}
Ok(())
+3
View File
@@ -189,6 +189,7 @@ impl StatsClient {
fn get_system_information(
sys: &sysinfo::System,
) -> SystemInformation {
let config = periphery_config();
SystemInformation {
name: System::name(),
os: System::long_os_version(),
@@ -201,5 +202,7 @@ fn get_system_information(
.next()
.map(|cpu| cpu.brand().to_string())
.unwrap_or_default(),
terminals_disabled: config.disable_terminals,
container_exec_disabled: config.disable_container_exec,
}
}
+346
View File
@@ -0,0 +1,346 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, OnceLock},
time::Duration,
};
use anyhow::{Context, anyhow};
use bytes::Bytes;
use komodo_client::{
api::write::TerminalRecreateMode, entities::server::TerminalInfo,
};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
type PtyName = String;
type PtyMap = tokio::sync::RwLock<HashMap<PtyName, Arc<Terminal>>>;
type StdinSender = mpsc::Sender<StdinMsg>;
type StdoutReceiver = broadcast::Receiver<Bytes>;
pub async fn create_terminal(
name: String,
command: String,
recreate: TerminalRecreateMode,
) -> anyhow::Result<()> {
trace!(
"CreateTerminal: {name} | command: {command} | recreate: {recreate:?}"
);
let mut terminals = terminals().write().await;
use TerminalRecreateMode::*;
if matches!(recreate, Never | DifferentCommand) {
if let Some(terminal) = terminals.get(&name) {
if terminal.command == command {
return Ok(());
} else if matches!(recreate, Never) {
return Err(anyhow!(
"Terminal {name} already exists, but has command {} instead of {command}",
terminal.command
));
}
}
}
if let Some(prev) = terminals.insert(
name,
Terminal::new(command)
.await
.context("Failed to init terminal")?
.into(),
) {
prev.cancel();
}
Ok(())
}
pub async fn delete_terminal(name: &str) {
if let Some(terminal) = terminals().write().await.remove(name) {
terminal.cancel.cancel();
}
}
pub async fn list_terminals() -> Vec<TerminalInfo> {
let mut terminals = terminals()
.read()
.await
.iter()
.map(|(name, terminal)| TerminalInfo {
name: name.to_string(),
command: terminal.command.clone(),
stored_size_kb: terminal.history.size_kb(),
})
.collect::<Vec<_>>();
terminals.sort_by(|a, b| a.name.cmp(&b.name));
terminals
}
pub async fn get_terminal(
name: &str,
) -> anyhow::Result<Arc<Terminal>> {
terminals()
.read()
.await
.get(name)
.cloned()
.with_context(|| format!("No terminal at {name}"))
}
pub async fn clean_up_terminals() {
terminals()
.write()
.await
.retain(|_, terminal| !terminal.cancel.is_cancelled());
}
pub async fn delete_all_terminals() {
terminals()
.write()
.await
.drain()
.for_each(|(_, terminal)| terminal.cancel());
// The terminals poll cancel every 500 millis, need to wait for them
// to finish cancelling.
tokio::time::sleep(Duration::from_millis(100)).await;
}
fn terminals() -> &'static PtyMap {
static TERMINALS: OnceLock<PtyMap> = OnceLock::new();
TERMINALS.get_or_init(Default::default)
}
#[derive(Clone, serde::Deserialize)]
pub struct ResizeDimensions {
rows: u16,
cols: u16,
}
#[derive(Clone)]
pub enum StdinMsg {
Bytes(Bytes),
Resize(ResizeDimensions),
}
pub struct Terminal {
/// The command that was used as the root command, eg `shell`
command: String,
pub cancel: CancellationToken,
pub stdin: StdinSender,
pub stdout: StdoutReceiver,
pub history: Arc<History>,
}
impl Terminal {
async fn new(command: String) -> anyhow::Result<Terminal> {
trace!("Creating terminal with command: {command}");
let terminal = native_pty_system()
.openpty(PtySize::default())
.context("Failed to open terminal")?;
let mut command_split = command.split(' ').map(|arg| arg.trim());
let cmd =
command_split.next().context("Command cannot be empty")?;
let mut cmd = CommandBuilder::new(cmd);
for arg in command_split {
cmd.arg(arg);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let mut child = terminal
.slave
.spawn_command(cmd)
.context("Failed to spawn child command")?;
// Check the child didn't stop immediately (after a little wait) with error
tokio::time::sleep(Duration::from_millis(100)).await;
if let Some(status) = child
.try_wait()
.context("Failed to check child process exit status")?
{
return Err(anyhow!(
"Child process exited immediately with code {}",
status.exit_code()
));
}
let mut terminal_write = terminal
.master
.take_writer()
.context("Failed to take terminal writer")?;
let mut terminal_read = terminal
.master
.try_clone_reader()
.context("Failed to clone terminal reader")?;
let cancel = CancellationToken::new();
// CHILD WAIT TASK
let _cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
loop {
if _cancel.is_cancelled() {
trace!("child wait handle cancelled from outside");
if let Err(e) = child.kill() {
debug!("Failed to kill child | {e:?}");
}
break;
}
match child.try_wait() {
Ok(Some(code)) => {
debug!("child exited with code {code}");
_cancel.cancel();
break;
}
Ok(None) => {
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => {
debug!("failed to wait for child | {e:?}");
_cancel.cancel();
break;
}
}
}
});
// WS (channel) -> STDIN TASK
// Theres only one consumer here, so use mpsc
let (stdin, mut channel_read) =
tokio::sync::mpsc::channel::<StdinMsg>(8192);
let _cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
loop {
if _cancel.is_cancelled() {
trace!("terminal write: cancelled from outside");
break;
}
match channel_read.blocking_recv() {
Some(StdinMsg::Bytes(bytes)) => {
if let Err(e) = terminal_write.write_all(&bytes) {
debug!("Failed to write to PTY: {e:?}");
_cancel.cancel();
break;
}
}
Some(StdinMsg::Resize(dimensions)) => {
if let Err(e) = terminal.master.resize(PtySize {
cols: dimensions.cols,
rows: dimensions.rows,
pixel_width: 0,
pixel_height: 0,
}) {
debug!("Failed to resize | {e:?}");
_cancel.cancel();
break;
};
}
None => {
debug!("WS -> PTY channel read error: Disconnected");
_cancel.cancel();
break;
}
}
}
});
let history = Arc::new(History::default());
// PTY -> WS (channel) TASK
// Uses broadcast to output to multiple client simultaneously
let (write, stdout) =
tokio::sync::broadcast::channel::<Bytes>(8192);
let _cancel = cancel.clone();
let _history = history.clone();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
if _cancel.is_cancelled() {
trace!("terminal read: cancelled from outside");
break;
}
match terminal_read.read(&mut buf) {
Ok(0) => {
// EOF
trace!("Got PTY read EOF");
_cancel.cancel();
break;
}
Ok(n) => {
_history.push(&buf[..n]);
if let Err(e) =
write.send(Bytes::copy_from_slice(&buf[..n]))
{
debug!("PTY -> WS channel send error: {e:?}");
_cancel.cancel();
break;
}
}
Err(e) => {
debug!("Failed to read for PTY: {e:?}");
_cancel.cancel();
break;
}
}
}
});
trace!("terminal tasks spawned");
Ok(Terminal {
command,
cancel,
stdin,
stdout,
history,
})
}
pub fn cancel(&self) {
trace!("Cancel called");
self.cancel.cancel();
}
}
/// 1 MiB max history size per terminal
const MAX_BYTES: usize = 1024 * 1024;
pub struct History {
buf: std::sync::RwLock<VecDeque<u8>>,
}
impl Default for History {
fn default() -> Self {
History {
buf: VecDeque::with_capacity(MAX_BYTES).into(),
}
}
}
impl History {
/// Push some bytes, evicting the oldest when full.
fn push(&self, bytes: &[u8]) {
let mut buf = self.buf.write().unwrap();
for byte in bytes {
if buf.len() == MAX_BYTES {
buf.pop_front();
}
buf.push_back(*byte);
}
}
pub fn bytes_parts(&self) -> (Bytes, Bytes) {
let buf = self.buf.read().unwrap();
let (a, b) = buf.as_slices();
(Bytes::copy_from_slice(a), Bytes::copy_from_slice(b))
}
pub fn size_kb(&self) -> f64 {
self.buf.read().unwrap().len() as f64 / 1024.0
}
}
+67
View File
@@ -0,0 +1,67 @@
## This is used to customize the shell prompt in Periphery container for Terminals
"$schema" = 'https://starship.rs/config-schema.json'
add_newline = true
format = "$time$hostname$container$memory_usage$all"
[character]
success_symbol = "[❯](bright-blue bold)"
error_symbol = "[❯](bright-red bold)"
[package]
disabled = true
[time]
format = "[❯$time](white dimmed) "
time_format = "%l:%M %p"
utc_time_offset = '-5'
disabled = true
[username]
format = "[❯ $user]($style) "
style_user = "bright-green"
show_always = true
[hostname]
format = "[❯ $hostname]($style) "
style = "bright-blue"
ssh_only = false
[directory]
format = "[❯ $path]($style)[$read_only]($read_only_style) "
style = "bright-cyan"
[git_branch]
format = "[❯ $symbol$branch(:$remote_branch)]($style) "
style = "bright-purple"
[git_status]
style = "bright-purple"
[rust]
format = "[❯ $symbol($version )]($style)"
symbol = "rustc "
style = "bright-red"
[nodejs]
format = "[❯ $symbol($version )]($style)"
symbol = "nodejs "
style = "bright-red"
[memory_usage]
format = "[❯ mem ${ram} ${ram_pct}]($style) "
threshold = -1
style = "white"
[cmd_duration]
format = "[❯ $duration]($style)"
style = "bright-yellow"
[container]
format = "[❯ 🦎 periphery container ]($style)"
style = "bright-green"
[aws]
disabled = true
+1 -2
View File
@@ -12,7 +12,6 @@ mod deployment;
mod procedure;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
@@ -23,7 +22,6 @@ pub use deployment::*;
pub use procedure::*;
pub use repo::*;
pub use server::*;
pub use server_template::*;
pub use stack::*;
pub use sync::*;
@@ -128,6 +126,7 @@ pub enum Execution {
DeployStackIfChanged(DeployStackIfChanged),
BatchDeployStackIfChanged(BatchDeployStackIfChanged),
PullStack(PullStack),
BatchPullStack(BatchPullStack),
StartStack(StartStack),
RestartStack(RestartStack),
PauseStack(PauseStack),
@@ -1,24 +0,0 @@
use derive_empty_traits::EmptyTraits;
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::update::Update;
use super::KomodoExecuteRequest;
/// Launch an EC2 instance with the specified config.
/// Response: [Update].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct LaunchServer {
/// The name of the created server.
pub name: String,
/// The server template used to define the config.
pub server_template: String,
}
+31
View File
@@ -152,6 +152,37 @@ pub struct PullStack {
//
/// Pulls multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse].
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Resolve,
EmptyTraits,
Parser,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(BatchExecutionResponse)]
#[error(serror::Error)]
pub struct BatchPullStack {
/// Id or name or wildcard pattern or regex.
/// Supports multiline and comma delineated combinations of the above.
///
/// Example:
/// ```
/// # match all foo-* stacks
/// foo-*
/// # add some more
/// extra-stack-1, extra-stack-2
/// ```
pub pattern: String,
}
//
/// Starts the target stack. `docker compose start`. Response: [Update]
#[typeshare]
#[derive(
+1
View File
@@ -69,5 +69,6 @@
pub mod auth;
pub mod execute;
pub mod read;
pub mod terminal;
pub mod user;
pub mod write;
+2 -2
View File
@@ -14,7 +14,6 @@ mod procedure;
mod provider;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
mod tag;
@@ -35,7 +34,6 @@ pub use procedure::*;
pub use provider::*;
pub use repo::*;
pub use server::*;
pub use server_template::*;
pub use stack::*;
pub use sync::*;
pub use tag::*;
@@ -106,6 +104,8 @@ pub struct GetCoreInfoResponse {
pub disable_confirm_dialog: bool,
/// The repo owners for which github webhook management api is available
pub github_webhook_owners: Vec<String>,
/// Whether to disable websocket automatic reconnect.
pub disable_websocket_reconnect: bool,
}
//
+25 -1
View File
@@ -13,7 +13,7 @@ use crate::entities::{
},
server::{
Server, ServerActionState, ServerListItem, ServerQuery,
ServerState,
ServerState, TerminalInfo,
},
stack::ComposeProject,
stats::{
@@ -628,3 +628,27 @@ pub struct GetServersSummaryResponse {
/// The number of disabled servers.
pub disabled: I64,
}
//
/// List the current terminals on specified server.
/// Response: [ListTerminalsResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListTerminalsResponse)]
#[error(serror::Error)]
pub struct ListTerminals {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub server: String,
/// Force a fresh call to Periphery for the list.
/// Otherwise the response will be cached for 30s
#[serde(default)]
pub fresh: bool,
}
#[typeshare]
pub type ListTerminalsResponse = Vec<TerminalInfo>;
@@ -1,86 +0,0 @@
use derive_empty_traits::EmptyTraits;
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::server_template::{
ServerTemplate, ServerTemplateListItem, ServerTemplateQuery,
};
use super::KomodoReadRequest;
//
/// Get a specific server template by id or name. Response: [ServerTemplate].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(GetServerTemplateResponse)]
#[error(serror::Error)]
pub struct GetServerTemplate {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub server_template: String,
}
#[typeshare]
pub type GetServerTemplateResponse = ServerTemplate;
//
/// List server templates matching structured query. Response: [ListServerTemplatesResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListServerTemplatesResponse)]
#[error(serror::Error)]
pub struct ListServerTemplates {
#[serde(default)]
pub query: ServerTemplateQuery,
}
#[typeshare]
pub type ListServerTemplatesResponse = Vec<ServerTemplateListItem>;
//
/// List server templates matching structured query. Response: [ListFullServerTemplatesResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListFullServerTemplatesResponse)]
#[error(serror::Error)]
pub struct ListFullServerTemplates {
#[serde(default)]
pub query: ServerTemplateQuery,
}
#[typeshare]
pub type ListFullServerTemplatesResponse = Vec<ServerTemplate>;
//
/// Gets a summary of data relating to all server templates.
/// Response: [GetServerTemplatesSummaryResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(GetServerTemplatesSummaryResponse)]
#[error(serror::Error)]
pub struct GetServerTemplatesSummary {}
/// Response for [GetServerTemplatesSummary].
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GetServerTemplatesSummaryResponse {
/// The total number of server templates.
pub total: u32,
}
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Query to connect to a terminal (interactive shell over websocket) on the given server.
/// TODO: Document calling.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnectTerminalQuery {
/// Server Id or name
pub server: String,
/// Each periphery can keep multiple terminals open.
/// If a terminals with the specified name does not exist,
/// the call will fail.
/// Create a terminal using [CreateTerminal][super::write::server::CreateTerminal]
pub terminal: String,
}
/// Query to connect to a container exec session (interactive shell over websocket) on the given server.
/// TODO: Document calling.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnectContainerExecQuery {
/// Server Id or name
pub server: String,
/// The container name
pub container: String,
/// The shell to connect to
pub shell: String,
}
/// Execute a terminal command on the given server.
/// TODO: Document calling.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ExecuteTerminalBody {
/// Server Id or name
pub server: String,
/// The name of the terminal on the server to use to execute.
/// If the terminal at name exists, it will be used to execute the command.
/// Otherwise, a new terminal will be created for this command, which will
/// persist until it exits or is deleted.
pub terminal: String,
/// The command to execute.
pub command: String,
}
+1 -1
View File
@@ -81,7 +81,7 @@ pub struct DeleteBuild {
#[response(Build)]
#[error(serror::Error)]
pub struct UpdateBuild {
/// The id of the build to update.
/// The id or name of the build to update.
pub id: String,
/// The partial config update to apply.
pub config: _PartialBuildConfig,
-2
View File
@@ -10,7 +10,6 @@ mod procedure;
mod provider;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
mod tags;
@@ -30,7 +29,6 @@ pub use procedure::*;
pub use provider::*;
pub use repo::*;
pub use server::*;
pub use server_template::*;
pub use stack::*;
pub use sync::*;
pub use tags::*;
+81
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{
NoData,
server::{_PartialServerConfig, Server},
update::Update,
};
@@ -105,3 +106,83 @@ pub struct CreateNetwork {
/// The name of the network to create.
pub name: String,
}
//
/// Configures the behavior of [CreateTerminal] if the
/// specified terminal name already exists.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub enum TerminalRecreateMode {
/// Never kill the old terminal if it already exists.
/// If the command is different, returns error.
#[default]
Never,
/// Always kill the old terminal and create new one
Always,
/// Only kill and recreate if the command is different.
DifferentCommand,
}
/// Create a terminal on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct CreateTerminal {
/// Server Id or name
pub server: String,
/// The name of the terminal on the server to create.
pub name: String,
/// The shell command (eg `bash`) to init the shell.
///
/// This can also include args:
/// `docker exec -it container sh`
///
/// Default: `bash`
#[serde(default = "default_command")]
pub command: String,
/// Default: `Never`
#[serde(default)]
pub recreate: TerminalRecreateMode,
}
fn default_command() -> String {
String::from("bash")
}
//
/// Delete a terminal on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteTerminal {
/// Server Id or name
pub server: String,
/// The name of the terminal on the server to delete.
pub terminal: String,
}
/// Delete all terminals on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteAllTerminals {
/// Server Id or name
pub server: String,
}
@@ -1,105 +0,0 @@
use derive_empty_traits::EmptyTraits;
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{
server_template::{PartialServerTemplateConfig, ServerTemplate},
update::Update,
};
use super::KomodoWriteRequest;
//
/// Create a server template. Response: [ServerTemplate].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(ServerTemplate)]
#[error(serror::Error)]
pub struct CreateServerTemplate {
/// The name given to newly created server template.
pub name: String,
/// Optional partial config to initialize the server template with.
#[serde(default)]
pub config: PartialServerTemplateConfig,
}
//
/// Creates a new server template with given `name` and the configuration
/// of the server template at the given `id`. Response: [ServerTemplate]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(ServerTemplate)]
#[error(serror::Error)]
pub struct CopyServerTemplate {
/// The name of the new server template.
pub name: String,
/// The id of the server template to copy.
pub id: String,
}
//
/// Deletes the server template at the given id, and returns the deleted server template.
/// Response: [ServerTemplate]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(ServerTemplate)]
#[error(serror::Error)]
pub struct DeleteServerTemplate {
/// The id or name of the server template to delete.
pub id: String,
}
//
/// Update the server template at the given id, and return the updated server template.
/// Response: [ServerTemplate].
///
/// Note. This method updates only the fields which are set in the [PartialServerTemplateConfig],
/// effectively merging diffs into the final document.
/// This is helpful when multiple users are using
/// the same resources concurrently by ensuring no unintentional
/// field changes occur from out of date local state.
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(ServerTemplate)]
#[error(serror::Error)]
pub struct UpdateServerTemplate {
/// The id of the server template to update.
pub id: String,
/// The partial config update to apply.
pub config: PartialServerTemplateConfig,
}
//
/// Rename the ServerTemplate at id to the given name.
/// Response: [Update].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct RenameServerTemplate {
/// The id or name of the ServerTemplate to rename.
pub id: String,
/// The new name.
pub name: String,
}
+7
View File
@@ -133,6 +133,12 @@ pub struct ActionConfig {
#[builder(default)]
pub webhook_secret: String,
/// Whether deno will be instructed to reload all dependencies,
/// this can usually be kept false outside of development.
#[serde(default)]
#[builder(default)]
pub reload_deno_deps: bool,
/// Typescript file contents using pre-initialized `komodo` client.
/// Supports variable / secret interpolation.
#[serde(default, deserialize_with = "file_contents_deserializer")]
@@ -177,6 +183,7 @@ impl Default for ActionConfig {
failure_alert: default_failure_alert(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
reload_deno_deps: Default::default(),
file_contents: Default::default(),
}
}
+7 -18
View File
@@ -106,6 +106,8 @@ pub struct Env {
pub komodo_disable_confirm_dialog: Option<bool>,
/// Override `disable_non_admin_create`
pub komodo_disable_non_admin_create: Option<bool>,
/// Override `disable_websocket_reconnect`
pub komodo_disable_websocket_reconnect: Option<bool>,
/// Override `local_auth`
pub komodo_local_auth: Option<bool>,
@@ -209,11 +211,6 @@ pub struct Env {
/// Override `aws.secret_access_key` with file
pub komodo_aws_secret_access_key_file: Option<PathBuf>,
/// Override `hetzner.token`
pub komodo_hetzner_token: Option<String>,
/// Override `hetzner.token` with file
pub komodo_hetzner_token_file: Option<PathBuf>,
/// Override `ssl_enabled`.
pub komodo_ssl_enabled: Option<bool>,
/// Override `ssl_key_file`
@@ -277,6 +274,10 @@ pub struct CoreConfig {
#[serde(default)]
pub disable_confirm_dialog: bool,
/// Disable the UI websocket from automatically reconnecting.
#[serde(default)]
pub disable_websocket_reconnect: bool,
/// If defined, ensure an enabled first server exists at this address.
/// Example: `http://periphery:8120`
#[serde(default)]
@@ -456,10 +457,6 @@ pub struct CoreConfig {
#[serde(default)]
pub aws: AwsCredentials,
/// Configure Hetzner credentials to use with Hetzner builds / server launches.
#[serde(default)]
pub hetzner: HetznerCredentials,
// =================
// = Git Providers =
// =================
@@ -601,6 +598,7 @@ impl CoreConfig {
transparent_mode: config.transparent_mode,
ui_write_disabled: config.ui_write_disabled,
disable_confirm_dialog: config.disable_confirm_dialog,
disable_websocket_reconnect: config.disable_websocket_reconnect,
enable_new_users: config.enable_new_users,
disable_user_registration: config.disable_user_registration,
disable_non_admin_create: config.disable_non_admin_create,
@@ -646,9 +644,6 @@ impl CoreConfig {
&config.aws.secret_access_key,
),
},
hetzner: HetznerCredentials {
token: empty_or_redacted(&config.hetzner.token),
},
secrets: config
.secrets
.into_iter()
@@ -760,12 +755,6 @@ pub struct AwsCredentials {
pub secret_access_key: String,
}
/// Provide Hetzner credentials for Komodo to use.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HetznerCredentials {
pub token: String,
}
/// Provide configuration for a Github Webhook app.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GithubWebhookAppConfig {
@@ -124,6 +124,10 @@ pub struct Env {
pub periphery_stack_dir: Option<PathBuf>,
/// Override `build_dir`
pub periphery_build_dir: Option<PathBuf>,
/// Override `disable_terminals`
pub periphery_disable_terminals: Option<bool>,
/// Override `disable_container_exec`
pub periphery_disable_container_exec: Option<bool>,
/// Override `stats_polling_rate`
pub periphery_stats_polling_rate: Option<Timelength>,
/// Override `legacy_compose_cli`
@@ -203,6 +207,18 @@ pub struct PeripheryConfig {
/// Default: empty
pub build_dir: Option<PathBuf>,
/// Whether to disable the create terminal
/// and disallow direct remote shell access.
/// Default: false
#[serde(default)]
pub disable_terminals: bool,
/// Whether to disable the container exec api
/// and disallow remote container shell access.
/// Default: false
#[serde(default)]
pub disable_container_exec: bool,
/// The rate at which the system stats will be polled to update the cache.
/// Default: `5-sec`
#[serde(default = "default_stats_polling_rate")]
@@ -256,7 +272,7 @@ pub struct PeripheryConfig {
pub docker_registries: Vec<DockerRegistry>,
/// Whether to enable ssl.
/// Default: false (will change in later release)
/// Default: true
#[serde(default = "default_ssl_enabled")]
pub ssl_enabled: bool,
@@ -286,7 +302,7 @@ fn default_stats_polling_rate() -> Timelength {
}
fn default_ssl_enabled() -> bool {
false
true
}
impl Default for PeripheryConfig {
@@ -298,6 +314,8 @@ impl Default for PeripheryConfig {
repo_dir: None,
stack_dir: None,
build_dir: None,
disable_terminals: Default::default(),
disable_container_exec: Default::default(),
stats_polling_rate: default_stats_polling_rate(),
legacy_compose_cli: Default::default(),
logging: Default::default(),
@@ -324,6 +342,8 @@ impl PeripheryConfig {
repo_dir: self.repo_dir.clone(),
stack_dir: self.stack_dir.clone(),
build_dir: self.build_dir.clone(),
disable_terminals: self.disable_terminals,
disable_container_exec: self.disable_container_exec,
stats_polling_rate: self.stats_polling_rate,
legacy_compose_cli: self.legacy_compose_cli,
logging: self.logging.clone(),
+3 -18
View File
@@ -54,8 +54,6 @@ pub mod repo;
pub mod resource;
/// Subtypes of [Server][server::Server].
pub mod server;
/// Subtypes of [ServerTemplate][server_template::ServerTemplate].
pub mod server_template;
/// Subtypes of [Stack][stack::Stack]
pub mod stack;
/// Subtypes for server stats reporting.
@@ -759,13 +757,6 @@ pub enum Operation {
DeleteAlerter,
TestAlerter,
// server template
CreateServerTemplate,
UpdateServerTemplate,
RenameServerTemplate,
DeleteServerTemplate,
LaunchServer,
// sync
CreateResourceSync,
UpdateResourceSync,
@@ -878,7 +869,6 @@ pub enum ResourceTarget {
Action(String),
Builder(String),
Alerter(String),
ServerTemplate(String),
ResourceSync(String),
}
@@ -897,7 +887,6 @@ impl ResourceTarget {
ResourceTarget::Alerter(id) => id,
ResourceTarget::Procedure(id) => id,
ResourceTarget::Action(id) => id,
ResourceTarget::ServerTemplate(id) => id,
ResourceTarget::ResourceSync(id) => id,
};
(self.extract_variant(), id)
@@ -956,12 +945,6 @@ impl From<&procedure::Procedure> for ResourceTarget {
}
}
impl From<&server_template::ServerTemplate> for ResourceTarget {
fn from(server_template: &server_template::ServerTemplate) -> Self {
Self::ServerTemplate(server_template.id.clone())
}
}
impl From<&sync::ResourceSync> for ResourceTarget {
fn from(resource_sync: &sync::ResourceSync) -> Self {
Self::ResourceSync(resource_sync.id.clone())
@@ -992,7 +975,6 @@ impl ResourceTargetVariant {
ResourceTargetVariant::Repo => "repo",
ResourceTargetVariant::Alerter => "alerter",
ResourceTargetVariant::Procedure => "procedure",
ResourceTargetVariant::ServerTemplate => "server_template",
ResourceTargetVariant::ResourceSync => "resource_sync",
ResourceTargetVariant::Stack => "stack",
ResourceTargetVariant::Action => "action",
@@ -1009,3 +991,6 @@ pub enum ScheduleFormat {
English,
Cron,
}
/// Used with ExecuteTerminal to capture the exit code
pub const KOMODO_EXIT_CODE: &str = "__KOMODO_EXIT_CODE:";
+17
View File
@@ -38,6 +38,10 @@ pub struct ServerListItemInfo {
pub send_mem_alerts: bool,
/// Whether server is configured to send disk alerts.
pub send_disk_alerts: bool,
/// Whether terminals are disabled for this Server.
pub terminals_disabled: bool,
/// Whether container exec is disabled for this Server.
pub container_exec_disabled: bool,
}
#[typeshare(serialized_as = "Partial<ServerConfig>")]
@@ -276,6 +280,19 @@ pub struct ServerHealth {
pub disks: HashMap<PathBuf, ServerHealthState>,
}
/// Info about an active terminal on a server.
/// Retrieve with [ListTerminals][crate::api::read::server::ListTerminals].
#[typeshare]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct TerminalInfo {
/// The name of the terminal.
pub name: String,
/// The root program / args of the pty
pub command: String,
/// The size of the terminal history in memory.
pub stored_size_kb: f64,
}
/// Current pending actions on the server.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
@@ -1,216 +0,0 @@
use derive_builder::Builder;
use partial_derive2::Partial;
use serde::{Deserialize, Serialize};
use strum::{AsRefStr, Display};
use typeshare::typeshare;
use crate::{
deserializers::{
option_string_list_deserializer, string_list_deserializer,
},
entities::builder::AwsBuilderConfig,
};
#[typeshare(serialized_as = "Partial<AwsServerTemplateConfig>")]
pub type _PartialAwsServerTemplateConfig =
PartialAwsServerTemplateConfig;
/// Aws EC2 instance config.
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)]
#[partial_derive(Debug, Clone, Default, Serialize, Deserialize)]
#[partial(skip_serializing_none, from, diff)]
pub struct AwsServerTemplateConfig {
/// The aws region to launch the server in, eg. us-east-1
#[serde(default = "default_region")]
#[builder(default = "default_region()")]
#[partial_default(default_region())]
pub region: String,
/// The instance type to launch, eg. c5.2xlarge
#[serde(default = "default_instance_type")]
#[builder(default = "default_instance_type()")]
#[partial_default(default_instance_type())]
pub instance_type: String,
/// Specify the ami id to use. Must be set up to start the periphery binary on startup.
pub ami_id: String,
/// The subnet to assign to the instance.
pub subnet_id: String,
/// The key pair name to give to the instance in case SSH access required.
pub key_pair_name: String,
/// Assign a public ip to the instance. Depending on how your network is
/// setup, this may be required for the instance to reach the public internet.
#[serde(default = "default_assign_public_ip")]
#[builder(default = "default_assign_public_ip()")]
#[partial_default(default_assign_public_ip())]
pub assign_public_ip: bool,
/// Use the instances public ip as the address for the server.
/// Could be used when build instances are created in another non-interconnected network to the core api.
#[serde(default = "default_use_public_ip")]
#[builder(default = "default_use_public_ip()")]
#[partial_default(default_use_public_ip())]
pub use_public_ip: bool,
/// The port periphery will be running on in AMI.
/// Default: `8120`
#[serde(default = "default_port")]
#[builder(default = "default_port()")]
#[partial_default(default_port())]
pub port: i32,
/// Whether Periphery will be running on https
#[serde(default = "default_use_https")]
#[builder(default = "default_use_https()")]
#[partial_default(default_use_https())]
pub use_https: bool,
/// The security groups to give to the instance.
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub security_group_ids: Vec<String>,
/// Specify the EBS volumes to attach.
#[serde(default = "default_volumes")]
#[builder(default = "default_volumes()")]
#[partial_default(default_volumes())]
pub volumes: Vec<AwsVolume>,
/// The user data to deploy the instance with.
#[serde(default = "default_user_data")]
#[builder(default = "default_user_data()")]
#[partial_default(default_user_data())]
pub user_data: String,
}
impl AwsServerTemplateConfig {
pub fn builder() -> AwsServerTemplateConfigBuilder {
AwsServerTemplateConfigBuilder::default()
}
}
fn default_region() -> String {
String::from("us-east-1")
}
fn default_instance_type() -> String {
String::from("t3.small")
}
fn default_assign_public_ip() -> bool {
true
}
fn default_use_public_ip() -> bool {
false
}
fn default_volumes() -> Vec<AwsVolume> {
vec![AwsVolume {
device_name: "/dev/sda1".to_string(),
size_gb: 20,
volume_type: AwsVolumeType::Gp2,
iops: 0,
throughput: 0,
}]
}
fn default_port() -> i32 {
8120
}
fn default_use_https() -> bool {
true
}
fn default_user_data() -> String {
String::from("#!/bin/bash
apt update
apt upgrade -y
curl -fsSL https://get.docker.com | sh
systemctl enable docker.service
systemctl enable containerd.service
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | HOME=/root python3
systemctl enable periphery.service")
}
impl Default for AwsServerTemplateConfig {
fn default() -> Self {
Self {
region: default_region(),
instance_type: default_instance_type(),
assign_public_ip: default_assign_public_ip(),
use_public_ip: default_use_public_ip(),
port: default_port(),
use_https: default_use_https(),
volumes: default_volumes(),
ami_id: Default::default(),
subnet_id: Default::default(),
key_pair_name: Default::default(),
user_data: default_user_data(),
security_group_ids: Default::default(),
}
}
}
/// For information on AWS volumes, see
/// `<https://docs.aws.amazon.com/ebs/latest/userguide/ebs-volume-types.html>`.
#[typeshare]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AwsVolume {
/// The device name (for example, `/dev/sda1` or `xvdh`).
pub device_name: String,
/// The size of the volume in GB
pub size_gb: i32,
/// The type of volume. Options: gp2, gp3, io1, io2.
pub volume_type: AwsVolumeType,
/// The iops of the volume, or 0 for AWS default.
pub iops: i32,
/// The throughput of the volume, or 0 for AWS default.
pub throughput: i32,
}
#[typeshare]
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
Display,
AsRefStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AwsVolumeType {
#[default]
Gp2,
Gp3,
Io1,
Io2,
}
impl AwsServerTemplateConfig {
pub fn from_builder_config(value: &AwsBuilderConfig) -> Self {
Self {
region: value.region.clone(),
instance_type: value.instance_type.clone(),
volumes: vec![AwsVolume {
device_name: "/dev/sda1".to_string(),
size_gb: value.volume_gb,
volume_type: AwsVolumeType::Gp2,
iops: 0,
throughput: 0,
}],
ami_id: value.ami_id.clone(),
subnet_id: value.subnet_id.clone(),
security_group_ids: value.security_group_ids.clone(),
key_pair_name: value.key_pair_name.clone(),
assign_public_ip: value.assign_public_ip,
use_public_ip: value.use_public_ip,
port: value.port,
use_https: value.use_https,
user_data: value.user_data.clone(),
}
}
}
@@ -1,255 +0,0 @@
use std::collections::HashMap;
use derive_builder::Builder;
use partial_derive2::Partial;
use serde::{Deserialize, Serialize};
use strum::AsRefStr;
use typeshare::typeshare;
use crate::{
deserializers::{
option_string_list_deserializer, string_list_deserializer,
},
entities::I64,
};
#[typeshare(serialized_as = "Partial<HetznerServerTemplateConfig>")]
pub type _PartialHetznerServerTemplateConfig =
PartialHetznerServerTemplateConfig;
/// Hetzner server config.
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)]
#[partial_derive(Debug, Clone, Default, Serialize, Deserialize)]
#[partial(skip_serializing_none, from, diff)]
pub struct HetznerServerTemplateConfig {
/// ID or name of the Image the Server is created from
#[serde(default = "default_image")]
#[builder(default = "default_image()")]
#[partial_default(default_image())]
pub image: String,
/// ID or name of Datacenter to create Server in
#[serde(default)]
#[builder(default)]
pub datacenter: HetznerDatacenter,
/// ID of the Placement Group the server should be in,
/// Or 0 to not use placement group.
#[serde(default)]
#[builder(default)]
pub placement_group: I64,
/// ID or name of the Server type this Server should be created with
#[serde(default)]
#[builder(default)]
pub server_type: HetznerServerType,
/// SSH key IDs ( integer ) or names ( string ) which should be injected into the Server at creation time
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub ssh_keys: Vec<String>,
/// Network IDs which should be attached to the Server private network interface at the creation time
#[serde(default)]
#[builder(default)]
pub private_network_ids: Vec<I64>,
/// Attach an IPv4 on the public NIC. If false, no IPv4 address will be attached.
#[serde(default)]
#[builder(default)]
pub enable_public_ipv4: bool,
/// Attach an IPv6 on the public NIC. If false, no IPv6 address will be attached.
#[serde(default)]
#[builder(default)]
pub enable_public_ipv6: bool,
/// Connect to the instance using it's public ip.
#[serde(default)]
#[builder(default)]
pub use_public_ip: bool,
/// The port periphery will be running on in AMI.
/// Default: `8120`
#[serde(default = "default_port")]
#[builder(default = "default_port()")]
#[partial_default(default_port())]
pub port: i32,
/// Whether Periphery will be running on https
#[serde(default = "default_use_https")]
#[builder(default = "default_use_https()")]
#[partial_default(default_use_https())]
pub use_https: bool,
/// The firewalls to attach to the instance
#[serde(default)]
#[builder(default)]
pub firewall_ids: Vec<I64>,
/// Labels for the server
#[serde(default)]
#[builder(default)]
pub labels: HashMap<String, String>,
/// Specs for volumes to attach
#[serde(default)]
#[builder(default)]
pub volumes: Vec<HetznerVolumeSpecs>,
/// Cloud-Init user data to use during Server creation. This field is limited to 32KiB.
#[serde(default = "default_user_data")]
#[builder(default = "default_user_data()")]
#[partial_default(default_user_data())]
pub user_data: String,
}
impl HetznerServerTemplateConfig {
pub fn builder() -> HetznerServerTemplateConfigBuilder {
HetznerServerTemplateConfigBuilder::default()
}
}
fn default_image() -> String {
String::from("ubuntu-24.04")
}
fn default_port() -> i32 {
8120
}
fn default_use_https() -> bool {
true
}
fn default_user_data() -> String {
String::from("#cloud-config
runcmd:
- apt update
- apt upgrade -y
- curl -fsSL https://get.docker.com | sh
- systemctl enable docker.service
- systemctl enable containerd.service
- curl -sSL 'https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py' | HOME=/root python3
- systemctl enable periphery.service")
}
impl Default for HetznerServerTemplateConfig {
fn default() -> Self {
Self {
port: default_port(),
use_https: default_use_https(),
image: default_image(),
datacenter: Default::default(),
private_network_ids: Default::default(),
placement_group: Default::default(),
enable_public_ipv4: Default::default(),
enable_public_ipv6: Default::default(),
firewall_ids: Default::default(),
server_type: Default::default(),
ssh_keys: Default::default(),
user_data: default_user_data(),
use_public_ip: Default::default(),
labels: Default::default(),
volumes: Default::default(),
}
}
}
#[typeshare]
#[derive(
Debug, Clone, PartialEq, Serialize, Deserialize, Builder,
)]
pub struct HetznerVolumeSpecs {
/// A name for the volume
pub name: String,
/// Size of the volume in GB
pub size_gb: I64,
/// The format for the volume
#[serde(default)]
#[builder(default)]
pub format: HetznerVolumeFormat,
/// Labels for the volume
#[serde(default)]
#[builder(default)]
pub labels: HashMap<String, String>,
}
#[typeshare]
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
)]
pub enum HetznerVolumeFormat {
#[default]
Xfs,
Ext4,
}
#[typeshare]
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Serialize,
Deserialize,
AsRefStr,
)]
#[allow(clippy::enum_variant_names)]
pub enum HetznerServerType {
// Shared
#[default]
/// CPX11 - AMD 2 Cores, 2 Gb Ram, 40 Gb disk
SharedAmd2Core2Ram40Disk,
/// CAX11 - Arm 2 Cores, 4 Gb Ram, 40 Gb disk
SharedArm2Core4Ram40Disk,
/// CX22 - Intel 2 Cores, 4 Gb Ram, 40 Gb disk
SharedIntel2Core4Ram40Disk,
/// CPX21 - AMD 3 Cores, 4 Gb Ram, 80 Gb disk
SharedAmd3Core4Ram80Disk,
/// CAX21 - Arm 4 Cores, 8 Gb Ram, 80 Gb disk
SharedArm4Core8Ram80Disk,
/// CX32 - Intel 4 Cores, 8 Gb Ram, 80 Gb disk
SharedIntel4Core8Ram80Disk,
/// CPX31 - AMD 4 Cores, 8 Gb Ram, 160 Gb disk
SharedAmd4Core8Ram160Disk,
/// CAX31 - Arm 8 Cores, 16 Gb Ram, 160 Gb disk
SharedArm8Core16Ram160Disk,
/// CX42 - Intel 8 Cores, 16 Gb Ram, 160 Gb disk
SharedIntel8Core16Ram160Disk,
/// CPX41 - AMD 8 Cores, 16 Gb Ram, 240 Gb disk
SharedAmd8Core16Ram240Disk,
/// CAX41 - Arm 16 Cores, 32 Gb Ram, 320 Gb disk
SharedArm16Core32Ram320Disk,
/// CX52 - Intel 16 Cores, 32 Gb Ram, 320 Gb disk
SharedIntel16Core32Ram320Disk,
/// CPX51 - AMD 16 Cores, 32 Gb Ram, 360 Gb disk
SharedAmd16Core32Ram360Disk,
// Dedicated
/// CCX13 - AMD 2 Cores, 8 Gb Ram, 80 Gb disk
DedicatedAmd2Core8Ram80Disk,
/// CCX23 - AMD 4 Cores, 16 Gb Ram, 160 Gb disk
DedicatedAmd4Core16Ram160Disk,
/// CCX33 - AMD 8 Cores, 32 Gb Ram, 240 Gb disk
DedicatedAmd8Core32Ram240Disk,
/// CCX43 - AMD 16 Cores, 64 Gb Ram, 360 Gb disk
DedicatedAmd16Core64Ram360Disk,
/// CCX53 - AMD 32 Cores, 128 Gb Ram, 600 Gb disk
DedicatedAmd32Core128Ram600Disk,
/// CCX63 - AMD 48 Cores, 192 Gb Ram, 960 Gb disk
DedicatedAmd48Core192Ram960Disk,
}
#[typeshare]
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Serialize,
Deserialize,
AsRefStr,
)]
pub enum HetznerDatacenter {
#[default]
Nuremberg1Dc3,
Helsinki1Dc2,
Falkenstein1Dc14,
AshburnDc1,
HillsboroDc1,
SingaporeDc1,
}
@@ -1,312 +0,0 @@
use bson::{Document, doc};
use derive_default_builder::DefaultBuilder;
use derive_variants::EnumVariants;
use partial_derive2::{Diff, MaybeNone, PartialDiff};
use serde::{Deserialize, Serialize};
use strum::{AsRefStr, Display, EnumString};
use typeshare::typeshare;
use self::{
aws::AwsServerTemplateConfig, hetzner::HetznerServerTemplateConfig,
};
use super::{
MergePartial,
resource::{AddFilters, Resource, ResourceListItem, ResourceQuery},
};
pub mod aws;
pub mod hetzner;
#[typeshare]
pub type ServerTemplate = Resource<ServerTemplateConfig, ()>;
#[typeshare]
pub type ServerTemplateListItem =
ResourceListItem<ServerTemplateListItemInfo>;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServerTemplateListItemInfo {
/// The cloud provider
pub provider: String,
/// The instance type, eg c5.2xlarge on for Aws templates
pub instance_type: Option<String>,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, EnumVariants)]
#[variant_derive(
Serialize,
Deserialize,
Debug,
Clone,
Copy,
Display,
EnumString,
AsRefStr
)]
#[serde(tag = "type", content = "params")]
pub enum ServerTemplateConfig {
/// Template to launch an AWS EC2 instance
Aws(aws::AwsServerTemplateConfig),
/// Template to launch a Hetzner server
Hetzner(hetzner::HetznerServerTemplateConfig),
}
impl Default for ServerTemplateConfig {
fn default() -> Self {
Self::Aws(Default::default())
}
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, EnumVariants)]
#[variant_derive(
Serialize,
Deserialize,
Debug,
Clone,
Copy,
Display,
EnumString,
AsRefStr
)]
#[serde(tag = "type", content = "params")]
pub enum PartialServerTemplateConfig {
Aws(#[serde(default)] aws::_PartialAwsServerTemplateConfig),
Hetzner(
#[serde(default)] hetzner::_PartialHetznerServerTemplateConfig,
),
}
impl Default for PartialServerTemplateConfig {
fn default() -> Self {
Self::Aws(Default::default())
}
}
impl MaybeNone for PartialServerTemplateConfig {
fn is_none(&self) -> bool {
match self {
PartialServerTemplateConfig::Aws(config) => config.is_none(),
PartialServerTemplateConfig::Hetzner(config) => {
config.is_none()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerTemplateConfigDiff {
Aws(aws::AwsServerTemplateConfigDiff),
Hetzner(hetzner::HetznerServerTemplateConfigDiff),
}
impl From<ServerTemplateConfigDiff> for PartialServerTemplateConfig {
fn from(value: ServerTemplateConfigDiff) -> Self {
match value {
ServerTemplateConfigDiff::Aws(diff) => {
PartialServerTemplateConfig::Aws(diff.into())
}
ServerTemplateConfigDiff::Hetzner(diff) => {
PartialServerTemplateConfig::Hetzner(diff.into())
}
}
}
}
impl Diff for ServerTemplateConfigDiff {
fn iter_field_diffs(
&self,
) -> impl Iterator<Item = partial_derive2::FieldDiff> {
match self {
ServerTemplateConfigDiff::Aws(diff) => {
diff.iter_field_diffs().collect::<Vec<_>>().into_iter()
}
ServerTemplateConfigDiff::Hetzner(diff) => {
diff.iter_field_diffs().collect::<Vec<_>>().into_iter()
}
}
}
}
impl
PartialDiff<PartialServerTemplateConfig, ServerTemplateConfigDiff>
for ServerTemplateConfig
{
fn partial_diff(
&self,
partial: PartialServerTemplateConfig,
) -> ServerTemplateConfigDiff {
match self {
ServerTemplateConfig::Aws(original) => match partial {
PartialServerTemplateConfig::Aws(partial) => {
ServerTemplateConfigDiff::Aws(
original.partial_diff(partial),
)
}
PartialServerTemplateConfig::Hetzner(partial) => {
let default = HetznerServerTemplateConfig::default();
ServerTemplateConfigDiff::Hetzner(
default.partial_diff(partial),
)
}
},
ServerTemplateConfig::Hetzner(original) => match partial {
PartialServerTemplateConfig::Hetzner(partial) => {
ServerTemplateConfigDiff::Hetzner(
original.partial_diff(partial),
)
}
PartialServerTemplateConfig::Aws(partial) => {
let default = AwsServerTemplateConfig::default();
ServerTemplateConfigDiff::Aws(default.partial_diff(partial))
}
},
}
}
}
impl MaybeNone for ServerTemplateConfigDiff {
fn is_none(&self) -> bool {
match self {
ServerTemplateConfigDiff::Aws(config) => config.is_none(),
ServerTemplateConfigDiff::Hetzner(config) => config.is_none(),
}
}
}
impl From<PartialServerTemplateConfig> for ServerTemplateConfig {
fn from(
value: PartialServerTemplateConfig,
) -> ServerTemplateConfig {
match value {
PartialServerTemplateConfig::Aws(config) => {
ServerTemplateConfig::Aws(config.into())
}
PartialServerTemplateConfig::Hetzner(config) => {
ServerTemplateConfig::Hetzner(config.into())
}
}
}
}
impl From<ServerTemplateConfig> for PartialServerTemplateConfig {
fn from(value: ServerTemplateConfig) -> Self {
match value {
ServerTemplateConfig::Aws(config) => {
PartialServerTemplateConfig::Aws(config.into())
}
ServerTemplateConfig::Hetzner(config) => {
PartialServerTemplateConfig::Hetzner(config.into())
}
}
}
}
impl MergePartial for ServerTemplateConfig {
type Partial = PartialServerTemplateConfig;
fn merge_partial(
self,
partial: PartialServerTemplateConfig,
) -> ServerTemplateConfig {
match partial {
PartialServerTemplateConfig::Aws(partial) => match self {
ServerTemplateConfig::Aws(config) => {
let config = aws::AwsServerTemplateConfig {
region: partial.region.unwrap_or(config.region),
instance_type: partial
.instance_type
.unwrap_or(config.instance_type),
volumes: partial.volumes.unwrap_or(config.volumes),
ami_id: partial.ami_id.unwrap_or(config.ami_id),
subnet_id: partial.subnet_id.unwrap_or(config.subnet_id),
security_group_ids: partial
.security_group_ids
.unwrap_or(config.security_group_ids),
key_pair_name: partial
.key_pair_name
.unwrap_or(config.key_pair_name),
assign_public_ip: partial
.assign_public_ip
.unwrap_or(config.assign_public_ip),
use_public_ip: partial
.use_public_ip
.unwrap_or(config.use_public_ip),
port: partial.port.unwrap_or(config.port),
use_https: partial.use_https.unwrap_or(config.use_https),
user_data: partial.user_data.unwrap_or(config.user_data),
};
ServerTemplateConfig::Aws(config)
}
ServerTemplateConfig::Hetzner(_) => {
ServerTemplateConfig::Aws(partial.into())
}
},
PartialServerTemplateConfig::Hetzner(partial) => match self {
ServerTemplateConfig::Hetzner(config) => {
let config = hetzner::HetznerServerTemplateConfig {
image: partial.image.unwrap_or(config.image),
datacenter: partial
.datacenter
.unwrap_or(config.datacenter),
private_network_ids: partial
.private_network_ids
.unwrap_or(config.private_network_ids),
placement_group: partial
.placement_group
.unwrap_or(config.placement_group),
enable_public_ipv4: partial
.enable_public_ipv4
.unwrap_or(config.enable_public_ipv4),
enable_public_ipv6: partial
.enable_public_ipv6
.unwrap_or(config.enable_public_ipv6),
firewall_ids: partial
.firewall_ids
.unwrap_or(config.firewall_ids),
server_type: partial
.server_type
.unwrap_or(config.server_type),
ssh_keys: partial.ssh_keys.unwrap_or(config.ssh_keys),
user_data: partial.user_data.unwrap_or(config.user_data),
use_public_ip: partial
.use_public_ip
.unwrap_or(config.use_public_ip),
labels: partial.labels.unwrap_or(config.labels),
volumes: partial.volumes.unwrap_or(config.volumes),
port: partial.port.unwrap_or(config.port),
use_https: partial.use_https.unwrap_or(config.use_https),
};
ServerTemplateConfig::Hetzner(config)
}
ServerTemplateConfig::Aws(_) => {
ServerTemplateConfig::Hetzner(partial.into())
}
},
}
}
}
#[typeshare]
pub type ServerTemplateQuery =
ResourceQuery<ServerTemplateQuerySpecifics>;
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder,
)]
pub struct ServerTemplateQuerySpecifics {
pub types: Vec<ServerTemplateConfigVariant>,
}
impl AddFilters for ServerTemplateQuerySpecifics {
fn add_filters(&self, filters: &mut Document) {
let types =
self.types.iter().map(|t| t.as_ref()).collect::<Vec<_>>();
if !self.types.is_empty() {
filters.insert("config.type", doc! { "$in": types });
}
}
}
+4
View File
@@ -21,6 +21,10 @@ pub struct SystemInformation {
pub host_name: Option<String>,
/// The CPU's brand
pub cpu_brand: String,
/// Whether terminals are disabled on this Periphery server
pub terminals_disabled: bool,
/// Whether container exec is disabled on this Periphery server
pub container_exec_disabled: bool,
}
/// System stats stored on the database.
-9
View File
@@ -10,7 +10,6 @@ use super::{
deployment::_PartialDeploymentConfig, permission::PermissionLevel,
procedure::_PartialProcedureConfig, repo::_PartialRepoConfig,
server::_PartialServerConfig,
server_template::PartialServerTemplateConfig,
stack::_PartialStackConfig, sync::_PartialResourceSyncConfig,
variable::Variable,
};
@@ -82,14 +81,6 @@ pub struct ResourcesToml {
)]
pub builders: Vec<ResourceToml<_PartialBuilderConfig>>,
#[serde(
default,
alias = "server_template",
skip_serializing_if = "Vec::is_empty"
)]
pub server_templates:
Vec<ResourceToml<PartialServerTemplateConfig>>,
#[serde(
default,
alias = "resource_sync",
+1
View File
@@ -39,6 +39,7 @@ pub mod busy;
pub mod deserializers;
pub mod entities;
pub mod parsers;
pub mod terminal;
pub mod ws;
mod request;
+2 -3
View File
@@ -1,5 +1,4 @@
use anyhow::{Context, anyhow};
use reqwest::StatusCode;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::json;
use serror::deserialize_error;
@@ -208,7 +207,7 @@ impl KomodoClient {
let res =
req.send().await.context("failed to reach Komodo API")?;
let status = res.status();
if status == StatusCode::OK {
if status.is_success() {
match res.json().await {
Ok(res) => Ok(res),
Err(e) => Err(anyhow!("{e:#?}").context(status)),
@@ -236,7 +235,7 @@ impl KomodoClient {
.json(&body);
let res = req.send().context("failed to reach Komodo API")?;
let status = res.status();
if status == StatusCode::OK {
if status.is_success() {
match res.json() {
Ok(res) => Ok(res),
Err(e) => Err(anyhow!("{e:#?}").context(status)),
+18
View File
@@ -0,0 +1,18 @@
use futures::{Stream, StreamExt, TryStreamExt};
pub struct TerminalStreamResponse(pub reqwest::Response);
impl TerminalStreamResponse {
pub fn into_line_stream(
self,
) -> impl Stream<Item = Result<String, tokio_util::codec::LinesCodecError>>
{
tokio_util::codec::FramedRead::new(
tokio_util::io::StreamReader::new(
self.0.bytes_stream().map_err(std::io::Error::other),
),
tokio_util::codec::LinesCodec::new(),
)
.map(|line| line.map(|line| line + "\n"))
}
}

Some files were not shown because too many files have changed in this diff Show More