@@ -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 ! ( "\n CMD [{}]" , 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.
105283const DEV_TOOLS : & [ & str ] = & [
106284 "bash" ,
0 commit comments