@@ -20,6 +20,7 @@ use dstack_types::{
2020 APP_COMPOSE , APP_KEYS , DECRYPTED_ENV , DECRYPTED_ENV_JSON , ENCRYPTED_ENV ,
2121 HOST_SHARED_DIR_NAME , HOST_SHARED_DISK_LABEL , INSTANCE_INFO , SYS_CONFIG , USER_CONFIG ,
2222 } ,
23+ version:: Version ,
2324 KeyProvider , KeyProviderInfo ,
2425} ;
2526use fs_err as fs;
@@ -372,6 +373,8 @@ const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
372373const WG_CONFIG_PATH : & str = "/etc/wireguard/dstack-wg0.conf" ;
373374/// Certificate validity period in seconds (10 days)
374375const CERT_VALIDITY_SECS : u64 = 10 * 24 * 3600 ;
376+ const MAX_SUPPORTED_MANIFEST_VERSION : u32 = 3 ;
377+ const OS_VERSION_POLICY_MANIFEST_VERSION : u32 = 3 ;
375378
376379#[ derive( Serialize , Deserialize , Clone ) ]
377380struct GatewayKeyStore {
@@ -759,6 +762,106 @@ fn emit_key_provider_info(provider_info: &KeyProviderInfo) -> Result<()> {
759762 Ok ( ( ) )
760763}
761764
765+ fn verify_manifest_version ( app_compose : & AppCompose ) -> Result < u32 > {
766+ let manifest_version = app_compose
767+ . manifest_version_u32 ( )
768+ . context ( "Invalid manifest_version" ) ?;
769+ if manifest_version > MAX_SUPPORTED_MANIFEST_VERSION {
770+ bail ! (
771+ "Unsupported manifest_version: {manifest_version}, max supported: {MAX_SUPPORTED_MANIFEST_VERSION}"
772+ ) ;
773+ }
774+ Ok ( manifest_version)
775+ }
776+
777+ fn verify_app_compose_policy ( app_compose : & AppCompose ) -> Result < ( ) > {
778+ let manifest_version = verify_manifest_version ( app_compose) ?;
779+ if app_compose. os_version_policy . min . is_some ( ) {
780+ let current_os_version =
781+ read_current_os_version ( ) . context ( "Failed to read current dstack OS version" ) ?;
782+ verify_os_version_policy ( app_compose, manifest_version, & current_os_version) ?;
783+ }
784+ Ok ( ( ) )
785+ }
786+
787+ fn verify_os_version_policy (
788+ app_compose : & AppCompose ,
789+ manifest_version : u32 ,
790+ current_os_version : & str ,
791+ ) -> Result < ( ) > {
792+ let Some ( min_os_version) = app_compose. os_version_policy . min . as_deref ( ) else {
793+ return Ok ( ( ) ) ;
794+ } ;
795+ if manifest_version < OS_VERSION_POLICY_MANIFEST_VERSION {
796+ bail ! (
797+ "os_version_policy requires manifest_version >= {OS_VERSION_POLICY_MANIFEST_VERSION}; \
798+ use string manifest_version \" {OS_VERSION_POLICY_MANIFEST_VERSION}\" so older guests fail closed"
799+ ) ;
800+ }
801+
802+ let min_os_version = Version :: parse ( min_os_version)
803+ . with_context ( || format ! ( "Invalid os_version_policy.min: {min_os_version}" ) ) ?;
804+ let current_os_version = Version :: parse ( current_os_version)
805+ . with_context ( || format ! ( "Invalid current dstack OS version: {current_os_version}" ) ) ?;
806+ if current_os_version < min_os_version {
807+ bail ! (
808+ "Unsupported dstack OS version: current {current_os_version}, required >= {min_os_version}"
809+ ) ;
810+ }
811+ info ! (
812+ "dstack OS version policy satisfied: current={}, min={}" ,
813+ current_os_version, min_os_version
814+ ) ;
815+ Ok ( ( ) )
816+ }
817+
818+ fn read_current_os_version ( ) -> Result < String > {
819+ const OS_RELEASE_PATHS : & [ & str ] = & [ "/etc/os-release" , "/usr/lib/os-release" ] ;
820+ for path in OS_RELEASE_PATHS {
821+ let content = match fs:: read_to_string ( path) {
822+ Ok ( content) => content,
823+ Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => continue ,
824+ Err ( err) => return Err ( err) . with_context ( || format ! ( "Failed to read {path}" ) ) ,
825+ } ;
826+ if let Some ( version) = os_release_value ( & content, "VERSION_ID" ) {
827+ return Ok ( version) ;
828+ }
829+ }
830+ bail ! ( "VERSION_ID not found in /etc/os-release or /usr/lib/os-release" )
831+ }
832+
833+ fn os_release_value ( content : & str , key : & str ) -> Option < String > {
834+ for line in content. lines ( ) {
835+ let line = line. trim ( ) ;
836+ if line. is_empty ( ) || line. starts_with ( '#' ) {
837+ continue ;
838+ }
839+ let Some ( ( k, v) ) = line. split_once ( '=' ) else {
840+ continue ;
841+ } ;
842+ if k == key {
843+ return Some ( unquote_os_release_value ( v) ) ;
844+ }
845+ }
846+ None
847+ }
848+
849+ fn unquote_os_release_value ( value : & str ) -> String {
850+ let value = value. trim ( ) ;
851+ let bytes = value. as_bytes ( ) ;
852+ if bytes. len ( ) >= 2
853+ && ( ( bytes[ 0 ] == b'"' && bytes[ bytes. len ( ) - 1 ] == b'"' )
854+ || ( bytes[ 0 ] == b'\'' && bytes[ bytes. len ( ) - 1 ] == b'\'' ) )
855+ {
856+ let inner = & value[ 1 ..value. len ( ) - 1 ] ;
857+ return inner
858+ . replace ( "\\ \" " , "\" " )
859+ . replace ( "\\ '" , "'" )
860+ . replace ( "\\ \\ " , "\\ " ) ;
861+ }
862+ value. to_string ( )
863+ }
864+
762865pub async fn cmd_sys_setup ( args : SetupArgs ) -> Result < ( ) > {
763866 let stage0 = Stage0 :: load ( & args) ?;
764867 let vmm = stage0. host_api ( ) ;
@@ -770,6 +873,8 @@ pub async fn cmd_sys_setup(args: SetupArgs) -> Result<()> {
770873}
771874
772875async fn do_sys_setup ( stage0 : Stage0 < ' _ > ) -> Result < ( ) > {
876+ verify_app_compose_policy ( & stage0. shared . app_compose )
877+ . context ( "Failed to verify app-compose policy" ) ?;
773878 if stage0. shared . app_compose . secure_time {
774879 info ! ( "Waiting for the system time to be synchronized" ) ;
775880 cmd ! {
@@ -1902,3 +2007,56 @@ fn test_validate_luks2_header() {
19022007 . to_string( )
19032008 . contains( "Invalid LUKS keyslot encryption" ) ) ;
19042009}
2010+
2011+ #[ cfg( test) ]
2012+ fn test_app_compose (
2013+ manifest_version : serde_json:: Value ,
2014+ min_os_version : Option < & str > ,
2015+ ) -> AppCompose {
2016+ let mut value = serde_json:: json!( {
2017+ "manifest_version" : manifest_version,
2018+ "name" : "test" ,
2019+ "runner" : "docker-compose"
2020+ } ) ;
2021+ if let Some ( min_os_version) = min_os_version {
2022+ value[ "os_version_policy" ] = serde_json:: json!( {
2023+ "min" : min_os_version
2024+ } ) ;
2025+ }
2026+ serde_json:: from_value ( value) . unwrap ( )
2027+ }
2028+
2029+ #[ test]
2030+ fn test_manifest_version_policy_rejects_above_guest_max ( ) {
2031+ let app_compose = test_app_compose ( serde_json:: json!( "4" ) , None ) ;
2032+ let err = verify_manifest_version ( & app_compose) . unwrap_err ( ) ;
2033+ assert ! ( err. to_string( ) . contains( "Unsupported manifest_version" ) ) ;
2034+ }
2035+
2036+ #[ test]
2037+ fn test_os_version_policy_requires_v3_manifest ( ) {
2038+ let app_compose = test_app_compose ( serde_json:: json!( "2" ) , Some ( "0.6.1" ) ) ;
2039+ let err = verify_os_version_policy ( & app_compose, 2 , "0.6.1" ) . unwrap_err ( ) ;
2040+ assert ! ( err. to_string( ) . contains( "requires manifest_version" ) ) ;
2041+ }
2042+
2043+ #[ test]
2044+ fn test_os_version_policy_rejects_too_old_os ( ) {
2045+ let app_compose = test_app_compose ( serde_json:: json!( "3" ) , Some ( "0.6.1" ) ) ;
2046+ let err = verify_os_version_policy ( & app_compose, 3 , "0.6.0" ) . unwrap_err ( ) ;
2047+ assert ! ( err. to_string( ) . contains( "Unsupported dstack OS version" ) ) ;
2048+ verify_os_version_policy ( & app_compose, 3 , "0.6.1" ) . unwrap ( ) ;
2049+ verify_os_version_policy ( & app_compose, 3 , "0.6.2" ) . unwrap ( ) ;
2050+ }
2051+
2052+ #[ test]
2053+ fn test_os_release_value_parses_quoted_version_id ( ) {
2054+ let content = r#"
2055+ NAME="DStack"
2056+ VERSION_ID="0.6.1"
2057+ "# ;
2058+ assert_eq ! (
2059+ os_release_value( content, "VERSION_ID" ) . as_deref( ) ,
2060+ Some ( "0.6.1" )
2061+ ) ;
2062+ }
0 commit comments