summaryrefslogtreecommitdiff
path: root/ironic/api/controllers/v1/ramdisk.py
blob: b98eb7dc21179f2fe35fde899a9efcadb53da270 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# Copyright 2016 Red Hat, Inc.
#
#    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.

from http import client as http_client

from oslo_config import cfg
from oslo_log import log
from pecan import rest

from ironic import api
from ironic.api.controllers.v1 import node as node_ctl
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import states
from ironic.common import utils
from ironic import objects


CONF = cfg.CONF
LOG = log.getLogger(__name__)

_LOOKUP_RETURN_FIELDS = ['uuid', 'properties', 'instance_info',
                         'driver_internal_info']
AGENT_VALID_STATES = ['start', 'end', 'error']


def config(token):
    return {
        'metrics': {
            'backend': CONF.metrics.agent_backend,
            'prepend_host': CONF.metrics.agent_prepend_host,
            'prepend_uuid': CONF.metrics.agent_prepend_uuid,
            'prepend_host_reverse': CONF.metrics.agent_prepend_host_reverse,
            'global_prefix': CONF.metrics.agent_global_prefix
        },
        'metrics_statsd': {
            'statsd_host': CONF.metrics_statsd.agent_statsd_host,
            'statsd_port': CONF.metrics_statsd.agent_statsd_port
        },
        'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout,
        'agent_token': token,
        # Since this is for the Victoria release, we send this as an
        # explicit True statement for newer agents to lock the setting
        # and behavior into place.
        'agent_token_required': True,
    }


def convert_with_links(node):
    token = node.driver_internal_info.get('agent_secret_token')
    node = node_ctl.node_convert_with_links(node, _LOOKUP_RETURN_FIELDS)
    return {'node': node, 'config': config(token)}


class LookupController(rest.RestController):
    """Controller handling node lookup for a deploy ramdisk."""

    def lookup_allowed(self, node):
        if utils.fast_track_enabled(node):
            return (
                node.provision_state in states.FASTTRACK_LOOKUP_ALLOWED_STATES
            )
        else:
            return node.provision_state in states.LOOKUP_ALLOWED_STATES

    @method.expose()
    @args.validate(addresses=args.string_list, node_uuid=args.uuid)
    def get_all(self, addresses=None, node_uuid=None):
        """Look up a node by its MAC addresses and optionally UUID.

        If the "restrict_lookup" option is set to True (the default), limit
        the search to nodes in certain transient states (e.g. deploy wait).

        :param addresses: list of MAC addresses for a node.
        :param node_uuid: UUID of a node.
        :raises: NotFound if requested API version does not allow this
            endpoint.
        :raises: NotFound if suitable node was not found or node's provision
            state is not allowed for the lookup.
        :raises: IncompleteLookup if neither node UUID nor any valid MAC
            address was provided.
        """
        if not api_utils.allow_ramdisk_endpoints():
            raise exception.NotFound()

        api_utils.check_policy('baremetal:driver:ipa_lookup')

        # Validate the list of MAC addresses
        if addresses is None:
            addresses = []

        valid_addresses = []
        invalid_addresses = []
        for addr in addresses:
            try:
                mac = utils.validate_and_normalize_mac(addr)
                valid_addresses.append(mac)
            except exception.InvalidMAC:
                invalid_addresses.append(addr)

        if invalid_addresses:
            node_log = ('' if not node_uuid
                        else '(Node UUID: %s)' % node_uuid)
            LOG.warning('The following MAC addresses "%(addrs)s" are '
                        'invalid and will be ignored by the lookup '
                        'request %(node)s',
                        {'addrs': ', '.join(invalid_addresses),
                         'node': node_log})

        if not valid_addresses and not node_uuid:
            raise exception.IncompleteLookup()

        try:
            if node_uuid:
                node = objects.Node.get_by_uuid(
                    api.request.context, node_uuid)
            else:
                node = objects.Node.get_by_port_addresses(
                    api.request.context, valid_addresses)
        except exception.NotFound as e:
            # NOTE(dtantsur): we are reraising the same exception to make sure
            # we don't disclose the difference between nodes that are not found
            # at all and nodes in a wrong state by different error messages.
            LOG.error('No node has been found during lookup: %s', e)
            raise exception.NotFound()

        if CONF.api.restrict_lookup and not self.lookup_allowed(node):
            LOG.error('Lookup is not allowed for node %(node)s in the '
                      'provision state %(state)s',
                      {'node': node.uuid, 'state': node.provision_state})
            raise exception.NotFound()

        if api_utils.allow_agent_token():
            try:
                topic = api.request.rpcapi.get_topic_for(node)
            except exception.NoValidHost as e:
                e.code = http_client.BAD_REQUEST
                raise

            found_node = api.request.rpcapi.get_node_with_token(
                api.request.context, node.uuid, topic=topic)
        else:
            found_node = node
        return convert_with_links(found_node)


