use std::path::PathBuf;
use std::sync::Arc;
use aion::EngineError;
use aion_awl_package::AwlAssembleOptions;
use aion_package::{ExtractionLimits, Package, PackageBuilder};
use aion_proto::WireError;
use aion_toolchain::{CompileRequest, ToolchainError, compile_source, compile_source_for_entry};
use serde::{Deserialize, Serialize};
use super::error::AuthoringApiError;
use crate::config::{AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED};
use crate::{CallerIdentity, ServerState};
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompileSourceRequest {
pub source: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompileSourceResponse {
pub workflow_type: String,
pub content_hash: String,
pub deployed_entry_module: String,
pub entry_function: String,
pub freshly_loaded: bool,
pub route_changed: bool,
}
pub async fn compile_and_load(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
request: CompileSourceRequest,
) -> Result<CompileSourceResponse, AuthoringApiError> {
compile_and_load_with_options(
state,
caller,
transport,
request,
AwlAssembleOptions::default(),
)
.await
}
pub async fn compile_and_load_with_options(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
request: CompileSourceRequest,
options: AwlAssembleOptions,
) -> Result<CompileSourceResponse, AuthoringApiError> {
compile_and_load_inner(state, caller, transport, request, options, None).await
}
pub async fn compile_and_load_document(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
request: CompileSourceRequest,
workflow_type: String,
options: AwlAssembleOptions,
) -> Result<CompileSourceResponse, AuthoringApiError> {
compile_and_load_inner(
state,
caller,
transport,
request,
options,
Some(workflow_type),
)
.await
}
async fn compile_and_load_inner(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
request: CompileSourceRequest,
options: AwlAssembleOptions,
workflow_type: Option<String>,
) -> Result<CompileSourceResponse, AuthoringApiError> {
admit_mutation(state, caller, transport, "authoring.compile")?;
let (gleam_path, template_root) = authoring_paths(state)?;
let expected_workflow_type = workflow_type.clone();
let mut compiled =
run_compile(gleam_path, template_root, request.source, workflow_type).await?;
if let Some(expected) = expected_workflow_type {
validate_document_identity(&compiled.package, &expected)?;
}
compiled.package = package_with_options(compiled.package, options)?;
load_authorized_package(
state,
caller,
transport,
"authoring.compile",
compiled.package,
)
.await
}
pub(crate) async fn load_admitted_package(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
operation: &'static str,
package: Package,
) -> Result<CompileSourceResponse, AuthoringApiError> {
ensure_not_draining(state)?;
load_authorized_package(state, caller, transport, operation, package).await
}
pub(crate) fn validate_document_identity(
package: &Package,
expected: &str,
) -> Result<(), AuthoringApiError> {
let actual = &package.manifest().entry_module;
if actual == expected {
return Ok(());
}
Err(AuthoringApiError::Wire(
WireError::backend(format!(
"document compile returned manifest entry module `{actual}` instead of `{expected}`"
))
.with_error_type("Toolchain"),
))
}
async fn load_authorized_package(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
operation: &'static str,
package: Package,
) -> Result<CompileSourceResponse, AuthoringApiError> {
let engine = engine_handle(state)?;
match engine.load_package(package).await {
Ok(outcome) => {
let workflow_type = outcome.record.workflow_type().to_owned();
let content_hash = outcome.record.version().to_string();
tracing::info!(
operation,
subject = caller.subject(),
grant_source = caller.grant_source().label(),
transport,
workflow_type = %workflow_type,
content_hash = %content_hash,
outcome = "loaded",
freshly_loaded = outcome.freshly_loaded,
route_changed = outcome.route_changed,
"authoring compile-and-load applied"
);
Ok(CompileSourceResponse {
workflow_type,
content_hash,
deployed_entry_module: outcome.record.deployed_entry_module().to_owned(),
entry_function: outcome.record.entry_function().to_owned(),
freshly_loaded: outcome.freshly_loaded,
route_changed: outcome.route_changed,
})
}
Err(error) => Err(map_load_failure(caller, transport, operation, error)),
}
}
pub(crate) fn package_with_options(
package: Package,
options: AwlAssembleOptions,
) -> Result<Package, AuthoringApiError> {
let Some(timeout) = options.timeout else {
return Ok(package);
};
let mut manifest = package.manifest().clone();
manifest.timeout = timeout;
let source = package
.source()
.iter()
.map(|(name, bytes)| (name.clone(), bytes.clone()));
let bytes = PackageBuilder::with_source(manifest, package.beams().clone(), source)
.with_explicit_timeout_identity()
.write_to_bytes()
.map_err(|error| package_options_error(&error))?;
Package::load_from_bytes(bytes, ExtractionLimits::unbounded())
.map_err(|error| package_options_error(&error))
}
fn package_options_error(error: &aion_package::PackageError) -> AuthoringApiError {
AuthoringApiError::Wire(
WireError::invalid_input(format!(
"AWL manifest options could not be applied: {error}"
))
.with_error_type("Package"),
)
}
pub(crate) fn admit_mutation(
state: &ServerState,
caller: &CallerIdentity,
transport: &'static str,
operation: &'static str,
) -> Result<(), AuthoringApiError> {
let guard = state.deploy_guard();
if let Err(error) = guard.authorize(caller) {
let wire = error.to_wire_error();
tracing::warn!(
operation,
subject = caller.subject(),
grant_source = caller.grant_source().label(),
transport,
reason = %wire.message,
"authoring operation denied"
);
return Err(AuthoringApiError::Wire(wire));
}
ensure_not_draining(state)
}
fn ensure_not_draining(state: &ServerState) -> Result<(), AuthoringApiError> {
if state.drain_state().is_draining() {
return Err(AuthoringApiError::Unavailable(WireError::backend(
"server is draining and not accepting authoring submissions",
)));
}
Ok(())
}
fn authoring_paths(state: &ServerState) -> Result<(PathBuf, PathBuf), AuthoringApiError> {
let authoring = &state.runtime_config().authoring;
let Some(gleam_path) = authoring.gleam_path.clone() else {
return Err(AuthoringApiError::Wire(WireError::backend(
AUTHORING_GLEAM_PATH_EMPTY,
)));
};
let Some(project_root) = authoring.project_root.clone() else {
return Err(AuthoringApiError::Wire(WireError::backend(
AUTHORING_PROJECT_ROOT_REQUIRED,
)));
};
Ok((gleam_path, project_root))
}
async fn run_compile(
gleam_path: PathBuf,
template_root: PathBuf,
source: String,
workflow_type: Option<String>,
) -> Result<aion_toolchain::CompiledWorkflow, AuthoringApiError> {
let join = tokio::task::spawn_blocking(move || {
let request = CompileRequest {
template_root: &template_root,
gleam_path: &gleam_path,
source: &source,
};
workflow_type.map_or_else(
|| compile_source(&request),
|entry_module| compile_source_for_entry(&request, &entry_module),
)
})
.await;
match join {
Ok(Ok(compiled)) => Ok(compiled),
Ok(Err(error)) => Err(map_toolchain_error(error)),
Err(join_error) => Err(AuthoringApiError::Wire(WireError::backend(format!(
"authoring compile task failed to run: {join_error}"
)))),
}
}
fn map_toolchain_error(error: ToolchainError) -> AuthoringApiError {
match error {
ToolchainError::TypeCheck { diagnostics } => AuthoringApiError::TypeError(diagnostics),
ToolchainError::GleamSpawn { .. } | ToolchainError::Io { .. } => {
AuthoringApiError::Wire(
WireError::backend(error.to_string()).with_error_type("Toolchain"),
)
}
ToolchainError::Packaging(_) | ToolchainError::InvalidProject { .. } => {
AuthoringApiError::Wire(
WireError::invalid_input(error.to_string()).with_error_type("Toolchain"),
)
}
}
}
fn map_load_failure(
caller: &CallerIdentity,
transport: &'static str,
operation: &'static str,
error: EngineError,
) -> AuthoringApiError {
let mapped = match error {
EngineError::ShuttingDown => AuthoringApiError::Unavailable(
WireError::backend(error.to_string()).with_error_type("ShuttingDown"),
),
EngineError::Load { .. } => AuthoringApiError::Wire(
WireError::invalid_input(error.to_string()).with_error_type("Load"),
),
EngineError::Package(_) => AuthoringApiError::Wire(
WireError::invalid_input(error.to_string()).with_error_type("Package"),
),
other => AuthoringApiError::Wire(crate::ServerError::from(other).to_wire_error()),
};
tracing::info!(
operation,
subject = caller.subject(),
grant_source = caller.grant_source().label(),
transport,
outcome = mapped.outcome(),
"authoring compile-and-load refused at hot-load"
);
mapped
}
fn engine_handle(state: &ServerState) -> Result<Arc<aion::Engine>, AuthoringApiError> {
state
.deploy_guard()
.engine()
.map(Arc::clone)
.map_err(|error| AuthoringApiError::Wire(error.to_wire_error()))
}