From 653d4e4ef55bf5acb315339eb79018c0e432c210 Mon Sep 17 00:00:00 2001 From: Ilya Etingof Date: Mon, 23 Mar 2020 13:47:08 +0100 Subject: Add `network_data` field to ironic node object A new dictionary field `network_data` is added to the node object. This new field can be populated by the operator with node static network configuration. Ironic API now performs formal JSON document validation of node `network_data` field content against user-supplied JSON schema at driver validation step. As of this commit, the new `network_data` field is not actually used by ironic, otherwise it should be perfectly functional. In the following commits, network static configuration will be taken from this field and handed over to ironic ramdisk bootstrapping utilities. Change-Id: I868b3b56a17f59e5aa1494b2e0ebc9c4e34ef173 Story: 2006691 Task: 36991 --- ironic/api/controllers/v1/network-data-schema.json | 580 +++++++++++++++++++++ ironic/api/controllers/v1/node.py | 47 +- ironic/api/controllers/v1/utils.py | 1 + ironic/api/controllers/v1/versions.py | 4 +- ironic/common/release_mappings.py | 4 +- .../cf1a80fdb352_add_node_network_data_field.py | 30 ++ ironic/db/sqlalchemy/models.py | 1 + ironic/drivers/modules/deploy_utils.py | 1 - ironic/objects/node.py | 29 +- ironic/tests/json_samples/network_data.json | 113 ++++ ironic/tests/unit/api/controllers/v1/test_node.py | 55 ++ ironic/tests/unit/db/sqlalchemy/test_migrations.py | 7 + ironic/tests/unit/db/utils.py | 1 + ironic/tests/unit/objects/test_objects.py | 2 +- 14 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 ironic/api/controllers/v1/network-data-schema.json create mode 100644 ironic/db/sqlalchemy/alembic/versions/cf1a80fdb352_add_node_network_data_field.py create mode 100644 ironic/tests/json_samples/network_data.json (limited to 'ironic') diff --git a/ironic/api/controllers/v1/network-data-schema.json b/ironic/api/controllers/v1/network-data-schema.json new file mode 100644 index 000000000..7162daf34 --- /dev/null +++ b/ironic/api/controllers/v1/network-data-schema.json @@ -0,0 +1,580 @@ +{ + "$schema": "http://openstack.org/nova/network_data.json#", + "id": "http://openstack.org/nova/network_data.json", + "type": "object", + "title": "OpenStack Nova network metadata schema", + "description": "Schema of Nova instance network configuration information", + "required": [ + "links", + "networks", + "services" + ], + "properties": { + "links": { + "$id": "#/properties/links", + "type": "array", + "title": "L2 interfaces settings", + "items": { + "$id": "#/properties/links/items", + "oneOf": [ + { + "$ref": "#/definitions/l2_link" + }, + { + "$ref": "#/definitions/l2_bond" + }, + { + "$ref": "#/definitions/l2_vlan" + } + ] + } + }, + "networks": { + "$id": "#/properties/networks", + "type": "array", + "title": "L3 networks", + "items": { + "$id": "#/properties/networks/items", + "oneOf": [ + { + "$ref": "#/definitions/l3_ipv4_network" + }, + { + "$ref": "#/definitions/l3_ipv6_network" + } + ] + } + }, + "services": { + "$ref": "#/definitions/services" + } + }, + "definitions": { + "l2_address": { + "$id": "#/definitions/l2_address", + "type": "string", + "pattern": "(?i)^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$", + "title": "L2 interface address", + "examples": [ + "fa:16:3e:9c:bf:3d" + ] + }, + "l2_id": { + "$id": "#/definitions/l2_id", + "type": "string", + "title": "L2 interface ID", + "examples": [ + "eth0" + ] + }, + "l2_mtu": { + "$id": "#/definitions/l2_mtu", + "title": "L2 interface MTU", + "anyOf": [ + { + "type": "number", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "null" + } + ], + "examples": [ + 1500 + ] + }, + "l2_vif_id": { + "$id": "#/definitions/l2_vif_id", + "type": "string", + "title": "Virtual interface ID", + "examples": [ + "cd9f6d46-4a3a-43ab-a466-994af9db96fc" + ] + }, + "l2_link": { + "$id": "#/definitions/l2_link", + "type": "object", + "title": "L2 interface configuration settings", + "required": [ + "ethernet_mac_address", + "id", + "type" + ], + "properties": { + "id": { + "$ref": "#/definitions/l2_id" + }, + "ethernet_mac_address": { + "$ref": "#/definitions/l2_address" + }, + "mtu": { + "$ref": "#/definitions/l2_mtu" + }, + "type": { + "$id": "#/definitions/l2_link/properties/type", + "type": "string", + "enum": [ + "bridge", + "dvs", + "hw_veb", + "hyperv", + "ovs", + "tap", + "vhostuser", + "vif", + "phy" + ], + "title": "Interface type", + "examples": [ + "bridge" + ] + }, + "vif_id": { + "$ref": "#/definitions/l2_vif_id" + } + } + }, + "l2_bond": { + "$id": "#/definitions/l2_bond", + "type": "object", + "title": "L2 bonding interface configuration settings", + "required": [ + "ethernet_mac_address", + "id", + "type", + "bond_mode", + "bond_links" + ], + "properties": { + "id": { + "$ref": "#/definitions/l2_id" + }, + "ethernet_mac_address": { + "$ref": "#/definitions/l2_address" + }, + "mtu": { + "$ref": "#/definitions/l2_mtu" + }, + "type": { + "$id": "#/definitions/l2_bond/properties/type", + "type": "string", + "enum": [ + "bond" + ], + "title": "Interface type", + "examples": [ + "bond" + ] + }, + "vif_id": { + "$ref": "#/definitions/l2_vif_id" + }, + "bond_mode": { + "$id": "#/definitions/bond/properties/bond_mode", + "type": "string", + "title": "Port bonding type", + "enum": [ + "802.1ad", + "balance-rr", + "active-backup", + "balance-xor", + "broadcast", + "balance-tlb", + "balance-alb" + ], + "examples": [ + "802.1ad" + ] + }, + "bond_links": { + "$id": "#/definitions/bond/properties/bond_links", + "type": "array", + "title": "Port bonding links", + "items": { + "$id": "#/definitions/bond/properties/bond_links/items", + "type": "string" + } + } + } + }, + "l2_vlan": { + "$id": "#/definitions/l2_vlan", + "type": "object", + "title": "L2 VLAN interface configuration settings", + "required": [ + "vlan_mac_address", + "id", + "type", + "vlan_link", + "vlan_id" + ], + "properties": { + "id": { + "$ref": "#/definitions/l2_id" + }, + "vlan_mac_address": { + "$ref": "#/definitions/l2_address" + }, + "mtu": { + "$ref": "#/definitions/l2_mtu" + }, + "type": { + "$id": "#/definitions/l2_vlan/properties/type", + "type": "string", + "enum": [ + "vlan" + ], + "title": "VLAN interface type", + "examples": [ + "vlan" + ] + }, + "vif_id": { + "$ref": "#/definitions/l2_vif_id" + }, + "vlan_id": { + "$id": "#/definitions/l2_vlan/properties/vlan_id", + "type": "integer", + "title": "VLAN ID" + }, + "vlan_link": { + "$id": "#/definitions/l2_vlan/properties/vlan_link", + "type": "string", + "title": "VLAN link name" + } + } + }, + "l3_id": { + "$id": "#/definitions/l3_id", + "type": "string", + "title": "Network name", + "examples": [ + "network0" + ] + }, + "l3_link": { + "$id": "#/definitions/l3_link", + "type": "string", + "title": "L2 network link to use for L3 interface", + "examples": [ + "99e88329-f20d-4741-9593-25bf07847b16" + ] + }, + "l3_network_id": { + "$id": "#/definitions/l3_network_id", + "type": "string", + "title": "Network ID", + "examples": [ + "99e88329-f20d-4741-9593-25bf07847b16" + ] + }, + "l3_ipv4_type": { + "$id": "#/definitions/l3_ipv4_type", + "type": "string", + "enum": [ + "ipv4", + "ipv4_dhcp" + ], + "title": "L3 IPv4 network type", + "examples": [ + "ipv4_dhcp" + ] + }, + "l3_ipv6_type": { + "$id": "#/definitions/l3_ipv6_type", + "type": "string", + "enum": [ + "ipv6", + "ipv6_dhcp", + "ipv6_slaac" + ], + "title": "L3 IPv6 network type", + "examples": [ + "ipv6_dhcp" + ] + }, + "l3_ipv4_host": { + "$id": "#/definitions/l3_ipv4_host", + "type": "string", + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", + "title": "L3 IPv4 host address", + "examples": [ + "192.168.81.99" + ] + }, + "l3_ipv6_host": { + "$id": "#/definitions/l3_ipv6_host", + "type": "string", + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(/[0-9]{1,2})?$", + "title": "L3 IPv6 host address", + "examples": [ + "2001:db8:3:4::192.168.81.99" + ] + }, + "l3_ipv4_netmask": { + "$id": "#/definitions/l3_ipv4_netmask", + "type": "string", + "pattern": "^(254|252|248|240|224|192|128|0)\\.0\\.0\\.0|255\\.(254|252|248|240|224|192|128|0)\\.0\\.0|255\\.255\\.(254|252|248|240|224|192|128|0)\\.0|255\\.255\\.255\\.(254|252|248|240|224|192|128|0)$", + "title": "L3 IPv4 network mask", + "examples": [ + "255.255.252.0" + ] + }, + "l3_ipv6_netmask": { + "$id": "#/definitions/l3_ipv6_netmask", + "type": "string", + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7})|(::))$", + "title": "L3 IPv6 network mask", + "examples": [ + "ffff:ffff:ffff:ffff::" + ] + }, + "l3_ipv4_nw": { + "$id": "#/definitions/l3_ipv4_nw", + "type": "string", + "pattern": "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", + "title": "L3 IPv4 network address", + "examples": [ + "0.0.0.0" + ] + }, + "l3_ipv6_nw": { + "$id": "#/definitions/l3_ipv6_nw", + "type": "string", + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7})|(::))$", + "title": "L3 IPv6 network address", + "examples": [ + "8000::" + ] + }, + "l3_ipv4_gateway": { + "$id": "#/definitions/l3_ipv4_gateway", + "type": "string", + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", + "title": "L3 IPv4 gateway address", + "examples": [ + "192.168.200.1" + ] + }, + "l3_ipv6_gateway": { + "$id": "#/definitions/l3_ipv6_gateway", + "type": "string", + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$", + "title": "L3 IPv6 gateway address", + "examples": [ + "2001:db8:3:4::192.168.81.99" + ] + }, + "l3_ipv4_network_route": { + "$id": "#/definitions/l3_ipv4_network_route", + "type": "object", + "title": "L3 IPv4 routing configuration item", + "required": [ + "gateway", + "netmask", + "network" + ], + "properties": { + "network": { + "$ref": "#/definitions/l3_ipv4_nw" + }, + "netmask": { + "$ref": "#/definitions/l3_ipv4_netmask" + }, + "gateway": { + "$ref": "#/definitions/l3_ipv4_gateway" + }, + "services": { + "$ref": "#/definitions/ipv4_services" + } + } + }, + "l3_ipv6_network_route": { + "$id": "#/definitions/l3_ipv6_network_route", + "type": "object", + "title": "L3 IPv6 routing configuration item", + "required": [ + "gateway", + "netmask", + "network" + ], + "properties": { + "network": { + "$ref": "#/definitions/l3_ipv6_nw" + }, + "netmask": { + "$ref": "#/definitions/l3_ipv6_netmask" + }, + "gateway": { + "$ref": "#/definitions/l3_ipv6_gateway" + }, + "services": { + "$ref": "#/definitions/ipv6_services" + } + } + }, + "l3_ipv4_network": { + "$id": "#/definitions/l3_ipv4_network", + "type": "object", + "title": "L3 IPv4 network configuration", + "required": [ + "id", + "link", + "network_id", + "type" + ], + "properties": { + "id": { + "$ref": "#/definitions/l3_id" + }, + "link": { + "$ref": "#/definitions/l3_link" + }, + "network_id": { + "$ref": "#/definitions/l3_network_id" + }, + "type": { + "$ref": "#/definitions/l3_ipv4_type" + }, + "ip_address": { + "$ref": "#/definitions/l3_ipv4_host" + }, + "netmask": { + "$ref": "#/definitions/l3_ipv4_netmask" + }, + "routes": { + "$id": "#/definitions/l3_ipv4_network/routes", + "type": "array", + "title": "L3 IPv4 network routes", + "items": { + "$ref": "#/definitions/l3_ipv4_network_route" + } + } + } + }, + "l3_ipv6_network": { + "$id": "#/definitions/l3_ipv6_network", + "type": "object", + "title": "L3 IPv6 network configuration", + "required": [ + "id", + "link", + "network_id", + "type" + ], + "properties": { + "id": { + "$ref": "#/definitions/l3_id" + }, + "link": { + "$ref": "#/definitions/l3_link" + }, + "network_id": { + "$ref": "#/definitions/l3_network_id" + }, + "type": { + "$ref": "#/definitions/l3_ipv6_type" + }, + "ip_address": { + "$ref": "#/definitions/l3_ipv6_host" + }, + "netmask": { + "$ref": "#/definitions/l3_ipv6_netmask" + }, + "routes": { + "$id": "#/definitions/properties/l3_ipv6_network/routes", + "type": "array", + "title": "L3 IPv6 network routes", + "items": { + "$ref": "#/definitions/l3_ipv6_network_route" + } + } + } + }, + "ipv4_service": { + "$id": "#/definitions/ipv4_service", + "type": "object", + "title": "Service on a IPv4 network", + "required": [ + "address", + "type" + ], + "properties": { + "address": { + "$ref": "#/definitions/l3_ipv4_host" + }, + "type": { + "$id": "#/definitions/ipv4_service/properties/type", + "type": "string", + "enum": [ + "dns" + ], + "title": "Service type", + "examples": [ + "dns" + ] + } + } + }, + "ipv6_service": { + "$id": "#/definitions/ipv6_service", + "type": "object", + "title": "Service on a IPv6 network", + "required": [ + "address", + "type" + ], + "properties": { + "address": { + "$ref": "#/definitions/l3_ipv6_host" + }, + "type": { + "$id": "#/definitions/ipv4_service/properties/type", + "type": "string", + "enum": [ + "dns" + ], + "title": "Service type", + "examples": [ + "dns" + ] + } + } + }, + "ipv4_services": { + "$id": "#/definitions/ipv4_services", + "type": "array", + "title": "Network services on IPv4 network", + "items": { + "$id": "#/definitions/ipv4_services/items", + "$ref": "#/definitions/ipv4_service" + } + }, + "ipv6_services": { + "$id": "#/definitions/ipv6_services", + "type": "array", + "title": "Network services on IPv6 network", + "items": { + "$id": "#/definitions/ipv6_services/items", + "$ref": "#/definitions/ipv6_service" + } + }, + "services": { + "$id": "#/definitions/services", + "type": "array", + "title": "Network services", + "items": { + "$id": "#/definitions/services/items", + "anyOf": [ + { + "$ref": "#/definitions/ipv4_service" + }, + { + "$ref": "#/definitions/ipv6_service" + } + ] + } + } + } +} diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index f0745ce2f..f0f691969 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -15,9 +15,12 @@ import datetime from http import client as http_client +import json +import os from ironic_lib import metrics_utils import jsonschema +from jsonschema import exceptions as json_schema_exc from oslo_log import log from oslo_utils import strutils from oslo_utils import uuidutils @@ -115,6 +118,10 @@ ALLOWED_TARGET_POWER_STATES = (ir_states.POWER_ON, _NODE_DESCRIPTION_MAX_LENGTH = 4096 +NETWORK_DATA_SCHEMA = os.path.join( + os.path.dirname(__file__), 'network-data-schema.json') + + def get_nodes_controller_reserved_names(): global _NODES_CONTROLLER_RESERVED_WORDS if _NODES_CONTROLLER_RESERVED_WORDS is None: @@ -179,6 +186,28 @@ def update_state_in_older_versions(obj): obj.provision_state = ir_states.INSPECTING +def validate_network_data(network_data): + """Validates node network_data field. + + This method validates network data configuration against JSON + schema. + + :param network_data: a network_data field to validate + :raises: Invalid if network data is not schema-compliant + """ + with open(NETWORK_DATA_SCHEMA, 'rb') as fl: + network_data_schema = json.load(fl) + + try: + jsonschema.validate(network_data, network_data_schema) + + except json_schema_exc.ValidationError as e: + # NOTE: Even though e.message is deprecated in general, it is + # said in jsonschema documentation to use this still. + msg = _("Invalid network_data: %s ") % e.message + raise exception.Invalid(msg) + + class BootDeviceController(rest.RestController): _custom_actions = { @@ -1265,6 +1294,9 @@ class Node(base.APIBase): retired_reason = atypes.wsattr(str) """Indicates the reason for a node's retirement.""" + network_data = atypes.wsattr({str: types.jsontype}) + """Static network configuration JSON ironic will hand over to the node.""" + # NOTE(tenbrae): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -1485,7 +1517,9 @@ class Node(base.APIBase): automated_clean=None, protected=False, protected_reason=None, owner=None, allocation_uuid='982ddb5b-bce5-4d23-8fb8-7f710f648cd5', - retired=False, retired_reason=None, lessee=None) + retired=False, retired_reason=None, lessee=None, + network_data={}) + # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1746,7 +1780,7 @@ class NodesController(rest.RestController): 'instance_info', 'driver_internal_info', 'clean_step', 'deploy_step', 'raid_config', 'target_raid_config', - 'traits'] + 'traits', 'network_data'] _subcontroller_map = { 'ports': port.PortsController, @@ -2231,6 +2265,9 @@ class NodesController(rest.RestController): msg = _("Allocation UUID cannot be specified, use allocations API") raise exception.Invalid(msg) + if node.network_data is not atypes.Unset: + validate_network_data(node.network_data) + # NOTE(tenbrae): get_topic_for checks if node.driver is in the hash # ring and raises NoValidHost if it is not. # We need to ensure that node has a UUID before it can @@ -2293,6 +2330,12 @@ class NodesController(rest.RestController): "characters") % _NODE_DESCRIPTION_MAX_LENGTH raise exception.Invalid(msg) + network_data_fields = api_utils.get_patch_values( + patch, '/network_data') + + for network_data in network_data_fields: + validate_network_data(network_data) + def _authorize_patch_and_get_node(self, node_ident, patch): # deal with attribute-specific policy rules policy_checks = [] diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 895947122..5c45036e3 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -492,6 +492,7 @@ VERSIONED_FIELDS = { 'retired': versions.MINOR_61_NODE_RETIRED, 'retired_reason': versions.MINOR_61_NODE_RETIRED, 'lessee': versions.MINOR_65_NODE_LESSEE, + 'network_data': versions.MINOR_66_NODE_NETWORK_DATA, } for field in V31_FIELDS: diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 26b5c7722..2e48dcd75 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -103,6 +103,7 @@ BASE_VERSION = 1 # v1.63: Add support for indicators # v1.64: Add network_type to port.local_link_connection # v1.65: Add lessee to the node object. +# v1.66: Add support for node network_data field. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -170,6 +171,7 @@ MINOR_62_AGENT_TOKEN = 62 MINOR_63_INDICATORS = 63 MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE = 64 MINOR_65_NODE_LESSEE = 65 +MINOR_66_NODE_NETWORK_DATA = 66 # When adding another version, update: # - MINOR_MAX_VERSION @@ -177,7 +179,7 @@ MINOR_65_NODE_LESSEE = 65 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_65_NODE_LESSEE +MINOR_MAX_VERSION = MINOR_66_NODE_NETWORK_DATA # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index cbd7b7f0f..8b47b3679 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -231,11 +231,11 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.65', + 'api': '1.66', 'rpc': '1.50', 'objects': { 'Allocation': ['1.1'], - 'Node': ['1.34'], + 'Node': ['1.35', '1.34'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'DeployTemplate': ['1.1'], diff --git a/ironic/db/sqlalchemy/alembic/versions/cf1a80fdb352_add_node_network_data_field.py b/ironic/db/sqlalchemy/alembic/versions/cf1a80fdb352_add_node_network_data_field.py new file mode 100644 index 000000000..cfd0e8edc --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/cf1a80fdb352_add_node_network_data_field.py @@ -0,0 +1,30 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Add nodes.network_data field + +Revision ID: cf1a80fdb352 +Revises: b2ad35726bb0 +Create Date: 2020-03-20 22:41:14.163881 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'cf1a80fdb352' +down_revision = 'b2ad35726bb0' + + +def upgrade(): + op.add_column('nodes', sa.Column('network_data', sa.Text(), + nullable=True)) diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index fa56c2611..68b366f21 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -197,6 +197,7 @@ class Node(Base): retired = Column(Boolean, nullable=True, default=False, server_default=false()) retired_reason = Column(Text, nullable=True) + network_data = Column(db_types.JsonEncodedDict) storage_interface = Column(String(255), nullable=True) power_interface = Column(String(255), nullable=True) vendor_interface = Column(String(255), nullable=True) diff --git a/ironic/drivers/modules/deploy_utils.py b/ironic/drivers/modules/deploy_utils.py index cb0af75c8..784a0c734 100644 --- a/ironic/drivers/modules/deploy_utils.py +++ b/ironic/drivers/modules/deploy_utils.py @@ -67,7 +67,6 @@ RESCUE_LIKE_STATES = (states.RESCUING, states.RESCUEWAIT, states.RESCUEFAIL, DISK_LAYOUT_PARAMS = ('root_gb', 'swap_mb', 'ephemeral_gb') - # All functions are called from deploy() directly or indirectly. # They are split for stub-out. diff --git a/ironic/objects/node.py b/ironic/objects/node.py index cdb02ebb9..d38f1ecd0 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -75,7 +75,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.32: Add description field # Version 1.33: Add retired and retired_reason fields # Version 1.34: Add lessee field - VERSION = '1.34' + # Version 1.35: Add network_data field + VERSION = '1.35' dbapi = db_api.get_instance() @@ -164,6 +165,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'description': object_fields.StringField(nullable=True), 'retired': objects.fields.BooleanField(nullable=True), 'retired_reason': object_fields.StringField(nullable=True), + 'network_data': object_fields.FlexibleDictField(nullable=True), } def as_dict(self, secure=False): @@ -549,6 +551,21 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): elif self.conductor_group: self.conductor_group = '' + def _convert_network_data_field(self, target_version, + remove_unavailable_fields=True): + # NOTE(etingof): The default value for `network_data` is an empty + # dict. Therefore we can't use generic version adjustment + # routine. + field_is_set = self.obj_attr_is_set('network_data') + if target_version >= (1, 35): + if not field_is_set: + self.network_data = {} + elif field_is_set: + if remove_unavailable_fields: + delattr(self, 'network_data') + elif self.network_data: + self.network_data = {} + # NOTE (yolanda): new method created to avoid repeating code in # _convert_to_version, and to avoid pep8 too complex error def _adjust_field_to_version(self, field_name, field_default_value, @@ -606,6 +623,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): should be set to False (or removed). Version 1.34: lessee was added. For versions prior to this, it should be set to None or removed. + Version 1.35: network_data was added. For versions prior to this, it + should be set to empty dict (or removed). :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are @@ -621,6 +640,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): ('automated_clean', 28), ('protected_reason', 29), ('owner', 30), ('allocation_id', 31), ('description', 32), ('retired_reason', 33), ('lessee', 34)] + for name, minor in fields: self._adjust_field_to_version(name, None, target_version, 1, minor, remove_unavailable_fields) @@ -637,14 +657,17 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): self._adjust_field_to_version('retired', False, target_version, 1, 33, remove_unavailable_fields) + self._convert_network_data_field(target_version, + remove_unavailable_fields) + @base.IronicObjectRegistry.register class NodePayload(notification.NotificationPayloadBase): """Base class used for all notification payloads about a Node object.""" # NOTE: This payload does not include the Node fields "chassis_id", # "driver_info", "driver_internal_info", "instance_info", "raid_config", - # "reservation", or "target_raid_config". These were excluded for reasons - # including: + # "network_data", "reservation", or "target_raid_config". These were + # excluded for reasons including: # - increased complexity needed for creating the payload # - sensitive information in the fields that shouldn't be exposed to # external services diff --git a/ironic/tests/json_samples/network_data.json b/ironic/tests/json_samples/network_data.json new file mode 100644 index 000000000..efce35ddd --- /dev/null +++ b/ironic/tests/json_samples/network_data.json @@ -0,0 +1,113 @@ +{ + "links": [ + { + "id": "interface2", + "type": "vif", + "ethernet_mac_address": "a0:36:9f:2c:e8:70", + "vif_id": "e1c90e9f-eafc-4e2d-8ec9-58b91cebb53d", + "mtu": 1500 + }, + { + "id": "interface0", + "type": "phy", + "ethernet_mac_address": "a0:36:9f:2c:e8:80", + "mtu": 9000 + }, + { + "id": "interface1", + "type": "phy", + "ethernet_mac_address": "a0:36:9f:2c:e8:81", + "mtu": 9000 + }, + { + "id": "bond0", + "type": "bond", + "bond_links": [ + "interface0", + "interface1" + ], + "ethernet_mac_address": "a0:36:9f:2c:e8:82", + "bond_mode": "802.1ad", + "bond_xmit_hash_policy": "layer3+4", + "bond_miimon": 100 + }, + { + "id": "vlan0", + "type": "vlan", + "vlan_link": "bond0", + "vlan_id": 101, + "vlan_mac_address": "a0:36:9f:2c:e8:80", + "vif_id": "e1c90e9f-eafc-4e2d-8ec9-58b91cebb53f" + } + ], + "networks": [ + { + "id": "private-ipv4", + "type": "ipv4", + "link": "interface0", + "ip_address": "10.184.0.244", + "netmask": "255.255.240.0", + "routes": [ + { + "network": "10.0.0.0", + "netmask": "255.0.0.0", + "gateway": "11.0.0.1" + }, + { + "network": "0.0.0.0", + "netmask": "0.0.0.0", + "gateway": "23.253.157.1" + } + ], + "network_id": "da5bb487-5193-4a65-a3df-4a0055a8c0d7" + }, + { + "id": "private-ipv4", + "type": "ipv6", + "link": "interface0", + "ip_address": "2001:cdba::3257:9652/24", + "routes": [ + { + "network": "::", + "netmask": "::", + "gateway": "fd00::1" + }, + { + "network": "::", + "netmask": "ffff:ffff:ffff::", + "gateway": "fd00::1:1" + } + ], + "network_id": "da5bb487-5193-4a65-a3df-4a0055a8c0d8" + }, + { + "id": "publicnet-ipv4", + "type": "ipv4", + "link": "vlan0", + "ip_address": "23.253.157.244", + "netmask": "255.255.255.0", + "dns_nameservers": [ + "69.20.0.164", + "69.20.0.196" + ], + "routes": [ + { + "network": "0.0.0.0", + "netmask": "0.0.0.0", + "gateway": "23.253.157.1" + } + ], + "network_id": "62611d6f-66cb-4270-8b1f-503ef0dd4736" + } + ], + "services": [ + { + "type": "dns", + "address": "8.8.8.8" + }, + { + "type": "dns", + "address": "8.8.4.4" + } + ] +} \ No newline at end of file diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 421175c10..3a0afbd9d 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -16,6 +16,7 @@ Tests for the API /nodes/ methods. import datetime from http import client as http_client import json +import os from urllib import parse as urlparse import fixtures @@ -42,12 +43,20 @@ from ironic.common import states from ironic.conductor import rpcapi from ironic import objects from ironic.objects import fields as obj_fields +from ironic import tests as tests_root from ironic.tests import base from ironic.tests.unit.api import base as test_api_base from ironic.tests.unit.api import utils as test_api_utils from ironic.tests.unit.objects import utils as obj_utils +with open( + os.path.join( + os.path.dirname(tests_root.__file__), + 'json_samples', 'network_data.json')) as fl: + NETWORK_DATA = json.load(fl) + + class TestNodeObject(base.TestCase): def test_node_init(self): @@ -138,6 +147,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('retired', data['nodes'][0]) self.assertNotIn('retired_reason', data['nodes'][0]) self.assertNotIn('lessee', data['nodes'][0]) + self.assertNotIn('network_data', data['nodes'][0]) def test_get_one(self): node = obj_utils.create_test_node(self.context, @@ -403,6 +413,19 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.65'}) self.assertEqual(data['lessee'], "some-lucky-project") + def test_node_network_data_hidden_in_lower_version(self): + self._test_node_field_hidden_in_lower_version('network_data', + '1.65', '1.66') + + def test_node_network_data(self): + node = obj_utils.create_test_node( + self.context, network_data=NETWORK_DATA, + provision_state='active', + uuid=uuidutils.generate_uuid()) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.66'}) + self.assertEqual(data['network_data'], NETWORK_DATA) + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -684,6 +707,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('allocation_uuid', data['nodes'][0]) self.assertIn('retired', data['nodes'][0]) self.assertIn('retired_reason', data['nodes'][0]) + self.assertIn('network_data', data['nodes'][0]) def test_detail_using_query(self): node = obj_utils.create_test_node(self.context, @@ -722,6 +746,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('chassis_id', data['nodes'][0]) self.assertIn('retired', data['nodes'][0]) self.assertIn('retired_reason', data['nodes'][0]) + self.assertIn('network_data', data['nodes'][0]) def test_detail_query_false(self): obj_utils.create_test_node(self.context) @@ -3654,6 +3679,36 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + def test_update_network_data(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state='active') + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.66'} + + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/network_data', + 'value': NETWORK_DATA, + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_network_data_old_api(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.62'} + + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/network_data', + 'value': NETWORK_DATA, + 'op': 'replace'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve', autospec=True) def test_patch_policy_update(self, mock_cmnpar): diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index f4b4708fc..1a2ef9cab 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -969,6 +969,13 @@ class MigrationCheckersMixin(object): col_names = [column.name for column in allocations.c] self.assertIn('owner', col_names) + def _check_cf1a80fdb352(self, engine, data): + nodes = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes.c] + self.assertIn('network_data', col_names) + self.assertIsInstance( + nodes.c.network_data.type, sqlalchemy.types.String) + def _pre_upgrade_cd2c80feb331(self, engine): data = { 'node_uuid': uuidutils.generate_uuid(), diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index a7b720f4b..96254889d 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -228,6 +228,7 @@ def get_test_node(**kw): 'retired': kw.get('retired', False), 'retired_reason': kw.get('retired_reason', None), 'lessee': kw.get('lessee', None), + 'network_data': kw.get('network_data'), } for iface in drivers_base.ALL_INTERFACES: diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 3e2b9a8d4..1320d96d3 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -676,7 +676,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.34-ae873e627cf30bf28fe9f98a807b6200', + 'Node': '1.35-aee8ecf5c4d0ed590eb484762aee7fca', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf', -- cgit v1.2.1