diff --git a/api-ref/source/baremetal-api-v1-nodes.inc b/api-ref/source/baremetal-api-v1-nodes.inc index 49d5cf436b..526f6e0d29 100644 --- a/api-ref/source/baremetal-api-v1-nodes.inc +++ b/api-ref/source/baremetal-api-v1-nodes.inc @@ -442,6 +442,7 @@ Response - allocation_uuid: allocation_uuid - retired: retired - retired_reason: retired_reason + - network_data: network_data **Example detailed list of Nodes:** @@ -491,6 +492,9 @@ only the specified set. .. versionadded:: 1.65 Introduced the ``lessee`` field. +.. versionadded:: 1.66 + Introduced the ``network_data`` field. + Normal response codes: 200 Error codes: 400,403,404,406 diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 59567b4881..979417abdd 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1012,6 +1012,13 @@ name: in: body required: true type: string +network_data: + description: | + Static network configuration for the node to eventually pass to node's + operating system. + in: body + required: false + type: JSON network_interface: description: | Which Network Interface provider to use when plumbing the network diff --git a/api-ref/source/samples/node-create-response.json b/api-ref/source/samples/node-create-response.json index 214305d0e0..273d0709ab 100644 --- a/api-ref/source/samples/node-create-response.json +++ b/api-ref/source/samples/node-create-response.json @@ -38,6 +38,7 @@ "maintenance_reason": null, "management_interface": null, "name": "test_node_classic", + "network_data": {}, "network_interface": "flat", "owner": null, "portgroups": [ diff --git a/api-ref/source/samples/node-show-response.json b/api-ref/source/samples/node-show-response.json index 9cb1931b02..68b7eacb98 100644 --- a/api-ref/source/samples/node-show-response.json +++ b/api-ref/source/samples/node-show-response.json @@ -41,6 +41,7 @@ "maintenance_reason": null, "management_interface": null, "name": "test_node_classic", + "network_data": {}, "network_interface": "flat", "owner": null, "portgroups": [ diff --git a/api-ref/source/samples/node-update-driver-info-response.json b/api-ref/source/samples/node-update-driver-info-response.json index f7d2d88ee1..3655243ce6 100644 --- a/api-ref/source/samples/node-update-driver-info-response.json +++ b/api-ref/source/samples/node-update-driver-info-response.json @@ -42,6 +42,7 @@ "maintenance_reason": "Replacing the hard drive", "management_interface": null, "name": "test_node_classic", + "network_data": {}, "network_interface": "flat", "owner": null, "portgroups": [ diff --git a/api-ref/source/samples/nodes-list-details-response.json b/api-ref/source/samples/nodes-list-details-response.json index 90dc72a2a0..98c22aa9a4 100644 --- a/api-ref/source/samples/nodes-list-details-response.json +++ b/api-ref/source/samples/nodes-list-details-response.json @@ -43,6 +43,7 @@ "maintenance_reason": null, "management_interface": null, "name": "test_node_classic", + "network_data": {}, "network_interface": "flat", "owner": "john doe", "portgroups": [ @@ -148,6 +149,7 @@ "maintenance_reason": null, "management_interface": "ipmitool", "name": "test_node_dynamic", + "network_data": {}, "network_interface": "flat", "owner": "43e61ec9-8e42-4dcb-bc45-30d66aa93e5b", "portgroups": [ diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 731ce2d629..49c9cbad08 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,13 @@ REST API Version History ======================== +1.66 (Victoria, master) +----------------------- + +Add ``network_data`` field to the node object, that will be used by +stand-alone ironic to pass L3 network configuration information to +ramdisk. + 1.65 (Ussuri, 15.0) --------------------- 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 0000000000..7162daf343 --- /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 f0745ce2fb..f0f6919696 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 8959471220..5c45036e3c 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 26b5c77227..2e48dcd754 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 cbd7b7f0ff..8b47b36792 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 0000000000..cfd0e8edc4 --- /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 fa56c2611c..68b366f21d 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 cb0af75c8e..784a0c7348 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 cdb02ebb98..d38f1ecd09 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 0000000000..efce35ddde --- /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 421175c10e..3a0afbd9d4 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 f4b4708fc9..1a2ef9cab3 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 a7b720f4b0..96254889da 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 3e2b9a8d4e..1320d96d3f 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',