|
from argparse import Namespace |
|
from collections.abc import Iterable |
|
from datetime import datetime, timedelta |
|
from hashlib import sha256 |
|
from logging import Logger, getLogger |
|
from os import getenv |
|
from pathlib import Path |
|
from re import sub |
|
|
|
from docker import DockerClient |
|
from docker.errors import APIError |
|
from pytz import UTC |
|
|
|
from runner.compose import ( |
|
ComposeSpec, |
|
Variable, |
|
login, |
|
logout, |
|
normalize_spec, |
|
reconcile_schema, |
|
stack_deploy, |
|
) |
|
|
|
logger = getLogger(__name__) |
|
|
|
|
|
def main(settings: Namespace): |
|
runner_version = getenv("__DEPLOYMENT_RUNNER_VERSION", "1.0.0") |
|
client = DockerClient( |
|
base_url=settings.docker_host, |
|
version=settings.docker_api_version or "auto", |
|
user_agent=f"matchory-deployment-runner/{runner_version} (linux-shell)", |
|
) |
|
|
|
login(client, settings) |
|
|
|
compose_spec = normalize_spec(settings) |
|
|
|
secrets = compose_spec.get("secrets") or {} |
|
for name, entry in secrets.items(): |
|
secrets[name] = _process_variable(name, entry, settings) |
|
|
|
configs = compose_spec.get("configs") or {} |
|
for name, entry in configs.items(): |
|
configs[name] = _process_variable(name, entry, settings) |
|
|
|
compose_spec = reconcile_schema(compose_spec) |
|
|
|
stack_deploy(settings, compose_spec) |
|
prune_variables(compose_spec, client, settings) |
|
|
|
logout(client, settings) |
|
|
|
|
|
def _process_variable(name: str, variable: Variable, settings: Namespace) -> Variable: |
|
""" |
|
Process a variable. |
|
|
|
:param name: Name of the variable. |
|
:param variable: Variable metadata as defined in the compose spec. |
|
:param settings: Application settings. |
|
:return: Modified variable metadata. |
|
""" |
|
variant_upper = name.upper() |
|
variant_prefix = f"{settings.env_var_prefix}_{variant_upper}" |
|
env_variable = ( |
|
getenv(variable["environment"]) |
|
if "environment" in variable |
|
else getenv(variant_upper, getenv(variant_prefix)) |
|
) |
|
path = Path(variable["file"]) if "file" in variable else None |
|
|
|
# Make sure we retain the ability to add secrets and configs from local files, not |
|
# just environment variables. If the file specified in the variable exists, we |
|
# assume it takes precedence over any environment variable with the same name. |
|
if path: |
|
if not path.exists(): |
|
logger.debug("Expanding %s to %s", name, path) |
|
|
|
if env_variable is None: |
|
raise VariableNotDefinedError(name, (variant_upper, variant_prefix)) |
|
|
|
path.write_text(env_variable.strip(), encoding="utf-8") |
|
else: |
|
logger.info( |
|
"Skipping secret expansion for %s: Existing file takes precedence " |
|
"over variable defined in environment", |
|
name, |
|
) |
|
else: |
|
if not env_variable: |
|
raise VariableNotDefinedError(name, (variable["environment"])) |
|
|
|
# Calculate the hash of the variable file: It will stay the same for subsequent |
|
# deployments if the actual value didn't change between them, which means we don't |
|
# have to invalidate a secret or config pointlessly. |
|
payload_hash = sha256( |
|
path.read_bytes() if path else env_variable.encode("utf-8"), |
|
).hexdigest()[:7] |
|
|
|
if "labels" not in variable: |
|
variable["labels"] = {} |
|
|
|
# By storing these values as labels, we can query for matching variables later on: |
|
# This enables us to prune outdated versions automatically. |
|
variable["labels"]["com.matchory.service"] = settings.service |
|
variable["labels"]["com.matchory.version"] = settings.version |
|
variable["labels"]["com.matchory.hash"] = payload_hash |
|
|
|
# Again, compatibility with legacy deployment runner versions |
|
if variable["name"].startswith(settings.service): |
|
variable["name"] = sub( |
|
pattern=rf"^{settings.service}[_-]", |
|
repl="", |
|
string=variable["name"], |
|
) |
|
|
|
if variable["name"].endswith(settings.version): |
|
variable["name"] = sub( |
|
pattern=rf"[_-]{settings.version}$", |
|
repl="", |
|
string=variable["name"], |
|
) |
|
|
|
variable["name"] = f"{settings.service}-{variable['name']}-{payload_hash}" |
|
|
|
return variable |
|
|
|
|
|
def prune_variables( |
|
compose_spec: ComposeSpec, |
|
client: DockerClient, |
|
settings: Namespace, |
|
): |
|
prune_logger = logger.getChild("pruning") |
|
_prune_secrets(compose_spec, client, prune_logger, settings) |
|
_prune_configs(compose_spec, client, prune_logger, settings) |
|
|
|
|
|
def _prune_secrets( |
|
compose_spec: ComposeSpec, |
|
client: DockerClient, |
|
prune_logger: Logger, |
|
settings: Namespace, |
|
): |
|
prune_logger.debug("Pruning secrets for service %s", settings.service) |
|
|
|
spec_secrets = ( |
|
[secret["name"] for secret in compose_spec["secrets"].values()] |
|
if "secrets" in compose_spec |
|
else [] |
|
) |
|
secrets = client.secrets.list( |
|
filters={ |
|
"label": f"com.matchory.service={settings.service}", |
|
}, |
|
) |
|
|
|
if len(secrets) > 0: |
|
prune_logger.debug( |
|
"Checking %d secret(s) for service %s", |
|
len(secrets), |
|
settings.service, |
|
) |
|
|
|
for i, secret in enumerate(secrets): |
|
prune_logger.debug( |
|
"Checking secret %d/%d: %s", |
|
i + 1, |
|
len(secrets), |
|
secret.name, |
|
) |
|
|
|
if "com.matchory.hash" not in secret.attrs["Spec"]["Labels"]: |
|
prune_logger.warning( |
|
"Found invalid secret '%s': Missing hash label", |
|
secret.name, |
|
) |
|
secret.remove() |
|
|
|
continue |
|
|
|
spec_name = sub( |
|
pattern=rf"^{settings.service}[_-](.+)[_-].[^_-]+$", |
|
repl=r"\1", |
|
string=secret.name, |
|
) |
|
|
|
if secret.name not in spec_secrets: |
|
secret_hash = secret.attrs["Spec"]["Labels"]["com.matchory.hash"] |
|
|
|
prune_logger.debug( |
|
"Pruning outdated version %s of secret %s: %s", |
|
secret_hash, |
|
spec_name, |
|
secret.name, |
|
) |
|
secret.remove() |
|
|
|
created_at = parse_date_string(secret.attrs["CreatedAt"]) |
|
delta = timedelta(days=30) |
|
|
|
if created_at < datetime.now(tz=UTC) - delta: |
|
prune_logger.warning( |
|
"Secret '%s' has been in use for more than 30 days and should " |
|
"be rotated!", |
|
spec_name, |
|
) |
|
|
|
secret_names = [secret.name for secret in secrets] |
|
|
|
legacy_secrets = [ |
|
secret |
|
for secret in client.secrets.list( |
|
filters={ |
|
"name": settings.service, |
|
}, |
|
) |
|
if secret.name not in secret_names |
|
] |
|
|
|
if len(legacy_secrets) > 0: |
|
prune_logger.debug( |
|
"Pruning %d legacy secret(s) for service %s", |
|
len(legacy_secrets), |
|
settings.service, |
|
) |
|
|
|
for secret in legacy_secrets: |
|
prune_logger.info("Pruning legacy secret %s", secret.name) |
|
try: |
|
secret.remove() |
|
except APIError: |
|
prune_logger.exception( |
|
"Failed to prune legacy secret %s", |
|
secret.name, |
|
) |
|
|
|
|
|
def _prune_configs( |
|
compose_spec: ComposeSpec, |
|
client: DockerClient, |
|
prune_logger: Logger, |
|
settings: Namespace, |
|
): |
|
prune_logger.debug("Pruning configs for service %s", settings.service) |
|
|
|
spec_configs = ( |
|
[config["name"] for config in compose_spec["configs"].values()] |
|
if "configs" in compose_spec |
|
else [] |
|
) |
|
configs = client.configs.list( |
|
filters={ |
|
"label": f"com.matchory.service={settings.service}", |
|
}, |
|
) |
|
|
|
if len(configs) > 0: |
|
prune_logger.debug( |
|
"Checking %d config(s) for service %s", |
|
len(configs), |
|
settings.service, |
|
) |
|
|
|
for i, config in enumerate(configs): |
|
prune_logger.debug( |
|
"Checking config %d/%d: %s", |
|
i + 1, |
|
len(configs), |
|
config.name, |
|
) |
|
|
|
if "com.matchory.hash" not in config.attrs["Spec"]["Labels"]: |
|
prune_logger.warning( |
|
"Found invalid config '%s': Missing hash label", |
|
config.name, |
|
) |
|
config.remove() |
|
|
|
continue |
|
|
|
spec_name = sub( |
|
pattern=rf"^{settings.service}[_-](.+)[_-].[^_-]+$", |
|
repl=r"\1", |
|
string=config.name, |
|
) |
|
|
|
if config.name not in spec_configs: |
|
config_hash = config.attrs["Spec"]["Labels"]["com.matchory.hash"] |
|
|
|
prune_logger.debug( |
|
"Pruning outdated version %s of config %s: %s", |
|
config_hash, |
|
spec_name, |
|
config.name, |
|
) |
|
config.remove() |
|
|
|
created_at = parse_date_string(config.attrs["CreatedAt"]) |
|
delta = timedelta(days=30) |
|
|
|
if created_at < datetime.now(tz=UTC) - delta: |
|
prune_logger.warning( |
|
"Secret '%s' has been in use for more than 30 days and should " |
|
"be rotated!", |
|
spec_name, |
|
) |
|
|
|
config_names = [config.name for config in configs] |
|
legacy_configs = [ |
|
config |
|
for config in client.configs.list( |
|
filters={ |
|
"name": settings.service, |
|
}, |
|
) |
|
if config.name not in config_names |
|
] |
|
|
|
if len(legacy_configs) > 0: |
|
prune_logger.debug( |
|
"Pruning %d legacy config(s) for service %s", |
|
len(legacy_configs), |
|
settings.service, |
|
) |
|
|
|
for config in legacy_configs: |
|
prune_logger.info("Pruning legacy config %s", config.name) |
|
|
|
try: |
|
config.remove() |
|
except APIError: |
|
prune_logger.exception( |
|
"Failed to prune legacy config %s", |
|
config.name, |
|
) |
|
|
|
|
|
def parse_date_string(date_string: str) -> datetime: |
|
""" |
|
Parse a date string provided by Docker Swarm. |
|
|
|
:param date_string: Date string to parse. |
|
:return: Parsed datetime. |
|
""" |
|
date, _, microseconds = date_string.partition(".") |
|
date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S").astimezone(tz=UTC) |
|
microseconds = int(microseconds.rstrip("Z"), 10) |
|
|
|
return date + timedelta(microseconds=microseconds) |
|
|
|
|
|
class VariableNotDefinedError(RuntimeError): |
|
variants: Iterable[str] |
|
name: str |
|
|
|
def __init__(self, name: str, variants: Iterable[str]): |
|
self.variants = variants |
|
self.name = name |
|
|
|
def __str__(self): |
|
""" |
|
Retrieve the error message. |
|
|
|
:return: |
|
""" |
|
return ( |
|
f"Variable '{self.name}' is undefined: {self._variants} in the build " |
|
"environment. A deployment variable must be declared in the repository " |
|
"variables, deployment environment, or workspace variables. Consult the " |
|
"following reference for detailed instructions: " |
|
"https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets" |
|
) |
|
|
|
@property |
|
def _variants(self): |
|
variants = list(self.variants) |
|
amount = len(variants) |
|
|
|
if amount == 1: |
|
name = variants[0] |
|
return f"'{name}' is not defined" |
|
|
|
if amount == 2: |
|
(first, second) = variants |
|
return f"Neither '{first}' nor '{second}' are defined" |
|
|
|
if amount > 2: |
|
names = "', '".join(variants) |
|
|
|
return f"None of '{names}' are defined" |
|
|
|
return "No suitable variants are defined" |