1use std::{
4 collections::{BTreeMap, BTreeSet},
5 ops::Deref,
6 path::Path,
7};
8
9use anyhow::{Context, bail};
10use lenso_app_plan::authoring::{
11 HostBinding, HostCatalog, HostDefaultPlugin, HostPluginRelease, HostSlot, PluginDescriptor,
12 PluginInstanceId, PluginRootInstance, PluginRootResolutionError, PluginRootSnapshot,
13 ResolvedApp, propose_plugin_root, resolve_plugin_root,
14};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18mod policy;
19pub use policy::{AdmittedRelease, SlotAdmission};
20
21pub(crate) const HOST_BUILD: &str = ".lenso/host-build.json";
22const SCHEMA: &str = "lenso.host-build.v1";
23
24#[derive(Clone, Debug, Deserialize, Serialize)]
26#[serde(deny_unknown_fields)]
27pub struct GeneratedHostBuild {
28 schema: String,
29 host_id: String,
30 catalog: HostCatalog,
31 admissions: Vec<SlotAdmission>,
32}
33
34#[derive(Debug)]
36pub struct HostPluginInput {
37 pub descriptor: PluginDescriptor,
38 pub instance: String,
39 pub configuration: Value,
40 pub source: String,
41}
42
43#[derive(Debug)]
45pub struct HostDependencyInput {
46 pub consumer: PluginInstanceId,
47 pub requirement: String,
48 pub providers: Vec<PluginInstanceId>,
49 pub default_provider: Option<PluginInstanceId>,
50}
51
52impl GeneratedHostBuild {
53 pub fn host_id(&self) -> &str {
54 &self.host_id
55 }
56
57 pub fn verify_distribution_bundle(
59 &self,
60 descriptor: &PluginDescriptor,
61 manifest_digest: &str,
62 ) -> anyhow::Result<()> {
63 let default = self
64 .catalog
65 .plugins()
66 .iter()
67 .any(|release| release.descriptor() == descriptor);
68 let admitted = self.admissions.iter().any(|rule| {
69 rule.releases.iter().any(|release| {
70 &release.descriptor == descriptor && release.manifest_digest == manifest_digest
71 })
72 });
73 if !default && !admitted {
74 bail!(
75 "bundle `{}` is not part of this Host build",
76 descriptor.plugin_id()
77 );
78 }
79 Ok(())
80 }
81
82 pub fn lower(
84 host_id: &str,
85 plugins: Vec<HostPluginInput>,
86 explicit_slots: Vec<HostSlot>,
87 ) -> anyhow::Result<Self> {
88 Self::lower_with_admission(host_id, plugins, explicit_slots, vec![])
89 }
90
91 pub fn lower_with_admission(
92 host_id: &str,
93 plugins: Vec<HostPluginInput>,
94 explicit_slots: Vec<HostSlot>,
95 admissions: Vec<SlotAdmission>,
96 ) -> anyhow::Result<Self> {
97 Self::lower_with_dependencies(host_id, plugins, explicit_slots, admissions, vec![])
98 }
99
100 pub fn lower_with_dependencies(
101 host_id: &str,
102 plugins: Vec<HostPluginInput>,
103 explicit_slots: Vec<HostSlot>,
104 admissions: Vec<SlotAdmission>,
105 dependencies: Vec<HostDependencyInput>,
106 ) -> anyhow::Result<Self> {
107 crate::identity::validate_plugin_id_v1(host_id).context("invalid Host identity")?;
108 let mut slots = BTreeMap::new();
109 for slot in explicit_slots {
110 let id = slot.id().to_owned();
111 if slots.insert(id.clone(), slot).is_some() {
112 bail!("duplicate explicit Host Slot `{id}`");
113 }
114 }
115 let mut offers: BTreeMap<String, Vec<String>> = BTreeMap::new();
116 let mut releases = BTreeMap::new();
117 let mut defaults = BTreeMap::new();
118 let mut normalized_ids = BTreeMap::new();
119 for input in plugins {
120 let descriptor = input.descriptor;
121 crate::validate_existing_plugin_id(descriptor.plugin_id())?;
122 crate::validate_instance_filename(&input.instance)?;
123 let default = HostDefaultPlugin::new(descriptor.plugin_id(), &input.instance)
124 .with_configuration(input.configuration);
125 let id = default.id().clone();
126 crate::reject_case_collision(
127 &mut normalized_ids,
128 &id.to_string(),
129 "Host Instance identity",
130 )?;
131 if let Some(previous) = defaults.insert(id.clone(), default) {
132 bail!(
133 "{}: duplicate Host Instance `{}`",
134 input.source,
135 previous.id()
136 );
137 }
138 offers
139 .entry(descriptor.root_slot().to_owned())
140 .or_default()
141 .push(format!("{id} ({})", input.source));
142 if let Some(previous) =
143 releases.insert(descriptor.plugin_id().to_owned(), descriptor.clone())
144 && previous != descriptor
145 {
146 bail!(
147 "{}: conflicting Releases or implementations for Plugin `{}`",
148 input.source,
149 descriptor.plugin_id()
150 );
151 }
152 }
153 for (slot, candidates) in &offers {
154 if !slots.contains_key(slot) {
155 if candidates.len() != 1 {
156 bail!(
157 "Host Slot `{slot}` has multiple defaults: {}; declare explicit Slot cardinality",
158 candidates.join(", ")
159 );
160 }
161 slots.insert(slot.clone(), HostSlot::one(slot));
162 }
163 }
164 for slot in slots.keys() {
165 if !offers.contains_key(slot)
166 && !admissions
167 .iter()
168 .any(|rule| rule.slot == *slot && !rule.releases.is_empty())
169 {
170 bail!("closed Host Slot `{slot}` has no authored default");
171 }
172 }
173 let bindings = lower_dependency_bindings(dependencies, &defaults, &releases)?;
174 let build = Self {
175 schema: SCHEMA.to_owned(),
176 host_id: host_id.to_owned(),
177 catalog: HostCatalog::new(
178 slots.into_values(),
179 releases.into_values().map(|descriptor| {
180 let replace = admissions.iter().any(|rule| {
181 rule.releases
182 .iter()
183 .any(|release| release.descriptor.plugin_id() == descriptor.plugin_id())
184 });
185 let release = HostPluginRelease::new(descriptor);
186 if replace {
187 release.allow_root_override()
188 } else {
189 release
190 }
191 }),
192 defaults.into_values(),
193 )
194 .with_bindings(bindings),
195 admissions,
196 };
197 build.validate()?;
198 Ok(build)
199 }
200
201 pub const fn catalog(&self) -> &HostCatalog {
202 &self.catalog
203 }
204
205 pub fn validate(&self) -> anyhow::Result<()> {
207 self.propose(&PluginRootSnapshot::default())
209 .context("resolve Host defaults")?;
210 Ok(())
211 }
212
213 fn validate_policy(&self) -> anyhow::Result<BTreeMap<String, jsonschema::Validator>> {
214 let mut validators = BTreeMap::new();
215 if self.admissions.len() > 256
216 || self.catalog.defaults().len() > 256
217 || self.catalog.slots().len() > 256
218 || self
219 .admissions
220 .iter()
221 .map(|rule| rule.releases.len())
222 .sum::<usize>()
223 > 256
224 {
225 bail!("Host policy exceeds the 256 Instance/Slot/release profile limit");
226 }
227 if self.schema != SCHEMA {
228 bail!("unsupported Host build schema `{}`", self.schema);
229 }
230 crate::identity::validate_plugin_id_v1(&self.host_id)?;
231 if self
232 .catalog
233 .defaults()
234 .iter()
235 .any(HostDefaultPlugin::is_disableable)
236 {
237 bail!("Host build cannot contain disableable defaults in this profile");
238 }
239 let mut slots = BTreeSet::new();
240 let mut plugins = BTreeSet::new();
241 for rule in &self.admissions {
242 if !slots.insert(&rule.slot)
243 || !self
244 .catalog
245 .slots()
246 .iter()
247 .any(|slot| slot.id() == rule.slot)
248 {
249 bail!("duplicate or unknown admission Slot `{}`", rule.slot);
250 }
251 if rule.max_instances == 0 || rule.max_instances > 256 {
252 bail!("Slot `{}` maxInstances must be 1..=256", rule.slot);
253 }
254 if let Some(schema) = &rule.configuration_schema {
255 validators.insert(
256 rule.slot.clone(),
257 policy::compile_ceiling(schema)
258 .with_context(|| format!("Slot `{}`", rule.slot))?,
259 );
260 }
261 for release in &rule.releases {
262 let descriptor = &release.descriptor;
263 crate::identity::validate_plugin_id_v1(descriptor.plugin_id())?;
264 if descriptor.root_slot() != rule.slot || !plugins.insert(descriptor.plugin_id()) {
265 bail!(
266 "admitted Plugin `{}` has a conflicting Slot or multiple release policies",
267 descriptor.plugin_id()
268 );
269 }
270 if self.catalog.plugins().iter().any(|item| {
271 item.descriptor().plugin_id() == descriptor.plugin_id()
272 && item.descriptor().root_slot() != rule.slot
273 }) {
274 bail!("replacement cannot move a Plugin to another Slot");
275 }
276 let digest = release
277 .manifest_digest
278 .strip_prefix("sha256:")
279 .context("invalid admitted manifest digest")?;
280 if digest.len() != 64
281 || !digest
282 .bytes()
283 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
284 {
285 bail!("invalid admitted manifest digest");
286 }
287 }
288 }
289 for slot in self
290 .catalog
291 .slots()
292 .iter()
293 .filter(|slot| slot.is_replaceable())
294 {
295 if !self
296 .admissions
297 .iter()
298 .any(|rule| rule.slot == slot.id() && !rule.releases.is_empty())
299 {
300 bail!(
301 "replaceable Slot `{}` needs an explicit release admission",
302 slot.id()
303 );
304 }
305 }
306 Ok(validators)
307 }
308
309 pub fn resolve(
310 &self,
311 snapshot: &PluginRootSnapshot,
312 ) -> Result<ResolvedApp, PluginRootResolutionError> {
313 self.resolve_with(snapshot, resolve_plugin_root)
314 }
315
316 pub fn propose(
317 &self,
318 snapshot: &PluginRootSnapshot,
319 ) -> Result<ResolvedApp, PluginRootResolutionError> {
320 self.resolve_with(snapshot, propose_plugin_root)
321 }
322
323 fn resolve_with(
324 &self,
325 snapshot: &PluginRootSnapshot,
326 resolver: impl FnOnce(
327 &HostCatalog,
328 &PluginRootSnapshot,
329 ) -> Result<ResolvedApp, PluginRootResolutionError>,
330 ) -> Result<ResolvedApp, PluginRootResolutionError> {
331 let validators = self.validate_policy().map_err(|error| {
332 PluginRootResolutionError::InvalidHostConfiguration(error.to_string())
333 })?;
334 self.admit(snapshot)?;
335 let resolved_app = resolver(&self.catalog, snapshot)?;
336 if resolved_app.instances().len() > 256 {
337 return Err(denied("Host exceeds 256 active Instances"));
338 }
339 for rule in &self.admissions {
340 let selected = resolved_app
341 .instances()
342 .iter()
343 .filter(|instance| {
344 self.descriptor(snapshot, instance.id().plugin_id())
345 .is_some_and(|descriptor| descriptor.root_slot() == rule.slot)
346 })
347 .collect::<Vec<_>>();
348 if selected.len() > rule.max_instances {
349 return Err(denied(format!(
350 "Slot `{}` exceeds maxInstances {}",
351 rule.slot, rule.max_instances
352 )));
353 }
354 if let Some(validator) = validators.get(&rule.slot) {
355 for instance in selected {
356 let plan = resolved_app
357 .plan()
358 .plugin_instances()
359 .iter()
360 .find(|plan| plan.instance_key() == instance.plan_key())
361 .ok_or_else(|| denied("missing resolved Instance"))?;
362 let configuration: Value = serde_json::from_str(plan.configuration())
363 .map_err(|_| denied("invalid resolved configuration"))?;
364 if let Err(error) = validator.validate(&configuration) {
365 return Err(denied(format!(
366 "Instance `{}` exceeds configuration ceiling in Slot `{}` at {} (schema {})",
367 instance.id(),
368 rule.slot,
369 error.instance_path(),
370 error.schema_path()
371 )));
372 }
373 }
374 }
375 }
376 Ok(resolved_app)
377 }
378
379 fn admit(&self, snapshot: &PluginRootSnapshot) -> Result<(), PluginRootResolutionError> {
380 let deny = |detail| {
381 PluginRootResolutionError::InvalidHostConfiguration(format!(
382 "Host admission denied: {detail}; change the Host declaration and rebuild"
383 ))
384 };
385 for release in snapshot.releases() {
386 if !self.admissions.iter().any(|rule| {
387 rule.releases
388 .iter()
389 .any(|allowed| allowed.descriptor == *release)
390 }) {
391 return Err(deny(format!(
392 "Root bundle `{}` is not admitted",
393 release.plugin_id()
394 )));
395 }
396 }
397 for id in snapshot
398 .instances()
399 .iter()
400 .map(PluginRootInstance::id)
401 .chain(snapshot.disabled())
402 {
403 if !self
404 .catalog
405 .defaults()
406 .iter()
407 .any(|default| default.id() == id)
408 && !self
409 .descriptor(snapshot, id.plugin_id())
410 .is_some_and(|descriptor| {
411 self.admissions.iter().any(|rule| {
412 rule.releases
413 .iter()
414 .any(|release| release.descriptor == *descriptor)
415 })
416 })
417 {
418 return Err(deny(format!(
419 "Instance `{id}` is not an exact Host default"
420 )));
421 }
422 }
423 Ok(())
424 }
425
426 fn descriptor<'a>(
427 &'a self,
428 snapshot: &'a PluginRootSnapshot,
429 plugin_id: &str,
430 ) -> Option<&'a PluginDescriptor> {
431 snapshot
432 .releases()
433 .iter()
434 .find(|descriptor| descriptor.plugin_id() == plugin_id)
435 .or_else(|| {
436 self.catalog
437 .plugins()
438 .iter()
439 .map(HostPluginRelease::descriptor)
440 .find(|descriptor| descriptor.plugin_id() == plugin_id)
441 })
442 }
443
444 fn select_bundle(
445 &self,
446 verified: &lenso_plugin_bundle::VerifiedBundle,
447 ) -> anyhow::Result<PluginDescriptor> {
448 self.admissions
450 .iter()
451 .flat_map(|rule| &rule.releases)
452 .find(|release| {
453 release.descriptor.plugin_id() == verified.plugin_id
454 && release.manifest_digest == verified.manifest_digest
455 })
456 .map(|release| release.descriptor.clone())
457 .with_context(|| {
458 format!(
459 "Host admission denied: bundle `{}` at `{}` is not an exact admitted release",
460 verified.plugin_id, verified.manifest_digest
461 )
462 })
463 }
464}
465
466fn lower_dependency_bindings(
467 dependencies: Vec<HostDependencyInput>,
468 defaults: &BTreeMap<PluginInstanceId, HostDefaultPlugin>,
469 releases: &BTreeMap<String, PluginDescriptor>,
470) -> anyhow::Result<Vec<HostBinding>> {
471 dependencies
472 .into_iter()
473 .map(|dependency| {
474 crate::validate_existing_plugin_id(dependency.consumer.plugin_id())?;
475 crate::validate_instance_filename(dependency.consumer.instance_key())?;
476 if dependency.providers.is_empty() {
477 bail!(
478 "selectable requirement `{}` needs at least one Host-permitted provider",
479 dependency.requirement
480 );
481 }
482 if !defaults.contains_key(&dependency.consumer) {
483 bail!(
484 "selectable requirement `{}` has unknown consumer `{}`",
485 dependency.requirement,
486 dependency.consumer
487 );
488 }
489 let descriptor = releases
490 .get(dependency.consumer.plugin_id())
491 .context("selectable dependency consumer has no exact Release")?;
492 let requirement = descriptor
493 .required_capabilities()
494 .iter()
495 .find(|requirement| requirement.requirement_id() == dependency.requirement)
496 .with_context(|| {
497 format!(
498 "Plugin `{}` does not declare requirement `{}`",
499 dependency.consumer.plugin_id(),
500 dependency.requirement
501 )
502 })?;
503 let mut seen = BTreeSet::new();
504 for provider in &dependency.providers {
505 crate::validate_existing_plugin_id(provider.plugin_id())?;
506 crate::validate_instance_filename(provider.instance_key())?;
507 if !defaults.contains_key(provider) || !seen.insert(provider) {
508 bail!(
509 "requirement `{}` contains an unknown or duplicate provider `{provider}`",
510 dependency.requirement
511 );
512 }
513 }
514 if dependency
515 .default_provider
516 .as_ref()
517 .is_some_and(|provider| !seen.contains(provider))
518 {
519 bail!(
520 "default provider for requirement `{}` is outside its Host-permitted set",
521 dependency.requirement
522 );
523 }
524 Ok(HostBinding::to_instances(
525 dependency.consumer,
526 requirement.capability_id(),
527 dependency.providers,
528 )
529 .with_requirement_id(dependency.requirement)
530 .selectable(dependency.default_provider))
531 })
532 .collect()
533}
534
535fn denied(detail: impl std::fmt::Display) -> PluginRootResolutionError {
536 PluginRootResolutionError::InvalidHostConfiguration(format!("Host admission denied: {detail}"))
537}
538
539#[derive(Debug, Serialize)]
541pub(crate) enum HostInput {
542 Legacy(HostCatalog),
543 Generated(GeneratedHostBuild),
544}
545
546impl Deref for HostInput {
547 type Target = HostCatalog;
548 fn deref(&self) -> &HostCatalog {
549 match self {
550 Self::Legacy(catalog) => catalog,
551 Self::Generated(build) => build.catalog(),
552 }
553 }
554}
555
556impl HostInput {
557 pub(crate) fn select_bundle(
558 &self,
559 path: &Path,
560 verified: &lenso_plugin_bundle::VerifiedBundle,
561 ) -> anyhow::Result<PluginDescriptor> {
562 match self {
563 Self::Legacy(_) => {
564 crate::read_verified_bundle_descriptor(path, &verified.plugin_id, verified)
565 }
566 Self::Generated(build) => build.select_bundle(verified),
567 }
568 }
569 pub(crate) fn resolve(
570 &self,
571 snapshot: &PluginRootSnapshot,
572 ) -> Result<ResolvedApp, PluginRootResolutionError> {
573 match self {
574 Self::Legacy(catalog) => resolve_plugin_root(catalog, snapshot),
575 Self::Generated(build) => build.resolve(snapshot),
576 }
577 }
578
579 pub(crate) fn propose(
580 &self,
581 snapshot: &PluginRootSnapshot,
582 ) -> Result<ResolvedApp, PluginRootResolutionError> {
583 match self {
584 Self::Legacy(catalog) => propose_plugin_root(catalog, snapshot),
585 Self::Generated(build) => build.propose(snapshot),
586 }
587 }
588}
589
590#[cfg(test)]
591mod admission_tests;
592#[cfg(test)]
593mod tests;