Add HeatPodLauncher

Add a new subclass, HeatPodLauncher to launching an emphemeral Heat
within a podman pod. The pod contains separate processes for Heat API
and engine, allowing for multiple engine workers (defaults to cpu
logical core count / 2).

The pod makes use of the undercloud's already installed database and
message bus.

Change-Id: Ie8b4a5ca83284f66dceae32c6f2fc99cdc8c9ffb
Signed-off-by: James Slagle <jslagle@redhat.com>
This commit is contained in:
James Slagle
2021-02-08 16:06:54 -05:00
parent 03e587dbca
commit 0f9fbfa58d
5 changed files with 443 additions and 26 deletions

View File

@@ -27,6 +27,9 @@ classifier =
packages = packages =
tripleoclient tripleoclient
data_files =
share/python-tripleoclient/templates = templates/*
[entry_points] [entry_points]
openstack.cli.extension = openstack.cli.extension =
tripleoclient = tripleoclient.plugin tripleoclient = tripleoclient.plugin

View File

@@ -0,0 +1,105 @@
apiVersion: v1
kind: Pod
metadata:
labels:
app: ephemeral-heat
name: ephemeral-heat
spec:
containers:
- command:
- heat-engine
- --config-file
- /etc/heat/heat.conf
env:
- name: PATH
value: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- name: TERM
value: xterm
- name: container
value: oci
- name: LANG
value: en_US.UTF-8
image: {{ engine_image }}
name: engine
resources: {}
securityContext:
allowPrivilegeEscalation: true
capabilities: {}
privileged: false
readOnlyRootFilesystem: false
runAsGroup: 0
runAsUser: 0
seLinuxOptions: {}
volumeMounts:
- mountPath: /var/log/heat
name: heat-log
- mountPath: /etc/heat/heat.conf
name: heat-config
readOnly: true
workingDir: /
- command:
- heat-api
- --config-file
- /etc/heat/heat.conf
ports:
- containerPort: {{ api_port }}
hostPort: {{ api_port }}
hostIP: {{ ctlplane_ip }}
env:
- name: PATH
value: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- name: TERM
value: xterm
- name: container
value: oci
- name: LANG
value: en_US.UTF-8
image: {{ api_image }}
name: api
resources: {}
securityContext:
allowPrivilegeEscalation: true
capabilities: {}
privileged: false
readOnlyRootFilesystem: false
runAsGroup: 0
runAsUser: 0
seLinuxOptions: {}
volumeMounts:
- mountPath: /var/log/heat
name: heat-log
- mountPath: /etc/heat/heat.conf
name: heat-config
readOnly: true
- mountPath: /etc/heat/api-paste.ini
name: heat-api-paste
readOnly: true
- mountPath: /token_file.json
name: heat-token-file
readOnly: true
- mountPath: /etc/heat/noauth_policy.json
name: heat-noauth-policy
readOnly: true
workingDir: /
volumes:
- hostPath:
path: {{ heat_dir}}/log
type: Directory
name: heat-log
- hostPath:
path: {{ install_dir }}/heat.conf
type: File
name: heat-config
- hostPath:
path: {{ heat_dir }}/api-paste.ini
type: File
name: heat-api-paste
- hostPath:
path: {{ heat_dir }}/token_file.json
type: File
name: heat-token-file
- hostPath:
path: {{ policy_file }}
type: File
name: heat-noauth-policy
status: {}

View File

@@ -0,0 +1,43 @@
[DEFAULT]
client_retry_limit=2
convergence_engine = true
debug = true
default_deployment_signal_transport = HEAT_SIGNAL
deferred_auth_method = password
keystone_backend = heat.engine.clients.os.keystone.fake_keystoneclient.FakeKeystoneClient
log_dir = /var/log/heat
max_json_body_size = 8388608
max_nested_stack_depth = 10
max_resources_per_stack=-1
num_engine_workers = {{ num_engine_workers }}
rpc_poll_timeout = 60
rpc_response_timeout = 600
transport_url={{ transport_url }}
[oslo_messaging_notifications]
driver = noop
[oslo_messaging_rabbit]
heatbeat_timeout_threshold=60
[noauth]
token_response = /token_file.json
[heat_api]
bind_host = 0.0.0.0
bind_port = {{ api_port }}
workers = 1
[database]
connection = {{ db_connection }}
[paste_deploy]
api_paste_config = /etc/heat/api-paste.ini
flavor = noauth
[oslo_policy]
policy_file = /etc/heat/noauth_policy.json
[yaql]
limit_iterators=9000
memory_quota=900000

View File

@@ -43,6 +43,14 @@ DEFAULT_HEAT_CONTAINER = ('{}/{}/openstack-heat-all:{}'.format(
DEFAULT_CONTAINER_REGISTRY, DEFAULT_CONTAINER_REGISTRY,
DEFAULT_CONTAINER_NAMESPACE, DEFAULT_CONTAINER_NAMESPACE,
DEFAULT_CONTAINER_TAG)) DEFAULT_CONTAINER_TAG))
DEFAULT_HEAT_API_CONTAINER = ('{}/{}/openstack-heat-api:{}'.format(
DEFAULT_CONTAINER_REGISTRY,
DEFAULT_CONTAINER_NAMESPACE,
DEFAULT_CONTAINER_TAG))
DEFAULT_HEAT_ENGINE_CONTAINER = ('{}/{}/openstack-heat-engine:{}'.format(
DEFAULT_CONTAINER_REGISTRY,
DEFAULT_CONTAINER_NAMESPACE,
DEFAULT_CONTAINER_TAG))
USER_PARAMETERS = 'user-environments/tripleoclient-parameters.yaml' USER_PARAMETERS = 'user-environments/tripleoclient-parameters.yaml'
@@ -98,8 +106,9 @@ VALIDATIONS_LOG_BASEDIR = '/var/log/validations'
DEFAULT_WORK_DIR = os.path.join(os.environ.get('HOME', '~/'), DEFAULT_WORK_DIR = os.path.join(os.environ.get('HOME', '~/'),
'config-download') 'config-download')
TRIPLEO_STATIC_INVENTORY = 'tripleo-ansible-inventory.yaml' DEFAULT_TEMPLATES_DIR = "/usr/share/python-tripleoclient/templates"
TRIPLEO_STATIC_INVENTORY = 'tripleo-ansible-inventory.yaml'
ANSIBLE_INVENTORY = os.path.join(DEFAULT_WORK_DIR, ANSIBLE_INVENTORY = os.path.join(DEFAULT_WORK_DIR,
'{}/', TRIPLEO_STATIC_INVENTORY) '{}/', TRIPLEO_STATIC_INVENTORY)
ANSIBLE_VALIDATION_DIR = ( ANSIBLE_VALIDATION_DIR = (

View File

@@ -17,14 +17,18 @@ import datetime
import grp import grp
import json import json
import logging import logging
import multiprocessing
import os import os
import pwd import pwd
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
import jinja2
from oslo_utils import timeutils from oslo_utils import timeutils
from tripleoclient import constants
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
NEXT_DAY = (timeutils.utcnow() + datetime.timedelta(days=2)).isoformat() NEXT_DAY = (timeutils.utcnow() + datetime.timedelta(days=2)).isoformat()
@@ -111,11 +115,21 @@ class HeatBaseLauncher(object):
# The init function will need permission to touch these files # The init function will need permission to touch these files
# and chown them accordingly for the heat user # and chown them accordingly for the heat user
def __init__(self, api_port, container_image, user='heat', def __init__(
heat_dir='/var/log/heat-launcher', self, api_port=8006,
use_tmp_dir=True): all_container_image=constants.DEFAULT_HEAT_CONTAINER,
api_container_image=constants.DEFAULT_HEAT_API_CONTAINER,
engine_container_image=constants.DEFAULT_HEAT_ENGINE_CONTAINER,
user='heat', heat_dir='/var/log/heat-launcher', use_tmp_dir=True):
self.api_port = api_port self.api_port = api_port
self.heat_dir = heat_dir self.all_container_image = all_container_image
self.api_container_image = api_container_image
self.engine_container_image = engine_container_image
self.heat_dir = os.path.abspath(heat_dir)
self.host = "127.0.0.1"
self.db_dump_path = os.path.join(
self.heat_dir, 'heat-db-dump.sql')
if os.path.isdir(self.heat_dir): if os.path.isdir(self.heat_dir):
# This one may fail but it's just cleanup. # This one may fail but it's just cleanup.
@@ -153,7 +167,8 @@ class HeatBaseLauncher(object):
if retval != 0: if retval != 0:
# It's ok if this fails, it will still work. It just won't # It's ok if this fails, it will still work. It just won't
# be on tmpfs. # be on tmpfs.
log.warning('Unable to mount tmpfs for logs and database %s: %s' % log.warning('Unable to mount tmpfs for logs and '
'database %s: %s' %
(self.heat_dir, cmd_stderr)) (self.heat_dir, cmd_stderr))
self.policy_file = os.path.join(os.path.dirname(__file__), self.policy_file = os.path.join(os.path.dirname(__file__),
@@ -163,7 +178,6 @@ class HeatBaseLauncher(object):
prefix='%s/undercloud_deploy-' % self.heat_dir) prefix='%s/undercloud_deploy-' % self.heat_dir)
else: else:
self.install_dir = self.heat_dir self.install_dir = self.heat_dir
self.container_image = container_image
self.user = user self.user = user
self.sql_db = os.path.join(self.install_dir, 'heat.sqlite') self.sql_db = os.path.join(self.install_dir, 'heat.sqlite')
self.log_file = os.path.join(self.install_dir, 'heat.log') self.log_file = os.path.join(self.install_dir, 'heat.log')
@@ -172,6 +186,7 @@ class HeatBaseLauncher(object):
self.token_file = os.path.join(self.install_dir, 'token_file.json') self.token_file = os.path.join(self.install_dir, 'token_file.json')
self._write_fake_keystone_token(self.api_port, self.token_file) self._write_fake_keystone_token(self.api_port, self.token_file)
self._write_heat_config() self._write_heat_config()
self._write_api_paste_config()
uid = int(self.get_heat_uid()) uid = int(self.get_heat_uid())
gid = int(self.get_heat_gid()) gid = int(self.get_heat_gid())
os.chown(self.install_dir, uid, gid) os.chown(self.install_dir, uid, gid)
@@ -223,9 +238,12 @@ limit_iterators=9000
''' % {'sqlite_db': self.sql_db, 'log_file': self.log_file, ''' % {'sqlite_db': self.sql_db, 'log_file': self.log_file,
'api_port': self.api_port, 'policy_file': self.policy_file, 'api_port': self.api_port, 'policy_file': self.policy_file,
'token_file': self.token_file} 'token_file': self.token_file}
with open(self.config_file, 'w') as temp_file: with open(self.config_file, 'w') as temp_file:
temp_file.write(heat_config) temp_file.write(heat_config)
def _write_api_paste_config(self):
heat_api_paste_config = ''' heat_api_paste_config = '''
[pipeline:heat-api-noauth] [pipeline:heat-api-noauth]
pipeline = faultwrap noauth context versionnegotiation apiv1app pipeline = faultwrap noauth context versionnegotiation apiv1app
@@ -258,27 +276,31 @@ heat.filter_factory = heat.api.openstack:faultwrap_filter
def get_heat_gid(self): def get_heat_gid(self):
return grp.getgrnam(self.user).gr_gid return grp.getgrnam(self.user).gr_gid
def check_database(self):
return True
def check_message_bus(self):
return True
class HeatContainerLauncher(HeatBaseLauncher): class HeatContainerLauncher(HeatBaseLauncher):
def __init__(self, api_port, container_image, user='heat', heat_type = 'container'
heat_dir='/var/log/heat-launcher',
use_tmp_dir=True): def __init__(self, *args, **kwargs):
self.container_image = container_image super(HeatContainerLauncher, self).__init__(*args, **kwargs)
self._fetch_container_image() self._fetch_container_image()
super(HeatContainerLauncher, self).__init__(api_port, container_image, self.host = "127.0.0.1"
user, heat_dir,
use_tmp_dir)
def _fetch_container_image(self): def _fetch_container_image(self):
# force pull of latest container image # force pull of latest container image
cmd = ['podman', 'pull', self.container_image] cmd = ['podman', 'pull', self.all_container_image]
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
try: try:
subprocess.check_output(cmd) subprocess.check_output(cmd)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise Exception('Unable to fetch container image {}.' raise Exception('Unable to fetch container image {}.'
'Error: {}'.format(self.container_image, e)) 'Error: {}'.format(self.all_container_image, e))
def launch_heat(self): def launch_heat(self):
# run the heat-all process # run the heat-all process
@@ -295,7 +317,7 @@ class HeatContainerLauncher(HeatBaseLauncher):
self.install_dir}, self.install_dir},
'--volume', '%(pfile)s:%(pfile)s:ro' % {'pfile': '--volume', '%(pfile)s:%(pfile)s:ro' % {'pfile':
self.policy_file}, self.policy_file},
self.container_image, 'heat-all' self.all_container_image, 'heat-all'
] ]
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
os.execvp('podman', cmd) os.execvp('podman', cmd)
@@ -309,7 +331,7 @@ class HeatContainerLauncher(HeatBaseLauncher):
self.config_file}, self.config_file},
'--volume', '%(inst_tmp)s:%(inst_tmp)s:Z' % {'inst_tmp': '--volume', '%(inst_tmp)s:%(inst_tmp)s:Z' % {'inst_tmp':
self.install_dir}, self.install_dir},
self.container_image, self.all_container_image,
'heat-manage', 'db_sync'] 'heat-manage', 'db_sync']
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
subprocess.check_call(cmd) subprocess.check_call(cmd)
@@ -317,7 +339,7 @@ class HeatContainerLauncher(HeatBaseLauncher):
def get_heat_uid(self): def get_heat_uid(self):
cmd = [ cmd = [
'podman', 'run', '--rm', 'podman', 'run', '--rm',
self.container_image, self.all_container_image,
'getent', 'passwd', self.user 'getent', 'passwd', self.user
] ]
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
@@ -331,7 +353,7 @@ class HeatContainerLauncher(HeatBaseLauncher):
def get_heat_gid(self): def get_heat_gid(self):
cmd = [ cmd = [
'podman', 'run', '--rm', 'podman', 'run', '--rm',
self.container_image, self.all_container_image,
'getent', 'group', self.user 'getent', 'group', self.user
] ]
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
@@ -342,7 +364,7 @@ class HeatContainerLauncher(HeatBaseLauncher):
return result.split(':')[2] return result.split(':')[2]
raise Exception('Could not find heat gid') raise Exception('Could not find heat gid')
def kill_heat(self, pid): def kill_heat(self, pid, backup_db=False):
cmd = ['podman', 'stop', 'heat_all'] cmd = ['podman', 'stop', 'heat_all']
log.debug(' '.join(cmd)) log.debug(' '.join(cmd))
# We don't want to hear from this command.. # We don't want to hear from this command..
@@ -351,10 +373,11 @@ class HeatContainerLauncher(HeatBaseLauncher):
class HeatNativeLauncher(HeatBaseLauncher): class HeatNativeLauncher(HeatBaseLauncher):
def __init__(self, api_port, container_image, user='heat', heat_type = 'native'
heat_dir='/var/log/heat-launcher'):
super(HeatNativeLauncher, self).__init__(api_port, container_image, def __init__(self, *args, **kwargs):
user, heat_dir) super(HeatNativeLauncher, self).__init__(*args, **kwargs)
self.host = "127.0.0.1"
def launch_heat(self): def launch_heat(self):
os.execvp('heat-all', ['heat-all', '--config-file', self.config_file]) os.execvp('heat-all', ['heat-all', '--config-file', self.config_file])
@@ -363,5 +386,239 @@ class HeatNativeLauncher(HeatBaseLauncher):
subprocess.check_call(['heat-manage', '--config-file', subprocess.check_call(['heat-manage', '--config-file',
self.config_file, 'db_sync']) self.config_file, 'db_sync'])
def kill_heat(self, pid): def kill_heat(self, pid, backup_db=False):
os.kill(pid, signal.SIGKILL) os.kill(pid, signal.SIGKILL)
class HeatPodLauncher(HeatContainerLauncher):
heat_type = 'pod'
def __init__(self, *args, **kwargs):
super(HeatPodLauncher, self).__init__(*args, **kwargs)
log_dir = os.path.join(self.heat_dir, 'log')
if not os.path.isdir(log_dir):
os.makedirs(log_dir)
self.host = self._get_ctlplane_ip()
self._chcon()
def _chcon(self):
subprocess.check_call(
['chcon', '-R', '-t', 'container_file_t',
'-l', 's0', self.heat_dir])
def _fetch_container_image(self):
# force pull of latest container image
for image in self.api_container_image, self.engine_container_image:
log.info("Pulling conatiner image {}.".format(image))
cmd = ['sudo', 'podman', 'pull', image]
log.debug(' '.join(cmd))
try:
subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
raise Exception('Unable to fetch container image {}.'
'Error: {}'.format(image, e))
def launch_heat(self):
inspect = subprocess.run([
'sudo', 'podman', 'pod', 'inspect', '--format',
'"{{.State}}"', 'ephemeral-heat'],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
if "Running" in self._decode(inspect.stdout):
log.info("ephemeral-heat pod already running, skipping launch")
return
self._write_heat_pod()
subprocess.check_call([
'sudo', 'podman', 'play', 'kube',
os.path.join(self.heat_dir, 'heat-pod.yaml')
])
def heat_db_sync(self, restore_db=False):
if not self.database_exists():
subprocess.check_call([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysql', '-e', 'create database heat'
])
subprocess.check_call([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysql', '-e',
'create user if not exists '
'\'heat\'@\'%\' identified by \'heat\''
])
subprocess.check_call([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysql', 'heat', '-e',
'grant all privileges on heat.* to \'heat\'@\'%\''
])
cmd = [
'sudo', 'podman', 'run', '--rm',
'--user', 'heat',
'--volume', '%(conf)s:/etc/heat/heat.conf:z' % {'conf':
self.config_file},
'--volume', '%(inst_tmp)s:%(inst_tmp)s:z' % {'inst_tmp':
self.install_dir},
self.api_container_image,
'heat-manage', 'db_sync']
log.debug(' '.join(cmd))
subprocess.check_call(cmd)
if restore_db:
self.do_restore_db()
def do_restore_db(self, db_dump_path=None):
if not db_dump_path:
db_dump_path = self.db_dump_path
subprocess.run([
'sudo', 'podman', 'exec', '-i', '-u', 'root',
'mysql', 'mysql', 'heat'], stdin=open(db_dump_path),
check=True)
def rm_heat(self, backup_db=False):
if self.database_exists():
if backup_db:
try:
with open(self.db_dump_path, 'w') as out:
subprocess.run([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysqldump', 'heat'], stdout=out,
check=True)
subprocess.check_call([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysql', 'heat', '-e',
'drop database heat'])
subprocess.check_call([
'sudo', 'podman', 'exec', '-it', '-u', 'root',
'mysql', 'mysql', 'heat', '-e',
'drop user \'heat\'@\'%\''])
except subprocess.CalledProcessError:
pass
subprocess.call([
'sudo', 'podman', 'pod', 'rm', '-f', 'ephemeral-heat'
])
def stop_heat(self):
subprocess.check_call([
'sudo', 'podman', 'pod', 'stop', 'ephemeral-heat'
])
def check_message_bus(self):
log.info("Checking that message bus (rabbitmq) is up")
try:
subprocess.check_call([
'sudo', 'podman', 'exec', '-u', 'root', 'rabbitmq',
'rabbitmqctl', 'list_queues'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return True
except subprocess.CalledProcessError as cpe:
log.error("The message bus (rabbitmq) does not seem "
"to be available")
log.error(cpe)
raise
def check_database(self):
log.info("Checking that database is up")
try:
subprocess.check_call([
'sudo', 'podman', 'exec', '-u', 'root', 'mysql',
'mysql', '-h', self._get_ctlplane_ip(), '-e',
'show databases;'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return True
except subprocess.CalledProcessError as cpe:
log.error("The database does not seem to be available")
log.error(cpe)
raise
def database_exists(self):
output = subprocess.check_output([
'sudo', 'podman', 'exec', '-it', '-u', 'root', 'mysql',
'mysql', '-e', 'show databases like "heat"'
])
return 'heat' in str(output)
def kill_heat(self, pid, backup_db=False):
subprocess.call([
'sudo', 'podman', 'pod', 'kill', 'ephemeral-heat'
])
def _decode(self, encoded):
if not encoded:
return ""
decoded = encoded.decode('utf-8')
if decoded.endswith('\n'):
decoded = decoded[:-1]
return decoded
def _get_transport_url(self):
user = self._decode(subprocess.check_output(
['sudo', 'hiera', 'rabbitmq::default_user']))
password = self._decode(subprocess.check_output(
['sudo', 'hiera', 'rabbitmq::default_pass']))
fqdn_ctlplane = self._decode(subprocess.check_output(
['sudo', 'hiera', 'fqdn_ctlplane']))
port = self._decode(subprocess.check_output(
['sudo', 'hiera', 'rabbitmq::port']))
transport_url = "rabbit://%s:%s@%s:%s/?ssl=0" % \
(user, password, fqdn_ctlplane, port)
return transport_url
def _get_db_connection(self):
return ('mysql+pymysql://'
'heat:heat@{}/heat?read_default_file='
'/etc/my.cnf.d/tripleo.cnf&read_default_group=tripleo'.format(
self._get_ctlplane_vip()))
def _get_ctlplane_vip(self):
return self._decode(subprocess.check_output(
['sudo', 'hiera', 'controller_virtual_ip']))
def _get_ctlplane_ip(self):
return self._decode(subprocess.check_output(
['sudo', 'hiera', 'ctlplane']))
def _get_num_engine_workers(self):
return int(multiprocessing.cpu_count() / 2)
def _write_heat_config(self):
heat_config_tmpl_path = os.path.join(constants.DEFAULT_TEMPLATES_DIR,
"ephemeral-heat",
"heat.conf.j2")
with open(heat_config_tmpl_path) as tmpl:
heat_config_tmpl = jinja2.Template(tmpl.read())
config_vars = {
"transport_url": self._get_transport_url(),
"db_connection": self._get_db_connection(),
"api_port": self.api_port,
"num_engine_workers": self._get_num_engine_workers(),
}
heat_config = heat_config_tmpl.render(**config_vars)
with open(self.config_file, 'w') as conf:
conf.write(heat_config)
def _write_heat_pod(self):
heat_pod_tmpl_path = os.path.join(constants.DEFAULT_TEMPLATES_DIR,
"ephemeral-heat",
"heat-pod.yaml.j2")
with open(heat_pod_tmpl_path) as tmpl:
heat_pod_tmpl = jinja2.Template(tmpl.read())
pod_vars = {
"install_dir": self.install_dir,
"heat_dir": self.heat_dir,
"policy_file": self.policy_file,
"ctlplane_ip": self.host,
"api_port": self.api_port,
"api_image": self.api_container_image,
"engine_image": self.engine_container_image,
}
heat_pod = heat_pod_tmpl.render(**pod_vars)
heat_pod_path = os.path.join(self.heat_dir, "heat-pod.yaml")
with open(heat_pod_path, 'w') as conf:
conf.write(heat_pod)