charm-glance-simplestreams-.../files/glance_simplestreams_sync.py

459 lines
16 KiB
Python
Executable File

#!/usr/bin/env python
#
# Copyright 2014 Canonical Ltd.
#
# This file is part of the glance-simplestreams sync charm.
# The glance-simplestreams sync charm is free software: you can
# redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation, either
# version 3 of the License, or (at your option) any later version.
#
# The charm is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this charm. If not, see <http://www.gnu.org/licenses/>.
# This script runs as a cron job installed by the
# glance-simplestreams-sync juju charm. It reads config files that
# are written by the hooks of that charm based on its config and
# juju relation to keystone. However, it does not execute in a
# juju hook context itself.
import atexit
import base64
import copy
import fcntl
import logging
import os
import shutil
import six
import sys
import subprocess
import tempfile
import time
import yaml
from keystoneclient.v2_0 import client as keystone_client
from keystoneclient.v3 import client as keystone_v3_client
from keystoneclient import exceptions as keystone_exceptions
def setup_logging():
logfilename = '/var/log/glance-simplestreams-sync.log'
if not os.path.exists(logfilename):
open(logfilename, 'a').close()
os.chmod(logfilename, 0o640)
h = logging.FileHandler(logfilename)
h.setFormatter(logging.Formatter(
'%(levelname)-9s * %(asctime)s [PID:%(process)d] * %(name)s * '
'%(message)s',
datefmt='%m-%d %H:%M:%S'))
logger = logging.getLogger()
logger.setLevel('DEBUG')
logger.addHandler(h)
return logger
log = setup_logging()
KEYRING = '/usr/share/keyrings/ubuntu-cloudimage-keyring.gpg'
CONF_FILE_DIR = '/etc/glance-simplestreams-sync'
PID_FILE_DIR = '/var/run'
CHARM_CONF_FILE_NAME = os.path.join(CONF_FILE_DIR, 'mirrors.yaml')
ID_CONF_FILE_NAME = os.path.join(CONF_FILE_DIR, 'identity.yaml')
SYNC_RUNNING_FLAG_FILE_NAME = os.path.join(PID_FILE_DIR,
'glance-simplestreams-sync.pid')
# juju looks in simplestreams/data/* in swift to figure out which
# images to deploy, so this path isn't really configurable even though
# it is.
SWIFT_DATA_DIR = 'simplestreams/data/'
# When running local apache for product-streams use path to place indexes.
APACHE_DATA_DIR = '/var/www/html'
PRODUCT_STREAMS_SERVICE_NAME = 'image-stream'
PRODUCT_STREAMS_SERVICE_TYPE = 'product-streams'
PRODUCT_STREAMS_SERVICE_DESC = 'Ubuntu Product Streams'
CRON_POLL_FILENAME = '/etc/cron.d/glance_simplestreams_sync_fastpoll'
SSTREAM_SNAP_COMMON = '/var/snap/simplestreams/common'
SSTREAM_LOG_FILE = os.path.join(SSTREAM_SNAP_COMMON,
'sstream-mirror-glance.log')
CACERT_FILE = os.path.join(SSTREAM_SNAP_COMMON, 'cacert.pem')
SYSTEM_CACERT_FILE = '/etc/ssl/certs/ca-certificates.crt'
# TODOs:
# - allow people to specify their own policy, since they can specify
# their own mirrors.
# - potentially allow people to specify backup mirrors?
# - debug keyring support
# - figure out what content_id is and whether we should allow users to
# set it
def read_conf(filename):
with open(filename) as f:
confobj = yaml.load(f)
return confobj
def redact_keys(data_dict, key_list=None):
"""Return a dict with top-level keys having redacted values."""
if not key_list:
key_list = [
'admin',
'password',
'rabbit_password',
'admin_password',
]
_data = copy.deepcopy(data_dict)
for _key in key_list:
if _key in _data.keys():
_data[_key] = '<redacted>'
return _data
def get_conf():
conf_files = [ID_CONF_FILE_NAME, CHARM_CONF_FILE_NAME]
for conf_file_name in conf_files:
if not os.path.exists(conf_file_name):
log.info("{} does not exist, exiting.".format(conf_file_name))
sys.exit(1)
try:
id_conf = read_conf(ID_CONF_FILE_NAME)
except Exception as e:
msg = ("Error in {} configuration file."
"Check juju config values for errors."
"Exception: {}").format(ID_CONF_FILE_NAME, e)
status_set('blocked', msg)
log.info(msg)
sys.exit(1)
if None in id_conf.values():
log.info("Configuration value missing in {}:\n"
"{}".format(ID_CONF_FILE_NAME, redact_keys(id_conf)))
sys.exit(1)
try:
charm_conf = read_conf(CHARM_CONF_FILE_NAME)
except Exception as e:
charm_conf = {}
msg = ("Error in {} configuration file. "
"Check juju config values for errors"
"Exception: {}").format(ID_CONF_FILE_NAME, e)
status_set('blocked', msg)
log.info(msg)
sys.exit(1)
if None in charm_conf.values():
log.info("Configuration value missing in {}:\n"
"{}".format(CHARM_CONF_FILE_NAME, redact_keys(charm_conf)))
sys.exit(1)
return id_conf, charm_conf
def get_keystone_client(api_version):
if api_version == 3:
ksc_vars = dict(
auth_url=os.environ['OS_AUTH_URL'],
username=os.environ['OS_USERNAME'],
password=os.environ['OS_PASSWORD'],
user_domain_name=os.environ['OS_USER_DOMAIN_NAME'],
project_domain_name=os.environ['OS_PROJECT_DOMAIN_NAME'],
project_name=os.environ['OS_PROJECT_NAME'],
project_id=os.environ['OS_PROJECT_ID'])
ksc_class = keystone_v3_client.Client
else:
ksc_vars = dict(
username=os.environ['OS_USERNAME'],
password=os.environ['OS_PASSWORD'],
tenant_id=os.environ['OS_TENANT_ID'],
tenant_name=os.environ['OS_TENANT_NAME'],
auth_url=os.environ['OS_AUTH_URL'])
ksc_class = keystone_client.Client
os_cacert = os.environ.get('OS_CACERT', None)
if (os.environ['OS_AUTH_URL'].startswith('https') and
os_cacert is not None):
ksc_vars['cacert'] = os_cacert
return ksc_class(**ksc_vars)
def set_openstack_env(id_conf, charm_conf):
version = 'v3' if str(id_conf['api_version']).startswith('3') else 'v2.0'
auth_url = ("{protocol}://{host}:{port}/{version}"
.format(protocol=id_conf['service_protocol'],
host=id_conf['service_host'],
port=id_conf['service_port'],
version=version))
os.environ['OS_AUTH_URL'] = auth_url
os.environ['OS_USERNAME'] = id_conf['admin_user']
os.environ['OS_PASSWORD'] = id_conf['admin_password']
os.environ['OS_REGION_NAME'] = charm_conf['region']
ssl_ca = id_conf.get('ssl_ca', None)
if id_conf['service_protocol'] == 'https' and ssl_ca is not None:
os.environ['OS_CACERT'] = CACERT_FILE
with open(CACERT_FILE, "wb") as f:
f.write(base64.b64decode(ssl_ca))
if version == 'v3':
# Keystone charm puts all service users in the default domain.
# Even so, it would be better if keystone passed this information
# down the relation.
os.environ['OS_USER_DOMAIN_NAME'] = id_conf['admin_domain_name']
os.environ['OS_PROJECT_ID'] = id_conf['admin_tenant_id']
os.environ['OS_PROJECT_NAME'] = id_conf['admin_tenant_name']
os.environ['OS_PROJECT_DOMAIN_NAME'] = id_conf['admin_domain_name']
if 'cacert' in id_conf.keys():
os.environ['OS_CACERT'] = id_conf['cacert']
if 'interface' in id_conf.keys():
os.environ['OS_INTERFACE'] = id_conf['interface']
os.environ['OS_ENDPOINT_TYPE'] = id_conf['interface']
else:
os.environ['OS_TENANT_ID'] = id_conf['admin_tenant_id']
os.environ['OS_TENANT_NAME'] = id_conf['admin_tenant_name']
def do_sync(charm_conf):
# NOTE(beisner): the user_agent variable was an unused assignment (lint).
# It may be worth re-visiting its usage, intent and benefit with the
# UrlMirrorReader call below at some point. Leaving it disabled for now,
# and not assigning it since it is not currently utilized.
# user_agent = charm_conf.get("user_agent")
for mirror_info in charm_conf['mirror_list']:
# NOTE: output directory must be under HOME
# or snap cannot access it for stream files
tmpdir = tempfile.mkdtemp(dir=os.environ['HOME'])
try:
log.info("Configuring sync for url {}".format(mirror_info))
content_id = charm_conf['content_id_template'].format(
region=charm_conf['region'])
sync_command = [
"/snap/bin/simplestreams.sstream-mirror-glance",
"-vv",
"--keep",
"--max", str(mirror_info['max']),
"--content-id", content_id,
"--cloud-name", charm_conf['cloud_name'],
"--path", mirror_info['path'],
"--name-prefix", charm_conf['name_prefix'],
"--keyring", KEYRING,
"--log-file", SSTREAM_LOG_FILE,
]
if charm_conf['use_swift']:
sync_command += [
'--output-swift',
SWIFT_DATA_DIR
]
else:
sync_command += [
"--output-dir",
tmpdir
]
if charm_conf.get('hypervisor_mapping', False):
sync_command += [
'--hypervisor-mapping'
]
if charm_conf.get('custom_properties'):
custom_properties = charm_conf.get('custom_properties').split()
for custom_property in custom_properties:
sync_command += [
'--custom-property',
custom_property
]
sync_command += [
mirror_info['url'],
]
sync_command += mirror_info['item_filters']
log.info("calling sstream-mirror-glance")
log.debug("command: {}".format(" ".join(sync_command)))
subprocess.check_call(sync_command)
if not charm_conf['use_swift']:
# Sync output directory to APACHE_DATA_DIR
subprocess.check_call([
'rsync', '-avz',
os.path.join(tmpdir, charm_conf['region'], 'streams'),
APACHE_DATA_DIR
])
finally:
shutil.rmtree(tmpdir)
def update_product_streams_service(ksc, services, region):
"""
Updates URLs of product-streams endpoint to point to swift URLs.
"""
try:
catalog = {
endpoint_type: ksc.service_catalog.url_for(
service_type='object-store', endpoint_type=endpoint_type)
for endpoint_type in ['publicURL', 'internalURL', 'adminURL']}
except keystone_exceptions.EndpointNotFound as e:
log.warning("could not retrieve swift endpoint, not updating "
"product-streams endpoint: {}".format(e))
raise
for endpoint_type in ['publicURL', 'internalURL']:
catalog[endpoint_type] += "/{}".format(SWIFT_DATA_DIR)
# Update the relation to keystone to update the catalog URLs
update_endpoint_urls(region, catalog['publicURL'],
catalog['adminURL'],
catalog['internalURL'])
def juju_run_cmd(cmd):
'''Execute the passed commands under the local unit context if required'''
# NOTE: determine whether juju-run is actually required
# supporting execution via actions.
if not os.environ.get('JUJU_CONTEXT_ID'):
id_conf, _ = get_conf()
unit_name = id_conf['unit_name']
_cmd = ['juju-run', unit_name, ' '.join(cmd)]
else:
_cmd = cmd
log.info("Executing command: {}".format(_cmd))
out = subprocess.check_output(_cmd)
if six.PY3:
out = out.decode('utf-8')
return out
def status_set(status, message):
try:
# NOTE: format of message is different for out of
# context execution.
if not os.environ.get('JUJU_CONTEXT_ID'):
juju_run_cmd(['status-set', status,
'"{}"'.format(message)])
else:
subprocess.check_output([
'status-set',
status,
message
])
except subprocess.CalledProcessError:
log.info(message)
def update_endpoint_urls(region, publicurl, adminurl, internalurl):
# Notify keystone via the identity service relation about
# any endpoint changes.
for rid in juju_run_cmd(['relation-ids', 'identity-service']).split():
log.info("Updating relation data for: {}".format(rid))
_cmd = ['relation-set', '-r', rid]
relation_data = {
'service': 'image-stream',
'region': region,
'public_url': publicurl,
'admin_url': adminurl,
'internal_url': internalurl
}
for k, v in relation_data.items():
_cmd.append('{}={}'.format(k, v))
juju_run_cmd(_cmd)
def cleanup():
try:
os.unlink(SYNC_RUNNING_FLAG_FILE_NAME)
except OSError as e:
if e.errno != 2:
raise e
def main():
log.info("glance-simplestreams-sync started.")
lockfile = open(SYNC_RUNNING_FLAG_FILE_NAME, 'w')
try:
fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
log.info("{} is locked, exiting".format(SYNC_RUNNING_FLAG_FILE_NAME))
sys.exit(0)
atexit.register(cleanup)
lockfile.write(str(os.getpid()))
id_conf, charm_conf = get_conf()
set_openstack_env(id_conf, charm_conf)
ksc = get_keystone_client(id_conf['api_version'])
services = [s._info for s in ksc.services.list()]
servicenames = [s['name'] for s in services]
ps_service_exists = PRODUCT_STREAMS_SERVICE_NAME in servicenames
swift_exists = 'swift' in servicenames
log.info("ps_service_exists={}, charm_conf['use_swift']={}"
", swift_exists={}".format(ps_service_exists,
charm_conf['use_swift'],
swift_exists))
try:
if not swift_exists and charm_conf['use_swift']:
# If use_swift is set, we need to wait for swift to become
# available.
log.info("Swift not yet ready.")
return
if ps_service_exists and charm_conf['use_swift'] and swift_exists:
log.info("Updating product streams service.")
update_product_streams_service(ksc, services, charm_conf['region'])
else:
log.info("Not updating product streams service.")
log.info("Beginning image sync")
status_set('maintenance', 'Synchronising images')
do_sync(charm_conf)
ts = time.strftime("%x %X")
# "Unit is ready" is one of approved message prefixes
# Prefix the message with it will help zaza to understand the status.
status_set('active', "Unit is ready (Sync completed at {})".format(ts))
# If this is an initial per-minute sync attempt, delete it on success.
if os.path.exists(CRON_POLL_FILENAME):
os.unlink(CRON_POLL_FILENAME)
log.info(
"Initial sync attempt done: every-minute cronjob removed.")
except keystone_exceptions.EndpointNotFound as e:
# matching string "{PublicURL} endpoint for {type}{region} not
# found". where {type} is 'image' and {region} is potentially
# not empty so we only match on this substring:
if 'endpoint for image' in e.message:
log.info("Glance endpoint not found, will continue polling.")
except Exception:
log.exception("Exception during syncing:")
status_set('blocked', 'Image sync failed, retrying soon.')
log.info("sync done.")
if __name__ == "__main__":
main()