Skip to content

Commit 84b3e00

Browse files
committed
fix: deployment contract CI bridge — container manifest, OCI labels, runtime stage
Implements the deployment contract CI bridge spec: 1. container-manifest.json — minimal CI-consumable JSON for image builds 2. OCI labels — static labels in contract, dynamic injected by CI 3. Dockerfile.runtime — runtime stage fragment for CI composition 4. from_cargo_toml() — auto-detect native deps from Cargo.toml features 5. schema_version field on DeploymentContract (default: 2) generate-artefacts now emits 3 additional files: container-manifest.json, deployment-contract.json, and Dockerfile.runtime.
1 parent ce8b774 commit 84b3e00

6 files changed

Lines changed: 331 additions & 5 deletions

File tree

src/cli/app.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,15 +301,33 @@ fn generate_artefacts<A: DfeApp>(
301301
));
302302
}
303303

304-
// Deployment contract
304+
// Deployment contract + container manifest
305305
#[cfg(feature = "deployment")]
306306
if let Some(contract) = app.deployment_contract() {
307+
// Full deployment contract (secrets, KEDA, Helm, everything)
307308
let path = output_dir.join("deployment-contract.json");
308309
let json = serde_json::to_string_pretty(&contract)
309310
.map_err(|e| CliError::Service(format!("deployment contract JSON failed: {e}")))?;
310311
std::fs::write(&path, &json)
311312
.map_err(|e| CliError::Service(format!("failed to write {}: {e}", path.display())))?;
312313
generated.push("deployment-contract.json".to_string());
314+
315+
// Container manifest (minimal subset for CI image builds)
316+
let cm_path = output_dir.join("container-manifest.json");
317+
let cm_json = crate::deployment::generate::generate_container_manifest(&contract)
318+
.map_err(|e| CliError::Service(format!("container manifest failed: {e}")))?;
319+
std::fs::write(&cm_path, &cm_json).map_err(|e| {
320+
CliError::Service(format!("failed to write {}: {e}", cm_path.display()))
321+
})?;
322+
generated.push("container-manifest.json".to_string());
323+
324+
// Runtime stage Dockerfile fragment (for CI composition)
325+
let rt_path = output_dir.join("Dockerfile.runtime");
326+
let rt_content = crate::deployment::generate::generate_runtime_stage(&contract);
327+
std::fs::write(&rt_path, &rt_content).map_err(|e| {
328+
CliError::Service(format!("failed to write {}: {e}", rt_path.display()))
329+
})?;
330+
generated.push("Dockerfile.runtime".to_string());
313331
}
314332

