blob: e774a52d345db74f6fe7c8597f75e4485378048e [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Explicitly composed management of a fixed deployment in the original Root.
use super::*;
use maka_event_log::{EventLog, StoreError, applications::validate_application};
use maka_plugin_core::contract::binary_resource_type;
use maka_plugin_core::deployment::management::{
DeploymentDocument, DeploymentDocumentChunk, DeploymentDocumentRead, DeploymentInstances,
DeploymentRead, DeploymentReplace, InstanceSetEnabled, ManagedInstance, ManagedInstancesPage,
ManagementRejection, ManagementResult, PackageBundleExport, PackageBundleRead, PackageInstall,
PackageInstalled, deployment_manager_contract,
};
use serde::Deserialize;
mod authoring;
mod download;
pub use download::download_bundle;
mod composition;
mod settings;
#[cfg(test)]
mod tests;
pub const DEPLOYMENT_MANAGER: &str = "maka.application.management";
/// Trusted launcher handle. The target and execution environment never come
/// from guest configuration or mutation input; every operation uses Host admission.
#[derive(Clone)]
pub struct ManagementTarget(Manager);
impl ManagementTarget {
pub async fn from_source(store: Arc<EventLog>, source: DeploymentRef) -> Result<Self> {
let metadata = store
.read_application_metadata_at(&source)
.await
.map_err(|error| error.to_string())?
.ok_or("managed source not found")?;
let environment = metadata.source.input.environment;
environment
.entry_target()
.map_err(|error| error.to_string())?;
Ok(Self(Manager {
source,
store,
environment,
writes: Arc::new(tokio::sync::Mutex::new(())),
}))
}
}
#[derive(Clone)]
struct Manager {
source: DeploymentRef,
store: Arc<EventLog>,
environment: maka_plugin_core::composition::Environment,
writes: Arc<tokio::sync::Mutex<()>>,
}
impl Manager {
async fn metadata(
&self,
) -> Result<maka_plugin_core::deployment::management::ManagedDeployment> {
self.store
.read_application_metadata(self.source.deployment)
.await
.map_err(|error| error.to_string())?
.ok_or("managed deployment not found".into())
}
async fn instances(&self, input: Value) -> Result<Value> {
let request: DeploymentInstances =
serde_json::from_value(input).map_err(|e| e.to_string())?;
let _materialize = self.writes.lock().await;
let (source, after) = match request {
DeploymentInstances::Start {} => (self.metadata().await?.source.reference, None),
DeploymentInstances::Continue { source, after } => (source, after),
};
if source.state_domain != self.source.state_domain
|| source.deployment != self.source.deployment
{
return Err("foreign managed deployment source".into());
}
let loaded = self
.store
.load_application_at(&source)
.await
.map_err(|e| e.to_string())?
.ok_or("managed source not found")?;
let plan = loaded.plan();
if after
.as_ref()
.is_some_and(|after| !plan.instances().contains_key(after))
{
return Err("managed instance cursor not found".into());
}
let mut remaining = plan.instances().values().filter(|instance| {
after
.as_ref()
.is_none_or(|after| &instance.identity > after)
});
let instances: Vec<_> = remaining
.by_ref()
.take(8)
.map(|instance| ManagedInstance {
instance: instance.identity.clone(),
path: instance.path.clone(),
package: instance.package.clone(),
presentation: loaded
.packages()
.iter()
.find(|package| package.application_ref() == instance.package)
.map(|package| package.manifest().presentation())
.unwrap_or_default(),
structural: instance.entry.is_none(),
disabled: instance.disabled,
parent_disabled: instance
.parent
.as_ref()
.is_some_and(|parent| plan.instances()[parent].disabled),
})
.collect();
let next = remaining.next().map(|_| {
instances
.last()
.expect("full managed page")
.instance
.clone()
});
serde_json::to_value(ManagedInstancesPage {
source,
instances,
next,
})
.map_err(|e| e.to_string())
}
async fn read_document(&self, input: Value) -> Result<Value> {
let request: DeploymentDocumentRead =
serde_json::from_value(input).map_err(|e| e.to_string())?;
if request.source.state_domain != self.source.state_domain
|| request.source.deployment != self.source.deployment
{
return Err("foreign managed deployment source".into());
}
let _materialize = self.writes.lock().await;
let metadata = self
.store
.read_application_metadata_at(&request.source)
.await
.map_err(|e| e.to_string())?
.ok_or("managed source not found")?;
let text = document_text(DeploymentDocument {
input: metadata.source.input,
remote_exports: metadata.source.remote_exports,
packages: metadata.packages,
})?;
if request.offset > text.len() || !text.is_char_boundary(request.offset) {
return Err("invalid document byte offset".into());
}
let mut end = text
.len()
.min(request.offset.saturating_add(RESOURCE_CHUNK_LIMIT));
while !text.is_char_boundary(end) {
end -= 1;
}
serde_json::to_value(DeploymentDocumentChunk {
source: request.source,
offset: request.offset,
total_bytes: text.len(),
text: text[request.offset..end].into(),
next: (end < text.len()).then_some(end),
})
.map_err(|e| e.to_string())
}
async fn set_enabled(&self, input: Value) -> Result<Value> {
let request: InstanceSetEnabled =
serde_json::from_value(input).map_err(|e| e.to_string())?;
let _write = self.writes.lock().await;
let metadata = self.metadata().await?;
if request.expected_revision != metadata.source.reference.revision {
return conflict(metadata.source.reference.revision);
}
let loaded = self
.store
.load_application_at(&metadata.source.reference)
.await
.map_err(|e| e.to_string())?
.ok_or("managed source not found")?;
let (mut source, packages, plan, _) = loaded.into_parts();
let Some(instance) = plan.instances().get(&request.instance) else {
return rejected(ManagementRejection::InstanceUnavailable);
};
if request.enabled
&& instance
.parent
.as_ref()
.is_some_and(|parent| plan.instances()[parent].disabled)
{
return rejected(ManagementRejection::ParentDisabled);
}
let [root, path @ ..] = instance.path.as_slice() else {
return Err("invalid actual managed path".into());
};
let node = source
.input
.instances
.get_mut(root)
.ok_or("managed root disappeared")?;
if path.is_empty() {
node.disabled = !request.enabled;
} else {
if let Some(patch) = node.overrides.iter_mut().find(|patch| patch.path == path) {
patch.disabled = Some(!request.enabled);
} else {
node.overrides
.push(maka_plugin_core::composition::Override {
path: path.to_vec(),
disabled: Some(!request.enabled),
..Default::default()
});
}
}
// Effective disabled may come from an ancestor. Persist the user's own
// explicit intent even when that effective projection already matches.
self.commit(
request.expected_revision,
source.input,
source.remote_exports,
packages,
|source| source,
)
.await
}
async fn install(&self, input: Value, context: Context) -> Result<Value> {
let request: PackageInstall =
serde_json::from_value(input).map_err(|error| error.to_string())?;
let _write = self.writes.lock().await;
let metadata = self.metadata().await?;
if request.expected_revision != metadata.source.reference.revision {
return conflict(metadata.source.reference.revision);
}
let resource = context
.attachments()
.get("package")
.ok_or("package bundle required")?
.clone();
let mut ticket = context
.scope()
.resources
.reserve()
.map_err(|error| error.to_string())?;
ticket.start();
let parsed = tokio::task::spawn_blocking(move || {
let parsed = read_bundle(&resource);
drop(resource);
ticket.complete(Ok(()));
parsed
})
.await
.map_err(|error| format!("package validation worker failed: {error}"))?;
let package = match parsed {
Ok(package) => package,
Err(_) => return rejected(ManagementRejection::InvalidBundle),
};
self.install_package(request.expected_revision, package, metadata)
.await
}
async fn install_package(
&self,
expected_revision: u64,
package: Package,
metadata: maka_plugin_core::deployment::management::ManagedDeployment,
) -> Result<Value> {
let reference = package.application_ref();
let loaded = self
.store
.load_application_at(&metadata.source.reference)
.await
.map_err(|error| error.to_string())?
.ok_or("managed source not found")?;
let (source, mut packages, _, _) = loaded.into_parts();
if !packages
.iter()
.any(|old| old.application_ref() == reference)
{
packages.push(package);
}
self.commit(
expected_revision,
source.input,
source.remote_exports,
packages,
move |source| PackageInstalled {
source,
package: reference,
},
)
.await
}
async fn replace(&self, input: Value, context: Context) -> Result<Value> {
let request: DeploymentReplace =
serde_json::from_value(input).map_err(|error| error.to_string())?;
let _write = self.writes.lock().await;
let metadata = self.metadata().await?;
if request.expected_revision != metadata.source.reference.revision {
return conflict(metadata.source.reference.revision);
}
let resource = context
.attachments()
.get("document")
.ok_or("source document required")?
.clone();
let bundles = context.attachments().get("packages").cloned();
let mut ticket = context
.scope()
.resources
.reserve()
.map_err(|e| e.to_string())?;
ticket.start();
let parsed = tokio::task::spawn_blocking(move || {
let parsed = (|| {
let document = read_document_resource(&resource)
.map_err(|error| (ManagementRejection::InvalidDocument, error))?;
let packages = match &bundles {
Some(resource) => read_package_bundles(resource)
.map_err(|error| (ManagementRejection::InvalidBundle, error))?,
None => Vec::new(),
};
Ok((document, packages))
})();
drop(resource);
drop(bundles);
ticket.complete(Ok(()));
parsed
})
.await
.map_err(|e| format!("document validation worker failed: {e}"))?;
let (document, packages) = match parsed {
Ok(parsed) => parsed,
Err((reason, error)) => return rejected_diagnostic(reason, error),
};
self.replace_document(request.expected_revision, document, metadata, packages)
.await
}
async fn replace_document(
&self,
expected_revision: u64,
document: DeploymentDocument,
metadata: maka_plugin_core::deployment::management::ManagedDeployment,
supplied: Vec<Package>,
) -> Result<Value> {
if document.input.environment != self.environment
|| (self.environment.location == ExecutionLocation::Host
&& !document.remote_exports.is_empty())
{
return rejected(ManagementRejection::ExecutionEnvironment);
}
let desired: BTreeSet<_> = document.packages.iter().cloned().collect();
if desired.len() != document.packages.len() {
return rejected_diagnostic(
ManagementRejection::InvalidDocument,
"duplicate document package reference".into(),
);
}
let mut added = BTreeMap::new();
for package in supplied {
let reference = package.application_ref();
if !desired.contains(&reference) || added.insert(reference, package).is_some() {
return rejected_diagnostic(
ManagementRejection::InvalidBundle,
"supplied packages must be distinct and selected by the document".into(),
);
}
}
if desired.iter().any(|reference| {
!metadata.packages.contains(reference) && !added.contains_key(reference)
}) {
return rejected(ManagementRejection::PackageUnavailable);
}
let loaded = self
.store
.load_application_at(&metadata.source.reference)
.await
.map_err(|error| error.to_string())?
.ok_or("managed source not found")?;
let mut available: BTreeMap<_, _> = loaded
.packages()
.iter()
.map(|package| (package.application_ref(), package.clone()))
.collect();
available.extend(added);
let packages = document
.packages
.into_iter()
.map(|reference| {
available
.get(&reference)
.cloned()
.ok_or("installed package disappeared".to_string())
})
.collect::<Result<Vec<_>>>()?;
self.commit(
expected_revision,
document.input,
document.remote_exports,
packages,
|source| source,
)
.await
}
async fn export_package(&self, input: Value, context: Context) -> Result<Value> {
let request: PackageBundleRead =
serde_json::from_value(input).map_err(|error| error.to_string())?;
let package = {
let _read = self.writes.lock().await;
let loaded = self.source_at(Some(request.source)).await?;
loaded
.packages()
.iter()
.find(|package| package.application_ref() == request.package)
.cloned()
.ok_or("package is not installed in this source")?
};
let mut ticket = context
.scope()
.resources
.reserve()
.map_err(|error| error.to_string())?;
ticket.start();
let bytes = tokio::task::spawn_blocking(move || {
let bytes = package.to_bundle();
drop(package);
ticket.complete(Ok(()));
bytes
})
.await
.map_err(|error| format!("package export worker failed: {error}"))?;
let text = String::from_utf8(bytes).map_err(|error| error.to_string())?;
let mut offset = 0;
while offset < text.len() {
let mut end = (offset + 16 * 1024).min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
context
.emit(
serde_json::to_value(PackageBundleExport::Chunk {
offset,
total_bytes: text.len(),
text: text[offset..end].into(),
})
.map_err(|error| error.to_string())?,
)
.await?;
offset = end;
}
serde_json::to_value(PackageBundleExport::Complete {}).map_err(|error| error.to_string())
}
async fn commit<T: serde::Serialize>(
&self,
expected: u64,
input: Input,
observations: Vec<maka_plugin_core::deployment::RemoteObservation>,
packages: Vec<Package>,
result: impl FnOnce(DeploymentRef) -> T,
) -> Result<Value> {
if input.environment != self.environment {
return rejected(ManagementRejection::ExecutionEnvironment);
}
if let Err(error) = validate_application(&input, &observations, &packages) {
return rejected_diagnostic(ManagementRejection::InvalidSource, error.to_string());
}
match self
.store
.commit_application(
self.source.deployment,
expected,
input,
observations,
packages,
)
.await
{
Ok(source) => serde_json::to_value(ManagementResult::Committed {
value: result(source),
})
.map_err(|error| error.to_string()),
Err(StoreError::RevisionConflict { actual, .. }) => conflict(
actual
.parse()
.map_err(|_| "invalid managed revision conflict")?,
),
// SQL and COMMIT uncertainty must never be converted to a known refusal.
Err(error) => Err(error.to_string()),
}
}
}
fn rejected(reason: ManagementRejection) -> Result<Value> {
serde_json::to_value(ManagementResult::<DeploymentRef>::Rejected {
reason,
diagnostic: None,
})
.map_err(|error| error.to_string())
}
fn rejected_diagnostic(reason: ManagementRejection, message: String) -> Result<Value> {
serde_json::to_value(ManagementResult::<DeploymentRef>::Rejected {
reason,
diagnostic: Some(message),
})
.map_err(|e| e.to_string())
}
fn conflict(current_revision: u64) -> Result<Value> {
serde_json::to_value(ManagementResult::<DeploymentRef>::Conflict { current_revision })
.map_err(|error| error.to_string())
}
impl Handler for Manager {
fn call(
&self,
method: String,
input: Value,
context: Context,
) -> BoxFuture<'static, Result<Value>> {
let manager = self.clone();
Box::pin(async move {
match method.as_str() {
"read" => {
serde_json::from_value::<DeploymentRead>(input)
.map_err(|error| error.to_string())?;
serde_json::to_value(manager.metadata().await?)
.map_err(|error| error.to_string())
}
"instances" => manager.instances(input).await,
"readDocument" => manager.read_document(input).await,
"readSettings" => manager.read_settings(input).await,
"configure" => manager.configure(input).await,
"bindingCandidates" => manager.binding_candidates(input).await,
"packageSettings" => manager.package_settings(input).await,
"editComposition" => manager.edit_composition(input).await,
"setEnabled" => manager.set_enabled(input).await,
"install" => manager.install(input, context).await,
"installSource" => manager.install_source(input).await,
"installUrl" => manager.install_url(input, context).await,
"readPackage" => manager.read_package(input).await,
"exportPackage" => manager.export_package(input, context).await,
"replaceSource" => manager.replace_source(input).await,
"replace" => manager.replace(input, context).await,
_ => Err("unknown deployment management method".into()),
}
})
}
}
impl NativeOwner for Manager {
fn prepare_retirement(&self) -> Option<Box<dyn crate::application::NativeRetirement>> {
Some(Box::new(()))
}
fn execution_basis(&self) -> Option<Value> {
Some(serde_json::json!({
"stateDomain": self.source.state_domain,
"deployment": self.source.deployment,
"environment": self.environment,
}))
}
fn close(&self) -> BoxFuture<'static, Result<()>> {
// Accepted requests and their blocking/SQL work belong to original
// Host execution Scopes; no independent activation work is started.
Box::pin(async { Ok(()) })
}
}
fn document_text(document: DeploymentDocument) -> Result<String> {
serde_json::to_string_pretty(&document).map_err(|error| error.to_string())
}
fn read_document_resource(
resource: &Arc<dyn maka_capabilities::call::ReadResource>,
) -> Result<DeploymentDocument> {
use maka_capabilities::call::ResourceEncoding;
if resource.resource_type() != maka_plugin_core::contract::text_resource_type()
|| resource.encoding() != ResourceEncoding::Utf8
|| resource.bytes() == 0
{
return Err("source document must be nonempty immutable UTF-8".into());
}
let length = resource.bytes();
let mut bytes = Vec::new();
bytes
.try_reserve_exact(length)
.map_err(|_| "package resource allocation failed")?;
while bytes.len() < length {
let offset = bytes.len();
let mut end = length.min(offset + RESOURCE_CHUNK_LIMIT);
let mut chunk = resource.read(offset..end);
// Snapshots reject ranges inside a Unicode scalar. Try at most its
// three possible trailing byte boundaries, as the original transport does.
for _ in 0..3 {
if chunk.is_ok() || end <= offset + 1 {
break;
}
end -= 1;
chunk = resource.read(offset..end);
}
let chunk = chunk?;
if chunk.len() != end - offset || chunk.is_empty() {
return Err("document snapshot length changed".into());
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
fn read_bundle(resource: &Arc<dyn maka_capabilities::call::ReadResource>) -> Result<Package> {
let bytes = read_binary_resource(resource)?;
Package::from_bundle(&bytes).map_err(|error| error.to_string())
}
fn read_package_bundles(
resource: &Arc<dyn maka_capabilities::call::ReadResource>,
) -> Result<Vec<Package>> {
let bytes = read_binary_resource(resource)?;
let bundles: Vec<&serde_json::value::RawValue> =
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
bundles
.into_iter()
.map(|bundle| {
Package::from_bundle(bundle.get().as_bytes()).map_err(|error| error.to_string())
})
.collect()
}
fn read_binary_resource(
resource: &Arc<dyn maka_capabilities::call::ReadResource>,
) -> Result<Vec<u8>> {
use maka_capabilities::call::ResourceEncoding;
let length = resource.bytes();
if resource.resource_type() != binary_resource_type()
|| resource.encoding() != ResourceEncoding::Bytes
{
return Err("invalid immutable package bundle resource".into());
}
let mut bytes = Vec::new();
bytes
.try_reserve_exact(length)
.map_err(|_| "package resource allocation failed")?;
for offset in (0..length).step_by(RESOURCE_CHUNK_LIMIT) {
let end = offset.saturating_add(RESOURCE_CHUNK_LIMIT).min(length);
let chunk = resource.read(offset..end)?;
if chunk.len() != end - offset {
return Err("package bundle snapshot length changed".into());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
pub(super) fn provide(native: &mut NativeRegistry) -> Result<()> {
provide_manager(native, self::native().implementation, None)
}
/// Makes a fixed target available to an explicitly composed native entry. This
/// does not start an entry, publish a binding, or grant a caller any permission.
pub fn provide_target(
native: &mut NativeRegistry,
implementation: String,
target: ManagementTarget,
) -> Result<()> {
provide_manager(native, implementation, Some(target))
}
fn provide_manager(
native: &mut NativeRegistry,
implementation: String,
target: Option<ManagementTarget>,
) -> Result<()> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Configuration {}
// All explicitly composed manager instances in this original Application
// share one bounded source-materialization lane. CAS remains the SQL authority.
let writes = Arc::new(tokio::sync::Mutex::new(()));
native.insert(
implementation,
Arc::new(move |context, configuration| {
let writes = writes.clone();
let target = target.clone();
Box::pin(async move {
let manager = (|| {
serde_json::from_value::<Configuration>(configuration)
.map_err(|error| error.to_string())?;
let persistence = context
.persistence()
.ok_or("deployment management requires the original persisted Root")?;
let manager = match target {
Some(target) => {
if !Arc::ptr_eq(persistence.store(), &target.0.store) {
return Err(
"management target requires the same original Root owner"
.into(),
);
}
target.0
}
None => Manager {
source: persistence.source().clone(),
store: persistence.store().clone(),
environment: maka_plugin_core::composition::Environment {
location: ExecutionLocation::Host,
presentation: None,
},
writes,
},
};
Ok::<_, String>(Arc::new(manager))
})();
match manager {
Ok(manager) => NativeActivation {
exports: Ok(BTreeMap::from([(
"Management".into(),
Published {
contract: deployment_manager_contract(),
value: manager.clone() as Arc<dyn Handler>,
},
)])),
owner: manager,
},
Err(error) => NativeActivation {
exports: Err(error),
owner: Arc::new(Uninitialized),
},
}
})
}),
)
}
struct Uninitialized;
impl NativeOwner for Uninitialized {
fn prepare_retirement(&self) -> Option<Box<dyn crate::application::NativeRetirement>> {
Some(Box::new(()))
}
fn close(&self) -> BoxFuture<'static, Result<()>> {
Box::pin(async { Ok(()) })
}
}
pub fn native() -> maka_plugin_core::package::NativeDefinition {
maka_plugin_core::package::NativeDefinition::new(
DEPLOYMENT_MANAGER,
serde_json::json!({"type":"object","properties":{},"required":[],"additionalProperties":false}),
[(
"Management",
maka_plugin_core::deployment::management::deployment_manager_contract(),
)],
)
}