Skip to content

Commit 2eab988

Browse files
committed
guest: enforce app compose version policy
1 parent 4380a54 commit 2eab988

7 files changed

Lines changed: 371 additions & 33 deletions

File tree

dstack-types/src/lib.rs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ impl TdxAttestationVariant {
6767

6868
#[derive(Deserialize, Serialize, Debug, Clone)]
6969
pub 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,97 @@ pub struct AppCompose {
105106
/// optional port whitelist).
106107
#[serde(default)]
107108
pub port_policy: PortPolicy,
109+
/// OS-version constraints enforced by guests that understand this field.
110+
/// Use manifest_version "3" (string) when setting this field so older
111+
/// guests, which only accept numeric manifest versions, fail closed instead
112+
/// of silently ignoring the policy.
113+
#[serde(default, skip_serializing_if = "OsVersionPolicy::is_empty")]
114+
pub os_version_policy: OsVersionPolicy,
115+
}
116+
117+
/// Manifest-level OS version constraints.
118+
///
119+
/// Only `min` is supported by the current schema. Unknown fields are rejected so
120+
/// authors cannot accidentally rely on a policy that this guest does not
121+
/// enforce.
122+
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
123+
#[serde(deny_unknown_fields)]
124+
pub struct OsVersionPolicy {
125+
/// Minimum dstack OS version required by this app, e.g. "0.6.1".
126+
#[serde(default, skip_serializing_if = "Option::is_none")]
127+
pub min: Option<String>,
128+
}
129+
130+
impl OsVersionPolicy {
131+
pub fn is_empty(&self) -> bool {
132+
self.min.is_none()
133+
}
134+
}
135+
136+
fn deserialize_manifest_version<'de, D>(deserializer: D) -> Result<String, D::Error>
137+
where
138+
D: serde::Deserializer<'de>,
139+
{
140+
struct ManifestVersionVisitor;
141+
142+
impl<'de> serde::de::Visitor<'de> for ManifestVersionVisitor {
143+
type Value = String;
144+
145+
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146+
formatter.write_str("a string manifest version, or legacy numeric 1/2")
147+
}
148+
149+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
150+
where
151+
E: serde::de::Error,
152+
{
153+
parse_manifest_version_string(value).map_err(E::custom)
154+
}
155+
156+
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
157+
where
158+
E: serde::de::Error,
159+
{
160+
self.visit_str(&value)
161+
}
162+
163+
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
164+
where
165+
E: serde::de::Error,
166+
{
167+
match value {
168+
1 | 2 => Ok(value.to_string()),
169+
_ => Err(E::custom(
170+
"numeric manifest_version is only supported for legacy versions 1 and 2; use a string for newer versions",
171+
)),
172+
}
173+
}
174+
175+
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
176+
where
177+
E: serde::de::Error,
178+
{
179+
let value = u64::try_from(value)
180+
.map_err(|_| E::custom("manifest_version must be a positive integer"))?;
181+
self.visit_u64(value)
182+
}
183+
}
184+
185+
deserializer.deserialize_any(ManifestVersionVisitor)
186+
}
187+
188+
fn parse_manifest_version_string(value: &str) -> Result<String, String> {
189+
let value = value.trim();
190+
if value.is_empty() {
191+
return Err("manifest_version must not be empty".to_string());
192+
}
193+
let parsed = value.parse::<u32>().map_err(|_| {
194+
format!("manifest_version must be a positive integer string, got {value:?}")
195+
})?;
196+
if parsed == 0 {
197+
return Err("manifest_version must be greater than 0".to_string());
198+
}
199+
Ok(parsed.to_string())
108200
}
109201

110202
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
@@ -180,6 +272,10 @@ pub struct DockerConfig {
180272
}
181273

182274
impl AppCompose {
275+
pub fn manifest_version_u32(&self) -> Option<u32> {
276+
self.manifest_version.parse().ok()
277+
}
278+
183279
pub fn feature_enabled(&self, feature: &str) -> bool {
184280
self.features.contains(&feature.to_string())
185281
}
@@ -208,6 +304,73 @@ impl AppCompose {
208304
}
209305
}
210306

307+
#[cfg(test)]
308+
mod app_compose_tests {
309+
use super::*;
310+
311+
fn parse_compose(manifest_version: serde_json::Value) -> serde_json::Result<AppCompose> {
312+
serde_json::from_value(serde_json::json!({
313+
"manifest_version": manifest_version,
314+
"name": "test",
315+
"runner": "docker-compose"
316+
}))
317+
}
318+
319+
#[test]
320+
fn manifest_version_accepts_string_versions() {
321+
let compose = parse_compose(serde_json::json!("3")).unwrap();
322+
assert_eq!(compose.manifest_version, "3");
323+
assert_eq!(compose.manifest_version_u32(), Some(3));
324+
}
325+
326+
#[test]
327+
fn manifest_version_accepts_legacy_numeric_1_and_2() {
328+
assert_eq!(
329+
parse_compose(serde_json::json!(1))
330+
.unwrap()
331+
.manifest_version,
332+
"1"
333+
);
334+
assert_eq!(
335+
parse_compose(serde_json::json!(2))
336+
.unwrap()
337+
.manifest_version,
338+
"2"
339+
);
340+
}
341+
342+
#[test]
343+
fn manifest_version_rejects_new_numeric_versions() {
344+
let err = parse_compose(serde_json::json!(3)).unwrap_err();
345+
assert!(err.to_string().contains("legacy versions 1 and 2"));
346+
}
347+
348+
#[test]
349+
fn os_version_policy_only_supports_min() {
350+
let compose: AppCompose = serde_json::from_value(serde_json::json!({
351+
"manifest_version": "3",
352+
"name": "test",
353+
"runner": "docker-compose",
354+
"os_version_policy": {
355+
"min": "0.6.1"
356+
}
357+
}))
358+
.unwrap();
359+
assert_eq!(compose.os_version_policy.min.as_deref(), Some("0.6.1"));
360+
361+
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
362+
"manifest_version": "3",
363+
"name": "test",
364+
"runner": "docker-compose",
365+
"os_version_policy": {
366+
"deny": ["0.6.2"]
367+
}
368+
}))
369+
.unwrap_err();
370+
assert!(err.to_string().contains("unknown field"));
371+
}
372+
}
373+
211374
#[derive(Deserialize, Serialize, Debug, Clone)]
212375
pub struct SysConfig {
213376
#[serde(default)]

dstack-util/src/system_setup.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
};
2526
use fs_err as fs;
@@ -372,6 +373,8 @@ const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
372373
const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
373374
/// Certificate validity period in seconds (10 days)
374375
const 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)]
377380
struct 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+
762865
pub 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

772875
async 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+
}

guest-agent/src/rpc_service.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ mod tests {
700700
temp_attestation_file.flush().unwrap();
701701

702702
let dummy_appcompose = AppCompose {
703-
manifest_version: 0,
703+
manifest_version: "2".to_string(),
704704
name: String::new(),
705705
features: Vec::new(),
706706
runner: String::new(),
@@ -719,6 +719,7 @@ mod tests {
719719
storage_fs: None,
720720
swap_size: 0,
721721
port_policy: Default::default(),
722+
os_version_policy: Default::default(),
722723
};
723724

724725
let dummy_appcompose_wrapper = AppComposeWrapper {

0 commit comments

Comments
 (0)