315333
if generated.is_empty() {

src/deployment/contract.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ pub enum ImageProfile {
4343
/// fragment) from scratch.
4444
#[derive(Debug, Clone, Serialize, Deserialize)]
4545
pub struct DeploymentContract {
46+
/// Contract schema version. CI checks this and fails if unsupported.
47+
#[serde(default = "default_schema_version")]
48+
pub schema_version: u32,
49+
4650
/// Application name (e.g., "dfe-loader") — matched against Chart.yaml `name`.
4751
pub app_name: String,
4852

@@ -116,6 +120,53 @@ pub struct DeploymentContract {
116120
/// to derive a development variant from an existing contract.
117121
#[serde(default)]
118122
pub image_profile: ImageProfile,
123+
124+
/// OCI image labels (static — dynamic labels injected by CI at build time).
125+
#[serde(default)]
126+
pub oci_labels: OciLabels,
127+
}
128+
129+
/// OCI image labels for the container.
130+
///
131+
/// Static labels are set from the contract. Dynamic labels (source, revision,
132+
/// version, created) are injected by CI at build time via `--build-arg`.
133+
#[derive(Debug, Clone, Serialize, Deserialize)]
134+
pub struct OciLabels {
135+
/// Image title (defaults to app_name).
136+
#[serde(default)]
137+
pub title: String,
138+
/// Image description.
139+
#[serde(default)]
140+
pub description: String,
141+
/// Image vendor.
142+
#[serde(default = "default_vendor")]
143+
pub vendor: String,
144+
/// License identifier.
145+
#[serde(default = "default_license")]
146+
pub licenses: String,
147+
}
148+
149+
impl Default for OciLabels {
150+
fn default() -> Self {
151+
Self {
152+
title: String::new(),
153+
description: String::new(),
154+
vendor: default_vendor(),
155+
licenses: default_license(),
156+
}
157+
}
158+
}
159+
160+
fn default_vendor() -> String {
161+
"HYPERI PTY LIMITED".to_string()
162+
}
163+
164+
fn default_license() -> String {
165+
"FSL-1.1-ALv2".to_string()
166+
}
167+
168+
fn default_schema_version() -> u32 {
169+
2
119170
}
120171

121172
/// Health probe endpoint paths.

src/deployment/generate.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,184 @@ ENTRYPOINT ["{binary}"]{cmd}
101101
)
102102
}
103103

104+
// ============================================================================
105+
// Container Manifest (CI-consumable JSON)
106+
// ============================================================================
107+
108+
/// Generate a container manifest JSON for CI consumption.
109+
///
110+
/// This is the minimal subset of the deployment contract that CI needs to
111+
/// build the container image. No secrets, no K8s-specific config.
112+
///
113+
/// # Errors
114+
///
115+
/// Returns an error string if JSON serialisation fails.
116+
pub fn generate_container_manifest(contract: &DeploymentContract) -> Result<String, String> {
117+
let binary = contract.binary();
118+
119+
let apt_repos: Vec<serde_json::Value> = contract
120+
.native_deps
121+
.apt_repos
122+
.iter()
123+
.map(|r| {
124+
serde_json::json!({
125+
"key_url": r.key_url,
126+
"keyring": r.keyring,
127+
"url": r.url,
128+
"codename": r.codename,
129+
"packages": r.packages,
130+
})
131+
})
132+
.collect();
133+
134+
let mut expose_ports: Vec<u16> = vec![contract.metrics_port];
135+
expose_ports.extend(contract.extra_ports.iter().map(|p| p.port));
136+
137+
let profile_str = match contract.image_profile {
138+
ImageProfile::Production => "production",
139+
ImageProfile::Development => "development",
140+
};
141+
142+
let title = if contract.oci_labels.title.is_empty() {
143+
&contract.app_name
144+
} else {
145+
&contract.oci_labels.title
146+
};
147+
148+
let manifest = serde_json::json!({
149+
"schema_version": "1",
150+
"app_name": contract.app_name,
151+
"binary_name": binary,
152+
"base_image": contract.base_image,
153+
"image_registry": contract.image_registry,
154+
"image_profile": profile_str,
155+
"runtime_packages": {
156+
"apt_repos": apt_repos,
157+
"apt_packages": contract.native_deps.apt_packages,
158+
},
159+
"expose_ports": expose_ports,
160+
"healthcheck": {
161+
"path": contract.health.liveness_path,
162+
"port": contract.metrics_port,
163+
"interval": "30s",
164+
"timeout": "3s",
165+
"start_period": "5s",
166+
"retries": 3,
167+
},
168+
"entrypoint": [binary],
169+
"cmd": contract.entrypoint_args,
170+
"user": "appuser",
171+
"uid": 1000,
172+
"labels": {
173+
"io.hyperi.profile": profile_str,
174+
"io.hyperi.app": contract.app_name,
175+
"io.hyperi.metrics_port": contract.metrics_port.to_string(),
176+
"org.opencontainers.image.title": title,
177+
"org.opencontainers.image.description": contract.oci_labels.description,
178+
"org.opencontainers.image.vendor": contract.oci_labels.vendor,
179+
"org.opencontainers.image.licenses": contract.oci_labels.licenses,
180+
},
181+
});
182+
183+
serde_json::to_string_pretty(&manifest)
184+
.map_err(|e| format!("container manifest JSON failed: {e}"))
185+
}
186+
187+
// ============================================================================
188+
// Runtime Stage Fragment (for CI Dockerfile composition)
189+
// ============================================================================
190+
191+
/// Generate only the runtime stage of a Dockerfile as a fragment.
192+
///
193+
/// CI composes the full Dockerfile by prepending its own build stages
194+
/// (cargo-chef pattern) and appending this runtime stage. This keeps
195+
/// the boundary clean: rustlib owns what's *in* the container, CI owns
196+
/// how to *build* the binary.
197+
#[must_use]
198+
pub fn generate_runtime_stage(contract: &DeploymentContract) -> String {
199+
let binary = contract.binary();
200+
let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
201+
202+
let profile_label = match contract.image_profile {
203+
ImageProfile::Production => "production",
204+
ImageProfile::Development => "development",
205+
};
206+
207+
let title = if contract.oci_labels.title.is_empty() {
208+
&contract.app_name
209+
} else {
210+
&contract.oci_labels.title
211+
};
212+
213+
let expose_ports = {
214+
let mut ports = vec![contract.metrics_port.to_string()];
215+
for p in &contract.extra_ports {
216+
ports.push(p.port.to_string());
217+
}
218+
ports.join(" ")
219+
};
220+
221+
let cmd = if contract.entrypoint_args.is_empty() {
222+
String::new()
223+
} else {
224+
let args: Vec<String> = contract
225+
.entrypoint_args
226+
.iter()
227+
.map(|a| format!("\"{a}\""))
228+
.collect();
229+
format!("\nCMD [{}]", args.join(", "))
230+
};
231+
232+
format!(
233+
r#"# --- Runtime stage (generated by hyperi-rustlib deployment contract) ---
234+
FROM {base_image} AS runtime
235+
236+
# Static OCI labels (from contract)
237+
LABEL org.opencontainers.image.title="{title}"
238+
LABEL org.opencontainers.image.description="{description}"
239+
LABEL org.opencontainers.image.vendor="{vendor}"
240+
LABEL org.opencontainers.image.licenses="{licenses}"
241+
LABEL io.hyperi.profile="{profile_label}"
242+
243+
{apt_block}
244+
# Dynamic OCI labels (injected by CI at build time)
245+
ARG OCI_SOURCE=""
246+
ARG OCI_REVISION=""
247+
ARG OCI_VERSION=""
248+
ARG OCI_CREATED=""
249+
LABEL org.opencontainers.image.source="${{OCI_SOURCE}}"
250+
LABEL org.opencontainers.image.revision="${{OCI_REVISION}}"
251+
LABEL org.opencontainers.image.version="${{OCI_VERSION}}"
252+
LABEL org.opencontainers.image.created="${{OCI_CREATED}}"
253+
254+
COPY --from=builder /app/target/release/{binary} /usr/local/bin/{binary}
255+
RUN chmod +x /usr/local/bin/{binary}
256+
257+
RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
258+
USER appuser
259+
260+
EXPOSE {expose_ports}
261+
262+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
263+
CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
264+
265+
ENTRYPOINT ["{binary}"]{cmd}
266+
"#,
267+
base_image = contract.base_image,
268+
title = title,
269+
description = contract.oci_labels.description,
270+
vendor = contract.oci_labels.vendor,
271+
licenses = contract.oci_labels.licenses,
272+
profile_label = profile_label,
273+
apt_block = apt_block,
274+
binary = binary,
275+
expose_ports = expose_ports,
276+
metrics_port = contract.metrics_port,
277+
liveness_path = contract.health.liveness_path,
278+
cmd = cmd,
279+
)
280+
}
281+
104282
/// Diagnostic tools installed in development images.
105283
const DEV_TOOLS: &[&str] = &[
106284
"bash",

src/deployment/mod.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,20 @@
7373
7474
mod contract;
7575
mod error;
76-
mod generate;
76+
pub mod generate;
7777
mod keda;
7878
mod native_deps;
7979
mod validate;
8080

8181
pub use contract::{
82-
DeploymentContract, HealthContract, ImageProfile, PortContract, SecretEnvContract,
82+
DeploymentContract, HealthContract, ImageProfile, OciLabels, PortContract, SecretEnvContract,
8383
SecretGroupContract,
8484
};
8585
pub use error::{ContractMismatch, DeploymentError};
86-
pub use generate::{generate_chart, generate_compose_fragment, generate_dockerfile};
86+
pub use generate::{
87+
generate_chart, generate_compose_fragment, generate_container_manifest, generate_dockerfile,
88+
generate_runtime_stage,
89+
};
8790
pub use keda::{KedaConfig, KedaContract};
8891
pub use native_deps::{AptRepoContract, NativeDepsContract};
8992
pub use validate::{validate_dockerfile, validate_helm_values};

src/deployment/native_deps.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,89 @@ impl NativeDepsContract {
153153
}
154154
}
155155

156+
/// Auto-detect native deps from the app's Cargo.toml.
157+
///
158+
/// Reads `[dependencies.hyperi-rustlib]` features from the given Cargo.toml
159+
/// and maps them to runtime packages. Falls back to empty deps if parsing fails.
160+
#[must_use]
161+
pub fn from_cargo_toml(cargo_toml_path: &std::path::Path, base_image: &str) -> Self {
162+
let Ok(content) = std::fs::read_to_string(cargo_toml_path) else {
163+
return Self::default();
164+
};
165+
166+
// Parse features from the hyperi-rustlib dependency line
167+
// Matches: features = ["transport-kafka", "spool", ...]
168+
let features = extract_rustlib_features(&content);
169+
if features.is_empty() {
170+
return Self::default();
171+
}
172+
173+
let feature_refs: Vec<&str> = features.iter().map(String::as_str).collect();
174+
Self::for_rustlib_features(&feature_refs, base_image)
175+
}
176+
156177
/// Returns true if there are no native deps to install.
157178
#[must_use]
158179
pub fn is_empty(&self) -> bool {
159180
self.apt_repos.is_empty() && self.apt_packages.is_empty()
160181
}
161182
}
162183

