charm-glance-simplestreams-.../files/glance_simplestreams_sync.py
Trent Lloyd c9db7fe9a9 Make the GPG validation keyring configurable
Add a new configuration option custom_keyring which allows the user to
provide a custom GPG keyring for validating the simplestreams source,
instead of the default /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg

Change-Id: Ib564a6e259274a076675687a03c0c2030d35e221
2024-10-04 08:20:04 +00:00

648 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
#
# 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 itertools
import logging
import os
import re
import shutil
import six
import subprocess
import sys
import tempfile
import time
import yaml
from keystoneclient import exceptions as keystone_exceptions
from keystoneclient.v2_0 import client as keystone_client
from keystoneclient.v3 import client as keystone_v3_client
if six.PY3:
from urllib import parse as urlparse
else:
import urlparse
def setup_file_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)
log = logging.getLogger()
DEFAULT_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'
ENDPOINT_TYPES = [
'publicURL',
'adminURL',
'internalURL',
]
# 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.safe_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'
if id_conf.get('interface') == 'internal':
host = id_conf['internal_host']
port = id_conf['internal_port']
protocol = id_conf['internal_protocol']
else:
host = id_conf['service_host']
port = id_conf['service_port']
protocol = id_conf['service_protocol']
auth_url = ("{protocol}://{host}:{port}/{version}"
.format(protocol=protocol, host=host,
port=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 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(ksc, 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")
region_name = charm_conf['region']
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=region_name)
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", charm_conf.get('keyring_path', DEFAULT_KEYRING),
"--log-file", SSTREAM_LOG_FILE,
]
if charm_conf['use_swift']:
sync_command += [
'--output-swift',
"{}/".format(SWIFT_DATA_DIR)
]
else:
# For debugging purposes only.
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
]
if charm_conf.get('image_import_conversion', False):
sync_command += [
'--image-import-conversion'
]
if charm_conf.get('set_latest_property', False):
sync_command += [
'--set-latest-property'
]
# --visibility is relatively new so use it only when the
# default value is modified for backward compatibility
if charm_conf['visibility'] != "public":
sync_command += [
"--visibility",
charm_conf['visibility'],
]
sync_command += [
mirror_info['url'],
]
sync_command += mirror_info['item_filters']
# Pass the current process' environment down along with proxy
# settings crafted for sstream-mirror-glance.
sstream_mirror_env = os.environ.copy()
sstream_mirror_env.update(get_sstream_mirror_proxy_env(
ksc, region_name,
charm_conf['ignore_proxy_for_object_store'],
))
log.info("calling sstream-mirror-glance")
log.debug("command: %s", " ".join(sync_command))
log.debug("sstream-mirror environment: %s", sstream_mirror_env)
subprocess.check_call(sync_command, env=sstream_mirror_env)
finally:
shutil.rmtree(tmpdir)
def get_sstream_mirror_proxy_env(ksc, region_name,
ignore_proxy_for_object_store=True):
'''Get proxy settings to be passed to sstreams-mirror-glance.
sstream-mirror-glance has multiple endpoints it needs to connect to:
1. Upstream image mirror (typically, an endpoint in public Internet);
2. Keystone (typically, a directly reachable endpoint);
3. Object storage (Swift) (typically a directly reachable endpoint).
4. Glance (typically, a directly reachable endpoint).
In a restricted environment where proxy settings have to be used for public
Internet connectivity we need to be explicit about hosts for which proxy
settings need to be used by sstream-mirror-glance. This function
dynamically builds a list of endpoints that need to be added to NO_PROXY
and optionally allows not including object storage endpoints into the
NO_PROXY list.
:param ksc: An instance of a Keystone client.
:type ksc: :class: `keystoneclient.v3.client.Client`
:param str region_name: A name of the region to retrieve endpoints for.
:param bool ignore_proxy_for_object_store: Do not include object-store
endpoints into NO_PROXY.
'''
proxy_settings = juju_proxy_settings()
if proxy_settings is None:
proxy_settings = {}
no_proxy_set = set()
else:
no_proxy_set = set(proxy_settings.get('NO_PROXY').split(','))
additional_hosts = set([
urlparse.urlparse(u).hostname for u in itertools.chain(
get_service_endpoints(ksc, 'identity', region_name).values(),
get_service_endpoints(ksc, 'image', region_name).values(),
get_object_store_endpoints(ksc, region_name)
if ignore_proxy_for_object_store else [],
)])
no_proxy = ','.join(no_proxy_set | additional_hosts)
proxy_settings['NO_PROXY'] = no_proxy
proxy_settings['no_proxy'] = no_proxy
return proxy_settings
def update_product_streams_service(ksc, services, region):
"""Updates URLs of product-streams endpoint to point to swift URLs."""
object_store_endpoints = get_service_endpoints(ksc, 'object-store', region)
for endpoint_type in ENDPOINT_TYPES:
object_store_endpoints[endpoint_type] += "/{}".format(SWIFT_DATA_DIR)
publicURL, internalURL, adminURL = (object_store_endpoints[t]
for t in ENDPOINT_TYPES)
# Update the relation to keystone to update the catalog URLs
update_endpoint_urls(
region,
publicURL,
internalURL,
adminURL,
)
def get_object_store_endpoints(ksc, region_name):
"""Get object-store endpoints from the service catalog.
The lack of those endpoints is not fatal for the purposes of this script
since a deployment may not have those in which case glance would still
be populated with images but metadata would not have a place to be stored.
:param ksc: An instance of a Keystone client.
:type ksc: :class: `keystoneclient.v3.client.Client`
:param str region_name: A name of the region to retrieve endpoints for.
"""
endpoints = []
try:
endpoints = list(get_service_endpoints(ksc, 'object-store',
region_name).values())
except keystone_exceptions.EndpointNotFound:
log.debug('object-store endpoints are not present')
return endpoints
def get_service_endpoints(ksc, service_type, region_name):
"""Get endpoints for a given service type from the Keystone catalog.
:param ksc: An instance of a Keystone client.
:type ksc: :class: `keystoneclient.v3.client.Client`
:param str service_type: An endpoint service type to use.
:param str region_name: A name of the region to retrieve endpoints for.
:raises :class: `keystone_exceptions.EndpointNotFound`
"""
try:
catalog = {
endpoint_type: ksc.service_catalog.url_for(
service_type=service_type, endpoint_type=endpoint_type,
region_name=region_name)
for endpoint_type in ['publicURL', 'internalURL', 'adminURL']}
except keystone_exceptions.EndpointNotFound:
# EndpointNotFound is raised for the case where a service does not
# exist as well as for the case where the service exists but not
# endpoints.
log.error('could not retrieve any {} endpoints'.format(service_type))
raise
return catalog
def juju_proxy_settings():
"""Get proxy settings from Juju environment.
Get charm proxy settings from environment variables that correspond to
juju-http-proxy, juju-https-proxy juju-no-proxy (available as of 2.4.2, see
lp:1782236) or the legacy unprefixed settings.
:rtype: None | dict[str, str]
"""
# Get proxy settings from the environment variables set by Juju.
juju_settings = {
m.groupdict()['var']: m.groupdict()['val']
for m in re.finditer(
'^((JUJU_CHARM_)?(?P<var>(HTTP|HTTPS|NO)_PROXY))=(?P<val>.*)$',
juju_exec_cmd(['env']), re.MULTILINE)
}
proxy_settings = {}
for var in ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY']:
var_val = juju_settings.get(var)
if var_val:
proxy_settings[var] = var_val
proxy_settings[var.lower()] = var_val
return proxy_settings if proxy_settings else None
def juju_exec_cmd(cmd):
'''Execute the passed commands under the local unit context if required'''
# NOTE: determine whether juju-exec is actually required
# supporting execution via actions.
if not os.environ.get('JUJU_CONTEXT_ID'):
if os.path.exists('/usr/bin/juju-exec'):
juju_exec = 'juju-exec'
else:
juju_exec = 'juju-run'
id_conf, _ = get_conf()
unit_name = id_conf['unit_name']
_cmd = [juju_exec, 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_exec_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_exec_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_exec_cmd(_cmd)
def cleanup():
try:
os.unlink(SYNC_RUNNING_FLAG_FILE_NAME)
except OSError as e:
if e.errno != 2:
raise e
def set_active_status(is_object_store_present_and_used):
"""Get object-store endpoints from the service catalog.
The lack of those endpoints is not fatal for the purposes of this script
since a deployment may not have those in which case glance would still
be populated with images but metadata would not have a place to be stored.
"""
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.
if is_object_store_present_and_used:
status_set('active', 'Unit is ready (Glance sync completed at {},'
' metadata uploaded to object store)'.format(ts))
else:
status_set('active', 'Unit is ready (Glance sync completed at {},'
' metadata not uploaded - object-store usage disabled)'
''.format(ts))
def assess_object_store_state(object_store_exists, object_store_requested):
"""Decide whether object store"""
if object_store_requested and not object_store_exists:
# If use_swift is set, we need to wait for swift to become
# available.
msg = ('Swift usage has been requested but'
' its endpoints are not yet in the catalog')
status_set('maintenance', msg)
log.info(msg)
return False
return True
def is_object_store_present(ksc, region_name):
"""Checks whether object store is present (service & endpoints)."""
return len(get_object_store_endpoints(ksc, region_name)) > 0
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(2)
returncode = 0
atexit.register(cleanup)
lockfile.write(str(os.getpid()))
id_conf, charm_conf = get_conf()
set_openstack_env(id_conf, charm_conf)
region_name = charm_conf['region']
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
object_store_present = is_object_store_present(ksc, region_name)
use_swift = charm_conf['use_swift']
log.info("ps_service_exists={}, charm_conf['use_swift']={}"
", object_store_present={}".format(ps_service_exists,
use_swift,
object_store_present))
try:
if not assess_object_store_state(object_store_present, use_swift):
return
is_object_store_present_and_used = use_swift and object_store_present
if ps_service_exists and is_object_store_present_and_used:
log.info("Updating product streams service.")
update_product_streams_service(ksc, services, region_name)
else:
log.info("Not updating product streams service.")
log.info("Beginning image sync")
status_set('maintenance', 'Synchronising images')
do_sync(ksc, charm_conf)
set_active_status(is_object_store_present_and_used)
# 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.")
returncode = os.EX_UNAVAILABLE
except subprocess.CalledProcessError as e:
returncode = e.returncode
log.exception("Exception during syncing:")
status_set('blocked', 'Image sync failed, retrying soon.')
log.info("sync done.")
return returncode
if __name__ == "__main__":
setup_file_logging()
sys.exit(main())