feat: add Workspace Plugin catalog and immutable artifact authority
This commit is contained in:
@@ -60,3 +60,7 @@ required-features = ["typescript"]
|
||||
[[example]]
|
||||
name = "generate_drive_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_plugin_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::plugin_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,12 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
|
||||
pub mod authorization;
|
||||
mod drive;
|
||||
mod notification;
|
||||
mod plugin;
|
||||
mod plugin_types;
|
||||
pub use notification::*;
|
||||
pub use plugin::*;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub use plugin_types::plugin_api_typescript;
|
||||
mod ticket_browse;
|
||||
pub use ticket_browse::*;
|
||||
pub mod repository_openapi_typescript;
|
||||
@@ -205,6 +210,15 @@ macro_rules! impl_openapi_schema {
|
||||
}
|
||||
|
||||
impl_openapi_schema!(
|
||||
PluginInstallRequest,
|
||||
PluginAddArtifactRequest,
|
||||
PluginRevokeRequest,
|
||||
PluginUninstallRequest,
|
||||
PluginListRequest,
|
||||
PluginUploadResponse,
|
||||
PluginInstallation,
|
||||
PluginListResponse,
|
||||
PluginOperationResponse,
|
||||
SubjectPermissionsResponse,
|
||||
SubjectPermissionsMutationResponse,
|
||||
SubjectPermissionInterruption,
|
||||
@@ -2890,6 +2904,73 @@ pub trait ServerApi {
|
||||
#[path] working_directory_id: String,
|
||||
#[body] request: WorkingDirectoryRemovalRequest,
|
||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
#[post("/api/w/{workspace_id}/plugin-uploads", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_upload(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[binary] body: BinaryBody,
|
||||
) -> Result<PluginUploadResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_install(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: PluginInstallRequest,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn plugin_list(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: PluginListRequest,
|
||||
) -> Result<PluginListResponse, RepositoryApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/plugins/{installation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn plugin_show(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] installation_id: String,
|
||||
) -> Result<PluginInstallation, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/plugins/{installation_id}/artifacts", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_artifact_add(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] installation_id: String,
|
||||
#[body] request: PluginAddArtifactRequest,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/plugins/{installation_id}/revoke", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_revoke(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] installation_id: String,
|
||||
#[body] request: PluginRevokeRequest,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/plugins/{installation_id}/uninstall", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_uninstall(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] installation_id: String,
|
||||
#[body] request: PluginUninstallRequest,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn plugin_operation(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] operation_id: String,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
// Ordinary operations accept member browser sessions or trusted Worker request
|
||||
// context. The service resolves Worker grants; no operation accepts an actor or
|
||||
// grant ID from the caller. Grant management is Workspace-owner-only.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub upload_id: String,
|
||||
pub alias: String,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginAddArtifactRequest {
|
||||
pub upload_id: String,
|
||||
pub expected_last_operation: String,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginRevokeRequest {
|
||||
pub artifact_digest: String,
|
||||
pub expected_last_operation: String,
|
||||
pub reason: String,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginUninstallRequest {
|
||||
pub expected_last_operation: String,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginListRequest {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSchemaContribution {
|
||||
pub identity: String,
|
||||
pub version: String,
|
||||
pub digest: String,
|
||||
pub namespace: String,
|
||||
pub dcdl: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginToolMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
pub input_schema: serde_json::Value,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
pub output_schema: serde_json::Value,
|
||||
pub error_codes: Vec<String>,
|
||||
pub external_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum PluginHttpMethod {
|
||||
Get,
|
||||
Head,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Options,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginConnectionMetadata {
|
||||
pub name: String,
|
||||
pub methods: Vec<PluginHttpMethod>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginGrantMetadata {
|
||||
Logging,
|
||||
Https { slot: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginArtifact {
|
||||
pub package_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub artifact_size: u32,
|
||||
pub schema_version: u32,
|
||||
pub world: String,
|
||||
pub required_imports: Vec<String>,
|
||||
pub tools: Vec<PluginToolMetadata>,
|
||||
pub connections: Vec<PluginConnectionMetadata>,
|
||||
pub requested_grants: Vec<PluginGrantMetadata>,
|
||||
pub config_schema: PluginSchemaContribution,
|
||||
pub revoked: bool,
|
||||
pub revocation_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginUploadResponse {
|
||||
pub upload_id: String,
|
||||
pub expires_at: String,
|
||||
pub artifact: PluginArtifact,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginInstallation {
|
||||
pub installation_id: String,
|
||||
pub alias: String,
|
||||
pub package_id: String,
|
||||
pub last_operation: String,
|
||||
pub uninstalled: bool,
|
||||
pub artifacts: Vec<PluginArtifact>,
|
||||
}
|
||||
|
||||
/// Catalog pages omit artifact/schema payloads so the item bound also bounds
|
||||
/// response work. Exact immutable metadata is available from installation show.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginInstallationSummary {
|
||||
pub installation_id: String,
|
||||
pub alias: String,
|
||||
pub package_id: String,
|
||||
pub last_operation: String,
|
||||
pub uninstalled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginListResponse {
|
||||
pub installations: Vec<PluginInstallationSummary>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginOperationKind {
|
||||
Install,
|
||||
AddArtifact,
|
||||
Revoke,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginOperationResponse {
|
||||
pub operation_id: String,
|
||||
pub kind: PluginOperationKind,
|
||||
pub installation: PluginInstallation,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Generated management types share the exact DTOs used by the HTTP declaration.
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn plugin_api_typescript() -> String {
|
||||
use crate::*;
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
PluginInstallRequest::decl(&config),
|
||||
PluginAddArtifactRequest::decl(&config),
|
||||
PluginRevokeRequest::decl(&config),
|
||||
PluginUninstallRequest::decl(&config),
|
||||
PluginListRequest::decl(&config),
|
||||
PluginSchemaContribution::decl(&config),
|
||||
PluginToolMetadata::decl(&config),
|
||||
PluginHttpMethod::decl(&config),
|
||||
PluginConnectionMetadata::decl(&config),
|
||||
PluginGrantMetadata::decl(&config),
|
||||
PluginArtifact::decl(&config),
|
||||
PluginUploadResponse::decl(&config),
|
||||
PluginInstallation::decl(&config),
|
||||
PluginInstallationSummary::decl(&config),
|
||||
PluginListResponse::decl(&config),
|
||||
PluginOperationKind::decl(&config),
|
||||
PluginOperationResponse::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n",
|
||||
declarations.map(|d| format!("export {d}")).join("\n\n")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn plugin_routes_use_binary_upload_and_closed_management_contracts() {
|
||||
let document = crate::canonical_openapi_document().unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||
let upload = &value["paths"]["/api/w/{workspace_id}/plugin-uploads"]["post"];
|
||||
assert!(upload["requestBody"]["content"]["application/octet-stream"].is_object());
|
||||
assert!(upload["requestBody"]["content"]["application/json"].is_null());
|
||||
for path in [
|
||||
"plugins",
|
||||
"plugins/{installation_id}",
|
||||
"plugin-operations/{operation_id}",
|
||||
] {
|
||||
assert!(value["paths"][format!("/api/w/{{workspace_id}}/{path}")]["get"].is_object());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<crate::PluginInstallRequest>(serde_json::json!({
|
||||
"upload_id":"x", "alias":"a", "operation_key":"k", "role":"owner"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/enable"].is_null());
|
||||
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/data"].is_null());
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
#[test]
|
||||
fn generated_plugin_types_are_current() {
|
||||
assert_eq!(
|
||||
crate::normalized_typescript(super::plugin_api_typescript()),
|
||||
include_str!("../../../web/workspace/src/lib/generated/plugin-api.ts")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into())
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
|
||||
@@ -368,10 +368,13 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
|
||||
}
|
||||
|
||||
// Preserve seeded assignment evidence while rebuilding the actual pre-business
|
||||
// capture composition. Changing only a receipt leaves schema105 admission fences
|
||||
// capture composition. Changing only a receipt leaves current admission fences
|
||||
// and erasure guards in an otherwise legacy fixture.
|
||||
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
|
||||
assert_eq!(current_schema_version(conn)?, 105);
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations;
|
||||
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
|
||||
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("retained96.db");
|
||||
prepare_retained_schema(&path, 96);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
-- Canonical composed Workspace Server schema. New databases are created directly at this generation.
|
||||
-- Plugin catalog schema is composed by store.rs.
|
||||
-- Ticket creates its standalone schema first; replace those empty tables with the composed authority.
|
||||
DROP TABLE typed_ticket_event_attributes;
|
||||
DROP TABLE typed_ticket_event_references;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod hosts;
|
||||
pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
pub mod plugin_catalog;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
pub mod records;
|
||||
@@ -161,6 +162,8 @@ pub enum Error {
|
||||
WorkerSourceIdentity(String),
|
||||
#[error("Workspace signing identity error ({code}): {message}")]
|
||||
WorkspaceSigningIdentity { code: String, message: String },
|
||||
#[error("Plugin catalog error: {0}")]
|
||||
PluginCatalog(#[from] plugin_catalog::PluginCatalogError),
|
||||
#[error("Workspace Drive error: {0}")]
|
||||
Drive(#[from] workspace_drive::Error),
|
||||
#[error("DB execution capacity exceeded")]
|
||||
|
||||
@@ -851,11 +851,11 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
|
||||
};
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 98);
|
||||
drop(conn);
|
||||
// Compare the complete current composition, not upgraded98 against fresh105.
|
||||
// Compare the complete current composition, not upgraded98 against fresh current schema.
|
||||
let upgraded_store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let upgraded = upgraded_store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 105);
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
Ok(snapshot(conn))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
//! Workspace catalog authority. The authenticated service checks current read/manage
|
||||
//! permission before entering these methods; neither a digest nor an actor string is
|
||||
//! an authorization credential. Blob publication, retention and GC share SQLite's
|
||||
//! immediate write transaction, including across independent Server connections.
|
||||
use crate::{Error, Result, store::SqliteWorkspaceStore};
|
||||
use chrono::{Duration, Utc};
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PluginCatalogError {
|
||||
#[error("invalid Plugin catalog request")]
|
||||
Invalid,
|
||||
#[error("Plugin catalog resource not found")]
|
||||
NotFound,
|
||||
#[error("Plugin catalog operation conflicts with observed state")]
|
||||
Conflict,
|
||||
#[error("Plugin installation has retained references")]
|
||||
Referenced,
|
||||
#[error("Plugin package exceeds size limits")]
|
||||
TooLarge,
|
||||
#[error("Plugin storage quota exceeded")]
|
||||
Quota,
|
||||
#[error("Plugin artifact storage unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
impl PluginCatalogError {
|
||||
pub fn status(self) -> u16 {
|
||||
match self {
|
||||
Self::Invalid => 400,
|
||||
Self::NotFound => 404,
|
||||
Self::Conflict | Self::Referenced => 409,
|
||||
Self::TooLarge => 413,
|
||||
Self::Quota => 429,
|
||||
Self::Unavailable => 503,
|
||||
}
|
||||
}
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Invalid => "plugin_invalid",
|
||||
Self::NotFound => "plugin_not_found",
|
||||
Self::Conflict => "plugin_conflict",
|
||||
Self::Referenced => "plugin_referenced",
|
||||
Self::TooLarge => "plugin_too_large",
|
||||
Self::Quota => "plugin_quota",
|
||||
Self::Unavailable => "plugin_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_BYTES: i64 = 1024 * 1024 * 1024;
|
||||
const ACTOR_UPLOAD_BYTES: i64 = 256 * 1024 * 1024;
|
||||
const MAX_UPLOADS: i64 = 128;
|
||||
const MAX_ACTOR_UPLOADS: i64 = 32;
|
||||
const MAX_INSTALLATIONS: i64 = 128;
|
||||
const MAX_ARTIFACTS: i64 = 64;
|
||||
|
||||
pub(crate) const MIGRATION_NAME: &str =
|
||||
"Workspace Plugin catalog and immutable artifact references";
|
||||
pub(crate) fn migrate(conn: &Connection) -> Result<()> {
|
||||
let version: i64 = conn.query_row(
|
||||
"SELECT max(version) FROM __yoi_schema_migrations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if version != 105 {
|
||||
return Err(Error::Store(
|
||||
"Plugin catalog migration requires schema 105".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,?1)",
|
||||
[MIGRATION_NAME],
|
||||
)?;
|
||||
verify(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn verify(conn: &Connection) -> Result<()> {
|
||||
let expected = Connection::open_in_memory()?;
|
||||
expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
|
||||
let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")?
|
||||
.query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (name, sql) in objects {
|
||||
let actual = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[&name],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
if actual.as_deref() != Some(sql.as_str()) {
|
||||
return Err(Error::Store(
|
||||
"Plugin catalog storage schema has drifted".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fail<T>(error: PluginCatalogError) -> Result<T> {
|
||||
Err(Error::PluginCatalog(error))
|
||||
}
|
||||
fn encode<T: Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|_| PluginCatalogError::Unavailable.into())
|
||||
}
|
||||
fn decode<T: DeserializeOwned>(value: &str) -> Result<T> {
|
||||
serde_json::from_str(value).map_err(|_| PluginCatalogError::Unavailable.into())
|
||||
}
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
fn id(prefix: &str) -> String {
|
||||
format!("{prefix}-{}", uuid::Uuid::now_v7())
|
||||
}
|
||||
fn bounded(value: &str, max: usize) -> bool {
|
||||
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
|
||||
}
|
||||
fn digest(value: &str) -> bool {
|
||||
value.strip_prefix("sha256:").is_some_and(hex_digest)
|
||||
}
|
||||
fn hex_digest(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
fn workspace_exists(conn: &Connection, workspace: &str) -> Result<()> {
|
||||
if !conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1)",
|
||||
[workspace],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)? {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_identity(actor: &str, key: &str) -> Result<()> {
|
||||
if !bounded(actor, 512) || !bounded(key, 128) {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn storage<T>(result: std::io::Result<T>) -> Result<T> {
|
||||
result.map_err(|_| PluginCatalogError::Unavailable.into())
|
||||
}
|
||||
fn blob_path(root: &Path, artifact: &str) -> Result<PathBuf> {
|
||||
if !digest(artifact) {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
Ok(root.join("blobs").join(&artifact[7..]))
|
||||
}
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
fn read_blob(root: &Path, artifact: &str, size: u64) -> Result<Vec<u8>> {
|
||||
let path = blob_path(root, artifact)?;
|
||||
let metadata = storage(fs::symlink_metadata(&path))?;
|
||||
if !metadata.is_file()
|
||||
|| metadata.len() != size
|
||||
|| size > PluginPackageLimits::default().max_package_size_bytes
|
||||
{
|
||||
return fail(PluginCatalogError::Unavailable);
|
||||
}
|
||||
let file = storage(fs::File::open(path))?;
|
||||
let mut bytes = Vec::new();
|
||||
storage(file.take(size + 1).read_to_end(&mut bytes))?;
|
||||
if bytes.len() as u64 != size || format!("sha256:{}", hash(&bytes)) != artifact {
|
||||
return fail(PluginCatalogError::Unavailable);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
// Caller holds SQLite write ownership until the upload reference commits. GC
|
||||
// cannot unlink a candidate between publication and the first durable reference.
|
||||
fn publish_blob(root: &Path, artifact: &str, bytes: &[u8]) -> Result<()> {
|
||||
let path = blob_path(root, artifact)?;
|
||||
storage(fs::create_dir_all(root.join("blobs")))?;
|
||||
if path.exists() {
|
||||
read_blob(root, artifact, bytes.len() as u64)?;
|
||||
return Ok(());
|
||||
}
|
||||
let pending = PendingFile(root.join("blobs").join(id(".pending")));
|
||||
let mut file = storage(
|
||||
fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&pending.0),
|
||||
)?;
|
||||
storage(file.write_all(bytes))?;
|
||||
storage(file.sync_all())?;
|
||||
match fs::hard_link(&pending.0, &path) {
|
||||
Ok(()) => (),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
read_blob(root, artifact, bytes.len() as u64)?;
|
||||
}
|
||||
Err(_) => return fail(PluginCatalogError::Unavailable),
|
||||
}
|
||||
storage(fs::File::open(root.join("blobs")))?
|
||||
.sync_all()
|
||||
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Unavailable))?;
|
||||
Ok(())
|
||||
}
|
||||
fn artifact_metadata(
|
||||
package: &plugin::MaterializedPluginPackage,
|
||||
canonical: &plugin::CanonicalPluginArtifact,
|
||||
) -> Result<PluginArtifact> {
|
||||
let m = &package.package.manifest;
|
||||
let schema_digest = format!("sha256:{}", hash(m.config_schema.as_bytes()));
|
||||
Ok(PluginArtifact {
|
||||
package_id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
package_digest: canonical.package_digest.clone(),
|
||||
artifact_digest: canonical.artifact_digest.clone(),
|
||||
artifact_size: u32::try_from(canonical.artifact_size)
|
||||
.map_err(|_| PluginCatalogError::TooLarge)?,
|
||||
schema_version: m.schema_version,
|
||||
world: m.runtime.world.clone(),
|
||||
required_imports: m.runtime.required_imports.clone(),
|
||||
tools: m
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| PluginToolMetadata {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
input_schema: t.input_schema.clone(),
|
||||
output_schema: t.output_schema.clone(),
|
||||
error_codes: t.error_codes.clone(),
|
||||
external_write: t.external_write,
|
||||
})
|
||||
.collect(),
|
||||
connections: decode(&encode(&m.connections)?)?,
|
||||
requested_grants: decode(&encode(&m.requested_grants)?)?,
|
||||
config_schema: PluginSchemaContribution {
|
||||
identity: format!("{}/config", m.id),
|
||||
version: m.version.clone(),
|
||||
namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest),
|
||||
digest: schema_digest,
|
||||
dcdl: m.config_schema.clone(),
|
||||
},
|
||||
revoked: false,
|
||||
revocation_reason: None,
|
||||
})
|
||||
}
|
||||
fn load_artifact(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
artifact: &str,
|
||||
) -> Result<PluginArtifact> {
|
||||
let row=conn.query_row("SELECT metadata_json,revoked,revocation_reason FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,artifact],|r|Ok((r.get::<_,String>(0)?,r.get::<_,bool>(1)?,r.get::<_,Option<String>>(2)?))).optional()?;
|
||||
let Some((json, revoked, reason)) = row else {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
};
|
||||
let mut result: PluginArtifact = decode(&json)?;
|
||||
result.revoked = revoked;
|
||||
result.revocation_reason = reason;
|
||||
Ok(result)
|
||||
}
|
||||
fn load_installation(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
) -> Result<PluginInstallation> {
|
||||
let row=conn.query_row("SELECT alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,bool>(3)?))).optional()?;
|
||||
let Some((alias, package_id, last_operation, uninstalled)) = row else {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
};
|
||||
let digests=conn.prepare("SELECT artifact_digest FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 ORDER BY artifact_digest")?.query_map(params![workspace,installation],|r|r.get::<_,String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let artifacts = digests
|
||||
.iter()
|
||||
.map(|d| load_artifact(conn, workspace, installation, d))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(PluginInstallation {
|
||||
installation_id: installation.into(),
|
||||
alias,
|
||||
package_id,
|
||||
last_operation,
|
||||
uninstalled,
|
||||
artifacts,
|
||||
})
|
||||
}
|
||||
fn compare_installation(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
expected: &str,
|
||||
) -> Result<PluginInstallation> {
|
||||
let current = load_installation(conn, workspace, installation)?;
|
||||
if current.uninstalled {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
}
|
||||
if current.last_operation != expected {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
fn load_upload(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
upload: &str,
|
||||
) -> Result<(PluginArtifact, i64)> {
|
||||
let row=conn.query_row("SELECT metadata_json,expires_at FROM plugin_uploads WHERE workspace_id=?1 AND actor=?2 AND upload_id=?3",params![workspace,actor,upload],|r|Ok((r.get::<_,String>(0)?,r.get::<_,i64>(1)?))).optional()?;
|
||||
let Some((json, expires)) = row else {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
};
|
||||
Ok((decode(&json)?, expires))
|
||||
}
|
||||
fn kind_name(kind: PluginOperationKind) -> &'static str {
|
||||
match kind {
|
||||
PluginOperationKind::Install => "install",
|
||||
PluginOperationKind::AddArtifact => "add_artifact",
|
||||
PluginOperationKind::Revoke => "revoke",
|
||||
PluginOperationKind::Uninstall => "uninstall",
|
||||
}
|
||||
}
|
||||
fn replay(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
kind: PluginOperationKind,
|
||||
key: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<PluginOperationResponse>> {
|
||||
let saved=conn.query_row("SELECT fingerprint,result_json FROM plugin_operations WHERE workspace_id=?1 AND actor=?2 AND kind=?3 AND operation_key=?4",params![workspace,actor,kind_name(kind),key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()?;
|
||||
if let Some((old, json)) = saved {
|
||||
if old != fingerprint {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
return Ok(Some(decode(&json)?));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn save_operation(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
key: &str,
|
||||
fingerprint: &str,
|
||||
kind: PluginOperationKind,
|
||||
operation: &str,
|
||||
installation: &str,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
tx.execute("UPDATE plugin_installations SET last_operation=?3 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation,operation])?;
|
||||
let result = PluginOperationResponse {
|
||||
operation_id: operation.into(),
|
||||
kind,
|
||||
installation: load_installation(tx, workspace, installation)?,
|
||||
};
|
||||
tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?;
|
||||
Ok(result)
|
||||
}
|
||||
fn add_artifact(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
artifact: &PluginArtifact,
|
||||
) -> Result<()> {
|
||||
let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?;
|
||||
if let Some((old, package, revoked)) = existing {
|
||||
if old != artifact.artifact_digest || package != artifact.package_digest || revoked {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let count: i64 = tx.query_row(
|
||||
"SELECT count(*) FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2",
|
||||
params![workspace, installation],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count >= MAX_ARTIFACTS {
|
||||
return fail(PluginCatalogError::Quota);
|
||||
}
|
||||
check_workspace_bytes(tx, workspace, artifact.artifact_size as i64)?;
|
||||
tx.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,installation,artifact.artifact_digest,artifact.package_digest,artifact.version,artifact.artifact_size as i64,encode(artifact)?])?;
|
||||
Ok(())
|
||||
}
|
||||
fn check_workspace_bytes(conn: &Connection, workspace: &str, additional: i64) -> Result<()> {
|
||||
let bytes:i64=conn.query_row("SELECT (SELECT coalesce(sum(a.artifact_size),0) FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE a.workspace_id=?1 AND i.uninstalled=0) + (SELECT coalesce(sum(artifact_size),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?2)",params![workspace,Utc::now().timestamp()],|r|r.get(0))?;
|
||||
if bytes + additional > WORKSPACE_BYTES {
|
||||
return fail(PluginCatalogError::Quota);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn plugin_upload(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<PluginUploadResponse> {
|
||||
if !bounded(actor, 512) {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
let limits = PluginPackageLimits::default();
|
||||
if bytes.len() as u64 > limits.max_package_size_bytes {
|
||||
return fail(PluginCatalogError::TooLarge);
|
||||
}
|
||||
let package = plugin::read_plugin_package_bytes(bytes, PluginSourceKind::Explicit, &limits)
|
||||
.map_err(|e| {
|
||||
if e.kind == plugin::PluginDiagnosticKind::Bounds {
|
||||
PluginCatalogError::TooLarge
|
||||
} else {
|
||||
PluginCatalogError::Invalid
|
||||
}
|
||||
})?;
|
||||
let canonical = plugin::pack_plugin_package(&package, &limits)
|
||||
.map_err(|_| PluginCatalogError::Invalid)?;
|
||||
let artifact = artifact_metadata(&package, &canonical)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
workspace_exists(&tx,workspace)?;
|
||||
let now=Utc::now();
|
||||
let (count,own,own_bytes):(i64,i64,i64)=tx.query_row("SELECT count(*),coalesce(sum(actor=?2),0),coalesce(sum(CASE WHEN actor=?2 THEN artifact_size ELSE 0 END),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?3",params![workspace,actor,now.timestamp()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
|
||||
if count>=MAX_UPLOADS || own>=MAX_ACTOR_UPLOADS || own_bytes+canonical.artifact_size as i64>ACTOR_UPLOAD_BYTES {return fail(PluginCatalogError::Quota);}
|
||||
check_workspace_bytes(&tx,workspace,canonical.artifact_size as i64)?;
|
||||
publish_blob(root,&canonical.artifact_digest,&canonical.bytes)?;
|
||||
let upload_id=id("pu"); let expires=now+Duration::hours(24);
|
||||
tx.execute("INSERT INTO plugin_uploads(workspace_id,upload_id,actor,expires_at,artifact_digest,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,upload_id,actor,expires.timestamp(),artifact.artifact_digest,artifact.artifact_size as i64,encode(&artifact)?])?;
|
||||
tx.commit()?;
|
||||
Ok(PluginUploadResponse{upload_id,expires_at:expires.to_rfc3339_opts(chrono::SecondsFormat::Secs,true),artifact:artifact.clone()})
|
||||
})
|
||||
}
|
||||
pub fn plugin_install(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
request: &PluginInstallRequest,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
validate_identity(actor, &request.operation_key)?;
|
||||
if !bounded(&request.alias, 128) || request.alias.trim() != request.alias {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let (artifact,expires)=load_upload(&tx,workspace,actor,&request.upload_id)?;
|
||||
let fingerprint=hash(encode(&(request,&artifact.package_digest,&artifact.artifact_digest))?.as_bytes());
|
||||
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Install,&request.operation_key,&fingerprint)? {return Ok(saved);}
|
||||
if expires<=Utc::now().timestamp() {return fail(PluginCatalogError::NotFound);}
|
||||
let (count,alias):(i64,bool)=tx.query_row("SELECT count(*),coalesce(max(alias=?2),0) FROM plugin_installations WHERE workspace_id=?1 AND uninstalled=0",params![workspace,request.alias],|r|Ok((r.get(0)?,r.get(1)?)))?;
|
||||
if alias {return fail(PluginCatalogError::Conflict);}
|
||||
if count>=MAX_INSTALLATIONS {return fail(PluginCatalogError::Quota);}
|
||||
let installation=id("pi"); let operation=id("po");
|
||||
tx.execute("INSERT INTO plugin_installations(workspace_id,installation_id,alias,package_id,last_operation) VALUES(?1,?2,?3,?4,?5)",params![workspace,installation,request.alias,artifact.package_id,operation])?;
|
||||
add_artifact(&tx,workspace,&installation,&artifact)?;
|
||||
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Install,&operation,&installation)?;
|
||||
tx.commit()?; Ok(result)
|
||||
})
|
||||
}
|
||||
pub fn plugin_add_artifact(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &PluginAddArtifactRequest,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
validate_identity(actor, &request.operation_key)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let (artifact, expires) = load_upload(&tx, workspace, actor, &request.upload_id)?;
|
||||
let fingerprint = hash(
|
||||
encode(&(
|
||||
installation,
|
||||
request,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
))?
|
||||
.as_bytes(),
|
||||
);
|
||||
if let Some(saved) = replay(
|
||||
&tx,
|
||||
workspace,
|
||||
actor,
|
||||
PluginOperationKind::AddArtifact,
|
||||
&request.operation_key,
|
||||
&fingerprint,
|
||||
)? {
|
||||
return Ok(saved);
|
||||
}
|
||||
if expires <= Utc::now().timestamp() {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
}
|
||||
let current = compare_installation(
|
||||
&tx,
|
||||
workspace,
|
||||
installation,
|
||||
&request.expected_last_operation,
|
||||
)?;
|
||||
if current.package_id != artifact.package_id {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
add_artifact(&tx, workspace, installation, &artifact)?;
|
||||
let result = save_operation(
|
||||
&tx,
|
||||
workspace,
|
||||
actor,
|
||||
&request.operation_key,
|
||||
&fingerprint,
|
||||
PluginOperationKind::AddArtifact,
|
||||
&id("po"),
|
||||
installation,
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
pub fn plugin_revoke(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &PluginRevokeRequest,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
validate_identity(actor, &request.operation_key)?;
|
||||
if !digest(&request.artifact_digest) || !bounded(&request.reason, 1024) {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let fingerprint=hash(encode(&(installation,request))?.as_bytes());
|
||||
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Revoke,&request.operation_key,&fingerprint)? {return Ok(saved);}
|
||||
compare_installation(&tx,workspace,installation,&request.expected_last_operation)?;
|
||||
let artifact=load_artifact(&tx,workspace,installation,&request.artifact_digest)?;
|
||||
if artifact.revoked {return fail(PluginCatalogError::Conflict);}
|
||||
tx.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason=?4 WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,request.artifact_digest,request.reason])?;
|
||||
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Revoke,&id("po"),installation)?;
|
||||
tx.commit()?; Ok(result)
|
||||
})
|
||||
}
|
||||
pub fn plugin_uninstall(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &PluginUninstallRequest,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
validate_identity(actor, &request.operation_key)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let fingerprint=hash(encode(&(installation,request))?.as_bytes());
|
||||
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Uninstall,&request.operation_key,&fingerprint)? {return Ok(saved);}
|
||||
compare_installation(&tx,workspace,installation,&request.expected_last_operation)?;
|
||||
let referenced:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM plugin_references WHERE workspace_id=?1 AND installation_id=?2)",params![workspace,installation],|r|r.get(0))?;
|
||||
if referenced {return fail(PluginCatalogError::Referenced);}
|
||||
tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation])?;
|
||||
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Uninstall,&id("po"),installation)?;
|
||||
tx.commit()?; Ok(result)
|
||||
})
|
||||
}
|
||||
pub fn plugin_show(&self, workspace: &str, installation: &str) -> Result<PluginInstallation> {
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let result = load_installation(&tx, workspace, installation)?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
pub fn plugin_operation(
|
||||
&self,
|
||||
workspace: &str,
|
||||
operation: &str,
|
||||
) -> Result<PluginOperationResponse> {
|
||||
self.with_conn(|conn| {
|
||||
let json=conn.query_row("SELECT result_json FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation],|r|r.get::<_,String>(0)).optional()?;
|
||||
match json {Some(json)=>decode(&json),None=>fail(PluginCatalogError::NotFound)}
|
||||
})
|
||||
}
|
||||
pub fn plugin_list(
|
||||
&self,
|
||||
workspace: &str,
|
||||
request: &PluginListRequest,
|
||||
) -> Result<PluginListResponse> {
|
||||
let limit = request.limit.unwrap_or(50);
|
||||
if limit == 0 || limit > 100 {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let conn = &tx;
|
||||
workspace_exists(conn,workspace)?;
|
||||
let after=if let Some(cursor)=&request.cursor {
|
||||
// Server generated operation identity is an opaque cursor scoped by
|
||||
// a persisted membership check, never interpreted as an arbitrary offset.
|
||||
let value=conn.query_row("SELECT json_extract(result_json,'$.installation.installation_id') FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,cursor],|r|r.get::<_,String>(0)).optional()?;
|
||||
value.ok_or(PluginCatalogError::Invalid)?
|
||||
} else {String::new()};
|
||||
let mut installations=conn.prepare("SELECT installation_id,alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id>?2 ORDER BY installation_id LIMIT ?3")?.query_map(params![workspace,after,limit+1],|r|Ok(PluginInstallationSummary{installation_id:r.get(0)?,alias:r.get(1)?,package_id:r.get(2)?,last_operation:r.get(3)?,uninstalled:r.get(4)?}))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let more=installations.len()>limit as usize;
|
||||
installations.truncate(limit as usize);
|
||||
let next_cursor=if more {installations.last().map(|i|i.last_operation.clone())} else {None};
|
||||
tx.commit()?;
|
||||
Ok(PluginListResponse{installations,next_cursor})
|
||||
})
|
||||
}
|
||||
pub fn plugin_artifact_bytes(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
artifact_digest: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current = load_installation(&tx, workspace, installation)?;
|
||||
if current.uninstalled {
|
||||
return fail(PluginCatalogError::NotFound);
|
||||
}
|
||||
let artifact = load_artifact(&tx, workspace, installation, artifact_digest)?;
|
||||
if artifact.revoked {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
let bytes = read_blob(root, artifact_digest, u64::from(artifact.artifact_size))?;
|
||||
tx.commit()?;
|
||||
Ok(bytes)
|
||||
})
|
||||
}
|
||||
pub fn plugin_gc(&self, root: &Path) -> Result<u64> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let retained=tx.prepare("SELECT a.artifact_digest FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE i.uninstalled=0 UNION SELECT artifact_digest FROM plugin_uploads WHERE expires_at>?1")?.query_map([Utc::now().timestamp()],|r|r.get::<_,String>(0))?.collect::<rusqlite::Result<BTreeSet<_>>>()?;
|
||||
let entries=match fs::read_dir(root.join("blobs")) {Ok(entries)=>entries,Err(e) if e.kind()==std::io::ErrorKind::NotFound=>return Ok(0),Err(_)=>return fail(PluginCatalogError::Unavailable)};
|
||||
let mut removed=0;
|
||||
for entry in entries {
|
||||
let entry=storage(entry)?; let name=entry.file_name(); let Some(name)=name.to_str() else {continue;};
|
||||
if (hex_digest(name) || name.starts_with(".pending-")) && !retained.contains(&format!("sha256:{name}")) {
|
||||
storage(fs::remove_file(entry.path()))?; removed+=1;
|
||||
}
|
||||
}
|
||||
storage(fs::File::open(root.join("blobs")))?.sync_all().map_err(|_|Error::PluginCatalog(PluginCatalogError::Unavailable))?;
|
||||
tx.commit()?; Ok(removed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginReferenceKind {
|
||||
Config,
|
||||
Pending,
|
||||
Active,
|
||||
Restorable,
|
||||
}
|
||||
impl PluginReferenceKind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Config => "config",
|
||||
Self::Pending => "pending",
|
||||
Self::Active => "active",
|
||||
Self::Restorable => "restorable",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginReference {
|
||||
pub kind: PluginReferenceKind,
|
||||
pub owner: String,
|
||||
pub installation_id: String,
|
||||
pub expected_last_operation: String,
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
}
|
||||
/// Called inside the config/plan owner's transaction. The same write transaction
|
||||
/// must commit that owner's content CAS and its reference changes. This function
|
||||
/// does not persist a second copy of config or policy.
|
||||
pub fn plugin_artifact_admit(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
expected_last_operation: &str,
|
||||
package_digest: &str,
|
||||
artifact_digest: &str,
|
||||
) -> Result<PluginArtifact> {
|
||||
compare_installation(tx, workspace, installation, expected_last_operation)?;
|
||||
let artifact = load_artifact(tx, workspace, installation, artifact_digest)?;
|
||||
if artifact.revoked || artifact.package_digest != package_digest {
|
||||
return fail(PluginCatalogError::Conflict);
|
||||
}
|
||||
Ok(artifact)
|
||||
}
|
||||
pub fn plugin_reference_put(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
reference: &PluginReference,
|
||||
) -> Result<()> {
|
||||
if !bounded(&reference.owner, 512) {
|
||||
return fail(PluginCatalogError::Invalid);
|
||||
}
|
||||
plugin_artifact_admit(
|
||||
tx,
|
||||
workspace,
|
||||
&reference.installation_id,
|
||||
&reference.expected_last_operation,
|
||||
&reference.package_digest,
|
||||
&reference.artifact_digest,
|
||||
)?;
|
||||
tx.execute("INSERT INTO plugin_references(workspace_id,kind,owner,installation_id,artifact_digest) VALUES(?1,?2,?3,?4,?5) ON CONFLICT DO NOTHING",params![workspace,reference.kind.name(),reference.owner,reference.installation_id,reference.artifact_digest])?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn plugin_reference_remove(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
kind: PluginReferenceKind,
|
||||
owner: &str,
|
||||
installation: &str,
|
||||
artifact_digest: &str,
|
||||
) -> Result<()> {
|
||||
tx.execute("DELETE FROM plugin_references WHERE workspace_id=?1 AND kind=?2 AND owner=?3 AND installation_id=?4 AND artifact_digest=?5",params![workspace,kind.name(),owner,installation,artifact_digest])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Exercise production migration/open against retained SQLite fixtures, including
|
||||
//! the oldest retained transition, marker failure rollback and real FK/CHECKs.
|
||||
use super::*;
|
||||
|
||||
fn catalog_schema(conn: &Connection) -> Vec<(String, String)> {
|
||||
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name").unwrap().query_map([],|r|Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
|
||||
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
|
||||
for version in [50, 105] {
|
||||
let path = dir.path().join(format!("v{version}.db"));
|
||||
if version == 50 {
|
||||
tests::prepare_schema_v50(&path, Some("workspace-a"));
|
||||
} else {
|
||||
tests::prepare_retained_schema(&path, version);
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
let before = workspace_schema_migration_history(&conn).unwrap();
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('plugin-owner','user','plugin-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('plugin-workspace','plugin-owner','Plugins','active','1','1');").unwrap();
|
||||
drop(conn);
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
migrated.with_conn(|c| {
|
||||
assert_eq!(catalog_schema(c),canonical);
|
||||
let after=workspace_schema_migration_history(c)?;
|
||||
assert!(after.starts_with(&before));
|
||||
assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||
assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err());
|
||||
c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','i','alias','p','operation',0)",[])?;
|
||||
assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','alias','p','operation',0)",[]).is_err());
|
||||
assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','other','p','operation',2)",[]).is_err());
|
||||
assert!(c.execute("INSERT INTO plugin_references VALUES('plugin-workspace','active','owner','i','unknown')",[]).is_err());
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_changing_applied() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("retained.db");
|
||||
tests::prepare_retained_schema(&path, 105);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
let before = workspace_schema_migration_history(&conn).unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER interrupt_plugin_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=106 BEGIN SELECT RAISE(ABORT,'injected plugin migration interruption'); END;").unwrap();
|
||||
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 105);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before);
|
||||
assert!(catalog_schema(&conn).is_empty());
|
||||
conn.execute_batch("DROP TRIGGER interrupt_plugin_migration")
|
||||
.unwrap();
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
migrated
|
||||
.with_conn(|c| {
|
||||
assert_eq!(current_schema_version(c)?, 106);
|
||||
crate::plugin_catalog::verify(c)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn plugin_catalog_startup_rejects_drifted_reference_fence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("fresh.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store
|
||||
.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER plugin_reference_live")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
assert!(
|
||||
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Real SQLite plus disk-component tests. Only authentication/HTTP and execution
|
||||
//! are outside this suite; packages use the checked-in real Rust PDK Component.
|
||||
use super::*;
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
store: SqliteWorkspaceStore,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
|
||||
for workspace in ["a", "b"] {
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: workspace.into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: workspace.into(),
|
||||
state: "active".into(),
|
||||
created_at: "2026-10-01T00:00:00Z".into(),
|
||||
updated_at: "2026-10-01T00:00:00Z".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
dir,
|
||||
store,
|
||||
bytes: package("0.1.0", ""),
|
||||
}
|
||||
}
|
||||
fn root(&self) -> PathBuf {
|
||||
self.dir.path().join("artifacts")
|
||||
}
|
||||
fn upload(&self) -> PluginUploadResponse {
|
||||
self.store
|
||||
.plugin_upload(&self.root(), "a", "actor", &self.bytes)
|
||||
.unwrap()
|
||||
}
|
||||
fn install(&self, upload: &PluginUploadResponse, alias: &str) -> PluginOperationResponse {
|
||||
self.store
|
||||
.plugin_install(
|
||||
"a",
|
||||
"actor",
|
||||
&PluginInstallRequest {
|
||||
upload_id: upload.upload_id.clone(),
|
||||
alias: alias.into(),
|
||||
operation_key: alias.into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn expire(&self) {
|
||||
self.store
|
||||
.with_conn(|c| {
|
||||
c.execute("UPDATE plugin_uploads SET expires_at=0", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn count(&self, table: &str) -> i64 {
|
||||
self.store
|
||||
.with_conn(|c| {
|
||||
Ok(c.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
fn package(version: &str, extra: &str) -> Vec<u8> {
|
||||
let dir = tempdir().unwrap();
|
||||
let manifest =
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
|
||||
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""));
|
||||
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("plugin.component.wasm"),
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
if !extra.is_empty() {
|
||||
fs::write(dir.path().join("extra.txt"), extra).unwrap();
|
||||
}
|
||||
let parsed = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
plugin::pack_plugin_package(&parsed, &PluginPackageLimits::default())
|
||||
.unwrap()
|
||||
.bytes
|
||||
}
|
||||
fn error<T: std::fmt::Debug>(result: Result<T>, expected: PluginCatalogError) {
|
||||
assert!(
|
||||
matches!(result,Err(Error::PluginCatalog(actual)) if actual==expected),
|
||||
"expected {expected:?}, got {result:?}"
|
||||
);
|
||||
}
|
||||
fn reference(result: &PluginOperationResponse, kind: PluginReferenceKind) -> PluginReference {
|
||||
let artifact = &result.installation.artifacts[0];
|
||||
PluginReference {
|
||||
kind,
|
||||
owner: "owner/config-or-plan".into(),
|
||||
installation_id: result.installation.installation_id.clone(),
|
||||
expected_last_operation: result.operation_id.clone(),
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
}
|
||||
}
|
||||
fn uninstall_request(result: &PluginOperationResponse, key: &str) -> PluginUninstallRequest {
|
||||
PluginUninstallRequest {
|
||||
expected_last_operation: result.operation_id.clone(),
|
||||
operation_key: key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validated_upload_is_not_install_and_exact_published_bytes_survive_restart() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
assert_eq!(f.count("plugin_installations"), 0);
|
||||
let installed = f.install(&upload, "echo");
|
||||
let installation = &installed.installation.installation_id;
|
||||
let artifact = &upload.artifact;
|
||||
assert_ne!(artifact.package_digest, artifact.artifact_digest);
|
||||
assert_eq!(
|
||||
artifact.config_schema.digest,
|
||||
format!("sha256:{}", hash(artifact.config_schema.dcdl.as_bytes()))
|
||||
);
|
||||
let fetched = f
|
||||
.store
|
||||
.plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest)
|
||||
.unwrap();
|
||||
assert_eq!(fetched, f.bytes);
|
||||
assert_eq!(fetched.len() as u64, u64::from(artifact.artifact_size));
|
||||
plugin::verify_plugin_artifact(
|
||||
&fetched,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
u64::from(artifact.artifact_size),
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
|
||||
assert_eq!(
|
||||
reopened.plugin_show("a", installation).unwrap(),
|
||||
installed.installation
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest)
|
||||
.unwrap(),
|
||||
fetched
|
||||
);
|
||||
error(
|
||||
reopened.plugin_artifact_bytes(&f.root(), "b", installation, &artifact.artifact_digest),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
error(
|
||||
reopened.plugin_operation("b", &installed.operation_id),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
error(
|
||||
reopened.plugin_show("b", installation),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_returns_original_receipt_not_latest_catalog_and_survives_uninstall() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "echo");
|
||||
let mut request = PluginInstallRequest {
|
||||
upload_id: upload.upload_id.clone(),
|
||||
alias: "echo".into(),
|
||||
operation_key: "echo".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
f.store.plugin_install("a", "actor", &request).unwrap(),
|
||||
first
|
||||
);
|
||||
request.alias = "changed".into();
|
||||
error(
|
||||
f.store.plugin_install("a", "actor", &request),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
request.alias = "echo".into();
|
||||
error(
|
||||
f.store.plugin_install("a", "other-actor", &request),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
let next = f
|
||||
.store
|
||||
.plugin_upload(&f.root(), "a", "actor", &package("0.2.0", ""))
|
||||
.unwrap();
|
||||
let added = f
|
||||
.store
|
||||
.plugin_add_artifact(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&PluginAddArtifactRequest {
|
||||
upload_id: next.upload_id,
|
||||
expected_last_operation: first.operation_id.clone(),
|
||||
operation_key: "add".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
f.store.plugin_install("a", "actor", &request).unwrap(),
|
||||
first
|
||||
);
|
||||
error(
|
||||
f.store.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&first, "remove"),
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
let uninstalled = f
|
||||
.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&added, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(uninstalled.installation.uninstalled);
|
||||
f.expire();
|
||||
f.store.plugin_gc(&f.root()).unwrap();
|
||||
assert_eq!(
|
||||
f.store.plugin_install("a", "actor", &request).unwrap(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&added, "remove")
|
||||
)
|
||||
.unwrap(),
|
||||
uninstalled
|
||||
);
|
||||
assert_eq!(
|
||||
f.store.plugin_operation("a", &first.operation_id).unwrap(),
|
||||
first
|
||||
);
|
||||
assert_eq!(f.count("plugin_operations"), 3);
|
||||
assert_eq!(f.count("plugin_artifacts"), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_collision_and_revoke_are_not_overwrite_or_reactivation() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "echo");
|
||||
let installation = &first.installation.installation_id;
|
||||
let collision = f
|
||||
.store
|
||||
.plugin_upload(
|
||||
&f.root(),
|
||||
"a",
|
||||
"actor",
|
||||
&package("0.1.0", "different bytes"),
|
||||
)
|
||||
.unwrap();
|
||||
error(
|
||||
f.store.plugin_add_artifact(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&PluginAddArtifactRequest {
|
||||
upload_id: collision.upload_id,
|
||||
expected_last_operation: first.operation_id.clone(),
|
||||
operation_key: "collide".into(),
|
||||
},
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
let revoked = f
|
||||
.store
|
||||
.plugin_revoke(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&PluginRevokeRequest {
|
||||
artifact_digest: upload.artifact.artifact_digest.clone(),
|
||||
expected_last_operation: first.operation_id.clone(),
|
||||
reason: "withdrawn".into(),
|
||||
operation_key: "revoke".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(revoked.installation.artifacts[0].revoked);
|
||||
let add = PluginAddArtifactRequest {
|
||||
upload_id: upload.upload_id.clone(),
|
||||
expected_last_operation: revoked.operation_id.clone(),
|
||||
operation_key: "reactivate".into(),
|
||||
};
|
||||
error(
|
||||
f.store
|
||||
.plugin_add_artifact("a", "actor", installation, &add),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
error(
|
||||
f.store.plugin_artifact_bytes(
|
||||
&f.root(),
|
||||
"a",
|
||||
installation,
|
||||
&upload.artifact.artifact_digest,
|
||||
),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
let mut pin = reference(&revoked, PluginReferenceKind::Restorable);
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
error(
|
||||
plugin_reference_put(&tx, "a", &pin),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let other = f.install(&upload, "new-installation");
|
||||
assert_ne!(other.installation.installation_id, *installation);
|
||||
pin.installation_id = other.installation.installation_id.clone();
|
||||
pin.expected_last_operation = other.operation_id;
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
plugin_reference_put(&tx, "a", &pin)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let rejected=f.store.with_conn(|c|{c.execute("UPDATE plugin_artifacts SET revoked=0,revocation_reason=NULL WHERE workspace_id='a' AND installation_id=?1",[installation])?;Ok(())});
|
||||
assert!(matches!(rejected, Err(Error::Sqlite(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn references_compose_with_owner_transaction_rollback_and_fence_all_lifetimes() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "echo");
|
||||
let installation = &first.installation.installation_id;
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
c.execute(
|
||||
"CREATE TABLE fixture_config_commits (content_digest TEXT PRIMARY KEY)",
|
||||
[],
|
||||
)?;
|
||||
let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute(
|
||||
"INSERT INTO fixture_config_commits VALUES ('uncommitted')",
|
||||
[],
|
||||
)?;
|
||||
plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Config))?;
|
||||
tx.rollback()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(f.count("plugin_references"), 0);
|
||||
assert_eq!(f.count("fixture_config_commits"), 0);
|
||||
for kind in [
|
||||
PluginReferenceKind::Config,
|
||||
PluginReferenceKind::Pending,
|
||||
PluginReferenceKind::Active,
|
||||
PluginReferenceKind::Restorable,
|
||||
] {
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
plugin_reference_put(&tx, "a", &reference(&first, kind))?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
error(
|
||||
f.store.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&uninstall_request(&first, "remove"),
|
||||
),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
}
|
||||
let mut stale = reference(&first, PluginReferenceKind::Config);
|
||||
stale.expected_last_operation = "stale".into();
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
error(
|
||||
plugin_reference_put(&tx, "a", &stale),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
stale.expected_last_operation = first.operation_id.clone();
|
||||
stale.package_digest = "0".repeat(64);
|
||||
error(
|
||||
plugin_reference_put(&tx, "a", &stale),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
error(
|
||||
plugin_reference_put(&tx, "b", &reference(&first, PluginReferenceKind::Config)),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let revoked = f
|
||||
.store
|
||||
.plugin_revoke(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&PluginRevokeRequest {
|
||||
artifact_digest: upload.artifact.artifact_digest.clone(),
|
||||
expected_last_operation: first.operation_id.clone(),
|
||||
operation_key: "revoke".into(),
|
||||
reason: "withdrawn".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.count("plugin_references"), 4);
|
||||
error(
|
||||
f.store.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&uninstall_request(&revoked, "remove"),
|
||||
),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
for kind in [
|
||||
PluginReferenceKind::Config,
|
||||
PluginReferenceKind::Pending,
|
||||
PluginReferenceKind::Active,
|
||||
PluginReferenceKind::Restorable,
|
||||
] {
|
||||
plugin_reference_remove(
|
||||
&tx,
|
||||
"a",
|
||||
kind,
|
||||
"owner/config-or-plan",
|
||||
installation,
|
||||
&upload.artifact.artifact_digest,
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let removed = f
|
||||
.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
installation,
|
||||
&uninstall_request(&revoked, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
let mut pin = reference(&removed, PluginReferenceKind::Pending);
|
||||
pin.expected_last_operation = removed.operation_id.clone();
|
||||
error(
|
||||
plugin_reference_put(&tx, "a", &pin),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_failure_rolls_back_domain_and_receipt_then_exact_retry_succeeds() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_plugin_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;Ok(())}).unwrap();
|
||||
let request = PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: "echo".into(),
|
||||
operation_key: "install".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
f.store.plugin_install("a", "actor", &request),
|
||||
Err(Error::Sqlite(_))
|
||||
));
|
||||
assert_eq!(f.count("plugin_installations"), 0);
|
||||
assert_eq!(f.count("plugin_artifacts"), 0);
|
||||
assert_eq!(f.count("plugin_operations"), 0);
|
||||
f.store
|
||||
.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER fail_plugin_receipt")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let installed = f.store.plugin_install("a", "actor", &request).unwrap();
|
||||
assert_eq!(
|
||||
f.store.plugin_install("a", "actor", &request).unwrap(),
|
||||
installed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_and_publish_failures_leave_no_authority_and_restart_gc_recovers_orphans() {
|
||||
let f = Fixture::new().await;
|
||||
for bytes in [&b"not a zip"[..], &f.bytes[..f.bytes.len() - 2]] {
|
||||
error(
|
||||
f.store.plugin_upload(&f.root(), "a", "actor", bytes),
|
||||
PluginCatalogError::Invalid,
|
||||
);
|
||||
}
|
||||
error(
|
||||
f.store.plugin_upload(
|
||||
&f.root(),
|
||||
"a",
|
||||
"actor",
|
||||
&vec![0; PluginPackageLimits::default().max_package_size_bytes as usize + 1],
|
||||
),
|
||||
PluginCatalogError::TooLarge,
|
||||
);
|
||||
assert_eq!(f.count("plugin_uploads"), 0);
|
||||
fs::write(f.dir.path().join("blocked"), "not a directory").unwrap();
|
||||
error(
|
||||
f.store
|
||||
.plugin_upload(&f.dir.path().join("blocked"), "a", "actor", &f.bytes),
|
||||
PluginCatalogError::Unavailable,
|
||||
);
|
||||
f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_upload_commit BEFORE INSERT ON plugin_uploads BEGIN SELECT RAISE(ABORT,'injected upload DB failure'); END;")?;Ok(())}).unwrap();
|
||||
assert!(matches!(
|
||||
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
|
||||
Err(Error::Sqlite(_))
|
||||
));
|
||||
assert_eq!(f.count("plugin_uploads"), 0);
|
||||
assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 1);
|
||||
fs::write(f.root().join("blobs/.pending-interrupted"), "partial").unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
|
||||
assert_eq!(reopened.plugin_gc(&f.root()).unwrap(), 2);
|
||||
assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 0);
|
||||
f.store
|
||||
.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER fail_upload_commit")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "echo");
|
||||
fs::write(
|
||||
blob_path(&f.root(), &upload.artifact.artifact_digest).unwrap(),
|
||||
"corrupt",
|
||||
)
|
||||
.unwrap();
|
||||
error(
|
||||
f.store.plugin_artifact_bytes(
|
||||
&f.root(),
|
||||
"a",
|
||||
&first.installation.installation_id,
|
||||
&upload.artifact.artifact_digest,
|
||||
),
|
||||
PluginCatalogError::Unavailable,
|
||||
);
|
||||
error(
|
||||
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
|
||||
PluginCatalogError::Unavailable,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_actor_workspace_expiry_and_quotas_are_enforced_without_partial_rows() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let request = PluginInstallRequest {
|
||||
upload_id: upload.upload_id.clone(),
|
||||
alias: "echo".into(),
|
||||
operation_key: "install".into(),
|
||||
};
|
||||
error(
|
||||
f.store.plugin_install("a", "other", &request),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
error(
|
||||
f.store.plugin_install("b", "actor", &request),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
f.expire();
|
||||
error(
|
||||
f.store.plugin_install("a", "actor", &request),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
assert_eq!(f.count("plugin_installations"), 0);
|
||||
for _ in 0..MAX_ACTOR_UPLOADS {
|
||||
f.upload();
|
||||
}
|
||||
error(
|
||||
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
|
||||
PluginCatalogError::Quota,
|
||||
);
|
||||
f.store
|
||||
.plugin_upload(&f.root(), "a", "other", &f.bytes)
|
||||
.unwrap();
|
||||
f.store
|
||||
.plugin_upload(&f.root(), "b", "actor", &f.bytes)
|
||||
.unwrap();
|
||||
f.expire();
|
||||
f.upload();
|
||||
assert_eq!(f.count("plugin_operations"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_gc_keeps_other_workspace_blob_and_historical_receipts_after_last_reference() {
|
||||
let f = Fixture::new().await;
|
||||
let a_upload = f.upload();
|
||||
let a = f.install(&a_upload, "echo");
|
||||
let b_upload = f
|
||||
.store
|
||||
.plugin_upload(&f.root(), "b", "actor", &f.bytes)
|
||||
.unwrap();
|
||||
let b = f
|
||||
.store
|
||||
.plugin_install(
|
||||
"b",
|
||||
"actor",
|
||||
&PluginInstallRequest {
|
||||
upload_id: b_upload.upload_id,
|
||||
alias: "echo".into(),
|
||||
operation_key: "install".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&a.installation.installation_id,
|
||||
&uninstall_request(&a, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
f.expire();
|
||||
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 0);
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_artifact_bytes(
|
||||
&f.root(),
|
||||
"b",
|
||||
&b.installation.installation_id,
|
||||
&a_upload.artifact.artifact_digest
|
||||
)
|
||||
.unwrap(),
|
||||
f.bytes
|
||||
);
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"b",
|
||||
"actor",
|
||||
&b.installation.installation_id,
|
||||
&uninstall_request(&b, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1);
|
||||
assert_eq!(f.store.plugin_operation("a", &a.operation_id).unwrap(), a);
|
||||
assert_eq!(f.count("plugin_artifacts"), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn independent_connections_cannot_uninstall_or_gc_across_a_reference_transaction() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "echo");
|
||||
f.expire();
|
||||
let second = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
|
||||
second
|
||||
.with_conn(|c| {
|
||||
c.busy_timeout(std::time::Duration::ZERO)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
f.store.with_conn_mut(|c|{
|
||||
let tx=c.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
plugin_reference_put(&tx,"a",&reference(&first,PluginReferenceKind::Pending))?;
|
||||
let denied=second.plugin_uninstall("a","actor",&first.installation.installation_id,&uninstall_request(&first,"remove"));
|
||||
assert!(matches!(denied,Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy));
|
||||
assert!(matches!(second.plugin_gc(&f.root()),Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy));
|
||||
assert!(blob_path(&f.root(),&upload.artifact.artifact_digest)?.exists());
|
||||
tx.commit()?;Ok(())
|
||||
}).unwrap();
|
||||
error(
|
||||
second.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&first, "remove"),
|
||||
),
|
||||
PluginCatalogError::Referenced,
|
||||
);
|
||||
assert_eq!(second.plugin_gc(&f.root()).unwrap(), 0);
|
||||
// Reverse ordering: after removal commits, a stale plan cannot recreate a pin.
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
plugin_reference_remove(
|
||||
&tx,
|
||||
"a",
|
||||
PluginReferenceKind::Pending,
|
||||
"owner/config-or-plan",
|
||||
&first.installation.installation_id,
|
||||
&upload.artifact.artifact_digest,
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
second
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&first, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
f.store
|
||||
.with_conn_mut(|c| {
|
||||
let tx = c.transaction()?;
|
||||
error(
|
||||
plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Active)),
|
||||
PluginCatalogError::NotFound,
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_opaque_list_cursor_is_workspace_bound_and_survives_catalog_changes() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let first = f.install(&upload, "one");
|
||||
f.install(&upload, "two");
|
||||
let page = f
|
||||
.store
|
||||
.plugin_list(
|
||||
"a",
|
||||
&PluginListRequest {
|
||||
cursor: None,
|
||||
limit: Some(1),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.installations.len(), 1);
|
||||
assert!(page.next_cursor.is_some());
|
||||
f.store
|
||||
.plugin_uninstall(
|
||||
"a",
|
||||
"actor",
|
||||
&first.installation.installation_id,
|
||||
&uninstall_request(&first, "remove"),
|
||||
)
|
||||
.unwrap();
|
||||
let next = f
|
||||
.store
|
||||
.plugin_list(
|
||||
"a",
|
||||
&PluginListRequest {
|
||||
cursor: page.next_cursor.clone(),
|
||||
limit: Some(1),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(next.installations.len(), 1);
|
||||
assert!(next.next_cursor.is_none());
|
||||
error(
|
||||
f.store.plugin_list(
|
||||
"b",
|
||||
&PluginListRequest {
|
||||
cursor: page.next_cursor,
|
||||
limit: Some(1),
|
||||
},
|
||||
),
|
||||
PluginCatalogError::Invalid,
|
||||
);
|
||||
error(
|
||||
f.store.plugin_list(
|
||||
"a",
|
||||
&PluginListRequest {
|
||||
cursor: None,
|
||||
limit: Some(101),
|
||||
},
|
||||
),
|
||||
PluginCatalogError::Invalid,
|
||||
);
|
||||
error(
|
||||
f.store.plugin_list(
|
||||
"a",
|
||||
&PluginListRequest {
|
||||
cursor: Some("nonsense".into()),
|
||||
limit: None,
|
||||
},
|
||||
),
|
||||
PluginCatalogError::Invalid,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destructive_mutations_roll_back_when_their_atomic_receipt_cannot_commit() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
let installed = f.install(&upload, "echo");
|
||||
let installation = &installed.installation.installation_id;
|
||||
f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER fail_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'receipt storage failure'); END;")?; Ok(()) }).unwrap();
|
||||
let revoke = PluginRevokeRequest {
|
||||
artifact_digest: upload.artifact.artifact_digest,
|
||||
expected_last_operation: installed.operation_id.clone(),
|
||||
reason: "withdrawn".into(),
|
||||
operation_key: "revoke".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
f.store.plugin_revoke("a", "actor", installation, &revoke),
|
||||
Err(Error::Sqlite(_))
|
||||
));
|
||||
assert_eq!(
|
||||
f.store.plugin_show("a", installation).unwrap(),
|
||||
installed.installation
|
||||
);
|
||||
let uninstall = uninstall_request(&installed, "remove");
|
||||
assert!(matches!(
|
||||
f.store
|
||||
.plugin_uninstall("a", "actor", installation, &uninstall),
|
||||
Err(Error::Sqlite(_))
|
||||
));
|
||||
assert_eq!(
|
||||
f.store.plugin_show("a", installation).unwrap(),
|
||||
installed.installation
|
||||
);
|
||||
assert_eq!(f.count("plugin_operations"), 1);
|
||||
f.store
|
||||
.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER fail_receipt")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let revoked = f
|
||||
.store
|
||||
.plugin_revoke("a", "actor", installation, &revoke)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
f.store
|
||||
.plugin_revoke("a", "actor", installation, &revoke)
|
||||
.unwrap(),
|
||||
revoked
|
||||
);
|
||||
error(
|
||||
f.store
|
||||
.plugin_uninstall("a", "actor", installation, &uninstall),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
let mut changed = revoke.clone();
|
||||
changed.reason = "changed reason".into();
|
||||
error(
|
||||
f.store.plugin_revoke("a", "actor", installation, &changed),
|
||||
PluginCatalogError::Conflict,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_workspace_quota_counts_all_actors_and_installation_quota_is_atomic() {
|
||||
let f = Fixture::new().await;
|
||||
let upload = f.upload();
|
||||
// Clone real immutable upload references to the same published bytes to put
|
||||
// accounting precisely at its global boundary without allocating duplicate blobs.
|
||||
f.store.with_conn(|c| {
|
||||
for n in 1..MAX_UPLOADS {
|
||||
c.execute("INSERT INTO plugin_uploads SELECT workspace_id,?1,?2,expires_at,artifact_digest,artifact_size,metadata_json FROM plugin_uploads WHERE upload_id=?3",params![format!("upload-{n}"),format!("actor-{n}"),upload.upload_id])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
error(
|
||||
f.store
|
||||
.plugin_upload(&f.root(), "a", "fresh-actor", &f.bytes),
|
||||
PluginCatalogError::Quota,
|
||||
);
|
||||
assert_eq!(f.count("plugin_uploads"), MAX_UPLOADS);
|
||||
for n in 0..MAX_INSTALLATIONS {
|
||||
f.install(&upload, &format!("install-{n}"));
|
||||
}
|
||||
let request = PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: "excess".into(),
|
||||
operation_key: "excess".into(),
|
||||
};
|
||||
error(
|
||||
f.store.plugin_install("a", "actor", &request),
|
||||
PluginCatalogError::Quota,
|
||||
);
|
||||
assert_eq!(f.count("plugin_installations"), MAX_INSTALLATIONS);
|
||||
assert_eq!(f.count("plugin_operations"), MAX_INSTALLATIONS);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
CREATE TABLE plugin_installations (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
installation_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL CHECK(length(alias) BETWEEN 1 AND 128),
|
||||
package_id TEXT NOT NULL,
|
||||
last_operation TEXT NOT NULL,
|
||||
uninstalled INTEGER NOT NULL DEFAULT 0 CHECK(uninstalled IN (0,1)),
|
||||
PRIMARY KEY(workspace_id, installation_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX plugin_live_alias ON plugin_installations(workspace_id, alias) WHERE uninstalled=0;
|
||||
CREATE TABLE plugin_artifacts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'),
|
||||
package_digest TEXT NOT NULL CHECK(length(package_digest)=71 AND substr(package_digest,1,7)='sha256:' AND substr(package_digest,8) NOT GLOB '*[^0-9a-f]*'),
|
||||
version TEXT NOT NULL,
|
||||
artifact_size INTEGER NOT NULL CHECK(artifact_size > 0),
|
||||
metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)),
|
||||
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0,1)),
|
||||
revocation_reason TEXT,
|
||||
CHECK((revoked=0 AND revocation_reason IS NULL) OR (revoked=1 AND revocation_reason IS NOT NULL)),
|
||||
PRIMARY KEY(workspace_id, installation_id, artifact_digest),
|
||||
UNIQUE(workspace_id, installation_id, version),
|
||||
FOREIGN KEY(workspace_id, installation_id) REFERENCES plugin_installations(workspace_id, installation_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE plugin_uploads (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
upload_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'),
|
||||
artifact_size INTEGER NOT NULL CHECK(artifact_size > 0),
|
||||
metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)),
|
||||
PRIMARY KEY(workspace_id, upload_id)
|
||||
);
|
||||
CREATE INDEX plugin_upload_expiry ON plugin_uploads(expires_at);
|
||||
CREATE TABLE plugin_operations (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
operation_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('install','add_artifact','revoke','uninstall')),
|
||||
operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128),
|
||||
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=64),
|
||||
result_json TEXT NOT NULL CHECK(json_valid(result_json)),
|
||||
PRIMARY KEY(workspace_id, operation_id),
|
||||
UNIQUE(workspace_id, actor, kind, operation_key)
|
||||
);
|
||||
CREATE TABLE plugin_references (
|
||||
workspace_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('config','pending','active','restorable')),
|
||||
owner TEXT NOT NULL CHECK(length(owner) BETWEEN 1 AND 512),
|
||||
installation_id TEXT NOT NULL,
|
||||
artifact_digest TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, kind, owner, installation_id, artifact_digest),
|
||||
FOREIGN KEY(workspace_id, installation_id, artifact_digest) REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TRIGGER plugin_revocation_irreversible BEFORE UPDATE OF revoked ON plugin_artifacts
|
||||
WHEN OLD.revoked=1 AND NEW.revoked<>1 BEGIN SELECT RAISE(ABORT,'plugin revocation is irreversible'); END;
|
||||
CREATE TRIGGER plugin_reference_live BEFORE INSERT ON plugin_references
|
||||
WHEN NOT EXISTS(SELECT 1 FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id, installation_id)
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.installation_id=NEW.installation_id AND a.artifact_digest=NEW.artifact_digest AND a.revoked=0 AND i.uninstalled=0)
|
||||
BEGIN SELECT RAISE(ABORT,'plugin reference target unavailable'); END;
|
||||
CREATE TRIGGER plugin_uninstall_referenced BEFORE UPDATE OF uninstalled ON plugin_installations
|
||||
WHEN NEW.uninstalled=1 AND EXISTS(SELECT 1 FROM plugin_references r WHERE r.workspace_id=NEW.workspace_id AND r.installation_id=NEW.installation_id)
|
||||
BEGIN SELECT RAISE(ABORT,'plugin installation referenced'); END;
|
||||
@@ -100,6 +100,10 @@ mod schema_convergence {
|
||||
version: 105,
|
||||
name: "Durable Subject deletion snapshots and Job admission fences".into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 106,
|
||||
name: crate::plugin_catalog::MIGRATION_NAME.into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -116,6 +120,7 @@ mod schema_convergence {
|
||||
let mut expected = history.clone();
|
||||
expected.push((104, "Converge Passkey results and Job input retention".into()));
|
||||
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
|
||||
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||
@@ -145,7 +150,7 @@ mod schema_convergence {
|
||||
assert_eq!(feature_rows(conn, job_variant), rows);
|
||||
assert_eq!(
|
||||
workspace_schema_migration_history(conn)?.len(),
|
||||
history.len() + 2
|
||||
history.len() + plan.migrations.len()
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
@@ -183,7 +188,7 @@ mod schema_convergence {
|
||||
upgraded
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(feature_rows(conn, job_variant), rows);
|
||||
assert_eq!(current_schema_version(conn)?, 105);
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -46,6 +46,8 @@ use worker_protocol::*;
|
||||
mod drive;
|
||||
#[path = "server_drive_grants.rs"]
|
||||
mod drive_grants;
|
||||
#[path = "server_plugin.rs"]
|
||||
mod plugin;
|
||||
#[path = "server_workspace_config.rs"]
|
||||
mod workspace_config;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -3418,11 +3420,12 @@ async fn authorize_scoped_workspace_request(
|
||||
.map_err(|error| {
|
||||
runtime_request_proof_rejection(&method, &request_path, workspace_id, &error)
|
||||
})?;
|
||||
if is_workspace_config_editor_path(&request_path) {
|
||||
if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path)
|
||||
{
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Runtime configuration editor access denied",
|
||||
"Runtime user-management access denied",
|
||||
));
|
||||
}
|
||||
request.extensions_mut().insert(source);
|
||||
@@ -3576,11 +3579,12 @@ async fn authorize_workspace_api_request(
|
||||
);
|
||||
}
|
||||
};
|
||||
if is_workspace_config_editor_path(&request_path) {
|
||||
if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path)
|
||||
{
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Runtime configuration editor access denied",
|
||||
"Runtime user-management access denied",
|
||||
);
|
||||
}
|
||||
request.extensions_mut().insert(source);
|
||||
@@ -4358,6 +4362,8 @@ impl WorkspaceApi {
|
||||
runtime_subscription_broker
|
||||
.register_embedded_runtime(embedded_runtime_id, embedded_subscription_runtime);
|
||||
let database_path = config.database_path.clone();
|
||||
let plugin_root =
|
||||
ServerConfig::server_data_root_for_data_dir(&config.data_root).join("plugin-artifacts");
|
||||
let executor = store.db_executor();
|
||||
let config_executor = executor.clone();
|
||||
let (config_store, repository_secrets) = executor
|
||||
@@ -4367,6 +4373,7 @@ impl WorkspaceApi {
|
||||
.open_store(&database_path)?
|
||||
.with_db_executor(config_executor),
|
||||
);
|
||||
config_store.plugin_gc(&plugin_root)?;
|
||||
let repository_secrets = Arc::new(RepositorySecretService::open(
|
||||
config_store.clone(),
|
||||
&database_path,
|
||||
@@ -7603,6 +7610,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_catalog(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(plugin::router(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_get(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_attach(service.clone()))
|
||||
.merge(
|
||||
@@ -13318,6 +13326,94 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn plugin_upload(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
body: server_api::BinaryBody,
|
||||
) -> std::result::Result<server_api::PluginUploadResponse, server_api::RepositoryApiError> {
|
||||
let api = self.workspace_api()?;
|
||||
plugin::upload(api, actor, workspace_id, body).await
|
||||
}
|
||||
|
||||
async fn plugin_install(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: server_api::PluginInstallRequest,
|
||||
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
plugin::install(api, actor, workspace_id, request).await
|
||||
}
|
||||
|
||||
async fn plugin_list(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
query: server_api::PluginListRequest,
|
||||
) -> std::result::Result<server_api::PluginListResponse, server_api::RepositoryApiError> {
|
||||
let api = self.workspace_api()?;
|
||||
plugin::list(api, actor, workspace_id, query).await
|
||||
}
|
||||
|
||||
async fn plugin_show(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
) -> std::result::Result<server_api::PluginInstallation, server_api::RepositoryApiError> {
|
||||
let api = self.workspace_api()?;
|
||||
plugin::show(api, actor, workspace_id, installation_id).await
|
||||
}
|
||||
|
||||
async fn plugin_artifact_add(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
request: server_api::PluginAddArtifactRequest,
|
||||
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
plugin::artifact_add(api, actor, workspace_id, installation_id, request).await
|
||||
}
|
||||
|
||||
async fn plugin_revoke(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
request: server_api::PluginRevokeRequest,
|
||||
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
plugin::revoke(api, actor, workspace_id, installation_id, request).await
|
||||
}
|
||||
|
||||
async fn plugin_uninstall(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
request: server_api::PluginUninstallRequest,
|
||||
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
plugin::uninstall(api, actor, workspace_id, installation_id, request).await
|
||||
}
|
||||
|
||||
async fn plugin_operation(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
operation_id: String,
|
||||
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
plugin::operation(api, actor, workspace_id, operation_id).await
|
||||
}
|
||||
|
||||
async fn drive_root(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -38064,6 +38160,10 @@ mod tests {
|
||||
include!("server_ticket_case_signed_tests.rs");
|
||||
}
|
||||
|
||||
mod plugin_tests {
|
||||
include!("server_plugin_tests.rs");
|
||||
}
|
||||
|
||||
mod ticket_tags_tests {
|
||||
include!("server_ticket_tags_tests.rs");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Human catalog management is separate from Runtime artifact delivery and execution grants.
|
||||
//! The outer dispatcher authenticates each request. Mutations additionally require the
|
||||
//! Workspace owner account; authenticated readers follow the existing Workspace read policy.
|
||||
//! Neither model text nor a Runtime workspace.request proof can manufacture a human actor.
|
||||
use super::*;
|
||||
use server_api::*;
|
||||
|
||||
type CatalogResult<T> = std::result::Result<T, RepositoryApiError>;
|
||||
|
||||
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
Router::new()
|
||||
.merge(
|
||||
server_api_axum::plugin_upload(service.clone()).layer(DefaultBodyLimit::max(
|
||||
manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize,
|
||||
)),
|
||||
)
|
||||
.merge(server_api_axum::plugin_install(service.clone()))
|
||||
.merge(server_api_axum::plugin_list(service.clone()))
|
||||
.merge(server_api_axum::plugin_show(service.clone()))
|
||||
.merge(server_api_axum::plugin_artifact_add(service.clone()))
|
||||
.merge(server_api_axum::plugin_revoke(service.clone()))
|
||||
.merge(server_api_axum::plugin_uninstall(service.clone()))
|
||||
.merge(server_api_axum::plugin_operation(service))
|
||||
.layer(middleware::from_fn(normalize_rejections))
|
||||
}
|
||||
|
||||
async fn normalize_rejections(request: Request, next: Next) -> Response {
|
||||
let response = next.run(request).await;
|
||||
// The management contract classifies malformed DTOs/queries as invalid=400,
|
||||
// including Axum's JSON data/media rejections. Preserve the common envelope
|
||||
// without reflecting attacker-supplied JSON values or transport diagnostics.
|
||||
if matches!(
|
||||
response.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
| StatusCode::UNPROCESSABLE_ENTITY
|
||||
| StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||
) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(RepositoryApiError::new(
|
||||
400,
|
||||
"plugin_invalid",
|
||||
"Invalid Plugin catalog request",
|
||||
vec![],
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_catalog_path(path: &str) -> bool {
|
||||
path.strip_prefix("/api/w/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
.is_some_and(|(_, resource)| {
|
||||
matches!(resource, "plugins" | "plugin-uploads")
|
||||
|| resource.starts_with("plugins/")
|
||||
|| resource.starts_with("plugin-operations/")
|
||||
})
|
||||
}
|
||||
|
||||
fn failure(error: Error) -> RepositoryApiError {
|
||||
let (status, code) = match error {
|
||||
Error::PluginCatalog(error) => (error.status(), error.code()),
|
||||
Error::WorkspacePermissionDenied(_) => (403, "access_denied"),
|
||||
Error::WorkspaceIdMismatch => (404, "not_found"),
|
||||
Error::DbCapacityExceeded => (503, "busy"),
|
||||
// A storage failure may be a lost acknowledgement. Do not assert rollback
|
||||
// or encourage a fresh operation key; the caller can reconcile its receipt.
|
||||
_ => (503, "catalog_outcome_unknown"),
|
||||
};
|
||||
RepositoryApiError::new(status, code, format!("Plugin catalog: {code}"), vec![])
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
api: &WorkspaceApi,
|
||||
workspace: &str,
|
||||
actor: &RequestActor,
|
||||
manage: bool,
|
||||
) -> CatalogResult<()> {
|
||||
validate_workspace_scope(api, workspace).map_err(|e| failure(e.error))?;
|
||||
if manage {
|
||||
require_workspace_owner(api, workspace, actor, "Plugin management")
|
||||
.await
|
||||
.map_err(|e| failure(e.error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn actor_identity(actor: &RequestActor) -> String {
|
||||
// Account alone is not a caller: two users of the same management account
|
||||
// must not consume one another's temporary uploads or operation keys.
|
||||
serde_json::to_string(&(&actor.account_id, &actor.user_id))
|
||||
.expect("string tuple is serializable")
|
||||
}
|
||||
|
||||
pub(super) async fn upload(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
body: BinaryBody,
|
||||
) -> CatalogResult<PluginUploadResponse> {
|
||||
authorize(api, &workspace, &actor, true).await?;
|
||||
let root =
|
||||
ServerConfig::server_data_root_for_data_dir(&api.config.data_root).join("plugin-artifacts");
|
||||
let store = api.config_store.clone();
|
||||
let actor = actor_identity(&actor);
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_upload(&root, &workspace, &actor, body.as_ref()))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn install(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
request: PluginInstallRequest,
|
||||
) -> CatalogResult<PluginOperationResponse> {
|
||||
authorize(api, &workspace, &actor, true).await?;
|
||||
let store = api.config_store.clone();
|
||||
let actor = actor_identity(&actor);
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_install(&workspace, &actor, &request))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn list(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
query: PluginListRequest,
|
||||
) -> CatalogResult<PluginListResponse> {
|
||||
authorize(api, &workspace, &actor, false).await?;
|
||||
let store = api.config_store.clone();
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_list(&workspace, &query))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn show(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
installation_id: String,
|
||||
) -> CatalogResult<PluginInstallation> {
|
||||
authorize(api, &workspace, &actor, false).await?;
|
||||
let store = api.config_store.clone();
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_show(&workspace, &installation_id))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn artifact_add(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
installation_id: String,
|
||||
request: PluginAddArtifactRequest,
|
||||
) -> CatalogResult<PluginOperationResponse> {
|
||||
authorize(api, &workspace, &actor, true).await?;
|
||||
let store = api.config_store.clone();
|
||||
let actor = actor_identity(&actor);
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_add_artifact(&workspace, &actor, &installation_id, &request))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn revoke(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
installation_id: String,
|
||||
request: PluginRevokeRequest,
|
||||
) -> CatalogResult<PluginOperationResponse> {
|
||||
authorize(api, &workspace, &actor, true).await?;
|
||||
let store = api.config_store.clone();
|
||||
let actor = actor_identity(&actor);
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_revoke(&workspace, &actor, &installation_id, &request))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn uninstall(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
installation_id: String,
|
||||
request: PluginUninstallRequest,
|
||||
) -> CatalogResult<PluginOperationResponse> {
|
||||
authorize(api, &workspace, &actor, true).await?;
|
||||
let store = api.config_store.clone();
|
||||
let actor = actor_identity(&actor);
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_uninstall(&workspace, &actor, &installation_id, &request))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
pub(super) async fn operation(
|
||||
api: &WorkspaceApi,
|
||||
actor: RequestActor,
|
||||
workspace: String,
|
||||
operation_id: String,
|
||||
) -> CatalogResult<PluginOperationResponse> {
|
||||
authorize(api, &workspace, &actor, false).await?;
|
||||
let store = api.config_store.clone();
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || store.plugin_operation(&workspace, &operation_id))
|
||||
.await
|
||||
.map_err(failure)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
use super::*;
|
||||
|
||||
// Generated routes -> real token resolver and Workspace admission -> real catalog
|
||||
// SQLite/blob storage. The unrelated Worker executor is the existing test backend.
|
||||
fn package_bytes(version: &str) -> Vec<u8> {
|
||||
use manifest::plugin::*;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("plugin.toml"),
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml")
|
||||
.replace("0.1.0", version),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("plugin.component.wasm"),
|
||||
include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let limits = PluginPackageLimits::default();
|
||||
pack_plugin_package(
|
||||
&read_plugin_directory(dir.path(), PluginSourceKind::Explicit, &limits).unwrap(),
|
||||
&limits,
|
||||
)
|
||||
.unwrap()
|
||||
.bytes
|
||||
}
|
||||
|
||||
async fn request(
|
||||
app: Router,
|
||||
token: Option<&str>,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Vec<u8>,
|
||||
binary: bool,
|
||||
expected: StatusCode,
|
||||
) -> Value {
|
||||
let mut req = Request::builder().method(method).uri(path).header(
|
||||
CONTENT_TYPE,
|
||||
if binary {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
"application/json"
|
||||
},
|
||||
);
|
||||
if let Some(token) = token {
|
||||
req = req.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let response = app
|
||||
.oneshot(req.body(Body::from(body)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 32 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
expected,
|
||||
"{method} {path}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
async fn json(app: Router, token: &str, path: &str, body: Value, expected: StatusCode) -> Value {
|
||||
request(
|
||||
app,
|
||||
Some(token),
|
||||
"POST",
|
||||
path,
|
||||
serde_json::to_vec(&body).unwrap(),
|
||||
false,
|
||||
expected,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode) -> Value {
|
||||
request(app, token, "GET", path, vec![], false, expected).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-owner").await;
|
||||
let reader = seed_test_api_token(api.store.as_ref(), "plugin-reader");
|
||||
let app = build_router(api.clone());
|
||||
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||
let catalog = format!("{prefix}/plugins");
|
||||
let upload_path = format!("{prefix}/plugin-uploads");
|
||||
get(app.clone(), None, &catalog, StatusCode::UNAUTHORIZED).await;
|
||||
get(app.clone(), Some(&reader), &catalog, StatusCode::OK).await;
|
||||
request(
|
||||
app.clone(),
|
||||
Some(&reader),
|
||||
"POST",
|
||||
&upload_path,
|
||||
package_bytes("0.1.0"),
|
||||
true,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let upload = request(
|
||||
app.clone(),
|
||||
Some(&owner),
|
||||
"POST",
|
||||
&upload_path,
|
||||
package_bytes("0.1.0"),
|
||||
true,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_ne!(
|
||||
upload["artifact"]["package_digest"],
|
||||
upload["artifact"]["artifact_digest"]
|
||||
);
|
||||
let install = serde_json::json!({"upload_id":upload["upload_id"],"alias":"primary","operation_key":"install"});
|
||||
let original = json(
|
||||
app.clone(),
|
||||
&owner,
|
||||
&catalog,
|
||||
install.clone(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
json(
|
||||
app.clone(),
|
||||
&owner,
|
||||
&catalog,
|
||||
install.clone(),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
original
|
||||
);
|
||||
let mut changed = install.clone();
|
||||
changed["alias"] = "other".into();
|
||||
json(app.clone(), &owner, &catalog, changed, StatusCode::CONFLICT).await;
|
||||
let id = original["installation"]["installation_id"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let detail_path = format!("{catalog}/{id}");
|
||||
let observed = get(app.clone(), Some(&reader), &detail_path, StatusCode::OK).await;
|
||||
assert_eq!(observed["last_operation"], original["operation_id"]);
|
||||
get(
|
||||
app.clone(),
|
||||
Some(&reader),
|
||||
&format!(
|
||||
"{prefix}/plugin-operations/{}",
|
||||
original["operation_id"].as_str().unwrap()
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
get(
|
||||
app.clone(),
|
||||
Some(&owner),
|
||||
&format!("/api/w/another/plugins/{id}"),
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
get(
|
||||
app.clone(),
|
||||
Some(&owner),
|
||||
&format!(
|
||||
"{prefix}/plugins/{}",
|
||||
upload["artifact"]["artifact_digest"].as_str().unwrap()
|
||||
),
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
let next_upload = request(
|
||||
app.clone(),
|
||||
Some(&owner),
|
||||
"POST",
|
||||
&upload_path,
|
||||
package_bytes("0.2.0"),
|
||||
true,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let add = json(app.clone(), &owner, &format!("{detail_path}/artifacts"), serde_json::json!({"upload_id":next_upload["upload_id"],"expected_last_operation":observed["last_operation"],"operation_key":"add"}), StatusCode::OK).await;
|
||||
json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":observed["last_operation"],"operation_key":"stale-delete"}), StatusCode::CONFLICT).await;
|
||||
let revoke = json(app.clone(), &owner, &format!("{detail_path}/revoke"), serde_json::json!({"expected_last_operation":add["operation_id"],"operation_key":"revoke","artifact_digest":upload["artifact"]["artifact_digest"],"reason":"withdrawn"}), StatusCode::OK).await;
|
||||
assert!(
|
||||
revoke["installation"]["artifacts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|a| a["revoked"] == true)
|
||||
);
|
||||
let removed = json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":revoke["operation_id"],"operation_key":"remove"}), StatusCode::OK).await;
|
||||
assert_eq!(removed["installation"]["uninstalled"], true);
|
||||
// Historical receipts remain historical after catalog movement.
|
||||
assert_eq!(
|
||||
json(
|
||||
app.clone(),
|
||||
&owner,
|
||||
&catalog,
|
||||
install.clone(),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
original
|
||||
);
|
||||
api.config_store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at='2026-01-01' WHERE token_hash=?1",
|
||||
[crate::auth::token_hash(&owner)],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
json(
|
||||
app.clone(),
|
||||
&owner,
|
||||
&catalog,
|
||||
install,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_rejects_invalid_uploads_self_reported_roles_and_runtime_proofs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(dir.path()).await;
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-input").await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-plugin-input").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-plugin-input");
|
||||
let app = build_router(api.clone());
|
||||
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||
request(
|
||||
app.clone(),
|
||||
Some(&token),
|
||||
"POST",
|
||||
&format!("{prefix}/plugin-uploads"),
|
||||
b"partial zip".to_vec(),
|
||||
true,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
let max = manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize;
|
||||
request(
|
||||
app.clone(),
|
||||
Some(&token),
|
||||
"POST",
|
||||
&format!("{prefix}/plugin-uploads"),
|
||||
vec![0; max + 1],
|
||||
true,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
)
|
||||
.await;
|
||||
json(app.clone(), &token, &format!("{prefix}/plugins"), serde_json::json!({"upload_id":"missing","alias":"bad","operation_key":"bad","role":"owner"}), StatusCode::BAD_REQUEST).await;
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(runtime_source_request(
|
||||
&identity,
|
||||
None,
|
||||
"GET",
|
||||
&format!("{prefix}/plugins"),
|
||||
vec![],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
get(
|
||||
app,
|
||||
Some(&token),
|
||||
&format!("{prefix}/plugins"),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await["installations"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_client_and_interrupted_binary_transport_use_real_catalog_authority() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-transport").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let app = build_router(api.clone());
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
|
||||
let header = format!(
|
||||
"POST /api/w/{TEST_WORKSPACE_ID}/plugin-uploads HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/octet-stream\r\nContent-Length: 2000\r\nConnection: close\r\n\r\npartial"
|
||||
);
|
||||
stream.write_all(header.as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
stream.read_to_end(&mut response),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.with_conn(|conn| Ok(conn.query_row(
|
||||
"SELECT count(*) FROM plugin_uploads",
|
||||
[],
|
||||
|r| r.get::<_, u64>(0)
|
||||
)?))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {token}").parse().unwrap(),
|
||||
);
|
||||
let client = server_api::ServerApiClient::builder(format!("http://{address}"))
|
||||
.unwrap()
|
||||
.client(
|
||||
reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
let upload = client
|
||||
.plugin_upload(TEST_WORKSPACE_ID.into(), package_bytes("0.1.0").into())
|
||||
.await
|
||||
.unwrap();
|
||||
let installed = client
|
||||
.plugin_install(
|
||||
TEST_WORKSPACE_ID.into(),
|
||||
server_api::PluginInstallRequest {
|
||||
upload_id: upload.upload_id,
|
||||
alias: "transport".into(),
|
||||
operation_key: "transport-install".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let observed = client
|
||||
.plugin_show(
|
||||
TEST_WORKSPACE_ID.into(),
|
||||
installed.installation.installation_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.last_operation, installed.operation_id);
|
||||
assert_eq!(observed.artifacts[0], upload.artifact);
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-restart").await;
|
||||
let app = build_router(api.clone());
|
||||
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||
let upload = request(
|
||||
app.clone(),
|
||||
Some(&token),
|
||||
"POST",
|
||||
&format!("{prefix}/plugin-uploads"),
|
||||
package_bytes("0.1.0"),
|
||||
true,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let installed = json(app.clone(),&token,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"restart","operation_key":"restart-install"}),StatusCode::OK).await;
|
||||
let root = ServerConfig::server_data_root_for_data_dir(&api.config.data_root)
|
||||
.join("plugin-artifacts/blobs");
|
||||
let orphan = root.join("f".repeat(64));
|
||||
let partial = root.join(".pending-interrupted-restart");
|
||||
fs::write(&orphan, b"published without committed reference").unwrap();
|
||||
fs::write(&partial, b"partial").unwrap();
|
||||
drop(app);
|
||||
shutdown_workspace_feature_storage(&api).await.unwrap();
|
||||
drop(api);
|
||||
let reopened = test_api(dir.path()).await;
|
||||
assert!(!orphan.exists());
|
||||
assert!(!partial.exists());
|
||||
let observed = reopened
|
||||
.config_store
|
||||
.plugin_show(
|
||||
TEST_WORKSPACE_ID,
|
||||
installed["installation"]["installation_id"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(observed.last_operation, installed["operation_id"]);
|
||||
let bytes = reopened
|
||||
.config_store
|
||||
.plugin_artifact_bytes(
|
||||
root.parent().unwrap(),
|
||||
TEST_WORKSPACE_ID,
|
||||
&observed.installation_id,
|
||||
&observed.artifacts[0].artifact_digest,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bytes, package_bytes("0.1.0"));
|
||||
shutdown_workspace_feature_storage(&reopened).await.unwrap();
|
||||
}
|
||||
@@ -127,7 +127,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 105;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 106;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -460,6 +460,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Durable Subject deletion snapshots and Job admission fences",
|
||||
apply: subject_deletion_store::migrate,
|
||||
},
|
||||
Migration {
|
||||
version: 106,
|
||||
name: crate::plugin_catalog::MIGRATION_NAME,
|
||||
apply: crate::plugin_catalog::migrate,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -1334,6 +1339,64 @@ pub struct HostingAdminInitialBearer {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
fn plugin_upload(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<server_api::PluginUploadResponse>;
|
||||
fn plugin_install(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
request: &server_api::PluginInstallRequest,
|
||||
) -> Result<server_api::PluginOperationResponse>;
|
||||
fn plugin_add_artifact(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginAddArtifactRequest,
|
||||
) -> Result<server_api::PluginOperationResponse>;
|
||||
fn plugin_revoke(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginRevokeRequest,
|
||||
) -> Result<server_api::PluginOperationResponse>;
|
||||
fn plugin_uninstall(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginUninstallRequest,
|
||||
) -> Result<server_api::PluginOperationResponse>;
|
||||
fn plugin_show(
|
||||
&self,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
) -> Result<server_api::PluginInstallation>;
|
||||
fn plugin_list(
|
||||
&self,
|
||||
workspace: &str,
|
||||
request: &server_api::PluginListRequest,
|
||||
) -> Result<server_api::PluginListResponse>;
|
||||
fn plugin_operation(
|
||||
&self,
|
||||
workspace: &str,
|
||||
operation: &str,
|
||||
) -> Result<server_api::PluginOperationResponse>;
|
||||
fn plugin_artifact_bytes(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
artifact_digest: &str,
|
||||
) -> Result<Vec<u8>>;
|
||||
fn plugin_gc(&self, root: &Path) -> Result<u64>;
|
||||
|
||||
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs
|
||||
/// and services into one closure covering connection/mutex waits, source authorization,
|
||||
/// receipts, CAS, audit, commit and ordered publication. Do not split checks around awaits
|
||||
@@ -4670,6 +4733,90 @@ fn insert_workspace_runtime_binding_audit(
|
||||
|
||||
#[async_trait]
|
||||
impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn plugin_upload(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<server_api::PluginUploadResponse> {
|
||||
SqliteWorkspaceStore::plugin_upload(self, root, workspace, actor, bytes)
|
||||
}
|
||||
fn plugin_install(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
request: &server_api::PluginInstallRequest,
|
||||
) -> Result<server_api::PluginOperationResponse> {
|
||||
SqliteWorkspaceStore::plugin_install(self, workspace, actor, request)
|
||||
}
|
||||
fn plugin_add_artifact(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginAddArtifactRequest,
|
||||
) -> Result<server_api::PluginOperationResponse> {
|
||||
SqliteWorkspaceStore::plugin_add_artifact(self, workspace, actor, installation, request)
|
||||
}
|
||||
fn plugin_revoke(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginRevokeRequest,
|
||||
) -> Result<server_api::PluginOperationResponse> {
|
||||
SqliteWorkspaceStore::plugin_revoke(self, workspace, actor, installation, request)
|
||||
}
|
||||
fn plugin_uninstall(
|
||||
&self,
|
||||
workspace: &str,
|
||||
actor: &str,
|
||||
installation: &str,
|
||||
request: &server_api::PluginUninstallRequest,
|
||||
) -> Result<server_api::PluginOperationResponse> {
|
||||
SqliteWorkspaceStore::plugin_uninstall(self, workspace, actor, installation, request)
|
||||
}
|
||||
fn plugin_show(
|
||||
&self,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
) -> Result<server_api::PluginInstallation> {
|
||||
SqliteWorkspaceStore::plugin_show(self, workspace, installation)
|
||||
}
|
||||
fn plugin_list(
|
||||
&self,
|
||||
workspace: &str,
|
||||
request: &server_api::PluginListRequest,
|
||||
) -> Result<server_api::PluginListResponse> {
|
||||
SqliteWorkspaceStore::plugin_list(self, workspace, request)
|
||||
}
|
||||
fn plugin_operation(
|
||||
&self,
|
||||
workspace: &str,
|
||||
operation: &str,
|
||||
) -> Result<server_api::PluginOperationResponse> {
|
||||
SqliteWorkspaceStore::plugin_operation(self, workspace, operation)
|
||||
}
|
||||
fn plugin_artifact_bytes(
|
||||
&self,
|
||||
root: &Path,
|
||||
workspace: &str,
|
||||
installation: &str,
|
||||
artifact_digest: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
SqliteWorkspaceStore::plugin_artifact_bytes(
|
||||
self,
|
||||
root,
|
||||
workspace,
|
||||
installation,
|
||||
artifact_digest,
|
||||
)
|
||||
}
|
||||
fn plugin_gc(&self, root: &Path) -> Result<u64> {
|
||||
SqliteWorkspaceStore::plugin_gc(self, root)
|
||||
}
|
||||
|
||||
fn db_executor(&self) -> crate::db_executor::DbExecutor {
|
||||
self.db_executor.clone()
|
||||
}
|
||||
@@ -15639,6 +15786,10 @@ fn create_workspace_schema_structure_from_sql(
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(
|
||||
&schema_sql
|
||||
.replace(
|
||||
"-- Plugin catalog schema is composed by store.rs.",
|
||||
include_str!("plugin_catalog_schema.sql"),
|
||||
)
|
||||
.replace(
|
||||
"-- Ticket-owned assignment schema is composed by store.rs.",
|
||||
ticket::TICKET_ASSIGNMENT_SCHEMA_SQL,
|
||||
@@ -16648,6 +16799,7 @@ fn apply_migrations_with_secret_source(
|
||||
}
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
crate::plugin_catalog::verify(conn)?;
|
||||
verify_workspace_runtime_binding_schema(conn)?;
|
||||
verify_workspace_runtime_verification_schema(conn)?;
|
||||
verify_workspace_deletion_schema(conn)?;
|
||||
@@ -19092,7 +19244,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
fixture.commit().unwrap();
|
||||
}
|
||||
|
||||
fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
|
||||
pub(super) fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
|
||||
prepare_schema_v52(path);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
@@ -19341,6 +19493,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
|
||||
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
|
||||
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
|
||||
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19456,6 +19609,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(103, "Passkey metadata and atomic addition results".to_string()),
|
||||
(104, "Converge Passkey results and Job input retention".to_string()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -22742,7 +22896,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into())]);
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
@@ -26936,6 +27091,7 @@ INSERT INTO worker_registry (
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
// This fixture deliberately reconstructs schema 102 from fresh.
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?;
|
||||
// Remove the newer Subject fence composition, not just its receipt.
|
||||
let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")?
|
||||
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -26947,7 +27103,7 @@ INSERT INTO worker_registry (
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=105;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106;
|
||||
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
|
||||
}).unwrap();
|
||||
drop(store);
|
||||
@@ -28259,3 +28415,7 @@ mod repository_key_migration_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "frozen_runtime_binding_tests.rs"]
|
||||
mod frozen_runtime_binding_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_catalog/migration_tests.rs"]
|
||||
mod plugin_catalog_migration_tests;
|
||||
|
||||
@@ -16,6 +16,11 @@ use crate::{Error, Result};
|
||||
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
|
||||
/// excluded so retries and audit remain available after the Workspace row is gone.
|
||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"plugin_references",
|
||||
"plugin_operations",
|
||||
"plugin_uploads",
|
||||
"plugin_artifacts",
|
||||
"plugin_installations",
|
||||
"artifacts",
|
||||
"audit_events",
|
||||
"backend_job_deliveries",
|
||||
@@ -1283,6 +1288,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_purge_removes_plugin_references_and_catalog_without_cross_workspace_loss() {
|
||||
let (store, owner, workspace_id) = setup();
|
||||
for workspace in ["workspace-a", "workspace-b"] {
|
||||
store.with_conn(|conn| {
|
||||
let digest = format!("sha256:{}", "a".repeat(64));
|
||||
conn.execute("INSERT INTO plugin_installations VALUES(?1,'installation','alias','package','operation',0)", [workspace])?;
|
||||
conn.execute("INSERT INTO plugin_artifacts VALUES(?1,'installation',?2,?2,'1',1,'{}',0,NULL)", params![workspace,digest])?;
|
||||
conn.execute("INSERT INTO plugin_uploads VALUES(?1,'upload','actor',9999999999,?2,1,'{}')", params![workspace,digest])?;
|
||||
conn.execute("INSERT INTO plugin_operations VALUES(?1,'operation','actor','install','key',?2,'{}')", params![workspace,"f".repeat(64)])?;
|
||||
for kind in ["config","pending","active","restorable"] {
|
||||
conn.execute("INSERT INTO plugin_references VALUES(?1,?2,'owner','installation',?3)", params![workspace,kind,digest])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
let counts = |workspace: &str| {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
[
|
||||
"plugin_references",
|
||||
"plugin_operations",
|
||||
"plugin_uploads",
|
||||
"plugin_artifacts",
|
||||
"plugin_installations",
|
||||
]
|
||||
.map(|table| {
|
||||
Ok(conn.query_row(
|
||||
&format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"),
|
||||
[workspace],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)?)
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let other = counts("workspace-b");
|
||||
assert_eq!(counts("workspace-a"), vec![4, 1, 1, 1, 1]);
|
||||
let preflight = store
|
||||
.workspace_deletion_preflight(&owner, &workspace_id)
|
||||
.unwrap();
|
||||
let operation = store
|
||||
.reserve_workspace_deletion(
|
||||
&owner,
|
||||
&workspace_id,
|
||||
&WorkspaceDeletionRequest {
|
||||
operation_id: "purge-plugins".into(),
|
||||
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
|
||||
confirmation: "Alpha".into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.operation;
|
||||
assert_eq!(
|
||||
store
|
||||
.finalize_workspace_deletion(&operation.operation_id)
|
||||
.unwrap()
|
||||
.state,
|
||||
WorkspaceDeletionState::Succeeded
|
||||
);
|
||||
assert_eq!(counts("workspace-a"), vec![0; 5]);
|
||||
assert_eq!(counts("workspace-b"), other);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_purge_removes_subject_deletion_snapshots_and_preserves_other_workspace() {
|
||||
let (store, owner, workspace_id) = setup();
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) enum CliCommand {
|
||||
Ticket,
|
||||
Objective,
|
||||
Plugin,
|
||||
PluginCatalog,
|
||||
Mcp,
|
||||
Session,
|
||||
Subject,
|
||||
@@ -49,7 +50,8 @@ impl CliCommand {
|
||||
CliCommand::WorkerCleanup => "yoi worker management",
|
||||
CliCommand::Ticket => "yoi ticket",
|
||||
CliCommand::Objective => "yoi objective",
|
||||
CliCommand::Plugin => "yoi plugin",
|
||||
CliCommand::Plugin => "yoi plugin authoring",
|
||||
CliCommand::PluginCatalog => "yoi plugin catalog",
|
||||
CliCommand::Mcp => "yoi mcp",
|
||||
CliCommand::Session => "yoi session",
|
||||
CliCommand::Subject => "yoi subject",
|
||||
@@ -65,7 +67,7 @@ impl CliCommand {
|
||||
| CliCommand::Panel
|
||||
| CliCommand::Ticket
|
||||
| CliCommand::Objective => CliConnectionRequirement::ConnectionAware,
|
||||
CliCommand::Login => CliConnectionRequirement::BackendOnly,
|
||||
CliCommand::Login | CliCommand::PluginCatalog => CliConnectionRequirement::BackendOnly,
|
||||
CliCommand::Keys
|
||||
| CliCommand::SetupModel
|
||||
| CliCommand::Serve
|
||||
@@ -317,10 +319,12 @@ mod tests {
|
||||
command.display_name()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
CliCommand::Login.connection_requirement(),
|
||||
CliConnectionRequirement::BackendOnly
|
||||
);
|
||||
for command in [CliCommand::Login, CliCommand::PluginCatalog] {
|
||||
assert_eq!(
|
||||
command.connection_requirement(),
|
||||
CliConnectionRequirement::BackendOnly
|
||||
);
|
||||
}
|
||||
|
||||
for command in [
|
||||
CliCommand::Keys,
|
||||
|
||||
+173
-14
@@ -44,6 +44,11 @@ enum Mode {
|
||||
WorkersHelp,
|
||||
Mcp(mcp_cli::McpCliCommand),
|
||||
Plugin(plugin_cli::PluginCliCommand),
|
||||
PluginCatalog {
|
||||
command: plugin_cli::PluginCatalogCommand,
|
||||
args: plugin_cli::PluginCliArgs,
|
||||
target: client::ResolvedTarget,
|
||||
},
|
||||
Objective {
|
||||
cli: objective_cli::ObjectiveCli,
|
||||
target: client::ResolvedTarget,
|
||||
@@ -184,6 +189,19 @@ async fn run(mode: Mode) -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::PluginCatalog {
|
||||
command,
|
||||
args,
|
||||
target,
|
||||
} => {
|
||||
let output = plugin_cli::run_catalog(command, args, target).await;
|
||||
print!("{}", output.stdout);
|
||||
if output.success {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
Mode::Objective { cli, target } => {
|
||||
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
|
||||
Ok(Ok(output)) => {
|
||||
@@ -612,9 +630,38 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
)?));
|
||||
}
|
||||
"plugin" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
|
||||
let plugin_cli = parse_plugin_args(&args[1..])?;
|
||||
return Ok(Mode::Plugin(plugin_cli));
|
||||
let cli = parse_plugin_args(&args[1..])?;
|
||||
if let plugin_cli::PluginCliCommand::Catalog { command, args } = cli {
|
||||
if target_selection.explicit_local {
|
||||
return Err(ParseError("yoi plugin catalog requires a Backend Workspace target and cannot use --local".into()));
|
||||
}
|
||||
let target = resolve_tui_target(
|
||||
connection_resolver,
|
||||
CliCommand::PluginCatalog,
|
||||
&target_selection,
|
||||
¤t_dir()?,
|
||||
)?
|
||||
.resolve()
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
if matches!(target, client::ResolvedTarget::Standalone) {
|
||||
return Err(ParseError(
|
||||
"yoi plugin catalog requires a Backend Workspace target".into(),
|
||||
));
|
||||
}
|
||||
return Ok(Mode::PluginCatalog {
|
||||
command,
|
||||
args,
|
||||
target,
|
||||
});
|
||||
}
|
||||
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
|
||||
return Err(backend_target_option_error_for_local_command(
|
||||
CliCommand::Plugin,
|
||||
"--backend/--workspace-id",
|
||||
));
|
||||
}
|
||||
// Explicit-path authoring has no connection or Workspace dependency.
|
||||
return Ok(Mode::Plugin(cli));
|
||||
}
|
||||
"login" => {
|
||||
if target_selection.explicit_local {
|
||||
@@ -1738,7 +1785,8 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
"yoi plugin requires `new`, `check`, or `pack`".to_string(),
|
||||
"yoi plugin requires an authoring or catalog command; see `yoi plugin --help`"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
match subcommand.as_str() {
|
||||
@@ -1790,6 +1838,16 @@ fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, Pa
|
||||
)),
|
||||
}
|
||||
}
|
||||
"upload" | "install" | "list" | "show" | "add-artifact" | "revoke" | "uninstall"
|
||||
| "operation" => {
|
||||
if rest
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.as_str(), "--help" | "-h"))
|
||||
{
|
||||
return Err(ParseError(plugin_usage().to_string()));
|
||||
}
|
||||
plugin_cli::parse_catalog_args(subcommand, rest).map_err(ParseError)
|
||||
}
|
||||
"--help" | "-h" => Err(ParseError(plugin_usage().to_string())),
|
||||
other => Err(ParseError(format!(
|
||||
"unknown yoi plugin subcommand `{other}`"
|
||||
@@ -1847,7 +1905,7 @@ fn parse_plugin_pack_args(
|
||||
}
|
||||
|
||||
fn plugin_usage() -> &'static str {
|
||||
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
|
||||
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n\nCatalog commands require a Backend Workspace target (select --backend/--workspace-id before `plugin`):\n yoi [TARGET] plugin upload <package> [--json]\n yoi [TARGET] plugin install --upload-id <id> --alias <alias> --operation-key <key> [--json]\n yoi [TARGET] plugin list [--cursor <cursor>] [--limit <1..100>] [--json]\n yoi [TARGET] plugin show <installation-id> [--json]\n yoi [TARGET] plugin add-artifact <installation-id> --upload-id <id> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin revoke <installation-id> --artifact-digest <digest> --reason <reason> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin uninstall <installation-id> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin operation <operation-id> [--json]\nUpload/install only register artifacts; they do not enable or start Workers. Use the observed last_operation from show; a replay receipt is not a fresh observation. Reconcile uncertain mutations before retrying with the same key and exact input."
|
||||
}
|
||||
|
||||
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
|
||||
@@ -2083,7 +2141,7 @@ Host commands:
|
||||
worker delete/prune Safely clean up legacy Worker metadata only
|
||||
ticket <COMMAND> Manage Tickets through a Backend target
|
||||
objective <COMMAND> Manage Objectives through a Backend target
|
||||
plugin <COMMAND> Author/check/pack explicit Plugin packages
|
||||
plugin <COMMAND> Author packages or manage Backend Plugin catalog
|
||||
mcp <COMMAND> Inspect configured MCP servers
|
||||
session <COMMAND> Inspect/prune Standalone session logs
|
||||
--local subject <COMMAND> Explicitly create/list local Subjects (create <ROLE>, list)
|
||||
@@ -2514,11 +2572,9 @@ backend = "shared"
|
||||
assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\""));
|
||||
assert!(TOP_LEVEL_HELP.contains("yoi-server"));
|
||||
assert!(TOP_LEVEL_HELP.contains("yoi-runtime"));
|
||||
assert!(
|
||||
TOP_LEVEL_HELP.contains(
|
||||
"plugin <COMMAND> Author/check/pack explicit Plugin packages"
|
||||
)
|
||||
);
|
||||
assert!(TOP_LEVEL_HELP.contains(
|
||||
"plugin <COMMAND> Author packages or manage Backend Plugin catalog"
|
||||
));
|
||||
assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins"));
|
||||
assert!(TOP_LEVEL_HELP.contains("yoi workspace create"));
|
||||
assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve"));
|
||||
@@ -3808,10 +3864,8 @@ backend = "shared"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
|
||||
fn plugin_cli_rejects_ambient_catalog_options() {
|
||||
for args in [
|
||||
vec!["plugin", "list"],
|
||||
vec!["plugin", "show", "project:echo"],
|
||||
vec!["plugin", "check", "plugin", "--workspace=/tmp/ws"],
|
||||
vec!["plugin", "check", "plugin", "--profile", "project:inspect"],
|
||||
] {
|
||||
@@ -3831,6 +3885,111 @@ backend = "shared"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_authoring_never_resolves_a_connection_or_workspace() {
|
||||
struct NoConnection;
|
||||
impl CliConnectionResolver for NoConnection {
|
||||
fn resolve_connection(
|
||||
&self,
|
||||
_: CliCommand,
|
||||
_: cli_connection::CliConnectionInput<'_>,
|
||||
) -> Result<Box<dyn Target>, ParseError> {
|
||||
panic!("offline Plugin authoring must not resolve a connection")
|
||||
}
|
||||
}
|
||||
for args in [
|
||||
vec!["plugin", "new", "rust-component-tool", "destination"],
|
||||
vec!["plugin", "check", "explicit.yoi-plugin"],
|
||||
vec![
|
||||
"--local",
|
||||
"plugin",
|
||||
"pack",
|
||||
"explicit",
|
||||
"--output=explicit.yoi-plugin",
|
||||
],
|
||||
] {
|
||||
let argv = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert!(matches!(
|
||||
parse_args_slice_with_connection_resolver(&argv, &NoConnection).unwrap(),
|
||||
Mode::Plugin(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_catalog_uses_backend_workspace_selection_and_rejects_local() {
|
||||
let resolver = DefaultBackendCliConnectionResolver {
|
||||
backend_url: "http://selected.example",
|
||||
};
|
||||
for args in [
|
||||
vec!["plugin", "list"],
|
||||
vec![
|
||||
"--backend",
|
||||
"http://selected.example",
|
||||
"plugin",
|
||||
"show",
|
||||
"installation",
|
||||
],
|
||||
vec![
|
||||
"--workspace-id",
|
||||
"explicit-workspace",
|
||||
"plugin",
|
||||
"operation",
|
||||
"operation",
|
||||
],
|
||||
] {
|
||||
let argv = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
match parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap() {
|
||||
Mode::PluginCatalog {
|
||||
target:
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
},
|
||||
..
|
||||
} => {
|
||||
assert_eq!(base_url, "http://selected.example");
|
||||
assert_eq!(
|
||||
workspace_id,
|
||||
if args.contains(&"--workspace-id") {
|
||||
"explicit-workspace"
|
||||
} else {
|
||||
"workspace-from-backend"
|
||||
}
|
||||
);
|
||||
}
|
||||
other => panic!("expected Backend Plugin catalog mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
let argv = ["--local", "plugin", "list"].map(str::to_string);
|
||||
assert!(
|
||||
parse_args_slice_with_connection_resolver(&argv, &resolver)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("requires a Backend")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_explicit_workspace_does_not_discover_repository_and_offline_backend_is_not_local() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://selected.example",
|
||||
};
|
||||
let argv = ["--workspace-id", "explicit", "plugin", "list"].map(str::to_string);
|
||||
// This resolver has no repository discovery implementation.
|
||||
assert!(matches!(
|
||||
parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap(),
|
||||
Mode::PluginCatalog { .. }
|
||||
));
|
||||
let argv = ["plugin", "list"].map(str::to_string);
|
||||
assert!(
|
||||
parse_args_slice_with_connection_resolver(&argv, &OfflineBackendCliConnectionResolver)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("Backend is offline")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mcp_commands() {
|
||||
match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::{self, Read as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use manifest::plugin::{
|
||||
@@ -38,6 +38,360 @@ pub(crate) enum PluginCliCommand {
|
||||
output: Option<PathBuf>,
|
||||
args: PluginCliArgs,
|
||||
},
|
||||
Catalog {
|
||||
command: PluginCatalogCommand,
|
||||
args: PluginCliArgs,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum PluginCatalogCommand {
|
||||
Upload(PathBuf),
|
||||
Install(server_api::PluginInstallRequest),
|
||||
List(server_api::PluginListRequest),
|
||||
Show(String),
|
||||
AddArtifact {
|
||||
installation_id: String,
|
||||
request: server_api::PluginAddArtifactRequest,
|
||||
},
|
||||
Revoke {
|
||||
installation_id: String,
|
||||
request: server_api::PluginRevokeRequest,
|
||||
},
|
||||
Uninstall {
|
||||
installation_id: String,
|
||||
request: server_api::PluginUninstallRequest,
|
||||
},
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
pub(crate) fn parse_catalog_args(
|
||||
subcommand: &str,
|
||||
args: &[String],
|
||||
) -> std::result::Result<PluginCliCommand, String> {
|
||||
let allowed: &[&str] = match subcommand {
|
||||
"upload" | "show" | "operation" => &[],
|
||||
"install" => &["--upload-id", "--alias", "--operation-key"],
|
||||
"list" => &["--cursor", "--limit"],
|
||||
"add-artifact" => &[
|
||||
"--upload-id",
|
||||
"--expected-last-operation-id",
|
||||
"--operation-key",
|
||||
],
|
||||
"revoke" => &[
|
||||
"--artifact-digest",
|
||||
"--reason",
|
||||
"--expected-last-operation-id",
|
||||
"--operation-key",
|
||||
],
|
||||
"uninstall" => &["--expected-last-operation-id", "--operation-key"],
|
||||
_ => return Err(format!("unknown yoi plugin subcommand `{subcommand}`")),
|
||||
};
|
||||
let mut options = std::collections::BTreeMap::new();
|
||||
let mut positional = Vec::new();
|
||||
let mut json = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--json" {
|
||||
json = true;
|
||||
} else if arg.starts_with('-') {
|
||||
let (name, inline) = arg
|
||||
.split_once('=')
|
||||
.map_or((arg.as_str(), None), |(k, v)| (k, Some(v)));
|
||||
if !allowed.contains(&name) {
|
||||
return Err(format!("unknown yoi plugin {subcommand} option `{name}`"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
index += 1;
|
||||
args.get(index)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| format!("{name} requires a value"))?
|
||||
}
|
||||
};
|
||||
if value.trim().is_empty() || value.starts_with('-') {
|
||||
return Err(format!("{name} requires a nonempty value"));
|
||||
}
|
||||
if options
|
||||
.insert(name.to_string(), value.to_string())
|
||||
.is_some()
|
||||
{
|
||||
return Err(format!("{name} must not be provided more than once"));
|
||||
}
|
||||
} else {
|
||||
positional.push(arg.clone());
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
let required = |name: &str| -> std::result::Result<String, String> {
|
||||
options
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("yoi plugin {subcommand} requires {name}"))
|
||||
};
|
||||
let one_positional = || -> std::result::Result<String, String> {
|
||||
match positional.as_slice() {
|
||||
[value] if !value.trim().is_empty() => Ok(value.clone()),
|
||||
_ => Err(format!(
|
||||
"yoi plugin {subcommand} requires exactly one {}",
|
||||
if subcommand == "upload" {
|
||||
"package path"
|
||||
} else if subcommand == "operation" {
|
||||
"operation ID"
|
||||
} else {
|
||||
"installation ID"
|
||||
}
|
||||
)),
|
||||
}
|
||||
};
|
||||
if matches!(subcommand, "install" | "list") && !positional.is_empty() {
|
||||
return Err(format!(
|
||||
"yoi plugin {subcommand} accepts no positional arguments"
|
||||
));
|
||||
}
|
||||
let command = match subcommand {
|
||||
"upload" => PluginCatalogCommand::Upload(PathBuf::from(one_positional()?)),
|
||||
"install" => PluginCatalogCommand::Install(server_api::PluginInstallRequest {
|
||||
upload_id: required("--upload-id")?,
|
||||
alias: required("--alias")?,
|
||||
operation_key: required("--operation-key")?,
|
||||
}),
|
||||
"list" => {
|
||||
let limit = options
|
||||
.get("--limit")
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|n| (1..=100).contains(n))
|
||||
.ok_or_else(|| "--limit must be between 1 and 100".to_string())
|
||||
})
|
||||
.transpose()?;
|
||||
PluginCatalogCommand::List(server_api::PluginListRequest {
|
||||
cursor: options.get("--cursor").cloned(),
|
||||
limit,
|
||||
})
|
||||
}
|
||||
"show" => PluginCatalogCommand::Show(one_positional()?),
|
||||
"add-artifact" => PluginCatalogCommand::AddArtifact {
|
||||
installation_id: one_positional()?,
|
||||
request: server_api::PluginAddArtifactRequest {
|
||||
upload_id: required("--upload-id")?,
|
||||
expected_last_operation: required("--expected-last-operation-id")?,
|
||||
operation_key: required("--operation-key")?,
|
||||
},
|
||||
},
|
||||
"revoke" => PluginCatalogCommand::Revoke {
|
||||
installation_id: one_positional()?,
|
||||
request: server_api::PluginRevokeRequest {
|
||||
artifact_digest: required("--artifact-digest")?,
|
||||
reason: required("--reason")?,
|
||||
expected_last_operation: required("--expected-last-operation-id")?,
|
||||
operation_key: required("--operation-key")?,
|
||||
},
|
||||
},
|
||||
"uninstall" => PluginCatalogCommand::Uninstall {
|
||||
installation_id: one_positional()?,
|
||||
request: server_api::PluginUninstallRequest {
|
||||
expected_last_operation: required("--expected-last-operation-id")?,
|
||||
operation_key: required("--operation-key")?,
|
||||
},
|
||||
},
|
||||
"operation" => PluginCatalogCommand::Operation(one_positional()?),
|
||||
_ => unreachable!("validated subcommand"),
|
||||
};
|
||||
Ok(PluginCliCommand::Catalog {
|
||||
command,
|
||||
args: PluginCliArgs { json },
|
||||
})
|
||||
}
|
||||
|
||||
// Use the shared credential resolver without exposing token storage or adding a
|
||||
// second login path. The generated client owns path encoding and response bounds.
|
||||
struct PluginAuthorizer(client::BackendApiClient);
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for PluginAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> std::result::Result<
|
||||
reqwest::header::HeaderMap,
|
||||
server_api::client_support::AuthorizationError,
|
||||
> {
|
||||
let prepared = self
|
||||
.0
|
||||
.request(request.method.clone(), request.path_and_query)
|
||||
.and_then(|builder| builder.build().map_err(client::BackendApiClientError::Http))
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?;
|
||||
Ok(prepared.headers().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CatalogFailure {
|
||||
status: Option<u16>,
|
||||
error: String,
|
||||
message: String,
|
||||
diagnostics: Vec<server_api::Diagnostic>,
|
||||
}
|
||||
|
||||
impl CatalogFailure {
|
||||
fn local(error: impl std::fmt::Display) -> Self {
|
||||
Self {
|
||||
status: None,
|
||||
error: "plugin_cli".into(),
|
||||
message: error.to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
|
||||
for CatalogFailure
|
||||
{
|
||||
fn from(
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> Self {
|
||||
match error {
|
||||
server_api::client_support::ClientError::Public { status, error } => Self {
|
||||
status: Some(status.as_u16()),
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
diagnostics: error.diagnostics,
|
||||
},
|
||||
server_api::client_support::ClientError::Failure(error) => Self::local(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogOutput {
|
||||
pub success: bool,
|
||||
pub stdout: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_catalog(
|
||||
command: PluginCatalogCommand,
|
||||
args: PluginCliArgs,
|
||||
target: client::ResolvedTarget,
|
||||
) -> CatalogOutput {
|
||||
let result = async {
|
||||
let client::ResolvedTarget::Backend { base_url, workspace_id } = target else {
|
||||
return Err(CatalogFailure::local("Plugin catalog requires a Backend Workspace target; use --backend and --workspace-id"));
|
||||
};
|
||||
let backend = client::BackendApiClient::from_stored_token(&base_url).map_err(CatalogFailure::local)?;
|
||||
// No redirect may forward a bearer credential to another origin.
|
||||
let http = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
|
||||
.build().map_err(CatalogFailure::local)?;
|
||||
let api = server_api::ServerApiClient::builder(backend.origin().as_str())
|
||||
.map_err(CatalogFailure::local)?.client(http).authorizer(PluginAuthorizer(backend))
|
||||
.response_body_limit(16 * 1024 * 1024).build().map_err(CatalogFailure::local)?;
|
||||
execute_catalog(command, &api, workspace_id).await
|
||||
}.await;
|
||||
catalog_output(result, args.json)
|
||||
}
|
||||
|
||||
fn catalog_output(
|
||||
result: std::result::Result<serde_json::Value, CatalogFailure>,
|
||||
json: bool,
|
||||
) -> CatalogOutput {
|
||||
let (success, value) = match result {
|
||||
Ok(value) => (true, value),
|
||||
Err(error) => (
|
||||
false,
|
||||
serde_json::json!({
|
||||
"status": "failed", "diagnostic": error,
|
||||
"reconciliation": "A failed response does not prove a mutation was not committed. Reconcile the saved operation; retry only with the same operation key and exact input. Do not refresh expected-last-operation-id for a replay."
|
||||
}),
|
||||
),
|
||||
};
|
||||
let rendered = serde_json::to_string_pretty(&value).expect("JSON value is serializable");
|
||||
let stdout = if json {
|
||||
format!("{rendered}\n")
|
||||
} else {
|
||||
format!(
|
||||
"plugin catalog: {}\n{rendered}\n",
|
||||
if success {
|
||||
"backend response (registration does not enable or start a Worker)"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
)
|
||||
};
|
||||
CatalogOutput { success, stdout }
|
||||
}
|
||||
|
||||
async fn execute_catalog<A: server_api::client_support::RequestAuthorizer>(
|
||||
command: PluginCatalogCommand,
|
||||
api: &server_api::ServerApiClient<A>,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<serde_json::Value, CatalogFailure> {
|
||||
let value = match command {
|
||||
PluginCatalogCommand::Upload(input) => {
|
||||
let bytes = read_upload(&input).map_err(CatalogFailure::local)?;
|
||||
serde_json::to_value(api.plugin_upload(workspace_id, bytes.into()).await?)
|
||||
}
|
||||
PluginCatalogCommand::Install(request) => {
|
||||
serde_json::to_value(api.plugin_install(workspace_id, request).await?)
|
||||
}
|
||||
PluginCatalogCommand::List(query) => {
|
||||
serde_json::to_value(api.plugin_list(workspace_id, query).await?)
|
||||
}
|
||||
PluginCatalogCommand::Show(id) => {
|
||||
serde_json::to_value(api.plugin_show(workspace_id, id).await?)
|
||||
}
|
||||
PluginCatalogCommand::AddArtifact {
|
||||
installation_id,
|
||||
request,
|
||||
} => serde_json::to_value(
|
||||
api.plugin_artifact_add(workspace_id, installation_id, request)
|
||||
.await?,
|
||||
),
|
||||
PluginCatalogCommand::Revoke {
|
||||
installation_id,
|
||||
request,
|
||||
} => serde_json::to_value(
|
||||
api.plugin_revoke(workspace_id, installation_id, request)
|
||||
.await?,
|
||||
),
|
||||
PluginCatalogCommand::Uninstall {
|
||||
installation_id,
|
||||
request,
|
||||
} => serde_json::to_value(
|
||||
api.plugin_uninstall(workspace_id, installation_id, request)
|
||||
.await?,
|
||||
),
|
||||
PluginCatalogCommand::Operation(id) => {
|
||||
serde_json::to_value(api.plugin_operation(workspace_id, id).await?)
|
||||
}
|
||||
};
|
||||
value.map_err(CatalogFailure::local)
|
||||
}
|
||||
|
||||
fn read_upload(input: &Path) -> Result<Vec<u8>> {
|
||||
let limit = PluginPackageLimits::default().max_package_size_bytes;
|
||||
let file = fs::File::open(input)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(
|
||||
"Plugin upload requires an explicit .yoi-plugin regular file; pack directories first"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if metadata.len() > limit {
|
||||
return Err(
|
||||
format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(),
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(limit + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err(
|
||||
format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(),
|
||||
);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
||||
@@ -62,6 +416,9 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
||||
output,
|
||||
args,
|
||||
} => render_pack(&input, output.as_deref(), &args)?,
|
||||
PluginCliCommand::Catalog { .. } => {
|
||||
return Err("Plugin catalog requires Backend dispatch".into());
|
||||
}
|
||||
};
|
||||
print!("{rendered}");
|
||||
Ok(())
|
||||
@@ -571,6 +928,422 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn catalog_command(subcommand: &str, args: &[&str]) -> PluginCatalogCommand {
|
||||
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
match parse_catalog_args(subcommand, &args).unwrap() {
|
||||
PluginCliCommand::Catalog { command, .. } => command,
|
||||
_ => panic!("expected catalog command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_parser_requires_explicit_mutation_keys_and_observed_operations() {
|
||||
for (subcommand, args, missing) in [
|
||||
(
|
||||
"install",
|
||||
vec!["--upload-id", "upload", "--alias", "alias"],
|
||||
"--operation-key",
|
||||
),
|
||||
(
|
||||
"add-artifact",
|
||||
vec![
|
||||
"installation",
|
||||
"--upload-id",
|
||||
"upload",
|
||||
"--operation-key",
|
||||
"key",
|
||||
],
|
||||
"--expected-last-operation-id",
|
||||
),
|
||||
(
|
||||
"revoke",
|
||||
vec![
|
||||
"installation",
|
||||
"--artifact-digest",
|
||||
"sha256:artifact",
|
||||
"--reason",
|
||||
"reason",
|
||||
"--operation-key",
|
||||
"key",
|
||||
],
|
||||
"--expected-last-operation-id",
|
||||
),
|
||||
(
|
||||
"uninstall",
|
||||
vec!["installation", "--operation-key", "key"],
|
||||
"--expected-last-operation-id",
|
||||
),
|
||||
(
|
||||
"uninstall",
|
||||
vec!["installation", "--expected-last-operation-id", "observed"],
|
||||
"--operation-key",
|
||||
),
|
||||
] {
|
||||
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||
assert!(
|
||||
parse_catalog_args(subcommand, &args)
|
||||
.unwrap_err()
|
||||
.contains(missing)
|
||||
);
|
||||
}
|
||||
for args in [
|
||||
vec![
|
||||
"installation",
|
||||
"--operation-key=",
|
||||
"--expected-last-operation-id=observed",
|
||||
],
|
||||
vec![
|
||||
"installation",
|
||||
"--operation-key=key",
|
||||
"--operation-key=other",
|
||||
"--expected-last-operation-id=observed",
|
||||
],
|
||||
vec![
|
||||
"installation",
|
||||
"--operation-key=key",
|
||||
"--expected-last-operation-id= ",
|
||||
],
|
||||
vec![
|
||||
"installation",
|
||||
"--operation-key=key",
|
||||
"--expected-last-operation-id=observed",
|
||||
"--operator",
|
||||
],
|
||||
] {
|
||||
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||
assert!(parse_catalog_args("uninstall", &args).is_err());
|
||||
}
|
||||
for limit in ["0", "101", "-1", "not-a-number"] {
|
||||
assert!(parse_catalog_args("list", &[format!("--limit={limit}")]).is_err());
|
||||
}
|
||||
assert!(parse_catalog_args("list", &["extra".into()]).is_err());
|
||||
assert!(parse_catalog_args("show", &[]).is_err());
|
||||
assert!(parse_catalog_args("operation", &["one".into(), "two".into()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_reads_explicit_regular_bytes_with_the_shared_package_bound() {
|
||||
let dir = tempdir().unwrap();
|
||||
let input = dir.path().join("package.yoi-plugin");
|
||||
let bytes = b"exact uploaded bytes\0";
|
||||
fs::write(&input, bytes).unwrap();
|
||||
assert_eq!(read_upload(&input).unwrap(), bytes);
|
||||
assert!(
|
||||
read_upload(dir.path())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("regular file")
|
||||
);
|
||||
let file = fs::File::create(&input).unwrap();
|
||||
file.set_len(PluginPackageLimits::default().max_package_size_bytes + 1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
read_upload(&input)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("shared package limit")
|
||||
);
|
||||
}
|
||||
|
||||
// The real argv parser, generated HTTP client and output conversion are connected
|
||||
// here. Only the remote Server and credential issuance are replaced; this does
|
||||
// not attest Server authentication, catalog persistence or domain validation.
|
||||
mod catalog_http {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
struct TestAuthorizer;
|
||||
impl server_api::client_support::RequestAuthorizer for TestAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> std::result::Result<
|
||||
reqwest::header::HeaderMap,
|
||||
server_api::client_support::AuthorizationError,
|
||||
> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
"Bearer test-only".parse().unwrap(),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
async fn server(
|
||||
status: u16,
|
||||
response: serde_json::Value,
|
||||
) -> (String, tokio::task::JoinHandle<(String, Vec<u8>)>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
tokio::time::timeout(Duration::from_secs(10), async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let (headers, body_start, content_len) = loop {
|
||||
let mut buf = [0; 4096];
|
||||
let len = stream.read(&mut buf).await.unwrap();
|
||||
assert_ne!(len, 0, "request ended before headers");
|
||||
request.extend_from_slice(&buf[..len]);
|
||||
if let Some(end) = request.windows(4).position(|b| b == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(request[..end].to_vec()).unwrap();
|
||||
let content_len = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().unwrap())
|
||||
}).unwrap_or(0);
|
||||
break (headers, end + 4, content_len);
|
||||
}
|
||||
};
|
||||
while request.len() - body_start < content_len {
|
||||
let mut buf = [0; 4096];
|
||||
let len = stream.read(&mut buf).await.unwrap();
|
||||
assert_ne!(len, 0, "request ended before body");
|
||||
request.extend_from_slice(&buf[..len]);
|
||||
}
|
||||
let body = serde_json::to_vec(&response).unwrap();
|
||||
stream.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap();
|
||||
stream.write_all(&body).await.unwrap();
|
||||
(headers, request[body_start..].to_vec())
|
||||
}).await.expect("bounded mock Server request")
|
||||
});
|
||||
(origin, task)
|
||||
}
|
||||
|
||||
fn installation() -> serde_json::Value {
|
||||
serde_json::json!({"installation_id":"inst/one", "alias":"alias", "package_id":"example", "last_operation":"saved-operation", "uninstalled":false, "artifacts":[]})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_commands_send_exact_workspace_paths_query_and_caller_mutation_inputs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let package = dir.path().join("explicit.yoi-plugin");
|
||||
fs::write(&package, b"exact\0archive bytes").unwrap();
|
||||
let upload_path = package.to_str().unwrap();
|
||||
let operation = |kind| serde_json::json!({"operation_id":"saved-operation", "kind":kind, "installation":installation()});
|
||||
let mut summary = installation();
|
||||
summary.as_object_mut().unwrap().remove("artifacts");
|
||||
let upload = serde_json::json!({"upload_id":"upload", "expires_at":"2026-10-30T00:00:00Z", "artifact":{
|
||||
"package_id":"example", "name":"Example", "version":"1.0.0", "description":null,
|
||||
"package_digest":"sha256:logical", "artifact_digest":"sha256:archive", "artifact_size":19,
|
||||
"schema_version":2, "world":"yoi:plugin/tool-plugin@2.0.0", "required_imports":[], "tools":[],
|
||||
"connections":[], "requested_grants":[], "config_schema":{"identity":"example", "version":"1", "digest":"sha256:schema", "namespace":"example", "dcdl":"schema"},
|
||||
"revoked":false, "revocation_reason":null
|
||||
}});
|
||||
let cases = vec![
|
||||
(
|
||||
"upload",
|
||||
vec![upload_path],
|
||||
"POST",
|
||||
"/api/w/ws%20space/plugin-uploads",
|
||||
None,
|
||||
upload,
|
||||
),
|
||||
(
|
||||
"install",
|
||||
vec![
|
||||
"--upload-id=upload",
|
||||
"--alias=alias",
|
||||
"--operation-key=exact-key",
|
||||
],
|
||||
"POST",
|
||||
"/api/w/ws%20space/plugins",
|
||||
Some(
|
||||
serde_json::json!({"upload_id":"upload", "alias":"alias", "operation_key":"exact-key"}),
|
||||
),
|
||||
operation("install"),
|
||||
),
|
||||
(
|
||||
"list",
|
||||
vec!["--cursor=next /+=", "--limit=7"],
|
||||
"GET",
|
||||
"/api/w/ws%20space/plugins",
|
||||
None,
|
||||
serde_json::json!({"installations":[summary], "next_cursor":"opaque-next"}),
|
||||
),
|
||||
(
|
||||
"show",
|
||||
vec!["inst/one"],
|
||||
"GET",
|
||||
"/api/w/ws%20space/plugins/inst%2Fone",
|
||||
None,
|
||||
installation(),
|
||||
),
|
||||
(
|
||||
"add-artifact",
|
||||
vec![
|
||||
"inst/one",
|
||||
"--upload-id=upload",
|
||||
"--expected-last-operation-id=observed-operation",
|
||||
"--operation-key=exact-key",
|
||||
],
|
||||
"POST",
|
||||
"/api/w/ws%20space/plugins/inst%2Fone/artifacts",
|
||||
Some(
|
||||
serde_json::json!({"upload_id":"upload", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
|
||||
),
|
||||
operation("add_artifact"),
|
||||
),
|
||||
(
|
||||
"revoke",
|
||||
vec![
|
||||
"inst/one",
|
||||
"--artifact-digest=sha256:archive",
|
||||
"--reason=explicit reason",
|
||||
"--expected-last-operation-id=observed-operation",
|
||||
"--operation-key=exact-key",
|
||||
],
|
||||
"POST",
|
||||
"/api/w/ws%20space/plugins/inst%2Fone/revoke",
|
||||
Some(
|
||||
serde_json::json!({"artifact_digest":"sha256:archive", "reason":"explicit reason", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
|
||||
),
|
||||
operation("revoke"),
|
||||
),
|
||||
(
|
||||
"uninstall",
|
||||
vec![
|
||||
"inst/one",
|
||||
"--expected-last-operation-id=observed-operation",
|
||||
"--operation-key=exact-key",
|
||||
],
|
||||
"POST",
|
||||
"/api/w/ws%20space/plugins/inst%2Fone/uninstall",
|
||||
Some(
|
||||
serde_json::json!({"expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
|
||||
),
|
||||
operation("uninstall"),
|
||||
),
|
||||
(
|
||||
"operation",
|
||||
vec!["saved/operation"],
|
||||
"GET",
|
||||
"/api/w/ws%20space/plugin-operations/saved%2Foperation",
|
||||
None,
|
||||
operation("install"),
|
||||
),
|
||||
];
|
||||
for (subcommand, args, method, path, expected_body, response) in cases {
|
||||
let (origin, captured) = server(200, response.clone()).await;
|
||||
let api = server_api::ServerApiClient::builder(origin.as_str())
|
||||
.unwrap()
|
||||
.authorizer(TestAuthorizer)
|
||||
.build()
|
||||
.unwrap();
|
||||
let result =
|
||||
execute_catalog(catalog_command(subcommand, &args), &api, "ws space".into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result, response,
|
||||
"{subcommand} output must retain authoritative metadata"
|
||||
);
|
||||
let output = catalog_output(Ok(result), true);
|
||||
assert!(output.success);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&output.stdout).unwrap(),
|
||||
response
|
||||
);
|
||||
let (headers, body) = captured.await.unwrap();
|
||||
let mut start = headers.lines().next().unwrap().split_whitespace();
|
||||
assert_eq!(start.next(), Some(method));
|
||||
let url =
|
||||
reqwest::Url::parse(&format!("{origin}{}", start.next().unwrap())).unwrap();
|
||||
assert_eq!(url.path(), path, "{subcommand}");
|
||||
assert!(
|
||||
headers
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer test-only")
|
||||
);
|
||||
if subcommand == "list" {
|
||||
let query = url
|
||||
.query_pairs()
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(query.get("cursor").unwrap(), "next /+=");
|
||||
assert_eq!(query.get("limit").unwrap(), "7");
|
||||
}
|
||||
if subcommand == "upload" {
|
||||
assert_eq!(body, b"exact\0archive bytes");
|
||||
assert!(headers.contains("application/octet-stream"));
|
||||
} else if let Some(expected) = expected_body {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
|
||||
expected
|
||||
);
|
||||
} else {
|
||||
assert!(body.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_rejections_keep_concrete_diagnostics_without_retry() {
|
||||
for (status, code) in [
|
||||
(401, "unauthenticated"),
|
||||
(403, "forbidden"),
|
||||
(409, "plugin_operation_conflict"),
|
||||
(413, "plugin_upload_too_large"),
|
||||
] {
|
||||
let diagnostics = serde_json::json!([{"code":"catalog_rejected", "severity":"error", "message":"safe blocker detail"}]);
|
||||
let response = serde_json::json!({"error":code,"message":"concrete rejection detail","diagnostics":diagnostics});
|
||||
let (origin, captured) = server(status, response).await;
|
||||
let api = server_api::ServerApiClient::builder(origin.as_str())
|
||||
.unwrap()
|
||||
.authorizer(TestAuthorizer)
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = execute_catalog(
|
||||
catalog_command(
|
||||
"uninstall",
|
||||
&[
|
||||
"inst",
|
||||
"--expected-last-operation-id=observed",
|
||||
"--operation-key=exact",
|
||||
],
|
||||
),
|
||||
&api,
|
||||
"ws".into(),
|
||||
)
|
||||
.await;
|
||||
let output = catalog_output(result, true);
|
||||
assert!(!output.success);
|
||||
let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
|
||||
assert_eq!(report["diagnostic"]["status"], status);
|
||||
assert_eq!(report["diagnostic"]["error"], code);
|
||||
assert_eq!(report["diagnostic"]["message"], "concrete rejection detail");
|
||||
assert_eq!(report["diagnostic"]["diagnostics"], diagnostics);
|
||||
assert!(
|
||||
report["reconciliation"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("same operation key and exact input")
|
||||
);
|
||||
let (_, body) = captured.await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(body["expected_last_operation"], "observed");
|
||||
assert_eq!(body["operation_key"], "exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_standalone_target_is_rejected_with_json_diagnostic() {
|
||||
let output = run_catalog(
|
||||
catalog_command("list", &[]),
|
||||
PluginCliArgs { json: true },
|
||||
client::ResolvedTarget::Standalone,
|
||||
)
|
||||
.await;
|
||||
assert!(!output.success);
|
||||
let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
|
||||
assert!(
|
||||
report["diagnostic"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Backend Workspace target")
|
||||
);
|
||||
}
|
||||
|
||||
fn built_tool_package(destination: &Path) {
|
||||
render_new(
|
||||
"rust-component-tool",
|
||||
|
||||
Reference in New Issue
Block a user