1use crate::identifiers::{is_sensitive_key, looks_like_local_path, looks_like_url, validate_text};
14use crate::{
15 ApplicationDependency, ApplicationId, CapabilityClass, CapabilityDeclaration, ContractError,
16 ContractResult, FeatureId, HealthRequirements, JobPolicy, LeadershipMode,
17 LeadershipRequirement, ModuleDeclaration, RuntimeRequirements, SchedulerRequirements,
18 ServiceId, StorageDurability, StorageRequirements, UpdatePolicy,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, BTreeSet};
22
23pub const APPLICATION_MANIFEST_VERSION: u16 = 1;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(try_from = "ApplicationManifestData")]
32pub struct ApplicationManifestV1 {
33 manifest_version: u16,
34 application_id: ApplicationId,
35 application_version: String,
36 display_name: String,
37 vendor: String,
38 service_id: ServiceId,
39 runtime: RuntimeRequirements,
40 capabilities: Vec<CapabilityDeclaration>,
41 leadership: Vec<LeadershipRequirement>,
42 jobs: JobPolicy,
43 dependencies: Vec<ApplicationDependency>,
44 storage: StorageRequirements,
45 scheduler: SchedulerRequirements,
46 health: HealthRequirements,
47 update: UpdatePolicy,
48 modules: Vec<ModuleDeclaration>,
49 feature_flags: BTreeMap<FeatureId, bool>,
50 metadata: BTreeMap<String, String>,
51}
52
53#[derive(Deserialize)]
54struct ApplicationManifestData {
55 manifest_version: u16,
56 application_id: ApplicationId,
57 application_version: String,
58 display_name: String,
59 vendor: String,
60 service_id: ServiceId,
61 runtime: RuntimeRequirements,
62 capabilities: Vec<CapabilityDeclaration>,
63 leadership: Vec<LeadershipRequirement>,
64 jobs: JobPolicy,
65 dependencies: Vec<ApplicationDependency>,
66 storage: StorageRequirements,
67 scheduler: SchedulerRequirements,
68 health: HealthRequirements,
69 update: UpdatePolicy,
70 modules: Vec<ModuleDeclaration>,
71 feature_flags: BTreeMap<FeatureId, bool>,
72 metadata: BTreeMap<String, String>,
73}
74
75impl ApplicationManifestV1 {
76 pub fn new(
78 application_id: ApplicationId,
79 application_version: impl Into<String>,
80 display_name: impl Into<String>,
81 vendor: impl Into<String>,
82 service_id: ServiceId,
83 runtime: RuntimeRequirements,
84 ) -> ContractResult<Self> {
85 let manifest = Self {
86 manifest_version: APPLICATION_MANIFEST_VERSION,
87 application_id,
88 application_version: application_version.into(),
89 display_name: display_name.into(),
90 vendor: vendor.into(),
91 service_id,
92 runtime,
93 capabilities: Vec::new(),
94 leadership: Vec::new(),
95 jobs: JobPolicy::disabled(),
96 dependencies: Vec::new(),
97 storage: StorageRequirements::new(StorageDurability::Local, 0, false),
98 scheduler: SchedulerRequirements::new(false, 0)?,
99 health: HealthRequirements::new(30_000, 10_000, 3)?,
100 update: UpdatePolicy::new("stable", false)?,
101 modules: Vec::new(),
102 feature_flags: BTreeMap::new(),
103 metadata: BTreeMap::new(),
104 };
105 manifest.validate()?;
106 Ok(manifest)
107 }
108
109 pub fn with_capability(mut self, capability: CapabilityDeclaration) -> ContractResult<Self> {
111 if self
112 .capabilities
113 .iter()
114 .any(|existing| existing.id() == capability.id())
115 {
116 return Err(ContractError::Duplicate {
117 field: "capability",
118 value: capability.id().to_string(),
119 });
120 }
121 self.capabilities.push(capability);
122 self.validate()?;
123 Ok(self)
124 }
125
126 pub fn with_leadership(mut self, requirement: LeadershipRequirement) -> ContractResult<Self> {
128 if self
129 .leadership
130 .iter()
131 .any(|existing| existing.service_id() == requirement.service_id())
132 {
133 return Err(ContractError::Duplicate {
134 field: "leadership.service_id",
135 value: requirement.service_id().to_string(),
136 });
137 }
138 self.leadership.push(requirement);
139 self.validate()?;
140 Ok(self)
141 }
142
143 pub fn with_job_policy(mut self, policy: JobPolicy) -> ContractResult<Self> {
145 self.jobs = policy;
146 self.validate()?;
147 Ok(self)
148 }
149
150 pub fn with_dependency(mut self, dependency: ApplicationDependency) -> ContractResult<Self> {
152 if self
153 .dependencies
154 .iter()
155 .any(|existing| existing.application_id() == dependency.application_id())
156 {
157 return Err(ContractError::Duplicate {
158 field: "dependency.application_id",
159 value: dependency.application_id().to_string(),
160 });
161 }
162 self.dependencies.push(dependency);
163 self.validate()?;
164 Ok(self)
165 }
166
167 pub fn with_storage_requirements(mut self, requirements: StorageRequirements) -> Self {
169 self.storage = requirements;
170 self
171 }
172
173 pub fn with_scheduler_requirements(
175 mut self,
176 requirements: SchedulerRequirements,
177 ) -> ContractResult<Self> {
178 self.scheduler = requirements;
179 self.validate()?;
180 Ok(self)
181 }
182
183 pub fn with_health_requirements(
185 mut self,
186 requirements: HealthRequirements,
187 ) -> ContractResult<Self> {
188 self.health = requirements;
189 self.validate()?;
190 Ok(self)
191 }
192
193 pub fn with_update_policy(mut self, policy: UpdatePolicy) -> ContractResult<Self> {
195 self.update = policy;
196 self.validate()?;
197 Ok(self)
198 }
199
200 pub fn with_module(mut self, module: ModuleDeclaration) -> ContractResult<Self> {
202 if self
203 .modules
204 .iter()
205 .any(|existing| existing.id() == module.id())
206 {
207 return Err(ContractError::Duplicate {
208 field: "module",
209 value: module.id().to_string(),
210 });
211 }
212 self.modules.push(module);
213 self.validate()?;
214 Ok(self)
215 }
216
217 pub fn with_feature_flag(mut self, feature: FeatureId, enabled: bool) -> Self {
219 self.feature_flags.insert(feature, enabled);
220 self
221 }
222
223 pub fn with_metadata(
225 mut self,
226 key: impl Into<String>,
227 value: impl Into<String>,
228 ) -> ContractResult<Self> {
229 let key = key.into();
230 let value = value.into();
231 validate_application_metadata(&key, &value)?;
232 self.metadata.insert(key, value);
233 Ok(self)
234 }
235
236 pub fn manifest_version(&self) -> u16 {
238 self.manifest_version
239 }
240
241 pub fn application_id(&self) -> &ApplicationId {
243 &self.application_id
244 }
245
246 pub fn application_version(&self) -> &str {
248 &self.application_version
249 }
250
251 pub fn display_name(&self) -> &str {
253 &self.display_name
254 }
255
256 pub fn vendor(&self) -> &str {
258 &self.vendor
259 }
260
261 pub fn service_id(&self) -> &ServiceId {
263 &self.service_id
264 }
265
266 pub fn runtime_requirements(&self) -> &RuntimeRequirements {
268 &self.runtime
269 }
270
271 pub fn capabilities(&self) -> &[CapabilityDeclaration] {
273 &self.capabilities
274 }
275
276 pub fn leadership(&self) -> &[LeadershipRequirement] {
278 &self.leadership
279 }
280
281 pub fn job_policy(&self) -> &JobPolicy {
283 &self.jobs
284 }
285
286 pub fn dependencies(&self) -> &[ApplicationDependency] {
288 &self.dependencies
289 }
290
291 pub fn storage_requirements(&self) -> &StorageRequirements {
293 &self.storage
294 }
295
296 pub fn scheduler_requirements(&self) -> &SchedulerRequirements {
298 &self.scheduler
299 }
300
301 pub fn health_requirements(&self) -> &HealthRequirements {
303 &self.health
304 }
305
306 pub fn update_policy(&self) -> &UpdatePolicy {
308 &self.update
309 }
310
311 pub fn modules(&self) -> &[ModuleDeclaration] {
313 &self.modules
314 }
315
316 pub fn feature_flags(&self) -> &BTreeMap<FeatureId, bool> {
318 &self.feature_flags
319 }
320
321 pub fn metadata(&self) -> &BTreeMap<String, String> {
323 &self.metadata
324 }
325
326 pub fn validate(&self) -> ContractResult<()> {
328 if self.manifest_version != APPLICATION_MANIFEST_VERSION {
329 return Err(ContractError::InvalidValue {
330 field: "manifest_version",
331 reason: "unsupported application manifest version",
332 });
333 }
334 validate_text("application_version", &self.application_version, 64)?;
335 validate_text("display_name", &self.display_name, 256)?;
336 validate_text("vendor", &self.vendor, 256)?;
337 self.runtime.validate()?;
338 self.jobs.validate()?;
339 self.scheduler.validate()?;
340 self.health.validate()?;
341 self.update.validate()?;
342
343 ensure_unique(
344 "capability",
345 self.capabilities.iter().map(|item| item.id().as_str()),
346 )?;
347 for capability in &self.capabilities {
348 capability.validate()?;
349 reject_reserved_capability_namespace(capability.id().as_str())?;
350 if capability.class() != CapabilityClass::Functional {
351 return Err(ContractError::InvalidValue {
352 field: "capabilities.class",
353 reason: "application manifests may declare only functional capabilities",
354 });
355 }
356 }
357 ensure_unique(
358 "leadership.service_id",
359 self.leadership
360 .iter()
361 .map(|item| item.service_id().as_str()),
362 )?;
363 for leadership in &self.leadership {
364 leadership.validate()?;
365 }
366 if self
367 .capabilities
368 .iter()
369 .any(CapabilityDeclaration::requires_leader)
370 && !self.leadership.iter().any(|requirement| {
371 requirement.service_id() == &self.service_id
372 && requirement.mode() != LeadershipMode::Disabled
373 })
374 {
375 return Err(ContractError::InvalidValue {
376 field: "capabilities.requires_leader",
377 reason: "primary service leadership must be enabled",
378 });
379 }
380 ensure_unique(
381 "dependency.application_id",
382 self.dependencies
383 .iter()
384 .map(|item| item.application_id().as_str()),
385 )?;
386 for dependency in &self.dependencies {
387 dependency.validate()?;
388 }
389 ensure_unique("module", self.modules.iter().map(|item| item.id().as_str()))?;
390 for module in &self.modules {
391 module.validate()?;
392 }
393 for (key, value) in &self.metadata {
394 validate_application_metadata(key, value)?;
395 }
396 Ok(())
397 }
398}
399
400fn reject_reserved_capability_namespace(capability: &str) -> ContractResult<()> {
401 const RESERVED_PREFIXES: [&str; 3] = ["appcore.", "runtime.", "infrastructure."];
402 let normalized = capability.to_ascii_lowercase();
403 if RESERVED_PREFIXES
404 .iter()
405 .any(|prefix| normalized.starts_with(prefix))
406 {
407 return Err(ContractError::InvalidValue {
408 field: "capabilities.id",
409 reason: "application capability uses a reserved Runtime namespace",
410 });
411 }
412 Ok(())
413}
414
415impl TryFrom<ApplicationManifestData> for ApplicationManifestV1 {
416 type Error = ContractError;
417
418 fn try_from(data: ApplicationManifestData) -> Result<Self, Self::Error> {
419 let manifest = Self {
420 manifest_version: data.manifest_version,
421 application_id: data.application_id,
422 application_version: data.application_version,
423 display_name: data.display_name,
424 vendor: data.vendor,
425 service_id: data.service_id,
426 runtime: data.runtime,
427 capabilities: data.capabilities,
428 leadership: data.leadership,
429 jobs: data.jobs,
430 dependencies: data.dependencies,
431 storage: data.storage,
432 scheduler: data.scheduler,
433 health: data.health,
434 update: data.update,
435 modules: data.modules,
436 feature_flags: data.feature_flags,
437 metadata: data.metadata,
438 };
439 manifest.validate()?;
440 Ok(manifest)
441 }
442}
443
444fn ensure_unique<'a>(
445 field: &'static str,
446 values: impl IntoIterator<Item = &'a str>,
447) -> ContractResult<()> {
448 let mut seen = BTreeSet::new();
449 for value in values {
450 if !seen.insert(value) {
451 return Err(ContractError::Duplicate {
452 field,
453 value: value.to_string(),
454 });
455 }
456 }
457 Ok(())
458}
459
460fn validate_application_metadata(key: &str, value: &str) -> ContractResult<()> {
461 validate_text("metadata.key", key, 128)?;
462 validate_text("metadata.value", value, 2_048)?;
463 if is_sensitive_key(key) {
464 return Err(ContractError::SecretValue {
465 field: format!("metadata.{key}"),
466 });
467 }
468 if key.to_ascii_lowercase().contains("path") || looks_like_local_path(value) {
469 return Err(ContractError::LocalPath {
470 field: format!("metadata.{key}"),
471 });
472 }
473 let is_location_key = key.split(['.', '_', '-']).any(|part| {
474 matches!(
475 part.to_ascii_lowercase().as_str(),
476 "url" | "uri" | "endpoint"
477 )
478 });
479 if is_location_key || looks_like_url(value) {
480 return Err(ContractError::InvalidValue {
481 field: "metadata",
482 reason: "installation-specific URLs belong to the deployment manifest",
483 });
484 }
485 Ok(())
486}
487
488#[cfg(test)]
489#[path = "application/tests.rs"]
490mod tests;