mod config_prepass;
use crate::ServerStartup;
use crate::args::shadow::PKG_VERSION;
use crate::command::termination_notifier::termination_notifier;
use crate::config::config_holder::ConfigHolder;
use crate::config::config_holder::PathPrefixes;
use crate::config::config_holder::load_deployment_resolved;
use crate::config::content_digest_to_wasm_file;
use crate::config::env_var::EnvVarConfig;
use crate::config::file_provider::CasFileProvider;
use crate::config::manifest::DeploymentManifest;
use crate::config::manifest::DeploymentManifestFile;
use crate::config::manifest::reconcile_deployment_digests;
use crate::config::secret_registry::EnvVarCleanupStrategy;
use crate::config::secret_registry::SecretRegistry;
use crate::config::toml::ActivityExecComponentConfigResolvedExt as _;
use crate::config::toml::ActivityExecConfigVerified;
use crate::config::toml::ActivityExternalComponentConfigResolved;
use crate::config::toml::ActivityExternalComponentConfigResolvedExt as _;
use crate::config::toml::ActivityExternalConfigVerified;
use crate::config::toml::ActivityJsComponentConfigResolvedExt as _;
use crate::config::toml::ActivityJsConfigVerified;
use crate::config::toml::ActivityStubComponentConfigResolved;
use crate::config::toml::ActivityStubComponentConfigResolvedExt as _;
use crate::config::toml::ActivityStubConfigVerified;
use crate::config::toml::ActivityStubExtConfigVerified;
use crate::config::toml::ActivityStubExtInlineConfigVerified;
use crate::config::toml::ActivityWasmComponentConfigTomlExt as _;
use crate::config::toml::ActivityWasmConfigVerified;
use crate::config::toml::AllowExecActivities;
use crate::config::toml::CancelWatcherTomlConfig;
use crate::config::toml::ComponentCommon;
use crate::config::toml::ComponentLocationFetchExt as _;
use crate::config::toml::ComponentLocationToml;
use crate::config::toml::ComponentStdOutputToml;
use crate::config::toml::ConfigName;
use crate::config::toml::DatabaseConfigToml;
use crate::config::toml::DeploymentResolved;
use crate::config::toml::InflightSemaphoreExt as _;
use crate::config::toml::LogLevelToml;
use crate::config::toml::SQLITE_FILE_NAME;
use crate::config::toml::ServerConfigToml;
use crate::config::toml::TimersWatcherTomlConfig;
use crate::config::toml::WasmtimeAllocatorConfig;
use crate::config::toml::WorkflowConfigVerified;
use crate::config::toml::WorkflowJsComponentConfigResolvedExt as _;
use crate::config::toml::WorkflowJsConfigVerified;
use crate::config::toml::WorkflowWasmComponentConfigResolvedExt as _;
use crate::config::toml::cron::CronComponentConfigTomlExt as _;
use crate::config::toml::cron::CronConfigVerified;
use crate::config::toml::resolve_allowed_hosts;
use crate::config::toml::webhook;
use crate::config::toml::webhook::HttpServer;
use crate::config::toml::webhook::WebhookJsComponentConfigResolvedExt as _;
use crate::config::toml::webhook::WebhookJsConfigVerified;
use crate::config::toml::webhook::WebhookRoute;
use crate::config::toml::webhook::WebhookRouteVerified;
use crate::config::toml::webhook::WebhookWasmComponentConfigResolvedExt as _;
use crate::config::toml::webhook::WebhookWasmComponentConfigVerified;
use crate::config::toml::{AllowedHostToml, MethodsInput, MethodsInputStar};
use crate::config::wasm_cache_metadata_dir;
use crate::init;
use crate::init::Guard;
use crate::prepare_server_startup;
use crate::server::grpc_server::GrpcServer;
use crate::server::web_api_server::WebApiState;
use crate::server::web_api_server::app_router;
use anyhow::Context;
use anyhow::bail;
use chrono::Utc;
use concepts::ComponentId;
use concepts::ComponentType;
use concepts::ContentDigest;
use concepts::ExecutionId;
use concepts::FnName;
use concepts::FunctionExtension;
use concepts::FunctionFqn;
use concepts::FunctionRegistry;
use concepts::IfcFqnName;
use concepts::ParameterType;
use concepts::Params;
use concepts::ReturnTypeExtendable;
use concepts::SUFFIX_FN_SCHEDULE;
use concepts::StrVariant;
use concepts::component_id::ComponentDigest;
use concepts::component_id::Digest;
use concepts::prefixed_ulid::DeploymentId;
use concepts::storage::CreateRequest;
use concepts::storage::DbErrorWrite;
use concepts::storage::DbErrorWriteNonRetriable;
use concepts::storage::DbExternalApi;
use concepts::storage::DbPool;
use concepts::storage::DbPoolCloseable;
use concepts::storage::DeploymentComponentRecord;
use concepts::storage::DeploymentFileRecord;
use concepts::storage::EnqueueOutcome;
use concepts::storage::LogInfoAppendRow;
use concepts::storage::LogLevel;
use concepts::storage::{ComponentMetadataRecord, DeploymentRecord, DeploymentStatus};
use concepts::time::ClockFn;
use concepts::time::Now;
use concepts::time::TokioSleep;
use db_postgres::postgres_dao::PostgresPool;
use db_sqlite::sqlite_dao::SqlitePool;
use executor::AbortOnDropHandle;
use executor::executor::ExecutorTaskHandle;
use executor::executor::WorkerTasksHandle;
use executor::executor::{ExecConfig, ExecTask};
use executor::expired_timers_watcher;
use executor::expired_timers_watcher::TimersWatcherConfig;
use executor::worker::Worker;
use futures_util::future::OptionFuture;
use grpc::extractor::accept_trace;
use grpc::grpc_gen;
use hashbrown::HashMap;
use indexmap::IndexMap;
use secrecy::ExposeSecret as _;
use serde_json::json;
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Debug;
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio::sync::OwnedSemaphorePermit;
use tokio::sync::Semaphore;
use tokio::sync::TryAcquireError;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
use toml_edit::{DocumentMut, Item, Table, value};
use tonic::codec::CompressionEncoding;
use tonic::service::RoutesBuilder;
use tonic_web::GrpcWebLayer;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing::Instrument;
use tracing::Span;
use tracing::info_span;
use tracing::instrument;
use tracing::warn;
use tracing::{debug, info, trace};
use utils::wasm_tools::WasmComponent;
use val_json::wast_val::WastValWithType;
use wasm_workers::RunnableComponent;
use wasm_workers::activity::activity_exec_worker::ActivityExecWorkerCompiled;
use wasm_workers::activity::activity_js_worker::ActivityJsWorkerCompiled;
use wasm_workers::activity::activity_worker::ActivityWorkerCompiled;
use wasm_workers::activity::cancel_registry::CancelRegistry;
use wasm_workers::component_logger::LogStrageConfig;
use wasm_workers::cron::cron_worker;
use wasm_workers::cron::cron_worker::CronOrOnce;
use wasm_workers::cron::cron_worker::CronWorker;
use wasm_workers::engines::EngineConfig;
use wasm_workers::engines::Engines;
use wasm_workers::engines::PoolingConfig;
use wasm_workers::epoch_ticker::EpochTicker;
use wasm_workers::http_request_policy::{AllowedHostConfig, GlobalHttpConfig};
use wasm_workers::log_db_forwarder;
use wasm_workers::registry::ComponentConfig;
use wasm_workers::registry::ComponentConfigImportable;
use wasm_workers::registry::ComponentConfigRegistry;
use wasm_workers::registry::ComponentConfigRegistryRO;
use wasm_workers::registry::ReplayWorker;
use wasm_workers::registry::ReplayWorkerRegistry;
use wasm_workers::registry::WitOrigin;
use wasm_workers::webhook::webhook_registry::WebhookRegistry;
use wasm_workers::webhook::webhook_trigger;
use wasm_workers::webhook::webhook_trigger::MethodAwareRouter;
use wasm_workers::webhook::webhook_trigger::WebhookEndpointCompiled;
use wasm_workers::webhook::webhook_trigger::WebhookEndpointConfig;
use wasm_workers::webhook::webhook_trigger::WebhookEndpointInstanceLinked;
use wasm_workers::webhook::webhook_trigger::WebhookEndpointJsConfig;
use wasm_workers::webhook::webhook_trigger::WebhookServerState;
use wasm_workers::workflow::deadline_tracker::{
DeadlineTrackerFactoryForReplay, DeadlineTrackerFactoryTokio,
};
use wasm_workers::workflow::host_exports::history_event_schedule_at_from_wast_val;
use wasm_workers::workflow::workflow_js_worker::WorkflowJsWorkerCompiled;
use wasm_workers::workflow::workflow_js_worker::WorkflowJsWorkerLinked;
use wasm_workers::workflow::workflow_worker::JoinNextBlockingStrategy;
use wasm_workers::workflow::workflow_worker::WorkflowConfig;
use wasm_workers::workflow::workflow_worker::WorkflowWorkerCompiled;
use wasm_workers::workflow::workflow_worker::WorkflowWorkerLinked;
use wasmtime::Engine;
pub(crate) struct DeploymentContext {
pub(crate) deployment_id: DeploymentId,
pub(crate) component_registry_ro: wasm_workers::registry::ComponentConfigRegistryRO,
pub(crate) exec_task_handles: Vec<ExecutorTaskHandle>,
pub(crate) replay_workers: Arc<ReplayWorkerRegistry>,
pub(crate) closed: bool,
}
pub(crate) type DeploymentContextHandle = Arc<tokio::sync::RwLock<DeploymentContext>>;
struct PreparedDeploymentSwitch {
deployment_id: DeploymentId,
digest: ContentDigest,
compiled_linked: ServerCompiledLinked,
}
#[derive(Clone)]
pub(crate) struct DeploymentSwitchManagerHandle {
inner: Arc<DeploymentSwitchManager>,
}
struct SwitchPermit(#[expect(dead_code)] OwnedSemaphorePermit);
struct SubmitPermit(#[expect(dead_code)] OwnedSemaphorePermit);
struct DeploymentSwitchManager {
submit_lane: Arc<Semaphore>,
switch_gate: Arc<Semaphore>,
submit_concurrency: u32,
latest_prepared: Mutex<Option<PreparedDeploymentSwitch>>,
server_verified: ServerVerified,
prepared_dirs: PreparedDirs,
db_pool: Arc<dyn DbPool>,
termination_watcher: watch::Receiver<()>,
deployment_ctx: DeploymentContextHandle,
webhook_registry: Arc<WebhookRegistry>,
cancel_registry: CancelRegistry,
log_forwarder_sender: mpsc::Sender<LogInfoAppendRow>,
}
impl DeploymentSwitchManagerHandle {
#[expect(clippy::too_many_arguments)]
pub(crate) fn new(
server_verified: ServerVerified,
prepared_dirs: PreparedDirs,
db_pool: Arc<dyn DbPool>,
termination_watcher: watch::Receiver<()>,
deployment_ctx: DeploymentContextHandle,
webhook_registry: Arc<WebhookRegistry>,
cancel_registry: CancelRegistry,
log_forwarder_sender: mpsc::Sender<LogInfoAppendRow>,
submit_concurrency: u32,
) -> Self {
Self {
inner: Arc::new(DeploymentSwitchManager {
submit_lane: Arc::new(Semaphore::new(submit_concurrency as usize)),
switch_gate: Arc::new(Semaphore::new(1)), submit_concurrency,
latest_prepared: Mutex::new(None),
server_verified,
prepared_dirs,
db_pool,
termination_watcher,
deployment_ctx,
webhook_registry,
cancel_registry,
log_forwarder_sender,
}),
}
}
fn try_acquire_switch_permit(&self) -> Result<SwitchPermit, SwitchError> {
match self.inner.switch_gate.clone().try_acquire_owned() {
Ok(permit) => Ok(SwitchPermit(permit)),
Err(TryAcquireError::NoPermits) => Err(SwitchError::Busy),
Err(TryAcquireError::Closed) => Err(SwitchError::Other(anyhow::anyhow!(
"server is being shut down"
))),
}
}
fn try_acquire_submit_permit(&self) -> Result<SubmitPermit, SubmitDeploymentError> {
match self.inner.submit_lane.clone().try_acquire_owned() {
Ok(permit) => Ok(SubmitPermit(permit)),
Err(TryAcquireError::NoPermits) => Err(SubmitDeploymentError::Busy),
Err(TryAcquireError::Closed) => Err(SubmitDeploymentError::Other(anyhow::anyhow!(
"server is being shut down"
))),
}
}
async fn take_latest_prepared(
&self,
deployment_id: DeploymentId,
digest: &ContentDigest,
) -> Option<PreparedDeploymentSwitch> {
let mut latest = self.inner.latest_prepared.lock().await;
if latest.as_ref().is_some_and(|prepared| {
prepared.deployment_id == deployment_id && &prepared.digest == digest
}) {
latest.take()
} else {
None
}
}
async fn store_latest_prepared(&self, prepared: PreparedDeploymentSwitch) {
let mut latest = self.inner.latest_prepared.lock().await;
*latest = Some(prepared);
}
async fn spawn_critical_hot_switch(
&self,
prepared: PreparedDeploymentSwitch,
switch_permit: SwitchPermit,
) -> Result<SwitchOutcome, SwitchError> {
let (tx, rx) = oneshot::channel();
let db_pool = self.inner.db_pool.clone();
let deployment_ctx = self.inner.deployment_ctx.clone();
let webhook_registry = self.inner.webhook_registry.clone();
let cancel_registry = self.inner.cancel_registry.clone();
let log_forwarder_sender = self.inner.log_forwarder_sender.clone();
tokio::spawn(async move {
let _switch_permit = switch_permit;
let deployment_id = prepared.deployment_id;
let result = async {
let db_conn = db_pool
.external_api_conn()
.await
.map_err(|e| SwitchError::Other(e.into()))?;
db_conn
.activate_deployment(deployment_id, chrono::Utc::now())
.await
.map_err(|e| SwitchError::Other(e.into()))?;
prepared
.compiled_linked
.create_missing_cron_seeds(&db_pool, deployment_id)
.await?;
switch_hot_redeploy(
prepared.compiled_linked,
deployment_id,
db_pool,
deployment_ctx,
webhook_registry,
cancel_registry,
log_forwarder_sender,
)
.await
}
.await;
let _ = tx.send(result);
});
rx.await.map_err(|_| {
SwitchError::Other(anyhow::anyhow!("deployment switch critical task exited"))
})?
}
pub(crate) async fn close(&self) {
let _switch_permit = self.inner.switch_gate.clone().acquire_owned().await;
let _submit_permits = self
.inner
.submit_lane
.clone()
.acquire_many_owned(self.inner.submit_concurrency)
.await;
self.inner.switch_gate.close();
self.inner.submit_lane.close();
}
}
const EPOCH_MILLIS: u64 = 10;
const CANCELLATION_DRIVER_BATCH_SIZE: u32 = 100;
const DEFAULT_SUBMIT_CONCURRENCY: u32 = 1;
const WEBUI_LOCATION: &str = include_str!("../../assets/webui-version.txt");
#[cfg(not(feature = "activity-js-local"))]
pub(crate) const ACTIVITY_JS_LOCATION: &str =
include_str!("../../assets/activity-js-runtime-version.txt");
#[cfg(not(feature = "workflow-js-local"))]
pub(crate) const WORKFLOW_JS_LOCATION: &str =
include_str!("../../assets/workflow-js-runtime-version.txt");
#[cfg(not(feature = "webhook-js-local"))]
pub(crate) const WEBHOOK_JS_LOCATION: &str =
include_str!("../../assets/webhook-js-runtime-version.txt");
const HTTP_SERVER_NAME_WEBUI: &str = "webui";
const HTTP_SERVER_NAME_EXTERNAL: &str = "external";
const COMPONENT_NAME_WEBUI: &str = "obelisk_webui";
#[derive(Debug, thiserror::Error)]
pub(crate) enum SubmitError {
#[error("must be a top-level execution id")]
ExecutionIdMustBeTopLevel,
#[error("function not found")]
FunctionNotFound,
#[error("{0}")]
ParamsInvalid(String),
#[error("execution already exists with the same id and different parameters")]
Conflict,
#[error(transparent)]
DbErrorWrite(DbErrorWrite),
}
pub(crate) enum SubmitOutcome {
Created,
ExistsWithSameParameters,
}
pub(crate) async fn submit(
deployment_id: DeploymentId,
db_connection: &dyn DbExternalApi,
execution_id: ExecutionId,
ffqn: FunctionFqn,
mut params: Vec<serde_json::Value>,
paused: bool,
component_registry_ro: &ComponentConfigRegistryRO,
) -> Result<SubmitOutcome, SubmitError> {
let span = Span::current();
span.record("execution_id", tracing::field::display(&execution_id));
if !execution_id.is_top_level() {
return Err(SubmitError::ExecutionIdMustBeTopLevel);
}
let Some((component_id, fn_metadata)) =
component_registry_ro.find_by_exported_ffqn_submittable(&ffqn)
else {
return Err(SubmitError::FunctionNotFound);
};
span.record("component_id", tracing::field::display(component_id));
let created_at = Now.now();
let (scheduled_at, params, fn_metadata) = if fn_metadata.extension
== Some(FunctionExtension::Schedule)
{
let Some(schedule_at) = params.drain(0..1).next() else {
return Err(SubmitError::ParamsInvalid(
"`params` must be an array with first value of type `schedule-at`".to_string(),
));
};
let schedule_at_type_wrapper = fn_metadata
.parameter_types
.iter()
.map(|ParameterType { type_wrapper, .. }| type_wrapper.clone())
.next()
.expect("checked that `fn_metadata` is FunctionExtension::Schedule");
let wast_val_with_type = json!({
"type": schedule_at_type_wrapper,
"value": schedule_at,
});
let wast_val_with_type: WastValWithType = serde_json::from_value(wast_val_with_type)
.map_err(|serde_err| {
SubmitError::ParamsInvalid(format!(
"`params` must be an array with first value of type `schedule-at` - {serde_err}"
))
})?;
let schedule_at = history_event_schedule_at_from_wast_val(&wast_val_with_type.value)
.map_err(|serde_err| {
SubmitError::ParamsInvalid(format!("cannot convert `schedule-at` - {serde_err}"))
})?;
let ffqn = FunctionFqn {
ifc_fqn: IfcFqnName::from_parts(
fn_metadata.ffqn.ifc_fqn.namespace(),
fn_metadata
.ffqn
.ifc_fqn
.package_strip_obelisk_schedule_suffix()
.expect("checked that the ifc is ext"),
fn_metadata.ffqn.ifc_fqn.ifc_name(),
fn_metadata.ffqn.ifc_fqn.version(),
),
function_name: FnName::from(
fn_metadata
.ffqn
.function_name
.to_string()
.strip_suffix(SUFFIX_FN_SCHEDULE)
.expect("checked that the function is FunctionExtension::Schedule")
.to_string(),
),
};
let (_component_id, fn_metadata) = component_registry_ro
.find_by_exported_ffqn_submittable(&ffqn)
.expect("-schedule must have the original counterpart in the component registry");
(
schedule_at.as_date_time(created_at).map_err(|err| {
SubmitError::ParamsInvalid(format!("schedule-at conversion error - {err}"))
})?,
params,
fn_metadata,
)
} else {
assert!(fn_metadata.extension.is_none());
(created_at, params, fn_metadata)
};
let ffqn = &fn_metadata.ffqn;
span.record("ffqn", tracing::field::display(ffqn));
let params = Params::from_json_values(
Arc::from(params),
fn_metadata
.parameter_types
.iter()
.map(|ParameterType { type_wrapper, .. }| type_wrapper),
)
.map_err(|err| SubmitError::ParamsInvalid(format!("argument `params` invalid - {err}")))?;
let metadata = concepts::ExecutionMetadata::from_parent_span(&span);
let res = db_connection
.create(CreateRequest {
created_at,
execution_id: execution_id.clone(),
metadata,
ffqn: ffqn.clone(),
params: params.clone(),
parent: None,
scheduled_at,
component_id: component_id.clone(),
deployment_id,
scheduled_by: None,
paused,
})
.await;
match res {
Ok(_) => Ok(SubmitOutcome::Created),
Err(DbErrorWrite::NonRetriable(DbErrorWriteNonRetriable::Conflict)) => {
let create_req = db_connection
.get_create_request(&execution_id)
.await
.map_err(|err| SubmitError::DbErrorWrite(err.into()))?;
if create_req.params == params {
Ok(SubmitOutcome::ExistsWithSameParameters)
} else {
Err(SubmitError::Conflict)
}
}
Err(err) => Err(SubmitError::DbErrorWrite(err)),
}
}
pub(crate) struct RunParams {
pub(crate) dir_params: PrepareDirsParams,
pub(crate) clean_sqlite_directory: bool,
pub(crate) suppress_type_checking_errors: bool,
pub(crate) allow_unauthenticated_api: bool,
}
pub(crate) async fn run(
config_holder: ConfigHolder,
config: ServerConfigToml,
deployment: Option<PathBuf>,
deployment_empty: bool,
description: Option<String>,
params: RunParams,
secret_registry: Arc<SecretRegistry>,
) -> anyhow::Result<()> {
let _guard: Guard = init::init(&config)?;
let deployment = if let Some(deployment_path) = deployment {
Some(LocalDeployment::from_path(&deployment_path).await?)
} else if deployment_empty {
Some(LocalDeployment::empty())
} else {
None
};
if description.is_some() && deployment.is_none() {
anyhow::bail!("--description requires --deployment or --empty");
}
let (termination_sender, termination_watcher) = watch::channel(());
tokio::spawn(async move { termination_notifier(termination_sender).await });
let prepared_dirs = prepare_dirs(
&config,
¶ms.dir_params,
&config_holder.path_prefixes,
&secret_registry,
)
.await?;
Box::pin(run_internal(
config,
deployment,
description,
config_holder.path_prefixes,
params,
prepared_dirs,
termination_watcher,
secret_registry,
))
.await?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RuntimeConfigAvailability {
Strict,
AllowUnavailable,
}
impl RuntimeConfigAvailability {
fn assert_strict(self) {
assert!(
self == Self::Strict,
"cannot activate a deployment verified with unavailable runtime config allowed"
);
}
}
fn webhook_global_http_allowlist(
self_authorize: bool,
allowed_hosts: &Arc<[AllowedHostConfig]>,
operator_global: &GlobalHttpConfig,
) -> GlobalHttpConfig {
if self_authorize {
GlobalHttpConfig::from(allowed_hosts.clone())
} else {
operator_global.clone()
}
}
#[derive(Debug, Clone)]
pub(crate) struct VerifyParams {
pub(crate) dir_params: PrepareDirsParams,
pub(crate) runtime_config_availability: RuntimeConfigAvailability,
pub(crate) suppress_type_checking_errors: bool,
pub(crate) suppress_linking_errors: bool,
}
pub(crate) async fn verify(
config_holder: ConfigHolder,
config: ServerConfigToml,
deployment: Option<PathBuf>,
verify_params: VerifyParams,
skip_db: bool,
fix: bool,
secret_registry: Arc<SecretRegistry>,
) -> Result<(), anyhow::Error> {
let _guard: Guard = init::init(&config)?;
let deployment_opt = if let Some(deployment_path) = deployment {
let broken = reconcile_deployment_digests(&deployment_path, fix).await?;
if !broken.is_empty() {
if fix {
for digest in &broken {
info!(
"Fixed {} ({}): {} -> {}",
digest.field_path, digest.path, digest.stored, digest.actual
);
}
info!(
"Corrected {} content digest(s) in {}",
broken.len(),
deployment_path.display()
);
} else {
let details = broken
.iter()
.map(|digest| {
format!(
"- {} ({}): expected {}, got {}",
digest.field_path, digest.path, digest.stored, digest.actual
)
})
.collect::<Vec<_>>()
.join("\n");
bail!(
"{} content digest(s) in {} do not match:\n{}\nRun again with `--fix` to update them.",
broken.len(),
deployment_path.display(),
details
);
}
}
Some(load_deployment_resolved(&deployment_path).await?)
} else {
None
};
let (config_holder, config, secret_registry) = if fix
&& let Some(server_config_path) = config_holder.config_source.as_deref()
{
let mut unregistered = BTreeSet::new();
config_prepass::collect_unregistered_allowed_host_secrets(
&config.outbound_http.allowed_hosts,
&secret_registry,
&mut unregistered,
);
if let Some(deployment) = deployment_opt.as_ref() {
config_prepass::collect_deployment_unregistered_secrets(
deployment,
&secret_registry,
&mut unregistered,
);
}
if !unregistered.is_empty() {
config_prepass::fix_server_secret_scaffolds(server_config_path, &unregistered).await?;
warn!(
"Scaffolded `[secrets]` entries in {}: {unregistered:?}",
server_config_path.display()
);
let ServerStartup {
config_holder,
config,
secret_registry,
} = prepare_server_startup(
config_holder.config_source,
EnvVarCleanupStrategy::Noop,
verify_params.runtime_config_availability,
)?;
(config_holder, config, secret_registry)
} else {
(config_holder, config, secret_registry)
}
} else {
(config_holder, config, secret_registry)
};
let (termination_sender, mut termination_watcher) = watch::channel(());
let prepared_dirs = prepare_dirs(
&config,
&verify_params.dir_params,
&config_holder.path_prefixes,
&secret_registry,
)
.await?;
let config = if fix
&& let (Some(server_config_path), Some(deployment)) = (
config_holder.config_source.as_deref(),
deployment_opt.as_ref(),
) {
let allowlist = fix_server_exec_digests(
server_config_path,
&deployment.activities_exec,
&prepared_dirs.wasm_cache_dir,
)
.await?;
let mut config = config;
config.allow_exec_activities = AllowExecActivities::Allowlist(allowlist);
config
} else {
config
};
let engines = create_engines(&config, &prepared_dirs)?;
tokio::spawn(async move { termination_notifier(termination_sender).await });
let mut db_pool = if !skip_db {
verify_db_schema(
&config.database,
&config_holder.path_prefixes,
&secret_registry,
)
.await?
} else {
None
};
let (deployment, deployment_id) = if let Some(deployment) = deployment_opt {
(deployment, DeploymentId::generate())
} else {
get_deployment_resolved_from_db(
&config.database,
&config_holder.path_prefixes,
&mut db_pool,
&secret_registry,
)
.await?
};
let server_verified = Box::pin(server_verify(config, engines, secret_registry)).await?;
config_prepass::preflight(
&server_verified,
Some(&deployment),
verify_params.runtime_config_availability,
)?;
let cas: Option<Arc<dyn concepts::cas::Cas>> = if let Some((pool, _)) = db_pool.as_ref() {
Some(pool.cas_conn().await?.into())
} else {
None
};
deployment_verify_config_compile_link(
server_verified,
&prepared_dirs,
deployment,
cas,
deployment_id,
verify_params,
&mut termination_watcher,
)
.await?;
if let Some((_, db_close)) = db_pool {
db_close.await;
}
Ok(())
}
fn ignore_not_found(err: std::io::Error) -> Result<(), std::io::Error> {
if err.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(err)
}
}
type DbPoolCloseableContainer = Option<(Arc<dyn DbPool>, Pin<Box<dyn Future<Output = ()> + Send>>)>;
async fn verify_db_schema(
db_config_toml: &DatabaseConfigToml,
path_prefixes: &PathPrefixes,
secret_registry: &SecretRegistry,
) -> Result<DbPoolCloseableContainer, anyhow::Error> {
let result: DbPoolCloseableContainer = match db_config_toml {
DatabaseConfigToml::Sqlite(sqlite_config_toml) => {
let db_dir = sqlite_config_toml
.get_sqlite_dir(path_prefixes, secret_registry)
.await?;
let sqlite_config = sqlite_config_toml.as_sqlite_config();
let sqlite_file = db_dir.join(SQLITE_FILE_NAME);
if sqlite_file.exists() {
let db_pool = Arc::new(
SqlitePool::new(&sqlite_file, sqlite_config)
.await
.with_context(|| format!("cannot open sqlite file {sqlite_file:?}"))?,
);
info!("SQLite database schema verified");
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
Some((db_pool, db_close))
} else {
info!("SQLite database does not exist yet, skipping schema verification");
None
}
}
DatabaseConfigToml::Postgres(postgres_config_toml) => {
let db_pool = Arc::new(
PostgresPool::new(
postgres_config_toml.as_config(secret_registry)?,
postgres_config_toml.as_provision_policy(),
)
.await
.context("cannot initialize postgres connection pool")?,
);
info!("PostgreSQL database schema verified");
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
Some((db_pool, db_close))
}
};
Ok(result)
}
#[derive(Debug, Clone)]
pub(crate) struct PrepareDirsParams {
pub(crate) clean_cache: bool,
pub(crate) clean_codegen_cache: bool,
}
pub(crate) async fn prepare_dirs(
config: &ServerConfigToml,
params: &PrepareDirsParams,
path_prefixes: &PathPrefixes,
secret_registry: &SecretRegistry,
) -> Result<PreparedDirs, anyhow::Error> {
let wasm_cache_dir = config
.wasm_global_config
.get_wasm_cache_directory(path_prefixes, secret_registry)
.await?;
let codegen_cache_dir = config
.wasm_global_config
.codegen_cache
.get_directory(path_prefixes, secret_registry)
.await?;
debug!("Using codegen cache? {codegen_cache_dir:?}");
if params.clean_cache {
tokio::fs::remove_dir_all(&wasm_cache_dir)
.await
.or_else(ignore_not_found)
.with_context(|| format!("cannot delete wasm cache directory {wasm_cache_dir:?}"))?;
}
if (params.clean_cache || params.clean_codegen_cache)
&& let Some(codegen_cache) = &codegen_cache_dir
{
tokio::fs::remove_dir_all(codegen_cache)
.await
.or_else(ignore_not_found)
.with_context(|| format!("cannot delete codegen cache directory {codegen_cache:?}"))?;
tokio::fs::create_dir_all(codegen_cache)
.await
.with_context(|| format!("cannot create codegen cache directory {codegen_cache:?}"))?;
}
tokio::fs::create_dir_all(&wasm_cache_dir)
.await
.with_context(|| format!("cannot create wasm cache directory {wasm_cache_dir:?}"))?;
let metadata_dir = wasm_cache_metadata_dir(&wasm_cache_dir);
tokio::fs::create_dir_all(&metadata_dir)
.await
.with_context(|| format!("cannot create wasm metadata directory {metadata_dir:?}"))?;
Ok(PreparedDirs {
codegen_cache_dir: codegen_cache_dir.map(Arc::from),
wasm_cache_dir: Arc::from(wasm_cache_dir),
metadata_dir: Arc::from(metadata_dir),
})
}
#[derive(Debug, Clone)]
#[expect(clippy::struct_field_names)]
pub(crate) struct PreparedDirs {
codegen_cache_dir: Option<Arc<Path>>,
wasm_cache_dir: Arc<Path>,
metadata_dir: Arc<Path>,
}
#[instrument(skip_all)]
pub(crate) async fn server_verify(
config: ServerConfigToml,
engines: Engines,
secret_registry: Arc<SecretRegistry>,
) -> Result<ServerVerified, anyhow::Error> {
info!("Verifying server configuration");
if let Some(version_req_str) = &config.obelisk_version {
let version_req = semver::VersionReq::parse(version_req_str)
.with_context(|| format!("Invalid obelisk-version requirement: {version_req_str}"))?;
let current_version = semver::Version::parse(PKG_VERSION)
.with_context(|| format!("Invalid current version: {PKG_VERSION}"))?;
if !version_req.matches(¤t_version) {
bail!(
"Obelisk version mismatch: config requires {version_req_str}, but running version is {PKG_VERSION}",
);
}
info!("Obelisk version {PKG_VERSION} matches requirement {version_req_str}",);
}
Box::pin(ServerVerified::new(engines, config, secret_registry)).await
}
#[instrument(skip_all, fields(%deployment_id))]
pub(crate) async fn deployment_verify_config_compile_link(
server_verified: ServerVerified,
prepared_dirs: &PreparedDirs,
deployment: DeploymentResolved,
cas: Option<Arc<dyn concepts::cas::Cas>>,
deployment_id: DeploymentId,
params: VerifyParams,
termination_watcher: &mut watch::Receiver<()>,
) -> Result<ServerCompiledLinked, anyhow::Error> {
info!("Verifying deployment configuration, compiling WASM components");
let deployment_verified = deployment_verify_config(
&server_verified,
prepared_dirs,
deployment,
cas,
params.clone(),
termination_watcher,
)
.await?;
deployment_compile_link(
server_verified,
deployment_verified,
deployment_id,
params,
termination_watcher,
)
.await
}
#[instrument(skip_all, fields(%deployment_id))]
pub(crate) async fn deployment_compile_link(
server_verified: ServerVerified,
deployment_verified: DeploymentVerified,
deployment_id: DeploymentId,
params: VerifyParams,
termination_watcher: &mut watch::Receiver<()>,
) -> Result<ServerCompiledLinked, anyhow::Error> {
let compiled_and_linked = ServerCompiledLinked::new(
deployment_id,
deployment_verified,
server_verified.launch,
termination_watcher,
params.suppress_type_checking_errors,
params.suppress_linking_errors,
)
.await?;
if compiled_and_linked.supressed_errors.is_none() {
info!("Obelisk configuration was verified");
} else {
warn!("Obelisk configuration was verified with supressed errors");
}
Ok(compiled_and_linked)
}
#[instrument(skip_all)]
pub(crate) async fn deployment_verify_config(
server_verified: &ServerVerified,
prepared_dirs: &PreparedDirs,
deployment: DeploymentResolved,
cas: Option<Arc<dyn concepts::cas::Cas>>,
params: VerifyParams,
termination_watcher: &mut watch::Receiver<()>,
) -> Result<DeploymentVerified, anyhow::Error> {
if params.runtime_config_availability == RuntimeConfigAvailability::Strict
&& !deployment.activities_exec.is_empty()
{
match &server_verified.allow_exec_activities {
AllowExecActivities::AllowAny => {}
AllowExecActivities::Deny => {
let lines = exec_content_digest_lines(
&deployment.activities_exec,
&prepared_dirs.wasm_cache_dir,
)
.await?
.into_iter()
.map(|(_, _, line)| line)
.collect::<Vec<_>>();
bail!(
"deployment contains exec activities, which run outside the WASM sandbox; \
enable them with `allow_exec_activities = true` in server.toml or \
`OBELISK__ALLOW_EXEC_ACTIVITIES=true`, or allowlist the reviewed scripts \
by adding to server.toml:\n[allow_exec_activities]\n{}",
lines.join("\n")
);
}
AllowExecActivities::Allowlist(allowed) => {
let rejected = exec_content_digest_lines(
&deployment.activities_exec,
&prepared_dirs.wasm_cache_dir,
)
.await?
.into_iter()
.filter(|(name, digest, _)| allowed.get(name.as_str()) != Some(digest))
.map(|(_, _, line)| line)
.collect::<Vec<_>>();
if !rejected.is_empty() {
bail!(
"deployment contains exec activities, which run outside the WASM sandbox, \
whose content digests are not in the `allow_exec_activities` allowlist \
in server.toml; review each script, then allow it by adding its line under \
`[allow_exec_activities]`:\n{}",
rejected.join("\n")
);
}
}
AllowExecActivities::LegacyAllowlist(allowed) => {
let rejected = exec_content_digest_lines(
&deployment.activities_exec,
&prepared_dirs.wasm_cache_dir,
)
.await?
.into_iter()
.filter(|(_, digest, _)| !allowed.contains(digest))
.map(|(_, _, line)| line)
.collect::<Vec<_>>();
if !rejected.is_empty() {
bail!(
"deployment contains exec activities, which run outside the WASM sandbox, \
whose content digests are not in the `allow_exec_activities` allowlist \
in server.toml; review each script, then allow it by adding its line under \
`[allow_exec_activities]`:\n{}",
rejected.join("\n")
);
}
}
}
}
let deployment =
DeploymentRunnable::resolve(deployment, cas.as_deref(), &prepared_dirs.wasm_cache_dir)
.await?;
let deployment_verified = Box::pin(DeploymentVerified::fetch_and_verify_all(
deployment,
server_verified.http_servers.clone(),
prepared_dirs.wasm_cache_dir.clone(),
prepared_dirs.metadata_dir.clone(),
params.runtime_config_availability,
server_verified.global_executor_instance_limiter.clone(),
server_verified.fuel,
termination_watcher,
server_verified.database_subscription_interruption,
server_verified.api_addr_if_webui_enabled.clone(),
server_verified.secret_registry.clone(),
server_verified.global_http_config.clone(),
))
.await?;
trace!("Verified deployment: {deployment_verified:#?}");
Ok(deployment_verified)
}
async fn exec_content_digest_lines(
activities_exec: &[crate::config::toml::ActivityExecComponentConfigResolved],
wasm_cache_dir: &Path,
) -> anyhow::Result<Vec<(ConfigName, ContentDigest, String)>> {
let mut digests_with_lines = Vec::with_capacity(activities_exec.len());
for activity in activities_exec {
let resolved = activity.resolve(wasm_cache_dir).await?;
let digest = resolved.content_digest;
let line = format!(
"\"{name}\" = \"{digest}\" # {ffqn}",
name = activity.name,
ffqn = activity.ffqn
);
digests_with_lines.push((activity.name.clone(), digest, line));
}
Ok(digests_with_lines)
}
async fn fix_server_exec_digests(
server_config_path: &Path,
activities_exec: &[crate::config::toml::ActivityExecComponentConfigResolved],
wasm_cache_dir: &Path,
) -> anyhow::Result<BTreeMap<String, ContentDigest>> {
if activities_exec.is_empty() {
return Ok(BTreeMap::new());
}
let allowlist = exec_content_digest_lines(activities_exec, wasm_cache_dir)
.await?
.into_iter()
.map(|(name, digest, _)| (name.to_string(), digest))
.collect::<BTreeMap<_, _>>();
let server_toml = tokio::fs::read_to_string(server_config_path)
.await
.with_context(|| format!("cannot read server config {server_config_path:?}"))?;
let mut doc = server_toml
.parse::<DocumentMut>()
.context("cannot parse server config as TOML")?;
if let Some(table) = doc
.get_mut("allow_exec_activities")
.and_then(Item::as_table_mut)
{
table.retain(|name, _| allowlist.contains_key(name));
for (name, digest) in &allowlist {
let replacement = value(digest.to_string());
if let Some(item) = table.get_mut(name) {
let decor = item.as_value().map(|value| value.decor().clone());
*item = replacement;
if let Some(decor) = decor {
*item.as_value_mut().unwrap().decor_mut() = decor;
}
} else {
table.insert(name, replacement);
}
}
table.sort_values();
} else {
let mut table = Table::new();
for (name, digest) in &allowlist {
table.insert(name, value(digest.to_string()));
}
if let Some(prefix) = doc
.as_table()
.key("allow_exec_activities")
.and_then(|key| key.leaf_decor().prefix())
.cloned()
{
table.decor_mut().set_prefix(prefix);
}
doc.insert("allow_exec_activities", Item::Table(table));
}
tokio::fs::write(server_config_path, doc.to_string())
.await
.with_context(|| format!("cannot write fixed server config {server_config_path:?}"))?;
info!(
"Updated {} exec activity digest(s) in {}",
allowlist.len(),
server_config_path.display()
);
Ok(allowlist)
}
async fn get_deployment_resolved_from_db(
database: &DatabaseConfigToml,
path_prefixes: &PathPrefixes,
db_pool_container: &mut DbPoolCloseableContainer,
secret_registry: &SecretRegistry,
) -> anyhow::Result<(DeploymentResolved, DeploymentId)> {
let conn = if let Some((pool, _)) = db_pool_container.as_ref() {
pool.external_api_conn()
.await
.context("cannot get db connection for deployment lookup")?
} else {
match database {
DatabaseConfigToml::Sqlite(sqlite_config_toml) => {
let db_dir = sqlite_config_toml
.get_sqlite_dir(path_prefixes, secret_registry)
.await?;
let sqlite_config = sqlite_config_toml.as_sqlite_config();
let sqlite_file = db_dir.join(SQLITE_FILE_NAME);
let db_pool = Arc::new(
SqlitePool::new(&sqlite_file, sqlite_config)
.await
.with_context(|| format!("cannot open sqlite file {sqlite_file:?}"))?,
);
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
let (db_pool, _) = db_pool_container.insert((db_pool.clone(), db_close));
db_pool
.external_api_conn()
.await
.context("cannot get db connection for deployment lookup")?
}
DatabaseConfigToml::Postgres(postgres_config_toml) => {
let db_pool = Arc::new(
PostgresPool::new(
postgres_config_toml.as_config(secret_registry)?,
postgres_config_toml.as_provision_policy(),
)
.await
.context("cannot initialize postgres connection pool")?,
);
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
let (db_pool, _) = db_pool_container.insert((db_pool.clone(), db_close));
db_pool
.external_api_conn()
.await
.context("cannot get db connection for deployment lookup")?
}
}
};
let record = conn
.get_current_deployment()
.await
.context("cannot query current deployment")?;
let record = record
.context("no Enqueued or Active deployment found in database; provide --deployment")?;
let pool = db_pool_container
.as_ref()
.map(|(pool, _)| pool.clone())
.expect("db pool was set above");
let deployment = deployment_resolved_from_manifest(pool.as_ref(), &record.deployment_toml)
.await
.with_context(|| {
format!(
"cannot resolve deployment manifest for {:?}",
record.deployment_id
)
})?;
Ok((deployment, record.deployment_id))
}
pub(crate) struct LocalDeployment {
deployment_toml: String,
resolved: DeploymentResolved,
files: Vec<DeploymentManifestFile>,
}
impl LocalDeployment {
pub(crate) async fn from_path(deployment_path: &Path) -> anyhow::Result<Self> {
let resolved = load_deployment_resolved(deployment_path).await?;
let prepared =
crate::config::manifest::prepare_deployment_manifest_from_disk(deployment_path).await?;
Ok(Self {
deployment_toml: prepared.deployment_toml,
resolved,
files: prepared.files,
})
}
pub(crate) fn empty() -> Self {
Self {
deployment_toml: String::new(),
resolved: DeploymentResolved::default(),
files: Vec::new(),
}
}
}
fn cas_deployment_dir() -> std::path::PathBuf {
std::path::PathBuf::new()
}
struct RecordingCasProvider {
inner: crate::config::file_provider::CasFileProvider,
seen: std::sync::Mutex<Vec<concepts::storage::DeploymentFileRecord>>,
}
#[async_trait::async_trait]
impl crate::config::file_provider::FileProvider for RecordingCasProvider {
async fn read(&self, path: &str, digest: Option<&ContentDigest>) -> anyhow::Result<Vec<u8>> {
let bytes = self.inner.read(path, digest).await?;
if let Some(digest) = digest {
self.seen
.lock()
.expect("RecordingCasProvider mutex poisoned")
.push(concepts::storage::DeploymentFileRecord {
path: path.to_string(),
digest: digest.clone(),
size: u64::try_from(bytes.len()).expect("file length fits u64"),
});
}
Ok(bytes)
}
}
async fn deployment_resolved_and_files_from_manifest(
db_pool: &dyn concepts::storage::DbPool,
deployment_toml: &str,
) -> anyhow::Result<(
DeploymentResolved,
Vec<concepts::storage::DeploymentFileRecord>,
)> {
let cas: Arc<dyn concepts::cas::Cas> = db_pool
.cas_conn()
.await
.context("cannot get CAS connection for deployment resolution")?
.into();
let provider = RecordingCasProvider {
inner: CasFileProvider { cas },
seen: std::sync::Mutex::new(Vec::new()),
};
let resolved = crate::config::manifest::manifest_to_resolved(
deployment_toml,
&cas_deployment_dir(),
&provider,
)
.await
.context("cannot resolve deployment manifest from the content-addressed store")?;
let files = provider
.seen
.into_inner()
.expect("RecordingCasProvider mutex poisoned");
Ok((resolved, files))
}
async fn deployment_resolved_from_manifest(
db_pool: &dyn concepts::storage::DbPool,
deployment_toml: &str,
) -> anyhow::Result<DeploymentResolved> {
Ok(
deployment_resolved_and_files_from_manifest(db_pool, deployment_toml)
.await?
.0,
)
}
pub(crate) struct DeploymentRunnable {
deployment: DeploymentResolved,
}
impl DeploymentRunnable {
pub(crate) async fn resolve(
mut deployment: DeploymentResolved,
cas: Option<&dyn concepts::cas::Cas>,
wasm_cache_dir: &Path,
) -> anyhow::Result<Self> {
for c in &mut deployment.activities_wasm {
materialize_wasm_location(
&mut c.common.location,
c.content_digest.as_ref(),
cas,
wasm_cache_dir,
)
.await?;
}
for c in &mut deployment.activities_stub {
if let ActivityStubComponentConfigResolved::File(f) = c {
materialize_wasm_location(
&mut f.common.location,
f.content_digest.as_ref(),
cas,
wasm_cache_dir,
)
.await?;
}
}
for c in &mut deployment.activities_external {
if let ActivityExternalComponentConfigResolved::File(f) = c {
materialize_wasm_location(
&mut f.common.location,
f.content_digest.as_ref(),
cas,
wasm_cache_dir,
)
.await?;
}
}
for c in &mut deployment.workflows_wasm {
materialize_wasm_location(
&mut c.common.location,
c.content_digest.as_ref(),
cas,
wasm_cache_dir,
)
.await?;
}
for c in &mut deployment.webhooks_wasm {
materialize_wasm_location(
&mut c.common.location,
c.content_digest.as_ref(),
cas,
wasm_cache_dir,
)
.await?;
}
Ok(Self { deployment })
}
fn into_resolved(self) -> DeploymentResolved {
self.deployment
}
}
async fn materialize_wasm_location(
location: &mut ComponentLocationToml,
content_digest: Option<&ContentDigest>,
cas: Option<&dyn concepts::cas::Cas>,
wasm_cache_dir: &Path,
) -> anyhow::Result<()> {
let ComponentLocationToml::Path(path) = location else {
return Ok(()); };
if Path::new(path).is_absolute() {
return Ok(()); }
let digest = content_digest.with_context(|| {
format!("deployment-owned WASM component `{path}` is missing a content digest")
})?;
let cas = cas.with_context(|| {
format!("cannot resolve deployment-owned WASM component `{path}` without a content-addressed store")
})?;
let target = content_digest_to_wasm_file(wasm_cache_dir, digest);
if !target.exists() {
let bytes = cas.read_blob(digest).await?.with_context(|| {
format!("blob {digest} for WASM component `{path}` not present in the CAS")
})?;
tokio::fs::write(&target, &bytes)
.await
.with_context(|| format!("cannot write WASM blob to cache file {target:?}"))?;
}
*location = ComponentLocationToml::Path(target.to_string_lossy().into_owned());
Ok(())
}
async fn prepare_new_deployment_record(
db_pool: &dyn concepts::storage::DbPool,
deployment_id: DeploymentId,
deployment_toml: String,
files: Vec<DeploymentManifestFile>,
description: Option<String>,
) -> anyhow::Result<DeploymentRecord> {
let cas = db_pool
.cas_conn()
.await
.context("cannot get CAS connection for deployment file upload")?;
let mut file_records = Vec::with_capacity(files.len());
for file in files {
cas.write_blob(&file.bytes)
.await
.with_context(|| format!("cannot upload deployment file `{}`", file.path))?;
file_records.push(DeploymentFileRecord {
path: file.path,
digest: file.digest,
size: u64::try_from(file.bytes.len()).expect("file length fits u64"),
});
}
let now = Utc::now();
let digest = DeploymentRecord::compute_digest(&deployment_toml);
Ok(DeploymentRecord {
deployment_id,
description,
digest,
created_at: now,
last_active_at: None,
status: DeploymentStatus::Inactive,
deployment_toml,
obelisk_version: PKG_VERSION.to_string(),
created_by: Some("server".to_string()),
files: file_records,
})
}
type DbClose = Pin<Box<dyn Future<Output = ()> + Send>>;
pub(crate) fn create_engines(
config: &ServerConfigToml,
prepared_dirs: &PreparedDirs,
) -> Result<Engines, anyhow::Error> {
let fuel: Option<u64> = config.wasm_global_config.fuel.into();
let consume_fuel = fuel.is_some();
let engine_config = EngineConfig {
codegen_cache_dir: prepared_dirs.codegen_cache_dir.clone(),
consume_fuel,
parallel_compilation: config.wasm_global_config.parallel_compilation,
debug: config.wasm_global_config.debug,
pooling_config: match config.wasm_global_config.allocator_config {
WasmtimeAllocatorConfig::OnDemand => PoolingConfig::OnDemand,
WasmtimeAllocatorConfig::Pooling => {
PoolingConfig::Pooling(config.wasm_global_config.wasmtime_pooling_config.into())
}
WasmtimeAllocatorConfig::Auto => PoolingConfig::PoolingWithFallback(
config.wasm_global_config.wasmtime_pooling_config.into(),
),
},
};
Ok(Engines::new(engine_config)?)
}
#[instrument(skip_all, name = "init", fields(deployment_id))]
#[expect(clippy::too_many_arguments)]
pub(crate) async fn run_internal(
config: ServerConfigToml,
deployment: Option<LocalDeployment>,
description: Option<String>,
path_prefixes: PathPrefixes,
params: RunParams,
prepared_dirs: PreparedDirs,
mut termination_watcher: watch::Receiver<()>,
secret_registry: Arc<SecretRegistry>,
) -> anyhow::Result<()> {
let api_listening_addr = config.api.enabled.then_some(config.api.listening_addr);
let api_config = config.api.clone();
let global_webhook_instance_limiter = config
.wasm_global_config
.global_webhook_instance_limiter
.as_semaphore();
let timers_watcher = config.timers_watcher;
let cancel_watcher = config.cancel_watcher;
let database = config.database.clone();
let (db_pool, db_close): (Arc<dyn DbPool>, DbClose) = match &database {
DatabaseConfigToml::Sqlite(sqlite_config_toml) => {
let db_dir = sqlite_config_toml
.get_sqlite_dir(&path_prefixes, &secret_registry)
.await?;
let sqlite_config = sqlite_config_toml.as_sqlite_config();
let sqlite_file = db_dir.join(SQLITE_FILE_NAME);
if params.clean_sqlite_directory {
warn!("Deleting sqlite directory {db_dir:?}");
tokio::fs::remove_dir_all(&db_dir)
.await
.or_else(ignore_not_found)
.with_context(|| format!("cannot delete database directory `{db_dir:?}`"))?;
tokio::fs::create_dir_all(&db_dir)
.await
.with_context(|| format!("cannot create database directory {db_dir:?}"))?;
}
let db_pool = Arc::new(
SqlitePool::new(&sqlite_file, sqlite_config)
.await
.with_context(|| format!("cannot open sqlite file {sqlite_file:?}"))?,
);
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
(db_pool, db_close)
}
DatabaseConfigToml::Postgres(postgres_config_toml) => {
let db_pool = Arc::new(
PostgresPool::new(
postgres_config_toml.as_config(&secret_registry)?,
postgres_config_toml.as_provision_policy(),
)
.await
.context("canont initialize postgres connection pool")?,
);
let db_close = Box::pin({
let db_pool = db_pool.clone();
async move { db_pool.close().await }
});
(db_pool, db_close)
}
};
let span = Span::current();
let (active_deployment_id, deployment_resolved, new_deployment_record) =
if let Some(deployment) = deployment {
let new_deployment_id = DeploymentId::generate();
span.record("deployment_id", tracing::field::display(&new_deployment_id));
let record = prepare_new_deployment_record(
&*db_pool,
new_deployment_id,
deployment.deployment_toml,
deployment.files,
description,
)
.await?;
(new_deployment_id, deployment.resolved, Some(record))
} else {
let conn = db_pool
.external_api_conn()
.await
.context("cannot get db connection for deployment lookup")?;
if let Some(record) = conn
.get_current_deployment()
.await
.context("cannot query current deployment")?
{
if record.status == concepts::storage::DeploymentStatus::Enqueued {
conn.activate_deployment(record.deployment_id, chrono::Utc::now())
.await
.context("cannot activate enqueued deployment")?;
}
let resolved =
deployment_resolved_from_manifest(&*db_pool, &record.deployment_toml).await?;
span.record(
"deployment_id",
tracing::field::display(&record.deployment_id),
);
if record.status == concepts::storage::DeploymentStatus::Enqueued {
info!("Activated enqueued deployment");
} else {
info!("Using the currently active deployment");
}
(record.deployment_id, resolved, None)
} else {
let new_deployment_id = DeploymentId::generate();
span.record("deployment_id", tracing::field::display(&new_deployment_id));
info!("No deployment found in DB; starting with empty deployment");
let record = prepare_new_deployment_record(
&*db_pool,
new_deployment_id,
String::new(),
Vec::new(),
None,
)
.await?;
(
new_deployment_id,
DeploymentResolved::default(),
Some(record),
)
}
};
let engines = create_engines(&config, &prepared_dirs)?;
let server_verified = server_verify(config, engines, secret_registry).await?;
config_prepass::preflight(
&server_verified,
Some(&deployment_resolved),
RuntimeConfigAvailability::Strict,
)?;
let cas: Arc<dyn concepts::cas::Cas> = db_pool.cas_conn().await?.into();
let compiled_and_linked = Box::pin(deployment_verify_config_compile_link(
server_verified.clone(),
&prepared_dirs,
deployment_resolved,
Some(cas),
active_deployment_id,
VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: params.dir_params.clean_cache,
clean_codegen_cache: params.dir_params.clean_codegen_cache,
},
runtime_config_availability: RuntimeConfigAvailability::Strict,
suppress_type_checking_errors: params.suppress_type_checking_errors,
suppress_linking_errors: false,
},
&mut termination_watcher,
))
.instrument(span.clone())
.await?;
if let Some(record) = new_deployment_record {
let api_conn = db_pool
.external_api_conn()
.await
.context("cannot get db connection for deployment insertion")?;
let (component_metadata, deployment_components) = build_component_metadata_records(
active_deployment_id,
&compiled_and_linked.component_registry_ro,
);
api_conn
.insert_deployment_with_components(record, component_metadata, deployment_components)
.await
.context("cannot insert deployment")?;
api_conn
.activate_deployment(active_deployment_id, chrono::Utc::now())
.await
.context("cannot activate deployment")?;
info!("Activated new deployment");
}
let cancel_registry = CancelRegistry::new();
let subscription_interruption = database.get_subscription_interruption();
let server_init = spawn_tasks_and_threads(
server_verified,
active_deployment_id,
db_pool,
db_close,
compiled_and_linked,
global_webhook_instance_limiter,
timers_watcher,
cancel_watcher,
&cancel_registry,
&termination_watcher,
prepared_dirs.clone(),
)
.instrument(span)
.await?;
let mut server_init = server_init;
let deployment_switch_manager = DeploymentSwitchManagerHandle::new(
server_init.server_verified.clone(),
server_init.prepared_dirs.clone(),
server_init.db_pool.clone(),
termination_watcher.clone(),
server_init.deployment_ctx.clone(),
server_init.webhook_registry.clone(),
cancel_registry.clone(),
server_init.log_forwarder_sender.clone(),
DEFAULT_SUBMIT_CONCURRENCY,
);
server_init.deployment_switch_manager = Some(deployment_switch_manager.clone());
switch_deployment(
deployment_switch_manager.clone(),
active_deployment_id,
SwitchDeploymentAction::Activate,
)
.instrument(info_span!("startup deployment manager no-op", %active_deployment_id))
.await
.map_err(|err| match err {
SwitchError::Busy => anyhow::anyhow!("deployment switch manager busy during startup"),
SwitchError::NotFound => {
anyhow::anyhow!("active deployment {active_deployment_id} not found during startup")
}
SwitchError::Other(err) => err,
})?;
let grpc_server = Arc::new(GrpcServer::new(
server_init.server_verified.clone(),
server_init.db_pool.clone(),
termination_watcher.clone(),
cancel_registry.clone(),
prepared_dirs.clone(),
server_init.deployment_ctx.clone(),
deployment_switch_manager.clone(),
));
let mut grpc = RoutesBuilder::default();
grpc.add_service(
grpc_gen::function_repository_server::FunctionRepositoryServer::from_arc(
grpc_server.clone(),
)
.send_compressed(CompressionEncoding::Zstd)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip),
)
.add_service(
grpc_gen::execution_repository_server::ExecutionRepositoryServer::from_arc(
grpc_server.clone(),
)
.send_compressed(CompressionEncoding::Zstd)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip),
)
.add_service(
grpc_gen::deployment_repository_server::DeploymentRepositoryServer::from_arc(
grpc_server.clone(),
)
.send_compressed(CompressionEncoding::Zstd)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip)
.max_decoding_message_size(crate::api::MAX_GRPC_MESSAGE_SIZE)
.max_encoding_message_size(crate::api::MAX_GRPC_MESSAGE_SIZE),
)
.add_service(
tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(grpc_gen::FILE_DESCRIPTOR_SET)
.build_v1()?,
);
let trace_layer = TraceLayer::new_for_grpc()
.make_span_with(make_span)
.on_failure(tower_http::trace::DefaultOnFailure::new().level(tracing::Level::DEBUG));
let grpc_service = ServiceBuilder::new()
.layer(GrpcWebLayer::new())
.layer(trace_layer)
.map_request(accept_trace)
.service(grpc.routes());
let app_router = app_router(WebApiState {
server_verified: server_init.server_verified.clone(),
deployment_ctx: server_init.deployment_ctx.clone(),
db_pool: server_init.db_pool.clone(),
cancel_registry,
termination_watcher: termination_watcher.clone(),
subscription_interruption,
prepared_dirs: server_init.prepared_dirs.clone(),
deployment_switch_manager,
});
if let Some(api_listening_addr) = api_listening_addr {
let app = app_router.fallback_service(grpc_service);
let app_svc = if params.allow_unauthenticated_api {
warn!(
"API authentication is disabled by --allow-unauthenticated-api: accepting all API requests"
);
app.into_make_service()
} else {
let api_auth = Arc::new(crate::server::auth::ApiAuth::new(&api_config));
info!(
"API startup token: {}",
api_auth.startup_token().expose_secret()
);
app.layer(axum::middleware::from_fn_with_state(
api_auth,
crate::server::auth::auth_middleware,
))
.into_make_service()
};
let listener = TcpListener::bind(api_listening_addr)
.await
.with_context(|| format!("cannot bind to {api_listening_addr}"))?;
axum::serve(listener, app_svc)
.with_graceful_shutdown(async move {
info!("Serving HTTP, gRPC and gRPC-Web requests at {api_listening_addr}");
info!("Obelisk is ready");
let _: Result<_, _> = termination_watcher.changed().await;
server_init.close().await; })
.await
.with_context(|| format!("server error listening on {api_listening_addr}"))?;
debug!("Server {api_listening_addr} has been closed");
} else {
info!("Obelisk is ready");
let _: Result<_, _> = termination_watcher.changed().await;
server_init.close().await;
}
Ok(())
}
fn make_span<B>(request: &axum::http::Request<B>) -> Span {
let mut headers = request.headers().clone();
if let Some(authorization) = headers.get_mut(axum::http::header::AUTHORIZATION) {
authorization.set_sensitive(true);
}
info_span!(
"gRPC request",
"otel.name" = format!("gRPC request {}", request.uri().path()),
?headers
)
}
#[derive(Clone)]
pub(crate) struct ServerVerified {
launch: ServerVerifiedLaunch,
allow_exec_activities: AllowExecActivities,
http_servers: Vec<HttpServer>,
fuel: Option<u64>,
global_executor_instance_limiter: Option<Arc<tokio::sync::Semaphore>>,
database_subscription_interruption: Option<Duration>,
api_addr_if_webui_enabled: Option<String>,
max_deployment_file_bytes: u32,
global_http_config: GlobalHttpConfig,
server_outbound_allowed_hosts: Vec<AllowedHostToml>,
source_path: Option<PathBuf>,
secret_registry: Arc<SecretRegistry>,
}
#[derive(Clone)]
struct ServerVerifiedLaunch {
engines: Engines,
build_semaphore: Option<u64>,
workflows_lock_extension_leeway: Duration,
}
impl ServerVerified {
#[instrument(name = "ServerVerified::new", skip_all)]
async fn new(
engines: Engines,
config: ServerConfigToml,
secret_registry: Arc<SecretRegistry>,
) -> Result<ServerVerified, anyhow::Error> {
trace!("Using server toml: {config:#?}");
let mut http_servers = config.http_servers;
if config.webui.enabled {
let webui_listening_addr = config.webui.listening_addr;
http_servers.push(webhook::HttpServer {
name: ConfigName::new(HTTP_SERVER_NAME_WEBUI.into()).unwrap(),
listening_addr: webui_listening_addr
.parse()
.context("error converting `webui.listening_addr` to a socket address")?,
});
if !config.api.enabled {
anyhow::bail!(
"cannot expose webui without enabling the API (`api.enabled = false` is set)"
);
}
}
if config.external.enabled {
http_servers.push(webhook::HttpServer {
name: ConfigName::new(HTTP_SERVER_NAME_EXTERNAL.into()).unwrap(),
listening_addr: config.external.listening_addr,
});
}
let fuel: Option<u64> = config.wasm_global_config.fuel.into();
let workflows_lock_extension_leeway =
config.workflows_global_config.lock_extension_leeway.into();
let build_semaphore = config.wasm_global_config.build_semaphore.into();
let global_executor_instance_limiter = config
.wasm_global_config
.global_executor_instance_limiter
.as_semaphore();
let database_subscription_interruption = config.database.get_subscription_interruption();
if config.allow_exec_activities == AllowExecActivities::AllowAny {
warn!(
"`allow_exec_activities = true` permits deployments to run arbitrary host \
programs; consider allowlisting reviewed scripts by content digest instead"
);
}
let server_outbound_allowed_hosts = config.outbound_http.allowed_hosts.clone();
let source_path = config.source_path.clone();
let (server_hosts, _advisories) =
resolve_allowed_hosts(config.outbound_http.allowed_hosts, false, &secret_registry)
.context("invalid server.toml `[[outbound_http.allowed_host]]` entry")?;
let global_http_config = GlobalHttpConfig::from(server_hosts);
Ok(Self {
launch: ServerVerifiedLaunch {
engines,
build_semaphore,
workflows_lock_extension_leeway,
},
allow_exec_activities: config.allow_exec_activities,
http_servers,
fuel,
global_executor_instance_limiter,
database_subscription_interruption,
api_addr_if_webui_enabled: if config.webui.enabled {
Some(config.api.listening_addr.to_string()) } else {
None
},
max_deployment_file_bytes: config.max_deployment_file_bytes.0,
global_http_config,
server_outbound_allowed_hosts,
source_path,
secret_registry,
})
}
}
pub(crate) type FrameFilesToSourceContent = HashMap<
String, String, >;
pub(crate) type HttpServersToWebhooksAndState = Vec<(
webhook::HttpServer,
(Vec<WebhookInstancesAndRoutes>, Arc<WebhookServerState>),
)>;
pub(crate) struct ServerCompiledLinked {
runtime_config_availability: RuntimeConfigAvailability,
engines: Engines,
pub(crate) component_registry_ro: ComponentConfigRegistryRO,
pub(crate) workers_linked: Vec<WorkerLinked>,
pub(crate) http_servers_to_webhooks_and_state: HttpServersToWebhooksAndState,
supressed_errors: Option<String>,
frame_files: Vec<(ComponentDigest, FrameFilesToSourceContent)>,
}
impl ServerCompiledLinked {
async fn new(
deployment_id: DeploymentId,
deployment_verified: DeploymentVerified,
server_verified: ServerVerifiedLaunch,
termination_watcher: &mut watch::Receiver<()>,
suppress_type_checking_errors: bool,
suppress_linking_errors: bool,
) -> Result<Self, anyhow::Error> {
trace!("Verified deployment: {deployment_verified:#?}");
let DeploymentVerified {
runtime_config_availability,
activities_wasm,
activities_js,
activities_exec,
activities_stub_ext,
activities_stub_ext_inline,
workflows,
workflows_js,
webhooks_wasm_by_names,
webhooks_js_by_names,
crons,
http_servers_to_webhook_names,
fuel,
global_http_config,
} = deployment_verified;
let linked = compile_and_link(
&server_verified.engines,
activities_wasm,
activities_js,
activities_exec,
activities_stub_ext,
activities_stub_ext_inline,
workflows,
workflows_js,
webhooks_wasm_by_names,
webhooks_js_by_names,
crons,
fuel,
global_http_config,
server_verified.build_semaphore,
server_verified.workflows_lock_extension_leeway,
termination_watcher,
suppress_linking_errors,
)
.await?;
if !suppress_type_checking_errors && linked.supressed_errors.is_some() {
bail!("type checking errors detected");
}
let http_server_len = http_servers_to_webhook_names.len();
let http_servers_to_webhooks = Self::connect_http_servers_to_webhooks(
&http_servers_to_webhook_names,
linked.webhooks_wasm_by_names,
);
assert_eq!(
http_server_len,
http_servers_to_webhooks.len(),
"must not omit empty http servers"
);
let fn_registry: Arc<dyn FunctionRegistry> =
Arc::from(linked.component_registry_ro.clone());
let http_servers_to_webhooks_and_state: Vec<_> = http_servers_to_webhooks
.into_iter()
.map(|(http_server, webhooks)| {
let state = Arc::new(build_webhook_server_state(
deployment_id,
&webhooks,
fn_registry.clone(),
));
(http_server, (webhooks, state))
})
.collect();
assert_eq!(
http_server_len,
http_servers_to_webhooks_and_state.len(),
"must not omit empty http servers"
);
Ok(ServerCompiledLinked {
runtime_config_availability,
workers_linked: linked.workers,
component_registry_ro: linked.component_registry_ro,
engines: server_verified.engines,
http_servers_to_webhooks_and_state,
supressed_errors: linked.supressed_errors,
frame_files: linked.all_frame_files,
})
}
fn connect_http_servers_to_webhooks(
http_servers_to_webhook_names: &[(webhook::HttpServer, Vec<ConfigName>)],
mut webhooks_wasm_by_names: IndexMap<ConfigName, WebhookInstancesAndRoutes>,
) -> Vec<(webhook::HttpServer, Vec<WebhookInstancesAndRoutes>)> {
http_servers_to_webhook_names
.iter()
.map(|(http_server, webhook_names)| {
let instances = webhook_names
.iter()
.map(|name| {
webhooks_wasm_by_names
.shift_remove(name)
.expect("all webhooks must be verified")
})
.collect();
(http_server.clone(), instances)
})
.collect()
}
async fn create_missing_cron_seeds(
&self,
db_pool: &Arc<dyn DbPool>,
deployment_id: DeploymentId,
) -> Result<(), anyhow::Error> {
create_missing_cron_seeds(
db_pool,
deployment_id,
self.workers_linked.iter().filter_map(|worker_linked| {
if let LinkedWorkerKind::Cron(cron_config) = &worker_linked.worker {
Some(cron_config.as_ref())
} else {
None
}
}),
)
.await
}
}
pub(crate) async fn upsert_backtrace_sources(
conn: &dyn DbExternalApi,
cas: &dyn concepts::cas::Cas,
server_compiled_linked: &ServerCompiledLinked,
) {
for (component_digest, frame_files) in &server_compiled_linked.frame_files {
for (config_key, source) in frame_files {
let (frame_key, is_suffix) = if let Some(stripped) = config_key.strip_prefix(".../") {
(format!("/{stripped}"), true)
} else {
(config_key.clone(), false)
};
let digest = match cas.write_blob(source.as_bytes()).await {
Ok(digest) => digest,
Err(err) => {
warn!("Cannot store backtrace source {config_key:?} in CAS: {err:?}");
continue;
}
};
if let Err(err) = conn
.upsert_source_mapping(component_digest, &frame_key, is_suffix, &digest)
.await
{
warn!("Cannot store backtrace source mapping {config_key:?} in DB: {err:?}");
}
}
}
}
fn build_component_metadata_records(
deployment_id: DeploymentId,
component_registry_ro: &ComponentConfigRegistryRO,
) -> (
Vec<ComponentMetadataRecord>, // List of components as stored in db
Vec<DeploymentComponentRecord>, // List of relations to each component for the depoloyment ID.
) {
let components = component_registry_ro.list(true);
let mut metadata_by_digest = HashMap::<ComponentDigest, ComponentMetadataRecord>::new();
let mut component_type_by_digest = HashMap::<ComponentDigest, ComponentType>::new();
let mut deployment_components = Vec::with_capacity(components.len());
for component in components {
let component_type = component.component_id.component_type;
let component_digest = component.component_id.component_digest.clone();
let exports: Vec<concepts::storage::PersistedFunctionMetadata> = component
.workflow_or_activity_config
.as_ref()
.map(|config| {
config
.exports_ext
.clone()
.into_iter()
.map(Into::into)
.collect()
})
.unwrap_or_default();
let imports = component
.imports
.clone()
.into_iter()
.map(Into::into)
.collect();
let wit_origin = component.wit_origin.to_string();
match metadata_by_digest.entry(component_digest.clone()) {
hashbrown::hash_map::Entry::Occupied(occupied) => {
let existing = occupied.get();
assert_eq!(
component_type_by_digest.get(&component_digest),
Some(&component_type),
"component digest reused across different component types"
);
assert_eq!(
existing.imports, imports,
"component digest reused with different imports"
);
assert_eq!(
existing.exports, exports,
"component digest reused with different exports"
);
assert_eq!(
existing.wit, component.wit,
"component digest reused with different WIT"
);
assert_eq!(
existing.wit_origin, wit_origin,
"component digest reused with different WIT origins"
);
}
hashbrown::hash_map::Entry::Vacant(vacant) => {
component_type_by_digest.insert(component_digest.clone(), component_type);
vacant.insert(ComponentMetadataRecord {
component_digest: component_digest.clone(),
imports,
exports,
wit: component.wit.clone(),
wit_origin,
});
}
}
deployment_components.push(DeploymentComponentRecord {
deployment_id,
component_name: component.component_id.name,
component_digest: component.component_id.component_digest,
component_type,
});
}
(
metadata_by_digest.into_values().collect(),
deployment_components,
)
}
pub(crate) struct SuppliedFile {
pub(crate) path: String,
pub(crate) supplied_digest: Option<String>,
pub(crate) content: Vec<u8>,
}
#[derive(Debug, Clone)]
pub(crate) struct SubmitFileIssue {
pub(crate) section: String,
pub(crate) component_name: Option<String>,
pub(crate) field_path: String,
pub(crate) path: Option<String>,
pub(crate) digest: Option<String>,
pub(crate) message: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SubmitDigestMismatch {
pub(crate) file: SubmitFileIssue,
pub(crate) supplied_digest: String,
pub(crate) actual_digest: String,
}
#[derive(Debug, Default)]
pub(crate) struct SubmitPackageError {
pub(crate) missing_digest_fields: Vec<SubmitFileIssue>,
pub(crate) missing_files: Vec<SubmitFileIssue>,
pub(crate) unexpected_files: Vec<SubmitFileIssue>,
pub(crate) digest_mismatches: Vec<SubmitDigestMismatch>,
pub(crate) oversized_files: Vec<SubmitFileIssue>,
}
impl SubmitPackageError {
fn is_empty(&self) -> bool {
self.missing_digest_fields.is_empty()
&& self.missing_files.is_empty()
&& self.unexpected_files.is_empty()
&& self.digest_mismatches.is_empty()
&& self.oversized_files.is_empty()
}
}
pub(crate) enum SubmitDeploymentError {
Busy,
Package(SubmitPackageError),
Other(anyhow::Error),
}
impl From<anyhow::Error> for SubmitDeploymentError {
fn from(err: anyhow::Error) -> Self {
Self::Other(err)
}
}
fn issue_from_ref(
file: &crate::config::manifest::DeploymentFileRef,
message: &str,
) -> SubmitFileIssue {
SubmitFileIssue {
section: file.field.section.clone(),
component_name: file.field.component_name.clone(),
field_path: file.field.field_path.clone(),
path: Some(file.path.clone()),
digest: Some(file.digest.to_string()),
message: message.to_string(),
}
}
fn supplied_issue(path: &str, digest: &ContentDigest, message: &str) -> SubmitFileIssue {
SubmitFileIssue {
section: "files".to_string(),
component_name: None,
field_path: format!("files[path={path}]"),
path: Some(path.to_string()),
digest: Some(digest.to_string()),
message: message.to_string(),
}
}
fn validate_submit_package(
expected: &[crate::config::manifest::DeploymentFileRef],
supplied: Vec<SuppliedFile>,
cas_present: &hashbrown::HashSet<ContentDigest>,
max_bytes: u32,
) -> Result<Vec<DeploymentManifestFile>, SubmitPackageError> {
use DeploymentManifestFile;
let mut err = SubmitPackageError::default();
let expected_by_digest: HashMap<ContentDigest, ()> = expected
.iter()
.map(|file| (file.digest.clone(), ()))
.collect();
let mut provided: HashMap<ContentDigest, Vec<u8>> = HashMap::new();
let max_bytes = max_bytes as usize; for file in supplied {
let actual = compute_content_digest(&file.content);
if let Some(supplied_digest) = &file.supplied_digest
&& supplied_digest.parse::<ContentDigest>().ok().as_ref() != Some(&actual)
{
err.digest_mismatches.push(SubmitDigestMismatch {
file: supplied_issue(
&file.path,
&actual,
"attached blob digest does not match the supplied digest",
),
supplied_digest: supplied_digest.clone(),
actual_digest: actual.to_string(),
});
continue;
}
if file.content.len() > max_bytes {
err.oversized_files.push(supplied_issue(
&file.path,
&actual,
&format!("attached blob exceeds the {max_bytes}-byte per-file limit"),
));
continue;
}
if expected_by_digest.contains_key(&actual) {
provided.insert(actual, file.content);
} else {
err.unexpected_files.push(supplied_issue(
&file.path,
&actual,
"attached blob is not referenced by the manifest",
));
}
}
let mut to_write = Vec::new();
for file in expected {
if cas_present.contains(&file.digest) {
continue;
}
if let Some(bytes) = provided.remove(&file.digest) {
to_write.push(DeploymentManifestFile {
path: file.path.clone(),
digest: file.digest.clone(),
bytes,
});
} else {
err.missing_files.push(issue_from_ref(
file,
"referenced file is neither attached nor present in the store",
));
}
}
if err.is_empty() {
Ok(to_write)
} else {
Err(err)
}
}
#[instrument(skip_all)]
#[expect(clippy::too_many_arguments)]
pub(crate) async fn submit_deployment(
server_verified: ServerVerified,
deployment_toml: &str,
runtime_config_availability: RuntimeConfigAvailability,
created_by: Option<String>,
description: Option<String>,
requested_deployment_id: Option<DeploymentId>,
prepared_dirs: &PreparedDirs,
supplied_files: Vec<SuppliedFile>,
db_pool: Arc<dyn DbPool>,
termination_watcher: &mut watch::Receiver<()>,
deployment_switch_manager: Option<DeploymentSwitchManagerHandle>,
) -> Result<DeploymentId, SubmitDeploymentError> {
info!("Submitting deployment");
let _submit_permit = if let Some(manager) = &deployment_switch_manager {
Some(manager.try_acquire_submit_permit()?)
} else {
None
};
let digest = DeploymentRecord::compute_digest(deployment_toml);
let conn = db_pool
.external_api_conn()
.await
.map_err(anyhow::Error::from)?;
if let Some(requested_deployment_id) = requested_deployment_id
&& let Some(existing) = conn
.get_deployment(requested_deployment_id)
.await
.map_err(anyhow::Error::from)?
{
if existing.digest == digest {
info!(%requested_deployment_id, "Deployment already exists with matching digest, returning existing ID");
return Ok(requested_deployment_id);
}
return Err(SubmitDeploymentError::Other(anyhow::anyhow!(
"deployment {requested_deployment_id} already exists with a different content digest \
(existing {}, submitted {digest}); use a fresh deployment ID",
existing.digest
)));
}
let manifest = DeploymentManifest::try_from_toml(deployment_toml, &cas_deployment_dir())
.context("cannot read deployment file references from manifest")?;
let cas = db_pool.cas_conn().await.map_err(anyhow::Error::from)?;
let mut cas_present = hashbrown::HashSet::new();
for file in &manifest.files {
if cas
.contains_blob(&file.digest)
.await
.map_err(|err| anyhow::anyhow!("cannot query CAS for {}: {err}", file.digest))?
{
cas_present.insert(file.digest.clone());
}
}
let to_write = validate_submit_package(
&manifest.files,
supplied_files,
&cas_present,
server_verified.max_deployment_file_bytes,
)
.map_err(SubmitDeploymentError::Package)?;
for file in &to_write {
let stored = cas.write_blob(&file.bytes).await.map_err(|err| {
anyhow::anyhow!("cannot store deployment file {}: {err}", file.digest)
})?;
if stored != file.digest {
return Err(SubmitDeploymentError::Other(anyhow::anyhow!(
"stored deployment file digest mismatch: expected {}, got {stored}",
file.digest
)));
}
}
let deployment_resolved = deployment_resolved_from_manifest(&*db_pool, deployment_toml)
.await
.map_err(SubmitDeploymentError::Other)?;
let cas_arc: Arc<dyn concepts::cas::Cas> = db_pool
.cas_conn()
.await
.map_err(anyhow::Error::from)?
.into();
let deployment_id = requested_deployment_id.unwrap_or_else(DeploymentId::generate);
config_prepass::preflight(
&server_verified,
Some(&deployment_resolved),
runtime_config_availability,
)
.map_err(SubmitDeploymentError::Other)?;
let compiled_linked = deployment_verify_config_compile_link(
server_verified,
prepared_dirs,
deployment_resolved,
Some(cas_arc),
deployment_id,
VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
},
termination_watcher,
)
.await
.map_err(SubmitDeploymentError::Other)?;
let now = chrono::Utc::now();
let (component_metadata, deployment_components) =
build_component_metadata_records(deployment_id, &compiled_linked.component_registry_ro);
conn.insert_deployment_with_components(
DeploymentRecord {
deployment_id,
description,
digest: digest.clone(),
created_at: now,
last_active_at: None,
status: DeploymentStatus::Inactive,
obelisk_version: crate::args::shadow::PKG_VERSION.to_string(),
created_by,
deployment_toml: deployment_toml.to_string(),
files: manifest.file_records(),
},
component_metadata,
deployment_components,
)
.await
.map_err(anyhow::Error::from)?;
upsert_backtrace_sources(conn.as_ref(), cas.as_ref(), &compiled_linked).await;
if let Some(manager) = deployment_switch_manager
&& runtime_config_availability == RuntimeConfigAvailability::Strict
{
manager
.store_latest_prepared(PreparedDeploymentSwitch {
deployment_id,
digest,
compiled_linked,
})
.await;
}
info!(%deployment_id, "Deployment submitted");
Ok(deployment_id)
}
fn compute_content_digest(content: &[u8]) -> ContentDigest {
let hash: [u8; 32] = Sha256::digest(content).into();
ContentDigest(Digest(hash))
}
#[derive(Debug, Clone, Copy, derive_more::Display)]
pub(crate) enum SwitchOutcome {
#[display("switched")]
Switched,
#[display("restart required")]
RestartRequired,
}
pub(crate) enum SwitchError {
Busy,
NotFound,
Other(anyhow::Error),
}
impl From<anyhow::Error> for SwitchError {
fn from(err: anyhow::Error) -> Self {
Self::Other(err)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SwitchDeploymentAction {
Activate,
Enqueue(RuntimeConfigAvailability),
}
impl SwitchDeploymentAction {
fn runtime_config_availability(self) -> RuntimeConfigAvailability {
match self {
SwitchDeploymentAction::Enqueue(availability) => availability,
SwitchDeploymentAction::Activate => RuntimeConfigAvailability::Strict,
}
}
}
async fn enqueue_deployment_and_release(
deployment_switch_manager: &DeploymentSwitchManagerHandle,
deployment_id: DeploymentId,
switch_permit: SwitchPermit,
) -> Result<EnqueueOutcome, SwitchError> {
let db_conn = deployment_switch_manager
.inner
.db_pool
.external_api_conn()
.await
.map_err(|e| SwitchError::Other(e.into()))?;
let outcome = db_conn
.enqueue_deployment(deployment_id)
.await
.map_err(|e| SwitchError::Other(e.into()))?;
drop(switch_permit);
Ok(outcome)
}
#[instrument(skip_all, fields(%deployment_id))]
pub(crate) async fn switch_deployment(
deployment_switch_manager: DeploymentSwitchManagerHandle,
deployment_id: DeploymentId,
action: SwitchDeploymentAction,
) -> Result<SwitchOutcome, SwitchError> {
let deployment_already_active = deployment_switch_manager
.inner
.deployment_ctx
.read()
.await
.deployment_id
== deployment_id;
match action {
SwitchDeploymentAction::Enqueue(_) => {
let switch_permit = deployment_switch_manager.try_acquire_switch_permit()?;
if !deployment_already_active {
let mut termination_watcher =
deployment_switch_manager.inner.termination_watcher.clone();
prepare_switch_deployment(
&deployment_switch_manager,
deployment_id,
action,
&mut termination_watcher,
)
.await?;
}
let outcome = enqueue_deployment_and_release(
&deployment_switch_manager,
deployment_id,
switch_permit,
)
.await?;
Ok(match outcome {
EnqueueOutcome::Enqueued => {
info!(%deployment_id, "Deployment enqueued for next restart");
SwitchOutcome::RestartRequired
}
EnqueueOutcome::AlreadyActive => {
info!(%deployment_id, "Deployment already active; it will remain active after restart");
SwitchOutcome::Switched
}
})
}
SwitchDeploymentAction::Activate => {
if deployment_already_active {
info!(%deployment_id, "Deployment switch no-op: deployment already active");
return Ok(SwitchOutcome::Switched);
}
let switch_permit = deployment_switch_manager.try_acquire_switch_permit()?;
let mut termination_watcher =
deployment_switch_manager.inner.termination_watcher.clone();
let prepared = prepare_switch_deployment(
&deployment_switch_manager,
deployment_id,
action,
&mut termination_watcher,
)
.await?;
deployment_switch_manager
.spawn_critical_hot_switch(prepared, switch_permit)
.await
}
}
}
#[instrument(skip_all, fields(%deployment_id))]
async fn prepare_switch_deployment(
deployment_switch_manager: &DeploymentSwitchManagerHandle,
deployment_id: DeploymentId,
action: SwitchDeploymentAction,
termination_watcher: &mut watch::Receiver<()>,
) -> Result<PreparedDeploymentSwitch, SwitchError> {
let db_conn = deployment_switch_manager
.inner
.db_pool
.external_api_conn()
.await
.map_err(|e| SwitchError::Other(e.into()))?;
let deployment_record = db_conn
.get_deployment(deployment_id)
.await
.map_err(|e| SwitchError::Other(e.into()))?
.ok_or(SwitchError::NotFound)?;
if let Some(prepared) = deployment_switch_manager
.take_latest_prepared(deployment_id, &deployment_record.digest)
.await
{
debug!(%deployment_id, "Using cached prepared deployment artifact");
return Ok(prepared);
}
let missing = db_conn
.missing_digests(deployment_id)
.await
.map_err(|e| SwitchError::Other(e.into()))?;
if !missing.is_empty() {
return Err(SwitchError::Other(anyhow::anyhow!(
"deployment {deployment_id} is missing {} referenced file blob(s): {}",
missing.len(),
missing
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)));
}
let target_deployment = deployment_resolved_from_manifest(
&*deployment_switch_manager.inner.db_pool,
&deployment_record.deployment_toml,
)
.await
.map_err(SwitchError::Other)?;
let cas: Arc<dyn concepts::cas::Cas> = deployment_switch_manager
.inner
.db_pool
.cas_conn()
.await
.map_err(|e| SwitchError::Other(e.into()))?
.into();
let verify_params = VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: action.runtime_config_availability(),
suppress_type_checking_errors: false,
suppress_linking_errors: false,
};
config_prepass::preflight(
&deployment_switch_manager.inner.server_verified,
Some(&target_deployment),
verify_params.runtime_config_availability,
)
.map_err(SwitchError::Other)?;
let compiled_linked = deployment_verify_config_compile_link(
deployment_switch_manager.inner.server_verified.clone(),
&deployment_switch_manager.inner.prepared_dirs,
target_deployment,
Some(cas),
deployment_id,
verify_params,
termination_watcher,
)
.await?;
Ok(PreparedDeploymentSwitch {
deployment_id,
digest: deployment_record.digest,
compiled_linked,
})
}
fn spawn_deployment_context(
deployment_id: DeploymentId,
workers_linked: Vec<WorkerLinked>,
component_registry_ro: ComponentConfigRegistryRO,
db_pool: &Arc<dyn DbPool>,
cancel_registry: &CancelRegistry,
log_forwarder_sender: &mpsc::Sender<LogInfoAppendRow>,
) -> DeploymentContext {
let mut exec_task_handles: Vec<ExecutorTaskHandle> = Vec::with_capacity(workers_linked.len());
let mut replay_workers = ReplayWorkerRegistry::default();
for pre_spawn in workers_linked {
let (handle, replay_entry) = pre_spawn.spawn(
deployment_id,
db_pool,
cancel_registry.clone(),
log_forwarder_sender,
);
exec_task_handles.push(handle);
if let Some((component_id, worker)) = replay_entry {
replay_workers.insert(component_id, worker);
}
}
DeploymentContext {
deployment_id,
component_registry_ro,
exec_task_handles,
replay_workers: Arc::new(replay_workers),
closed: false,
}
}
#[instrument(skip_all, fields(%deployment_id))]
async fn switch_hot_redeploy(
server_compiled_linked: ServerCompiledLinked,
deployment_id: DeploymentId,
db_pool: Arc<dyn DbPool>,
deployment_ctx: DeploymentContextHandle,
webhook_registry: Arc<WebhookRegistry>,
cancel_registry: CancelRegistry,
log_forwarder_sender: mpsc::Sender<LogInfoAppendRow>,
) -> Result<SwitchOutcome, SwitchError> {
server_compiled_linked
.runtime_config_availability
.assert_strict();
let mut write_guard_ctx = deployment_ctx.write().await;
if write_guard_ctx.closed {
return Err(SwitchError::Other(anyhow::anyhow!(
"server is being shut down"
)));
}
debug!("Closing old executors");
let old = std::mem::take(&mut write_guard_ctx.exec_task_handles);
let worker_tasks_handles =
futures_util::future::join_all(old.into_iter().map(ExecutorTaskHandle::close_outer_task))
.await;
debug!("Waiting for workers");
futures_util::future::join_all(
worker_tasks_handles
.into_iter()
.map(WorkerTasksHandle::close),
)
.await;
debug!("Swapping webhook registry");
webhook_registry.swap(
server_compiled_linked
.http_servers_to_webhooks_and_state
.iter()
.map(|(http_server, (_instance, state))| (http_server.name.to_string(), state.clone())),
);
*write_guard_ctx = spawn_deployment_context(
deployment_id,
server_compiled_linked.workers_linked,
server_compiled_linked.component_registry_ro,
&db_pool,
&cancel_registry,
&log_forwarder_sender,
);
info!(%deployment_id, "Switched to new deployment");
Ok(SwitchOutcome::Switched)
}
async fn create_missing_cron_seeds(
db_pool: &Arc<dyn DbPool>,
deployment_id: DeploymentId,
cron_configs: impl Iterator<Item = &ScheduleWorkerConfig>,
) -> Result<(), anyhow::Error> {
let conn = db_pool.external_api_conn().await?;
for cron_config in cron_configs {
let digest = &cron_config.component_id.component_digest;
let execution_id = ExecutionId::deterministic_at_unix_epoch(&digest.0.0);
if conn.get(&execution_id).await.is_ok() {
info!(
%execution_id,
"Cron execution `{}` already found",
cron_config.component_id.name
);
} else {
info!(
%execution_id,
"Creating cron execution `{}`",
cron_config.component_id.name
);
let source_ffqn = cron_worker::cron_ffqn(&cron_config.target_ffqn);
let now = chrono::Utc::now();
conn.create(CreateRequest {
created_at: now,
execution_id,
ffqn: source_ffqn,
params: Params::empty(),
parent: None,
scheduled_at: now,
component_id: cron_config.component_id.clone(),
deployment_id,
metadata: concepts::ExecutionMetadata::empty(),
scheduled_by: None,
paused: false,
})
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to create seed execution for cron `{}`: {e}",
cron_config.component_id.name
)
})?;
}
}
Ok(())
}
#[instrument(skip_all)]
#[expect(clippy::too_many_arguments)]
async fn spawn_tasks_and_threads(
server_verified: ServerVerified,
deployment_id: DeploymentId,
db_pool: Arc<dyn DbPool>,
db_close: Pin<Box<dyn Future<Output = ()> + Send>>,
server_compiled_linked: ServerCompiledLinked,
global_webhook_instance_limiter: Option<Arc<tokio::sync::Semaphore>>,
timers_watcher: TimersWatcherTomlConfig,
cancel_watcher: CancelWatcherTomlConfig,
cancel_registry: &CancelRegistry,
termination_watcher: &watch::Receiver<()>,
prepared_dirs: PreparedDirs,
) -> Result<ServerInit, anyhow::Error> {
server_compiled_linked
.runtime_config_availability
.assert_strict();
upsert_backtrace_sources(
db_pool.external_api_conn().await?.as_ref(),
db_pool.cas_conn().await?.as_ref(),
&server_compiled_linked,
)
.await;
let epoch_ticker = EpochTicker::spawn_new(
server_compiled_linked.engines.weak_refs(),
Duration::from_millis(EPOCH_MILLIS),
);
let timers_watcher = if timers_watcher.enabled {
Some(expired_timers_watcher::spawn_new(
db_pool.clone(),
TimersWatcherConfig {
tick_sleep: timers_watcher.tick_sleep.into(),
clock_fn: Now.clone_box(),
leeway: timers_watcher.leeway.into(),
},
))
} else {
None
};
let cancellation_driver = wasm_workers::cancellation_driver::CancellationDriver::spawn(
db_pool.clone(),
cancel_registry.clone(),
Now.clone_box(),
TokioSleep,
cancel_watcher.tick_sleep.into(),
CANCELLATION_DRIVER_BATCH_SIZE,
);
let cancel_watcher = cancel_registry.spawn_cancel_watcher(cancel_watcher.tick_sleep.into());
server_compiled_linked
.create_missing_cron_seeds(&db_pool, deployment_id)
.await?;
let (log_forwarder_sender, log_db_forarder) = {
let (log_forwarder_sender, receiver) = mpsc::channel(1000); let log_db_forarder = log_db_forwarder::spawn_new(db_pool.clone(), receiver);
(log_forwarder_sender, log_db_forarder)
};
let webhook_registry = WebhookRegistry::new(
server_compiled_linked
.http_servers_to_webhooks_and_state
.iter()
.map(|(http_server, (_instance, state))| (http_server.name.to_string(), state.clone())),
);
let http_servers = server_compiled_linked
.http_servers_to_webhooks_and_state
.iter()
.map(|(http_server, _)| http_server);
let http_servers_handles: Vec<AbortOnDropHandle> = start_http_servers(
http_servers,
&webhook_registry,
&server_compiled_linked.engines,
db_pool.clone(),
global_webhook_instance_limiter.clone(),
termination_watcher,
&log_forwarder_sender,
)
.await?;
let deployment_ctx: DeploymentContextHandle =
Arc::new(tokio::sync::RwLock::new(spawn_deployment_context(
deployment_id,
server_compiled_linked.workers_linked,
server_compiled_linked.component_registry_ro,
&db_pool,
cancel_registry,
&log_forwarder_sender,
)));
let server_init = ServerInit {
server_verified,
deployment_ctx,
db_pool,
db_close,
timers_watcher,
cancel_watcher,
cancellation_driver,
http_servers_handles,
epoch_ticker,
log_db_forarder,
engines: server_compiled_linked.engines,
log_forwarder_sender,
webhook_registry: Arc::new(webhook_registry),
prepared_dirs,
deployment_switch_manager: None,
};
Ok(server_init)
}
struct ServerInit {
server_verified: ServerVerified,
deployment_ctx: DeploymentContextHandle,
db_pool: Arc<dyn DbPool>,
db_close: Pin<Box<dyn Future<Output = ()> + Send>>,
engines: Engines,
timers_watcher: Option<AbortOnDropHandle>,
cancel_watcher: AbortOnDropHandle,
cancellation_driver: AbortOnDropHandle,
http_servers_handles: Vec<AbortOnDropHandle>,
epoch_ticker: EpochTicker,
log_db_forarder: AbortOnDropHandle,
log_forwarder_sender: mpsc::Sender<LogInfoAppendRow>,
webhook_registry: Arc<WebhookRegistry>,
prepared_dirs: PreparedDirs,
deployment_switch_manager: Option<DeploymentSwitchManagerHandle>,
}
impl ServerInit {
async fn close(self) {
info!("Server is shutting down");
let ServerInit {
server_verified: _,
deployment_ctx,
db_pool,
db_close,
engines,
timers_watcher,
cancel_watcher,
cancellation_driver,
http_servers_handles,
epoch_ticker,
log_db_forarder,
log_forwarder_sender,
webhook_registry,
prepared_dirs: _,
deployment_switch_manager,
} = self;
if let Some(deployment_switch_manager) = deployment_switch_manager {
deployment_switch_manager.close().await;
}
debug!("Closing executors");
let executors = {
let mut deployment_lock = deployment_ctx.write().await;
deployment_lock.closed = true;
std::mem::take(&mut deployment_lock.exec_task_handles)
};
let worker_tasks_handles = futures_util::future::join_all(
executors
.into_iter()
.map(ExecutorTaskHandle::close_outer_task),
)
.await;
debug!("Waiting for workers");
futures_util::future::join_all(
worker_tasks_handles
.into_iter()
.map(WorkerTasksHandle::close),
)
.await;
drop(db_pool);
drop(timers_watcher);
drop(cancel_watcher);
drop(cancellation_driver);
drop(http_servers_handles);
drop(epoch_ticker);
drop(engines);
drop(webhook_registry);
drop(log_forwarder_sender);
drop(log_db_forarder); debug!("Closing db");
db_close.await;
}
}
type WebhookInstancesAndRoutes = (WebhookEndpointInstanceLinked, Vec<WebhookRouteVerified>);
pub(crate) fn build_webhook_server_state(
deployment_id: DeploymentId,
webhooks: &[WebhookInstancesAndRoutes],
fn_registry: Arc<dyn FunctionRegistry>,
) -> WebhookServerState {
let mut router = MethodAwareRouter::default();
for (webhook_instance_linked, routes) in webhooks {
for route in routes {
if route.methods.is_empty() {
router.add(None, &route.route, webhook_instance_linked.clone());
} else {
for method in &route.methods {
router.add(
Some(method.clone()),
&route.route,
webhook_instance_linked.clone(),
);
}
}
}
}
WebhookServerState {
deployment_id,
router: Arc::new(router),
fn_registry,
}
}
async fn start_http_servers(
http_servers: impl Iterator<Item = &webhook::HttpServer>,
webhook_registry: &WebhookRegistry,
engines: &Engines,
db_pool: Arc<dyn DbPool>,
global_webhook_instance_limiter: Option<Arc<tokio::sync::Semaphore>>,
termination_watcher: &watch::Receiver<()>,
log_forwarder_sender: &mpsc::Sender<LogInfoAppendRow>,
) -> Result<Vec<AbortOnDropHandle>, anyhow::Error> {
let mut abort_handles = Vec::new();
let engine = &engines.webhook_engine;
for http_server in http_servers {
let state_watcher = webhook_registry
.get_watcher(http_server.name.as_ref())
.expect("wh_server_state_watcher must exist for every http_server");
let tcp_listener = TcpListener::bind(&http_server.listening_addr)
.await
.with_context(|| {
format!(
"cannot bind socket {} for `http_server` named `{}`",
http_server.listening_addr, http_server.name
)
})?;
let server_addr = tcp_listener.local_addr()?;
info!(
"HTTP server `{}` is listening on http://{server_addr}",
http_server.name,
);
let server = AbortOnDropHandle::new(
tokio::spawn(webhook_trigger::server(
http_server.name.to_string(),
tcp_listener,
engine.clone(),
state_watcher,
log_forwarder_sender.clone(),
db_pool.clone(),
Now.clone_box(),
Arc::new(TokioSleep),
global_webhook_instance_limiter.clone(),
termination_watcher.clone(),
))
.abort_handle(),
);
abort_handles.push(server);
}
Ok(abort_handles)
}
#[derive(Debug)]
pub(crate) struct DeploymentVerified {
runtime_config_availability: RuntimeConfigAvailability, activities_wasm: Vec<ActivityWasmConfigVerified>,
activities_js: Vec<ActivityJsConfigVerified>,
activities_exec: Vec<ActivityExecConfigVerified>,
activities_stub_ext: Vec<ActivityStubExtConfigVerified>,
activities_stub_ext_inline: Vec<ActivityStubExtInlineConfigVerified>,
workflows: Vec<WorkflowConfigVerified>,
workflows_js: Vec<WorkflowJsConfigVerified>,
webhooks_wasm_by_names: IndexMap<ConfigName, WebhookWasmComponentConfigVerified>,
webhooks_js_by_names: IndexMap<ConfigName, WebhookJsConfigVerified>,
crons: Vec<CronConfigVerified>,
http_servers_to_webhook_names: Vec<(webhook::HttpServer, Vec<ConfigName>)>,
fuel: Option<u64>,
global_http_config: GlobalHttpConfig,
}
impl DeploymentVerified {
fn validate_component_digests(&self) -> Result<(), anyhow::Error> {
fn record_component_ids<'a>(
component_ids_by_digest: &mut HashMap<ComponentDigest, ComponentId>,
component_ids: impl IntoIterator<Item = &'a ComponentId>,
) -> Result<(), anyhow::Error> {
for component_id in component_ids {
if let Some(existing) = component_ids_by_digest
.insert(component_id.component_digest.clone(), component_id.clone())
&& existing.component_type != component_id.component_type
{
bail!(
"component digest `{}` is shared between component types `{}` ({}) and `{}` ({})",
component_id.component_digest,
existing.component_type,
existing.name,
component_id.component_type,
component_id.name,
);
}
}
Ok(())
}
let mut component_ids_by_digest = HashMap::<ComponentDigest, ComponentId>::new();
record_component_ids(
&mut component_ids_by_digest,
self.activities_wasm
.iter()
.map(ActivityWasmConfigVerified::component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.activities_js
.iter()
.map(ActivityJsConfigVerified::component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.activities_exec
.iter()
.map(ActivityExecConfigVerified::component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.activities_stub_ext
.iter()
.map(|activity| &activity.component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.activities_stub_ext_inline
.iter()
.map(|activity| &activity.component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.workflows
.iter()
.map(WorkflowConfigVerified::component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.workflows_js
.iter()
.map(WorkflowJsConfigVerified::component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.webhooks_wasm_by_names
.values()
.map(|webhook| &webhook.component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.webhooks_js_by_names
.values()
.map(|webhook| &webhook.component_id),
)?;
record_component_ids(
&mut component_ids_by_digest,
self.crons.iter().map(|cron| &cron.component_id),
)?;
Ok(())
}
fn verify_no_webui_server_bindings(
deployment: &DeploymentResolved,
) -> Result<(), anyhow::Error> {
let mut offending_webhooks = deployment
.webhooks_wasm
.iter()
.filter(|webhook| &**webhook.http_server == HTTP_SERVER_NAME_WEBUI)
.map(|webhook| &webhook.common.name)
.chain(
deployment
.webhooks_js
.iter()
.filter(|webhook| &**webhook.http_server == HTTP_SERVER_NAME_WEBUI)
.map(|webhook| &webhook.name),
)
.peekable();
if offending_webhooks.peek().is_some() {
bail!(
"the `{HTTP_SERVER_NAME_WEBUI}` http_server is reserved for the web UI; \
the following webhook(s) must not attach to it: {:?}",
offending_webhooks.collect::<Vec<_>>()
);
}
Ok(())
}
#[instrument(skip_all)]
#[expect(clippy::too_many_arguments)]
async fn fetch_and_verify_all(
deployment: DeploymentRunnable,
http_servers: Vec<webhook::HttpServer>,
wasm_cache_dir: Arc<Path>,
metadata_dir: Arc<Path>,
runtime_config_availability: RuntimeConfigAvailability,
global_executor_instance_limiter: Option<Arc<tokio::sync::Semaphore>>,
fuel: Option<u64>,
termination_watcher: &mut watch::Receiver<()>,
subscription_interruption: Option<Duration>,
api_addr_if_webui_enabled: Option<String>,
secret_registry: Arc<SecretRegistry>,
global_http_config: GlobalHttpConfig,
) -> Result<DeploymentVerified, anyhow::Error> {
let ignore_missing_env_vars =
runtime_config_availability == RuntimeConfigAvailability::AllowUnavailable;
let mut deployment = deployment.into_resolved();
trace!("Using deployment toml: {deployment:#?}");
if http_servers.len()
> http_servers
.iter()
.map(|it| &it.name)
.collect::<hashbrown::HashSet<_>>()
.len()
{
bail!("Each `http_server` must have a unique name");
}
Self::verify_no_webui_server_bindings(&deployment)?;
if let Some(api_listening_addr) = api_addr_if_webui_enabled {
let target_url = format!("http://{api_listening_addr}");
deployment
.webhooks_wasm
.push(webhook::WebhookWasmComponentConfigResolved {
common: ComponentCommon {
name: ConfigName::new(StrVariant::Static(COMPONENT_NAME_WEBUI)).unwrap(),
location: WEBUI_LOCATION
.parse()
.expect("hard-coded webui reference must be parsed"),
},
content_digest: None,
http_server: ConfigName::new(HTTP_SERVER_NAME_WEBUI.into()).unwrap(),
routes: vec![WebhookRoute::default()],
forward_stdout: ComponentStdOutputToml::default(),
forward_stderr: ComponentStdOutputToml::default(),
env_vars: vec![EnvVarConfig::KeyValue {
key: "TARGET_URL".to_string(),
value: target_url.clone(),
}],
backtrace: crate::config::toml::ComponentBacktraceConfigResolved::default(),
backtrace_persist: false,
logs_store_min_level: LogLevelToml::Off,
allowed_hosts: vec![AllowedHostToml {
pattern: target_url,
methods: Some(MethodsInput::Star(MethodsInputStar::default())),
request_url_regex: None,
secrets: Vec::new(),
replace_in: Vec::new(),
}],
is_webui: true,
});
}
let http_servers_to_webhook_names = {
let mut remaining_server_names_to_webhook_names = {
let mut map: hashbrown::HashMap<ConfigName, Vec<ConfigName>> =
hashbrown::HashMap::default();
for webhook in &deployment.webhooks_wasm {
map.entry(webhook.http_server.clone())
.or_default()
.push(webhook.common.name.clone());
}
for webhook_js in &deployment.webhooks_js {
map.entry(webhook_js.http_server.clone())
.or_default()
.push(webhook_js.name.clone());
}
map
};
let http_servers_len = http_servers.len();
let http_servers_to_webhook_names = {
let mut vec = Vec::new();
for http_server in http_servers {
let webhooks = remaining_server_names_to_webhook_names
.remove(&http_server.name)
.unwrap_or_default();
vec.push((http_server, webhooks));
}
vec
};
if !remaining_server_names_to_webhook_names.is_empty() {
bail!(
"No matching `http_server` found for some `webhook` configurations: {:?}",
remaining_server_names_to_webhook_names
.keys()
.collect::<Vec<_>>()
);
}
assert_eq!(
http_servers_len,
http_servers_to_webhook_names.len(),
"http servers with empty names must not be removed"
);
http_servers_to_webhook_names
};
let activities_wasm = deployment
.activities_wasm
.into_iter()
.map(|activity_wasm| {
let wasm_cache_dir = wasm_cache_dir.clone();
let metadata_dir = metadata_dir.clone();
let global_executor_instance_limiter = global_executor_instance_limiter.clone();
let secret_registry = secret_registry.clone();
let global_http_config = global_http_config.clone();
tokio::spawn(
async move {
activity_wasm
.fetch_and_verify(
wasm_cache_dir,
metadata_dir,
ignore_missing_env_vars,
&secret_registry,
global_http_config,
global_executor_instance_limiter,
fuel,
)
.await
}
.in_current_span(),
)
})
.collect::<Vec<_>>();
let activities_stub_tasks = deployment
.activities_stub
.into_iter()
.map(|activity| {
tokio::spawn(
activity
.fetch_and_verify(wasm_cache_dir.clone(), metadata_dir.clone())
.in_current_span(),
)
})
.collect::<Vec<_>>();
let activities_external_tasks = deployment
.activities_external
.into_iter()
.map(|activity| {
tokio::spawn(
activity
.fetch_and_verify(wasm_cache_dir.clone(), metadata_dir.clone())
.in_current_span(),
)
})
.collect::<Vec<_>>();
let workflows = deployment
.workflows_wasm
.into_iter()
.map(|workflow| {
tokio::spawn(
workflow
.fetch_and_verify(
wasm_cache_dir.clone(),
metadata_dir.clone(),
global_executor_instance_limiter.clone(),
fuel,
subscription_interruption,
)
.in_current_span(),
)
})
.collect::<Vec<_>>();
let webhooks_wasm_by_names = deployment
.webhooks_wasm
.into_iter()
.map(|webhook| {
let wasm_cache_dir = wasm_cache_dir.clone();
let metadata_dir = metadata_dir.clone();
let secret_registry = secret_registry.clone();
tokio::spawn(
async move {
webhook
.fetch_and_verify(
wasm_cache_dir,
metadata_dir,
ignore_missing_env_vars,
&secret_registry,
subscription_interruption,
)
.await
}
.in_current_span(),
)
})
.collect::<Vec<_>>();
let activity_js_runtime_fetch: OptionFuture<_> = if deployment.activities_js.is_empty() {
None
} else {
Some(fetch_activity_js_runtime(
wasm_cache_dir.clone(),
metadata_dir.clone(),
))
}
.into();
let workflow_js_runtime_fetch: OptionFuture<_> = if deployment.workflows_js.is_empty() {
None
} else {
Some(fetch_workflow_js_runtime(
wasm_cache_dir.clone(),
metadata_dir.clone(),
))
}
.into();
let webhook_js_runtime_fetch: OptionFuture<_> = if deployment.webhooks_js.is_empty() {
None
} else {
Some(fetch_webhook_js_runtime(
wasm_cache_dir.clone(),
metadata_dir.clone(),
))
}
.into();
let all = futures_util::future::join3(
futures_util::future::join5(
futures_util::future::join_all(activities_wasm),
futures_util::future::join(
futures_util::future::join_all(activities_stub_tasks),
futures_util::future::join_all(activities_external_tasks),
),
futures_util::future::join_all(workflows),
futures_util::future::join_all(webhooks_wasm_by_names),
activity_js_runtime_fetch,
),
workflow_js_runtime_fetch,
webhook_js_runtime_fetch,
);
tokio::select! {
((activity_wasm_results, (activity_stub_task_results, activity_external_task_results), workflow_results, webhook_results, activity_js_runtime_result), workflow_js_runtime_result, webhook_js_runtime_result) = all => {
let activities_wasm = activity_wasm_results.into_iter().collect::<Result<Result<Vec<_>, _>, _>>()??;
let stub_results: Vec<ActivityStubConfigVerified> = activity_stub_task_results.into_iter().collect::<Result<Result<Vec<_>, _>, _>>()??;
let external_results: Vec<ActivityExternalConfigVerified> = activity_external_task_results.into_iter().collect::<Result<Result<Vec<_>, _>, _>>()??;
let mut activities_stub_ext: Vec<ActivityStubExtConfigVerified> = Vec::new();
let mut activities_stub_ext_inline: Vec<ActivityStubExtInlineConfigVerified> = Vec::new();
for result in stub_results {
match result {
ActivityStubConfigVerified::File(ext) => activities_stub_ext.push(ext),
ActivityStubConfigVerified::Inline(inline) => activities_stub_ext_inline.push(inline),
}
}
for result in external_results {
match result {
ActivityExternalConfigVerified::File(ext) => activities_stub_ext.push(ext),
ActivityExternalConfigVerified::Inline(inline) => activities_stub_ext_inline.push(inline),
}
}
let workflows = workflow_results.into_iter().collect::<Result<Result<Vec<_>, _>, _>>()??;
let mut webhooks_wasm_by_names = IndexMap::new();
for webhook in webhook_results {
let (k, v) = webhook??;
webhooks_wasm_by_names.insert(k, v);
}
let activities_js_verified = if !deployment.activities_js.is_empty() {
let activity_js_wasm_path = activity_js_runtime_result.transpose()?;
let activity_js_wasm_path: Arc<Path> = Arc::from(activity_js_wasm_path
.expect("None only if there are no JS activities, see `activity_js_runtime_fetch`"));
let mut activities_js_verified = Vec::with_capacity(deployment.activities_js.len());
for js in deployment.activities_js {
activities_js_verified.push(
js.fetch_and_verify(
activity_js_wasm_path.clone(),
wasm_cache_dir.clone(),
ignore_missing_env_vars,
&secret_registry,
global_http_config.clone(),
global_executor_instance_limiter.clone(),
fuel,
).await?
);
}
activities_js_verified
} else {
Vec::new()
};
let workflows_js_verified = if !deployment.workflows_js.is_empty() {
let workflow_js_wasm_path = workflow_js_runtime_result.transpose()?;
let workflow_js_wasm_path: Arc<Path> = Arc::from(workflow_js_wasm_path
.expect("None only if there are no JS workflows, see `workflow_js_runtime_fetch`"));
let mut workflows_js_verified = Vec::with_capacity(deployment.workflows_js.len());
for workflow_js in deployment.workflows_js {
workflows_js_verified.push(
workflow_js.fetch_and_verify(
workflow_js_wasm_path.clone(),
wasm_cache_dir.clone(),
global_executor_instance_limiter.clone(),
).await?
);
}
workflows_js_verified
} else {
Vec::new()
};
let mut webhooks_js_by_names = IndexMap::new();
if !deployment.webhooks_js.is_empty() {
let webhook_js_wasm_path = webhook_js_runtime_result.transpose()?
.expect("None only if there are no JS webhooks, see `webhook_js_runtime_fetch`");
let webhook_js_wasm_path: Arc<Path> = Arc::from(webhook_js_wasm_path);
for webhook_js in deployment.webhooks_js {
let (k, v) = webhook_js.fetch_and_verify(
webhook_js_wasm_path.clone(),
wasm_cache_dir.clone(),
ignore_missing_env_vars,
&secret_registry,
).await?;
webhooks_js_by_names.insert(k, v);
}
}
let mut activities_exec_verified = Vec::with_capacity(deployment.activities_exec.len());
for exec in deployment.activities_exec {
let resolved_program = exec.resolve(&wasm_cache_dir).await?;
activities_exec_verified.push(
exec.fetch_and_verify(
resolved_program,
ignore_missing_env_vars,
&secret_registry,
global_executor_instance_limiter.clone(),
)?
);
}
let mut crons = Vec::with_capacity(deployment.crons.len());
for cron in deployment.crons {
let name = cron.name.clone();
crons.push(cron.verify().with_context(|| {
format!("failed to verify cron `{name}`")
})?);
}
let deployment_verified = DeploymentVerified {
runtime_config_availability,
activities_wasm,
activities_js: activities_js_verified,
activities_exec: activities_exec_verified,
activities_stub_ext,
activities_stub_ext_inline,
workflows,
workflows_js: workflows_js_verified,
webhooks_wasm_by_names,
webhooks_js_by_names,
crons,
http_servers_to_webhook_names,
fuel,
global_http_config,
};
deployment_verified.validate_component_digests()?;
Ok(deployment_verified)
},
_ = termination_watcher.changed() => {
warn!("Received SIGINT, cancelling while resolving the WASM files");
anyhow::bail!("cancelling while resolving the WASM files")
}
}
}
}
struct Linked {
workers: Vec<WorkerLinked>,
webhooks_wasm_by_names: IndexMap<ConfigName, WebhookInstancesAndRoutes>,
component_registry_ro: ComponentConfigRegistryRO,
supressed_errors: Option<String>,
all_frame_files: Vec<(ComponentDigest, FrameFilesToSourceContent)>,
}
#[expect(clippy::large_enum_variant)]
enum CompiledComponent {
ActivityOrWorkflow {
worker: WorkerCompiled,
component_config: ComponentConfig,
frame_files: FrameFilesToSourceContent,
},
Webhook {
webhook_name: ConfigName,
webhook_compiled: WebhookEndpointCompiled,
routes: Vec<WebhookRouteVerified>,
frame_files: FrameFilesToSourceContent,
},
ActivityStubOrExternal {
component_config: ComponentConfig,
},
}
#[instrument(skip_all)]
#[expect(clippy::too_many_arguments)]
async fn compile_and_link(
engines: &Engines,
activities_wasm: Vec<ActivityWasmConfigVerified>,
activities_js: Vec<ActivityJsConfigVerified>,
activities_exec: Vec<ActivityExecConfigVerified>,
activities_stub_ext: Vec<ActivityStubExtConfigVerified>,
activities_stub_ext_inline: Vec<ActivityStubExtInlineConfigVerified>,
workflows: Vec<WorkflowConfigVerified>,
workflows_js: Vec<WorkflowJsConfigVerified>,
webhooks_wasm_by_names: IndexMap<ConfigName, WebhookWasmComponentConfigVerified>,
webhooks_js_by_names: IndexMap<ConfigName, WebhookJsConfigVerified>,
crons: Vec<CronConfigVerified>,
fuel: Option<u64>,
global_http_config: GlobalHttpConfig,
build_semaphore: Option<u64>,
workflows_lock_extension_leeway: Duration,
termination_watcher: &mut watch::Receiver<()>,
suppress_linking_errors: bool,
) -> Result<Linked, anyhow::Error> {
let build_semaphore = build_semaphore.map(|permits| {
semaphore::Semaphore::new(permits.try_into().expect("u64 must fit into usize"))
});
let parent_span = Span::current();
let activity_js_runnable = if let Some(first_activity_js) = activities_js.first() {
let engine = engines.activity_engine.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
let wasm_path = first_activity_js.wasm_path.clone();
let component_type = first_activity_js.component_id().component_type;
let runnable = tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "activity_js_wasm_compile");
span.in_scope(|| {
debug!("Building activity-js-runtime");
RunnableComponent::new(&wasm_path, &engine, component_type)
.context("cannot compile activity-js-runtime")
})
});
Some(runnable)
} else {
None
};
let workflow_js_runnable = if let Some(first_workflow_js) = workflows_js.first() {
let engine = engines.workflow_engine.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
let wasm_path = first_workflow_js.wasm_path.clone();
let runnable = tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "workflow_js_wasm_compile");
span.in_scope(|| {
debug!("Building workflow-js-runtime");
RunnableComponent::new(&wasm_path, &engine, ComponentType::Workflow)
.context("cannot compile workflow-js-runtime")
})
});
Some(runnable)
} else {
None
};
let webhook_js_runnable = if let Some((_, first_webhook_js)) = webhooks_js_by_names.first() {
let engine = engines.webhook_engine.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
let wasm_path = first_webhook_js.wasm_path.clone();
let runnable = tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "webhook_js_wasm_compile");
span.in_scope(|| {
debug!("Building webhook-js-runtime");
RunnableComponent::new(&wasm_path, &engine, ComponentType::WebhookEndpoint)
.context("cannot compile webhook-js-runtime")
})
});
Some(runnable)
} else {
None
};
let activity_js_runnable = match activity_js_runnable {
Some(wasm) => Some(wasm.await??),
None => None,
};
let workflow_js_runnable = match workflow_js_runnable {
Some(wasm) => Some(wasm.await??),
None => None,
};
let webhook_js_runnable = match webhook_js_runnable {
Some(wasm) => Some(wasm.await??),
None => None,
};
let pre_spawns: Vec<tokio::task::JoinHandle<Result<CompiledComponent, anyhow::Error>>> = activities_wasm
.into_iter()
.map(|activity_wasm| {
let engines = engines.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "activity_wasm_compile", component_id = %activity_wasm.component_id());
span.in_scope(|| {
prespawn_activity_wasm(activity_wasm, &engines, suppress_linking_errors)
})
})
})
.chain(
activities_js.into_iter().map(|activity_js| {
let engines = engines.clone();
let parent_span = parent_span.clone();
let activity_js_runnable = activity_js_runnable.clone().expect("must have been filled above");
tokio::task::spawn_blocking(move || {
let span = info_span!(parent: parent_span, "activity_js_compile", component_id = %activity_js.component_id());
span.in_scope(|| {
prespawn_activity_js(activity_js, &engines, activity_js_runnable).map(|(worker, component_config, frame_files)| {
CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files,
}
})
})
})
}))
.chain(activities_exec.into_iter().map(|activity_exec| {
let parent_span = parent_span.clone();
tokio::task::spawn_blocking(move || {
let span = info_span!(parent: parent_span, "activity_exec_compile", component_id = %activity_exec.component_id());
span.in_scope(|| {
prespawn_activity_exec(activity_exec).map(|(worker, component_config)| {
CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files: FrameFilesToSourceContent::default(),
}
})
})
})
}))
.chain(activities_stub_ext.into_iter().map(|activity_stub_ext| {
let span = info_span!("activity_stub_ext_init", component_id = %activity_stub_ext.component_id); tokio::task::spawn_blocking(move || {
span.in_scope(|| {
let wasm_component = WasmComponent::new(
activity_stub_ext.wasm_path,
activity_stub_ext.component_id.component_type,
)?;
let wit = wasm_component.wit();
let exports_ext = wasm_component.exim.get_exports(true).to_vec();
let exports_hierarchy_ext =
wasm_component.exim.get_exports_hierarchy_ext().to_vec();
let component_config_importable = ComponentConfigImportable {
exports_ext,
exports_hierarchy_ext,
};
let component_config = ComponentConfig {
component_id: activity_stub_ext.component_id,
imports: vec![],
workflow_or_activity_config: Some(component_config_importable),
wit,
wit_origin: WitOrigin::Wasm,
};
Ok(CompiledComponent::ActivityStubOrExternal { component_config })
})
})
}))
.chain(activities_stub_ext_inline.into_iter().map(|stub| {
let span = info_span!("activity_inline_init", component_id = %stub.component_id);
tokio::task::spawn_blocking(move || {
span.in_scope(|| {
let component_config = compile_activity_inline(
stub.component_id,
&stub.ffqn,
&stub.params,
&stub.return_type,
)?;
Ok(CompiledComponent::ActivityStubOrExternal { component_config })
})
})
}))
.chain(workflows.into_iter().map(|workflow| {
let engines = engines.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "workflow_compile", component_id = %workflow.component_id());
span.in_scope(|| {
prespawn_workflow_wasm(workflow, &engines, workflows_lock_extension_leeway)
.map(|(worker, component_config, frame_files)| {
CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files,
}
})
})
})
}))
.chain(workflows_js.into_iter().map(|workflow_js| {
let engines = engines.clone();
let parent_span = parent_span.clone();
let workflow_js_runnable = workflow_js_runnable.clone().expect("must have been filled above");
tokio::task::spawn_blocking(move || {
let span = info_span!(parent: parent_span, "workflow_js_compile", component_id = %workflow_js.component_id());
span.in_scope(|| {
prespawn_workflow_js(workflow_js, &engines,workflow_js_runnable, workflows_lock_extension_leeway)
.map(|(worker, component_config, frame_files)| {
CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files,
}
})
})
})
}))
.chain(
webhooks_wasm_by_names
.into_iter()
.map(|(webhook_name, webhook)| {
let engines = engines.clone();
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
let global_http_config = global_http_config.clone();
tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "webhook_compile", component_id = %webhook.component_id);
span.in_scope(|| {
let component_id = webhook.component_id;
let allowed_hosts = webhook.allowed_hosts;
let global_http_config = webhook_global_http_allowlist(
webhook.is_webui,
&allowed_hosts,
&global_http_config,
);
let config = WebhookEndpointConfig {
component_id,
forward_stdout: webhook.forward_stdout,
forward_stderr: webhook.forward_stderr,
env_vars: webhook.env_vars,
fuel,
backtrace_persist: webhook.backtrace_persist,
subscription_interruption: webhook.subscription_interruption,
logs_store_min_level: webhook.logs_store_min_level,
allowed_hosts,
global_http_config,
secrets: webhook.secrets,
js_config: None,
config_section_hint: webhook.config_section_hint,
};
let runnable_component =
RunnableComponent::new(webhook.wasm_path, &engines.webhook_engine, ComponentType::WebhookEndpoint)?;
let webhook_compiled = webhook_trigger::WebhookEndpointCompiled::new(
config,
runnable_component,
)?;
Ok(CompiledComponent::Webhook {
webhook_name,
webhook_compiled,
routes: webhook.routes,
frame_files: webhook.frame_files_to_sources,
})
})
})
}),
)
.chain(
webhooks_js_by_names
.into_iter()
.map(|(webhook_name, webhook_js)| {
let build_semaphore = build_semaphore.clone();
let parent_span = parent_span.clone();
let global_http_config = global_http_config.clone();
let webhook_js_runnable = webhook_js_runnable.clone().expect("must have been filled above");
tokio::task::spawn_blocking(move || {
let _permit = build_semaphore.map(semaphore::Semaphore::acquire);
let span = info_span!(parent: parent_span, "webhook_js_compile", component_id = %webhook_js.component_id);
span.in_scope(|| {
let frame_files = webhook_js.as_frame_sources();
let config = WebhookEndpointConfig {
component_id: webhook_js.component_id,
forward_stdout: webhook_js.forward_stdout,
forward_stderr: webhook_js.forward_stderr,
env_vars: webhook_js.env_vars,
fuel,
backtrace_persist: webhook_js.backtrace_persist,
subscription_interruption: None,
logs_store_min_level: webhook_js.logs_store_min_level,
allowed_hosts: webhook_js.allowed_hosts,
global_http_config: global_http_config.clone(),
secrets: webhook_js.secrets,
js_config: Some(WebhookEndpointJsConfig {
source: webhook_js.js_source,
file_name: webhook_js.js_file_name.clone(),
}),
config_section_hint: webhook_js.config_section_hint,
};
let webhook_compiled = webhook_trigger::WebhookEndpointCompiled::new(
config,
webhook_js_runnable
)?;
Ok(CompiledComponent::Webhook {
webhook_name,
webhook_compiled,
routes: webhook_js.routes,
frame_files,
})
})
})
}),
)
.collect();
let pre_spawns = futures_util::future::join_all(pre_spawns);
let results_of_results = tokio::select! {
results_of_results = pre_spawns => results_of_results,
_ = termination_watcher.changed() => {
warn!("Received SIGINT, cancelling while compiling the components");
anyhow::bail!("cancelling while compiling the components")
}
};
let mut component_registry = ComponentConfigRegistry::default();
let mut workers_compiled = Vec::with_capacity(results_of_results.len());
let mut webhooks_compiled_by_names = hashbrown::HashMap::new();
let mut all_frame_files: Vec<(ComponentDigest, FrameFilesToSourceContent)> = Vec::new();
for handle in results_of_results {
match handle?? {
CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files,
} => {
if !frame_files.is_empty() {
all_frame_files.push((
component_config.component_id.component_digest.clone(),
frame_files,
));
}
component_registry.insert(component_config)?;
workers_compiled.push(worker);
}
CompiledComponent::Webhook {
webhook_name,
webhook_compiled,
routes,
frame_files,
} => {
if !frame_files.is_empty() {
all_frame_files.push((
webhook_compiled
.config
.component_id
.component_digest
.clone(),
frame_files,
));
}
let component = ComponentConfig {
component_id: webhook_compiled.config.component_id.clone(),
imports: webhook_compiled.imports().to_vec(),
workflow_or_activity_config: None,
wit: webhook_compiled.runnable_component.wasm_component.wit(),
wit_origin: WitOrigin::Wasm,
};
component_registry.insert(component)?;
let old =
webhooks_compiled_by_names.insert(webhook_name, (webhook_compiled, routes));
assert!(old.is_none());
}
CompiledComponent::ActivityStubOrExternal { component_config } => {
component_registry.insert(component_config)?;
}
}
}
for cron in &crons {
let component_config = ComponentConfig {
component_id: cron.component_id.clone(),
imports: vec![],
workflow_or_activity_config: None,
wit: String::new(), wit_origin: WitOrigin::Synthesized,
};
component_registry.insert(component_config)?; }
let (component_registry_ro, supressed_errors) = component_registry.verify_registry();
let fn_registry: Arc<dyn FunctionRegistry> = Arc::from(component_registry_ro.clone());
let mut workers_linked = workers_compiled
.into_iter()
.map(|worker| worker.link(&fn_registry))
.collect::<Result<Vec<_>, _>>()?;
for cron in crons {
let (target_component_id, target_fn_metadata) = component_registry_ro
.find_by_exported_ffqn(&cron.target_ffqn)
.ok_or_else(|| {
anyhow::anyhow!(
"cron `{}` targets function `{}` which is not exported by any component",
cron.component_id.name,
cron.target_ffqn,
)
})?;
let params = Params::from_json_values(
Arc::from(cron.params_json),
target_fn_metadata
.parameter_types
.iter()
.map(|pt| &pt.type_wrapper),
)
.with_context(|| {
format!(
"cron `{}`: params do not match target function `{}` parameter types",
cron.component_id.name, cron.target_ffqn,
)
})?;
workers_linked.push(WorkerLinked {
worker: LinkedWorkerKind::Cron(Box::new(ScheduleWorkerConfig {
component_id: cron.component_id,
target_ffqn: cron.target_ffqn,
target_component_id: target_component_id.clone(),
params,
cron_schedule: cron.cron_schedule,
})),
exec_config: cron.exec_config,
logs_store_min_level: None,
});
}
let webhooks_wasm_by_names = webhooks_compiled_by_names
.into_iter()
.map(|(name, (compiled, routes))| {
let component_id = compiled.config.component_id.clone();
compiled
.link(&engines.webhook_engine, fn_registry.as_ref())
.map(|instance| (name, (instance, routes)))
.with_context(|| format!("cannot compile {component_id}"))
})
.collect::<Result<IndexMap<_, _>, _>>()?;
Ok(Linked {
workers: workers_linked,
webhooks_wasm_by_names,
component_registry_ro,
supressed_errors,
all_frame_files,
})
}
fn compile_activity_inline(
component_id: ComponentId,
ffqn: &FunctionFqn,
params: &[ParameterType],
return_type: &ReturnTypeExtendable,
) -> Result<ComponentConfig, utils::wasm_tools::DecodeError> {
let wasm_component = WasmComponent::new_from_fn_signature(
ffqn,
params,
return_type,
ComponentType::ActivityStub,
"stub-activity",
)?;
let wit_text_with_extensions = wasm_component.wit();
let exports_ext = wasm_component.exim.get_exports(true).to_vec();
let exports_hierarchy_ext = wasm_component.exim.get_exports_hierarchy_ext().to_vec();
let component_config_importable = ComponentConfigImportable {
exports_ext,
exports_hierarchy_ext,
};
Ok(ComponentConfig {
component_id,
imports: vec![],
workflow_or_activity_config: Some(component_config_importable),
wit: wit_text_with_extensions,
wit_origin: WitOrigin::Synthesized,
})
}
mod semaphore {
use std::sync::{Arc, Condvar, Mutex};
pub(crate) struct Semaphore {
mutex: Mutex<usize>,
condvar: Condvar,
}
impl Semaphore {
pub(crate) fn new(permits: usize) -> Arc<Semaphore> {
Arc::new(Semaphore {
mutex: Mutex::new(permits),
condvar: Condvar::new(),
})
}
pub(crate) fn acquire(self: Arc<Semaphore>) -> Permit {
{
let mut guard = self.mutex.lock().unwrap();
while *guard == 0 {
guard = self.condvar.wait(guard).unwrap();
}
*guard -= 1;
}
Permit(self)
}
}
pub(crate) struct Permit(Arc<Semaphore>);
impl Drop for Permit {
fn drop(&mut self) {
{
let mut guard = self.0.mutex.lock().unwrap();
*guard += 1;
}
self.0.condvar.notify_one();
}
}
}
#[instrument(level = "debug", skip_all, fields(
executor_id = %activity.exec_config.executor_id,
component_id = %activity.exec_config.component_id,
wasm_path = ?activity.wasm_path,
))]
fn prespawn_activity_wasm(
activity: ActivityWasmConfigVerified,
engines: &Engines,
suppress_linking_errors: bool,
) -> Result<CompiledComponent, anyhow::Error> {
let component_id = activity.component_id().clone();
assert!(component_id.component_type == ComponentType::Activity);
debug!("Instantiating activity");
trace!(?activity, "Full configuration");
let engine = engines.activity_engine.clone();
let runnable_component =
RunnableComponent::new(activity.wasm_path, &engine, component_id.component_type)?;
let wit = runnable_component.wasm_component.wit();
let exports_ext = runnable_component
.wasm_component
.exim
.get_exports(true)
.to_vec();
let exports_hierarchy_ext = runnable_component
.wasm_component
.exim
.get_exports_hierarchy_ext()
.to_vec();
let imports_flat = runnable_component.wasm_component.exim.imports_flat.clone();
match ActivityWorkerCompiled::new_with_config(
runnable_component,
activity.activity_config,
engine,
Now.clone_box(),
Arc::new(TokioSleep),
) {
Ok(worker) => {
let (worker, component_config) = WorkerCompiled::new_activity(
worker,
activity.exec_config,
wit,
activity.logs_store_min_level,
);
Ok(CompiledComponent::ActivityOrWorkflow {
worker,
component_config,
frame_files: FrameFilesToSourceContent::new(),
})
}
Err(err) if suppress_linking_errors => {
warn!("Suppressing linking error for {component_id}: {err:#}");
let component_config = ComponentConfig {
component_id: activity.exec_config.component_id,
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext,
exports_hierarchy_ext,
}),
imports: imports_flat,
wit,
wit_origin: WitOrigin::Wasm,
};
Ok(CompiledComponent::ActivityStubOrExternal { component_config })
}
Err(err) => Err(err).with_context(|| format!("cannot compile {component_id}")),
}
}
#[instrument(level = "debug", skip_all, fields(
component_id = %activity_js.exec_config.component_id,
))]
fn prespawn_activity_js(
activity_js: ActivityJsConfigVerified,
engines: &Engines,
runnable_component: RunnableComponent,
) -> Result<(WorkerCompiled, ComponentConfig, FrameFilesToSourceContent), anyhow::Error> {
let component_id = activity_js.component_id().clone();
assert!(component_id.component_type == ComponentType::Activity);
let frame_files = activity_js.as_frame_sources();
let inner = ActivityWorkerCompiled::new_with_config(
runnable_component,
activity_js.activity_config,
engines.activity_engine.clone(),
Now.clone_box(),
Arc::new(TokioSleep),
)
.with_context(|| format!("cannot compile JS activity runtime for {component_id}"))?;
let worker = ActivityJsWorkerCompiled::new(
inner,
activity_js.js_source,
activity_js.ffqn,
activity_js.params,
activity_js.return_type,
)
.with_context(|| format!("cannot create JS activity worker for {component_id}"))?;
let wit = worker.wit();
Ok(WorkerCompiled::new_js_activity(
worker,
activity_js.exec_config,
wit,
activity_js.logs_store_min_level,
frame_files,
))
}
fn prespawn_activity_exec(
activity_exec: ActivityExecConfigVerified,
) -> Result<(WorkerCompiled, ComponentConfig), anyhow::Error> {
let component_id = activity_exec.component_id().clone();
assert!(component_id.component_type == ComponentType::Activity);
let program = activity_exec.program;
let secrets = activity_exec.secrets;
let worker = ActivityExecWorkerCompiled::new(
program,
activity_exec.ffqn,
activity_exec.params,
activity_exec.return_type,
activity_exec.env_vars,
activity_exec.max_output_bytes,
activity_exec.forward_stdout,
activity_exec.forward_stderr,
secrets,
activity_exec.params_via_stdin,
)
.with_context(|| format!("cannot create exec activity worker for {component_id}"))?;
let wit = worker.wit();
Ok(WorkerCompiled::new_exec_activity(
worker,
activity_exec.exec_config,
wit,
activity_exec.logs_store_min_level,
))
}
#[cfg(feature = "activity-js-local")]
async fn fetch_activity_js_runtime(
_wasm_cache_dir: Arc<Path>,
_metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
Ok(PathBuf::from(
activity_js_runtime_builder::ACTIVITY_JS_RUNTIME,
))
}
#[cfg(not(feature = "activity-js-local"))]
async fn fetch_activity_js_runtime(
wasm_cache_dir: Arc<Path>,
metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
let location: crate::config::toml::ComponentLocationToml = ACTIVITY_JS_LOCATION
.parse()
.context("cannot parse built-in activity-js runtime location")?;
let (_content_digest, wasm_path) = location
.fetch(&wasm_cache_dir, &metadata_dir)
.await
.context("cannot fetch activity-js runtime")?;
Ok(wasm_path)
}
#[cfg(feature = "workflow-js-local")]
async fn fetch_workflow_js_runtime(
_wasm_cache_dir: Arc<Path>,
_metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
Ok(PathBuf::from(
workflow_js_runtime_builder::WORKFLOW_JS_RUNTIME,
))
}
#[cfg(not(feature = "workflow-js-local"))]
async fn fetch_workflow_js_runtime(
wasm_cache_dir: Arc<Path>,
metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
let location: crate::config::toml::ComponentLocationToml = WORKFLOW_JS_LOCATION
.parse()
.context("cannot parse built-in workflow-js runtime location")?;
let (_content_digest, wasm_path) = location
.fetch(&wasm_cache_dir, &metadata_dir)
.await
.context("cannot fetch workflow-js runtime")?;
Ok(wasm_path)
}
#[cfg(feature = "webhook-js-local")]
async fn fetch_webhook_js_runtime(
_wasm_cache_dir: Arc<Path>,
_metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
Ok(PathBuf::from(
webhook_js_runtime_builder::WEBHOOK_JS_RUNTIME,
))
}
#[cfg(not(feature = "webhook-js-local"))]
#[instrument(skip_all)]
async fn fetch_webhook_js_runtime(
wasm_cache_dir: Arc<Path>,
metadata_dir: Arc<Path>,
) -> Result<PathBuf, anyhow::Error> {
let location: crate::config::toml::ComponentLocationToml = WEBHOOK_JS_LOCATION
.parse()
.context("cannot parse built-in webhook-js runtime location")?;
let (_content_digest, wasm_path) = location
.fetch(&wasm_cache_dir, &metadata_dir)
.await
.context("cannot fetch webhook-js runtime")?;
Ok(wasm_path)
}
#[instrument(level = "debug", skip_all, fields(
executor_id = %workflow.exec_config.executor_id,
component_id = %workflow.exec_config.component_id,
wasm_path = ?workflow.wasm_path,
))]
fn prespawn_workflow_wasm(
workflow: WorkflowConfigVerified,
engines: &Engines,
workflows_lock_extension_leeway: Duration,
) -> Result<(WorkerCompiled, ComponentConfig, FrameFilesToSourceContent), anyhow::Error> {
let component_id = workflow.component_id().clone();
assert!(component_id.component_type == ComponentType::Workflow);
debug!("Instantiating workflow");
trace!(?workflow, "Full configuration");
let engine = engines.workflow_engine.clone();
let runnable_component =
RunnableComponent::new(&workflow.wasm_path, &engine, component_id.component_type)?;
let wit = runnable_component.wasm_component.wit();
WorkerCompiled::new_workflow(
runnable_component,
engine,
workflow,
wit,
workflows_lock_extension_leeway,
)
.with_context(|| format!("cannot compile {component_id}"))
}
#[instrument(level = "debug", skip_all, fields(
executor_id = %workflow_js.exec_config.executor_id,
component_id = %workflow_js.exec_config.component_id,
wasm_path = ?workflow_js.wasm_path,
))]
fn prespawn_workflow_js(
workflow_js: WorkflowJsConfigVerified,
engines: &Engines,
runnable_component: RunnableComponent,
workflows_lock_extension_leeway: Duration,
) -> Result<(WorkerCompiled, ComponentConfig, FrameFilesToSourceContent), anyhow::Error> {
let component_id = workflow_js.component_id().clone();
assert!(component_id.component_type == ComponentType::Workflow);
let engine = engines.workflow_engine.clone();
let replay_inner = WorkflowWorkerCompiled::new_with_config(
runnable_component.clone(),
replay_workflow_config(&component_id),
engine.clone(),
Now.clone_box(),
)
.with_context(|| format!("cannot compile replay JS workflow runtime for {component_id}"))?;
let replay_compiled = WorkflowJsWorkerCompiled::new(
replay_inner,
workflow_js.js_source.clone(),
workflow_js.js_file_name.clone(),
&workflow_js.ffqn,
workflow_js.params.clone(),
workflow_js.return_type.clone(),
)
.with_context(|| format!("cannot create replay JS workflow worker for {component_id}"))?;
let inner = WorkflowWorkerCompiled::new_with_config(
runnable_component,
workflow_js.workflow_config,
engine,
Now.clone_box(),
)
.with_context(|| format!("cannot compile JS workflow runtime for {component_id}"))?;
let worker = WorkflowJsWorkerCompiled::new(
inner,
workflow_js.js_source.clone(),
workflow_js.js_file_name.clone(),
&workflow_js.ffqn,
workflow_js.params,
workflow_js.return_type,
)
.with_context(|| format!("cannot create JS workflow worker for {component_id}"))?;
let wit = worker.wit();
Ok(WorkerCompiled::new_js_workflow(
worker,
replay_compiled,
workflow_js.exec_config,
workflow_js.logs_store_min_level,
workflows_lock_extension_leeway,
wit,
workflow_js.js_source,
workflow_js.js_file_name,
))
}
fn replay_workflow_config(component_id: &ComponentId) -> WorkflowConfig {
WorkflowConfig {
component_id: component_id.clone(),
join_next_blocking_strategy: JoinNextBlockingStrategy::Interrupt,
stub_wasi: true,
fuel: None,
lock_extension: None, subscription_interruption: None, }
}
struct WorkflowWorkerCompiledWithConfig {
worker: WorkflowWorkerCompiled,
workflows_lock_extension_leeway: Duration,
replay_compiled: WorkflowWorkerCompiled,
}
struct WorkflowJsWorkerCompiledWithConfig {
worker: WorkflowJsWorkerCompiled,
workflows_lock_extension_leeway: Duration,
replay_compiled: WorkflowJsWorkerCompiled,
}
enum CompiledWorkerKind {
ActivityWasm(Box<ActivityWorkerCompiled>),
ActivityJs(Box<ActivityJsWorkerCompiled>),
ActivityExec(Box<ActivityExecWorkerCompiled>),
WorkflowWasm(Box<WorkflowWorkerCompiledWithConfig>),
WorkflowJs(Box<WorkflowJsWorkerCompiledWithConfig>),
}
struct WorkerCompiled {
worker: CompiledWorkerKind,
exec_config: ExecConfig,
logs_store_min_level: Option<LogLevel>,
}
impl WorkerCompiled {
fn new_activity(
worker: ActivityWorkerCompiled,
exec_config: ExecConfig,
wit: String,
logs_store_min_level: Option<LogLevel>,
) -> (WorkerCompiled, ComponentConfig) {
let component = ComponentConfig {
component_id: exec_config.component_id.clone(),
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext: worker.exported_functions_ext().to_vec(),
exports_hierarchy_ext: worker.exports_hierarchy_ext().to_vec(),
}),
imports: worker.imported_functions().to_vec(),
wit,
wit_origin: WitOrigin::Wasm,
};
(
WorkerCompiled {
worker: CompiledWorkerKind::ActivityWasm(Box::new(worker)),
exec_config,
logs_store_min_level,
},
component,
)
}
fn new_js_activity(
worker: ActivityJsWorkerCompiled,
exec_config: ExecConfig,
wit: String,
logs_store_min_level: Option<LogLevel>,
frame_files: FrameFilesToSourceContent, ) -> (WorkerCompiled, ComponentConfig, FrameFilesToSourceContent) {
let component = ComponentConfig {
component_id: exec_config.component_id.clone(),
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext: worker.exported_functions_ext().to_vec(),
exports_hierarchy_ext: worker.exports_hierarchy_ext().to_vec(),
}),
imports: worker.imported_functions().to_vec(),
wit,
wit_origin: WitOrigin::Synthesized,
};
(
WorkerCompiled {
worker: CompiledWorkerKind::ActivityJs(Box::new(worker)),
exec_config,
logs_store_min_level,
},
component,
frame_files,
)
}
fn new_exec_activity(
worker: ActivityExecWorkerCompiled,
exec_config: ExecConfig,
wit: String,
logs_store_min_level: Option<LogLevel>,
) -> (WorkerCompiled, ComponentConfig) {
let component = ComponentConfig {
component_id: exec_config.component_id.clone(),
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext: worker.exported_functions_ext().to_vec(),
exports_hierarchy_ext: worker.exports_hierarchy_ext().to_vec(),
}),
imports: vec![],
wit,
wit_origin: WitOrigin::Synthesized,
};
(
WorkerCompiled {
worker: CompiledWorkerKind::ActivityExec(Box::new(worker)),
exec_config,
logs_store_min_level,
},
component,
)
}
fn new_workflow(
runnable_component: RunnableComponent,
engine: Arc<Engine>,
workflow: WorkflowConfigVerified,
wit: String,
workflows_lock_extension_leeway: Duration,
) -> Result<
(WorkerCompiled, ComponentConfig, FrameFilesToSourceContent),
utils::wasm_tools::DecodeError,
> {
let replay_compiled = WorkflowWorkerCompiled::new_with_config(
runnable_component.clone(),
replay_workflow_config(&workflow.workflow_config.component_id),
engine.clone(),
Now.clone_box(),
)?;
let worker = WorkflowWorkerCompiled::new_with_config(
runnable_component,
workflow.workflow_config,
engine,
Now.clone_box(),
)?;
let component = ComponentConfig {
component_id: workflow.exec_config.component_id.clone(),
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext: worker.exported_functions_ext().to_vec(),
exports_hierarchy_ext: worker.exports_hierarchy_ext().to_vec(),
}),
imports: worker.imported_functions().to_vec(),
wit,
wit_origin: WitOrigin::Wasm,
};
Ok((
WorkerCompiled {
worker: CompiledWorkerKind::WorkflowWasm(Box::new(
WorkflowWorkerCompiledWithConfig {
worker,
workflows_lock_extension_leeway,
replay_compiled,
},
)),
exec_config: workflow.exec_config,
logs_store_min_level: workflow.logs_store_min_level,
},
component,
workflow.frame_files_to_sources,
))
}
#[expect(clippy::too_many_arguments)]
fn new_js_workflow(
worker: WorkflowJsWorkerCompiled,
replay_compiled: WorkflowJsWorkerCompiled,
exec_config: ExecConfig,
logs_store_min_level: Option<LogLevel>,
workflows_lock_extension_leeway: Duration,
wit: String,
js_source: String,
js_file_name: String,
) -> (WorkerCompiled, ComponentConfig, FrameFilesToSourceContent) {
let frame_files = WorkflowJsConfigVerified::frame_sources(js_file_name, js_source);
let component = ComponentConfig {
component_id: exec_config.component_id.clone(),
workflow_or_activity_config: Some(ComponentConfigImportable {
exports_ext: worker.exported_functions_ext().to_vec(),
exports_hierarchy_ext: worker.exports_hierarchy_ext().to_vec(),
}),
imports: worker.imported_functions().to_vec(),
wit,
wit_origin: WitOrigin::Synthesized,
};
(
WorkerCompiled {
worker: CompiledWorkerKind::WorkflowJs(Box::new(
WorkflowJsWorkerCompiledWithConfig {
worker,
workflows_lock_extension_leeway,
replay_compiled,
},
)),
exec_config,
logs_store_min_level,
},
component,
frame_files,
)
}
#[instrument(skip_all, fields(component_id = %self.exec_config.component_id))]
fn link(self, fn_registry: &Arc<dyn FunctionRegistry>) -> Result<WorkerLinked, anyhow::Error> {
Ok(WorkerLinked {
worker: match self.worker {
CompiledWorkerKind::ActivityWasm(activity) => {
LinkedWorkerKind::ActivityWasm(activity)
}
CompiledWorkerKind::ActivityJs(js_activity) => {
LinkedWorkerKind::ActivityJs(js_activity)
}
CompiledWorkerKind::ActivityExec(exec_activity) => {
LinkedWorkerKind::ActivityExec(exec_activity)
}
CompiledWorkerKind::WorkflowWasm(workflow_compiled) => {
LinkedWorkerKind::WorkflowWasm(Box::new(WorkflowWorkerLinkedWithConfig {
worker: workflow_compiled.worker.link(fn_registry.clone())?,
workflows_lock_extension_leeway: workflow_compiled
.workflows_lock_extension_leeway,
replay_linked: workflow_compiled
.replay_compiled
.link(fn_registry.clone())?,
}))
}
CompiledWorkerKind::WorkflowJs(workflow_js_compiled) => {
LinkedWorkerKind::WorkflowJs(Box::new(WorkflowJsWorkerLinkedWithConfig {
worker: workflow_js_compiled.worker.link(fn_registry.clone())?,
workflows_lock_extension_leeway: workflow_js_compiled
.workflows_lock_extension_leeway,
replay_linked: workflow_js_compiled
.replay_compiled
.link(fn_registry.clone())?,
}))
}
},
exec_config: self.exec_config,
logs_store_min_level: self.logs_store_min_level,
})
}
}
struct WorkflowWorkerLinkedWithConfig {
worker: WorkflowWorkerLinked,
workflows_lock_extension_leeway: Duration,
replay_linked: WorkflowWorkerLinked,
}
struct WorkflowJsWorkerLinkedWithConfig {
worker: WorkflowJsWorkerLinked,
workflows_lock_extension_leeway: Duration,
replay_linked: WorkflowJsWorkerLinked,
}
enum LinkedWorkerKind {
ActivityWasm(Box<ActivityWorkerCompiled>),
ActivityJs(Box<ActivityJsWorkerCompiled>),
ActivityExec(Box<ActivityExecWorkerCompiled>),
WorkflowWasm(Box<WorkflowWorkerLinkedWithConfig>),
WorkflowJs(Box<WorkflowJsWorkerLinkedWithConfig>),
Cron(Box<ScheduleWorkerConfig>),
}
struct ScheduleWorkerConfig {
component_id: ComponentId,
target_ffqn: FunctionFqn,
target_component_id: ComponentId,
params: Params,
cron_schedule: CronOrOnce,
}
pub(crate) struct WorkerLinked {
worker: LinkedWorkerKind,
exec_config: ExecConfig,
logs_store_min_level: Option<LogLevel>,
}
impl WorkerLinked {
pub(crate) fn spawn(
self,
deployment_id: DeploymentId,
db_pool: &Arc<dyn DbPool>,
cancel_registry: CancelRegistry,
log_forwarder_sender: &mpsc::Sender<LogInfoAppendRow>,
) -> (ExecutorTaskHandle, Option<(ComponentId, ReplayWorker)>) {
let logs_storage_config = self.logs_store_min_level.map(|min_level| LogStrageConfig {
min_level,
log_sender: log_forwarder_sender.clone(),
});
let mut replay_entry: Option<(ComponentId, ReplayWorker)> = None;
let worker: Arc<dyn Worker> = match self.worker {
LinkedWorkerKind::ActivityWasm(activity_compiled) => {
Arc::from(activity_compiled.into_worker(
cancel_registry,
log_forwarder_sender,
logs_storage_config,
))
}
LinkedWorkerKind::ActivityJs(js_activity_compiled) => {
Arc::from(js_activity_compiled.into_worker(
cancel_registry,
log_forwarder_sender,
logs_storage_config,
))
}
LinkedWorkerKind::ActivityExec(exec_activity_compiled) => {
Arc::from(exec_activity_compiled.into_worker(
cancel_registry,
log_forwarder_sender,
logs_storage_config,
))
}
LinkedWorkerKind::WorkflowWasm(workflow_linked) => {
let factory = DeadlineTrackerFactoryTokio::new(
workflow_linked.workflows_lock_extension_leeway,
Now.clone_box(),
);
let replay_worker = Arc::new(workflow_linked.replay_linked.into_worker(
deployment_id,
db_pool.clone(),
Arc::new(DeadlineTrackerFactoryForReplay {}),
CancelRegistry::new(),
logs_storage_config.clone(),
));
replay_entry = Some((
self.exec_config.component_id.clone(),
ReplayWorker::Wasm(replay_worker),
));
Arc::from(workflow_linked.worker.into_worker(
deployment_id,
db_pool.clone(),
Arc::new(factory),
cancel_registry,
logs_storage_config,
))
}
LinkedWorkerKind::WorkflowJs(workflow_js_linked) => {
let factory = DeadlineTrackerFactoryTokio::new(
workflow_js_linked.workflows_lock_extension_leeway,
Now.clone_box(),
);
let replay_worker = Arc::new(workflow_js_linked.replay_linked.into_worker(
deployment_id,
db_pool.clone(),
Arc::new(DeadlineTrackerFactoryForReplay {}),
CancelRegistry::new(),
logs_storage_config.clone(),
));
replay_entry = Some((
self.exec_config.component_id.clone(),
ReplayWorker::Js(replay_worker),
));
Arc::from(workflow_js_linked.worker.into_worker(
deployment_id,
db_pool.clone(),
Arc::new(factory),
cancel_registry,
logs_storage_config,
))
}
LinkedWorkerKind::Cron(config) => {
let config = *config;
Arc::from(CronWorker::new(
config.component_id,
config.target_ffqn,
config.target_component_id,
config.params,
config.cron_schedule,
deployment_id,
db_pool.clone(),
Now.clone_box(),
))
}
};
let handle = ExecTask::spawn_new(
deployment_id,
worker,
self.exec_config,
Now.clone_box(),
db_pool.clone(),
TokioSleep,
);
(handle, replay_entry)
}
}
pub(crate) fn gen_trace_id() -> String {
use rand::SeedableRng;
let mut rng = rand::rngs::SmallRng::from_os_rng();
(0..5)
.map(|_| rand::Rng::random_range(&mut rng, b'a'..=b'z') as char)
.collect::<String>()
}
#[cfg(test)]
mod tests {
use crate::{
command::server::{
DeploymentRunnable, DeploymentVerified, PrepareDirsParams, RuntimeConfigAvailability,
ServerCompiledLinked, ServerVerified, VerifyParams, compile_activity_inline,
compute_content_digest,
config_prepass::{
collect_outbound_http_secret_replacements, collect_uncovered_outbound_http_hosts,
global_secret_replacements, host_allowlist_snippet,
report_missing_outbound_http_secret_replacements,
},
create_engines, deployment_verify_config, fix_server_exec_digests, prepare_dirs,
webhook_global_http_allowlist,
},
config::{
config_holder::{ConfigHolder, load_deployment_resolved},
secret_registry::SecretRegistry,
toml::{
AllowExecActivities, AllowedHostToml, MethodsInput, MethodsInputStar, ReplaceIn,
ScriptLocationResolved, ServerConfigToml,
},
},
};
use concepts::{ComponentId, FunctionFqn, prefixed_ulid::DeploymentId};
use concepts::{
ComponentType, ParameterType, ReturnType, StrVariant,
component_id::{ComponentDigest, Digest},
};
use directories::BaseDirs;
use rstest::rstest;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::watch;
use toml_edit::DocumentMut;
use wasm_workers::http_request_policy::GlobalHttpConfig;
fn get_workspace_dir() -> PathBuf {
PathBuf::from(std::env::var("CARGO_WORKSPACE_DIR").unwrap())
}
fn test_secret_registry() -> Arc<SecretRegistry> {
Arc::new(SecretRegistry::from_test_values([(
"MY_SECRET".to_string(),
secrecy::SecretString::from("s3cret_value"),
)]))
}
#[test]
fn strict_outbound_http_replacement_error_has_both_toml_entries() {
let entry = AllowedHostToml {
pattern: "api.example.com".to_string(),
methods: Some(MethodsInput::Star(MethodsInputStar::default())),
request_url_regex: None,
secrets: vec!["API_KEY".to_string()],
replace_in: vec![ReplaceIn::Headers],
};
let component_name = crate::config::toml::ConfigName::new("caller".into()).unwrap();
let secret_registry = SecretRegistry::from_test_values([(
"API_KEY".to_string(),
secrecy::SecretString::from("value"),
)]);
let global_http_config = GlobalHttpConfig::default();
let server_replacements = global_secret_replacements(&global_http_config);
let mut missing = Vec::new();
collect_outbound_http_secret_replacements(
"activity_wasm",
&component_name,
&[entry],
&server_replacements,
&secret_registry,
&mut missing,
);
let err = report_missing_outbound_http_secret_replacements(
&missing,
RuntimeConfigAvailability::Strict,
)
.unwrap_err()
.to_string();
assert!(err.contains("[[outbound_http.allowed_host]]"));
assert!(err.contains("[[activity_wasm.allowed_host]]"));
assert!(err.contains("secrets = [\"API_KEY\"]"));
assert!(err.contains("replace_in = [\"headers\"]"));
}
#[test]
fn unregistered_secrets_collect_all_and_error_shows_scaffold() {
use crate::command::server::config_prepass::{
collect_unregistered_allowed_host_secrets, report_unregistered_secrets,
};
let entry = |secret: &str| AllowedHostToml {
pattern: "api.example.com".to_string(),
methods: Some(MethodsInput::Star(MethodsInputStar::default())),
request_url_regex: None,
secrets: vec![secret.to_string()],
replace_in: vec![ReplaceIn::Headers],
};
let secret_registry = SecretRegistry::from_test_values([(
"KNOWN".to_string(),
secrecy::SecretString::from("v"),
)]);
let mut unregistered = std::collections::BTreeSet::new();
collect_unregistered_allowed_host_secrets(
&[entry("MISSING_B"), entry("KNOWN"), entry("MISSING_A")],
&secret_registry,
&mut unregistered,
);
let err = report_unregistered_secrets(
&unregistered,
"the deployment's outbound HTTP entries",
RuntimeConfigAvailability::Strict,
)
.unwrap_err()
.to_string();
assert!(err.contains("`MISSING_A`, `MISSING_B`"), "{err}");
assert!(!err.contains("KNOWN"), "{err}");
assert!(err.contains("[secrets]"), "{err}");
assert!(err.contains("MISSING_A = { env = \"MISSING_A\" }"), "{err}");
assert!(err.contains("MISSING_B = { env = \"MISSING_B\" }"), "{err}");
}
#[test]
fn unregistered_secrets_downgrade_to_warning_when_unavailable_allowed() {
use crate::command::server::config_prepass::report_unregistered_secrets;
let mut unregistered = std::collections::BTreeSet::new();
unregistered.insert("MISSING".to_string());
report_unregistered_secrets(
&unregistered,
"the deployment's outbound HTTP entries",
RuntimeConfigAvailability::AllowUnavailable,
)
.expect("unavailable runtime config downgrades unregistered secrets to a warning");
}
#[tokio::test]
async fn fix_server_secret_scaffolds_appends_missing() {
use crate::command::server::config_prepass::fix_server_secret_scaffolds;
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "[secrets]\nEXISTING = {{ env = \"EXISTING_SRC\" }}\n").unwrap();
let path = file.path();
let mut names = std::collections::BTreeSet::new();
names.insert("NEW_ONE".to_string());
fix_server_secret_scaffolds(path, &names).await.unwrap();
let after = std::fs::read_to_string(path).unwrap();
assert!(
after.contains("EXISTING = { env = \"EXISTING_SRC\" }"),
"{after}"
);
assert!(after.contains("NEW_ONE = { env = \"NEW_ONE\" }"), "{after}");
}
#[test]
fn strict_outbound_http_replacement_error_collects_all_components() {
let entry = |secret: &str| AllowedHostToml {
pattern: "api.example.com".to_string(),
methods: Some(MethodsInput::Star(MethodsInputStar::default())),
request_url_regex: None,
secrets: vec![secret.to_string()],
replace_in: vec![ReplaceIn::Headers],
};
let activity_name = crate::config::toml::ConfigName::new("caller".into()).unwrap();
let webhook_name = crate::config::toml::ConfigName::new("hook".into()).unwrap();
let secret_registry = SecretRegistry::from_test_values([
(
"API_KEY".to_string(),
secrecy::SecretString::from("value-a"),
),
(
"OTHER_KEY".to_string(),
secrecy::SecretString::from("value-b"),
),
]);
let global_http_config = GlobalHttpConfig::default();
let server_replacements = global_secret_replacements(&global_http_config);
let mut missing = Vec::new();
collect_outbound_http_secret_replacements(
"activity_wasm",
&activity_name,
&[entry("API_KEY")],
&server_replacements,
&secret_registry,
&mut missing,
);
collect_outbound_http_secret_replacements(
"webhook_endpoint_js",
&webhook_name,
&[entry("OTHER_KEY")],
&server_replacements,
&secret_registry,
&mut missing,
);
assert_eq!(missing.len(), 2);
let err = report_missing_outbound_http_secret_replacements(
&missing,
RuntimeConfigAvailability::Strict,
)
.unwrap_err()
.to_string();
assert!(
err.contains("`caller`"),
"missing activity component: {err}"
);
assert!(err.contains("`hook`"), "missing webhook component: {err}");
assert!(err.contains("secrets = [\"API_KEY\"]"));
assert!(err.contains("secrets = [\"OTHER_KEY\"]"));
}
#[test]
fn uncovered_outbound_http_host_collected_covered_skipped() {
let host = |pattern: &str| AllowedHostToml {
pattern: pattern.to_string(),
methods: Some(MethodsInput::List(vec!["GET".to_string()])),
request_url_regex: None,
secrets: Vec::new(),
replace_in: Vec::new(),
};
let registry =
SecretRegistry::from_test_values(Vec::<(String, secrecy::SecretString)>::new());
let global = GlobalHttpConfig::from(
crate::config::toml::resolve_allowed_hosts(vec![host("obeli.sk")], false, ®istry)
.unwrap()
.0,
);
let name = crate::config::toml::ConfigName::new("caller".into()).unwrap();
let mut uncovered = Vec::new();
collect_uncovered_outbound_http_hosts(
"activity_wasm",
&name,
&[host("obeli.sk"), host("example.com")],
&global,
®istry,
false,
&mut uncovered,
);
assert_eq!(uncovered.len(), 1, "only the uncovered host is collected");
assert_eq!(uncovered[0].entry.pattern, "example.com");
let snippet = host_allowlist_snippet("outbound_http", &uncovered[0].entry);
assert!(snippet.contains("[[outbound_http.allowed_host]]"));
assert!(snippet.contains("pattern = \"example.com\""));
assert!(snippet.contains("methods = [\"GET\"]"));
assert!(!snippet.contains("secrets"), "snippet: {snippet}");
assert!(!snippet.contains("replace_in"), "snippet: {snippet}");
}
#[test]
fn self_authorizing_webhook_uses_own_allowlist() {
use wasm_workers::http_request_policy::{AllowedHostConfig, HostPattern, MethodsPattern};
let target = AllowedHostConfig {
pattern: HostPattern::parse_with_methods(
"http://127.0.0.1:5005",
MethodsPattern::AllMethods,
)
.unwrap(),
request_url_regex: None,
secret_names: Vec::new(),
replace_in: hashbrown::HashSet::new(),
};
let allowed: Arc<[AllowedHostConfig]> = Arc::from(vec![target]);
let operator = GlobalHttpConfig::default();
let self_auth = webhook_global_http_allowlist(true, &allowed, &operator);
assert_eq!(
self_auth.entries().len(),
1,
"self-authorized from own allowlist"
);
let bound = webhook_global_http_allowlist(false, &allowed, &operator);
assert!(
bound.entries().is_empty(),
"other webhooks keep the operator allowlist"
);
}
#[test]
fn wit_includes_obelisk_extension_packages() {
let component_id = ComponentId::new(
ComponentType::ActivityStub,
StrVariant::Static("test_stub"),
ComponentDigest(Digest([0u8; 32])),
)
.unwrap();
let ffqn = FunctionFqn::new_static("ns:pkg/ifc", "my-fn");
let params = vec![ParameterType {
name: StrVariant::Static("id"),
type_wrapper: val_json::type_wrapper::TypeWrapper::U64,
wit_type: StrVariant::Static("u64"),
}];
let ret_type = {
let tw = val_json::type_wrapper::parse_wit_type("result<string, string>").unwrap();
let ReturnType::Extendable(rt) =
ReturnType::detect(tw, StrVariant::Static("result<string, string>"))
else {
unreachable!()
};
rt
};
let config = compile_activity_inline(component_id, &ffqn, ¶ms, &ret_type)
.expect("compile must succeed");
let group =
wit_parser::UnresolvedPackageGroup::parse(std::path::PathBuf::new(), &config.wit)
.expect("synthesized WIT must parse");
wit_parser::Resolve::new()
.push_group(group)
.expect("synthesized WIT must not contain a package dependency cycle");
insta::assert_snapshot!(config.wit);
}
#[rstest]
#[tokio::test]
async fn server_verify(
#[values("server-sqlite.toml", "server-postgres.toml")] server_toml: &'static str,
#[values(
"deployment-testing-wasm-local.toml",
"deployment-testing-wasm-oci.toml"
)]
deployment_toml: &'static str,
) -> Result<(), anyhow::Error> {
test_utils::set_up();
let workspace = get_workspace_dir();
let project_dirs = crate::project_dirs();
let base_dirs = BaseDirs::new();
let config_holder =
ConfigHolder::new(project_dirs, base_dirs, Some(workspace.join(server_toml)))?;
let config = config_holder.load_config()?;
let fixture = crate::command::test_support::target_aware_deployment_fixture(
&workspace,
deployment_toml,
)
.await?;
let deployment = load_deployment_resolved(fixture.path()).await?;
let prepared_dirs = prepare_dirs(
&config,
&PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
&config_holder.path_prefixes,
&SecretRegistry::empty(),
)
.await?;
let (_termination_sender, mut termination_watcher) = watch::channel(());
let engines = create_engines(&config, &prepared_dirs)?;
let server_verified =
Box::pin(ServerVerified::new(engines, config, test_secret_registry())).await?;
let params = VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: RuntimeConfigAvailability::Strict,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
};
let webui_enabled = None;
let deployment =
DeploymentRunnable::resolve(deployment, None, &prepared_dirs.wasm_cache_dir).await?;
let deployment_verified = Box::pin(DeploymentVerified::fetch_and_verify_all(
deployment,
server_verified.http_servers,
prepared_dirs.wasm_cache_dir.clone(),
prepared_dirs.metadata_dir.clone(),
params.runtime_config_availability,
server_verified.global_executor_instance_limiter,
server_verified.fuel,
&mut termination_watcher,
server_verified.database_subscription_interruption,
webui_enabled,
server_verified.secret_registry,
server_verified.global_http_config,
))
.await?;
let _compiled_and_linked = ServerCompiledLinked::new(
DeploymentId::generate(),
deployment_verified,
server_verified.launch,
&mut termination_watcher,
params.suppress_type_checking_errors,
params.suppress_linking_errors,
)
.await?;
Ok(())
}
#[tokio::test]
async fn deployment_verify_rejects_digest_shared_across_component_types()
-> Result<(), anyhow::Error> {
test_utils::set_up();
let workspace = get_workspace_dir();
let project_dirs = crate::project_dirs();
let base_dirs = BaseDirs::new();
let config_holder = ConfigHolder::new(
project_dirs,
base_dirs,
Some(workspace.join("server-sqlite.toml")),
)?;
let config = config_holder.load_config()?;
let fixture = crate::command::test_support::target_aware_deployment_fixture(
&workspace,
"deployment-testing-wasm-local.toml",
)
.await?;
let mut deployment = load_deployment_resolved(fixture.path()).await?;
let shared_digest: ComponentDigest =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.parse()
.unwrap();
deployment.activities_wasm[0].component_digest = Some(shared_digest.clone());
deployment.workflows_wasm[0].component_digest = Some(shared_digest);
let prepared_dirs = prepare_dirs(
&config,
&PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
&config_holder.path_prefixes,
&SecretRegistry::empty(),
)
.await?;
let (_termination_sender, mut termination_watcher) = watch::channel(());
let engines = create_engines(&config, &prepared_dirs)?;
let server_verified =
Box::pin(ServerVerified::new(engines, config, test_secret_registry())).await?;
let err = deployment_verify_config(
&server_verified,
&prepared_dirs,
deployment,
None,
VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: RuntimeConfigAvailability::Strict,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
},
&mut termination_watcher,
)
.await
.unwrap_err();
assert!(err.to_string().contains("shared between component types"));
Ok(())
}
#[tokio::test]
async fn deployment_verify_rejects_exec_activities_on_any_runtime_config_availability()
-> Result<(), anyhow::Error> {
test_utils::set_up();
let server_toml_empty = tempfile::NamedTempFile::new().unwrap();
let server_toml_empty_path = server_toml_empty.path();
let workspace = get_workspace_dir();
let config_holder = ConfigHolder::new(
crate::project_dirs(),
BaseDirs::new(),
Some(server_toml_empty_path.to_path_buf()),
)?;
let config = config_holder.load_config()?;
assert_eq!(config.allow_exec_activities, AllowExecActivities::Deny);
let deployment =
load_deployment_resolved(&workspace.join("deployment-testing-exec.toml")).await?;
let prepared_dirs = prepare_dirs(
&config,
&PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
&config_holder.path_prefixes,
&SecretRegistry::empty(),
)
.await?;
let engines = create_engines(&config, &prepared_dirs)?;
let server_verified =
Box::pin(ServerVerified::new(engines, config, test_secret_registry())).await?;
let (_termination_sender, mut termination_watcher) = watch::channel(());
let err = deployment_verify_config(
&server_verified,
&prepared_dirs,
deployment.clone(),
None,
VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: RuntimeConfigAvailability::Strict,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
},
&mut termination_watcher,
)
.await
.unwrap_err();
assert!(err.to_string().contains("run outside the WASM sandbox"));
assert!(err.to_string().contains("exec-stream"));
assert!(err.to_string().contains("[allow_exec_activities]\n"));
assert!(err.to_string().contains("\"sha256:"));
let verified = deployment_verify_config(
&server_verified,
&prepared_dirs,
deployment,
None,
VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: RuntimeConfigAvailability::AllowUnavailable,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
},
&mut termination_watcher,
)
.await?;
assert_eq!(
verified.runtime_config_availability,
RuntimeConfigAvailability::AllowUnavailable
);
Ok(())
}
#[tokio::test]
async fn fix_server_exec_digests_replaces_policy_with_sorted_map() -> Result<(), anyhow::Error>
{
use std::fmt::Write as _;
test_utils::set_up();
let workspace = get_workspace_dir();
let deployment =
load_deployment_resolved(&workspace.join("deployment-testing-exec.toml")).await?;
let dir = tempfile::tempdir()?;
let server_config_path = dir.path().join("server.toml");
let wrong = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
let mut server_toml = "# formatting is preserved\n[allow_exec_activities]\n".to_string();
for activity in deployment.activities_exec.iter().rev() {
writeln!(
server_toml,
"\"{}\" = \"{wrong}\" # reviewed",
activity.name
)?;
}
writeln!(server_toml, "stale = \"{wrong}\"\n")?;
server_toml.push_str("[api]\nenabled = false\n");
tokio::fs::write(&server_config_path, server_toml).await?;
let allowlist =
fix_server_exec_digests(&server_config_path, &deployment.activities_exec, dir.path())
.await?;
let fixed = tokio::fs::read_to_string(&server_config_path).await?;
let doc = fixed.parse::<DocumentMut>()?;
let table = doc["allow_exec_activities"].as_table().unwrap();
assert_eq!(
table.iter().map(|(name, _)| name).collect::<Vec<_>>(),
allowlist.keys().map(String::as_str).collect::<Vec<_>>()
);
assert!(fixed.contains("# formatting is preserved"));
assert_eq!(
fixed.matches("# reviewed").count(),
allowlist.len(),
"{fixed}"
);
assert!(!fixed.contains("stale ="));
assert!(fixed.contains("[api]\nenabled = false"));
assert_eq!(
toml::from_str::<ServerConfigToml>(&fixed)?.allow_exec_activities,
AllowExecActivities::Allowlist(allowlist)
);
Ok(())
}
#[tokio::test]
async fn deployment_verify_exec_allowlist_should_check_content_digests()
-> Result<(), anyhow::Error> {
test_utils::set_up();
let workspace = get_workspace_dir();
let config_holder = ConfigHolder::new(
crate::project_dirs(),
BaseDirs::new(),
Some(workspace.join("server-sqlite.toml")),
)?;
let mut config = config_holder.load_config()?;
let deployment =
load_deployment_resolved(&workspace.join("deployment-testing-exec.toml")).await?;
let digests = deployment
.activities_exec
.iter()
.map(|activity| match &activity.location {
ScriptLocationResolved::Content { content, .. } => {
compute_content_digest(content.as_bytes())
}
ScriptLocationResolved::Oci { .. } => {
unreachable!("fixture uses only inline/local scripts")
}
})
.collect::<Vec<_>>();
let prepared_dirs = prepare_dirs(
&config,
&PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
&config_holder.path_prefixes,
&SecretRegistry::empty(),
)
.await?;
let engines = create_engines(&config, &prepared_dirs)?;
let (_termination_sender, mut termination_watcher) = watch::channel(());
let verify_params = VerifyParams {
dir_params: PrepareDirsParams {
clean_cache: false,
clean_codegen_cache: false,
},
runtime_config_availability: RuntimeConfigAvailability::Strict,
suppress_type_checking_errors: false,
suppress_linking_errors: false,
};
config.allow_exec_activities = AllowExecActivities::Allowlist(
deployment
.activities_exec
.iter()
.skip(1)
.zip(digests.iter().skip(1))
.map(|(activity, digest)| (activity.name.to_string(), digest.clone()))
.collect(),
);
let server_verified = Box::pin(ServerVerified::new(
engines.clone(),
config.clone(),
test_secret_registry(),
))
.await?;
let err = deployment_verify_config(
&server_verified,
&prepared_dirs,
deployment.clone(),
None,
verify_params.clone(),
&mut termination_watcher,
)
.await
.unwrap_err();
assert!(err.to_string().contains("allow_exec_activities"));
assert!(err.to_string().contains(&digests[0].to_string()));
assert!(
err.to_string()
.contains(deployment.activities_exec[0].name.as_str())
);
config.allow_exec_activities = AllowExecActivities::Allowlist(
deployment
.activities_exec
.iter()
.zip(digests)
.map(|(activity, digest)| (activity.name.to_string(), digest))
.collect(),
);
let server_verified =
Box::pin(ServerVerified::new(engines, config, test_secret_registry())).await?;
deployment_verify_config(
&server_verified,
&prepared_dirs,
deployment,
None,
verify_params,
&mut termination_watcher,
)
.await?;
Ok(())
}
mod submit_package {
use crate::command::server::{
SuppliedFile, compute_content_digest, validate_submit_package,
};
use crate::config::manifest::{DeploymentFileRef, ManifestFieldRef};
use crate::config::toml::MAX_DEPLOYMENT_FILE_BYTES;
use concepts::ContentDigest;
use hashbrown::HashSet;
fn file_ref(path: &str, content: &[u8]) -> (DeploymentFileRef, ContentDigest) {
let digest = compute_content_digest(content);
(
DeploymentFileRef {
path: path.to_string(),
digest: digest.clone(),
field: ManifestFieldRef {
section: "activity_wasm".to_string(),
component_name: Some("c".to_string()),
field_path: "activity_wasm[name=c].location".to_string(),
},
},
digest,
)
}
#[test]
fn attached_blob_is_scheduled_for_write() {
let (expected, _digest) = file_ref("a.wasm", b"\0asm-bytes");
let supplied = vec![SuppliedFile {
path: "a.wasm".to_string(),
supplied_digest: None,
content: b"\0asm-bytes".to_vec(),
}];
let to_write = validate_submit_package(
&[expected],
supplied,
&HashSet::new(),
MAX_DEPLOYMENT_FILE_BYTES,
)
.expect("complete package");
assert_eq!(to_write.len(), 1);
assert_eq!(to_write[0].path, "a.wasm");
}
#[test]
fn cas_hit_needs_no_attached_blob_and_no_write() {
let (expected, digest) = file_ref("a.wasm", b"\0asm-bytes");
let cas_present = HashSet::from_iter([digest]);
let to_write = validate_submit_package(
&[expected],
Vec::new(),
&cas_present,
MAX_DEPLOYMENT_FILE_BYTES,
)
.expect("CAS hit is complete");
assert!(to_write.is_empty());
}
#[test]
fn missing_blob_reports_field_context() {
let (expected, digest) = file_ref("a.wasm", b"\0asm-bytes");
let err = validate_submit_package(
&[expected],
Vec::new(),
&HashSet::new(),
MAX_DEPLOYMENT_FILE_BYTES,
)
.expect_err("missing blob");
assert_eq!(err.missing_files.len(), 1);
assert_eq!(err.missing_files[0].section, "activity_wasm");
assert_eq!(
err.missing_files[0].digest.as_deref(),
Some(digest.to_string().as_str())
);
}
#[test]
fn unexpected_blob_is_rejected() {
let (expected, _digest) = file_ref("a.wasm", b"\0asm-bytes");
let supplied = vec![SuppliedFile {
path: "stray.wasm".to_string(),
supplied_digest: None,
content: b"unexpected".to_vec(),
}];
let err = validate_submit_package(
&[expected],
supplied,
&HashSet::new(),
MAX_DEPLOYMENT_FILE_BYTES,
)
.expect_err("unexpected blob");
assert_eq!(err.unexpected_files.len(), 1);
assert_eq!(err.unexpected_files[0].path.as_deref(), Some("stray.wasm"));
}
#[test]
fn digest_mismatch_is_rejected() {
let (expected, _digest) = file_ref("a.wasm", b"\0asm-bytes");
let supplied = vec![SuppliedFile {
path: "a.wasm".to_string(),
supplied_digest: Some(
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
),
content: b"\0asm-bytes".to_vec(),
}];
let err = validate_submit_package(
&[expected],
supplied,
&HashSet::new(),
MAX_DEPLOYMENT_FILE_BYTES,
)
.expect_err("digest mismatch");
assert_eq!(err.digest_mismatches.len(), 1);
assert_eq!(
err.digest_mismatches[0].actual_digest,
compute_content_digest(b"\0asm-bytes").to_string()
);
}
#[test]
fn oversized_blob_is_rejected() {
let (expected, _digest) = file_ref("a.wasm", b"\0asm-bytes");
let supplied = vec![SuppliedFile {
path: "a.wasm".to_string(),
supplied_digest: None,
content: b"\0asm-bytes".to_vec(),
}];
let err = validate_submit_package(&[expected], supplied, &HashSet::new(), 1)
.expect_err("oversized blob");
assert_eq!(err.oversized_files.len(), 1);
}
}
}