1use std::{
2 error::Error,
3 fmt,
4 fmt::Write as _,
5 fs,
6 path::{Path, PathBuf},
7 str,
8 str::FromStr,
9 sync::{Arc, Mutex},
10};
11
12use crate::host_authoring::{HOST_BUILD, HostInput};
13use anyhow::{Context, bail};
14use lenso_app_plan::authoring::{
15 DependencyChoice, PluginInstanceId, PluginRootInstance, PluginRootResolutionError,
16 PluginRootSnapshot, ResolvedApp,
17};
18use serde::Serialize;
19use sha2::{Digest, Sha256};
20
21use super::{
22 DEPENDENCY_SELECTIONS, DEPENDENCY_SELECTIONS_SCHEMA_VERSION, DependencySelectionsDocument,
23 HOST_CATALOG, LEGACY_DEPENDENCY_SELECTIONS, MAX_CONFIGURATION_BYTES, PLUGIN_ROOT,
24 PluginRootAuthoringState, inspect_plugin_root, load_host_catalog, lock_plugin_root,
25 root_transaction, snapshot_plugin_root, validate_existing_plugin_id,
26 validate_instance_filename, validate_requirement_id,
27};
28
29const PROPOSAL_SCHEMA: &str = "lenso.plugin-configuration-proposal.v1";
30const PUBLICATION_SCHEMA: &str = "lenso.plugin-configuration-publication.v1";
31const SOURCE_DIGEST_SCHEMA: &str = "lenso.plugin-configuration-source.v1";
32const ROOT_CHANGE_PROPOSAL_SCHEMA: &str = "lenso.plugin-root-change-proposal.v1";
33const ROOT_CHANGE_PUBLICATION_SCHEMA: &str = "lenso.plugin-root-change-publication.v1";
34const ROOT_SOURCE_DIGEST_SCHEMA: &str = "lenso.plugin-root-source.v1";
35
36#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct PluginConfigurationAuthoritySource {
39 kind: String,
40 reference: String,
41}
42
43impl PluginConfigurationAuthoritySource {
44 pub fn new(kind: impl Into<String>, reference: impl Into<String>) -> anyhow::Result<Self> {
46 let kind = kind.into();
47 let reference = reference.into();
48 if kind.is_empty()
49 || kind.len() > 64
50 || !kind.bytes().all(|byte| {
51 byte.is_ascii_lowercase()
52 || byte.is_ascii_digit()
53 || matches!(byte, b'_' | b'-' | b'.')
54 })
55 {
56 bail!("Plugin configuration authority kind is invalid");
57 }
58 if reference.is_empty() || reference.len() > 256 || reference.chars().any(char::is_control)
59 {
60 bail!("Plugin configuration authority reference is invalid");
61 }
62 Ok(Self { kind, reference })
63 }
64
65 pub fn kind(&self) -> &str {
66 &self.kind
67 }
68
69 pub fn reference(&self) -> &str {
70 &self.reference
71 }
72}
73
74pub trait PluginConfigurationAuthority: fmt::Debug + Send + Sync {
79 fn source(&self) -> PluginConfigurationAuthoritySource;
80
81 fn inspect(&self) -> anyhow::Result<PluginRootAuthoringState>;
82
83 fn propose(
84 &self,
85 expected_revision: &PluginRootRevision,
86 plugin_id: &str,
87 instance: &str,
88 bytes: &[u8],
89 ) -> anyhow::Result<PluginConfigurationProposal>;
90
91 fn publish(
92 &self,
93 proposal: &PluginConfigurationProposal,
94 ) -> anyhow::Result<PluginConfigurationPublication>;
95
96 fn propose_changes(
97 &self,
98 expected_revision: &PluginRootRevision,
99 changes: PluginRootChangeSet,
100 ) -> anyhow::Result<PluginRootChangeProposal>;
101
102 fn publish_changes(
103 &self,
104 proposal: &PluginRootChangeProposal,
105 ) -> anyhow::Result<PluginRootChangePublication>;
106}
107
108#[derive(Clone, Debug)]
110pub struct LocalPluginRootAuthority {
111 root: PathBuf,
112 access: Arc<Mutex<()>>,
113}
114
115impl LocalPluginRootAuthority {
116 pub fn new(root: impl Into<PathBuf>) -> Self {
117 Self {
118 root: root.into(),
119 access: Arc::new(Mutex::new(())),
120 }
121 }
122
123 pub fn root(&self) -> &Path {
124 &self.root
125 }
126
127 pub(crate) fn lock(&self) -> anyhow::Result<std::sync::MutexGuard<'_, ()>> {
128 self.access
129 .lock()
130 .map_err(|_| anyhow::anyhow!("Plugin configuration authority lock is poisoned"))
131 }
132}
133
134impl PluginConfigurationAuthority for LocalPluginRootAuthority {
135 fn source(&self) -> PluginConfigurationAuthoritySource {
136 PluginConfigurationAuthoritySource {
137 kind: "local_plugin_root".to_owned(),
138 reference: "app".to_owned(),
139 }
140 }
141
142 fn inspect(&self) -> anyhow::Result<PluginRootAuthoringState> {
143 let _guard = self.lock()?;
144 inspect_plugin_root(&self.root)
145 }
146
147 fn propose(
148 &self,
149 expected_revision: &PluginRootRevision,
150 plugin_id: &str,
151 instance: &str,
152 bytes: &[u8],
153 ) -> anyhow::Result<PluginConfigurationProposal> {
154 let _guard = self.lock()?;
155 propose_instance_configuration(&self.root, expected_revision, plugin_id, instance, bytes)
156 }
157
158 fn publish(
159 &self,
160 proposal: &PluginConfigurationProposal,
161 ) -> anyhow::Result<PluginConfigurationPublication> {
162 let _guard = self.lock()?;
163 publish_instance_configuration(&self.root, proposal)
164 }
165
166 fn propose_changes(
167 &self,
168 expected_revision: &PluginRootRevision,
169 changes: PluginRootChangeSet,
170 ) -> anyhow::Result<PluginRootChangeProposal> {
171 let _guard = self.lock()?;
172 propose_plugin_root_changes(&self.root, expected_revision, changes)
173 }
174
175 fn publish_changes(
176 &self,
177 proposal: &PluginRootChangeProposal,
178 ) -> anyhow::Result<PluginRootChangePublication> {
179 let _guard = self.lock()?;
180 publish_plugin_root_changes(&self.root, proposal)
181 }
182}
183
184#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
189pub struct PluginRootRevision(String);
190
191impl PluginRootRevision {
192 pub fn as_str(&self) -> &str {
193 &self.0
194 }
195}
196
197impl fmt::Display for PluginRootRevision {
198 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199 formatter.write_str(&self.0)
200 }
201}
202
203impl FromStr for PluginRootRevision {
204 type Err = PluginRootRevisionParseError;
205
206 fn from_str(value: &str) -> Result<Self, Self::Err> {
207 let Some(digest) = value.strip_prefix("sha256:") else {
208 return Err(PluginRootRevisionParseError);
209 };
210 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
211 return Err(PluginRootRevisionParseError);
212 }
213 Ok(Self(format!("sha256:{}", digest.to_ascii_lowercase())))
214 }
215}
216
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub struct PluginRootRevisionParseError;
220
221impl fmt::Display for PluginRootRevisionParseError {
222 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
223 formatter.write_str("Plugin Root revision must be `sha256:` followed by 64 hex digits")
224 }
225}
226
227impl Error for PluginRootRevisionParseError {}
228
229#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct PluginRootRevisionConflict {
232 expected: PluginRootRevision,
233 current: PluginRootRevision,
234}
235
236impl PluginRootRevisionConflict {
237 pub const fn expected(&self) -> &PluginRootRevision {
238 &self.expected
239 }
240
241 pub const fn current(&self) -> &PluginRootRevision {
242 &self.current
243 }
244}
245
246impl fmt::Display for PluginRootRevisionConflict {
247 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248 write!(
249 formatter,
250 "Plugin Root revision conflict: expected {}, current {}",
251 self.expected, self.current
252 )
253 }
254}
255
256impl Error for PluginRootRevisionConflict {}
257
258#[derive(Clone, Debug, Serialize)]
260pub struct PluginRootConfigurationChange {
261 plugin_id: String,
262 instance_key: String,
263 toml: Vec<u8>,
264}
265
266impl PluginRootConfigurationChange {
267 pub fn new(
268 plugin_id: impl Into<String>,
269 instance: impl Into<String>,
270 toml: impl Into<Vec<u8>>,
271 ) -> Self {
272 Self {
273 plugin_id: plugin_id.into(),
274 instance_key: instance.into(),
275 toml: toml.into(),
276 }
277 }
278
279 pub fn plugin_id(&self) -> &str {
280 &self.plugin_id
281 }
282
283 pub fn instance_key(&self) -> &str {
284 &self.instance_key
285 }
286
287 pub fn toml(&self) -> &[u8] {
288 &self.toml
289 }
290}
291
292#[derive(Clone, Debug, Default)]
294pub struct PluginRootChangeSet {
295 configurations: Vec<PluginRootConfigurationChange>,
296 dependency_choices: Option<Vec<DependencyChoice>>,
297}
298
299impl PluginRootChangeSet {
300 pub const fn new() -> Self {
301 Self {
302 configurations: Vec::new(),
303 dependency_choices: None,
304 }
305 }
306
307 #[must_use]
308 pub fn with_configuration(mut self, change: PluginRootConfigurationChange) -> Self {
309 self.configurations.push(change);
310 self
311 }
312
313 #[must_use]
315 pub fn with_dependency_choices(
316 mut self,
317 choices: impl IntoIterator<Item = DependencyChoice>,
318 ) -> Self {
319 self.dependency_choices = Some(choices.into_iter().collect());
320 self
321 }
322
323 pub fn configurations(&self) -> &[PluginRootConfigurationChange] {
324 &self.configurations
325 }
326
327 pub fn dependency_choices(&self) -> Option<&[DependencyChoice]> {
328 self.dependency_choices.as_deref()
329 }
330}
331
332#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct PluginRootSourceDigest {
335 path: String,
336 digest: String,
337}
338
339impl PluginRootSourceDigest {
340 pub fn path(&self) -> &str {
341 &self.path
342 }
343
344 pub fn digest(&self) -> &str {
345 &self.digest
346 }
347}
348
349#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
351pub struct PluginRequirementMigration {
352 consumer: PluginInstanceId,
353 old_requirement_id: String,
354 new_requirement_ids: Vec<String>,
355 provider: Option<PluginInstanceId>,
356}
357
358impl PluginRequirementMigration {
359 pub const fn consumer(&self) -> &PluginInstanceId {
360 &self.consumer
361 }
362
363 pub fn old_requirement_id(&self) -> &str {
364 &self.old_requirement_id
365 }
366
367 pub fn new_requirement_ids(&self) -> &[String] {
368 &self.new_requirement_ids
369 }
370
371 pub const fn provider(&self) -> Option<&PluginInstanceId> {
372 self.provider.as_ref()
373 }
374}
375
376#[derive(Clone, Debug)]
378pub struct PluginRootChangeProposal {
379 schema: &'static str,
380 base_revision: PluginRootRevision,
381 host_catalog_digest: String,
382 source_digests: Vec<PluginRootSourceDigest>,
383 candidate_revision: PluginRootRevision,
384 digest: String,
385 status: PluginConfigurationProposalStatus,
386 application: PluginConfigurationApplication,
387 diagnostics: Vec<PluginConfigurationDiagnostic>,
388 requirement_migrations: Vec<PluginRequirementMigration>,
389 changes: PluginRootChangeSet,
390 materialized_choices: Option<Vec<DependencyChoice>>,
391}
392
393impl PluginRootChangeProposal {
394 pub const fn schema(&self) -> &str {
395 self.schema
396 }
397
398 pub const fn base_revision(&self) -> &PluginRootRevision {
399 &self.base_revision
400 }
401
402 pub fn host_catalog_digest(&self) -> &str {
403 &self.host_catalog_digest
404 }
405
406 pub fn source_digests(&self) -> &[PluginRootSourceDigest] {
407 &self.source_digests
408 }
409
410 pub const fn candidate_revision(&self) -> &PluginRootRevision {
411 &self.candidate_revision
412 }
413
414 pub fn digest(&self) -> &str {
415 &self.digest
416 }
417
418 pub const fn status(&self) -> PluginConfigurationProposalStatus {
419 self.status
420 }
421
422 pub const fn application(&self) -> PluginConfigurationApplication {
423 self.application
424 }
425
426 pub fn diagnostics(&self) -> &[PluginConfigurationDiagnostic] {
427 &self.diagnostics
428 }
429
430 pub fn requirement_migrations(&self) -> &[PluginRequirementMigration] {
431 &self.requirement_migrations
432 }
433
434 pub const fn changes(&self) -> &PluginRootChangeSet {
435 &self.changes
436 }
437
438 pub fn materialized_dependency_choices(&self) -> Option<&[DependencyChoice]> {
439 self.materialized_choices.as_deref()
440 }
441}
442
443#[derive(Clone, Debug)]
445pub struct PluginRootChangePublication {
446 schema: &'static str,
447 base_revision: PluginRootRevision,
448 revision: PluginRootRevision,
449 proposal_digest: String,
450 resolved: ResolvedApp,
451}
452
453impl PluginRootChangePublication {
454 pub const fn schema(&self) -> &str {
455 self.schema
456 }
457
458 pub const fn base_revision(&self) -> &PluginRootRevision {
459 &self.base_revision
460 }
461
462 pub const fn revision(&self) -> &PluginRootRevision {
463 &self.revision
464 }
465
466 pub fn proposal_digest(&self) -> &str {
467 &self.proposal_digest
468 }
469
470 pub const fn resolved(&self) -> &ResolvedApp {
471 &self.resolved
472 }
473
474 pub fn into_resolved(self) -> ResolvedApp {
475 self.resolved
476 }
477}
478
479#[derive(Clone, Debug)]
481pub struct PluginConfigurationProposal {
482 schema: &'static str,
483 base_revision: PluginRootRevision,
484 base_source_digest: PluginConfigurationSourceDigest,
485 candidate_revision: PluginRootRevision,
486 digest: String,
487 status: PluginConfigurationProposalStatus,
488 application: PluginConfigurationApplication,
489 diagnostics: Vec<PluginConfigurationDiagnostic>,
490 plugin_id: String,
491 instance_key: String,
492 toml: Vec<u8>,
493 root_proposal: PluginRootChangeProposal,
494}
495
496impl PluginConfigurationProposal {
497 pub const fn schema(&self) -> &str {
498 self.schema
499 }
500
501 pub const fn base_revision(&self) -> &PluginRootRevision {
502 &self.base_revision
503 }
504
505 pub const fn base_source_digest(&self) -> &PluginConfigurationSourceDigest {
506 &self.base_source_digest
507 }
508
509 pub const fn candidate_revision(&self) -> &PluginRootRevision {
510 &self.candidate_revision
511 }
512
513 pub fn digest(&self) -> &str {
514 &self.digest
515 }
516
517 pub const fn status(&self) -> PluginConfigurationProposalStatus {
518 self.status
519 }
520
521 pub const fn application(&self) -> PluginConfigurationApplication {
522 self.application
523 }
524
525 pub fn diagnostics(&self) -> &[PluginConfigurationDiagnostic] {
526 &self.diagnostics
527 }
528
529 pub fn plugin_id(&self) -> &str {
530 &self.plugin_id
531 }
532
533 pub fn instance_key(&self) -> &str {
534 &self.instance_key
535 }
536}
537
538#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540pub enum PluginConfigurationProposalStatus {
541 Ready,
542 NeedsDecision,
543 Rejected,
544}
545
546#[derive(Clone, Copy, Debug, Eq, PartialEq)]
548pub enum PluginConfigurationApplication {
549 Noop,
550 AppGeneration,
551 Blocked,
552}
553
554#[derive(Clone, Debug, Eq, PartialEq)]
556pub struct PluginConfigurationDiagnostic {
557 code: &'static str,
558 detail: String,
559}
560
561impl PluginConfigurationDiagnostic {
562 pub const fn code(&self) -> &str {
563 self.code
564 }
565
566 pub fn detail(&self) -> &str {
567 &self.detail
568 }
569}
570
571#[derive(Clone, Debug)]
573pub struct PluginConfigurationPublication {
574 schema: &'static str,
575 base_revision: PluginRootRevision,
576 base_source_digest: PluginConfigurationSourceDigest,
577 revision: PluginRootRevision,
578 proposal_digest: String,
579 resolved: ResolvedApp,
580}
581
582impl PluginConfigurationPublication {
583 pub const fn schema(&self) -> &str {
584 self.schema
585 }
586
587 pub const fn base_revision(&self) -> &PluginRootRevision {
588 &self.base_revision
589 }
590
591 pub const fn base_source_digest(&self) -> &PluginConfigurationSourceDigest {
592 &self.base_source_digest
593 }
594
595 pub const fn revision(&self) -> &PluginRootRevision {
596 &self.revision
597 }
598
599 pub fn proposal_digest(&self) -> &str {
600 &self.proposal_digest
601 }
602
603 pub const fn resolved(&self) -> &ResolvedApp {
604 &self.resolved
605 }
606
607 pub fn into_resolved(self) -> ResolvedApp {
608 self.resolved
609 }
610}
611
612pub fn propose_plugin_root_changes(
614 root: &Path,
615 expected_revision: &PluginRootRevision,
616 changes: PluginRootChangeSet,
617) -> anyhow::Result<PluginRootChangeProposal> {
618 let _lock = lock_plugin_root(root)?;
619 let host = load_host_catalog(root)?;
620 let current = snapshot_plugin_root(root, &host)?;
621 let current_revision = revision_for_snapshot(¤t)?;
622 ensure_revision(expected_revision, ¤t_revision)?;
623 build_root_change_proposal(root, &host, ¤t, current_revision, changes)
624}
625
626pub fn publish_plugin_root_changes(
628 root: &Path,
629 proposal: &PluginRootChangeProposal,
630) -> anyhow::Result<PluginRootChangePublication> {
631 let _lock = lock_plugin_root(root)?;
632 let host = load_host_catalog(root)?;
633 let current = snapshot_plugin_root(root, &host)?;
634 let current_revision = revision_for_snapshot(¤t)?;
635 ensure_revision(&proposal.base_revision, ¤t_revision)?;
636 let current_host_digest = host_catalog_digest(root)?;
637 if current_host_digest != proposal.host_catalog_digest {
638 bail!("Host Catalog changed after the Plugin Root proposal was reviewed");
639 }
640 let current_sources = source_digests_for_changes(root, &proposal.changes)?;
641 if current_sources != proposal.source_digests {
642 bail!("Plugin Root source bytes changed after the proposal was reviewed");
643 }
644 let verified = build_root_change_proposal(
645 root,
646 &host,
647 ¤t,
648 current_revision,
649 proposal.changes.clone(),
650 )?;
651 if proposal.candidate_revision != verified.candidate_revision
652 || proposal.digest != verified.digest
653 || proposal.materialized_choices != verified.materialized_choices
654 {
655 bail!("Plugin Root proposal no longer matches its reviewed candidate");
656 }
657 ensure_ready(
658 verified.status,
659 verified.application,
660 &verified.diagnostics,
661 "Plugin Root proposal",
662 )?;
663
664 let mut files = verified
665 .changes
666 .configurations
667 .iter()
668 .map(|change| {
669 root_transaction::RootFileChange::write(
670 PathBuf::from(&change.plugin_id).join(format!("{}.toml", change.instance_key)),
671 change.toml.clone(),
672 )
673 })
674 .collect::<Vec<_>>();
675 if let Some(choices) = &verified.materialized_choices {
676 let document = DependencySelectionsDocument {
677 schema_version: DEPENDENCY_SELECTIONS_SCHEMA_VERSION,
678 choices: choices.clone(),
679 };
680 files.push(root_transaction::RootFileChange::write(
681 DEPENDENCY_SELECTIONS,
682 serde_json::to_vec_pretty(&document).context("encode dependency selections")?,
683 ));
684 files.push(root_transaction::RootFileChange::remove(
685 LEGACY_DEPENDENCY_SELECTIONS,
686 ));
687 }
688 root_transaction::publish_root_files(root, files)?;
689
690 let published = snapshot_plugin_root(root, &host)?;
691 let revision = revision_for_snapshot(&published)?;
692 if revision != proposal.candidate_revision {
693 bail!("published Plugin Root does not match the reviewed candidate revision");
694 }
695 let resolved = host.resolve(&published).map_err(anyhow::Error::msg)?;
696 Ok(PluginRootChangePublication {
697 schema: ROOT_CHANGE_PUBLICATION_SCHEMA,
698 base_revision: proposal.base_revision.clone(),
699 revision,
700 proposal_digest: proposal.digest.clone(),
701 resolved,
702 })
703}
704
705fn build_root_change_proposal(
706 root: &Path,
707 host: &HostInput,
708 current: &PluginRootSnapshot,
709 base_revision: PluginRootRevision,
710 changes: PluginRootChangeSet,
711) -> anyhow::Result<PluginRootChangeProposal> {
712 let changes = normalize_change_set(changes)?;
713 let mut instances = current.instances().to_vec();
714 for change in &changes.configurations {
715 let id = PluginInstanceId::new(&change.plugin_id, &change.instance_key);
716 instances.retain(|instance| instance.id() != &id);
717 instances.push(
718 PluginRootInstance::new(&change.plugin_id, &change.instance_key)
719 .with_configuration(parse_configuration(&change.toml)?),
720 );
721 }
722 let mut candidate = PluginRootSnapshot::new(
723 current.releases().iter().cloned(),
724 instances,
725 current.disabled().iter().cloned(),
726 );
727 candidate = match &changes.dependency_choices {
728 Some(choices) => candidate.with_dependency_choices(choices.clone()),
729 None => crate::preserve_dependency_selections(candidate, current),
730 };
731
732 let mut materialized_choices = None;
733 let resolution = if changes.dependency_choices.is_some() {
734 match host.propose(&candidate) {
735 Ok(proposed) => {
736 let choices = proposed.dependency_choices().to_vec();
737 candidate = PluginRootSnapshot::new(
738 candidate.releases().iter().cloned(),
739 candidate.instances().iter().cloned(),
740 candidate.disabled().iter().cloned(),
741 )
742 .with_dependency_choices(choices.clone());
743 materialized_choices = Some(choices);
744 host.resolve(&candidate)
745 }
746 Err(error) => Err(error),
747 }
748 } else {
749 host.resolve(&candidate)
750 };
751 if let Some(choices) = &materialized_choices {
752 validate_dependency_choices(choices)?;
753 let document = DependencySelectionsDocument {
754 schema_version: DEPENDENCY_SELECTIONS_SCHEMA_VERSION,
755 choices: choices.clone(),
756 };
757 let bytes = serde_json::to_vec(&document).context("encode dependency selections")?;
758 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > super::MAX_DEPENDENCY_SELECTION_BYTES {
759 bail!("Plugin dependency selections exceed 1 MiB");
760 }
761 }
762 let candidate_revision = revision_for_snapshot(&candidate)?;
763 let (mut status, mut application, mut diagnostics) =
764 classify_resolution(resolution, &candidate_revision, &base_revision);
765 let requirement_migrations = materialized_choices
766 .as_ref()
767 .map_or_else(Vec::new, |choices| {
768 requirement_migrations(current.dependency_choices(), choices)
769 });
770 if changes
771 .dependency_choices
772 .as_ref()
773 .is_some_and(|requested| has_unreviewed_split(&requirement_migrations, requested))
774 {
775 status = PluginConfigurationProposalStatus::NeedsDecision;
776 application = PluginConfigurationApplication::Blocked;
777 diagnostics.push(PluginConfigurationDiagnostic {
778 code: "migration_mapping_required",
779 detail: "a split requirement migration needs every new requirement mapped explicitly"
780 .to_owned(),
781 });
782 }
783 let host_catalog_digest = host_catalog_digest(root)?;
784 let source_digests = source_digests_for_changes(root, &changes)?;
785 let authority = serde_json::to_vec(&(
786 ROOT_CHANGE_PROPOSAL_SCHEMA,
787 base_revision.as_str(),
788 &host_catalog_digest,
789 source_digests
790 .iter()
791 .map(|source| (&source.path, &source.digest))
792 .collect::<Vec<_>>(),
793 candidate_revision.as_str(),
794 &changes.configurations,
795 &materialized_choices,
796 &requirement_migrations,
797 ))
798 .context("encode Plugin Root proposal authority")?;
799 Ok(PluginRootChangeProposal {
800 schema: ROOT_CHANGE_PROPOSAL_SCHEMA,
801 base_revision,
802 host_catalog_digest,
803 source_digests,
804 candidate_revision,
805 digest: sha256_digest(&authority),
806 status,
807 application,
808 diagnostics,
809 requirement_migrations,
810 changes,
811 materialized_choices,
812 })
813}
814
815fn has_unreviewed_split(
816 migrations: &[PluginRequirementMigration],
817 requested: &[DependencyChoice],
818) -> bool {
819 let requested_keys = requested
820 .iter()
821 .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
822 .collect::<std::collections::BTreeSet<_>>();
823 migrations.iter().any(|migration| {
824 migration.new_requirement_ids.len() > 1
825 && migration.new_requirement_ids.iter().any(|requirement_id| {
826 !requested_keys.contains(&(&migration.consumer, requirement_id.as_str()))
827 })
828 })
829}
830
831fn normalize_change_set(mut changes: PluginRootChangeSet) -> anyhow::Result<PluginRootChangeSet> {
832 if changes.configurations.is_empty() && changes.dependency_choices.is_none() {
833 bail!("Plugin Root proposal must contain at least one change");
834 }
835 let mut identities = std::collections::BTreeSet::new();
836 for change in &changes.configurations {
837 validate_existing_plugin_id(&change.plugin_id)?;
838 validate_instance_filename(&change.instance_key)?;
839 parse_configuration(&change.toml)?;
840 if !identities.insert((change.plugin_id.clone(), change.instance_key.clone())) {
841 bail!(
842 "duplicate Plugin configuration change for `{}/{}`",
843 change.plugin_id,
844 change.instance_key
845 );
846 }
847 }
848 changes.configurations.sort_by(|left, right| {
849 left.plugin_id
850 .cmp(&right.plugin_id)
851 .then_with(|| left.instance_key.cmp(&right.instance_key))
852 });
853 if let Some(choices) = &mut changes.dependency_choices {
854 validate_dependency_choices(choices)?;
855 choices.sort_by(|left, right| {
856 left.consumer
857 .cmp(&right.consumer)
858 .then_with(|| left.requirement_id.cmp(&right.requirement_id))
859 });
860 }
861 Ok(changes)
862}
863
864fn requirement_migrations(
865 current: &[DependencyChoice],
866 candidate: &[DependencyChoice],
867) -> Vec<PluginRequirementMigration> {
868 let current_keys = current
869 .iter()
870 .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
871 .collect::<std::collections::BTreeSet<_>>();
872 let candidate_keys = candidate
873 .iter()
874 .map(|choice| (&choice.consumer, choice.requirement_id.as_str()))
875 .collect::<std::collections::BTreeSet<_>>();
876 current
877 .iter()
878 .filter(|choice| {
879 !candidate_keys.contains(&(&choice.consumer, choice.requirement_id.as_str()))
880 })
881 .map(|old| {
882 let mut new_requirement_ids = candidate
883 .iter()
884 .filter(|new| {
885 !current_keys.contains(&(&new.consumer, new.requirement_id.as_str()))
886 && new.consumer == old.consumer
887 && new.provider == old.provider
888 })
889 .map(|choice| choice.requirement_id.clone())
890 .collect::<Vec<_>>();
891 new_requirement_ids.sort();
892 PluginRequirementMigration {
893 consumer: old.consumer.clone(),
894 old_requirement_id: old.requirement_id.clone(),
895 new_requirement_ids,
896 provider: old.provider.clone(),
897 }
898 })
899 .collect()
900}
901
902fn validate_dependency_choices(choices: &[DependencyChoice]) -> anyhow::Result<()> {
903 if choices.len() > super::MAX_DEPENDENCY_SELECTIONS {
904 bail!(
905 "Plugin dependency selections exceed {} entries",
906 super::MAX_DEPENDENCY_SELECTIONS
907 );
908 }
909 let mut keys = std::collections::BTreeSet::new();
910 for choice in choices {
911 validate_existing_plugin_id(choice.consumer.plugin_id())?;
912 validate_instance_filename(choice.consumer.instance_key())?;
913 validate_requirement_id(&choice.requirement_id)?;
914 if let Some(provider) = &choice.provider {
915 validate_existing_plugin_id(provider.plugin_id())?;
916 validate_instance_filename(provider.instance_key())?;
917 }
918 if !keys.insert((choice.consumer.clone(), choice.requirement_id.clone())) {
919 bail!("duplicate dependency choice for `{}`", choice.consumer);
920 }
921 }
922 Ok(())
923}
924
925fn host_catalog_digest(root: &Path) -> anyhow::Result<String> {
926 let generated = root.join(HOST_BUILD);
927 let path = match fs::symlink_metadata(&generated) {
928 Ok(_) => generated,
929 Err(error) if error.kind() == std::io::ErrorKind::NotFound => root.join(HOST_CATALOG),
930 Err(error) => return Err(error).context("inspect generated Host authority source"),
931 };
932 let metadata = fs::symlink_metadata(&path).context("inspect Host Catalog source")?;
933 if !metadata.file_type().is_file() {
934 bail!(
935 "Host Catalog source must be a regular file: {}",
936 path.display()
937 );
938 }
939 Ok(sha256_digest(
940 &fs::read(path).context("read Host Catalog source")?,
941 ))
942}
943
944fn source_digests_for_changes(
945 root: &Path,
946 changes: &PluginRootChangeSet,
947) -> anyhow::Result<Vec<PluginRootSourceDigest>> {
948 let mut paths = changes
949 .configurations
950 .iter()
951 .map(|change| format!("{}/{}.toml", change.plugin_id, change.instance_key))
952 .collect::<Vec<_>>();
953 if changes.dependency_choices.is_some() {
954 paths.extend([
955 DEPENDENCY_SELECTIONS.to_owned(),
956 LEGACY_DEPENDENCY_SELECTIONS.to_owned(),
957 ]);
958 }
959 paths.sort();
960 paths.dedup();
961 paths
962 .into_iter()
963 .map(|path| root_source_digest(root, path))
964 .collect()
965}
966
967fn root_source_digest(root: &Path, path: String) -> anyhow::Result<PluginRootSourceDigest> {
968 let source = root.join(PLUGIN_ROOT).join(&path);
969 let bytes = match fs::symlink_metadata(&source) {
970 Ok(metadata) if metadata.file_type().is_file() => {
971 Some(fs::read(&source).with_context(|| format!("read {}", source.display()))?)
972 }
973 Ok(_) => bail!(
974 "Plugin Root source must be a regular file: {}",
975 source.display()
976 ),
977 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
978 Err(error) => return Err(error).with_context(|| format!("inspect {}", source.display())),
979 };
980 let mut digest = Sha256::new();
981 update_digest_component(&mut digest, ROOT_SOURCE_DIGEST_SCHEMA.as_bytes());
982 update_digest_component(&mut digest, path.as_bytes());
983 match bytes {
984 Some(bytes) => {
985 digest.update([1]);
986 update_digest_component(&mut digest, &bytes);
987 }
988 None => digest.update([0]),
989 }
990 Ok(PluginRootSourceDigest {
991 path,
992 digest: encode_sha256(digest.finalize()),
993 })
994}
995
996pub fn propose_instance_configuration(
998 root: &Path,
999 expected_revision: &PluginRootRevision,
1000 plugin_id: &str,
1001 instance: &str,
1002 bytes: &[u8],
1003) -> anyhow::Result<PluginConfigurationProposal> {
1004 validate_existing_plugin_id(plugin_id)?;
1005 validate_instance_filename(instance)?;
1006 let _lock = lock_plugin_root(root)?;
1007 let host = load_host_catalog(root)?;
1008 let current = snapshot_plugin_root(root, &host)?;
1009 let current_revision = revision_for_snapshot(¤t)?;
1010 ensure_revision(expected_revision, ¤t_revision)?;
1011 let base_source_digest = source_digest_for_instance(root, plugin_id, instance)?;
1012 let root_proposal = build_root_change_proposal(
1013 root,
1014 &host,
1015 ¤t,
1016 current_revision.clone(),
1017 PluginRootChangeSet::new().with_configuration(PluginRootConfigurationChange::new(
1018 plugin_id,
1019 instance,
1020 bytes.to_vec(),
1021 )),
1022 )?;
1023 Ok(PluginConfigurationProposal {
1024 schema: PROPOSAL_SCHEMA,
1025 base_revision: current_revision,
1026 base_source_digest,
1027 candidate_revision: root_proposal.candidate_revision.clone(),
1028 digest: root_proposal.digest.clone(),
1029 status: root_proposal.status,
1030 application: root_proposal.application,
1031 diagnostics: root_proposal.diagnostics.clone(),
1032 plugin_id: plugin_id.to_owned(),
1033 instance_key: instance.to_owned(),
1034 toml: bytes.to_vec(),
1035 root_proposal,
1036 })
1037}
1038
1039pub fn publish_instance_configuration(
1041 root: &Path,
1042 proposal: &PluginConfigurationProposal,
1043) -> anyhow::Result<PluginConfigurationPublication> {
1044 if proposal.plugin_id != proposal.root_proposal.changes.configurations[0].plugin_id
1045 || proposal.instance_key != proposal.root_proposal.changes.configurations[0].instance_key
1046 || proposal.toml != proposal.root_proposal.changes.configurations[0].toml
1047 || proposal.digest != proposal.root_proposal.digest
1048 {
1049 bail!("Plugin configuration proposal no longer matches its reviewed candidate");
1050 }
1051 let current_revision = inspect_plugin_root(root)?.revision().clone();
1052 ensure_revision(&proposal.base_revision, ¤t_revision)?;
1053 let current_source_digest =
1054 source_digest_for_instance(root, &proposal.plugin_id, &proposal.instance_key)?;
1055 ensure_source_digest(&proposal.base_source_digest, ¤t_source_digest)?;
1056 let publication = publish_plugin_root_changes(root, &proposal.root_proposal)?;
1057 Ok(PluginConfigurationPublication {
1058 schema: PUBLICATION_SCHEMA,
1059 base_revision: proposal.base_revision.clone(),
1060 base_source_digest: proposal.base_source_digest.clone(),
1061 revision: publication.revision,
1062 proposal_digest: proposal.digest.clone(),
1063 resolved: publication.resolved,
1064 })
1065}
1066
1067fn classify_resolution(
1068 resolution: Result<ResolvedApp, PluginRootResolutionError>,
1069 candidate_revision: &PluginRootRevision,
1070 base_revision: &PluginRootRevision,
1071) -> (
1072 PluginConfigurationProposalStatus,
1073 PluginConfigurationApplication,
1074 Vec<PluginConfigurationDiagnostic>,
1075) {
1076 match resolution {
1077 Ok(_) => (
1078 PluginConfigurationProposalStatus::Ready,
1079 if candidate_revision == base_revision {
1080 PluginConfigurationApplication::Noop
1081 } else {
1082 PluginConfigurationApplication::AppGeneration
1083 },
1084 Vec::new(),
1085 ),
1086 Err(error) => {
1087 let status = if matches!(
1088 error,
1089 PluginRootResolutionError::AmbiguousSlot { .. }
1090 | PluginRootResolutionError::AmbiguousCapability { .. }
1091 ) {
1092 PluginConfigurationProposalStatus::NeedsDecision
1093 } else {
1094 PluginConfigurationProposalStatus::Rejected
1095 };
1096 (
1097 status,
1098 PluginConfigurationApplication::Blocked,
1099 vec![PluginConfigurationDiagnostic {
1100 code: resolution_error_code(&error),
1101 detail: error.to_string(),
1102 }],
1103 )
1104 }
1105 }
1106}
1107
1108fn ensure_ready(
1109 status: PluginConfigurationProposalStatus,
1110 application: PluginConfigurationApplication,
1111 diagnostics: &[PluginConfigurationDiagnostic],
1112 subject: &str,
1113) -> anyhow::Result<()> {
1114 if status == PluginConfigurationProposalStatus::Ready
1115 && application != PluginConfigurationApplication::Blocked
1116 {
1117 return Ok(());
1118 }
1119 let detail = diagnostics
1120 .first()
1121 .map_or("candidate did not pass the Ready Gate", |diagnostic| {
1122 diagnostic.detail()
1123 });
1124 bail!("{subject} cannot be published: {detail}")
1125}
1126
1127fn resolution_error_code(error: &PluginRootResolutionError) -> &'static str {
1128 match error {
1129 PluginRootResolutionError::InvalidHostConfiguration(_) => "host_admission_denied",
1130 PluginRootResolutionError::AmbiguousSlot { .. } => "ambiguous_slot",
1131 PluginRootResolutionError::AmbiguousCapability { .. } => "ambiguous_capability",
1132 PluginRootResolutionError::InvalidConfiguration { .. } => "invalid_configuration",
1133 PluginRootResolutionError::MissingRequiredSlot(_) => "missing_required_slot",
1134 PluginRootResolutionError::MissingCapability { .. } => "missing_capability",
1135 PluginRootResolutionError::RequiredInstanceDisabled(_) => "required_instance_disabled",
1136 PluginRootResolutionError::UnknownPlugin(_) => "unknown_plugin",
1137 PluginRootResolutionError::UnknownDisabledInstance(_) => "unknown_disabled_instance",
1138 _ => "invalid_plugin_root",
1139 }
1140}
1141
1142fn parse_configuration(bytes: &[u8]) -> anyhow::Result<serde_json::Value> {
1143 let byte_count = u64::try_from(bytes.len()).context("Plugin configuration is too large")?;
1144 if byte_count > MAX_CONFIGURATION_BYTES {
1145 bail!("Plugin configuration exceeds 256 KiB");
1146 }
1147 let text = str::from_utf8(bytes).context("Plugin configuration must be UTF-8 TOML")?;
1148 let table: toml::Table = toml::from_str(text).context("parse Plugin configuration TOML")?;
1149 serde_json::to_value(table).context("convert Plugin configuration to portable values")
1150}
1151
1152pub(crate) fn ensure_revision(
1153 expected: &PluginRootRevision,
1154 current: &PluginRootRevision,
1155) -> anyhow::Result<()> {
1156 if expected == current {
1157 return Ok(());
1158 }
1159 Err(PluginRootRevisionConflict {
1160 expected: expected.clone(),
1161 current: current.clone(),
1162 }
1163 .into())
1164}
1165
1166#[derive(Clone, Debug, Eq, PartialEq)]
1168pub struct PluginConfigurationSourceDigest(String);
1169
1170impl PluginConfigurationSourceDigest {
1171 pub fn as_str(&self) -> &str {
1172 &self.0
1173 }
1174
1175 pub fn for_source(
1177 plugin_id: &str,
1178 instance: &str,
1179 bytes: Option<&[u8]>,
1180 ) -> anyhow::Result<Self> {
1181 validate_existing_plugin_id(plugin_id)?;
1182 validate_instance_filename(instance)?;
1183 Ok(source_digest_for_bytes(plugin_id, instance, bytes))
1184 }
1185}
1186
1187impl fmt::Display for PluginConfigurationSourceDigest {
1188 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1189 formatter.write_str(&self.0)
1190 }
1191}
1192
1193#[derive(Clone, Debug, Eq, PartialEq)]
1195pub struct PluginConfigurationSourceConflict {
1196 expected: PluginConfigurationSourceDigest,
1197 current: PluginConfigurationSourceDigest,
1198}
1199
1200impl PluginConfigurationSourceConflict {
1201 pub const fn expected(&self) -> &PluginConfigurationSourceDigest {
1202 &self.expected
1203 }
1204
1205 pub const fn current(&self) -> &PluginConfigurationSourceDigest {
1206 &self.current
1207 }
1208}
1209
1210impl fmt::Display for PluginConfigurationSourceConflict {
1211 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1212 write!(
1213 formatter,
1214 "Plugin configuration source conflict: expected {}, current {}",
1215 self.expected, self.current
1216 )
1217 }
1218}
1219
1220impl Error for PluginConfigurationSourceConflict {}
1221
1222pub(crate) fn source_digest_for_bytes(
1223 plugin_id: &str,
1224 instance: &str,
1225 bytes: Option<&[u8]>,
1226) -> PluginConfigurationSourceDigest {
1227 let mut authority = Sha256::new();
1228 update_digest_component(&mut authority, SOURCE_DIGEST_SCHEMA.as_bytes());
1229 update_digest_component(&mut authority, plugin_id.as_bytes());
1230 update_digest_component(&mut authority, instance.as_bytes());
1231 match bytes {
1232 Some(bytes) => {
1233 authority.update([1]);
1234 update_digest_component(&mut authority, bytes);
1235 }
1236 None => authority.update([0]),
1237 }
1238 PluginConfigurationSourceDigest(encode_sha256(authority.finalize()))
1239}
1240
1241fn source_digest_for_instance(
1242 root: &Path,
1243 plugin_id: &str,
1244 instance: &str,
1245) -> anyhow::Result<PluginConfigurationSourceDigest> {
1246 let path = root
1247 .join(PLUGIN_ROOT)
1248 .join(plugin_id)
1249 .join(format!("{instance}.toml"));
1250 let bytes = match fs::symlink_metadata(&path) {
1251 Ok(metadata) if metadata.file_type().is_file() => Some(
1252 fs::read(&path)
1253 .with_context(|| format!("read Plugin configuration source {}", path.display()))?,
1254 ),
1255 Ok(_) => bail!(
1256 "Plugin configuration source must be a regular file: {}",
1257 path.display()
1258 ),
1259 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1260 Err(error) => {
1261 return Err(error).with_context(|| {
1262 format!("inspect Plugin configuration source {}", path.display())
1263 });
1264 }
1265 };
1266 Ok(source_digest_for_bytes(
1267 plugin_id,
1268 instance,
1269 bytes.as_deref(),
1270 ))
1271}
1272
1273fn ensure_source_digest(
1274 expected: &PluginConfigurationSourceDigest,
1275 current: &PluginConfigurationSourceDigest,
1276) -> anyhow::Result<()> {
1277 if expected == current {
1278 return Ok(());
1279 }
1280 Err(PluginConfigurationSourceConflict {
1281 expected: expected.clone(),
1282 current: current.clone(),
1283 }
1284 .into())
1285}
1286
1287fn update_digest_component(authority: &mut Sha256, bytes: &[u8]) {
1288 authority.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
1289 authority.update(bytes);
1290}
1291
1292pub(crate) fn revision_for_snapshot(
1293 snapshot: &PluginRootSnapshot,
1294) -> anyhow::Result<PluginRootRevision> {
1295 let canonical =
1296 serde_json::to_vec(snapshot).context("encode Plugin Root revision authority")?;
1297 Ok(PluginRootRevision(sha256_digest(&canonical)))
1298}
1299
1300fn sha256_digest(bytes: &[u8]) -> String {
1301 encode_sha256(Sha256::digest(bytes))
1302}
1303
1304fn encode_sha256(digest: impl AsRef<[u8]>) -> String {
1305 let digest = digest.as_ref();
1306 let mut encoded = String::with_capacity(7 + digest.len() * 2);
1307 encoded.push_str("sha256:");
1308 for byte in digest {
1309 write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail");
1310 }
1311 encoded
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use std::{fs, sync::Arc};
1317
1318 use lenso_app_plan::authoring::{
1319 HostDefaultPlugin, HostPluginRelease, HostSlot, PluginDescriptor,
1320 };
1321
1322 use super::*;
1323 use crate::{HOST_CATALOG, inspect_plugin_root};
1324
1325 fn fixture_root() -> tempfile::TempDir {
1326 let root = tempfile::tempdir().unwrap();
1327 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1328 let descriptor = PluginDescriptor::new("example.agent", "1.0.0", "agent")
1329 .with_configuration_schema(serde_json::json!({
1330 "type": "object",
1331 "properties": {
1332 "greeting": { "type": "string" }
1333 },
1334 "additionalProperties": false
1335 }));
1336 let host = lenso_app_plan::authoring::HostCatalog::new(
1337 [HostSlot::one("agent")],
1338 [HostPluginRelease::new(descriptor)],
1339 [HostDefaultPlugin::new("example.agent", "default")],
1340 );
1341 fs::write(
1342 root.path().join(HOST_CATALOG),
1343 serde_json::to_vec(&host).unwrap(),
1344 )
1345 .unwrap();
1346 root
1347 }
1348
1349 fn coordinated_root() -> tempfile::TempDir {
1350 let root = tempfile::tempdir().unwrap();
1351 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1352 let schema = serde_json::json!({
1353 "type": "object",
1354 "properties": { "value": { "type": "string" } },
1355 "additionalProperties": false
1356 });
1357 let host = lenso_app_plan::authoring::HostCatalog::new(
1358 [HostSlot::one("source"), HostSlot::one("target")],
1359 [
1360 HostPluginRelease::new(
1361 PluginDescriptor::new("example.source", "1.0.0", "source")
1362 .with_configuration_schema(schema.clone()),
1363 ),
1364 HostPluginRelease::new(
1365 PluginDescriptor::new("example.target", "1.0.0", "target")
1366 .with_configuration_schema(schema),
1367 ),
1368 ],
1369 [
1370 HostDefaultPlugin::new("example.source", "default"),
1371 HostDefaultPlugin::new("example.target", "default"),
1372 ],
1373 );
1374 fs::write(
1375 root.path().join(HOST_CATALOG),
1376 serde_json::to_vec(&host).unwrap(),
1377 )
1378 .unwrap();
1379 root
1380 }
1381
1382 #[test]
1383 fn proposal_is_read_only_and_publication_advances_the_revision() {
1384 let root = fixture_root();
1385 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1386 let proposal = propose_instance_configuration(
1387 root.path(),
1388 &base,
1389 "example.agent",
1390 "default",
1391 b"greeting = \"hello\"\n",
1392 )
1393 .unwrap();
1394
1395 assert_eq!(proposal.status(), PluginConfigurationProposalStatus::Ready);
1396 assert_eq!(
1397 proposal.application(),
1398 PluginConfigurationApplication::AppGeneration
1399 );
1400 assert_eq!(proposal.base_revision(), &base);
1401 assert!(
1402 proposal
1403 .base_source_digest()
1404 .as_str()
1405 .starts_with("sha256:")
1406 );
1407 assert_ne!(proposal.candidate_revision(), &base);
1408 assert!(proposal.digest().starts_with("sha256:"));
1409 assert!(!configuration_path(root.path()).exists());
1410
1411 let publication = publish_instance_configuration(root.path(), &proposal).unwrap();
1412 assert_eq!(publication.base_revision(), &base);
1413 assert_eq!(
1414 publication.base_source_digest(),
1415 proposal.base_source_digest()
1416 );
1417 assert_eq!(publication.revision(), proposal.candidate_revision());
1418 assert_eq!(publication.proposal_digest(), proposal.digest());
1419 assert_eq!(
1420 fs::read_to_string(configuration_path(root.path())).unwrap(),
1421 "greeting = \"hello\"\n"
1422 );
1423 }
1424
1425 #[test]
1426 fn coordinated_proposal_publishes_two_configurations_and_choices_together() {
1427 let root = coordinated_root();
1428 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1429 let changes = PluginRootChangeSet::new()
1430 .with_configuration(PluginRootConfigurationChange::new(
1431 "example.source",
1432 "default",
1433 b"value = \"source\"\n".to_vec(),
1434 ))
1435 .with_configuration(PluginRootConfigurationChange::new(
1436 "example.target",
1437 "default",
1438 b"value = \"target\"\n".to_vec(),
1439 ))
1440 .with_dependency_choices([]);
1441 let proposal = propose_plugin_root_changes(root.path(), &base, changes).unwrap();
1442
1443 assert_eq!(proposal.status(), PluginConfigurationProposalStatus::Ready);
1444 assert_eq!(proposal.source_digests().len(), 4);
1445 assert!(proposal.host_catalog_digest().starts_with("sha256:"));
1446 assert!(
1447 !root
1448 .path()
1449 .join("plugins/example.source/default.toml")
1450 .exists()
1451 );
1452 assert!(!root.path().join("plugins/.dependencies.json").exists());
1453
1454 let publication = publish_plugin_root_changes(root.path(), &proposal).unwrap();
1455
1456 assert_eq!(publication.revision(), proposal.candidate_revision());
1457 assert_eq!(
1458 fs::read_to_string(root.path().join("plugins/example.source/default.toml")).unwrap(),
1459 "value = \"source\"\n"
1460 );
1461 assert_eq!(
1462 fs::read_to_string(root.path().join("plugins/example.target/default.toml")).unwrap(),
1463 "value = \"target\"\n"
1464 );
1465 let choices: DependencySelectionsDocument = serde_json::from_slice(
1466 &fs::read(root.path().join("plugins/.dependencies.json")).unwrap(),
1467 )
1468 .unwrap();
1469 assert_eq!(choices.schema_version, DEPENDENCY_SELECTIONS_SCHEMA_VERSION);
1470 assert!(choices.choices.is_empty());
1471 }
1472
1473 #[test]
1474 fn coordinated_publication_rejects_a_byte_changed_host_catalog() {
1475 let root = fixture_root();
1476 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1477 let changes =
1478 PluginRootChangeSet::new().with_configuration(PluginRootConfigurationChange::new(
1479 "example.agent",
1480 "default",
1481 b"greeting = \"hello\"\n".to_vec(),
1482 ));
1483 let proposal = propose_plugin_root_changes(root.path(), &base, changes).unwrap();
1484 let catalog: serde_json::Value =
1485 serde_json::from_slice(&fs::read(root.path().join(HOST_CATALOG)).unwrap()).unwrap();
1486 fs::write(
1487 root.path().join(HOST_CATALOG),
1488 serde_json::to_vec_pretty(&catalog).unwrap(),
1489 )
1490 .unwrap();
1491
1492 let error = publish_plugin_root_changes(root.path(), &proposal).unwrap_err();
1493
1494 assert!(error.to_string().contains("Host Catalog changed"));
1495 assert!(!configuration_path(root.path()).exists());
1496 }
1497
1498 #[test]
1499 fn requirement_migration_preserves_exact_provider_and_exposes_splits() {
1500 let consumer = PluginInstanceId::new("example.copy", "default");
1501 let provider = PluginInstanceId::new("example.store", "account-a");
1502 let current = [DependencyChoice {
1503 consumer: consumer.clone(),
1504 requirement_id: "~example.store@1".to_owned(),
1505 provider: Some(provider.clone()),
1506 }];
1507 let candidate = ["source", "archive"].map(|requirement_id| DependencyChoice {
1508 consumer: consumer.clone(),
1509 requirement_id: requirement_id.to_owned(),
1510 provider: Some(provider.clone()),
1511 });
1512
1513 let migrations = requirement_migrations(¤t, &candidate);
1514
1515 assert_eq!(migrations.len(), 1);
1516 assert_eq!(migrations[0].provider(), Some(&provider));
1517 assert_eq!(
1518 migrations[0].new_requirement_ids(),
1519 &["archive".to_owned(), "source".to_owned()]
1520 );
1521 assert!(has_unreviewed_split(&migrations, &[]));
1522 assert!(!has_unreviewed_split(&migrations, &candidate));
1523 }
1524
1525 #[test]
1526 fn local_authority_dispatches_through_the_host_port() {
1527 let root = fixture_root();
1528 let authority: Arc<dyn PluginConfigurationAuthority> =
1529 Arc::new(LocalPluginRootAuthority::new(root.path()));
1530 let source = authority.source();
1531 let base = authority.inspect().unwrap().revision().clone();
1532
1533 let proposal = authority
1534 .propose(
1535 &base,
1536 "example.agent",
1537 "default",
1538 b"greeting = \"authority\"\n",
1539 )
1540 .unwrap();
1541 assert!(!configuration_path(root.path()).exists());
1542
1543 let publication = authority.publish(&proposal).unwrap();
1544
1545 assert_eq!(source.kind(), "local_plugin_root");
1546 assert_eq!(source.reference(), "app");
1547 assert_eq!(publication.revision(), proposal.candidate_revision());
1548 assert_eq!(
1549 authority.inspect().unwrap().revision(),
1550 publication.revision()
1551 );
1552 }
1553
1554 #[test]
1555 fn stale_publication_fails_with_a_typed_conflict_and_preserves_the_winner() {
1556 let root = fixture_root();
1557 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1558 let first = proposal(root.path(), &base, b"greeting = \"first\"\n");
1559 let stale = proposal(root.path(), &base, b"greeting = \"stale\"\n");
1560 let first_publication = publish_instance_configuration(root.path(), &first).unwrap();
1561
1562 let error = publish_instance_configuration(root.path(), &stale).unwrap_err();
1563 let conflict = error.downcast_ref::<PluginRootRevisionConflict>().unwrap();
1564
1565 assert_eq!(conflict.expected(), &base);
1566 assert_eq!(conflict.current(), first_publication.revision());
1567 assert_eq!(
1568 fs::read_to_string(configuration_path(root.path())).unwrap(),
1569 "greeting = \"first\"\n"
1570 );
1571 }
1572
1573 #[test]
1574 fn concurrent_publications_allow_exactly_one_winner() {
1575 let root = fixture_root();
1576 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1577 let first = proposal(root.path(), &base, b"greeting = \"first\"\n");
1578 let second = proposal(root.path(), &base, b"greeting = \"second\"\n");
1579 let path = root.path().to_path_buf();
1580 let barrier = Arc::new(std::sync::Barrier::new(3));
1581 let handles = [first, second].map(|proposal| {
1582 let path = path.clone();
1583 let barrier = Arc::clone(&barrier);
1584 std::thread::spawn(move || {
1585 barrier.wait();
1586 publish_instance_configuration(&path, &proposal)
1587 })
1588 });
1589 barrier.wait();
1590 let outcomes = handles.map(|handle| handle.join().unwrap());
1591
1592 assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1);
1593 let error = outcomes.into_iter().find_map(Result::err).unwrap();
1594 assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1595 let contents = fs::read_to_string(configuration_path(&path)).unwrap();
1596 assert!(contents == "greeting = \"first\"\n" || contents == "greeting = \"second\"\n");
1597 }
1598
1599 #[test]
1600 fn rejected_proposal_keeps_structured_diagnostics_without_writing() {
1601 let root = fixture_root();
1602 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1603 let proposal = proposal(root.path(), &base, b"unexpected = true\n");
1604
1605 assert_eq!(
1606 proposal.status(),
1607 PluginConfigurationProposalStatus::Rejected
1608 );
1609 assert_eq!(
1610 proposal.application(),
1611 PluginConfigurationApplication::Blocked
1612 );
1613 assert_eq!(proposal.diagnostics()[0].code(), "invalid_configuration");
1614 assert!(!configuration_path(root.path()).exists());
1615 assert!(publish_instance_configuration(root.path(), &proposal).is_err());
1616 }
1617
1618 #[test]
1619 fn plugin_root_revision_is_semantic_not_toml_formatting() {
1620 let root = fixture_root();
1621 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1622 let proposal = proposal(root.path(), &base, b"greeting = \"hello\"\n");
1623 let publication = publish_instance_configuration(root.path(), &proposal).unwrap();
1624 fs::write(
1625 configuration_path(root.path()),
1626 b"# human note\n\ngreeting=\"hello\"\n",
1627 )
1628 .unwrap();
1629
1630 let reformatted = inspect_plugin_root(root.path()).unwrap();
1631 assert_eq!(reformatted.revision(), publication.revision());
1632 }
1633
1634 #[test]
1635 fn formatting_only_source_change_rejects_stale_publication_without_overwrite() {
1636 let root = fixture_root();
1637 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1638 let initial = proposal(root.path(), &base, b"greeting = \"hello\"\n");
1639 let initial = publish_instance_configuration(root.path(), &initial).unwrap();
1640 let stale = proposal(root.path(), initial.revision(), b"greeting = \"goodbye\"\n");
1641 let external = b"# keep this human note\n\ngreeting=\"hello\"\n";
1642 fs::write(configuration_path(root.path()), external).unwrap();
1643
1644 assert_eq!(
1645 inspect_plugin_root(root.path()).unwrap().revision(),
1646 initial.revision()
1647 );
1648 let error = publish_instance_configuration(root.path(), &stale).unwrap_err();
1649 let conflict = error
1650 .downcast_ref::<PluginConfigurationSourceConflict>()
1651 .unwrap();
1652
1653 assert_eq!(conflict.expected(), stale.base_source_digest());
1654 assert_ne!(conflict.current(), stale.base_source_digest());
1655 assert_eq!(fs::read(configuration_path(root.path())).unwrap(), external);
1656 }
1657
1658 #[test]
1659 fn proposal_digest_closes_the_exact_reviewed_toml() {
1660 let root = fixture_root();
1661 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1662 let compact = propose_instance_configuration(
1663 root.path(),
1664 &base,
1665 "example.agent",
1666 "default",
1667 b"greeting=\"hello\"\n",
1668 )
1669 .unwrap();
1670 let formatted = propose_instance_configuration(
1671 root.path(),
1672 &base,
1673 "example.agent",
1674 "default",
1675 b"greeting = \"hello\"\n",
1676 )
1677 .unwrap();
1678
1679 assert_eq!(compact.candidate_revision(), formatted.candidate_revision());
1680 assert_ne!(compact.digest(), formatted.digest());
1681 }
1682
1683 #[test]
1684 fn source_digest_domains_raw_bytes_absence_and_instance_identity() {
1685 let absent =
1686 PluginConfigurationSourceDigest::for_source("example.agent", "default", None).unwrap();
1687 let empty =
1688 PluginConfigurationSourceDigest::for_source("example.agent", "default", Some(b""))
1689 .unwrap();
1690 let other_instance =
1691 PluginConfigurationSourceDigest::for_source("example.agent", "secondary", None)
1692 .unwrap();
1693
1694 assert_ne!(absent, empty);
1695 assert_ne!(absent, other_instance);
1696 }
1697
1698 #[test]
1699 fn plugin_root_revision_round_trips_for_http_preconditions() {
1700 let root = fixture_root();
1701 let revision = inspect_plugin_root(root.path()).unwrap().revision().clone();
1702
1703 assert_eq!(
1704 revision.as_str().parse::<PluginRootRevision>().unwrap(),
1705 revision
1706 );
1707 assert!("sha256:not-a-digest".parse::<PluginRootRevision>().is_err());
1708 }
1709
1710 fn proposal(
1711 root: &Path,
1712 base: &PluginRootRevision,
1713 toml: &[u8],
1714 ) -> PluginConfigurationProposal {
1715 propose_instance_configuration(root, base, "example.agent", "default", toml).unwrap()
1716 }
1717
1718 fn configuration_path(root: &Path) -> std::path::PathBuf {
1719 root.join("plugins/example.agent/default.toml")
1720 }
1721}