184+
/// Extract hyperi-rustlib feature names from Cargo.toml content.
185+
///
186+
/// Parses the `features = [...]` array from the `hyperi-rustlib` dependency.
187+
/// Returns empty vec if not found or parsing fails.
188+
fn extract_rustlib_features(content: &str) -> Vec<String> {
189+
// Find the hyperi-rustlib dependency line
190+
let mut in_rustlib = false;
191+
let mut features = Vec::new();
192+
193+
for line in content.lines() {
194+
let trimmed = line.trim();
195+
196+
// Single-line: hyperi-rustlib = { version = "...", features = [...] }
197+
if trimmed.starts_with("hyperi-rustlib")
198+
&& trimmed.contains("features")
199+
&& let Some(start) = trimmed.find("features = [")
200+
{
201+
let after = &trimmed[start + 12..];
202+
if let Some(end) = after.find(']') {
203+
let feature_str = &after[..end];
204+
for feat in feature_str.split(',') {
205+
let f = feat.trim().trim_matches('"').trim();
206+
if !f.is_empty() {
207+
features.push(f.to_string());
208+
}
209+
}
210+
return features;
211+
}
212+
}
213+
214+
// Multi-line: features = [\n"transport-kafka",\n...\n]
215+
if trimmed.starts_with("hyperi-rustlib") {
216+
in_rustlib = true;
217+
continue;
218+
}
219+
if in_rustlib {
220+
if trimmed.starts_with(']') {
221+
return features;
222+
}
223+
if trimmed.starts_with('"') {
224+
let f = trimmed.trim_matches('"').trim_end_matches(',').trim();
225+
if !f.is_empty() {
226+
features.push(f.to_string());
227+
}
228+
}
229+
// End of dependency block
230+
if trimmed.starts_with('[') && !trimmed.starts_with("[dependencies") {
231+
return features;
232+
}
233+
}
234+
}
235+
236+
features
237+
}
238+
163239
#[cfg(test)]
164240
mod tests {
165241
use super::*;

0 commit comments

Comments
 (0)