diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8c6cfc24e..96e592a39ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to version 2. These metrics also count requests that would be rejected in MMDS version 2 when MMDS version 1 is configured. They helps users assess readiness for migrating to MMDS version 2. +- [#5310](https://github.com/firecracker-microvm/firecracker/pull/5310): Added + an optional `imds_compat` field (default to false if not provided) to PUT + requests to `/mmds/config` to enforce MMDS to always respond plain text + contents in the IMDS format regardless of the `Accept` header in requests. + Users need to regenerate snapshots. ### Changed diff --git a/docs/device-api.md b/docs/device-api.md index e93ee575617..f60e856aed7 100644 --- a/docs/device-api.md +++ b/docs/device-api.md @@ -76,6 +76,7 @@ specification: | `MmdsConfig` | network_interfaces | O | O | O | O | **R** | O | O | | | version | O | O | O | O | **R** | O | O | | | ipv4_address | O | O | O | O | **R** | O | O | +| | imds_compat | O | O | O | O | O | O | O | | `NetworkInterface` | guest_mac | O | O | O | O | **R** | O | O | | | host_dev_name | O | O | O | O | **R** | O | O | | | iface_id | O | O | O | O | **R** | O | O | diff --git a/docs/mmds/mmds-user-guide.md b/docs/mmds/mmds-user-guide.md index a08ce707d95..115a7648407 100644 --- a/docs/mmds/mmds-user-guide.md +++ b/docs/mmds/mmds-user-guide.md @@ -308,7 +308,10 @@ The response format can be JSON or IMDS. The IMDS documentation can be found The output format can be selected by specifying the optional `Accept` header. Using `Accept: application/json` will format the output to JSON, while using `Accept: plain/text` or not specifying this optional header at all will format -the output to IMDS. +the output to IMDS. Setting `imds_compat` to `true` through PUT request to +`/mmds/config` enforces MMDS to always respond in IMDS format regardless of the +`Accept` header. This allows code written to work on EC2 IMDS to also work on +Firecracker MMDS. Retrieving MMDS resources in IMDS format, other than JSON `string` and `object` types, is not supported. diff --git a/src/firecracker/swagger/firecracker.yaml b/src/firecracker/swagger/firecracker.yaml index f5c91a8ddbe..c6b5ff29988 100644 --- a/src/firecracker/swagger/firecracker.yaml +++ b/src/firecracker/swagger/firecracker.yaml @@ -888,7 +888,7 @@ definitions: is_read_only: type: boolean description: - Is block read only. + Is block read only. This field is required for virtio-block config and should be omitted for vhost-user-block configuration. path_on_host: type: string @@ -1125,6 +1125,12 @@ definitions: format: "169.254.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-4]).([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])" default: "169.254.169.254" description: A valid IPv4 link-local address. + imds_comat: + type: boolean + description: + MMDS operates compatibly with EC2 IMDS (i.e. reponds "text/plain" + content regardless of Accept header in requests). + default: false MmdsContentsObject: type: object diff --git a/src/vmm/src/device_manager/persist.rs b/src/vmm/src/device_manager/persist.rs index f532d13fc83..fd24db52c3b 100644 --- a/src/vmm/src/device_manager/persist.rs +++ b/src/vmm/src/device_manager/persist.rs @@ -151,29 +151,10 @@ pub struct ConnectedLegacyState { pub device_info: MMIODeviceInfo, } -/// Holds the MMDS data store version. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum MmdsVersionState { - V1, - V2, -} - -impl From for MmdsVersion { - fn from(state: MmdsVersionState) -> Self { - match state { - MmdsVersionState::V1 => MmdsVersion::V1, - MmdsVersionState::V2 => MmdsVersion::V2, - } - } -} - -impl From for MmdsVersionState { - fn from(version: MmdsVersion) -> Self { - match version { - MmdsVersion::V1 => MmdsVersionState::V1, - MmdsVersion::V2 => MmdsVersionState::V2, - } - } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MmdsState { + version: MmdsVersion, + imds_compat: bool, } /// Holds the device states. @@ -191,7 +172,7 @@ pub struct DeviceStates { /// Balloon device state. pub balloon_device: Option, /// Mmds version. - pub mmds_version: Option, + pub mmds: Option, /// Entropy device state. pub entropy_device: Option, } @@ -345,11 +326,12 @@ impl<'a> Persist<'a> for MMIODeviceManager { } TYPE_NET => { let net = locked_device.as_any().downcast_ref::().unwrap(); - if let (Some(mmds_ns), None) = - (net.mmds_ns.as_ref(), states.mmds_version.as_ref()) - { - states.mmds_version = - Some(mmds_ns.mmds.lock().expect("Poisoned lock").version().into()); + if let (Some(mmds_ns), None) = (net.mmds_ns.as_ref(), states.mmds.as_ref()) { + let mmds_guard = mmds_ns.mmds.lock().expect("Poisoned lock"); + states.mmds = Some(MmdsState { + version: mmds_guard.version(), + imds_compat: mmds_guard.imds_compat(), + }); } states.net_devices.push(ConnectedNetState { @@ -556,20 +538,13 @@ impl<'a> Persist<'a> for MMIODeviceManager { )?; } - // If the snapshot has the mmds version persisted, initialise the data store with it. - if let Some(mmds_version) = &state.mmds_version { - constructor_args - .vm_resources - .set_mmds_version(mmds_version.clone().into(), constructor_args.instance_id)?; - } else if state - .net_devices - .iter() - .any(|dev| dev.device_state.mmds_ns.is_some()) - { - // If there's at least one network device having an mmds_ns, it means - // that we are restoring from a version that did not persist the `MmdsVersionState`. - // Init with the default. - constructor_args.vm_resources.mmds_or_default()?; + // Initialize MMDS if MMDS state is included. + if let Some(mmds) = &state.mmds { + constructor_args.vm_resources.set_mmds_basic_config( + mmds.version, + mmds.imds_compat, + constructor_args.instance_id, + )?; } for net_state in &state.net_devices { @@ -856,7 +831,8 @@ mod tests { "network_interfaces": [ "netif" ], - "ipv4_address": "169.254.169.254" + "ipv4_address": "169.254.169.254", + "imds_compat": false }}, "network-interfaces": [ {{ @@ -889,7 +865,7 @@ mod tests { .version(), MmdsVersion::V2 ); - assert_eq!(device_states.mmds_version.unwrap(), MmdsVersion::V2.into()); + assert_eq!(device_states.mmds.unwrap().version, MmdsVersion::V2); assert_eq!(restored_dev_manager, original_mmio_device_manager); assert_eq!( diff --git a/src/vmm/src/mmds/data_store.rs b/src/vmm/src/mmds/data_store.rs index dd5a1c3ad55..9b78be52ade 100644 --- a/src/vmm/src/mmds/data_store.rs +++ b/src/vmm/src/mmds/data_store.rs @@ -17,6 +17,7 @@ pub struct Mmds { token_authority: TokenAuthority, is_initialized: bool, data_store_limit: usize, + imds_compat: bool, } /// MMDS version. @@ -39,7 +40,7 @@ impl Display for MmdsVersion { } /// MMDS possible outputs. -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum OutputFormat { /// MMDS output format as Json Json, @@ -78,6 +79,7 @@ impl Mmds { token_authority: TokenAuthority::try_new()?, is_initialized: false, data_store_limit, + imds_compat: false, }) } @@ -102,6 +104,16 @@ impl Mmds { self.version } + /// Set the compatibility with EC2 IMDS. + pub fn set_imds_compat(&mut self, imds_compat: bool) { + self.imds_compat = imds_compat; + } + + /// Get the compatibility with EC2 IMDS. + pub fn imds_compat(&self) -> bool { + self.imds_compat + } + /// Sets the Additional Authenticated Data to be used for encryption and /// decryption of the session token. pub fn set_aad(&mut self, instance_id: &str) { @@ -244,9 +256,13 @@ impl Mmds { }; if let Some(json) = value { - match format { - OutputFormat::Json => Ok(json.to_string()), - OutputFormat::Imds => Mmds::format_imds(json), + match self.imds_compat { + // EC2 IMDS ignores the Accept header. + true => Mmds::format_imds(json), + false => match format { + OutputFormat::Json => Ok(json.to_string()), + OutputFormat::Imds => Mmds::format_imds(json), + }, } } else { Err(MmdsDatastoreError::NotFound) @@ -317,172 +333,158 @@ mod tests { #[test] fn test_get_value() { - let mut mmds = Mmds::default(); - let data = r#"{ - "name": { - "first": "John", - "second": "Doe" - }, - "age": 43, - "phones": [ - "+401234567", - "+441234567" - ], - "member": false, - "shares_percentage": 12.12, - "balance": -24, - "json_string": "{\n \"hello\": \"world\"\n}" - }"#; - let data_store: Value = serde_json::from_str(data).unwrap(); - mmds.put_data(data_store).unwrap(); - - // Test invalid path. - assert_eq!( - mmds.get_value("/invalid_path".to_string(), OutputFormat::Json) - .unwrap_err() - .to_string(), - MmdsDatastoreError::NotFound.to_string() - ); - assert_eq!( - mmds.get_value("/invalid_path".to_string(), OutputFormat::Imds) - .unwrap_err() - .to_string(), - MmdsDatastoreError::NotFound.to_string() - ); - - // Retrieve an object. - let mut expected_json = r#"{ - "first": "John", - "second": "Doe" - }"# - .to_string(); - expected_json.retain(|c| !c.is_whitespace()); - assert_eq!( - mmds.get_value("/name".to_string(), OutputFormat::Json) - .unwrap(), - expected_json - ); - let expected_imds = "first\nsecond"; - assert_eq!( - mmds.get_value("/name".to_string(), OutputFormat::Imds) - .unwrap(), - expected_imds - ); - - // Retrieve an integer. - assert_eq!( - mmds.get_value("/age".to_string(), OutputFormat::Json) - .unwrap(), - "43" - ); - assert_eq!( - mmds.get_value("/age".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); - - // Test path ends with /; Value is a dictionary. - // Retrieve an array. - let mut expected = r#"[ - "+401234567", - "+441234567" - ]"# - .to_string(); - expected.retain(|c| !c.is_whitespace()); - assert_eq!( - mmds.get_value("/phones/".to_string(), OutputFormat::Json) - .unwrap(), - expected - ); - assert_eq!( - mmds.get_value("/phones/".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); - - // Test path does NOT end with /; Value is a dictionary. - assert_eq!( - mmds.get_value("/phones".to_string(), OutputFormat::Json) - .unwrap(), - expected - ); - assert_eq!( - mmds.get_value("/phones".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); + for imds_compat in [false, true] { + let mut mmds = Mmds::default(); + mmds.set_imds_compat(imds_compat); + let data = r#"{ + "name": { + "first": "John", + "second": "Doe" + }, + "age": 43, + "phones": [ + "+401234567", + "+441234567" + ], + "member": false, + "shares_percentage": 12.12, + "balance": -24, + "json_string": "{\n \"hello\": \"world\"\n}" + }"#; + let data_store: Value = serde_json::from_str(data).unwrap(); + mmds.put_data(data_store).unwrap(); + + for format in [OutputFormat::Imds, OutputFormat::Json] { + // Test invalid path. + assert_eq!( + mmds.get_value("/invalid_path".to_string(), format) + .unwrap_err() + .to_string(), + MmdsDatastoreError::NotFound.to_string() + ); + + // Retrieve an object. + let expected = match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => "first\nsecond", + (false, OutputFormat::Json) => r#"{"first":"John","second":"Doe"}"#, + }; + assert_eq!( + mmds.get_value("/name".to_string(), format).unwrap(), + expected + ); + + // Retrieve an integer. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/age".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string() + ), + (false, OutputFormat::Json) => { + assert_eq!(mmds.get_value("/age".to_string(), format).unwrap(), "43") + } + }; + + // Test path ends with /; Value is a dictionary. + // Retrieve an array. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/phones/".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string() + ), + (false, OutputFormat::Json) => assert_eq!( + mmds.get_value("/phones/".to_string(), format).unwrap(), + r#"["+401234567","+441234567"]"# + ), + } - // Retrieve the first element of an array. - assert_eq!( - mmds.get_value("/phones/0/".to_string(), OutputFormat::Json) - .unwrap(), - "\"+401234567\"" - ); - assert_eq!( - mmds.get_value("/phones/0/".to_string(), OutputFormat::Imds) - .unwrap(), - "+401234567" - ); + // Test path does NOT end with /; Value is a dictionary. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/phones".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string() + ), + (false, OutputFormat::Json) => assert_eq!( + mmds.get_value("/phones".to_string(), format).unwrap(), + r#"["+401234567","+441234567"]"# + ), + } - // Retrieve a boolean. - assert_eq!( - mmds.get_value("/member".to_string(), OutputFormat::Json) - .unwrap(), - "false" - ); - assert_eq!( - mmds.get_value("/member".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); + // Retrieve the first element of an array. + let expected = match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => "+401234567", + (false, OutputFormat::Json) => "\"+401234567\"", + }; + assert_eq!( + mmds.get_value("/phones/0/".to_string(), format).unwrap(), + expected + ); + + // Retrieve a boolean. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/member".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string() + ), + (false, OutputFormat::Json) => assert_eq!( + mmds.get_value("/member".to_string(), format).unwrap(), + "false" + ), + } - // Retrieve a float. - assert_eq!( - mmds.get_value("/shares_percentage".to_string(), OutputFormat::Json) - .unwrap(), - "12.12" - ); - assert_eq!( - mmds.get_value("/shares_percentage".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); + // Retrieve a float. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/shares_percentage".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string() + ), + (false, OutputFormat::Json) => assert_eq!( + mmds.get_value("/shares_percentage".to_string(), format) + .unwrap(), + "12.12" + ), + } - // Retrieve a negative integer. - assert_eq!( - mmds.get_value("/balance".to_string(), OutputFormat::Json) - .unwrap(), - "-24" - ); - assert_eq!( - mmds.get_value("/balance".to_string(), OutputFormat::Imds) - .err() - .unwrap() - .to_string(), - MmdsDatastoreError::UnsupportedValueType.to_string() - ); + // Retrieve a negative integer. + match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => assert_eq!( + mmds.get_value("/balance".to_string(), format) + .err() + .unwrap() + .to_string(), + MmdsDatastoreError::UnsupportedValueType.to_string(), + ), + (false, OutputFormat::Json) => assert_eq!( + mmds.get_value("/balance".to_string(), format).unwrap(), + "-24" + ), + } - // Retrieve a string including escapes. - assert_eq!( - mmds.get_value("/json_string".to_string(), OutputFormat::Json) - .unwrap(), - r#""{\n \"hello\": \"world\"\n}""# - ); - assert_eq!( - mmds.get_value("/json_string".to_string(), OutputFormat::Imds) - .unwrap(), - "{\n \"hello\": \"world\"\n}" - ) + // Retrieve a string including escapes. + let expected = match (imds_compat, format) { + (false, OutputFormat::Imds) | (true, _) => "{\n \"hello\": \"world\"\n}", + (false, OutputFormat::Json) => r#""{\n \"hello\": \"world\"\n}""#, + }; + assert_eq!( + mmds.get_value("/json_string".to_string(), format).unwrap(), + expected + ); + } + } } #[test] diff --git a/src/vmm/src/persist.rs b/src/vmm/src/persist.rs index cc2f9a02bb9..1ff158d9973 100644 --- a/src/vmm/src/persist.rs +++ b/src/vmm/src/persist.rs @@ -148,7 +148,7 @@ pub enum CreateSnapshotError { } /// Snapshot version -pub const SNAPSHOT_VERSION: Version = Version::new(7, 0, 0); +pub const SNAPSHOT_VERSION: Version = Version::new(8, 0, 0); /// Creates a Microvm snapshot. pub fn create_snapshot( diff --git a/src/vmm/src/resources.rs b/src/vmm/src/resources.rs index 29c4258b8a7..00199fd1fe2 100644 --- a/src/vmm/src/resources.rs +++ b/src/vmm/src/resources.rs @@ -290,10 +290,12 @@ impl VmResources { .collect(); if !net_devs_with_mmds.is_empty() { + let mmds_guard = mmds.lock().expect("Poisoned lock"); let mut inner_mmds_config = MmdsConfig { - version: mmds.lock().expect("Poisoned lock").version(), + version: mmds_guard.version(), network_interfaces: vec![], ipv4_address: None, + imds_compat: mmds_guard.imds_compat(), }; for net_dev in net_devs_with_mmds { @@ -383,19 +385,21 @@ impl VmResources { instance_id: &str, ) -> Result<(), MmdsConfigError> { self.set_mmds_network_stack_config(&config)?; - self.set_mmds_version(config.version, instance_id)?; + self.set_mmds_basic_config(config.version, config.imds_compat, instance_id)?; Ok(()) } - /// Updates MMDS version. - pub fn set_mmds_version( + /// Updates MMDS-related config other than MMDS network stack. + pub fn set_mmds_basic_config( &mut self, version: MmdsVersion, + imds_compat: bool, instance_id: &str, ) -> Result<(), MmdsConfigError> { let mut mmds_guard = self.locked_mmds_or_default()?; mmds_guard.set_version(version); + mmds_guard.set_imds_compat(imds_compat); mmds_guard.set_aad(instance_id); Ok(()) diff --git a/src/vmm/src/rpc_interface.rs b/src/vmm/src/rpc_interface.rs index c567808f4de..e015152470e 100644 --- a/src/vmm/src/rpc_interface.rs +++ b/src/vmm/src/rpc_interface.rs @@ -1243,6 +1243,7 @@ mod tests { ipv4_address: None, version: MmdsVersion::default(), network_interfaces: Vec::new(), + imds_compat: false, }, ))); check_unsupported(runtime_request(VmmAction::UpdateMachineConfiguration( diff --git a/src/vmm/src/vmm_config/mmds.rs b/src/vmm/src/vmm_config/mmds.rs index a9205e51647..4c9cee2b7fd 100644 --- a/src/vmm/src/vmm_config/mmds.rs +++ b/src/vmm/src/vmm_config/mmds.rs @@ -18,6 +18,9 @@ pub struct MmdsConfig { pub network_interfaces: Vec, /// MMDS IPv4 configured address. pub ipv4_address: Option, + /// Compatibility with EC2 IMDS. + #[serde(default)] + pub imds_compat: bool, } impl MmdsConfig { diff --git a/tests/framework/utils.py b/tests/framework/utils.py index a979bb41500..11b93c9a4f2 100644 --- a/tests/framework/utils.py +++ b/tests/framework/utils.py @@ -435,7 +435,9 @@ def generate_mmds_get_request( return cmd -def configure_mmds(test_microvm, iface_ids, version=None, ipv4_address=None): +def configure_mmds( + test_microvm, iface_ids, version=None, ipv4_address=None, imds_compat=False +): """Configure mmds service.""" mmds_config = {"network_interfaces": iface_ids} @@ -445,6 +447,9 @@ def configure_mmds(test_microvm, iface_ids, version=None, ipv4_address=None): if ipv4_address: mmds_config["ipv4_address"] = ipv4_address + if imds_compat is not None: + mmds_config["imds_compat"] = imds_compat + response = test_microvm.api.mmds_config.put(**mmds_config) return response diff --git a/tests/framework/vm_config_with_mmdsv2.json b/tests/framework/vm_config_with_mmdsv2.json index 2bc70d1ef3f..b5855b9faa4 100644 --- a/tests/framework/vm_config_with_mmdsv2.json +++ b/tests/framework/vm_config_with_mmdsv2.json @@ -39,7 +39,8 @@ "mmds-config": { "network_interfaces": ["1"], "ipv4_address": "169.254.169.250", - "version": "V2" + "version": "V2", + "imds_compat": true }, "entropy": null } diff --git a/tests/integration_tests/functional/test_api.py b/tests/integration_tests/functional/test_api.py index 24283228f9a..e241d4ef1c7 100644 --- a/tests/integration_tests/functional/test_api.py +++ b/tests/integration_tests/functional/test_api.py @@ -1139,11 +1139,12 @@ def test_get_full_config_after_restoring_snapshot(microvm_factory, uvm_nano): "boot_args": None, } - # no ipv4 specified during PUT /mmds/config so we expect the default + # no ipv4_address or imds_compat specified during PUT /mmds/config so we expect the default expected_cfg["mmds-config"] = { "version": "V1", "ipv4_address": "169.254.169.254", "network_interfaces": [net_iface.dev_name], + "imds_compat": False, } # We should expect a null entropy device @@ -1235,6 +1236,7 @@ def test_get_full_config(uvm_plain): "version": "V2", "ipv4_address": "169.254.169.250", "network_interfaces": ["1"], + "imds_compat": True, } response = test_microvm.api.mmds_config.put(**mmds_config) @@ -1244,6 +1246,7 @@ def test_get_full_config(uvm_plain): "version": "V2", "ipv4_address": "169.254.169.250", "network_interfaces": ["1"], + "imds_compat": True, } # We should expect a null entropy device diff --git a/tests/integration_tests/functional/test_cmd_line_start.py b/tests/integration_tests/functional/test_cmd_line_start.py index dd0e1e0227e..d4c6c270b8d 100644 --- a/tests/integration_tests/functional/test_cmd_line_start.py +++ b/tests/integration_tests/functional/test_cmd_line_start.py @@ -104,8 +104,10 @@ def _get_optional_fields_from_file(vm_config_file): version = mmds_config.get("version", "V1") # Set to default if IPv4 is not specified . ipv4_address = mmds_config.get("ipv4_address", "169.254.169.254") + # Default to False if imds_compat is not specified. + imds_compat = mmds_config.get("imds_compat", False) - return version, ipv4_address + return version, ipv4_address, imds_compat @pytest.mark.parametrize("vm_config_file", ["framework/vm_config.json"]) @@ -436,7 +438,7 @@ def test_config_start_and_mmds_with_api(uvm_plain, vm_config_file): assert response.json() == data_store # Get MMDS version and IPv4 address configured from the file. - version, ipv4_address = _get_optional_fields_from_file(vm_config_file) + version, ipv4_address, imds_compat = _get_optional_fields_from_file(vm_config_file) cmd = "ip route add {} dev eth0".format(ipv4_address) _, stdout, stderr = test_microvm.ssh.run(cmd) @@ -446,12 +448,16 @@ def test_config_start_and_mmds_with_api(uvm_plain, vm_config_file): cmd = _build_cmd_to_fetch_metadata(test_microvm.ssh, version, ipv4_address) cmd += "/latest/meta-data/" _, stdout, _ = test_microvm.ssh.run(cmd) - assert json.loads(stdout) == data_store["latest"]["meta-data"] + if imds_compat: + assert stdout == "ami-id\nreservation-id" + else: + assert json.loads(stdout) == data_store["latest"]["meta-data"] # Validate MMDS configuration. response = test_microvm.api.vm_config.get() assert response.json()["mmds-config"] == { "network_interfaces": ["1"], + "imds_compat": imds_compat, "ipv4_address": ipv4_address, "version": version, } @@ -477,7 +483,7 @@ def test_with_config_and_metadata_no_api(uvm_plain, vm_config_file, metadata_fil test_microvm.spawn() # Get MMDS version and IPv4 address configured from the file. - version, ipv4_address = _get_optional_fields_from_file(vm_config_file) + version, ipv4_address, imds_compat = _get_optional_fields_from_file(vm_config_file) cmd = "ip route add {} dev eth0".format(ipv4_address) _, stdout, stderr = test_microvm.ssh.run(cmd) @@ -488,4 +494,8 @@ def test_with_config_and_metadata_no_api(uvm_plain, vm_config_file, metadata_fil _, stdout, _ = test_microvm.ssh.run(cmd) # Compare response against the expected MMDS contents. - assert json.loads(stdout) == json.load(Path(metadata_file).open(encoding="UTF-8")) + metadata = json.load(Path(metadata_file).open(encoding="UTF-8")) + if imds_compat: + assert stdout == "2016-09-02/\n2019-08-01/\nlatest/" + else: + assert json.loads(stdout) == metadata diff --git a/tests/integration_tests/functional/test_mmds.py b/tests/integration_tests/functional/test_mmds.py index 93d2654f9ee..1b548944dd8 100644 --- a/tests/integration_tests/functional/test_mmds.py +++ b/tests/integration_tests/functional/test_mmds.py @@ -11,7 +11,6 @@ import pytest -from framework.artifacts import working_version_as_artifact from framework.utils import ( configure_mmds, generate_mmds_get_request, @@ -30,97 +29,6 @@ MMDS_VERSIONS = ["V2", "V1"] -def _validate_mmds_snapshot( - basevm, - microvm_factory, - version, - fc_binary_path=None, - jailer_binary_path=None, -): - """Test MMDS behaviour across snap-restore.""" - ipv4_address = "169.254.169.250" - - # Configure MMDS version with custom IPv4 address. - configure_mmds( - basevm, - version=version, - iface_ids=["eth0"], - ipv4_address=ipv4_address, - ) - - expected_mmds_config = { - "version": version, - "ipv4_address": ipv4_address, - "network_interfaces": ["eth0"], - } - response = basevm.api.vm_config.get() - assert response.json()["mmds-config"] == expected_mmds_config - - data_store = {"latest": {"meta-data": {"ami-id": "ami-12345678"}}} - populate_data_store(basevm, data_store) - - basevm.start() - ssh_connection = basevm.ssh - run_guest_cmd(ssh_connection, f"ip route add {ipv4_address} dev eth0", "") - - # Both V1 and V2 support token generation. - token = generate_mmds_session_token(ssh_connection, ipv4_address, token_ttl=60) - - # Fetch metadata. - cmd = generate_mmds_get_request( - ipv4_address, - token=token, - ) - run_guest_cmd(ssh_connection, cmd, data_store, use_json=True) - - # Create snapshot. - snapshot = basevm.snapshot_full() - - # Resume microVM and ensure session token is still valid on the base. - response = basevm.resume() - - # Fetch metadata again using the same token. - run_guest_cmd(ssh_connection, cmd, data_store, use_json=True) - - # Kill base microVM. - basevm.kill() - - # Load microVM clone from snapshot. - kwargs = {} - if fc_binary_path: - kwargs["fc_binary_path"] = fc_binary_path - if jailer_binary_path: - kwargs["jailer_binary_path"] = jailer_binary_path - microvm = microvm_factory.build(**kwargs) - microvm.spawn() - microvm.restore_from_snapshot(snapshot, resume=True) - - ssh_connection = microvm.ssh - - # Check the reported MMDS config. - response = microvm.api.vm_config.get() - assert response.json()["mmds-config"] == expected_mmds_config - - # Since V1 should accept GET request even with invalid token, don't regenerate a token for V1. - if version == "V2": - # Attempting to reuse the token across a restore must fail in V2. - cmd = generate_mmds_get_request(ipv4_address, token=token) - run_guest_cmd(ssh_connection, cmd, "MMDS token not valid.") - - # Re-generate token. - token = generate_mmds_session_token(ssh_connection, ipv4_address, token_ttl=60) - - # Data store is empty after a restore. - cmd = generate_mmds_get_request(ipv4_address, token=token) - run_guest_cmd(ssh_connection, cmd, "null") - - # Now populate the store. - populate_data_store(microvm, data_store) - - # Fetch metadata. - run_guest_cmd(ssh_connection, cmd, data_store, use_json=True) - - @pytest.mark.parametrize("version", MMDS_VERSIONS) @pytest.mark.parametrize("imds_compat", [True, False]) def test_mmds_token(uvm_plain, version, imds_compat): @@ -272,73 +180,21 @@ def test_custom_ipv4(uvm_plain, version): @pytest.mark.parametrize("version", MMDS_VERSIONS) -def test_json_response(uvm_plain, version): +@pytest.mark.parametrize("imds_compat", [None, False, True]) +@pytest.mark.parametrize("app_json", [False, True]) +def test_mmds_response(uvm_plain, version, imds_compat, app_json): """ - Test the MMDS json response. + Test MMDS responses to various datastore requests. """ + expected_json = not imds_compat and app_json + test_microvm = uvm_plain test_microvm.spawn() - data_store = { - "latest": { - "meta-data": { - "ami-id": "ami-12345678", - "reservation-id": "r-fea54097", - "local-hostname": "ip-10-251-50-12.ec2.internal", - "public-hostname": "ec2-203-0-113-25.compute-1.amazonaws.com", - "dummy_res": ["res1", "res2"], - }, - "Limits": {"CPU": 512, "Memory": 512}, - "Usage": {"CPU": 12.12}, - } - } - - # Attach network device. test_microvm.add_net_iface() - - # Configure MMDS version. - configure_mmds(test_microvm, iface_ids=["eth0"], version=version) - - # Populate data store with contents. - populate_data_store(test_microvm, data_store) - - test_microvm.basic_config(vcpu_count=1) - test_microvm.start() - ssh_connection = test_microvm.ssh - - cmd = "ip route add {} dev eth0".format(DEFAULT_IPV4) - run_guest_cmd(ssh_connection, cmd, "") - - token = None - if version == "V2": - # Generate token. - token = generate_mmds_session_token(ssh_connection, DEFAULT_IPV4, token_ttl=60) - - pre = generate_mmds_get_request(DEFAULT_IPV4, token) - - cmd = pre + "latest/meta-data/" - run_guest_cmd(ssh_connection, cmd, data_store["latest"]["meta-data"], use_json=True) - - cmd = pre + "latest/meta-data/ami-id/" - run_guest_cmd(ssh_connection, cmd, "ami-12345678", use_json=True) - - cmd = pre + "latest/meta-data/dummy_res/0" - run_guest_cmd(ssh_connection, cmd, "res1", use_json=True) - - cmd = pre + "latest/Usage/CPU" - run_guest_cmd(ssh_connection, cmd, 12.12, use_json=True) - - cmd = pre + "latest/Limits/CPU" - run_guest_cmd(ssh_connection, cmd, 512, use_json=True) - - -@pytest.mark.parametrize("version", MMDS_VERSIONS) -def test_mmds_response(uvm_plain, version): - """ - Test MMDS responses to various datastore requests. - """ - test_microvm = uvm_plain - test_microvm.spawn() + configure_mmds( + test_microvm, iface_ids=["eth0"], version=version, imds_compat=imds_compat + ) data_store = { "latest": { @@ -357,13 +213,6 @@ def test_mmds_response(uvm_plain, version): "Usage": {"CPU": 12.12}, } } - - # Attach network device. - test_microvm.add_net_iface() - - # Configure MMDS version. - configure_mmds(test_microvm, iface_ids=["eth0"], version=version) - # Populate data store with contents. populate_data_store(test_microvm, data_store) test_microvm.basic_config(vcpu_count=1) @@ -373,47 +222,62 @@ def test_mmds_response(uvm_plain, version): cmd = "ip route add {} dev eth0".format(DEFAULT_IPV4) run_guest_cmd(ssh_connection, cmd, "") - token = None - if version == "V2": - # Generate token. - token = generate_mmds_session_token(ssh_connection, DEFAULT_IPV4, token_ttl=60) - - pre = generate_mmds_get_request(DEFAULT_IPV4, token=token, app_json=False) + token = generate_mmds_session_token(ssh_connection, DEFAULT_IPV4, token_ttl=60) + pre = generate_mmds_get_request(DEFAULT_IPV4, token, app_json) + # Query a branch node cmd = pre + "latest/meta-data/" - expected = ( - "ami-id\n" - "dummy_array\n" - "dummy_empty\n" - "dummy_obj/\n" - "local-hostname\n" - "public-hostname\n" - "reservation-id" - ) - run_guest_cmd(ssh_connection, cmd, expected) + if expected_json: + run_guest_cmd( + ssh_connection, cmd, data_store["latest"]["meta-data"], use_json=True + ) + else: + expected = ( + "ami-id\n" + "dummy_array\n" + "dummy_empty\n" + "dummy_obj/\n" + "local-hostname\n" + "public-hostname\n" + "reservation-id" + ) + run_guest_cmd(ssh_connection, cmd, expected, use_json=False) + # Query a leaf node with a string value cmd = pre + "latest/meta-data/ami-id/" - run_guest_cmd(ssh_connection, cmd, "ami-12345678") + run_guest_cmd(ssh_connection, cmd, "ami-12345678", use_json=expected_json) + # Query the first item of an array node cmd = pre + "latest/meta-data/dummy_array/0" - run_guest_cmd(ssh_connection, cmd, "arr_val1") + run_guest_cmd(ssh_connection, cmd, "arr_val1", use_json=expected_json) + # Query a leaf node with an empty string cmd = pre + "latest/meta-data/dummy_empty" - run_guest_cmd(ssh_connection, cmd, "") - - cmd = pre + "latest/Usage/CPU" - run_guest_cmd( - ssh_connection, - cmd, - "Cannot retrieve value. The value has" " an unsupported type.", - ) + run_guest_cmd(ssh_connection, cmd, "", use_json=expected_json) + # Query a leaf node with an integer value cmd = pre + "latest/Limits/CPU" - run_guest_cmd( - ssh_connection, - cmd, - "Cannot retrieve value. The value has" " an unsupported type.", - ) + if expected_json: + run_guest_cmd(ssh_connection, cmd, 512, use_json=True) + else: + run_guest_cmd( + ssh_connection, + cmd, + "Cannot retrieve value. The value has an unsupported type.", + use_json=False, + ) + + # Query a leaf node with a float value + cmd = pre + "latest/Usage/CPU" + if expected_json: + run_guest_cmd(ssh_connection, cmd, 12.12, use_json=True) + else: + run_guest_cmd( + ssh_connection, + cmd, + "Cannot retrieve value. The value has an unsupported type.", + use_json=False, + ) @pytest.mark.parametrize("version", MMDS_VERSIONS) @@ -651,23 +515,102 @@ def test_mmds_limit_scenario(uvm_plain, version): @pytest.mark.parametrize("version", MMDS_VERSIONS) -def test_mmds_snapshot(uvm_nano, microvm_factory, version): +@pytest.mark.parametrize("imds_compat", [None, False, True]) +def test_mmds_snapshot(uvm_nano, microvm_factory, version, imds_compat): """ Test MMDS behavior by restoring a snapshot on current FC versions. Ensures that the version is persisted or initialised with the default if the firecracker version does not support it. """ + basevm = uvm_nano + basevm.add_net_iface() + ipv4_address = "169.254.169.250" + + # Configure MMDS version with custom IPv4 address. + configure_mmds( + basevm, + version=version, + iface_ids=["eth0"], + ipv4_address=ipv4_address, + imds_compat=imds_compat, + ) + + expected_mmds_config = { + "version": version, + "ipv4_address": ipv4_address, + "network_interfaces": ["eth0"], + "imds_compat": False if imds_compat is None else imds_compat, + } + response = basevm.api.vm_config.get() + assert response.json()["mmds-config"] == expected_mmds_config + + data_store = {"latest": {"meta-data": {"ami-id": "ami-12345678"}}} + populate_data_store(basevm, data_store) + expected_response = "latest/" if imds_compat else data_store + + basevm.start() + ssh_connection = basevm.ssh + run_guest_cmd(ssh_connection, f"ip route add {ipv4_address} dev eth0", "") + + # Both V1 and V2 support token generation. + token = generate_mmds_session_token(ssh_connection, ipv4_address, token_ttl=60) - current_release = working_version_as_artifact() - uvm_nano.add_net_iface() - _validate_mmds_snapshot( - uvm_nano, - microvm_factory, - version, - fc_binary_path=current_release.path, - jailer_binary_path=current_release.jailer, + # Fetch metadata. + cmd = generate_mmds_get_request( + ipv4_address, + token=token, ) + run_guest_cmd(ssh_connection, cmd, expected_response, use_json=not imds_compat) + + # Create snapshot. + snapshot = basevm.snapshot_full() + + # Resume microVM and ensure session token is still valid on the base. + response = basevm.resume() + + # Fetch metadata again using the same token. + run_guest_cmd(ssh_connection, cmd, expected_response, use_json=not imds_compat) + + # Kill base microVM. + basevm.kill() + + # Load microVM clone from snapshot. + microvm = microvm_factory.build() + microvm.spawn() + microvm.restore_from_snapshot(snapshot, resume=True) + + ssh_connection = microvm.ssh + + # Check the reported MMDS config. + response = microvm.api.vm_config.get() + assert response.json()["mmds-config"] == expected_mmds_config + + if version == "V2": + # Attempting to reuse the token across a restore must fail in V2. + cmd = generate_mmds_get_request(ipv4_address, token=token) + run_guest_cmd(ssh_connection, cmd, "MMDS token not valid.") + + # Re-generate token. + token = generate_mmds_session_token(ssh_connection, ipv4_address, token_ttl=60) + + # Data store is empty after a restore. + cmd = generate_mmds_get_request(ipv4_address, token=token) + run_guest_cmd( + ssh_connection, + cmd, + ( + "Cannot retrieve value. The value has an unsupported type." + if imds_compat + else "null" + ), + ) + + # Now populate the store. + populate_data_store(microvm, data_store) + + # Fetch metadata. + run_guest_cmd(ssh_connection, cmd, expected_response, use_json=not imds_compat) def test_mmds_v2_negative(uvm_plain): @@ -806,7 +749,8 @@ def test_deprecated_mmds_config(uvm_plain): @pytest.mark.parametrize("version", MMDS_VERSIONS) -def test_aws_credential_provider(uvm_plain, version): +@pytest.mark.parametrize("imds_compat", [None, False, True]) +def test_aws_credential_provider(uvm_plain, version, imds_compat): """ Test AWS CLI credential provider """ @@ -815,7 +759,9 @@ def test_aws_credential_provider(uvm_plain, version): test_microvm.basic_config() test_microvm.add_net_iface() # V2 requires session tokens for GET requests - configure_mmds(test_microvm, iface_ids=["eth0"], version=version) + configure_mmds( + test_microvm, iface_ids=["eth0"], version=version, imds_compat=imds_compat + ) now = datetime.now(timezone.utc) credentials = { "Code": "Success",