@@ -67,7 +67,8 @@ impl TdxAttestationVariant {
6767
6868#[ derive( Deserialize , Serialize , Debug , Clone ) ]
6969pub struct AppCompose {
70- pub manifest_version : u32 ,
70+ #[ serde( deserialize_with = "deserialize_manifest_version" ) ]
71+ pub manifest_version : String ,
7172 pub name : String ,
7273 // Deprecated
7374 #[ serde( default ) ]
@@ -105,6 +106,98 @@ pub struct AppCompose {
105106 /// optional port whitelist).
106107 #[ serde( default ) ]
107108 pub port_policy : PortPolicy ,
109+ /// Guest-side requirements enforced by guests that understand this field.
110+ ///
111+ /// Use manifest_version "3" (string) when setting this field so older
112+ /// guests, which only accept numeric manifest versions, fail closed instead
113+ /// of silently ignoring the requirements.
114+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
115+ pub requirements : Option < Requirements > ,
116+ }
117+
118+ #[ derive( Deserialize , Serialize , Debug , Clone , Default , PartialEq , Eq ) ]
119+ #[ serde( default , deny_unknown_fields) ]
120+ pub struct Requirements {
121+ /// OS-version requirement parsed with Rust semver requirement semantics,
122+ /// e.g. `">=0.6.0"` or `">=0.6.0, <0.7.0"`.
123+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
124+ pub os_version : Option < String > ,
125+ /// Allowed attestation platforms. Omitted means any supported platform;
126+ /// an explicit empty list means no platform is allowed.
127+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
128+ pub platforms : Option < Vec < String > > ,
129+ }
130+
131+ impl Requirements {
132+ pub fn is_empty ( & self ) -> bool {
133+ self . os_version . is_none ( ) && self . platforms . is_none ( )
134+ }
135+ }
136+
137+ fn deserialize_manifest_version < ' de , D > ( deserializer : D ) -> Result < String , D :: Error >
138+ where
139+ D : serde:: Deserializer < ' de > ,
140+ {
141+ struct ManifestVersionVisitor ;
142+
143+ impl < ' de > serde:: de:: Visitor < ' de > for ManifestVersionVisitor {
144+ type Value = String ;
145+
146+ fn expecting ( & self , formatter : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
147+ formatter. write_str ( "a string manifest version, or legacy numeric 1/2" )
148+ }
149+
150+ fn visit_str < E > ( self , value : & str ) -> Result < Self :: Value , E >
151+ where
152+ E : serde:: de:: Error ,
153+ {
154+ parse_manifest_version_string ( value) . map_err ( E :: custom)
155+ }
156+
157+ fn visit_string < E > ( self , value : String ) -> Result < Self :: Value , E >
158+ where
159+ E : serde:: de:: Error ,
160+ {
161+ self . visit_str ( & value)
162+ }
163+
164+ fn visit_u64 < E > ( self , value : u64 ) -> Result < Self :: Value , E >
165+ where
166+ E : serde:: de:: Error ,
167+ {
168+ match value {
169+ 1 | 2 => Ok ( value. to_string ( ) ) ,
170+ _ => Err ( E :: custom (
171+ "numeric manifest_version is only supported for legacy versions 1 and 2; use a string for newer versions" ,
172+ ) ) ,
173+ }
174+ }
175+
176+ fn visit_i64 < E > ( self , value : i64 ) -> Result < Self :: Value , E >
177+ where
178+ E : serde:: de:: Error ,
179+ {
180+ let value = u64:: try_from ( value)
181+ . map_err ( |_| E :: custom ( "manifest_version must be a positive integer" ) ) ?;
182+ self . visit_u64 ( value)
183+ }
184+ }
185+
186+ deserializer. deserialize_any ( ManifestVersionVisitor )
187+ }
188+
189+ fn parse_manifest_version_string ( value : & str ) -> Result < String , String > {
190+ let value = value. trim ( ) ;
191+ if value. is_empty ( ) {
192+ return Err ( "manifest_version must not be empty" . to_string ( ) ) ;
193+ }
194+ let parsed = value. parse :: < u32 > ( ) . map_err ( |_| {
195+ format ! ( "manifest_version must be a positive integer string, got {value:?}" )
196+ } ) ?;
197+ if parsed == 0 {
198+ return Err ( "manifest_version must be greater than 0" . to_string ( ) ) ;
199+ }
200+ Ok ( parsed. to_string ( ) )
108201}
109202
110203#[ derive( Deserialize , Serialize , Debug , Clone , Default ) ]
@@ -180,6 +273,10 @@ pub struct DockerConfig {
180273}
181274
182275impl AppCompose {
276+ pub fn manifest_version_u32 ( & self ) -> Option < u32 > {
277+ self . manifest_version . parse ( ) . ok ( )
278+ }
279+
183280 pub fn feature_enabled ( & self , feature : & str ) -> bool {
184281 self . features . contains ( & feature. to_string ( ) )
185282 }
@@ -208,6 +305,106 @@ impl AppCompose {
208305 }
209306}
210307
308+ #[ cfg( test) ]
309+ mod app_compose_tests {
310+ use super :: * ;
311+
312+ fn parse_compose ( manifest_version : serde_json:: Value ) -> serde_json:: Result < AppCompose > {
313+ serde_json:: from_value ( serde_json:: json!( {
314+ "manifest_version" : manifest_version,
315+ "name" : "test" ,
316+ "runner" : "docker-compose"
317+ } ) )
318+ }
319+
320+ #[ test]
321+ fn manifest_version_accepts_string_versions ( ) {
322+ let compose = parse_compose ( serde_json:: json!( "3" ) ) . unwrap ( ) ;
323+ assert_eq ! ( compose. manifest_version, "3" ) ;
324+ assert_eq ! ( compose. manifest_version_u32( ) , Some ( 3 ) ) ;
325+ }
326+
327+ #[ test]
328+ fn manifest_version_accepts_legacy_numeric_1_and_2 ( ) {
329+ assert_eq ! (
330+ parse_compose( serde_json:: json!( 1 ) )
331+ . unwrap( )
332+ . manifest_version,
333+ "1"
334+ ) ;
335+ assert_eq ! (
336+ parse_compose( serde_json:: json!( 2 ) )
337+ . unwrap( )
338+ . manifest_version,
339+ "2"
340+ ) ;
341+ }
342+
343+ #[ test]
344+ fn manifest_version_rejects_new_numeric_versions ( ) {
345+ let err = parse_compose ( serde_json:: json!( 3 ) ) . unwrap_err ( ) ;
346+ assert ! ( err. to_string( ) . contains( "legacy versions 1 and 2" ) ) ;
347+ }
348+
349+ #[ test]
350+ fn requirements_support_os_version_and_platforms ( ) {
351+ let compose: AppCompose = serde_json:: from_value ( serde_json:: json!( {
352+ "manifest_version" : "3" ,
353+ "name" : "test" ,
354+ "runner" : "docker-compose" ,
355+ "requirements" : {
356+ "os_version" : ">=0.6.1" ,
357+ "platforms" : [ "dstack-gcp-tdx" , "dstack-tdx" ]
358+ }
359+ } ) )
360+ . unwrap ( ) ;
361+ let requirements = compose. requirements . as_ref ( ) . unwrap ( ) ;
362+ assert_eq ! ( requirements. os_version. as_deref( ) , Some ( ">=0.6.1" ) ) ;
363+ assert_eq ! (
364+ requirements. platforms,
365+ Some ( vec![ "dstack-gcp-tdx" . to_string( ) , "dstack-tdx" . to_string( ) ] )
366+ ) ;
367+
368+ let err = serde_json:: from_value :: < AppCompose > ( serde_json:: json!( {
369+ "manifest_version" : "3" ,
370+ "name" : "test" ,
371+ "runner" : "docker-compose" ,
372+ "requirements" : {
373+ "os_version_policy" : ">=0.6.1"
374+ }
375+ } ) )
376+ . unwrap_err ( ) ;
377+ assert ! ( err. to_string( ) . contains( "unknown field" ) ) ;
378+ }
379+
380+ #[ test]
381+ fn requirements_distinguish_omitted_and_empty_platforms ( ) {
382+ let omitted: AppCompose = serde_json:: from_value ( serde_json:: json!( {
383+ "manifest_version" : "3" ,
384+ "name" : "test" ,
385+ "runner" : "docker-compose" ,
386+ "requirements" : { }
387+ } ) )
388+ . unwrap ( ) ;
389+ let requirements = omitted. requirements . as_ref ( ) . unwrap ( ) ;
390+ assert_eq ! ( requirements. platforms, None ) ;
391+ assert ! ( requirements. is_empty( ) ) ;
392+
393+ let explicit_empty: AppCompose = serde_json:: from_value ( serde_json:: json!( {
394+ "manifest_version" : "3" ,
395+ "name" : "test" ,
396+ "runner" : "docker-compose" ,
397+ "requirements" : {
398+ "platforms" : [ ]
399+ }
400+ } ) )
401+ . unwrap ( ) ;
402+ let requirements = explicit_empty. requirements . as_ref ( ) . unwrap ( ) ;
403+ assert_eq ! ( requirements. platforms, Some ( vec![ ] ) ) ;
404+ assert ! ( !requirements. is_empty( ) ) ;
405+ }
406+ }
407+
211408#[ derive( Deserialize , Serialize , Debug , Clone ) ]
212409pub struct SysConfig {
213410 #[ serde( default ) ]
0 commit comments