artifacts!
This commit is contained in:
@@ -582,6 +582,13 @@ pub struct Config {
|
||||
#[serde(default = "default_runtime_envs_dir")]
|
||||
pub runtime_envs_dir: String,
|
||||
|
||||
/// Artifacts directory (shared volume for file-based artifact storage).
|
||||
/// File-type artifacts (FileBinary, FileDatatable, FileText, Log) are stored
|
||||
/// on disk at this location rather than in the database.
|
||||
/// Pattern: {artifacts_dir}/{ref_slug}/v{version}.{ext}
|
||||
#[serde(default = "default_artifacts_dir")]
|
||||
pub artifacts_dir: String,
|
||||
|
||||
/// Notifier configuration (optional, for notifier service)
|
||||
pub notifier: Option<NotifierConfig>,
|
||||
|
||||
@@ -609,6 +616,10 @@ fn default_runtime_envs_dir() -> String {
|
||||
"/opt/attune/runtime_envs".to_string()
|
||||
}
|
||||
|
||||
fn default_artifacts_dir() -> String {
|
||||
"/opt/attune/artifacts".to_string()
|
||||
}
|
||||
|
||||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -844,6 +855,7 @@ mod tests {
|
||||
sensor: None,
|
||||
packs_base_dir: default_packs_base_dir(),
|
||||
runtime_envs_dir: default_runtime_envs_dir(),
|
||||
artifacts_dir: default_artifacts_dir(),
|
||||
notifier: None,
|
||||
pack_registry: PackRegistryConfig::default(),
|
||||
executor: None,
|
||||
@@ -917,6 +929,7 @@ mod tests {
|
||||
sensor: None,
|
||||
packs_base_dir: default_packs_base_dir(),
|
||||
runtime_envs_dir: default_runtime_envs_dir(),
|
||||
artifacts_dir: default_artifacts_dir(),
|
||||
notifier: None,
|
||||
pack_registry: PackRegistryConfig::default(),
|
||||
executor: None,
|
||||
|
||||
@@ -367,6 +367,24 @@ pub mod enums {
|
||||
Minutes,
|
||||
}
|
||||
|
||||
/// Visibility level for artifacts.
|
||||
/// - `Public`: viewable by all authenticated users on the platform.
|
||||
/// - `Private`: restricted based on the artifact's `scope` and `owner` fields.
|
||||
/// Full RBAC enforcement is deferred; for now the field enables filtering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, ToSchema)]
|
||||
#[sqlx(type_name = "artifact_visibility_enum", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArtifactVisibility {
|
||||
Public,
|
||||
Private,
|
||||
}
|
||||
|
||||
impl Default for ArtifactVisibility {
|
||||
fn default() -> Self {
|
||||
Self::Private
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, ToSchema)]
|
||||
#[sqlx(type_name = "workflow_task_status_enum", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -1268,6 +1286,7 @@ pub mod artifact {
|
||||
pub scope: OwnerType,
|
||||
pub owner: String,
|
||||
pub r#type: ArtifactType,
|
||||
pub visibility: ArtifactVisibility,
|
||||
pub retention_policy: RetentionPolicyType,
|
||||
pub retention_limit: i32,
|
||||
/// Human-readable name (e.g. "Build Log", "Test Results")
|
||||
@@ -1289,7 +1308,7 @@ pub mod artifact {
|
||||
/// Select columns for Artifact queries (excludes DB-only columns if any arise).
|
||||
/// Must be kept in sync with the Artifact struct field order.
|
||||
pub const SELECT_COLUMNS: &str =
|
||||
"id, ref, scope, owner, type, retention_policy, retention_limit, \
|
||||
"id, ref, scope, owner, type, visibility, retention_policy, retention_limit, \
|
||||
name, description, content_type, size_bytes, execution, data, \
|
||||
created, updated";
|
||||
}
|
||||
@@ -1314,6 +1333,10 @@ pub mod artifact_version {
|
||||
pub content: Option<Vec<u8>>,
|
||||
/// Structured JSON content
|
||||
pub content_json: Option<serde_json::Value>,
|
||||
/// Relative path from `artifacts_dir` root for disk-stored content.
|
||||
/// When set, `content` BYTEA is NULL — the file lives on a shared volume.
|
||||
/// Pattern: `{ref_slug}/v{version}.{ext}`
|
||||
pub file_path: Option<String>,
|
||||
/// Free-form metadata about this version
|
||||
pub meta: Option<serde_json::Value>,
|
||||
/// Who created this version
|
||||
@@ -1324,12 +1347,12 @@ pub mod artifact_version {
|
||||
/// Select columns WITHOUT the potentially large `content` BYTEA column.
|
||||
/// Use `SELECT_COLUMNS_WITH_CONTENT` when you need the binary payload.
|
||||
pub const SELECT_COLUMNS: &str = "id, artifact, version, content_type, size_bytes, \
|
||||
NULL::bytea AS content, content_json, meta, created_by, created";
|
||||
NULL::bytea AS content, content_json, file_path, meta, created_by, created";
|
||||
|
||||
/// Select columns INCLUDING the binary `content` column.
|
||||
pub const SELECT_COLUMNS_WITH_CONTENT: &str =
|
||||
"id, artifact, version, content_type, size_bytes, \
|
||||
content, content_json, meta, created_by, created";
|
||||
content, content_json, file_path, meta, created_by, created";
|
||||
}
|
||||
|
||||
/// Workflow orchestration models
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! with headers and payload.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -124,6 +124,17 @@ impl MessageType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a UUID, substituting a freshly-generated one when the value is
|
||||
/// null or absent. This keeps envelope parsing tolerant of messages that were
|
||||
/// hand-crafted or produced by older tooling.
|
||||
fn deserialize_uuid_default<'de, D>(deserializer: D) -> Result<Uuid, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<Uuid> = Option::deserialize(deserializer)?;
|
||||
Ok(opt.unwrap_or_else(Uuid::new_v4))
|
||||
}
|
||||
|
||||
/// Message envelope that wraps all messages with metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEnvelope<T>
|
||||
@@ -131,9 +142,17 @@ where
|
||||
T: Clone,
|
||||
{
|
||||
/// Unique message identifier
|
||||
#[serde(
|
||||
default = "Uuid::new_v4",
|
||||
deserialize_with = "deserialize_uuid_default"
|
||||
)]
|
||||
pub message_id: Uuid,
|
||||
|
||||
/// Correlation ID for tracing related messages
|
||||
#[serde(
|
||||
default = "Uuid::new_v4",
|
||||
deserialize_with = "deserialize_uuid_default"
|
||||
)]
|
||||
pub correlation_id: Uuid,
|
||||
|
||||
/// Message type
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::models::{
|
||||
artifact::*,
|
||||
artifact_version::ArtifactVersion,
|
||||
enums::{ArtifactType, OwnerType, RetentionPolicyType},
|
||||
enums::{ArtifactType, ArtifactVisibility, OwnerType, RetentionPolicyType},
|
||||
};
|
||||
use crate::Result;
|
||||
use sqlx::{Executor, Postgres, QueryBuilder};
|
||||
@@ -29,6 +29,7 @@ pub struct CreateArtifactInput {
|
||||
pub scope: OwnerType,
|
||||
pub owner: String,
|
||||
pub r#type: ArtifactType,
|
||||
pub visibility: ArtifactVisibility,
|
||||
pub retention_policy: RetentionPolicyType,
|
||||
pub retention_limit: i32,
|
||||
pub name: Option<String>,
|
||||
@@ -44,6 +45,7 @@ pub struct UpdateArtifactInput {
|
||||
pub scope: Option<OwnerType>,
|
||||
pub owner: Option<String>,
|
||||
pub r#type: Option<ArtifactType>,
|
||||
pub visibility: Option<ArtifactVisibility>,
|
||||
pub retention_policy: Option<RetentionPolicyType>,
|
||||
pub retention_limit: Option<i32>,
|
||||
pub name: Option<String>,
|
||||
@@ -59,6 +61,7 @@ pub struct ArtifactSearchFilters {
|
||||
pub scope: Option<OwnerType>,
|
||||
pub owner: Option<String>,
|
||||
pub r#type: Option<ArtifactType>,
|
||||
pub visibility: Option<ArtifactVisibility>,
|
||||
pub execution: Option<i64>,
|
||||
pub name_contains: Option<String>,
|
||||
pub limit: u32,
|
||||
@@ -127,9 +130,9 @@ impl Create for ArtifactRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"INSERT INTO artifact (ref, scope, owner, type, retention_policy, retention_limit, \
|
||||
"INSERT INTO artifact (ref, scope, owner, type, visibility, retention_policy, retention_limit, \
|
||||
name, description, content_type, execution, data) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING {}",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
@@ -138,6 +141,7 @@ impl Create for ArtifactRepository {
|
||||
.bind(input.scope)
|
||||
.bind(&input.owner)
|
||||
.bind(input.r#type)
|
||||
.bind(input.visibility)
|
||||
.bind(input.retention_policy)
|
||||
.bind(input.retention_limit)
|
||||
.bind(&input.name)
|
||||
@@ -178,6 +182,7 @@ impl Update for ArtifactRepository {
|
||||
push_field!(input.scope, "scope");
|
||||
push_field!(&input.owner, "owner");
|
||||
push_field!(input.r#type, "type");
|
||||
push_field!(input.visibility, "visibility");
|
||||
push_field!(input.retention_policy, "retention_policy");
|
||||
push_field!(input.retention_limit, "retention_limit");
|
||||
push_field!(&input.name, "name");
|
||||
@@ -241,6 +246,10 @@ impl ArtifactRepository {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("type = ${}", param_idx));
|
||||
}
|
||||
if filters.visibility.is_some() {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("visibility = ${}", param_idx));
|
||||
}
|
||||
if filters.execution.is_some() {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("execution = ${}", param_idx));
|
||||
@@ -270,6 +279,9 @@ impl ArtifactRepository {
|
||||
if let Some(r#type) = filters.r#type {
|
||||
count_query = count_query.bind(r#type);
|
||||
}
|
||||
if let Some(visibility) = filters.visibility {
|
||||
count_query = count_query.bind(visibility);
|
||||
}
|
||||
if let Some(execution) = filters.execution {
|
||||
count_query = count_query.bind(execution);
|
||||
}
|
||||
@@ -298,6 +310,9 @@ impl ArtifactRepository {
|
||||
if let Some(r#type) = filters.r#type {
|
||||
data_query = data_query.bind(r#type);
|
||||
}
|
||||
if let Some(visibility) = filters.visibility {
|
||||
data_query = data_query.bind(visibility);
|
||||
}
|
||||
if let Some(execution) = filters.execution {
|
||||
data_query = data_query.bind(execution);
|
||||
}
|
||||
@@ -466,6 +481,21 @@ impl ArtifactRepository {
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Update the size_bytes of an artifact (used by worker finalization to sync
|
||||
/// the parent artifact's size with the latest file-based version).
|
||||
pub async fn update_size_bytes<'e, E>(executor: E, id: i64, size_bytes: i64) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result =
|
||||
sqlx::query("UPDATE artifact SET size_bytes = $1, updated = NOW() WHERE id = $2")
|
||||
.bind(size_bytes)
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -489,6 +519,7 @@ pub struct CreateArtifactVersionInput {
|
||||
pub content_type: Option<String>,
|
||||
pub content: Option<Vec<u8>>,
|
||||
pub content_json: Option<serde_json::Value>,
|
||||
pub file_path: Option<String>,
|
||||
pub meta: Option<serde_json::Value>,
|
||||
pub created_by: Option<String>,
|
||||
}
|
||||
@@ -646,8 +677,8 @@ impl ArtifactVersionRepository {
|
||||
|
||||
let query = format!(
|
||||
"INSERT INTO artifact_version \
|
||||
(artifact, version, content_type, size_bytes, content, content_json, meta, created_by) \
|
||||
VALUES ($1, next_artifact_version($1), $2, $3, $4, $5, $6, $7) \
|
||||
(artifact, version, content_type, size_bytes, content, content_json, file_path, meta, created_by) \
|
||||
VALUES ($1, next_artifact_version($1), $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING {}",
|
||||
artifact_version::SELECT_COLUMNS_WITH_CONTENT
|
||||
);
|
||||
@@ -657,6 +688,7 @@ impl ArtifactVersionRepository {
|
||||
.bind(size_bytes)
|
||||
.bind(&input.content)
|
||||
.bind(&input.content_json)
|
||||
.bind(&input.file_path)
|
||||
.bind(&input.meta)
|
||||
.bind(&input.created_by)
|
||||
.fetch_one(executor)
|
||||
@@ -699,4 +731,67 @@ impl ArtifactVersionRepository {
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Update the size_bytes of a specific artifact version (used by worker finalization).
|
||||
pub async fn update_size_bytes<'e, E>(
|
||||
executor: E,
|
||||
version_id: i64,
|
||||
size_bytes: i64,
|
||||
) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query("UPDATE artifact_version SET size_bytes = $1 WHERE id = $2")
|
||||
.bind(size_bytes)
|
||||
.bind(version_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Find all file-backed versions linked to an execution.
|
||||
/// Joins artifact_version → artifact on artifact.execution to find all
|
||||
/// file-based versions produced by a given execution.
|
||||
pub async fn find_file_versions_by_execution<'e, E>(
|
||||
executor: E,
|
||||
execution_id: i64,
|
||||
) -> Result<Vec<ArtifactVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"SELECT av.{} \
|
||||
FROM artifact_version av \
|
||||
JOIN artifact a ON av.artifact = a.id \
|
||||
WHERE a.execution = $1 AND av.file_path IS NOT NULL",
|
||||
artifact_version::SELECT_COLUMNS
|
||||
.split(", ")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", av.")
|
||||
);
|
||||
sqlx::query_as::<_, ArtifactVersion>(&query)
|
||||
.bind(execution_id)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Find all file-backed versions for a specific artifact (used for disk cleanup on delete).
|
||||
pub async fn find_file_versions_by_artifact<'e, E>(
|
||||
executor: E,
|
||||
artifact_id: i64,
|
||||
) -> Result<Vec<ArtifactVersion>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"SELECT {} FROM artifact_version WHERE artifact = $1 AND file_path IS NOT NULL",
|
||||
artifact_version::SELECT_COLUMNS
|
||||
);
|
||||
sqlx::query_as::<_, ArtifactVersion>(&query)
|
||||
.bind(artifact_id)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Tests cover CRUD operations, specialized queries, constraints,
|
||||
//! enum handling, timestamps, and edge cases.
|
||||
|
||||
use attune_common::models::enums::{ArtifactType, OwnerType, RetentionPolicyType};
|
||||
use attune_common::models::enums::{
|
||||
ArtifactType, ArtifactVisibility, OwnerType, RetentionPolicyType,
|
||||
};
|
||||
use attune_common::repositories::artifact::{
|
||||
ArtifactRepository, CreateArtifactInput, UpdateArtifactInput,
|
||||
};
|
||||
@@ -65,6 +67,7 @@ impl ArtifactFixture {
|
||||
scope: OwnerType::System,
|
||||
owner: self.unique_owner("system"),
|
||||
r#type: ArtifactType::FileText,
|
||||
visibility: ArtifactVisibility::default(),
|
||||
retention_policy: RetentionPolicyType::Versions,
|
||||
retention_limit: 5,
|
||||
name: None,
|
||||
@@ -252,6 +255,7 @@ async fn test_update_artifact_all_fields() {
|
||||
scope: Some(OwnerType::Identity),
|
||||
owner: Some(fixture.unique_owner("identity")),
|
||||
r#type: Some(ArtifactType::FileImage),
|
||||
visibility: Some(ArtifactVisibility::Public),
|
||||
retention_policy: Some(RetentionPolicyType::Days),
|
||||
retention_limit: Some(30),
|
||||
name: Some("Updated Name".to_string()),
|
||||
|
||||
Reference in New Issue
Block a user