Skip to main content

lenso_app_authoring/
host_authoring.rs

1//! Closed Host lowering and admission above the shared Plugin Root resolver.
2
3use 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/// Generated Host authority. This is a build artifact, never App-owner input.
25#[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/// A selected, verified implementation and its authored default Instance.
35#[derive(Debug)]
36pub struct HostPluginInput {
37    pub descriptor: PluginDescriptor,
38    pub instance: String,
39    pub configuration: Value,
40    pub source: String,
41}
42
43/// Host-granted candidate set for one App-selectable named dependency.
44#[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    /// Confirms that one re-verified bundle Descriptor belongs to this immutable Host build.
58    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    /// Lowers closed defaults without rewriting offers or resolving dependencies in TS.
83    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    /// Rejects malformed/unsupported authority instead of treating it as a legacy Catalog.
206    pub fn validate(&self) -> anyhow::Result<()> {
207        // The shared resolver remains the sole validator for complete bindings/configuration.
208        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        // HostInput is validated before scanning or mutating bundles.
449        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/// Loaded atomically from one authority file; configuration proposals bind this full value.
540#[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;