class HeartbeatController(rest.RestController):
    """Controller handling heartbeats from deploy ramdisk."""

    @method.expose(status_code=http_client.ACCEPTED)
    @args.validate(node_ident=args.uuid_or_name, callback_url=args.string,
                   agent_version=args.string, agent_token=args.string,
                   agent_verify_ca=args.string, agent_status=args.string,
                   agent_status_message=args.string)
    def post(self, node_ident, callback_url, agent_version=None,
             agent_token=None, agent_verify_ca=None, agent_status=None,
             agent_status_message=None):
        """Process a heartbeat from the deploy ramdisk.

        :param node_ident: the UUID or logical name of a node.
        :param callback_url: the URL to reach back to the ramdisk.
        :param agent_version: The version of the agent that is heartbeating.
            ``None`` indicates that the agent that is heartbeating is a version
            before sending agent_version was introduced so agent v3.0.0 (the
            last release before sending agent_version was introduced) will be
            assumed.
        :param agent_token: randomly generated validation token.
        :param agent_verify_ca: TLS certificate to use to connect to the agent.
        :param agent_status: Current status of the heartbeating agent. Used by
            anaconda ramdisk to send status back to Ironic. The valid states
            are 'start', 'end', 'error'
        :param agent_status_message: Optional status message describing current
            agent_status
        :raises: NodeNotFound if node with provided UUID or name was not found.
        :raises: InvalidUuidOrName if node_ident is not valid name or UUID.
        :raises: NoValidHost if RPC topic for node could not be retrieved.
        :raises: NotFound if requested API version does not allow this
            endpoint.
        """
        if not api_utils.allow_ramdisk_endpoints():
            raise exception.NotFound()

        if agent_version and not api_utils.allow_agent_version_in_heartbeat():
            raise exception.InvalidParameterValue(
                _('Field "agent_version" not recognised'))

        if ((agent_status or agent_status_message)
                and not api_utils.allow_status_in_heartbeat()):
            raise exception.InvalidParameterValue(
                _('Fields "agent_status" and "agent_status_message" '
                  'not recognised.')
            )

        api_utils.check_policy('baremetal:node:ipa_heartbeat')

        if (agent_verify_ca is not None
                and not api_utils.allow_verify_ca_in_heartbeat()):
            raise exception.InvalidParameterValue(
                _('Field "agent_verify_ca" not recognised in this version'))

        rpc_node = api_utils.get_rpc_node_with_suffix(node_ident)
        dii = rpc_node['driver_internal_info']
        agent_url = dii.get('agent_url')
        # If we have an agent_url on file, and we get something different
        # we should fail because this is unexpected behavior of the agent.
        if agent_url is not None and agent_url != callback_url:
            LOG.error('Received heartbeat for node %(node)s with '
                      'callback URL %(url)s. This is not expected, '
                      'and the heartbeat will not be processed.',
                      {'node': rpc_node.uuid, 'url': callback_url})
            raise exception.Invalid(
                _('Detected change in ramdisk provided '
                  '"callback_url"'))
        # NOTE(TheJulia): If tokens are required, lets go ahead and fail the
        # heartbeat very early on.
        if agent_token is None:
            LOG.error('Agent heartbeat received for node %(node)s '
                      'without an agent token.', {'node': node_ident})
            raise exception.InvalidParameterValue(
                _('Agent token is required for heartbeat processing.'))

        if agent_status is not None and agent_status not in AGENT_VALID_STATES:
            valid_states = ','.join(AGENT_VALID_STATES)
            LOG.error('Agent heartbeat received for node %(node)s '
                      'has an invalid agent status: %(agent_status)s. '
                      'Valid states are %(valid_states)s ',
                      {'node': node_ident, 'agent_status': agent_status,
                       'valid_states': valid_states})
            msg = (_('Agent status is invalid. Valid states are %s.') %
                   valid_states)
            raise exception.InvalidParameterValue(msg)

        try:
            topic = api.request.rpcapi.get_topic_for(rpc_node)
        except exception.NoValidHost as e:
            e.code = http_client.BAD_REQUEST
            raise

        api.request.rpcapi.heartbeat(
            api.request.context, rpc_node.uuid, callback_url,
            agent_version, agent_token, agent_verify_ca, agent_status,
            agent_status_message, topic=topic)