From 93012915a3445a8ac8a0b30b702df30febbbb728 Mon Sep 17 00:00:00 2001 From: Mark McClain Date: Wed, 8 Oct 2014 18:49:20 +0000 Subject: [PATCH] Add database relationship between router and ports Add an explicit schema relationship between a router and its ports. This change ensures referential integrity among the entities and prevents orphaned ports. Change-Id: I09e8a694cdff7f64a642a39b45cbd12422132806 Closes-Bug: #1378866 --- neutron/db/l3_db.py | 159 ++++++++++++------ neutron/db/l3_dvr_db.py | 119 ++++++++----- .../544673ac99ab_add_router_port_table.py | 65 +++++++ .../alembic_migrations/versions/HEAD | 2 +- 4 files changed, 249 insertions(+), 96 deletions(-) create mode 100644 neutron/db/migration/alembic_migrations/versions/544673ac99ab_add_router_port_table.py diff --git a/neutron/db/l3_db.py b/neutron/db/l3_db.py index 709c99cb0dd..0f8a56c0efb 100644 --- a/neutron/db/l3_db.py +++ b/neutron/db/l3_db.py @@ -47,6 +47,26 @@ API_TO_DB_COLUMN_MAP = {'port_id': 'fixed_port_id'} CORE_ROUTER_ATTRS = ('id', 'name', 'tenant_id', 'admin_state_up', 'status') +class RouterPort(model_base.BASEV2): + router_id = sa.Column( + sa.String(36), + sa.ForeignKey('routers.id', ondelete="CASCADE"), + primary_key=True) + port_id = sa.Column( + sa.String(36), + sa.ForeignKey('ports.id', ondelete="CASCADE"), + primary_key=True) + # The port_type attribute is redundant as the port table already specifies + # it in DEVICE_OWNER.However, this redundancy enables more efficient + # queries on router ports, and also prevents potential error-prone + # conditions which might originate from users altering the DEVICE_OWNER + # property of router ports. + port_type = sa.Column(sa.String(255)) + port = orm.relationship( + models_v2.Port, + backref=orm.backref('routerport', uselist=False, cascade="all,delete")) + + class Router(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant): """Represents a v2 neutron router.""" @@ -55,6 +75,10 @@ class Router(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant): admin_state_up = sa.Column(sa.Boolean) gw_port_id = sa.Column(sa.String(36), sa.ForeignKey('ports.id')) gw_port = orm.relationship(models_v2.Port, lazy='joined') + attached_ports = orm.relationship( + RouterPort, + backref='router', + lazy='dynamic') class FloatingIP(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant): @@ -76,6 +100,7 @@ class FloatingIP(model_base.BASEV2, models_v2.HasId, models_v2.HasTenant): # aysnchronous backend is unavailable when the floating IP is disassociated last_known_router_id = sa.Column(sa.String(36)) status = sa.Column(sa.String(16)) + router = orm.relationship(Router, backref='floating_ips') class L3_NAT_dbonly_mixin(l3.RouterPluginBase): @@ -259,7 +284,13 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): with context.session.begin(subtransactions=True): router.gw_port = self._core_plugin._get_port(context.elevated(), gw_port['id']) + router_port = RouterPort( + router_id=router.id, + port_id=gw_port['id'], + port_type=DEVICE_OWNER_ROUTER_GW + ) context.session.add(router) + context.session.add(router_port) def _validate_gw_info(self, context, gw_port, info): network_id = info['network_id'] if info else None @@ -281,11 +312,12 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): raise l3.RouterExternalGatewayInUseByFloatingIp( router_id=router_id, net_id=router.gw_port['network_id']) with context.session.begin(subtransactions=True): - gw_port_id = router.gw_port['id'] + gw_port = router.gw_port router.gw_port = None context.session.add(router) + context.session.expire(gw_port) self._core_plugin.delete_port( - admin_ctx, gw_port_id, l3_port_check=False) + admin_ctx, gw_port['id'], l3_port_check=False) def _create_gw_port(self, context, router_id, router, new_network): new_valid_gw_port_attachment = ( @@ -295,7 +327,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): subnets = self._core_plugin._get_subnets_by_network(context, new_network) for subnet in subnets: - self._check_for_dup_router_subnet(context, router_id, + self._check_for_dup_router_subnet(context, router, new_network, subnet['id'], subnet['cidr']) self._create_router_gw_port(context, router, new_network) @@ -317,11 +349,8 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): admin_ctx, filters={'router_id': [router_id]}): raise l3.RouterInUse(router_id=router_id) device_owner = self._get_device_owner(context, router) - device_filter = {'device_id': [router_id], - 'device_owner': [device_owner]} - port_count = self._core_plugin.get_ports_count( - admin_ctx, filters=device_filter) - if port_count: + if any(rp.port_type == device_owner + for rp in router.attached_ports.all()): raise l3.RouterInUse(router_id=router_id) return router @@ -335,18 +364,13 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): if vpnservice: vpnservice.check_router_in_use(context, id) + router_ports = router.attached_ports.all() + # Set the router's gw_port to None to avoid a constraint violation. + router.gw_port = None + for rp in router_ports: + self._core_plugin._delete_port(context.elevated(), rp.port.id) context.session.delete(router) - # Delete the gw port after the router has been removed to - # avoid a constraint violation. - device_filter = {'device_id': [id], - 'device_owner': [DEVICE_OWNER_ROUTER_GW]} - ports = self._core_plugin.get_ports(context.elevated(), - filters=device_filter) - if ports: - self._core_plugin._delete_port(context.elevated(), - ports[0]['id']) - def get_router(self, context, id, fields=None): router = self._get_router(context, id) return self._make_router_dict(router, fields) @@ -367,15 +391,13 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): return self._get_collection_count(context, Router, filters=filters) - def _check_for_dup_router_subnet(self, context, router_id, + def _check_for_dup_router_subnet(self, context, router, network_id, subnet_id, subnet_cidr): try: - rport_qry = context.session.query(models_v2.Port) - rports = rport_qry.filter_by(device_id=router_id) # It's possible these ports are on the same network, but # different subnets. new_ipnet = netaddr.IPNetwork(subnet_cidr) - for p in rports: + for p in (rp.port for rp in router.attached_ports): for ip in p['fixed_ips']: if ip['subnet_id'] == subnet_id: msg = (_("Router already has a port on subnet %s") @@ -415,7 +437,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): raise n_exc.BadRequest(resource='router', msg=msg) return port_id_specified, subnet_id_specified - def _add_interface_by_port(self, context, router_id, port_id, owner): + def _add_interface_by_port(self, context, router, port_id, owner): with context.session.begin(subtransactions=True): port = self._core_plugin._get_port(context, port_id) if port['device_id']: @@ -428,19 +450,19 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): raise n_exc.BadRequest(resource='router', msg=msg) subnet_id = fixed_ips[0]['subnet_id'] subnet = self._core_plugin._get_subnet(context, subnet_id) - self._check_for_dup_router_subnet(context, router_id, + self._check_for_dup_router_subnet(context, router, port['network_id'], subnet['id'], subnet['cidr']) - port.update({'device_id': router_id, 'device_owner': owner}) + port.update({'device_id': router.id, 'device_owner': owner}) return port - def _add_interface_by_subnet(self, context, router_id, subnet_id, owner): + def _add_interface_by_subnet(self, context, router, subnet_id, owner): subnet = self._core_plugin._get_subnet(context, subnet_id) if not subnet['gateway_ip']: msg = _('Subnet for router interface must have a gateway IP') raise n_exc.BadRequest(resource='router', msg=msg) - self._check_for_dup_router_subnet(context, router_id, + self._check_for_dup_router_subnet(context, router, subnet['network_id'], subnet_id, subnet['cidr']) @@ -453,7 +475,7 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): 'fixed_ips': [fixed_ip], 'mac_address': attributes.ATTR_NOT_SPECIFIED, 'admin_state_up': True, - 'device_id': router_id, + 'device_id': router.id, 'device_owner': owner, 'name': ''}}) @@ -468,18 +490,27 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): } def add_router_interface(self, context, router_id, interface_info): + router = self._get_router(context, router_id) add_by_port, add_by_sub = self._validate_interface_info(interface_info) device_owner = self._get_device_owner(context, router_id) if add_by_port: port = self._add_interface_by_port( - context, router_id, interface_info['port_id'], device_owner) + context, router, interface_info['port_id'], device_owner) elif add_by_sub: port = self._add_interface_by_subnet( - context, router_id, interface_info['subnet_id'], device_owner) + context, router, interface_info['subnet_id'], device_owner) + + with context.session.begin(subtransactions=True): + router_port = RouterPort( + port_id=port['id'], + router_id=router.id, + port_type=device_owner + ) + context.session.add(router_port) return self._make_router_interface_info( - router_id, port['tenant_id'], port['id'], + router.id, port['tenant_id'], port['id'], port['fixed_ips'][0]['subnet_id']) def _confirm_router_interface_not_in_use(self, context, router_id, @@ -494,9 +525,15 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): def _remove_interface_by_port(self, context, router_id, port_id, subnet_id, owner): - port_db = self._core_plugin._get_port(context, port_id) - if not (port_db['device_owner'] == owner and - port_db['device_id'] == router_id): + qry = context.session.query(RouterPort) + qry = qry.filter_by( + port_id=port_id, + router_id=router_id, + port_type=owner + ) + try: + port_db = qry.one().port + except exc.NoResultFound: raise l3.RouterInterfaceNotFound(router_id=router_id, port_id=port_id) port_subnet_id = port_db['fixed_ips'][0]['subnet_id'] @@ -517,11 +554,12 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): subnet = self._core_plugin._get_subnet(context, subnet_id) try: - rport_qry = context.session.query(models_v2.Port) - ports = rport_qry.filter_by( - device_id=router_id, - device_owner=owner, - network_id=subnet['network_id']) + rport_qry = context.session.query(models_v2.Port).join(RouterPort) + ports = rport_qry.filter( + RouterPort.router_id == router_id, + RouterPort.port_type == owner, + models_v2.Port.network_id == subnet['network_id'] + ) for p in ports: if p['fixed_ips'][0]['subnet_id'] == subnet_id: @@ -570,10 +608,12 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): return self._fields(res, fields) def _get_interface_ports_for_network(self, context, network_id): - router_intf_qry = context.session.query(models_v2.Port) - return router_intf_qry.filter_by( - network_id=network_id, - device_owner=DEVICE_OWNER_ROUTER_INTF) + router_intf_qry = context.session.query(RouterPort) + router_intf_qry = router_intf_qry.join(models_v2.Port) + return router_intf_qry.filter( + models_v2.Port.network_id == network_id, + RouterPort.port_type == DEVICE_OWNER_ROUTER_INTF + ) def _get_router_for_floatingip(self, context, internal_port, internal_subnet_id, @@ -588,16 +628,16 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): router_intf_ports = self._get_interface_ports_for_network( context, internal_port['network_id']) - for intf_p in router_intf_ports: - if intf_p['fixed_ips'][0]['subnet_id'] == internal_subnet_id: - router_id = intf_p['device_id'] - router_gw_qry = context.session.query(models_v2.Port) - has_gw_port = router_gw_qry.filter_by( - network_id=external_network_id, - device_id=router_id, - device_owner=DEVICE_OWNER_ROUTER_GW).count() - if has_gw_port: - return router_id + # This joins on port_id so is not a cross-join + routerport_qry = router_intf_ports.join(models_v2.IPAllocation) + routerport_qry = routerport_qry.filter( + models_v2.IPAllocation.subnet_id == internal_subnet_id + ) + + router_port = routerport_qry.first() + + if router_port and router_port.router.gw_port: + return router_port.router.id raise l3.ExternalGatewayForFloatingIPNotFound( subnet_id=internal_subnet_id, @@ -936,9 +976,16 @@ class L3_NAT_dbonly_mixin(l3.RouterPluginBase): device_owners = device_owners or [DEVICE_OWNER_ROUTER_INTF] if not router_ids: return [] - filters = {'device_id': router_ids, - 'device_owner': device_owners} - interfaces = self._core_plugin.get_ports(context, filters) + qry = context.session.query(RouterPort) + qry = qry.filter( + Router.id.in_(router_ids), + RouterPort.port_type.in_(device_owners) + ) + + # TODO(markmcclain): This is suboptimal but was left to reduce + # changeset size since it is late in cycle + ports = [rp.port.id for rp in qry] + interfaces = self._core_plugin.get_ports(context, {'id': ports}) if interfaces: self._populate_subnet_for_ports(context, interfaces) return interfaces diff --git a/neutron/db/l3_dvr_db.py b/neutron/db/l3_dvr_db.py index 4b605ffbf97..b6e826b8c1d 100644 --- a/neutron/db/l3_dvr_db.py +++ b/neutron/db/l3_dvr_db.py @@ -81,13 +81,11 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, self, context, router_id, router_db, data, gw_info): """Update the model to support the dvr case of a router.""" if not attributes.is_attr_set(gw_info) and data.get('distributed'): - admin_ctx = context.elevated() - filters = {'device_id': [router_id], - 'device_owner': [l3_const.DEVICE_OWNER_ROUTER_INTF]} - ports = self._core_plugin.get_ports(admin_ctx, filters=filters) - for p in ports: - port_db = self._core_plugin._get_port(admin_ctx, p['id']) - port_db.update({'device_owner': DEVICE_OWNER_DVR_INTERFACE}) + old_owner = l3_const.DEVICE_OWNER_ROUTER_INTF + new_owner = DEVICE_OWNER_DVR_INTERFACE + for rp in router_db.attached_ports.filter_by(port_type=old_owner): + rp.port_type = new_owner + rp.port.device_owner = new_owner def _update_router_db(self, context, router_id, data, gw_info): with context.session.begin(subtransactions=True): @@ -119,7 +117,7 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, router, new_network) if router.extra_attributes.distributed and router.gw_port: snat_p_list = self.create_snat_intf_ports_if_not_exists( - context.elevated(), router['id']) + context.elevated(), router) if not snat_p_list: LOG.debug("SNAT interface ports not created: %s", snat_p_list) @@ -134,12 +132,15 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, self)._get_device_owner(context, router) def _get_interface_ports_for_network(self, context, network_id): - router_intf_qry = (context.session.query(models_v2.Port). - filter_by(network_id=network_id)) - return (router_intf_qry. - filter(models_v2.Port.device_owner.in_( - [l3_const.DEVICE_OWNER_ROUTER_INTF, - DEVICE_OWNER_DVR_INTERFACE]))) + router_intf_qry = context.session.query(l3_db.RouterPort) + router_intf_qry = router_intf_qry.join(models_v2.Port) + + return router_intf_qry.filter( + models_v2.Port.network_id == network_id, + l3_db.RouterPort.port_type.in_( + [l3_const.DEVICE_OWNER_ROUTER_INTF, DEVICE_OWNER_DVR_INTERFACE] + ) + ) def _update_fip_assoc(self, context, fip, floatingip_db, external_port): previous_router_id = floatingip_db.router_id @@ -208,14 +209,22 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, if add_by_port: port = self._add_interface_by_port( - context, router_id, interface_info['port_id'], device_owner) + context, router, interface_info['port_id'], device_owner) elif add_by_sub: port = self._add_interface_by_subnet( - context, router_id, interface_info['subnet_id'], device_owner) + context, router, interface_info['subnet_id'], device_owner) + + with context.session.begin(subtransactions=True): + router_port = l3_db.RouterPort( + port_id=port['id'], + router_id=router.id, + port_type=device_owner + ) + context.session.add(router_port) if router.extra_attributes.distributed and router.gw_port: self.add_csnat_router_interface_port( - context.elevated(), router_id, port['network_id'], + context.elevated(), router, port['network_id'], port['fixed_ips'][0]['subnet_id']) router_interface_info = self._make_router_interface_info( @@ -257,9 +266,16 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, """Query router interfaces that relate to list of router_ids.""" if not router_ids: return [] - filters = {'device_id': router_ids, - 'device_owner': [DEVICE_OWNER_DVR_SNAT]} - interfaces = self._core_plugin.get_ports(context, filters) + qry = context.session.query(l3_db.RouterPort) + qry = qry.filter( + l3_db.RouterPort.router_id.in_(router_ids), + l3_db.RouterPort.port_type == DEVICE_OWNER_DVR_SNAT + ) + + # TODO(markmcclain): This is suboptimal but was left to reduce + # changeset size since it is late in cycle + ports = [rp.port.id for rp in qry] + interfaces = self._core_plugin.get_ports(context, {'id': ports}) LOG.debug("Return the SNAT ports: %s", interfaces) if interfaces: self._populate_subnet_for_ports(context, interfaces) @@ -447,12 +463,19 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, def get_snat_interface_ports_for_router(self, context, router_id): """Return all existing snat_router_interface ports.""" - filters = {'device_id': [router_id], - 'device_owner': [DEVICE_OWNER_DVR_SNAT]} - return self._core_plugin.get_ports(context, filters) + # TODO(markmcclain): This is suboptimal but was left to reduce + # changeset size since it is late in cycle + qry = context.session.query(l3_db.RouterPort) + qry = qry.filter_by( + router_id=router_id, + port_type=DEVICE_OWNER_DVR_SNAT + ) + + ports = [rp.port.id for rp in qry] + return self._core_plugin.get_ports(context, {'id': ports}) def add_csnat_router_interface_port( - self, context, router_id, network_id, subnet_id, do_pop=True): + self, context, router, network_id, subnet_id, do_pop=True): """Add SNAT interface to the specified router and subnet.""" snat_port = self._core_plugin.create_port( context, @@ -460,19 +483,27 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, 'network_id': network_id, 'mac_address': attributes.ATTR_NOT_SPECIFIED, 'fixed_ips': [{'subnet_id': subnet_id}], - 'device_id': router_id, + 'device_id': router.id, 'device_owner': DEVICE_OWNER_DVR_SNAT, 'admin_state_up': True, 'name': ''}}) if not snat_port: msg = _("Unable to create the SNAT Interface Port") raise n_exc.BadRequest(resource='router', msg=msg) - elif do_pop: + + with context.session.begin(subtransactions=True): + router_port = l3_db.RouterPort( + port_id=snat_port['id'], + router_id=router.id, + port_type=DEVICE_OWNER_DVR_SNAT + ) + context.session.add(router_port) + + if do_pop: return self._populate_subnet_for_ports(context, [snat_port]) return snat_port - def create_snat_intf_ports_if_not_exists( - self, context, router_id): + def create_snat_intf_ports_if_not_exists(self, context, router): """Function to return the snat interface port list. This function will return the snat interface port list @@ -480,24 +511,27 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, new ports and then return the list. """ port_list = self.get_snat_interface_ports_for_router( - context, router_id) + context, router.id) if port_list: self._populate_subnet_for_ports(context, port_list) return port_list port_list = [] - filters = { - 'device_id': [router_id], - 'device_owner': [DEVICE_OWNER_DVR_INTERFACE]} - int_ports = self._core_plugin.get_ports(context, filters) + + int_ports = ( + rp.port for rp in + router.attached_ports.filter_by( + port_type=DEVICE_OWNER_DVR_INTERFACE + ) + ) LOG.info(_('SNAT interface port list does not exist,' ' so create one: %s'), port_list) for intf in int_ports: - if intf.get('fixed_ips'): + if intf.fixed_ips: # Passing the subnet for the port to make sure the IP's # are assigned on the right subnet if multiple subnet # exists snat_port = self.add_csnat_router_interface_port( - context, router_id, intf['network_id'], + context, router, intf['network_id'], intf['fixed_ips'][0]['subnet_id'], do_pop=False) port_list.append(snat_port) if port_list: @@ -539,11 +573,18 @@ class L3_NAT_with_dvr_db_mixin(l3_db.L3_NAT_db_mixin, # Each csnat router interface port is associated # with a subnet, so we need to pass the subnet id to # delete the right ports. - device_filter = { - 'device_id': [router['id']], - 'device_owner': [DEVICE_OWNER_DVR_SNAT]} + + # TODO(markmcclain): This is suboptimal but was left to reduce + # changeset size since it is late in cycle + ports = ( + rp.port.id for rp in + router.attached_ports.filter_by(port_type=DEVICE_OWNER_DVR_SNAT) + ) + c_snat_ports = self._core_plugin.get_ports( - context, filters=device_filter) + context, + filters={'id': ports} + ) for p in c_snat_ports: if subnet_id is None: self._core_plugin.delete_port(context, diff --git a/neutron/db/migration/alembic_migrations/versions/544673ac99ab_add_router_port_table.py b/neutron/db/migration/alembic_migrations/versions/544673ac99ab_add_router_port_table.py new file mode 100644 index 00000000000..cf3190bec22 --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/544673ac99ab_add_router_port_table.py @@ -0,0 +1,65 @@ +# Copyright 2014 OpenStack Foundation +# +# 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 router port relationship + +Revision ID: 544673ac99ab +Revises: 1680e1f0c4dc +Create Date: 2014-01-14 11:58:13.754747 + +""" + +# revision identifiers, used by Alembic. +revision = '544673ac99ab' +down_revision = '1680e1f0c4dc' + +from alembic import op +import sqlalchemy as sa + +SQL_STATEMENT = ( + "insert into routerports " + "select " + "p.device_id as router_id, p.id as port_id, p.device_owner as port_type " + "from ports p join routers r on (p.device_id=r.id) " + "where " + "(r.tenant_id=p.tenant_id AND p.device_owner='network:router_interface') " + "OR (p.tenant_id='' AND p.device_owner='network:router_gateway')" +) + + +def upgrade(): + op.create_table( + 'routerports', + sa.Column('router_id', sa.String(length=36), nullable=False), + sa.Column('port_id', sa.String(length=36), nullable=False), + sa.Column('port_type', sa.String(length=255)), + sa.PrimaryKeyConstraint('router_id', 'port_id'), + sa.ForeignKeyConstraint( + ['router_id'], + ['routers.id'], + ondelete='CASCADE' + ), + sa.ForeignKeyConstraint( + ['port_id'], + ['ports.id'], + ondelete='CASCADE' + ), + ) + + op.execute(SQL_STATEMENT) + + +def downgrade(): + op.drop_table('routerports') diff --git a/neutron/db/migration/alembic_migrations/versions/HEAD b/neutron/db/migration/alembic_migrations/versions/HEAD index aa8f506d6e6..3f554aa166c 100644 --- a/neutron/db/migration/alembic_migrations/versions/HEAD +++ b/neutron/db/migration/alembic_migrations/versions/HEAD @@ -1 +1 @@ -1680e1f0c4dc +544673ac99ab