use std::sync::Arc;
use aion_core::{Event, WorkflowId};
use aion_proto::WireError;
use super::error::workflow_not_found_error;
use crate::awl::deployed::DeployedDocument;
use crate::{CallerIdentity, NamespaceGuard, NamespaceOperation, ServerError, WorkflowTarget};
pub const RUN_PACKAGE_NOT_RECORDED: &str = "RunPackageNotRecorded";
#[derive(Clone)]
pub struct RunDocumentAccess {
pub namespace: String,
pub workflow_type: String,
pub content_hash: String,
engine: Arc<aion::Engine>,
}
impl std::fmt::Debug for RunDocumentAccess {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RunDocumentAccess")
.field("namespace", &self.namespace)
.field("workflow_type", &self.workflow_type)
.field("content_hash", &self.content_hash)
.finish_non_exhaustive()
}
}
impl RunDocumentAccess {
pub async fn read(&self) -> Result<DeployedDocument, WireError> {
crate::awl::deployed::read_document(&self.engine, &self.workflow_type, &self.content_hash)
.await
.map_err(|error| error.to_wire_error())
}
}
pub async fn authorize_run_document(
guard: &NamespaceGuard,
caller: &CallerIdentity,
namespace: &str,
workflow_id: &WorkflowId,
content_hash: &str,
) -> Result<RunDocumentAccess, WireError> {
require_canonical_hash(content_hash)?;
let target = WorkflowTarget::workflow(workflow_id);
let scoped = guard
.scope(
caller,
&NamespaceOperation::read_document(namespace, target),
)
.await
.map_err(|error| error.to_wire_error())?;
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let history = engine
.store()
.read_history(workflow_id)
.await
.map_err(|error| ServerError::from(error).to_wire_error())?;
if history.is_empty() {
return Err(workflow_not_found_error(workflow_id));
}
let workflow_type = generation_started_under(&history, content_hash).ok_or_else(|| {
WireError::not_found_with_type(
RUN_PACKAGE_NOT_RECORDED,
format!(
"workflow {workflow_id} recorded no generation started under package \
{content_hash}"
),
)
})?;
Ok(RunDocumentAccess {
namespace: scoped.namespace().to_owned(),
workflow_type,
content_hash: content_hash.to_owned(),
engine: std::sync::Arc::clone(engine),
})
}
fn generation_started_under(history: &[Event], content_hash: &str) -> Option<String> {
history.iter().rev().find_map(|event| match event {
Event::WorkflowStarted {
workflow_type,
package_version,
..
} if package_version.as_str() == content_hash => Some(workflow_type.clone()),
_ => None,
})
}
fn require_canonical_hash(content_hash: &str) -> Result<(), WireError> {
let canonical = content_hash.len() == 64
&& content_hash
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'));
if canonical {
Ok(())
} else {
Err(WireError::invalid_input(
"content_hash must be the package's 64-character lowercase hexadecimal content \
hash, as `package_version` on the run's summary carries it",
))
}
}
#[cfg(test)]
mod tests {
use aion_core::{Event, PackageVersion, RunId};
use aion_package::AwlSource;
use aion_proto::WireErrorCode;
use aion_store::WriteToken;
use super::super::test_support::{
NAMESPACE, append_started, context, event_envelope, payload, workflow_id,
};
use super::{RUN_PACKAGE_NOT_RECORDED, authorize_run_document};
use crate::awl::deployed::fixtures::{DOCUMENT, manifest, record};
const STARTED_HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[tokio::test]
async fn a_scoped_caller_reads_the_document_of_the_hash_the_run_recorded()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let row = record(
manifest("fixture"),
Some(AwlSource::new(
"fixture.awl",
DOCUMENT,
std::iter::empty::<(String, Vec<u8>)>(),
)),
1_700_000_000,
)?;
let hash = row.content_hash.clone();
context.store.put_package(row).await?;
context
.store
.append(
WriteToken::recorder(),
&workflow_id(),
&[Event::WorkflowStarted {
envelope: event_envelope(1),
workflow_type: "fixture".to_owned(),
input: payload()?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new(hash.clone()),
}],
0,
)
.await?;
let access = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
&hash,
)
.await?;
assert_eq!(access.namespace, NAMESPACE);
assert_eq!(access.workflow_type, "fixture");
let document = access.read().await?;
assert_eq!(document.content_hash, hash);
assert_eq!(document.workflow_type, "fixture");
assert_eq!(document.source, DOCUMENT);
Ok(())
}
#[tokio::test]
async fn each_hash_resolves_to_the_latest_generation_that_recorded_it()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let hash_a = "a".repeat(64);
let hash_b = "b".repeat(64);
let started = |seq: u64,
workflow_type: &str,
hash: &str|
-> Result<Event, Box<dyn std::error::Error>> {
Ok(Event::WorkflowStarted {
envelope: event_envelope(seq),
workflow_type: workflow_type.to_owned(),
input: payload()?,
run_id: RunId::new(uuid::Uuid::from_u128(u128::from(seq))),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new(hash.to_owned()),
})
};
context
.store
.append(
WriteToken::recorder(),
&workflow_id(),
&[
started(1, "fixture", &hash_a)?,
started(2, "fixture_v2", &hash_b)?,
started(3, "fixture_renamed", &hash_a)?,
],
0,
)
.await?;
let under_b = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
&hash_b,
)
.await?;
assert_eq!(under_b.workflow_type, "fixture_v2");
let under_a = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
&hash_a,
)
.await?;
assert_eq!(
under_a.workflow_type, "fixture_renamed",
"the LATEST generation that recorded the hash labels the answer"
);
Ok(())
}
#[tokio::test]
async fn a_hash_the_workflow_never_started_under_is_not_found_by_its_own_type()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_started(context.store.as_ref()).await?;
let foreign = record(
manifest("fixture"),
Some(AwlSource::new(
"fixture.awl",
DOCUMENT,
std::iter::empty::<(String, Vec<u8>)>(),
)),
1_700_000_000,
)?;
let foreign_hash = foreign.content_hash.clone();
context.store.put_package(foreign).await?;
let error = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
&foreign_hash,
)
.await
.err()
.ok_or("a hash the run never recorded must be refused")?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some(RUN_PACKAGE_NOT_RECORDED));
Ok(())
}
#[tokio::test]
async fn a_recorded_hash_with_no_persisted_archive_is_the_archive_reader_s_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_started(context.store.as_ref()).await?;
let access = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
STARTED_HASH,
)
.await?;
let error = access
.read()
.await
.err()
.ok_or("an unpersisted archive must be refused")?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("DeployedVersionNotFound"));
Ok(())
}
#[tokio::test]
async fn a_malformed_hash_is_refused_before_any_read() -> Result<(), Box<dyn std::error::Error>>
{
let context = context().await?;
for malformed in ["", "abc", &"A".repeat(64), &"g".repeat(64), &"a".repeat(63)] {
let error = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
malformed,
)
.await
.err()
.ok_or_else(|| format!("{malformed:?} must be refused"))?;
assert_eq!(error.code, WireErrorCode::InvalidInput, "{malformed:?}");
}
Ok(())
}
#[tokio::test]
async fn an_unowned_workflow_is_refused_by_the_namespace_guard()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), "tenant-b")?;
append_started(context.store.as_ref()).await?;
let error = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
STARTED_HASH,
)
.await
.err()
.ok_or("a foreign workflow must be refused")?;
assert_eq!(error.code, WireErrorCode::NotFound, "{error:?}");
assert_eq!(error.error_type, None, "{error:?}");
assert!(
error.message.contains("not found in namespace tenant-a"),
"{error:?}"
);
Ok(())
}
#[tokio::test]
async fn an_unknown_workflow_is_workflow_not_found() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let error = authorize_run_document(
&context.guard,
&context.caller,
NAMESPACE,
&workflow_id(),
STARTED_HASH,
)
.await
.err()
.ok_or("a workflow with no history must be not found")?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
Ok(())
}
}