From c4408c4c7846cf3145e4fdad8610cae54ecd1e97 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 10 Nov 2016 23:35:24 +0000 Subject: [PATCH] Adds support for systemd amphora images This patch enables auto-detection of the init system used in the amphora image and adds support for systemd amphora. This patch allows Ubuntu xenial amphora images to work. It also merges two functional test files into one file to reduce code duplication. This is a scenario gate fix. Change-Id: I5fec1680bd47719ae9f2fcb6abaaba8a78e2ae8b Closes-Bug: #1640866 --- .../systemd/amphora-agent.service | 12 + .../11-enable-amphora-agent-systemd | 11 + elements/haproxy-octavia-ubuntu/element-deps | 1 + .../package-installs.json | 2 +- elements/haproxy-octavia-ubuntu/pkg-map | 22 + .../post-install.d/20-disable-default-haproxy | 4 +- .../pre-install.d/01-backports | 4 +- .../keepalived-octavia-ubuntu/element-deps | 1 + .../package-installs.json | 2 +- elements/keepalived-octavia-ubuntu/pkg-map | 23 + .../pre-install.d/00-backports | 4 +- etc/octavia.conf | 3 + .../backends/agent/api_server/keepalived.py | 45 +- .../backends/agent/api_server/listener.py | 98 +- .../api_server/templates/keepalived.conf.j2 | 67 - .../templates/keepalived.systemd.j2 | 13 + .../templates/keepalived.sysvinit.j2 | 83 ++ .../templates/keepalived.upstart.j2 | 25 + .../api_server/templates/systemd.conf.j2 | 29 + .../backends/agent/api_server/util.py | 55 +- octavia/common/config.py | 3 + octavia/common/constants.py | 21 +- .../backend/agent/api_server/test_server.py | 216 +++- .../agent/api_server/test_server_sysvinit.py | 1134 ----------------- .../add-systemd-support-5794252f02bce666.yaml | 8 + 25 files changed, 630 insertions(+), 1256 deletions(-) create mode 100644 elements/amphora-agent/init-scripts/systemd/amphora-agent.service create mode 100755 elements/amphora-agent/post-install.d/11-enable-amphora-agent-systemd create mode 100644 elements/haproxy-octavia-ubuntu/pkg-map create mode 100644 elements/keepalived-octavia-ubuntu/pkg-map delete mode 100644 octavia/amphorae/backends/agent/api_server/templates/keepalived.conf.j2 create mode 100644 octavia/amphorae/backends/agent/api_server/templates/keepalived.systemd.j2 create mode 100644 octavia/amphorae/backends/agent/api_server/templates/keepalived.sysvinit.j2 create mode 100644 octavia/amphorae/backends/agent/api_server/templates/keepalived.upstart.j2 create mode 100644 octavia/amphorae/backends/agent/api_server/templates/systemd.conf.j2 delete mode 100644 octavia/tests/functional/amphorae/backend/agent/api_server/test_server_sysvinit.py create mode 100644 releasenotes/notes/add-systemd-support-5794252f02bce666.yaml diff --git a/elements/amphora-agent/init-scripts/systemd/amphora-agent.service b/elements/amphora-agent/init-scripts/systemd/amphora-agent.service new file mode 100644 index 0000000000..d2880beb67 --- /dev/null +++ b/elements/amphora-agent/init-scripts/systemd/amphora-agent.service @@ -0,0 +1,12 @@ +[Unit] +Description=OpenStack Octavia Amphora Agent +After=network.target syslog.service +Wants=syslog.service + +[Service] +ExecStart=/usr/local/bin/amphora-agent --config-file /etc/octavia/amphora-agent.conf +KillMode=mixed +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/elements/amphora-agent/post-install.d/11-enable-amphora-agent-systemd b/elements/amphora-agent/post-install.d/11-enable-amphora-agent-systemd new file mode 100755 index 0000000000..4135231a7f --- /dev/null +++ b/elements/amphora-agent/post-install.d/11-enable-amphora-agent-systemd @@ -0,0 +1,11 @@ +#!/bin/bash + +if [ ${DIB_DEBUG_TRACE:-0} -gt 0 ]; then + set -x +fi +set -eu +set -o pipefail + +if [[ -f /bin/systemctl ]]; then + /bin/systemctl enable amphora-agent +fi diff --git a/elements/haproxy-octavia-ubuntu/element-deps b/elements/haproxy-octavia-ubuntu/element-deps index 80570ab87a..a490ea0497 100644 --- a/elements/haproxy-octavia-ubuntu/element-deps +++ b/elements/haproxy-octavia-ubuntu/element-deps @@ -1,3 +1,4 @@ os-svc-install package-installs +pkg-map sysctl diff --git a/elements/haproxy-octavia-ubuntu/package-installs.json b/elements/haproxy-octavia-ubuntu/package-installs.json index 9f8b353b36..6270153dda 100644 --- a/elements/haproxy-octavia-ubuntu/package-installs.json +++ b/elements/haproxy-octavia-ubuntu/package-installs.json @@ -1,3 +1,3 @@ { - "haproxy/trusty-backports": null + "haproxy": null } diff --git a/elements/haproxy-octavia-ubuntu/pkg-map b/elements/haproxy-octavia-ubuntu/pkg-map new file mode 100644 index 0000000000..190027c791 --- /dev/null +++ b/elements/haproxy-octavia-ubuntu/pkg-map @@ -0,0 +1,22 @@ +{ + "release": { + "ubuntu": { + "trusty": { + "haproxy": "haproxy/trusty-backports" + } + } + }, + "distro": { + "ubuntu": { + "haproxy": "haproxy" + } + }, + "family": { + "debian": { + "haproxy": "haproxy" + } + }, + "default": { + "haproxy": "haproxy" + } +} diff --git a/elements/haproxy-octavia-ubuntu/post-install.d/20-disable-default-haproxy b/elements/haproxy-octavia-ubuntu/post-install.d/20-disable-default-haproxy index 0f23a0d44c..9db1721251 100755 --- a/elements/haproxy-octavia-ubuntu/post-install.d/20-disable-default-haproxy +++ b/elements/haproxy-octavia-ubuntu/post-install.d/20-disable-default-haproxy @@ -3,4 +3,6 @@ set -eu set -o pipefail -update-rc.d -f haproxy remove +# Doing both here as just remove doesn't seem to work on xenial +update-rc.d haproxy disable || true +update-rc.d -f haproxy remove || true diff --git a/elements/haproxy-octavia-ubuntu/pre-install.d/01-backports b/elements/haproxy-octavia-ubuntu/pre-install.d/01-backports index 6f284bd487..a6eb639cb3 100755 --- a/elements/haproxy-octavia-ubuntu/pre-install.d/01-backports +++ b/elements/haproxy-octavia-ubuntu/pre-install.d/01-backports @@ -4,4 +4,6 @@ set -eu set -o xtrace -echo deb http://archive.ubuntu.com/ubuntu trusty-backports main restricted universe multiverse > /etc/apt/sources.list.d/backports.list +if [ "$DISTRO_NAME" == "ubuntu" ] && [ "$DIB_RELEASE" == "trusty" ]; then + echo deb http://archive.ubuntu.com/ubuntu trusty-backports main restricted universe multiverse > /etc/apt/sources.list.d/backports.list +fi diff --git a/elements/keepalived-octavia-ubuntu/element-deps b/elements/keepalived-octavia-ubuntu/element-deps index 29e4e2426c..593d05319b 100644 --- a/elements/keepalived-octavia-ubuntu/element-deps +++ b/elements/keepalived-octavia-ubuntu/element-deps @@ -1,2 +1,3 @@ os-svc-install package-installs +pkg-map diff --git a/elements/keepalived-octavia-ubuntu/package-installs.json b/elements/keepalived-octavia-ubuntu/package-installs.json index aba7c57226..1c54782b32 100644 --- a/elements/keepalived-octavia-ubuntu/package-installs.json +++ b/elements/keepalived-octavia-ubuntu/package-installs.json @@ -1,3 +1,3 @@ { - "keepalived/trusty-backports": null + "keepalived": null } diff --git a/elements/keepalived-octavia-ubuntu/pkg-map b/elements/keepalived-octavia-ubuntu/pkg-map new file mode 100644 index 0000000000..40dd50c56c --- /dev/null +++ b/elements/keepalived-octavia-ubuntu/pkg-map @@ -0,0 +1,23 @@ +{ + "release": { + "ubuntu": { + "trusty": { + "keepalived": "keepalived/trusty-backports" + } + } + }, + "distro": { + "ubuntu": { + "keepalived": "keepalived" + } + }, + "family": { + "debian": { + "keepalived": "keepalived" + } + }, + "default": { + "keepalived": "keepalived" + } + +} diff --git a/elements/keepalived-octavia-ubuntu/pre-install.d/00-backports b/elements/keepalived-octavia-ubuntu/pre-install.d/00-backports index 6f284bd487..a6eb639cb3 100755 --- a/elements/keepalived-octavia-ubuntu/pre-install.d/00-backports +++ b/elements/keepalived-octavia-ubuntu/pre-install.d/00-backports @@ -4,4 +4,6 @@ set -eu set -o xtrace -echo deb http://archive.ubuntu.com/ubuntu trusty-backports main restricted universe multiverse > /etc/apt/sources.list.d/backports.list +if [ "$DISTRO_NAME" == "ubuntu" ] && [ "$DIB_RELEASE" == "trusty" ]; then + echo deb http://archive.ubuntu.com/ubuntu trusty-backports main restricted universe multiverse > /etc/apt/sources.list.d/backports.list +fi diff --git a/etc/octavia.conf b/etc/octavia.conf index 7315a8c7a2..c5a8e5d2ae 100644 --- a/etc/octavia.conf +++ b/etc/octavia.conf @@ -124,7 +124,10 @@ # respawn_interval = 2 # client_cert = /etc/octavia/certs/client.pem # server_ca = /etc/octavia/certs/server_ca.pem +# +# This setting is deprecated. It is now automatically discovered. # use_upstart = True +# # rest_request_conn_timeout = 10 # rest_request_read_timeout = 60 diff --git a/octavia/amphorae/backends/agent/api_server/keepalived.py b/octavia/amphorae/backends/agent/api_server/keepalived.py index 93ade5a9c8..14bdac28bf 100644 --- a/octavia/amphorae/backends/agent/api_server/keepalived.py +++ b/octavia/amphorae/backends/agent/api_server/keepalived.py @@ -31,7 +31,9 @@ LOG = logging.getLogger(__name__) j2_env = jinja2.Environment(autoescape=True, loader=jinja2.FileSystemLoader( os.path.dirname(os.path.realpath(__file__)) + consts.AGENT_API_TEMPLATES)) -template = j2_env.get_template(consts.KEEPALIVED_CONF) +UPSTART_TEMPLATE = j2_env.get_template(consts.KEEPALIVED_JINJA2_UPSTART) +SYSVINIT_TEMPLATE = j2_env.get_template(consts.KEEPALIVED_JINJA2_SYSVINIT) +SYSTEMD_TEMPLATE = j2_env.get_template(consts.KEEPALIVED_JINJA2_SYSTEMD) check_script_template = j2_env.get_template(consts.CHECK_SCRIPT_CONF) @@ -54,10 +56,28 @@ class Keepalived(object): f.write(b) b = stream.read(BUFFER) - file_path = util.keepalived_init_path() - # mode 00755 - mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | - stat.S_IROTH | stat.S_IXOTH) + init_system = util.get_os_init_system() + + file_path = util.keepalived_init_path(init_system) + + if init_system == consts.INIT_SYSTEMD: + template = SYSTEMD_TEMPLATE + init_enable_cmd = "systemctl enable octavia-keepalived" + elif init_system == consts.INIT_UPSTART: + template = UPSTART_TEMPLATE + elif init_system == consts.INIT_SYSVINIT: + template = SYSVINIT_TEMPLATE + init_enable_cmd = "insserv {file}".format(file=file_path) + else: + raise util.UnknownInitError() + + if init_system == consts.INIT_SYSTEMD: + # mode 00644 + mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + else: + # mode 00755 + mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) if not os.path.exists(file_path): with os.fdopen(os.open(file_path, flags, mode), 'w') as text_file: text = template.render( @@ -71,6 +91,9 @@ class Keepalived(object): # Renders the Keepalived check script keepalived_path = util.keepalived_check_script_path() + # mode 00755 + mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) open_obj = os.open(keepalived_path, flags, mode) with os.fdopen(open_obj, 'w') as text_file: text = check_script_template.render( @@ -78,6 +101,18 @@ class Keepalived(object): ) text_file.write(text) + # Make sure the new service is enabled on boot + if init_system != consts.INIT_UPSTART: + try: + subprocess.check_output(init_enable_cmd.split(), + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + LOG.debug("Failed to enable octavia-keepalived service: " + "%(err)s", {'err': e}) + return flask.make_response(flask.jsonify(dict( + message="Error enabling octavia-keepalived service", + details=e.output)), 500) + res = flask.make_response(flask.jsonify({ 'message': 'OK'}), 200) res.headers['ETag'] = stream.get_md5() diff --git a/octavia/amphorae/backends/agent/api_server/listener.py b/octavia/amphorae/backends/agent/api_server/listener.py index 5f583f6543..2f5c68fdf2 100644 --- a/octavia/amphorae/backends/agent/api_server/listener.py +++ b/octavia/amphorae/backends/agent/api_server/listener.py @@ -30,12 +30,14 @@ from octavia.amphorae.backends.agent.api_server import util from octavia.amphorae.backends.utils import haproxy_query as query from octavia.common import constants as consts from octavia.common import utils as octavia_utils +from octavia.i18n import _LE LOG = logging.getLogger(__name__) BUFFER = 100 UPSTART_CONF = 'upstart.conf.j2' SYSVINIT_CONF = 'sysvinit.conf.j2' +SYSTEMD_CONF = 'systemd.conf.j2' JINJA_ENV = jinja2.Environment( autoescape=True, @@ -44,6 +46,7 @@ JINJA_ENV = jinja2.Environment( ) + consts.AGENT_API_TEMPLATES)) UPSTART_TEMPLATE = JINJA_ENV.get_template(UPSTART_CONF) SYSVINIT_TEMPLATE = JINJA_ENV.get_template(SYSVINIT_CONF) +SYSTEMD_TEMPLATE = JINJA_ENV.get_template(SYSTEMD_CONF) class ParsingError(Exception): @@ -113,7 +116,7 @@ class Listener(object): try: subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - LOG.debug("Failed to verify haproxy file: %s", e) + LOG.error(_LE("Failed to verify haproxy file: %s"), e) os.remove(name) # delete file return flask.make_response(flask.jsonify(dict( message="Invalid request", @@ -122,15 +125,44 @@ class Listener(object): # file ok - move it os.rename(name, util.config_path(listener_id)) - use_upstart = util.CONF.haproxy_amphora.use_upstart - file = util.init_path(listener_id) - # mode 00755 - mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | - stat.S_IROTH | stat.S_IXOTH) - if not os.path.exists(file): - with os.fdopen(os.open(file, flags, mode), 'w') as text_file: - template = (UPSTART_TEMPLATE if use_upstart - else SYSVINIT_TEMPLATE) + try: + + init_system = util.get_os_init_system() + + LOG.debug('Found init system: {0}'.format(init_system)) + + init_path = util.init_path(listener_id, init_system) + + if init_system == consts.INIT_SYSTEMD: + template = SYSTEMD_TEMPLATE + init_enable_cmd = "systemctl enable haproxy-{list}".format( + list=listener_id) + elif init_system == consts.INIT_UPSTART: + template = UPSTART_TEMPLATE + elif init_system == consts.INIT_SYSVINIT: + template = SYSVINIT_TEMPLATE + init_enable_cmd = "insserv {file}".format(file=init_path) + else: + raise util.UnknownInitError() + + except util.UnknownInitError: + LOG.error(_LE("Unknown init system found.")) + return flask.make_response(flask.jsonify(dict( + message="Unknown init system in amphora", + details="The amphora image is running an unknown init " + "system. We can't create the init configuration " + "file for the load balancing process.")), 500) + + if init_system == consts.INIT_SYSTEMD: + # mode 00644 + mode = (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + else: + # mode 00755 + mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + if not os.path.exists(init_path): + with os.fdopen(os.open(init_path, flags, mode), 'w') as text_file: + text = template.render( peer_name=peer_name, haproxy_pid=util.pid_path(listener_id), @@ -143,18 +175,18 @@ class Listener(object): ) text_file.write(text) - if not use_upstart: - insrvcmd = ("insserv {file}".format(file=file)) - + # Make sure the new service is enabled on boot + if init_system != consts.INIT_UPSTART: try: - subprocess.check_output(insrvcmd.split(), + subprocess.check_output(init_enable_cmd.split(), stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - LOG.debug("Failed to make %(file)s executable: %(err)s", - {'file': file, 'err': e}) + LOG.error(_LE("Failed to enable haproxy-%(list)s " + "service: %(err)s"), + {'list': listener_id, 'err': e}) return flask.make_response(flask.jsonify(dict( - message="Error making file {0} executable".format(file), - details=e.output)), 500) + message="Error enabling haproxy-{0} service".format( + listener_id), details=e.output)), 500) res = flask.make_response(flask.jsonify({ 'message': 'OK'}), 202) @@ -221,7 +253,7 @@ class Listener(object): try: subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: - LOG.debug("Failed to stop HAProxy service: %s", e) + LOG.error(_LE("Failed to stop HAProxy service: %s"), e) return flask.make_response(flask.jsonify(dict( message="Error stopping haproxy", details=e.output)), 500) @@ -239,10 +271,34 @@ class Listener(object): except Exception: pass + # disable the service + init_system = util.get_os_init_system() + init_path = util.init_path(listener_id, init_system) + + if init_system == consts.INIT_SYSTEMD: + init_disable_cmd = "systemctl disable haproxy-{list}".format( + list=listener_id) + elif init_system == consts.INIT_SYSVINIT: + init_disable_cmd = "insserv -r {file}".format(file=init_path) + elif init_system != consts.INIT_UPSTART: + raise util.UnknownInitError() + + if init_system != consts.INIT_UPSTART: + try: + subprocess.check_output(init_disable_cmd.split(), + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + LOG.error(_LE("Failed to disable haproxy-%(list)s " + "service: %(err)s"), + {'list': listener_id, 'err': e}) + return flask.make_response(flask.jsonify(dict( + message="Error disabling haproxy-{0} service".format( + listener_id), details=e.output)), 500) + # delete the directory + init script for that listener shutil.rmtree(util.haproxy_dir(listener_id)) - if os.path.exists(util.init_path(listener_id)): - os.remove(util.init_path(listener_id)) + if os.path.exists(init_path): + os.remove(init_path) return flask.jsonify({'message': 'OK'}) diff --git a/octavia/amphorae/backends/agent/api_server/templates/keepalived.conf.j2 b/octavia/amphorae/backends/agent/api_server/templates/keepalived.conf.j2 deleted file mode 100644 index dbc98407f2..0000000000 --- a/octavia/amphorae/backends/agent/api_server/templates/keepalived.conf.j2 +++ /dev/null @@ -1,67 +0,0 @@ -{# -# Copyright 2015 Hewlett-Packard Development Company, L.P. -# -# 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. -# -#} -#!/bin/sh - -RETVAL=0 - -prog="octavia-keepalived" - -start() { - echo -n $"Starting $prog" - ip netns exec {{ amphora_nsname }} {{ keepalived_cmd }} -D -d -f {{ keepalived_cfg }} - RETVAL=$? - echo - [ $RETVAL -eq 0 ] && touch {{ keepalived_pid }} -} - -stop() { - echo -n $"Stopping $prog" - kill -9 `pidof keepalived` - RETVAL=$? - echo - [ $RETVAL -eq 0 ] && rm -f {{ keepalived_pid }} -} - -status() { - kill -0 `pidof keepalived` - RETVAL=$? - [ $RETVAL -eq 0 ] && echo -n $"$prog is running" - [ $RETVAL -eq 1 ] && echo -n $"$prog is not found" - echo -} - -# See how we were called. -case "$1" in - start) - start - ;; - stop) - stop - ;; - reload) - stop - start - ;; - status) - status - ;; - *) - echo "Usage: $0 {start|stop|reload|status}" - RETVAL=1 -esac - -exit $RETVAL diff --git a/octavia/amphorae/backends/agent/api_server/templates/keepalived.systemd.j2 b/octavia/amphorae/backends/agent/api_server/templates/keepalived.systemd.j2 new file mode 100644 index 0000000000..3ee6d9c8e9 --- /dev/null +++ b/octavia/amphorae/backends/agent/api_server/templates/keepalived.systemd.j2 @@ -0,0 +1,13 @@ +[Unit] +Description=Keepalive Daemon (LVS and VRRP) +After=network-online.target +Wants=network-online.target + +[Service] +Type=forking +KillMode=process +ExecStart=/sbin/ip netns exec {{ amphora_nsname }} {{ keepalived_cmd }} -D -d -f {{ keepalived_cfg }} +ExecReload=/bin/kill -HUP $MAINPID + +[Install] +WantedBy=multi-user.target diff --git a/octavia/amphorae/backends/agent/api_server/templates/keepalived.sysvinit.j2 b/octavia/amphorae/backends/agent/api_server/templates/keepalived.sysvinit.j2 new file mode 100644 index 0000000000..8029413b17 --- /dev/null +++ b/octavia/amphorae/backends/agent/api_server/templates/keepalived.sysvinit.j2 @@ -0,0 +1,83 @@ +#!/bin/sh +# +# keepalived LVS cluster monitor daemon. +# +# Written by Andres Salomon +# +### BEGIN INIT INFO +# Provides: keepalived +# Required-Start: $syslog $network $remote_fs +# Required-Stop: $syslog $network $remote_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Starts keepalived +# Description: Starts keepalived lvs loadbalancer +### END INIT INFO +PATH=/sbin:/bin:/usr/sbin:/usr/bin +DAEMON="ip netns exec {{ amphora_nsname }} {{ keepalived_cmd }}" +NAME=octavia-keepalived +DESC=octavia-keepalived +TMPFILES="/tmp/.vrrp /tmp/.healthcheckers" +DAEMON_ARGS="-D -d -f {{ keepalived_cfg }}" + +#includes lsb functions +. /lib/lsb/init-functions + +test -f $DAEMON || exit 0 + + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +case "$1" in + start) + log_daemon_msg "Starting $DESC" "$NAME" + for file in $TMPFILES + do + test -e $file && test ! -L $file && rm $file + done + if start-stop-daemon --start --quiet --pidfile /var/run/$NAME.pid \ + --exec $DAEMON -- $DAEMON_ARGS; then + log_end_msg 0 + else + log_end_msg 1 + fi + ;; + stop) + log_daemon_msg "Stopping $DESC" "$NAME" + if start-stop-daemon --oknodo --stop --quiet --pidfile /var/run/$NAME.pid \ + --exec $DAEMON; then + log_end_msg 0 + else + log_end_msg 1 + fi + ;; + reload|force-reload) + log_action_begin_msg "Reloading $DESC configuration..." + if start-stop-daemon --stop --quiet --signal 1 --pidfile \ + /var/run/$NAME.pid --exec $DAEMON; then + log_end_msg 0 + else + log_action_end_msg 1 + fi + ;; + restart) + log_action_begin_msg "Restarting $DESC" "$NAME" + + start-stop-daemon --stop --quiet --pidfile \ + /var/run/$NAME.pid --exec $DAEMON || true + sleep 1 + if start-stop-daemon --start --quiet --pidfile \ + /var/run/$NAME.pid --exec $DAEMON -- $DAEMON_ARGS; then + log_end_msg 0 + else + log_end_msg 1 + fi + ;; + *) + echo "Usage: /etc/init.d/$NAME {start|stop|restart|reload|force-reload}" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/octavia/amphorae/backends/agent/api_server/templates/keepalived.upstart.j2 b/octavia/amphorae/backends/agent/api_server/templates/keepalived.upstart.j2 new file mode 100644 index 0000000000..131d244bea --- /dev/null +++ b/octavia/amphorae/backends/agent/api_server/templates/keepalived.upstart.j2 @@ -0,0 +1,25 @@ +{# +# Copyright 2015 Hewlett-Packard Development Company, L.P. +# Copyright 2016 Rackspace +# +# 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. +# +#} +description "Octavia keepalived" + +start on runlevel [2345] +stop on runlevel [!2345] + +respawn + +exec /sbin/ip netns exec {{ amphora_nsname }} {{ keepalived_cmd }} -n -D -d -f {{ keepalived_cfg }} diff --git a/octavia/amphorae/backends/agent/api_server/templates/systemd.conf.j2 b/octavia/amphorae/backends/agent/api_server/templates/systemd.conf.j2 new file mode 100644 index 0000000000..739a9c285d --- /dev/null +++ b/octavia/amphorae/backends/agent/api_server/templates/systemd.conf.j2 @@ -0,0 +1,29 @@ +[Unit] +Description=HAProxy Load Balancer +Documentation=man:haproxy(1) +Documentation=file:/usr/share/doc/haproxy/configuration.txt.gz +After=network.target syslog.service +Wants=syslog.service + +[Service] +EnvironmentFile=-/etc/default/haproxy +ExecStartPre=/usr/sbin/haproxy -f {{ haproxy_cfg }} -c -q +# Re-add the namespace +ExecStartPre=-/sbin/ip netns add {{ amphora_nsname }} +# Load the system sysctl into the new namespace +ExecStartPre=-/sbin/ip netns exec {{ amphora_nsname }} sysctl --system +# We need the plugged_interfaces file sorted to join the host interfaces +ExecStartPre=-/bin/sh -c '/usr/bin/sort -k 1 /var/lib/octavia/plugged_interfaces > /var/lib/octavia/plugged_interfaces.sorted' +# Assign the interfaces into the namespace with the appropriate name +ExecStartPre=-/bin/sh -c '/sbin/ip link | awk \'{getline n; print $0,n}\' | awk \'{sub(":","",$2)} {print $17 " " $2}\' | sort -k 1 | join -j 1 - /var/lib/octavia/plugged_interfaces.sorted | awk \'{system("ip link set "$2" netns {{ amphora_nsname }} name "$3"")}\'' +# Bring up all of the namespace interfaces +ExecStartPre=-/sbin/ip netns exec {{ amphora_nsname }} ifup -a +# +ExecStart=/sbin/ip netns exec {{ amphora_nsname }} /usr/sbin/haproxy-systemd-wrapper -f {{ haproxy_cfg }} -p {{ haproxy_pid }} -L {{ peer_name }} $EXTRAOPTS +ExecReload=/usr/sbin/haproxy -c -f {{ haproxy_cfg }} +ExecReload=/bin/kill -USR2 $MAINPID +KillMode=mixed +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/octavia/amphorae/backends/agent/api_server/util.py b/octavia/amphorae/backends/agent/api_server/util.py index effdce9691..a81040dae8 100644 --- a/octavia/amphorae/backends/agent/api_server/util.py +++ b/octavia/amphorae/backends/agent/api_server/util.py @@ -14,23 +14,33 @@ import os +import subprocess from oslo_config import cfg +from octavia.common import constants as consts + CONF = cfg.CONF CONF.import_group('amphora_agent', 'octavia.common.config') CONF.import_group('haproxy_amphora', 'octavia.common.config') -UPSTART_DIR = '/etc/init' -KEEPALIVED_INIT_DIR = '/etc/init.d' -SYSVINIT_DIR = '/etc/init.d' + +class UnknownInitError(Exception): + pass -def init_path(listener_id): - use_upstart = CONF.haproxy_amphora.use_upstart - hconf = 'haproxy-{0}.conf' if use_upstart else 'haproxy-{0}' - idir = UPSTART_DIR if use_upstart else SYSVINIT_DIR - return os.path.join(idir, hconf.format(listener_id)) +def init_path(listener_id, init_system): + if init_system == consts.INIT_SYSTEMD: + return os.path.join(consts.SYSTEMD_DIR, + 'haproxy-{0}.service'.format(listener_id)) + elif init_system == consts.INIT_UPSTART: + return os.path.join(consts.UPSTART_DIR, + 'haproxy-{0}.conf'.format(listener_id)) + elif init_system == consts.INIT_SYSVINIT: + return os.path.join(consts.SYSVINIT_DIR, + 'haproxy-{0}'.format(listener_id)) + else: + raise UnknownInitError() def haproxy_dir(listener_id): @@ -63,8 +73,15 @@ def keepalived_dir(): return os.path.join(CONF.haproxy_amphora.base_path, 'vrrp') -def keepalived_init_path(): - return os.path.join(KEEPALIVED_INIT_DIR, 'octavia-keepalived') +def keepalived_init_path(init_system): + if init_system == consts.INIT_SYSTEMD: + return os.path.join(consts.SYSTEMD_DIR, consts.KEEPALIVED_SYSTEMD) + elif init_system == consts.INIT_UPSTART: + return os.path.join(consts.UPSTART_DIR, consts.KEEPALIVED_UPSTART) + elif init_system == consts.INIT_SYSVINIT: + return os.path.join(consts.SYSVINIT_DIR, consts.KEEPALIVED_SYSVINIT) + else: + raise UnknownInitError() def keepalived_pid_path(): @@ -115,3 +132,21 @@ def get_network_interface_file(interface): return CONF.amphora_agent.agent_server_network_file return os.path.join(CONF.amphora_agent.agent_server_network_dir, interface + '.cfg') + + +def get_os_init_system(): + if os.path.exists(consts.INIT_PROC_COMM_PATH): + with open(consts.INIT_PROC_COMM_PATH, 'r') as init_comm: + init_proc_name = init_comm.read().rstrip('\n') + if init_proc_name == consts.INIT_SYSTEMD: + return consts.INIT_SYSTEMD + if init_proc_name == 'init': + init_path = consts.INIT_PATH + if os.path.exists(init_path): + args = [init_path, '--version'] + init_version = subprocess.check_output(args, shell=False) + if consts.INIT_UPSTART in init_version: + return consts.INIT_UPSTART + else: + return consts.INIT_SYSVINIT + return consts.INIT_UNKOWN diff --git a/octavia/common/config.py b/octavia/common/config.py index 8294aacde6..9dac3a5693 100644 --- a/octavia/common/config.py +++ b/octavia/common/config.py @@ -191,6 +191,9 @@ haproxy_amphora_opts = [ cfg.StrOpt('server_ca', default='/etc/octavia/certs/server_ca.pem', help=_("The ca which signed the server certificates")), cfg.BoolOpt('use_upstart', default=True, + deprecated_for_removal=True, + deprecated_reason='This is now automatically discovered ' + ' and configured.', help=_("If False, use sysvinit.")), ] diff --git a/octavia/common/constants.py b/octavia/common/constants.py index f3974659b9..dc1e830f76 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -310,7 +310,9 @@ HAPROXY_MEMBER_STATUSES = (UP, DOWN, NO_CHECK) API_VERSION = '0.5' HAPROXY_BASE_PEER_PORT = 1025 -KEEPALIVED_CONF = 'keepalived.conf.j2' +KEEPALIVED_JINJA2_UPSTART = 'keepalived.upstart.j2' +KEEPALIVED_JINJA2_SYSTEMD = 'keepalived.systemd.j2' +KEEPALIVED_JINJA2_SYSVINIT = 'keepalived.sysvinit.j2' CHECK_SCRIPT_CONF = 'keepalived_check_script.conf.j2' PLUGGED_INTERFACES = '/var/lib/octavia/plugged_interfaces' @@ -336,3 +338,20 @@ AMP_ACTION_START = 'start' AMP_ACTION_STOP = 'stop' AMP_ACTION_RELOAD = 'reload' GLANCE_IMAGE_ACTIVE = 'active' + +INIT_SYSTEMD = 'systemd' +INIT_UPSTART = 'upstart' +INIT_SYSVINIT = 'sysvinit' +INIT_UNKOWN = 'unknown' +VALID_INIT_SYSTEMS = (INIT_SYSTEMD, INIT_SYSVINIT, INIT_UPSTART) +INIT_PATH = '/sbin/init' + +SYSTEMD_DIR = '/usr/lib/systemd/system' +SYSVINIT_DIR = '/etc/init.d' +UPSTART_DIR = '/etc/init' + +INIT_PROC_COMM_PATH = '/proc/1/comm' + +KEEPALIVED_SYSTEMD = 'octavia-keepalived.service' +KEEPALIVED_SYSVINIT = 'octavia-keepalived' +KEEPALIVED_UPSTART = 'octavia-keepalived.conf' diff --git a/octavia/tests/functional/amphorae/backend/agent/api_server/test_server.py b/octavia/tests/functional/amphorae/backend/agent/api_server/test_server.py index efd90403b9..42150379b8 100644 --- a/octavia/tests/functional/amphorae/backend/agent/api_server/test_server.py +++ b/octavia/tests/functional/amphorae/backend/agent/api_server/test_server.py @@ -51,13 +51,48 @@ class TestServerTestCase(base.TestCase): conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) conf.config(group="haproxy_amphora", base_path='/var/lib/octavia') + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSTEMD) @mock.patch('os.path.exists') @mock.patch('os.makedirs') @mock.patch('os.rename') @mock.patch('subprocess.check_output') @mock.patch('os.remove') - def test_haproxy(self, mock_remove, mock_subprocess, mock_rename, - mock_makedirs, mock_exists): + def test_haproxy_systemd(self, mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system): + self._test_haproxy(mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system, + consts.INIT_SYSTEMD) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSVINIT) + @mock.patch('os.path.exists') + @mock.patch('os.makedirs') + @mock.patch('os.rename') + @mock.patch('subprocess.check_output') + @mock.patch('os.remove') + def test_haproxy_sysvinit(self, mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system): + self._test_haproxy(mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system, + consts.INIT_SYSVINIT) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_UPSTART) + @mock.patch('os.path.exists') + @mock.patch('os.makedirs') + @mock.patch('os.rename') + @mock.patch('subprocess.check_output') + @mock.patch('os.remove') + def test_haproxy_upstart(self, mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system): + self._test_haproxy(mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system, + consts.INIT_UPSTART) + + def _test_haproxy(self, mock_remove, mock_subprocess, mock_rename, + mock_makedirs, mock_exists, mock_init_system, + init_system): flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC mock_exists.return_value = True @@ -77,7 +112,7 @@ class TestServerTestCase(base.TestCase): self.assertEqual(202, rv.status_code) handle = m() handle.write.assert_called_once_with(six.b('test')) - mock_subprocess.assert_called_once_with( + mock_subprocess.assert_any_call( "haproxy -c -L {peer} -f {config_file}".format( config_file=file_name, peer=(octavia_utils. @@ -87,6 +122,17 @@ class TestServerTestCase(base.TestCase): '/var/lib/octavia/123/haproxy.cfg.new', '/var/lib/octavia/123/haproxy.cfg') + if init_system == consts.INIT_SYSTEMD: + mock_subprocess.assert_any_call( + "systemctl enable haproxy-123".split(), + stderr=subprocess.STDOUT) + elif init_system == consts.INIT_SYSVINIT: + mock_subprocess.assert_any_call( + "insserv /etc/init.d/haproxy-123".split(), + stderr=subprocess.STDOUT) + else: + self.assertIn(init_system, consts.VALID_INIT_SYSTEMS) + # exception writing m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open m.side_effect = IOError() # open crashes @@ -98,7 +144,15 @@ class TestServerTestCase(base.TestCase): # check if files get created mock_exists.return_value = False - init_path = '/etc/init/haproxy-123.conf' + if init_system == consts.INIT_SYSTEMD: + init_path = consts.SYSTEMD_DIR + '/haproxy-123.service' + elif init_system == consts.INIT_UPSTART: + init_path = consts.UPSTART_DIR + '/haproxy-123.conf' + elif init_system == consts.INIT_SYSVINIT: + init_path = consts.SYSVINIT_DIR + '/haproxy-123' + else: + self.assertIn(init_system, consts.VALID_INIT_SYSTEMS) + m = self.useFixture(test_utils.OpenFixture(init_path)).mock_open # happy case upstart file exists with mock.patch('os.open') as mock_open, mock.patch.object( @@ -109,8 +163,12 @@ class TestServerTestCase(base.TestCase): data='test') self.assertEqual(202, rv.status_code) - mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | - stat.S_IROTH | stat.S_IXOTH) + if init_system == consts.INIT_SYSTEMD: + mode = (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | + stat.S_IROTH) + else: + mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) mock_open.assert_called_with(init_path, flags, mode) mock_fdopen.assert_called_with(123, 'w') handle = mock_fdopen() @@ -145,6 +203,16 @@ class TestServerTestCase(base.TestCase): stderr=-2) mock_remove.assert_called_once_with(file_name) + # unhappy path with bogus init system + mock_init_system.return_value = 'bogus' + with mock.patch('os.open') as mock_open, mock.patch.object( + os, 'fdopen', m) as mock_fdopen: + mock_open.return_value = 123 + rv = self.app.put('/' + api_server.VERSION + + '/listeners/amp_123/123/haproxy', + data='test') + self.assertEqual(500, rv.status_code) + @mock.patch('os.path.exists') @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' 'vrrp_check_script_update') @@ -247,14 +315,53 @@ class TestServerTestCase(base.TestCase): hostname='test-host'), json.loads(rv.data.decode('utf-8'))) + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSTEMD) @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_haproxy_pid') @mock.patch('shutil.rmtree') @mock.patch('os.remove') - def test_delete_listener(self, mock_remove, mock_rmtree, mock_pid, - mock_check_output, mock_exists): + def test_delete_listener_systemd(self, mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + mock_init_system): + self._test_delete_listener(mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + consts.INIT_SYSTEMD) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSVINIT) + @mock.patch('os.path.exists') + @mock.patch('subprocess.check_output') + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + + 'get_haproxy_pid') + @mock.patch('shutil.rmtree') + @mock.patch('os.remove') + def test_delete_listener_sysvinit(self, mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + mock_init_system): + self._test_delete_listener(mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + consts.INIT_SYSVINIT) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_UPSTART) + @mock.patch('os.path.exists') + @mock.patch('subprocess.check_output') + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + + 'get_haproxy_pid') + @mock.patch('shutil.rmtree') + @mock.patch('os.remove') + def test_delete_listener_upstart(self, mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + mock_init_system): + self._test_delete_listener(mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, + consts.INIT_UPSTART) + + def _test_delete_listener(self, mock_remove, mock_rmtree, mock_pid, + mock_check_output, mock_exists, init_system): mock_exists.return_value = False rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') self.assertEqual(404, rv.status_code) @@ -271,7 +378,19 @@ class TestServerTestCase(base.TestCase): self.assertEqual({u'message': u'OK'}, json.loads(rv.data.decode('utf-8'))) mock_rmtree.assert_called_with('/var/lib/octavia/123') - mock_exists.assert_called_with('/etc/init/haproxy-123.conf') + + if init_system == consts.INIT_SYSTEMD: + mock_exists.assert_called_with(consts.SYSTEMD_DIR + + '/haproxy-123.service') + elif init_system == consts.INIT_UPSTART: + mock_exists.assert_called_with(consts.UPSTART_DIR + + '/haproxy-123.conf') + elif init_system == consts.INIT_SYSVINIT: + mock_exists.assert_called_with(consts.SYSVINIT_DIR + + '/haproxy-123') + else: + self.assertIn(init_system, consts.VALID_INIT_SYSTEMS) + mock_exists.assert_any_call('/var/lib/octavia/123/123.pid') # service is stopped + upstart script @@ -280,7 +399,18 @@ class TestServerTestCase(base.TestCase): self.assertEqual(200, rv.status_code) self.assertEqual({u'message': u'OK'}, json.loads(rv.data.decode('utf-8'))) - mock_remove.assert_called_once_with('/etc/init/haproxy-123.conf') + + if init_system == consts.INIT_SYSTEMD: + mock_remove.assert_called_with(consts.SYSTEMD_DIR + + '/haproxy-123.service') + elif init_system == consts.INIT_UPSTART: + mock_remove.assert_called_with(consts.UPSTART_DIR + + '/haproxy-123.conf') + elif init_system == consts.INIT_SYSVINIT: + mock_remove.assert_called_with(consts.SYSVINIT_DIR + + '/haproxy-123') + else: + self.assertIn(init_system, consts.VALID_INIT_SYSTEMS) # service is running + upstart script mock_exists.side_effect = [True, True, True, True] @@ -290,9 +420,23 @@ class TestServerTestCase(base.TestCase): self.assertEqual({u'message': u'OK'}, json.loads(rv.data.decode('utf-8'))) mock_pid.assert_called_once_with('123') - mock_check_output.assert_called_once_with( + mock_check_output.assert_any_call( ['/usr/sbin/service', 'haproxy-123', 'stop'], stderr=-2) + if init_system == consts.INIT_SYSTEMD: + mock_check_output.assert_any_call( + "systemctl disable haproxy-123".split(), + stderr=subprocess.STDOUT) + elif init_system == consts.INIT_UPSTART: + mock_remove.assert_any_call(consts.UPSTART_DIR + + '/haproxy-123.conf') + elif init_system == consts.INIT_SYSVINIT: + mock_check_output.assert_any_call( + "insserv -r /etc/init.d/haproxy-123".split(), + stderr=subprocess.STDOUT) + else: + self.assertIn(init_system, consts.VALID_INIT_SYSTEMS) + # service is running + stopping fails mock_exists.side_effect = [True, True, True] mock_check_output.side_effect = subprocess.CalledProcessError( @@ -1239,12 +1383,58 @@ class TestServerTestCase(base.TestCase): content_type='application/json') self.assertEqual(400, rv.status_code) + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSTEMD) @mock.patch('os.path.exists') @mock.patch('os.makedirs') @mock.patch('os.rename') + @mock.patch('subprocess.check_output') @mock.patch('os.remove') - def test_upload_keepalived_config(self, mock_remove, - mock_rename, mock_makedirs, mock_exists): + def test_upload_keepalived_config_systemd(self, mock_remove, + mock_subprocess, mock_rename, + mock_makedirs, mock_exists, + mock_init_system): + self._test_upload_keepalived_config(mock_remove, mock_subprocess, + mock_rename, mock_makedirs, + mock_exists, mock_init_system, + consts.INIT_SYSTEMD) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_UPSTART) + @mock.patch('os.path.exists') + @mock.patch('os.makedirs') + @mock.patch('os.rename') + @mock.patch('subprocess.check_output') + @mock.patch('os.remove') + def test_upload_keepalived_config_upstart(self, mock_remove, + mock_subprocess, mock_rename, + mock_makedirs, mock_exists, + mock_init_system): + self._test_upload_keepalived_config(mock_remove, mock_subprocess, + mock_rename, mock_makedirs, + mock_exists, mock_init_system, + consts.INIT_UPSTART) + + @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + 'get_os_init_system', return_value=consts.INIT_SYSVINIT) + @mock.patch('os.path.exists') + @mock.patch('os.makedirs') + @mock.patch('os.rename') + @mock.patch('subprocess.check_output') + @mock.patch('os.remove') + def test_upload_keepalived_config_sysvinit(self, mock_remove, + mock_subprocess, mock_rename, + mock_makedirs, mock_exists, + mock_init_system): + self._test_upload_keepalived_config(mock_remove, mock_subprocess, + mock_rename, mock_makedirs, + mock_exists, mock_init_system, + consts.INIT_SYSVINIT) + + def _test_upload_keepalived_config(self, mock_remove, mock_subprocess, + mock_rename, mock_makedirs, + mock_exists, mock_init_system, + init_system): flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC diff --git a/octavia/tests/functional/amphorae/backend/agent/api_server/test_server_sysvinit.py b/octavia/tests/functional/amphorae/backend/agent/api_server/test_server_sysvinit.py deleted file mode 100644 index 0d5dc9db06..0000000000 --- a/octavia/tests/functional/amphorae/backend/agent/api_server/test_server_sysvinit.py +++ /dev/null @@ -1,1134 +0,0 @@ -# Copyright 2015 Hewlett-Packard Development Company, L.P. -# -# 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. -import hashlib -import json -import os -import random -import socket -import stat -import subprocess - -import mock -import netifaces -from oslo_config import cfg -from oslo_config import fixture as oslo_fixture -import six - -from octavia.amphorae.backends.agent import api_server -from octavia.amphorae.backends.agent.api_server import certificate_update -from octavia.amphorae.backends.agent.api_server import server -from octavia.amphorae.backends.agent.api_server import util -from octavia.common import constants as consts -from octavia.common import utils as octavia_utils -from octavia.tests.common import utils as test_utils -import octavia.tests.unit.base as base - -RANDOM_ERROR = 'random error' -OK = dict(message='OK') - - -class ServerTestCase(base.TestCase): - app = None - - def setUp(self): - super(ServerTestCase, self).setUp() - - self.test_server = server.Server() - self.app = self.test_server.app.test_client() - - conf = self.useFixture(oslo_fixture.Config(cfg.CONF)) - conf.config(group="haproxy_amphora", use_upstart=False) - - @mock.patch('os.path.exists') - @mock.patch('os.makedirs') - @mock.patch('os.rename') - @mock.patch('subprocess.check_output') - @mock.patch('os.remove') - def test_haproxy(self, mock_remove, mock_subprocess, mock_rename, - mock_makedirs, mock_exists): - - mock_exists.return_value = True - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - file_name = '/var/lib/octavia/123/haproxy.cfg.new' - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - - # happy case init file exists - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.put('/' + api_server.VERSION + - '/listeners/amp_123/123/haproxy', - data='test') - mode = stat.S_IRUSR | stat.S_IWUSR - mock_open.assert_called_with(file_name, flags, mode) - mock_fdopen.assert_called_with(123, 'w') - self.assertEqual(202, rv.status_code) - - handle = m() - handle.write.assert_called_once_with(six.b('test')) - calls = [ - mock.call("haproxy -c -L {peer} -f {config_file}".format( - config_file=file_name, - peer=(octavia_utils.base64_sha1_string('amp_123') - .rstrip('='))).split(), stderr=-2) - ] - mock_subprocess.assert_has_calls(calls) - mock_rename.assert_called_once_with( - '/var/lib/octavia/123/haproxy.cfg.new', - '/var/lib/octavia/123/haproxy.cfg') - - # exception writing - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - m.side_effect = IOError() # open crashes - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.put('/' + api_server.VERSION + - '/listeners/amp_123/123/haproxy', - data='test') - self.assertEqual(500, rv.status_code) - - # check if files get created - mock_exists.return_value = False - init_path = '/etc/init/haproxy-123.conf' - m = self.useFixture(test_utils.OpenFixture(init_path)).mock_open - - # happy case init file exists - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.put('/' + api_server.VERSION + - '/listeners/amp_123/123/haproxy', - data='test') - - self.assertEqual(202, rv.status_code) - mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | - stat.S_IROTH | stat.S_IXOTH) - mock_open.assert_called_with(util.SYSVINIT_DIR + '/haproxy-123', - flags, mode) - mock_fdopen.assert_called_with(123, 'w') - handle = mock_fdopen() - handle.write.assert_any_call(six.b('test')) - # skip the template stuff - mock_makedirs.assert_called_with('/var/lib/octavia/123') - - # unhappy case haproxy check fails - mock_exists.return_value = True - mock_subprocess.side_effect = [subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR)] - - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.put('/' + api_server.VERSION + - '/listeners/amp_123/123/haproxy', - data='test') - self.assertEqual(400, rv.status_code) - self.assertEqual( - {'message': 'Invalid request', u'details': u'random error'}, - json.loads(rv.data.decode('utf-8'))) - mode = stat.S_IRUSR | stat.S_IWUSR - mock_open.assert_called_with(file_name, flags, mode) - mock_fdopen.assert_called_with(123, 'w') - handle = mock_fdopen() - handle.write.assert_called_with(six.b('test')) - mock_subprocess.assert_called_with( - "haproxy -c -L {peer} -f {config_file}".format( - config_file=file_name, - peer=(octavia_utils. - base64_sha1_string('amp_123').rstrip('='))).split(), - stderr=-2) - mock_remove.assert_called_once_with(file_name) - - @mock.patch('os.path.exists') - @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' - 'vrrp_check_script_update') - @mock.patch('subprocess.check_output') - def test_start(self, mock_subprocess, mock_vrrp, mock_exists): - rv = self.app.put('/' + api_server.VERSION + '/listeners/123/error') - self.assertEqual(400, rv.status_code) - self.assertEqual( - {'message': 'Invalid Request', - 'details': 'Unknown action: error', }, - json.loads(rv.data.decode('utf-8'))) - - mock_exists.return_value = False - rv = self.app.put('/' + api_server.VERSION + '/listeners/123/start') - self.assertEqual(404, rv.status_code) - self.assertEqual( - {'message': 'Listener Not Found', - 'details': 'No listener with UUID: 123'}, - json.loads(rv.data.decode('utf-8'))) - mock_exists.assert_called_with('/var/lib/octavia/123/haproxy.cfg') - - mock_exists.return_value = True - rv = self.app.put('/' + api_server.VERSION + '/listeners/123/start') - self.assertEqual(202, rv.status_code) - self.assertEqual( - {'message': 'OK', - 'details': 'Configuration file is valid\nhaproxy daemon for' - ' 123 started'}, - json.loads(rv.data.decode('utf-8'))) - mock_subprocess.assert_called_with( - ['/usr/sbin/service', 'haproxy-123', 'start'], stderr=-2) - - mock_exists.return_value = True - mock_subprocess.side_effect = subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR) - rv = self.app.put('/' + api_server.VERSION + '/listeners/123/start') - self.assertEqual(500, rv.status_code) - self.assertEqual( - { - 'message': 'Error starting haproxy', - 'details': RANDOM_ERROR, - }, json.loads(rv.data.decode('utf-8'))) - mock_subprocess.assert_called_with( - ['/usr/sbin/service', 'haproxy-123', 'start'], stderr=-2) - - @mock.patch('socket.gethostname') - @mock.patch('subprocess.check_output') - def test_info(self, mock_subbprocess, mock_hostname): - mock_hostname.side_effect = ['test-host'] - mock_subbprocess.side_effect = [ - """Package: haproxy - Status: install ok installed - Priority: optional - Section: net - Installed-Size: 803 - Maintainer: Ubuntu Developers - Architecture: amd64 - Version: 1.4.24-2 - """] - rv = self.app.get('/' + api_server.VERSION + '/info') - - self.assertEqual(200, rv.status_code) - self.assertEqual(dict( - api_version='0.5', - haproxy_version='1.4.24-2', - hostname='test-host'), - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('os.path.exists') - @mock.patch('subprocess.check_output') - @mock.patch('octavia.amphorae.backends.agent.api_server.util.' + - 'get_haproxy_pid') - @mock.patch('shutil.rmtree') - @mock.patch('os.remove') - def test_delete_listener(self, mock_remove, mock_rmtree, mock_pid, - mock_check_output, mock_exists): - mock_exists.return_value = False - rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(404, rv.status_code) - self.assertEqual( - {'message': 'Listener Not Found', - 'details': 'No listener with UUID: 123'}, - json.loads(rv.data.decode('utf-8'))) - mock_exists.assert_called_with('/var/lib/octavia/123/haproxy.cfg') - - # service is stopped + no init script - mock_exists.side_effect = [True, False, False] - rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(200, rv.status_code) - self.assertEqual({u'message': u'OK'}, - json.loads(rv.data.decode('utf-8'))) - mock_rmtree.assert_called_with('/var/lib/octavia/123') - mock_exists.assert_called_with('/etc/init.d/haproxy-123') - mock_exists.assert_any_call('/var/lib/octavia/123/123.pid') - - # service is stopped + init script - mock_exists.side_effect = [True, False, True] - rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(200, rv.status_code) - self.assertEqual({u'message': u'OK'}, - json.loads(rv.data.decode('utf-8'))) - mock_remove.assert_called_once_with('/etc/init.d/haproxy-123') - - # service is running + init script - mock_exists.side_effect = [True, True, True, True] - mock_pid.return_value = '456' - rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(200, rv.status_code) - self.assertEqual({u'message': u'OK'}, - json.loads(rv.data.decode('utf-8'))) - mock_pid.assert_called_once_with('123') - mock_check_output.assert_called_once_with( - ['/usr/sbin/service', 'haproxy-123', 'stop'], stderr=-2) - - # service is running + stopping fails - mock_exists.side_effect = [True, True, True] - mock_check_output.side_effect = subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR) - rv = self.app.delete('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(500, rv.status_code) - self.assertEqual( - {'details': 'random error', 'message': 'Error stopping haproxy'}, - json.loads(rv.data.decode('utf-8'))) - # that's the last call before exception - mock_exists.assert_called_with('/proc/456') - - @mock.patch('os.path.exists') - def test_get_haproxy(self, mock_exists): - CONTENT = "bibble\nbibble" - mock_exists.side_effect = [False] - rv = self.app.get('/' + api_server.VERSION + '/listeners/123/haproxy') - self.assertEqual(404, rv.status_code) - - mock_exists.side_effect = [True] - path = util.config_path('123') - self.useFixture(test_utils.OpenFixture(path, CONTENT)) - - rv = self.app.get('/' + api_server.VERSION + - '/listeners/123/haproxy') - self.assertEqual(200, rv.status_code) - self.assertEqual(six.b(CONTENT), rv.data) - self.assertEqual('text/plain; charset=utf-8', - rv.headers['Content-Type']) - - @mock.patch('octavia.amphorae.backends.agent.api_server.util.' - 'get_listeners') - @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' - '_check_listener_status') - @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' - '_parse_haproxy_file') - def test_get_all_listeners(self, mock_parse, mock_status, mock_listener): - # no listeners - mock_listener.side_effect = [[]] - rv = self.app.get('/' + api_server.VERSION + '/listeners') - - self.assertEqual(200, rv.status_code) - self.assertFalse(json.loads(rv.data.decode('utf-8'))) - - # one listener ACTIVE - mock_listener.side_effect = [['123']] - mock_parse.side_effect = [{'mode': 'test'}] - mock_status.side_effect = [consts.ACTIVE] - rv = self.app.get('/' + api_server.VERSION + '/listeners') - - self.assertEqual(200, rv.status_code) - self.assertEqual( - [{'status': consts.ACTIVE, 'type': 'test', 'uuid': '123'}], - json.loads(rv.data.decode('utf-8'))) - - # two listener one ACTIVE, one ERROR - mock_listener.side_effect = [['123', '456']] - mock_parse.side_effect = [{'mode': 'test'}, {'mode': 'http'}] - mock_status.side_effect = [consts.ACTIVE, consts.ERROR] - rv = self.app.get('/' + api_server.VERSION + '/listeners') - - self.assertEqual(200, rv.status_code) - self.assertEqual( - [{'status': consts.ACTIVE, 'type': 'test', 'uuid': '123'}, - {'status': consts.ERROR, 'type': '', 'uuid': '456'}], - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' - '_check_listener_status') - @mock.patch('octavia.amphorae.backends.agent.api_server.listener.Listener.' - '_parse_haproxy_file') - @mock.patch('octavia.amphorae.backends.utils.haproxy_query.HAProxyQuery') - @mock.patch('os.path.exists') - def test_get_listener(self, mock_exists, mock_query, mock_parse, - mock_status): - # Listener not found - mock_exists.side_effect = [False] - rv = self.app.get('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(404, rv.status_code) - self.assertEqual( - {'message': 'Listener Not Found', - 'details': 'No listener with UUID: 123'}, - json.loads(rv.data.decode('utf-8'))) - - # Listener not ACTIVE - mock_parse.side_effect = [dict(mode='test')] - mock_status.side_effect = [consts.ERROR] - mock_exists.side_effect = [True] - rv = self.app.get('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(200, rv.status_code) - self.assertEqual(dict( - status=consts.ERROR, - type='', - uuid='123'), json.loads(rv.data.decode('utf-8'))) - - # Listener ACTIVE - mock_parse.side_effect = [dict(mode='test', stats_socket='blah')] - mock_status.side_effect = [consts.ACTIVE] - mock_exists.side_effect = [True] - mock_pool = mock.Mock() - mock_query.side_effect = [mock_pool] - mock_pool.get_pool_status.side_effect = [ - {'tcp-servers': { - 'status': 'DOWN', - 'uuid': 'tcp-servers', - 'members': [ - {'id-34833': 'DOWN'}, - {'id-34836': 'DOWN'}]}}] - rv = self.app.get('/' + api_server.VERSION + '/listeners/123') - self.assertEqual(200, rv.status_code) - self.assertEqual(dict( - status=consts.ACTIVE, - type='test', - uuid='123', - pools=[dict( - status=consts.DOWN, - uuid='tcp-servers', - members=[ - {u'id-34833': u'DOWN'}, - {u'id-34836': u'DOWN'}])]), - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('os.path.exists') - @mock.patch('os.remove') - def test_delete_cert(self, mock_remove, mock_exists): - mock_exists.side_effect = [False] - rv = self.app.delete('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem') - self.assertEqual(404, rv.status_code) - self.assertEqual(dict( - details='No certificate with filename: test.pem', - message='Certificate Not Found'), - json.loads(rv.data.decode('utf-8'))) - mock_exists.assert_called_once_with( - '/var/lib/octavia/certs/123/test.pem') - - # wrong file name - mock_exists.side_effect = [True] - rv = self.app.put('/' + api_server.VERSION + - '/listeners/123/certificates/test.bla', - data='TestTest') - self.assertEqual(400, rv.status_code) - - mock_exists.side_effect = [True] - rv = self.app.delete('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem') - self.assertEqual(200, rv.status_code) - self.assertEqual(OK, json.loads(rv.data.decode('utf-8'))) - mock_remove.assert_called_once_with( - '/var/lib/octavia/certs/123/test.pem') - - @mock.patch('os.path.exists') - def test_get_certificate_md5(self, mock_exists): - CONTENT = "TestTest" - - mock_exists.side_effect = [False] - rv = self.app.get('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem') - self.assertEqual(404, rv.status_code) - self.assertEqual(dict( - details='No certificate with filename: test.pem', - message='Certificate Not Found'), - json.loads(rv.data.decode('utf-8'))) - mock_exists.assert_called_with('/var/lib/octavia/certs/123/test.pem') - - # wrong file name - mock_exists.side_effect = [True] - rv = self.app.put('/' + api_server.VERSION + - '/listeners/123/certificates/test.bla', - data='TestTest') - self.assertEqual(400, rv.status_code) - - mock_exists.return_value = True - mock_exists.side_effect = None - path = self.test_server._listener._cert_file_path('123', 'test.pem') - self.useFixture(test_utils.OpenFixture(path, CONTENT)) - rv = self.app.get('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem') - self.assertEqual(200, rv.status_code) - self.assertEqual(dict(md5sum=hashlib.md5(six.b(CONTENT)).hexdigest()), - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('os.path.exists') - @mock.patch('os.makedirs') - def test_upload_certificate_md5(self, mock_makedir, mock_exists): - # wrong file name - rv = self.app.put('/' + api_server.VERSION + - '/listeners/123/certificates/test.bla', - data='TestTest') - self.assertEqual(400, rv.status_code) - - mock_exists.return_value = True - path = self.test_server._listener._cert_file_path('123', 'test.pem') - m = self.useFixture(test_utils.OpenFixture(path)).mock_open - - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.put('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem', - data='TestTest') - self.assertEqual(200, rv.status_code) - self.assertEqual(OK, json.loads(rv.data.decode('utf-8'))) - handle = m() - handle.write.assert_called_once_with(six.b('TestTest')) - - mock_exists.return_value = False - m = self.useFixture(test_utils.OpenFixture(path)).mock_open - - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.put('/' + api_server.VERSION + - '/listeners/123/certificates/test.pem', - data='TestTest') - self.assertEqual(200, rv.status_code) - self.assertEqual(OK, json.loads(rv.data.decode('utf-8'))) - handle = m() - handle.write.assert_called_once_with(six.b('TestTest')) - mock_makedir.assert_called_once_with('/var/lib/octavia/certs/123') - - def test_upload_server_certificate(self): - certificate_update.BUFFER = 5 # test the while loop - path = '/etc/octavia/certs/server.pem' - m = self.useFixture(test_utils.OpenFixture(path)).mock_open - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.put('/' + api_server.VERSION + - '/certificate', - data='TestTest') - self.assertEqual(202, rv.status_code) - self.assertEqual(OK, json.loads(rv.data.decode('utf-8'))) - handle = m() - handle.write.assert_any_call(six.b('TestT')) - handle.write.assert_any_call(six.b('est')) - - @mock.patch('netifaces.interfaces') - @mock.patch('netifaces.ifaddresses') - @mock.patch('pyroute2.IPRoute') - @mock.patch('pyroute2.NetNS') - @mock.patch('subprocess.check_output') - @mock.patch('octavia.amphorae.backends.agent.api_server.' - 'plug.Plug._netns_interface_exists') - def test_plug_network(self, mock_int_exists, mock_check_output, mock_netns, - mock_pyroute2, mock_ifaddress, mock_interfaces): - port_info = {'mac_address': '123'} - test_int_num = random.randint(0, 9999) - - mock_int_exists.return_value = False - netns_handle = mock_netns.return_value.__enter__.return_value - netns_handle.get_links.return_value = [0] * test_int_num - - test_int_num = str(test_int_num) - - # No interface at all - mock_interfaces.side_effect = [[]] - rv = self.app.post('/' + api_server.VERSION + "/plug/network", - content_type='application/json', - data=json.dumps(port_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - - # No interface down - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_INET]] - rv = self.app.post('/' + api_server.VERSION + "/plug/network", - content_type='application/json', - data=json.dumps(port_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - mock_ifaddress.assert_called_once_with('blah') - - # One Interface down, Happy Path - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = '/etc/netns/{0}/network/interfaces.d/eth{1}.cfg'.format( - consts.AMPHORA_NAMESPACE, test_int_num) - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - - rv = self.app.post('/' + api_server.VERSION + "/plug/network", - content_type='application/json', - data=json.dumps(port_info)) - self.assertEqual(202, rv.status_code) - - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - - handle = m() - handle.write.assert_any_call( - '\n# Generated by Octavia agent\n' - 'auto eth' + test_int_num + '\n' - 'iface eth' + test_int_num + ' inet dhcp\n' - 'auto eth' + test_int_num + ':0\n' - 'iface eth' + test_int_num + ':0 inet6 auto\n') - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', 'amphora-haproxy', 'ifup', - 'eth' + test_int_num], stderr=-2) - - # same as above but ifup fails - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - mock_check_output.side_effect = [subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR), subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR)] - - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.post('/' + api_server.VERSION + "/plug/network", - content_type='application/json', - data=json.dumps(port_info)) - self.assertEqual(500, rv.status_code) - self.assertEqual( - {'details': RANDOM_ERROR, - 'message': 'Error plugging network'}, - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('netifaces.interfaces') - @mock.patch('netifaces.ifaddresses') - @mock.patch('pyroute2.IPRoute') - @mock.patch('pyroute2.NetNS') - @mock.patch('subprocess.check_output') - def test_plug_network_host_routes(self, mock_check_output, mock_netns, - mock_pyroute2, mock_ifaddress, - mock_interfaces): - SUBNET_CIDR = '192.0.2.0/24' - BROADCAST = '192.0.2.255' - NETMASK = '255.255.255.0' - IP = '192.0.1.5' - MAC = '123' - DEST1 = '198.51.100.0/24' - DEST2 = '203.0.113.0/24' - NEXTHOP = '192.0.2.1' - - netns_handle = mock_netns.return_value.__enter__.return_value - netns_handle.get_links.return_value = [{ - 'attrs': [['IFLA_IFNAME', consts.NETNS_PRIMARY_INTERFACE]]}] - - port_info = {'mac_address': MAC, 'fixed_ips': [ - {'ip_address': IP, 'subnet_cidr': SUBNET_CIDR, - 'host_routes': [{'destination': DEST1, 'nexthop': NEXTHOP}, - {'destination': DEST2, 'nexthop': NEXTHOP}]}]} - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = '/etc/netns/{0}/network/interfaces.d/{1}.cfg'.format( - consts.AMPHORA_NAMESPACE, consts.NETNS_PRIMARY_INTERFACE) - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.post('/' + api_server.VERSION + "/plug/network", - content_type='application/json', - data=json.dumps(port_info)) - self.assertEqual(202, rv.status_code) - - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - - handle = m() - handle.write.assert_any_call( - '\n\n# Generated by Octavia agent\n' - 'auto ' + consts.NETNS_PRIMARY_INTERFACE + - '\niface ' + consts.NETNS_PRIMARY_INTERFACE + - ' inet static\n' + - 'address ' + IP + '\nbroadcast ' + BROADCAST + '\n' + - 'netmask ' + NETMASK + '\n' + - 'up route add -net ' + DEST1 + ' gw ' + NEXTHOP + - ' dev ' + consts.NETNS_PRIMARY_INTERFACE + '\n' - 'down route del -net ' + DEST1 + ' gw ' + NEXTHOP + - ' dev ' + consts.NETNS_PRIMARY_INTERFACE + '\n' - 'up route add -net ' + DEST2 + ' gw ' + NEXTHOP + - ' dev ' + consts.NETNS_PRIMARY_INTERFACE + '\n' - 'down route del -net ' + DEST2 + ' gw ' + NEXTHOP + - ' dev ' + consts.NETNS_PRIMARY_INTERFACE + '\n' - ) - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', consts.AMPHORA_NAMESPACE, - 'ifup', consts.NETNS_PRIMARY_INTERFACE], stderr=-2) - - @mock.patch('pyroute2.NSPopen') - @mock.patch('netifaces.interfaces') - @mock.patch('netifaces.ifaddresses') - @mock.patch('pyroute2.IPRoute') - @mock.patch('pyroute2.netns.create') - @mock.patch('pyroute2.NetNS') - @mock.patch('subprocess.check_output') - @mock.patch('shutil.copytree') - @mock.patch('os.makedirs') - def test_plug_vip4(self, mock_makedirs, mock_copytree, mock_check_output, - mock_netns, mock_netns_create, mock_pyroute2, - mock_ifaddress, mock_interfaces, mock_nspopen): - - subnet_info = { - 'subnet_cidr': '203.0.113.0/24', - 'gateway': '203.0.113.1', - 'mac_address': '123' - } - - # malformed ip - rv = self.app.post('/' + api_server.VERSION + '/plug/vip/error', - data=json.dumps(subnet_info), - content_type='application/json') - self.assertEqual(400, rv.status_code) - - # No subnet info - rv = self.app.post('/' + api_server.VERSION + '/plug/vip/error') - self.assertEqual(400, rv.status_code) - - # No interface at all - mock_interfaces.side_effect = [[]] - rv = self.app.post('/' + api_server.VERSION + "/plug/vip/203.0.113.2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - - # Two interfaces down - mock_interfaces.side_effect = [['blah', 'blah2']] - mock_ifaddress.side_effect = [['blabla'], ['blabla']] - rv = self.app.post('/' + api_server.VERSION + "/plug/vip/203.0.113.2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - - # Happy Path IPv4, with VRRP_IP and host route - full_subnet_info = { - 'subnet_cidr': '203.0.113.0/24', - 'gateway': '203.0.113.1', - 'mac_address': '123', - 'vrrp_ip': '203.0.113.4', - 'host_routes': [{'destination': '203.0.114.0/24', - 'nexthop': '203.0.113.5'}, - {'destination': '203.0.115.0/24', - 'nexthop': '203.0.113.5'}] - } - - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = ('/etc/netns/{netns}/network/interfaces.d/' - '{netns_int}.cfg'.format( - netns=consts.AMPHORA_NAMESPACE, - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/203.0.113.2", - content_type='application/json', - data=json.dumps(full_subnet_info)) - self.assertEqual(202, rv.status_code) - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - - handle = m() - handle.write.assert_any_call( - '\n# Generated by Octavia agent\n' - 'auto {netns_int} {netns_int}:0\n' - 'iface {netns_int} inet static\n' - 'address 203.0.113.4\n' - 'broadcast 203.0.113.255\n' - 'netmask 255.255.255.0\n' - 'gateway 203.0.113.1\n' - 'up route add -net 203.0.114.0/24 gw 203.0.113.5 ' - 'dev {netns_int}\n' - 'down route del -net 203.0.114.0/24 gw 203.0.113.5 ' - 'dev {netns_int}\n' - 'up route add -net 203.0.115.0/24 gw 203.0.113.5 ' - 'dev {netns_int}\n' - 'down route del -net 203.0.115.0/24 gw 203.0.113.5 ' - 'dev {netns_int}\n' - '\n' - 'iface {netns_int}:0 inet static\n' - 'address 203.0.113.2\n' - 'broadcast 203.0.113.255\n' - 'netmask 255.255.255.0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', consts.AMPHORA_NAMESPACE, - 'ifup', '{netns_int}:0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)], stderr=-2) - - # Verify sysctl was loaded - mock_nspopen.assert_called_once_with( - 'amphora-haproxy', ['/sbin/sysctl', '--system'], - stdout=subprocess.PIPE) - - # One Interface down, Happy Path IPv4 - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = ('/etc/netns/{netns}/network/interfaces.d/' - '{netns_int}.cfg'.format( - netns=consts.AMPHORA_NAMESPACE, - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - m = mock.mock_open() - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/203.0.113.2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(202, rv.status_code) - - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - - handle = m() - handle.write.assert_any_call( - '\n# Generated by Octavia agent\n' - 'auto {netns_int} {netns_int}:0\n' - 'iface {netns_int} inet dhcp\n\n' - 'iface {netns_int}:0 inet static\n' - 'address 203.0.113.2\n' - 'broadcast 203.0.113.255\n' - 'netmask 255.255.255.0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', 'amphora-haproxy', 'ifup', - '{netns_int}:0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)], stderr=-2) - - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - mock_check_output.side_effect = [ - 'unplug1', - subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR), subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR)] - - m = mock.mock_open() - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/203.0.113.2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(500, rv.status_code) - self.assertEqual( - {'details': RANDOM_ERROR, - 'message': 'Error plugging VIP'}, - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('pyroute2.NSPopen') - @mock.patch('netifaces.interfaces') - @mock.patch('netifaces.ifaddresses') - @mock.patch('pyroute2.IPRoute') - @mock.patch('pyroute2.netns.create') - @mock.patch('pyroute2.NetNS') - @mock.patch('subprocess.check_output') - @mock.patch('shutil.copytree') - @mock.patch('os.makedirs') - def test_plug_vip6(self, mock_makedirs, mock_copytree, mock_check_output, - mock_netns, mock_netns_create, mock_pyroute2, - mock_ifaddress, mock_interfaces, mock_nspopen): - - subnet_info = { - 'subnet_cidr': '2001:db8::/32', - 'gateway': '2001:db8::1', - 'mac_address': '123' - } - - # malformed ip - rv = self.app.post('/' + api_server.VERSION + '/plug/vip/error', - data=json.dumps(subnet_info), - content_type='application/json') - self.assertEqual(400, rv.status_code) - - # No subnet info - rv = self.app.post('/' + api_server.VERSION + '/plug/vip/error') - self.assertEqual(400, rv.status_code) - - # No interface at all - mock_interfaces.side_effect = [[]] - rv = self.app.post('/' + api_server.VERSION + "/plug/vip/2001:db8::2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - - # Two interfaces down - mock_interfaces.side_effect = [['blah', 'blah2']] - mock_ifaddress.side_effect = [['blabla'], ['blabla']] - rv = self.app.post('/' + api_server.VERSION + "/plug/vip/2001:db8::2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(404, rv.status_code) - self.assertEqual(dict(details="No suitable network interface found"), - json.loads(rv.data.decode('utf-8'))) - - # Happy Path IPv6, with VRRP_IP and host route - full_subnet_info = { - 'subnet_cidr': '2001:db8::/32', - 'gateway': '2001:db8::1', - 'mac_address': '123', - 'vrrp_ip': '2001:db8::4', - 'host_routes': [{'destination': '2001:db9::/32', - 'nexthop': '2001:db8::5'}, - {'destination': '2001:db9::/32', - 'nexthop': '2001:db8::5'}] - } - - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = ('/etc/netns/{netns}/network/interfaces.d/' - '{netns_int}.cfg'.format( - netns=consts.AMPHORA_NAMESPACE, - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - m = self.useFixture(test_utils.OpenFixture(file_name)).mock_open - - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/2001:db8::2", - content_type='application/json', - data=json.dumps(full_subnet_info)) - self.assertEqual(202, rv.status_code) - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - handle = m() - handle.write.assert_any_call( - '\n# Generated by Octavia agent\n' - 'auto {netns_int} {netns_int}:0\n' - 'iface {netns_int} inet6 static\n' - 'address 2001:db8::4\n' - 'broadcast 2001:0db8:ffff:ffff:ffff:ffff:ffff:ffff\n' - 'netmask 32\n' - 'gateway 2001:db8::1\n' - 'up route add -net 2001:db9::/32 gw 2001:db8::5 ' - 'dev {netns_int}\n' - 'down route del -net 2001:db9::/32 gw 2001:db8::5 ' - 'dev {netns_int}\n' - 'up route add -net 2001:db9::/32 gw 2001:db8::5 ' - 'dev {netns_int}\n' - 'down route del -net 2001:db9::/32 gw 2001:db8::5 ' - 'dev {netns_int}\n' - '\n' - 'iface {netns_int}:0 inet6 static\n' - 'address 2001:0db8:0000:0000:0000:0000:0000:0002\n' - 'broadcast 2001:0db8:ffff:ffff:ffff:ffff:ffff:ffff\n' - 'netmask 32'.format(netns_int=consts.NETNS_PRIMARY_INTERFACE)) - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', consts.AMPHORA_NAMESPACE, - 'ifup', '{netns_int}:0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)], stderr=-2) - - # Verify sysctl was loaded - mock_nspopen.assert_called_once_with( - 'amphora-haproxy', ['/sbin/sysctl', '--system'], - stdout=subprocess.PIPE) - - # One Interface down, Happy Path IPv6 - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - file_name = ('/etc/netns/{netns}/network/interfaces.d/' - '{netns_int}.cfg'.format( - netns=consts.AMPHORA_NAMESPACE, - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - m = mock.mock_open() - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/2001:db8::2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(202, rv.status_code) - - mock_open.assert_any_call(file_name, flags, mode) - mock_fdopen.assert_any_call(123, 'w') - - plug_inf_file = '/var/lib/octavia/plugged_interfaces' - flags = os.O_RDWR | os.O_CREAT - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_any_call(plug_inf_file, flags, mode) - mock_fdopen.assert_any_call(123, 'r+') - - handle = m() - handle.write.assert_any_call( - '\n# Generated by Octavia agent\n' - 'auto {netns_int} {netns_int}:0\n' - 'iface {netns_int} inet6 auto\n\n' - 'iface {netns_int}:0 inet6 static\n' - 'address 2001:0db8:0000:0000:0000:0000:0000:0002\n' - 'broadcast 2001:0db8:ffff:ffff:ffff:ffff:ffff:ffff\n' - 'netmask 32'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)) - mock_check_output.assert_called_with( - ['ip', 'netns', 'exec', 'amphora-haproxy', 'ifup', - '{netns_int}:0'.format( - netns_int=consts.NETNS_PRIMARY_INTERFACE)], stderr=-2) - - mock_interfaces.side_effect = [['blah']] - mock_ifaddress.side_effect = [[netifaces.AF_LINK], - {netifaces.AF_LINK: [{'addr': '123'}]}] - mock_check_output.side_effect = [ - 'unplug1', - subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR), subprocess.CalledProcessError( - 7, 'test', RANDOM_ERROR)] - - m = mock.mock_open() - with mock.patch('os.open'), mock.patch.object(os, 'fdopen', m): - rv = self.app.post('/' + api_server.VERSION + - "/plug/vip/2001:db8::2", - content_type='application/json', - data=json.dumps(subnet_info)) - self.assertEqual(500, rv.status_code) - self.assertEqual( - {'details': RANDOM_ERROR, - 'message': 'Error plugging VIP'}, - json.loads(rv.data.decode('utf-8'))) - - @mock.patch('pyroute2.NetNS') - def test_get_interface(self, mock_netns): - - netns_handle = mock_netns.return_value.__enter__.return_value - - interface_res = {'interface': 'eth0'} - - # Happy path - netns_handle.get_addr.return_value = [{ - 'index': 3, 'family': socket.AF_INET, - 'attrs': [['IFA_ADDRESS', '203.0.113.2']]}] - netns_handle.get_links.return_value = [{ - 'attrs': [['IFLA_IFNAME', 'eth0']]}] - rv = self.app.get('/' + api_server.VERSION + '/interface/203.0.113.2', - data=json.dumps(interface_res), - content_type='application/json') - self.assertEqual(200, rv.status_code) - - # Happy path with IPv6 address normalization - netns_handle.get_addr.return_value = [{ - 'index': 3, 'family': socket.AF_INET6, - 'attrs': [['IFA_ADDRESS', - '0000:0000:0000:0000:0000:0000:0000:0001']]}] - netns_handle.get_links.return_value = [{ - 'attrs': [['IFLA_IFNAME', 'eth0']]}] - rv = self.app.get('/' + api_server.VERSION + '/interface/::1', - data=json.dumps(interface_res), - content_type='application/json') - self.assertEqual(200, rv.status_code) - - # Nonexistent interface - rv = self.app.get('/' + api_server.VERSION + '/interface/10.0.0.1', - data=json.dumps(interface_res), - content_type='application/json') - self.assertEqual(404, rv.status_code) - - # Invalid IP address - rv = self.app.get('/' + api_server.VERSION + - '/interface/00:00:00:00:00:00', - data=json.dumps(interface_res), - content_type='application/json') - self.assertEqual(400, rv.status_code) - - @mock.patch('os.path.exists') - @mock.patch('os.makedirs') - @mock.patch('os.rename') - @mock.patch('os.remove') - def test_upload_keepalived_config(self, mock_remove, - mock_rename, mock_makedirs, mock_exists): - - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - - mock_exists.return_value = True - cfg_path = util.keepalived_cfg_path() - m = self.useFixture(test_utils.OpenFixture(cfg_path)).mock_open - - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.put('/' + api_server.VERSION + '/vrrp/upload', - data='test') - - mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - mock_open.assert_called_with(cfg_path, flags, mode) - mock_fdopen(123, 'w') - self.assertEqual(200, rv.status_code) - - mock_exists.return_value = False - script_path = util.keepalived_check_script_path() - m = self.useFixture(test_utils.OpenFixture(script_path)).mock_open - - with mock.patch('os.open') as mock_open, mock.patch.object( - os, 'fdopen', m) as mock_fdopen: - mock_open.return_value = 123 - rv = self.app.put('/' + api_server.VERSION + '/vrrp/upload', - data='test') - mode = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | - stat.S_IROTH | stat.S_IXOTH) - mock_open.assert_called_with(script_path, flags, mode) - mock_fdopen(123, 'w') - self.assertEqual(200, rv.status_code) - - @mock.patch('subprocess.check_output') - def test_manage_service_vrrp(self, mock_check_output): - rv = self.app.put('/' + api_server.VERSION + '/vrrp/start') - self.assertEqual(202, rv.status_code) - - rv = self.app.put('/' + api_server.VERSION + '/vrrp/restart') - self.assertEqual(400, rv.status_code) - - mock_check_output.side_effect = subprocess.CalledProcessError(1, - 'blah!') - - rv = self.app.put('/' + api_server.VERSION + '/vrrp/start') - self.assertEqual(500, rv.status_code) diff --git a/releasenotes/notes/add-systemd-support-5794252f02bce666.yaml b/releasenotes/notes/add-systemd-support-5794252f02bce666.yaml new file mode 100644 index 0000000000..27ca865f3f --- /dev/null +++ b/releasenotes/notes/add-systemd-support-5794252f02bce666.yaml @@ -0,0 +1,8 @@ +--- +features: + - Adds support for amphora images that use systemd. + - Add support for Ubuntu Xenial amphora images. +deprecations: + - The "use_upstart" configuration option is now deprecated + because the amphora agent can now automatically discover + the init system in use in the amphora image.