blob: 37f53c2d56d527539c71c288ece48cad44440648 [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.
*/
use super::*;
use cap_fs_ext::DirExt;
use maka_event_log::root::RootOwner;
use maka_skills::publication::{
self, Publisher, Tree,
receipt::{Destination, Operation, Outcome},
};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Collection {
Installed,
Imported,
}
impl Collection {
fn prefix(self) -> &'static str {
match self {
Self::Installed => "installed:",
Self::Imported => "imported:",
}
}
fn destination(self, id: &str) -> Destination {
match self {
Self::Installed => Destination::Skill {
reference: format!("installed:{id}"),
},
Self::Imported => Destination::Source { id: id.into() },
}
}
}
pub(super) struct Managed {
root: Arc<RootOwner>,
data: cap_std::fs::Dir,
pub read: ReadRoot,
imported: cap_std::fs::Dir,
pub imported_read: ReadRoot,
pub serial: tokio::sync::Mutex<()>,
}
impl Managed {
pub fn execution_basis(&self) -> Option<Value> {
// These read roots are clones of the actual publication handles.
serde_json::to_value((
self.root.root_id(),
self.read.execution_basis().ok()?,
self.imported_read.execution_basis().ok()?,
))
.ok()
}
pub async fn open(root: Arc<RootOwner>, namespace: String) -> Result<Arc<Self>, String> {
let (managed, publisher, imported_publisher) = tokio::task::spawn_blocking(move || {
root.validate_current().map_err(store_error)?;
let directory = cap_std::fs::Dir::open_ambient_dir(
root.canonical_path(),
cap_std::ambient_authority(),
)
.map_err(store_error)?;
root.validate_directory_handle(
&directory.try_clone().map_err(store_error)?.into_std_file(),
)
.map_err(store_error)?;
let parent = child(&directory, "skill-libraries").map_err(store_error)?;
let data = child(&parent, &namespace).map_err(store_error)?;
let publisher =
Publisher::open(&data, &CancellationToken::new()).map_err(store_error)?;
let read = ReadRoot::from_handle(
data.try_clone().map_err(store_error)?,
root.canonical_path()
.join("skill-libraries")
.join(&namespace),
)
.select(BTreeSet::from(["skills/".into()]))
.map_err(store_error)?;
let imported = child(&data, "imported").map_err(store_error)?;
let imported_publisher =
Publisher::open(&imported, &CancellationToken::new()).map_err(store_error)?;
let imported_read = ReadRoot::from_handle(
imported.try_clone().map_err(store_error)?,
root.canonical_path()
.join("skill-libraries")
.join(&namespace)
.join("imported"),
)
.select(BTreeSet::from(["skills/".into()]))
.map_err(store_error)?;
Ok::<_, String>((
Arc::new(Self {
root,
data,
read,
imported,
imported_read,
serial: Default::default(),
}),
publisher,
imported_publisher,
))
})
.await
.map_err(store_error)??;
publisher.recover().await.map_err(store_error)?;
imported_publisher.recover().await.map_err(store_error)?;
Ok(managed)
}
pub async fn publisher(
self: &Arc<Self>,
scope: &Scope,
collection: Collection,
) -> Result<Publisher, String> {
let managed = self.clone();
let scope = scope.clone();
let mut ticket = scope.resources.reserve().map_err(store_error)?;
tokio::task::spawn_blocking(move || {
ticket.start();
let result = managed
.root
.validate_current()
.map_err(store_error)
.and_then(|()| {
Publisher::open_scoped(
match collection {
Collection::Installed => &managed.data,
Collection::Imported => &managed.imported,
},
&scope,
)
.map_err(store_error)
});
drop(managed);
ticket.complete(Ok(()));
result
})
.await
.map_err(store_error)?
}
}
fn child(parent: &cap_std::fs::Dir, name: &str) -> std::io::Result<cap_std::fs::Dir> {
match parent.create_dir(name) {
Ok(()) => {
#[cfg(unix)]
parent.open(".")?.sync_all()?;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
parent.open_dir_nofollow(name)
}
pub(super) fn store_error(_: impl std::fmt::Display) -> String {
"skills_store_unavailable".into()
}
struct Plan {
review: Review,
id: String,
expected: Option<Tree>,
next: Option<Tree>,
locally_modified: bool,
collection: Collection,
}
impl Plan {
fn preview(self) -> Preview {
let complete = self
.next
.as_ref()
.or(self.expected.as_ref())
.map_or_else(Vec::new, Tree::paths);
let entries = complete.len();
let mut paths = Vec::new();
for path in complete {
paths.push(path);
if serde_json::to_vec(&paths)
.expect("path strings serialize")
.len()
> 24 * 1024
{
paths.pop();
break;
}
}
Preview::Ready {
target: format!("{}{}", self.collection.prefix(), self.id),
entries,
paths_truncated: paths.len() != entries,
paths,
locally_modified: self.locally_modified,
review: Box::new(self.review),
}
}
}
enum Prepared {
Ready(Box<Plan>),
Changed,
Rejected(&'static str),
}
impl Library {
async fn prepare(
&self,
change: Change,
publisher: &Publisher,
collection: Collection,
scope: &Scope,
) -> Result<Prepared, String> {
let rejected = |reason| Ok(Prepared::Rejected(reason));
if collection == Collection::Imported && !matches!(change.kind, ChangeKind::Delete) {
return rejected("read_only_source");
}
let snapshot = self.snapshot(scope).await?;
if snapshot.revision != change.revision {
return Ok(Prepared::Changed);
}
let Some((entry, digest, _)) = snapshot
.entries
.iter()
.find(|(entry, _, _)| entry.reference == change.reference)
else {
return rejected("not_found");
};
let (id, source) = match change.kind {
ChangeKind::Delete => {
let Some(id) = change
.reference
.strip_prefix(collection.prefix())
.filter(|id| maka_skills::safe_source_id(id))
else {
return rejected("read_only_source");
};
(id.to_owned(), None)
}
ChangeKind::Install | ChangeKind::Update => {
if !entry.valid {
return rejected("invalid_skill");
}
let (id, tree) = if let Some(id) = change
.reference
.strip_prefix("imported:")
.filter(|id| maka_skills::safe_source_id(id))
{
let managed = self.managed.as_ref().ok_or("skills_store_unavailable")?;
(
id.to_owned(),
Tree::read_view(
&managed.imported_read.bind_scope(scope),
&format!("skills/{id}"),
)
.await
.map_err(store_error)?,
)
} else {
let Some((id, root)) = self.sources.iter().find_map(|source| {
change
.reference
.strip_prefix(&format!("{}:", source.prefix))
.filter(|id| maka_skills::safe_source_id(id))
.map(|id| (id, source))
}) else {
return rejected("read_only_source");
};
(
id.to_owned(),
Tree::read_view(
&root.root.bind_scope(scope),
&root.directory.join(id).to_string_lossy(),
)
.await
.map_err(store_error)?,
)
};
if tree
.get("SKILL.md")
.map(maka_agent_facts::artifact::content_digest)
.as_ref()
!= Some(digest)
{
return Ok(Prepared::Changed);
}
(id.to_owned(), Some(tree))
}
};
// Match the original installation catalog's case-insensitive occupancy
// rule on both case-sensitive and case-insensitive filesystems.
if matches!(change.kind, ChangeKind::Install)
&& snapshot.entries.iter().any(|(entry, _, _)| {
entry
.reference
.strip_prefix("installed:")
.is_some_and(|installed| installed.eq_ignore_ascii_case(&id))
})
{
return rejected("already_installed");
}
let expected = publisher
.capture(&id, &scope.cancellation)
.await
.map_err(store_error)?;
if let Some(tree) = &expected {
let current = snapshot
.entries
.iter()
.find(|(entry, _, _)| entry.reference == format!("{}{id}", collection.prefix()));
if current.is_none_or(|(_, digest, _)| {
tree.get("SKILL.md")
.map(maka_agent_facts::artifact::content_digest)
.as_ref()
!= Some(digest)
}) {
return Ok(Prepared::Changed);
}
}
match change.kind {
ChangeKind::Install if expected.is_some() => return rejected("already_installed"),
ChangeKind::Update | ChangeKind::Delete if expected.is_none() => {
return rejected("not_installed");
}
_ => {}
}
let locally_modified = collection == Collection::Installed
&& expected
.as_ref()
.is_some_and(|tree| tree.get("SKILL.md") != tree.get(".maka/baseline/SKILL.md"));
let digest = maka_agent_facts::artifact::content_digest(
&serde_json::to_vec(&(
&change,
expected
.as_ref()
.map(Tree::digest)
.transpose()
.map_err(store_error)?,
source
.as_ref()
.map(Tree::digest)
.transpose()
.map_err(store_error)?,
))
.map_err(store_error)?,
);
let next = match (&change.kind, source) {
(ChangeKind::Delete, _) => None,
(_, Some(source)) => {
let content = source
.get("SKILL.md")
.ok_or("skills_source_unavailable")?
.to_vec();
let mut tree = if matches!(change.kind, ChangeKind::Update) {
expected.as_ref().unwrap().clone()
} else {
source
};
publication::set_installation(
&mut tree,
&id,
&id,
maka_skills::api::InstallSource::Managed,
content,
)
.map_err(store_error)?;
Some(tree)
}
_ => unreachable!("installation source was captured"),
};
Ok(Prepared::Ready(Box::new(Plan {
review: Review { change, digest },
id,
expected,
next,
locally_modified,
collection,
})))
}
pub(super) async fn preview(&self, input: Change, scope: &Scope) -> Result<Preview, String> {
let Some(managed) = &self.managed else {
return Ok(Preview::Rejected {
reason: "management_disabled".into(),
});
};
let _serial = tokio::select! {
biased;
_ = scope.cancellation.cancelled() => return Err("skills_cancelled".into()),
guard = managed.serial.lock() => guard,
};
let collection = if matches!(input.kind, ChangeKind::Delete)
&& input.reference.starts_with("imported:")
{
Collection::Imported
} else {
Collection::Installed
};
let publisher = managed.publisher(scope, collection).await?;
Ok(
match self.prepare(input, &publisher, collection, scope).await? {
Prepared::Ready(plan) => plan.preview(),
Prepared::Changed => Preview::Changed,
Prepared::Rejected(reason) => Preview::Rejected {
reason: reason.into(),
},
},
)
}
pub(super) async fn apply(
&self,
input: Review,
reference: &maka_plugin_core::operation::RecoveryRef,
scope: &Scope,
) -> Result<ChangeResult, String> {
let operation = operation(reference)?;
self.apply_operation(input, &operation, scope).await
}
async fn apply_operation(
&self,
input: Review,
operation: &Operation,
scope: &Scope,
) -> Result<ChangeResult, String> {
self.apply_in(input, operation, scope, Collection::Installed)
.await
}
pub(super) async fn delete_source(
&self,
input: Review,
operation: &Operation,
scope: &Scope,
) -> Result<ChangeResult, String> {
self.apply_in(input, operation, scope, Collection::Imported)
.await
}
async fn apply_in(
&self,
input: Review,
operation: &Operation,
scope: &Scope,
collection: Collection,
) -> Result<ChangeResult, String> {
let Some(managed) = &self.managed else {
return Ok(ChangeResult::Rejected {
reason: "management_disabled".into(),
});
};
let _serial = tokio::select! {
biased;
_ = scope.cancellation.cancelled() => return Err("skills_cancelled".into()),
guard = managed.serial.lock() => guard,
};
let publisher = managed.publisher(scope, collection).await?;
if let Some(outcome) = publisher.reserve(operation).await.map_err(store_error)? {
return recorded(outcome);
}
publisher.recover().await.map_err(store_error)?;
if let Some(outcome) = publisher.reserve(operation).await.map_err(store_error)? {
return recorded(outcome);
}
let prepared = self
.prepare(input.change, &publisher, collection, scope)
.await?;
let outcome = match prepared {
Prepared::Ready(plan) if plan.review.digest == input.digest => {
let destination = collection.destination(&plan.id);
match publisher
.publish_operation(
&plan.id,
plan.expected.as_ref(),
plan.next.as_ref(),
Some((operation, destination.clone())),
&scope.cancellation,
)
.await
{
Ok(()) => Outcome::Applied { destination },
Err(publication::Error::Conflict) => Outcome::Conflict,
Err(error) => return Err(store_error(error)),
}
}
Prepared::Changed | Prepared::Ready(_) => Outcome::Conflict,
Prepared::Rejected(reason) => Outcome::Rejected {
reason: reason.into(),
},
};
publisher
.complete(operation, outcome.clone())
.await
.map_err(store_error)?;
recorded(outcome)
}
}
pub(super) fn operation(
reference: &maka_plugin_core::operation::RecoveryRef,
) -> Result<Operation, String> {
Ok(Operation {
id: reference.operation,
// Preserve the exact Host fingerprint in the publication domain’s digest format.
fingerprint: maka_agent_facts::artifact::content_digest(reference.fingerprint.as_bytes()),
binding: maka_agent_facts::artifact::content_digest(
&serde_json::to_vec(reference).map_err(store_error)?,
),
})
}
pub(super) fn recorded(outcome: Outcome) -> Result<ChangeResult, String> {
match outcome {
Outcome::Applied {
destination: Destination::Skill { reference },
} => Ok(ChangeResult::Applied { reference }),
Outcome::Applied {
destination: Destination::Source { id },
} => Ok(ChangeResult::Applied {
reference: format!("imported:{id}"),
}),
Outcome::Conflict => Ok(ChangeResult::Changed),
Outcome::Rejected { reason } => Ok(ChangeResult::Rejected { reason }),
_ => Err("skills_receipt_invalid".into()),
}
}
#[cfg(test)]
mod tests;