diff --git a/README.md b/README.md index 039866e..ab3d1dd 100644 --- a/README.md +++ b/README.md @@ -287,38 +287,38 @@ Here is a minimal example: ```yml # examples/nsq-template/dc.yml -kind: DeploymentConfig -apiVersion: v1 +kind: Deployment +apiVersion: apps/v1 spec: template: spec: - containers: - - name: "nsqd" - image: "nsqio/nsq" + containers: + - name: "nsqd" + image: "nsqio/nsq" ``` ```yml # examples/my-app/dc.yml -kind: DeploymentConfig -apiVersion: v1 +kind: Deployment +apiVersion: apps/v1 metadata: - name: "${DC_NAME}" + name: "${APP_NAME}" spec: replicas: 1 selector: - name: "${DC_NAME}" + name: "${APP_NAME}" strategy: - type: Rolling + type: RollingUpdate template: metadata: labels: - name: "${DC_NAME}" + name: "${APP_NAME}" spec: containers: - - name: "${DC_NAME}" + - name: "${APP_NAME}" image: "docker-registry.default.svc:5000/oc-project/my-app:prod" ``` @@ -327,26 +327,26 @@ If we now apply the nsq-template to our app using `postApplyTemplates: [nsq-temp ```yml # Merged result after applying template -kind: DeploymentConfig -apiVersion: v1 +kind: Deployment +apiVersion: apps/v1 metadata: - name: "${DC_NAME}" + name: "${APP_NAME}" spec: replicas: 1 selector: - name: "${DC_NAME}" + name: "${APP_NAME}" strategy: - type: Rolling + type: RollingUpdate template: metadata: labels: - name: "${DC_NAME}" + name: "${APP_NAME}" spec: containers: - - name: "${DC_NAME}" + - name: "${APP_NAME}" image: "docker-registry.default.svc:5000/oc-project/my-app:prod" # This is the part of the template - name: "nsqd" @@ -416,10 +416,14 @@ The file will be updated in place. For deploying encrypted secrets, you'll need to set the environment variable `OCTOPLOY_KEY` with your key used to encrypt the data. -## Change tracking +## State tracking + +Octoploy currently uses a ConfigMap called `octoploy-state` to keep track of the object states. + +The ConfigMap contains all managed objects and their md5 sum. If this hash has changed the whole object will be +applied. If the object does already exist, but is not listed in the state it will simply be added to the state. -Changes are detected by storing a md5 sum in the label of the object. If this hash has changed the whole object will be -applied. If no label has been found in openshift the object is assumed to be equal, and the label is added. +You can modify the name of the configmap by setting the `stateName` variable in the `_root.yml` file. Currently, the actual fields of the yml files are not compared, however this is a planned feature. diff --git a/examples/grafana/deploy.yml b/examples/grafana/deploy.yml index 9f4c5f3..9516333 100644 --- a/examples/grafana/deploy.yml +++ b/examples/grafana/deploy.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" diff --git a/examples/my-app/dc.yml b/examples/my-app/dc.yml index ff3cf5c..7019a21 100644 --- a/examples/my-app/dc.yml +++ b/examples/my-app/dc.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" diff --git a/examples/nsq-template/deploy.yml b/examples/nsq-template/deploy.yml index ec909fa..0fb3089 100644 --- a/examples/nsq-template/deploy.yml +++ b/examples/nsq-template/deploy.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 spec: template: diff --git a/examples/prometheus/dc.yml b/examples/prometheus/dc.yml index 409ab37..56ce0cc 100644 --- a/examples/prometheus/dc.yml +++ b/examples/prometheus/dc.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" diff --git a/examples/python-app-template/dc.yml b/examples/python-app-template/dc.yml index 9a6b419..e5674b1 100644 --- a/examples/python-app-template/dc.yml +++ b/examples/python-app-template/dc.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" diff --git a/octoploy/oc/Model.py b/octoploy/api/Model.py similarity index 100% rename from octoploy/oc/Model.py rename to octoploy/api/Model.py diff --git a/octoploy/oc/Oc.py b/octoploy/api/Oc.py similarity index 63% rename from octoploy/oc/Oc.py rename to octoploy/api/Oc.py index b1a4e30..6167544 100644 --- a/octoploy/oc/Oc.py +++ b/octoploy/api/Oc.py @@ -4,7 +4,7 @@ from abc import abstractmethod from typing import Optional, List -from octoploy.oc.Model import ItemDescription, PodData +from octoploy.api.Model import ItemDescription, PodData from octoploy.utils.Log import Log @@ -13,11 +13,12 @@ def __init__(self): super().__init__('K8Api') @abstractmethod - def tag(self, source: str, dest: str): + def tag(self, source: str, dest: str, namespace: Optional[str] = None): """ Tags the given image stream (OC only!) :param source: Source tag :param dest: Destination tag + :param namespace: Namespace """ raise NotImplemented @@ -28,49 +29,56 @@ def get_namespaces(self) -> List[str]: """ @abstractmethod - def get(self, name: str) -> Optional[ItemDescription]: + def get(self, name: str, namespace: Optional[str] = None) -> Optional[ItemDescription]: """ Returns the given item :param name: Name + :param namespace: Namespace :return: Data (if found) """ raise NotImplemented @abstractmethod - def apply(self, yml: str) -> str: + def apply(self, yml: str, namespace: Optional[str] = None) -> str: """ Applies the given yml file :param yml: Yml file + :param namespace: Namespace :return: Stdout """ raise NotImplemented @abstractmethod - def get_pod(self, dc_name: str = None, pod_name: str = None) -> Optional[PodData]: + def get_pod(self, dc_name: str = None, pod_name: str = None, + namespace: Optional[str] = None) -> Optional[PodData]: """ Returns a pod by deployment name or pod name :param dc_name: Deployment name :param pod_name: Pod name + :param namespace: Namespace :return: Pod (if found) :raise Exception: More than one pod found """ raise NotImplemented @abstractmethod - def get_pods(self, dc_name: str = None, pod_name: str = None) -> List[PodData]: + def get_pods(self, dc_name: str = None, pod_name: str = None, + namespace: Optional[str] = None) -> List[PodData]: """ Returns all pods which match the given deployment name or pod name :param dc_name: Deployment name :param pod_name: Pod name + :param namespace: Namespace :return: Pods """ raise NotImplemented @abstractmethod - def rollout(self, name: str): + def rollout(self, name: str, namespace: Optional[str] = None): """ Re-Deploys the latest DC with the given name :param name: Deployment name + :param namespace: Namespace """ raise NotImplemented @@ -84,14 +92,6 @@ def exec(self, pod_name: str, cmd: str, args: List[str]): """ raise NotImplemented - @abstractmethod - def set_namespace(self, namespace: str): - """ - Changes the default project / namespace - :param namespace: Namespace name - """ - raise NotImplemented - @abstractmethod def switch_context(self, context: str): """ @@ -101,12 +101,21 @@ def switch_context(self, context: str): raise NotImplemented @abstractmethod - def annotate(self, name: str, key: str, value: str): + def annotate(self, name: str, key: str, value: str, namespace: Optional[str] = None): """ Add / updates the annotation at the given item :param name: Name :param key: Annotation key :param value: Annotation value + :param namespace: Namespace + """ + raise NotImplemented + + def delete(self, name: str, namespace: str): + """ + Deletes the given item + :param name: Name + :param namespace: Namespace """ raise NotImplemented @@ -116,12 +125,12 @@ def get_namespaces(self) -> List[str]: lines = self._exec(['get', 'namespaces', '-o', 'name']) return lines.splitlines() - def tag(self, source: str, dest: str): - self._exec(['tag', source, dest], print_out=True) + def tag(self, source: str, dest: str, namespace: Optional[str] = None): + self._exec(['tag', source, dest], print_out=True, namespace=namespace) - def get(self, name: str) -> Optional[ItemDescription]: + def get(self, name: str, namespace: Optional[str] = None) -> Optional[ItemDescription]: try: - json_str = self._exec(['get', name, '-o', 'json']) + json_str = self._exec(['get', name, '-o', 'json'], namespace=namespace) except Exception as e: if 'NotFound' in str(e): return None @@ -129,18 +138,18 @@ def get(self, name: str) -> Optional[ItemDescription]: return ItemDescription(json.loads(json_str)) - def apply(self, yml: str) -> str: - return self._exec(['apply', '-f', '-'], stdin=yml) + def apply(self, yml: str, namespace: Optional[str] = None) -> str: + return self._exec(['apply', '-f', '-'], stdin=yml, namespace=namespace) - def get_pod(self, dc_name: str = None, pod_name: str = None) -> Optional[PodData]: - pods = self.get_pods(dc_name=dc_name, pod_name=pod_name) + def get_pod(self, dc_name: str = None, pod_name: str = None, namespace: Optional[str] = None) -> Optional[PodData]: + pods = self.get_pods(dc_name=dc_name, pod_name=pod_name, namespace=namespace) if len(pods) == 0: return None if len(pods) > 1: raise Exception('More than one match found') return pods[0] - def get_pods(self, dc_name: str = None, pod_name: str = None) -> List[PodData]: + def get_pods(self, dc_name: str = None, pod_name: str = None, namespace: Optional[str] = None) -> List[PodData]: pods = [] json_str = self._exec(['get', 'pods', '-o', 'json']) data = json.loads(json_str) @@ -168,29 +177,42 @@ def get_pods(self, dc_name: str = None, pod_name: str = None) -> List[PodData]: return pods - def rollout(self, name: str): + def rollout(self, name: str, namespace: Optional[str] = None): """ Re-Deploys the latest DC with the given name :param name: DC name + :param namespace: Namespace """ - self._exec(['rollout', 'latest', name]) + self._exec(['rollout', 'latest', name], namespace=namespace) - def exec(self, pod_name: str, cmd: str, args: List[str]): - proc_args = ['exec', pod_name, '--', cmd] + def exec(self, pod_name: str, cmd: str, args: List[str], namespace: Optional[str] = None): + proc_args = ['exec', pod_name, '--namespace', namespace, '--', cmd] proc_args.extend(args) self._exec(proc_args, print_out=True) - def set_namespace(self, namespace: str): - self._exec(['project', namespace]) - def switch_context(self, context: str): raise NotImplemented('Not available for openshift') - def annotate(self, name: str, key: str, value: str): - self._exec(['annotate', '--overwrite=true', name, key + '=' + value]) + def annotate(self, name: str, key: str, value: str, namespace: Optional[str] = None): + self._exec(['annotate', '--overwrite=true', name, key + '=' + value], namespace=namespace) - def _exec(self, args, print_out: bool = False, stdin: str = None) -> str: + def delete(self, name: str, namespace: str): + try: + self._exec(['delete', name], namespace=namespace) + except Exception as e: + # Yes, we all know this is bad... + # at that point it would make much more sense to just + # use the k8s api directly. + if '(NotFound)' in str(e): + return + raise e + + def _exec(self, args, print_out: bool = False, stdin: str = None, namespace: Optional[str] = None) -> str: args.insert(0, self._get_bin()) + if namespace is not None: + args.append('--namespace') + args.append(namespace) + if print_out: print(str(args)) @@ -216,23 +238,19 @@ def _get_bin(self) -> str: class K8(Oc): - _namespace: str = '' + def rollout(self, name: str, namespace: Optional[str] = None): + self._exec(['rollout', 'restart', 'deployments', name], namespace=namespace) - def rollout(self, name: str): - self._exec(['rollout', 'restart', 'deployments', name]) - - def tag(self, source: str, dest: str): + def tag(self, source: str, dest: str, namespace: Optional[str] = None): raise NotImplemented('Not available for k8') - def set_namespace(self, namespace: str): - self._namespace = namespace - def switch_context(self, context: str): self._exec(['config', 'use-context', context]) - def _exec(self, args, print_out: bool = False, stdin: str = None): - if self._namespace != '': - args.append('--namespace=' + self._namespace) + def _exec(self, args, print_out: bool = False, stdin: str = None, namespace: Optional[str] = None): + if namespace is not None: + args.append('--namespace') + args.append(namespace) return super()._exec(args, print_out, stdin) def _get_bin(self) -> str: diff --git a/octoploy/oc/__init__.py b/octoploy/api/__init__.py similarity index 100% rename from octoploy/oc/__init__.py rename to octoploy/api/__init__.py diff --git a/octoploy/backup/BackupGenerator.py b/octoploy/backup/BackupGenerator.py index 56b6208..0490023 100644 --- a/octoploy/backup/BackupGenerator.py +++ b/octoploy/backup/BackupGenerator.py @@ -1,6 +1,6 @@ import os -from octoploy.config.Config import ProjectConfig +from octoploy.config.Config import RootConfig from octoploy.utils.Log import Log @@ -9,7 +9,7 @@ class BackupGenerator(Log): Very crude backup implementation. """ - def __init__(self, config: ProjectConfig): + def __init__(self, config: RootConfig): super().__init__() self._config = config @@ -23,15 +23,14 @@ def create_backup(self, dir_name: str): for namespace in namespaces: namespace = namespace.split('/')[1] self.log.info(f'Backup up namespace {namespace}') - oc.set_namespace(namespace) for api in apis: try: - names = oc._exec(['get', api, '-o', 'name']).splitlines() + names = oc._exec(['get', api, '-o', 'name'], namespace=namespace).splitlines() except Exception: continue self.log.info(f'Backing up api {api}') for item in names: - output = oc._exec(['get', item, '-o', 'yaml']) + output = oc._exec(['get', item, '-o', 'yaml'], namespace=namespace) file_name = namespace + '_' + item.replace('/', '_') + '.yaml' with open(os.path.join(dir_name, file_name), 'w') as f: f.write(output) diff --git a/octoploy/config/AppConfig.py b/octoploy/config/AppConfig.py index db8dd16..3b1d7f8 100644 --- a/octoploy/config/AppConfig.py +++ b/octoploy/config/AppConfig.py @@ -3,7 +3,7 @@ from typing import List, Optional, Dict from octoploy.config.BaseConfig import BaseConfig -from octoploy.config.ConfigMap import ConfigMap +from octoploy.config.DynamicConfigMap import DynamicConfigMap from octoploy.config.DeploymentActionConfig import DeploymentActionConfig from octoploy.utils.DictUtils import DictUtils from octoploy.utils.Errors import MissingVar @@ -18,11 +18,11 @@ def __init__(self, config_root: str, path: Optional[str], external_vars: Dict[st super().__init__(path, external_vars) self._config_root = config_root - def get_config_maps(self) -> List[ConfigMap]: + def get_config_maps(self) -> List[DynamicConfigMap]: """ Returns additional config maps which should contain the content of a file """ - return [ConfigMap(data) for data in self.data.get('configmaps', [])] + return [DynamicConfigMap(data) for data in self.data.get('configmaps', [])] def enabled(self) -> bool: """ @@ -54,7 +54,7 @@ def get_for_each(self) -> List[AppConfig]: assert isinstance(instance_vars, dict) dc_name = instance_vars.get('APP_NAME') if dc_name is None: - raise MissingVar('APP_NAME not defined in forEach for app ' + str(self.get_dc_name())) + raise MissingVar('APP_NAME not defined in forEach for app ' + str(self.get_name())) config = AppConfig(self._config_root, None, instance_vars) # Inherit all parameters @@ -90,10 +90,10 @@ def get_reload_actions(self) -> List[DeploymentActionConfig]: """ return [DeploymentActionConfig(self, x) for x in self.data.get('on-config-change', [])] - def get_dc_name(self) -> Optional[str]: + def get_name(self) -> Optional[str]: """ Returns the app name - :return: Name + :return: Name or None if not defined """ name = DictUtils.get(self.data, 'name') if name is not None: @@ -106,7 +106,7 @@ def get_replacements(self) -> Dict[str, str]: :return: Key, value map """ items = super().get_replacements() - dc_name = self.get_dc_name() + dc_name = self.get_name() if dc_name is not None: items.update({ 'APP_NAME': dc_name, diff --git a/octoploy/config/Config.py b/octoploy/config/Config.py index 22ad043..46f0620 100644 --- a/octoploy/config/Config.py +++ b/octoploy/config/Config.py @@ -3,11 +3,12 @@ import os from typing import Optional, Dict, List +from octoploy.api.Oc import Oc, K8, K8sApi from octoploy.config.AppConfig import AppConfig from octoploy.config.BaseConfig import BaseConfig -from octoploy.oc.Oc import Oc, K8, K8sApi from octoploy.processing.DataPreProcessor import DataPreProcessor, OcToK8PreProcessor from octoploy.processing.YmlTemplateProcessor import YmlTemplateProcessor +from octoploy.state.StateTracking import StateTracking from octoploy.utils.Errors import ConfigError @@ -29,16 +30,16 @@ def __init__(self): """ -class ProjectConfig(BaseConfig): +class RootConfig(BaseConfig): """ - Configuration for a project (aka a collection of apps inside a single context/namespace) + Root configuration for a project (aka a collection of apps inside a single context/namespace) """ def __init__(self, config_root: str, path: str): super().__init__(path) self._config_root = config_root self._oc = None - self._library = None # type: Optional[ProjectConfig] + self._library = None # type: Optional[RootConfig] inherit = self.data.get('inherit') if inherit is not None: @@ -47,13 +48,29 @@ def __init__(self, config_root: str, path: str): lib_dir = os.path.join(parent_dir, inherit) if not os.path.isdir(lib_dir): raise FileNotFoundError('Library not found: ' + lib_dir) - self._library = ProjectConfig.load(lib_dir) + self._library = RootConfig.load(lib_dir) if not self._library.is_library(): raise ConfigError('Project ' + inherit + ' referenced as library but is not a library') + state_name = self.data.get('stateName', '') + self._state = StateTracking(self.create_api(), state_name) + @classmethod - def load(cls, path: str) -> ProjectConfig: - return ProjectConfig(path, os.path.join(path, '_root.yml')) + def load(cls, path: str) -> RootConfig: + return RootConfig(path, os.path.join(path, '_root.yml')) + + def get_state(self) -> StateTracking: + return self._state + + def initialize_state(self, run_mode: RunMode): + if run_mode.dry_run: + return + self._state.restore(self.get_namespace_name()) + + def persist_state(self, run_mode: RunMode): + if run_mode.dry_run or run_mode.plan: + return + self._state.store(self.get_namespace_name()) def get_config_root(self) -> str: return self._config_root @@ -69,16 +86,16 @@ def get_pre_processor(self) -> DataPreProcessor: Returns the pre processor for the current config """ mode = self._get_mode() - if mode == 'k8': + if mode == 'k8s' or mode == 'k8s': return OcToK8PreProcessor() return DataPreProcessor() def _get_mode(self) -> str: - return self.data.get('mode', 'oc') + return self.data.get('mode', 'k8s') def create_api(self) -> K8sApi: """ - Creates a new openshift / k8 client + Creates a new openshift / k8s client :return: Client """ if self._oc is not None: @@ -87,7 +104,7 @@ def create_api(self) -> K8sApi: mode = self._get_mode() if mode == 'oc': oc = Oc() - elif mode == 'k8': + elif mode == 'k8s' or mode == 'k8': oc = K8() else: raise ValueError(f'Invalid mode: {mode}') @@ -136,6 +153,7 @@ def load_app_configs(self) -> List[AppConfig]: :return: """ items = [] + names = set() for dir_item in os.listdir(self._config_root): path = os.path.join(self._config_root, dir_item) if not os.path.isdir(path): @@ -149,6 +167,10 @@ def load_app_configs(self) -> List[AppConfig]: if app_config.is_template() or not app_config.enabled(): # Silently skip continue + app_name = app_config.get_name() + if app_name in names: + raise ValueError(f'The app name {app_name} has already been used') + names.add(app_name) items.append(app_config) if self._library is not None: items.extend(self._library.load_app_configs()) diff --git a/octoploy/config/DeploymentActionConfig.py b/octoploy/config/DeploymentActionConfig.py index d6dd9ee..8cdf347 100644 --- a/octoploy/config/DeploymentActionConfig.py +++ b/octoploy/config/DeploymentActionConfig.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from octoploy.config.Config import AppConfig -from octoploy.oc.Oc import K8sApi +from octoploy.api.Oc import K8sApi class DeploymentActionConfig(Log): @@ -22,7 +22,7 @@ def __init__(self, app_config: AppConfig, data): def run(self, oc: K8sApi): if self._data == 'deploy': - oc.rollout(self._app_config.get_dc_name()) + oc.rollout(self._app_config.get_name()) return exec_config = self._data.get('exec', None) @@ -30,7 +30,7 @@ def run(self, oc: K8sApi): cmd = exec_config['command'] args = exec_config['args'] - dc_name = self._app_config.get_dc_name() + dc_name = self._app_config.get_name() self.log.info('Reloading via exec in pods of ' + dc_name) pods = oc.get_pods(dc_name=dc_name) for pod in pods: diff --git a/octoploy/config/ConfigMap.py b/octoploy/config/DynamicConfigMap.py similarity index 97% rename from octoploy/config/ConfigMap.py rename to octoploy/config/DynamicConfigMap.py index 98903b9..b9eafb2 100644 --- a/octoploy/config/ConfigMap.py +++ b/octoploy/config/DynamicConfigMap.py @@ -11,7 +11,7 @@ def __init__(self, data: Dict, disable_templating: bool = False): self.disable_templating = disable_templating -class ConfigMap: +class DynamicConfigMap: def __init__(self, data): self._data = data self.name = data['name'] diff --git a/octoploy/deploy/AppDeploy.py b/octoploy/deploy/AppDeploy.py index 5741be4..d65e880 100644 --- a/octoploy/deploy/AppDeploy.py +++ b/octoploy/deploy/AppDeploy.py @@ -5,7 +5,7 @@ import yaml -from octoploy.config.Config import ProjectConfig, AppConfig, RunMode +from octoploy.config.Config import RootConfig, AppConfig, RunMode from octoploy.deploy.DeploymentBundle import DeploymentBundle from octoploy.deploy.K8sObjectDeployer import K8sObjectDeployer from octoploy.processing.YmlTemplateProcessor import YmlTemplateProcessor @@ -16,10 +16,10 @@ class AppDeployment: """ - Deploys a single application + Deploys a single application (aka all yml files inside an app directory) """ - def __init__(self, root_config: ProjectConfig, app_config: AppConfig, mode: RunMode): + def __init__(self, root_config: RootConfig, app_config: AppConfig, mode: RunMode): self._root_config = root_config self._app_config = app_config self._mode = mode @@ -43,7 +43,7 @@ class AppDeployRunnerFactory: Creates AppDeployRunner objects """ - def __init__(self, root_config: ProjectConfig, mode: RunMode): + def __init__(self, root_config: RootConfig, mode: RunMode): self._root_config = root_config self._mode = mode @@ -64,7 +64,7 @@ class AppDeployRunner(Log): Executes the deployment of a single app """ - def __init__(self, root_config: ProjectConfig, app_config: AppConfig, mode: RunMode = RunMode()): + def __init__(self, root_config: RootConfig, app_config: AppConfig, mode: RunMode = RunMode()): super().__init__() self._root_config = root_config self._app_config = app_config @@ -78,7 +78,7 @@ def deploy(self): if not self._app_config.enabled(): raise ValueError('App is disabled') if self._app_config.is_template(): - raise ValueError('App is a template and can\'t be deployed') + raise ValueError("App is a template and can't be deployed") # Resolve all templating references template_processor = self._app_config.get_template_processor() @@ -99,14 +99,15 @@ def deploy(self): self.log.warning(f'Skipping object: {e}') self._bundle.objects.remove(data) - k8sapi = self._root_config.create_api() + api = self._root_config.create_api() if self._mode.out_file is not None: self._bundle.dump_objects(self._mode.out_file) + if self._mode.dry_run: return - self.log.info(f'Checking {self._app_config.get_dc_name()}') - object_deployer = K8sObjectDeployer(self._root_config, k8sapi, self._app_config, mode=self._mode) + self.log.info(f'Checking {self._app_config.get_name()}') + object_deployer = K8sObjectDeployer(self._root_config, api, self._app_config, mode=self._mode) self._bundle.deploy(object_deployer) def _apply_templates(self, template_names: List[str], template_processor: YmlTemplateProcessor): diff --git a/octoploy/deploy/DeploymentBundle.py b/octoploy/deploy/DeploymentBundle.py index f577a20..7e929d5 100644 --- a/octoploy/deploy/DeploymentBundle.py +++ b/octoploy/deploy/DeploymentBundle.py @@ -10,7 +10,7 @@ from octoploy.k8s.BaseObj import BaseObj from octoploy.processing.DataPreProcessor import DataPreProcessor from octoploy.processing.DecryptionProcessor import DecryptionProcessor -from octoploy.processing.OcObjectMerge import OcObjectMerge +from octoploy.processing.K8sObjectMerge import K8sObjectMerge from octoploy.processing.YmlTemplateProcessor import YmlTemplateProcessor from octoploy.utils.Log import Log from octoploy.utils.YmlWriter import YmlWriter @@ -42,7 +42,7 @@ def add_object(self, data: dict, template_processor: YmlTemplateProcessor): if template_processor is not None: template_processor.process(data) - merger = OcObjectMerge() + merger = K8sObjectMerge() # Check if the new data can be merged into any existing objects for item in self.objects: if merger.merge(item, data): @@ -54,10 +54,10 @@ def add_object(self, data: dict, template_processor: YmlTemplateProcessor): def deploy(self, deploy_runner: K8sObjectDeployer): """ - Deploys all object + Deploys all objects in this bundle :param deploy_runner: Deployment runner which should be used """ - deploy_runner.select_namespace() + deploy_runner.select_context() # First sort the objects, we want "deployments" to be the last object type # so all prerequisites are available @@ -73,6 +73,8 @@ def sorting(x): for item in self.objects: deploy_runner.deploy_object(item) + deploy_runner.delete_abandoned_objects() + def dump_objects(self, path: str): """ Very crude method for dumping the objects to a yml file. diff --git a/octoploy/deploy/K8sObjectDeployer.py b/octoploy/deploy/K8sObjectDeployer.py index e71221c..0a6215f 100644 --- a/octoploy/deploy/K8sObjectDeployer.py +++ b/octoploy/deploy/K8sObjectDeployer.py @@ -1,8 +1,9 @@ import hashlib -from octoploy.config.Config import ProjectConfig, AppConfig, RunMode +from octoploy.api.Oc import K8sApi +from octoploy.config.Config import RootConfig, AppConfig, RunMode from octoploy.k8s.BaseObj import BaseObj -from octoploy.oc.Oc import K8sApi +from octoploy.state.StateTracking import StateTracking from octoploy.utils.Log import Log from octoploy.utils.YmlWriter import YmlWriter @@ -16,79 +17,102 @@ class K8sObjectDeployer(Log): """ Name of the annotation that stores the hash """ + _root_config: RootConfig + _app_config: AppConfig + _api: K8sApi + _state: StateTracking - def __init__(self, root_config: ProjectConfig, k8sapi: K8sApi, app_config: AppConfig, mode: RunMode = RunMode()): + def __init__(self, root_config: RootConfig, k8sapi: K8sApi, app_config: AppConfig, mode: RunMode = RunMode()): super().__init__() - self._root_config = root_config # type: ProjectConfig - self._app_config = app_config # type: AppConfig - self._k8sapi = k8sapi # type: K8sApi + self._root_config = root_config + self._app_config = app_config + self._api = k8sapi self._mode = mode + self._state = root_config.get_state() - def select_namespace(self): + def select_context(self): """ - Selects the required openshift project + Selects the cluster context """ context = self._root_config.get_kubectl_context() if context is not None: - self._k8sapi.switch_context(context) - namespace = self._root_config.get_namespace_name() - if namespace is not None: - self._k8sapi.set_namespace(namespace) + self._api.switch_context(context) def deploy_object(self, data: dict): """ Deploy the given object (if a deployment required, otherwise does nothing) :param data: Data which should be deployed """ - # Sort the content so it's always reproducible str_repr = YmlWriter.dump(data) - hash_val = hashlib.md5(str_repr.encode('utf-8')).hexdigest() k8s_object = BaseObj(data) # An object might be in a different namespace than the current context - object_namespace = k8s_object.namespace - if object_namespace is not None: - self._k8sapi.set_namespace(object_namespace) + namespace = k8s_object.namespace + if namespace is None: + namespace = self._root_config.get_namespace_name() + k8s_object.namespace = namespace # Make sure the object points to the correct namespace - item_name = k8s_object.get_fqn() - description = self._k8sapi.get(item_name) - current_hash = None - if description is not None: - current_hash = description.get_annotation(self.HASH_ANNOTATION) + item_path = k8s_object.get_fqn() + current_object = self._api.get(item_path, namespace=namespace) + if current_object is None: + self.log.info(f'{item_path} will be created') - if description is not None and current_hash is None: - # Item has not been deployed yet with this script, assume both are the same - self.log.info('Updating annotation of ' + item_name) - self._k8sapi.annotate(item_name, self.HASH_ANNOTATION, hash_val) + current_hash = None + if current_object is not None: + obj_state = self._state.get_state(self._app_config.get_name(), k8s_object) + if obj_state is not None and obj_state.hash != '': + current_hash = obj_state.hash + else: # Fallback to old hash location + current_hash = current_object.get_annotation(self.HASH_ANNOTATION) + + if current_object is not None and current_hash is None: + # Item has not been deployed with octoploy, but it does already exist + self.log.warning(f'{item_path} has no state annotation, assuming no change required') + self._state.visit(self._app_config.get_name(), k8s_object, hash_val) return if current_hash == hash_val: - self.log.debug('No change in ' + item_name) + self._state.visit(self._app_config.get_name(), k8s_object, hash_val) + self.log.debug(f"{item_path} hasn't changed") return + if current_object is not None: + self.log.info(f'{item_path} will be updated') + if self._mode.plan: - self.log.warning('Update required for ' + item_name) + self._state.visit(self._app_config.get_name(), k8s_object, hash_val) return - self.log.info('Applying update ' + item_name + ' (item has changed)') - self._k8sapi.apply(str_repr) - self._k8sapi.annotate(item_name, self.HASH_ANNOTATION, hash_val) - - if object_namespace is not None: - # Use project namespace as default again - default_namespace = self._root_config.get_namespace_name() - if default_namespace is not None: - self._k8sapi.set_namespace(default_namespace) + self._api.apply(str_repr, namespace=namespace) + self._state.visit(self._app_config.get_name(), k8s_object, hash_val) if k8s_object.is_kind('ConfigMap'): self._reload_config() + def delete_abandoned_objects(self): + """ + Deletes all objects which are not anymore included in the + current app config + """ + abandoned = self._state.get_not_visited(self._app_config.get_name()) + for item in abandoned: + namespace = item.namespace + if namespace is None: + namespace = self._root_config.get_namespace_name() + item_path = item.kind + '/' + item.name + self.log.info(f'{item_path} will be deleted') + if self._mode.plan: + continue + + self._api.delete(item_path, namespace=namespace) + self._state.remove(item) + def _reload_config(self): """ Tries to reload the configuration for the app """ reload_actions = self._app_config.get_reload_actions() for action in reload_actions: - action.run(self._k8sapi) + action.run(self._api) diff --git a/octoploy/k8s/BaseObj.py b/octoploy/k8s/BaseObj.py index ecdeec5..1246c5c 100644 --- a/octoploy/k8s/BaseObj.py +++ b/octoploy/k8s/BaseObj.py @@ -2,15 +2,16 @@ class BaseObj: - kind: Optional[str] + api_version: str + kind: str name: Optional[str] namespace: Optional[str] metadata: Dict[str, any] def __init__(self, data: Dict[str, any]): self.data = data - self.kind = data.get('kind', None) - self.api_version = data.get('apiVersion', None) + self.kind = data['kind'] + self.api_version = data['apiVersion'] self.metadata = data.get('metadata', {}) self.name = self.metadata.get('name', None) diff --git a/octoploy/octoploy.py b/octoploy/octoploy.py index 61a9e94..0d2e64b 100644 --- a/octoploy/octoploy.py +++ b/octoploy/octoploy.py @@ -2,26 +2,25 @@ import argparse -from octoploy.processing.DecryptionProcessor import DecryptionProcessor - from octoploy.backup.BackupGenerator import BackupGenerator -from octoploy.config.Config import ProjectConfig, RunMode +from octoploy.config.Config import RootConfig, RunMode from octoploy.deploy.AppDeploy import AppDeployment +from octoploy.processing.DecryptionProcessor import DecryptionProcessor from octoploy.utils.Encryption import YmlEncrypter from octoploy.utils.Log import Log log_instance = Log('octoploy') -def load_project(config_dir: str) -> ProjectConfig: +def load_project(config_dir: str) -> RootConfig: if config_dir != '': - return ProjectConfig.load(config_dir) + return RootConfig.load(config_dir) # No path specified, try a few common ones paths = ['.', 'configs', 'octoploy'] for path in paths: try: - return ProjectConfig.load(path) + return RootConfig.load(path) except FileNotFoundError: continue raise FileNotFoundError(f'Did not find config in any of {paths}') @@ -38,7 +37,7 @@ def reload_config(args): return oc = root_config.create_api() - log_instance.log.info('Reloading ' + app_config.get_dc_name()) + log_instance.log.info('Reloading ' + app_config.get_name()) reload_actions = app_config.get_reload_actions() for action in reload_actions: action.run(oc) @@ -48,17 +47,25 @@ def reload_config(args): def _run_app_deploy(config_dir: str, app_name: str, mode: RunMode): root_config = load_project(config_dir) app_config = root_config.load_app_config(app_name) - deployment = AppDeployment(root_config, app_config, mode) - deployment.deploy() + root_config.initialize_state(mode) + try: + deployment = AppDeployment(root_config, app_config, mode) + deployment.deploy() + finally: + root_config.persist_state(mode) log_instance.log.info('Done') def _run_apps_deploy(config_dir: str, mode: RunMode): root_config = load_project(config_dir) configs = root_config.load_app_configs() - log_instance.log.info(f'Got {len(configs)} configs') - for app_config in configs: - AppDeployment(root_config, app_config, mode).deploy() + root_config.initialize_state(mode) + log_instance.log.debug(f'Found {len(configs)} apps to deploy') + try: + for app_config in configs: + AppDeployment(root_config, app_config, mode).deploy() + finally: + root_config.persist_state(mode) log_instance.log.info('Done') @@ -99,6 +106,19 @@ def encrypt_secrets(args): YmlEncrypter(file).encrypt() +def move_state(args): + run_mode = RunMode() + source = args.items[0] + dest = args.items[1] + + root_config = load_project(args.config_dir) + root_config.initialize_state(run_mode) + + state = root_config.get_state() + state.move(source, dest) + root_config.persist_state(run_mode) + + def main(): parser = argparse.ArgumentParser() parser.add_argument('--version', dest='version', action='store_true', @@ -107,14 +127,23 @@ def main(): help='Enables debug logging') parser.add_argument('--skip-secrets', dest='skip_secrets', action='store_true', help="Skips all secret objects and therefore doesn't require a key to be set") + parser.add_argument('--deploy-plain-secrets', dest='deploy_plain_text', action='store_true', + help="Deploys plain text secret objects") parser.add_argument('-c', '--config-dir', dest='config_dir', help='Path to the folder containing all configurations', default='') subparsers = parser.add_subparsers(help='Commands') - backup_parser = subparsers.add_parser('encrypt', help='Encrypts k8s secrets objects') - backup_parser.add_argument('file', help='Yml file to be encrypted', nargs=1) - backup_parser.set_defaults(func=encrypt_secrets) + state_parser = subparsers.add_parser('move-state', help='Moves objects from the state to other places') + state_parser.add_argument('items', help='Source and destination.\n' + 'The format for an object is: octoployName/Namespace/Kind/K8sObjectName\n' + 'For example: "my-old-app/Namespace2/Deployment/app" ' + '"my-new-app/Namespace2/Deployment/app"', nargs=2) + state_parser.set_defaults(func=move_state) + + encrypt_parser = subparsers.add_parser('encrypt', help='Encrypts k8s secrets objects') + encrypt_parser.add_argument('file', help='Yml file to be encrypted', nargs=1) + encrypt_parser.set_defaults(func=encrypt_secrets) backup_parser = subparsers.add_parser('backup', help='Creates a backup of all resources in the cluster') backup_parser.add_argument('name', help='Name of the backup folder', nargs=1) @@ -136,7 +165,10 @@ def main(): deploy_parser.add_argument('--out-file', dest='out_file', help='Writes all objects into a yml file instead of deploying them. ' 'This does not communicate with openshift in any way') - deploy_parser.add_argument('--dry-run', dest='dry_run', help='Does not interact with openshift', + deploy_parser.add_argument('--dry-run', dest='dry_run', help='Does not interact with k8s/openshift ' + '(pure template processing). ' + 'Can be used in conjunction with out-file to preview ' + 'templates', action='store_true') deploy_parser.add_argument('name', help='Name of the app which should be deployed (folder name)', nargs=1) deploy_parser.set_defaults(func=deploy_app) @@ -165,6 +197,7 @@ def main(): Log.set_debug() DecryptionProcessor.skip_secrets = args.skip_secrets + DecryptionProcessor.deploy_plain_text = args.deploy_plain_text args.func(args) diff --git a/octoploy/processing/DecryptionProcessor.py b/octoploy/processing/DecryptionProcessor.py index 0f491a9..321f106 100644 --- a/octoploy/processing/DecryptionProcessor.py +++ b/octoploy/processing/DecryptionProcessor.py @@ -5,9 +5,10 @@ from octoploy.utils import Utils from octoploy.utils.Encryption import Encryption from octoploy.utils.Errors import SkipObject +from octoploy.utils.Log import Log -class DecryptionProcessor(TreeProcessor): +class DecryptionProcessor(TreeProcessor, Log): """ Processes all objects, and replaces any encrypted placeholders. If skip_secrets is True, all secret object types wil lbe skipped @@ -18,9 +19,15 @@ class DecryptionProcessor(TreeProcessor): Indicates if all secrets should be skipped """ + deploy_plain_text: bool = False + """ + Indicates if plain text secrets should be skipped + """ + _secret_obj: Optional[SecretObj] def __init__(self): + super().__init__(__name__) self.encryption = Encryption() def process(self, root: Dict[str, any]): @@ -44,9 +51,13 @@ def process_str(self, value: str, parent: Dict[str, any], key: str) -> str: # Also, it may increase chances of accidentally storing them in vcs if key in self._secret_obj.base64_data or \ key in self._secret_obj.string_data: - raise SkipObject('Secret contains plain text - use "octoploy encrypt" to encrypt your secrets') + if not DecryptionProcessor.deploy_plain_text: + raise SkipObject( + f'Secret {self._secret_obj.get_fqn()} contains plain text - ' + f'use "octoploy encrypt" to encrypt your secrets') return value + if DecryptionProcessor.skip_secrets and self._secret_obj is not None: # We should skip the secret raise SkipObject('Secrets should be skipped') diff --git a/octoploy/processing/OcObjectMerge.py b/octoploy/processing/K8sObjectMerge.py similarity index 95% rename from octoploy/processing/OcObjectMerge.py rename to octoploy/processing/K8sObjectMerge.py index 042bdd6..5471174 100644 --- a/octoploy/processing/OcObjectMerge.py +++ b/octoploy/processing/K8sObjectMerge.py @@ -1,13 +1,13 @@ from typing import Dict -from octoploy.oc.Model import DeploymentConfig, NamedItem +from octoploy.api.Model import DeploymentConfig, NamedItem from octoploy.utils.DictUtils import DictUtils from octoploy.utils.Log import Log -class OcObjectMerge(Log): +class K8sObjectMerge(Log): """ - Merges openshift objects + Merges kubernetes objects """ NAME_PATH = 'metadata.name' @@ -33,7 +33,7 @@ def merge(self, existing, to_add) -> bool: if name is not None and expected_name is not None and expected_name != name: return False - if expected_type == 'DeploymentConfig'.lower(): + if expected_type == 'DeploymentConfig'.lower() or expected_type == 'Deployment'.lower(): self._merge_dc(DeploymentConfig(existing), DeploymentConfig(to_add)) return True diff --git a/octoploy/processing/ValueLoader.py b/octoploy/processing/ValueLoader.py index 4b0326e..9e6b516 100644 --- a/octoploy/processing/ValueLoader.py +++ b/octoploy/processing/ValueLoader.py @@ -20,6 +20,11 @@ def __init__(self, config: BaseConfig): def load(self, data: Dict) -> Dict[str, str]: pass + def _resolve_path(self, path: str) -> str: + if os.path.isabs(path): + return path + return self._config.get_file(path) + class EnvLoader(ValueLoader): @@ -30,7 +35,7 @@ def load(self, data: Dict) -> Dict[str, str]: class FileLoader(ValueLoader): def load(self, data: Dict) -> Dict[str, str]: - file = data['file'] + file = self._resolve_path(data['file']) encoding = data.get('encoding', 'utf-8') conversion = data.get('conversion') @@ -48,7 +53,7 @@ def load(self, data: Dict) -> Dict[str, str]: class PemLoader(ValueLoader): def load(self, data: Dict) -> Dict[str, str]: - file = data['file'] + file = self._resolve_path(data['file']) cert = Cert(self._config.get_file(file)) return { '_PUBLIC': cert.cert, diff --git a/octoploy/state/StateTracking.py b/octoploy/state/StateTracking.py new file mode 100644 index 0000000..251f407 --- /dev/null +++ b/octoploy/state/StateTracking.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +import yaml + +from octoploy.api.Oc import K8sApi +from octoploy.k8s.BaseObj import BaseObj +from octoploy.utils.Log import Log +from octoploy.utils.YmlWriter import YmlWriter + + +class ObjectState: + def __init__(self): + self.context = '' + self.name = '' + self.kind = '' + self.hash = '' + self.namespace: Optional[str] = None + self.visited = False + """ + Transient flag to indicate if the object has been visited by octoploy + """ + + def update_from_key(self, key: str): + segments = key.split('/') + count = len(segments) + if count > 0: + self.context = segments[0] + if count > 1: + self.namespace = segments[1] + if count > 2: + self.kind = segments[2] + if count > 3: + self.name = segments[3] + + def parse(self, data: Dict[str, str]) -> ObjectState: + self.context = data['context'] + self.namespace = data['namespace'] + self.kind = data['kind'] + self.name = data['name'] + self.hash = data.get('hash', '') + return self + + def to_dict(self) -> Dict[str, str]: + return { + 'name': self.name, + 'hash': self.hash, + 'kind': self.kind, + 'context': self.context, + 'namespace': self.namespace, + } + + def get_key(self) -> str: + return f'{self.context}/{self.namespace}/{self.kind}/{self.name}' + + +class StateTracking(Log): + """ + Stores the objects that have been deployed with octoploy. + This allows octoploy to detect renamed / deleted objects. + """ + CM_NAME = 'octoploy-state' + _k8s_api: K8sApi + _state: Dict[str, ObjectState] + + def __init__(self, api: K8sApi, name_suffix: str = ''): + super().__init__() + self._k8s_api = api + self._cm_name = self.CM_NAME + name_suffix + self._state = {} + + def restore(self, namespace: str): + item = self._k8s_api.get(f'ConfigMap/{self._cm_name}', namespace=namespace) + if item is None: + return + cm = item.data + state_data_str = cm.get('data', {}).get('state', '') + state_data = yaml.safe_load(state_data_str) + if state_data is None: + return + + for state_obj in state_data: + object_state = ObjectState().parse(state_obj) + self._state[object_state.get_key()] = object_state + + def store(self, namespace: str): + self.log.debug(f'Persisting state in ConfigMap {self._cm_name}') + + states = [] + for object_state in self._state.values(): + states.append(object_state.to_dict()) + + data = { + 'kind': 'ConfigMap', + 'apiVersion': 'v1', + 'metadata': { + 'name': self._cm_name + }, + 'data': { + 'state': YmlWriter.dump(states) + } + } + yml = YmlWriter.dump(data) + self._k8s_api.apply(yml, namespace=namespace) + + def move(self, source: str, dest: str): + if source.count('/') != dest.count('/'): + raise ValueError('Source and destination point to different path depths') + + for value in list(self._state.values()): + key = value.get_key() + if not key.startswith(source): + continue + target = key.replace(source, dest) + self.log.info(f'Moving {key} to {target}') + value.update_from_key(target) + + if key in self._state: + del self._state[key] + self._state[target] = value + + def get_not_visited(self, context: str) -> List[ObjectState]: + """ + Returns all objects which have not been visited + :param context: Context + :return: Objects + """ + items = [] + for object_state in self._state.values(): + if not object_state.visited and object_state.context == context: + items.append(object_state) + return items + + def get_state(self, context_name: str, k8s_object: BaseObj) -> Optional[ObjectState]: + state = self._k8s_to_state(context_name, k8s_object) + return self._state.get(state.get_key()) + + def visit(self, context_name: str, k8s_object: BaseObj, hash_val: str): + """ + Marks the given object as "visited". + If the object is not yet in the state it will be added + :param context_name: Name of the state context + :param k8s_object: Kubernetes object + :param hash_val: The new hash value of the object. + """ + state = self._k8s_to_state(context_name, k8s_object) + existing_state = self._state.get(state.get_key()) + if existing_state is None: + state.hash = hash_val + self._state[state.get_key()] = state + return + existing_state.hash = hash_val + existing_state.visited = True + + def remove(self, object_state: ObjectState): + del self._state[object_state.get_key()] + + @staticmethod + def _k8s_to_state(context_name: str, k8s_object: BaseObj) -> ObjectState: + api_version = k8s_object.api_version + kind = k8s_object.kind + name = k8s_object.name + + state = ObjectState() + state.namespace = k8s_object.namespace + state.context = context_name + state.api_version = api_version + state.kind = kind + state.name = name + state.visited = True + return state diff --git a/octoploy/state/__init__.py b/octoploy/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/AppDeploymentTest.py b/tests/AppDeploymentTest.py index 086cc25..92fcf84 100644 --- a/tests/AppDeploymentTest.py +++ b/tests/AppDeploymentTest.py @@ -1,12 +1,14 @@ import os +from time import sleep from unittest import TestCase import yaml -from octoploy.config.Config import ProjectConfig, RunMode +from octoploy.config.Config import RootConfig, RunMode from octoploy.deploy.AppDeploy import AppDeployment from octoploy.utils.Errors import MissingParam from octoploy.utils.Yml import Yml +from tests import TestUtils class AppDeploymentTest(TestCase): @@ -76,7 +78,7 @@ def test_for_each(self): self.assertEqual('hello', docs[1]['metadata']['REMAPPED']) def test_params(self): - prj_config = ProjectConfig.load(os.path.join(self._base_path, 'app_deploy_test')) + prj_config = RootConfig.load(os.path.join(self._base_path, 'app_deploy_test_params')) app_config = prj_config.load_app_config('app-params') runner = AppDeployment(prj_config, app_config, self._mode) try: @@ -99,14 +101,14 @@ def test_secret_without_encryption(self): """ Makes s ure secrets without encrypted values are not deployed """ - os.environ['OCTOPLOY_KEY'] = 'key123' + os.environ['OCTOPLOY_KEY'] = TestUtils.OCTOPLOY_KEY self._deploy('secrets') with open(self._tmp_file) as f: data = yaml.load(f, Loader=yaml.FullLoader) self.assertTrue('plainText' not in data['stringData']) def test_decryption(self): - os.environ['OCTOPLOY_KEY'] = 'key123' + os.environ['OCTOPLOY_KEY'] = TestUtils.OCTOPLOY_KEY self._deploy('secrets') with open(self._tmp_file) as f: data = yaml.load(f, Loader=yaml.FullLoader) @@ -114,7 +116,7 @@ def test_decryption(self): self.assertEqual('hello world', data['stringData']['field']) def _deploy(self, app: str, project: str = 'app_deploy_test'): - prj_config = ProjectConfig.load(os.path.join(self._base_path, project)) + prj_config = RootConfig.load(os.path.join(self._base_path, project)) app_config = prj_config.load_app_config(app) runner = AppDeployment(prj_config, app_config, self._mode) runner.deploy() diff --git a/tests/OcObjectMergeTest.py b/tests/OcObjectMergeTest.py index 0ee5f03..c3e9a3d 100644 --- a/tests/OcObjectMergeTest.py +++ b/tests/OcObjectMergeTest.py @@ -2,13 +2,13 @@ import yaml -from octoploy.processing.OcObjectMerge import OcObjectMerge +from octoploy.processing.K8sObjectMerge import K8sObjectMerge -class OcObjectMergeTest(TestCase): +class K8sObjectMergeTest(TestCase): def test_append_sidecar_container(self): - existing = '''kind: DeploymentConfig + existing = '''kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" @@ -33,14 +33,14 @@ def test_append_sidecar_container(self): mountPath: /java/java-cacerts ''' new = ''' -kind: DeploymentConfig +kind: Deployment spec: template: spec: containers: - name: "sidecar-container" ''' - merge = OcObjectMerge() + merge = K8sObjectMerge() data = yaml.safe_load(existing) merge.merge(data, yaml.safe_load(new)) @@ -63,7 +63,7 @@ def validate(data): validate(new_data) def test_merge_same_container(self): - existing = '''kind: DeploymentConfig + existing = '''kind: Deployment apiVersion: v1 metadata: name: "${DC_NAME}" @@ -117,7 +117,7 @@ def test_merge_same_container(self): name: '${DC_NAME}:prod' ''' new = ''' -kind: DeploymentConfig +kind: Deployment metadata: name: "${DC_NAME}" @@ -139,7 +139,7 @@ def test_merge_same_container(self): persistentVolumeClaim: claimName: "pvc-crawler-packages"''' - merge = OcObjectMerge() + merge = K8sObjectMerge() data = yaml.safe_load(existing) merge.merge(data, yaml.safe_load(new)) diff --git a/tests/StateTrackingTest.py b/tests/StateTrackingTest.py new file mode 100644 index 0000000..4ba9054 --- /dev/null +++ b/tests/StateTrackingTest.py @@ -0,0 +1,266 @@ +import json +import os +from typing import List +from unittest import TestCase + +import yaml + +import octoploy.octoploy +from octoploy.config.Config import RunMode, RootConfig +from octoploy.state.StateTracking import StateTracking, ObjectState +from tests import TestUtils +from tests.TestUtils import DummyK8sApi + + +class StateTrackingTest(TestCase): + + def setUp(self) -> None: + self._base_path = os.path.dirname(__file__) + self._tmp_file = 'out.yml' + self._mode = RunMode() + self._dummy_api = DummyK8sApi() + octoploy.octoploy.load_project = self._load_project + + def tearDown(self) -> None: + self._dummy_api = None + + def _load_project(self, config_dir: str) -> RootConfig: + def get_dummy_api(): + return self._dummy_api + + prj_config = RootConfig.load(os.path.join(self._base_path, config_dir)) + prj_config.get_state()._k8s_api = self._dummy_api + prj_config.create_api = get_dummy_api + return prj_config + + def test_move(self): + state = StateTracking(None) + data = state._state + + obj = ObjectState() + obj.update_from_key('a/b/c/d') + self.assertEqual('a', obj.context) + self.assertEqual('b', obj.namespace) + self.assertEqual('c', obj.kind) + self.assertEqual('d', obj.name) + data['a'] = obj + + obj = ObjectState() + obj.update_from_key('a/b/c') + self.assertEqual('a', obj.context) + self.assertEqual('b', obj.namespace) + self.assertEqual('c', obj.kind) + obj.name = 'name' + data['b'] = obj + + obj = ObjectState() + obj.update_from_key('a/b') + self.assertEqual('a', obj.context) + self.assertEqual('b', obj.namespace) + obj.kind = 'Deployment' + obj.name = 'name' + data['c'] = obj + + obj = ObjectState() + obj.update_from_key('a') + self.assertEqual('a', obj.context) + obj.namespace = 'unittest' + obj.kind = 'Deployment' + obj.name = 'name' + data['d'] = obj + init_state = dict(data) + + state.move('a/b/c/d', '1/2/3/4') + self.assertEqual('1', data['a'].context) + self.assertEqual('2', data['a'].namespace) + self.assertEqual('3', data['a'].kind) + self.assertEqual('4', data['a'].name) + state.move('1/2/3/4', 'a/b/c/d') + + state.move('a/b/c', '1/2/3') + self.assertEqual('1', data['a'].context) + self.assertEqual('2', data['a'].namespace) + self.assertEqual('3', data['a'].kind) + self.assertEqual('d', data['a'].name) + + self.assertEqual('1', data['b'].context) + self.assertEqual('2', data['b'].namespace) + self.assertEqual('3', data['b'].kind) + self.assertEqual('name', data['b'].name) + state.move('1/2/3', 'a/b/c') + + def test_create_new(self): + self._dummy_api.respond(['get', 'Deployment/ABC', '-o', 'json'], '', error=Exception('NotFound')) + self._dummy_api.respond(['get', 'ConfigMap/octoploy-state', '-o', 'json'], '{}') + + octoploy.octoploy._run_app_deploy('app_deploy_test', 'app', self._mode) + + self.assertEqual(4, len(self._dummy_api.commands)) + self.assertEqual(['get', 'ConfigMap/octoploy-state', '-o', 'json'], self._dummy_api.commands[0].args) + state_update = self._dummy_api.commands[-1] + self.assertEqual(['apply', '-f', '-'], state_update.args) + self.assertStateEqual([{"context": "ABC", + "hash": "d7e628adce4fa1b75b861be76920b6dc", + "kind": "Deployment", + "name": "ABC", + "namespace": "oc-project", + }], state_update.stdin) + + def test_deploy_all_then_app(self): + """ + Deploys an entire folder, then a single app and makes + sure the other objects don't get removed again from k8s + """ + self._dummy_api.not_found_by_default() + + os.environ['OCTOPLOY_KEY'] = TestUtils.OCTOPLOY_KEY + octoploy.octoploy._run_apps_deploy('app_deploy_test', self._mode) + + self.assertEqual(14, len(self._dummy_api.commands)) + self.assertEqual(['get', 'ConfigMap/octoploy-state', '-o', 'json'], self._dummy_api.commands[0].args) + + state_update = self._dummy_api.commands[-1] + self.assertEqual(['apply', '-f', '-'], state_update.args) + self.assertStateEqual([ + {"context": "ABC", + "hash": "d7e628adce4fa1b75b861be76920b6dc", + "kind": "Deployment", + "name": "ABC", + "namespace": "oc-project"}, + {"context": "entity-compare-api", + "hash": "8b3e2f9b6499582cc3e4fe66681a7850", + "kind": "Deployment", + "name": "8080", + "namespace": "oc-project"}, + {"context": "favorite-api", + "hash": "96204a555b80b50b3cf49e5684f3daa9", + "kind": "Deployment", + "name": "8081", + "namespace": "oc-project"}, + {"context": "cm-types", + "hash": "a48eadd6d347e2591a3929df077aead7", + "kind": "ConfigMap", + "name": "config", + "namespace": "oc-project"}, + {"context": "ABC2", + "hash": "ea23b547bb9a06ca79d3dc23c3d1d0dd", + "kind": "Secret", + "name": "secret", + "namespace": "oc-project"}, + {"context": "var-append", + "hash": "e19d6902d55e14e99213b1d112acbde0", + "kind": "ConfigMap", + "name": "config", + "namespace": "oc-project"}, + ], state_update.stdin) + + # Now deploy a single app + current_state = yaml.safe_load(state_update.stdin) + self._dummy_api.respond(['get', 'ConfigMap/octoploy-state', '-o', 'json'], json.dumps(current_state)) + self._dummy_api.respond(['get', 'Deployment/ABC', '-o', 'json'], '{}') + self._dummy_api.commands = [] + octoploy.octoploy._run_app_deploy('app_deploy_test', 'app', self._mode) + + self.assertEqual(3, len(self._dummy_api.commands)) + state_update = self._dummy_api.commands[-1] + new_state = yaml.safe_load(state_update.stdin) + self.assertEqual(current_state, new_state) + + def test_deploy_all_twice(self): + """ + Deploys an entire folder twice and validates that the objects don't get removed again from k8s + """ + self._dummy_api.not_found_by_default() + + os.environ['OCTOPLOY_KEY'] = TestUtils.OCTOPLOY_KEY + octoploy.octoploy._run_apps_deploy('app_deploy_test', self._mode) + + self.assertEqual(14, len(self._dummy_api.commands)) + self.assertEqual(['get', 'ConfigMap/octoploy-state', '-o', 'json'], self._dummy_api.commands[0].args) + + state_update = self._dummy_api.commands[-1] + self.assertEqual(['apply', '-f', '-'], state_update.args) + self.assertStateEqual([{"context": "ABC", + "hash": "d7e628adce4fa1b75b861be76920b6dc", + "kind": "Deployment", + "name": "ABC", + "namespace": "oc-project"}, + {"context": "entity-compare-api", + "hash": "8b3e2f9b6499582cc3e4fe66681a7850", + "kind": "Deployment", + "name": "8080", + "namespace": "oc-project"}, + {"context": "favorite-api", + "hash": "96204a555b80b50b3cf49e5684f3daa9", + "kind": "Deployment", + "name": "8081", + "namespace": "oc-project"}, + {"context": "cm-types", + "hash": "a48eadd6d347e2591a3929df077aead7", + "kind": "ConfigMap", + "name": "config", + "namespace": "oc-project"}, + {"context": "ABC2", + "hash": "ea23b547bb9a06ca79d3dc23c3d1d0dd", + "kind": "Secret", + "name": "secret", + "namespace": "oc-project"}, + {"context": "var-append", + "hash": "e19d6902d55e14e99213b1d112acbde0", + "kind": "ConfigMap", + "name": "config", + "namespace": "oc-project"}, + ], state_update.stdin) + # Now deploy a single app + current_state = yaml.safe_load(state_update.stdin) + self._dummy_api.respond(['get', 'ConfigMap/octoploy-state', '-o', 'json'], json.dumps(current_state)) + self._dummy_api.respond(['get', 'Deployment/ABC', '-o', 'json'], '{}') + self._dummy_api.respond(['get', 'Deployment/8080', '-o', 'json'], '{}') + self._dummy_api.respond(['get', 'Deployment/8081', '-o', 'json'], '{}') + self._dummy_api.respond(['get', 'ConfigMap/config', '-o', 'json'], '{}') + self._dummy_api.respond(['get', 'Secret/secret', '-o', 'json'], '{}') + self._dummy_api.commands = [] + octoploy.octoploy._run_apps_deploy('app_deploy_test', self._mode) + + self.assertEqual(8, len(self._dummy_api.commands)) + for x in range(7): + self.assertEqual('get', self._dummy_api.commands[x].args[0]) + + state_update = self._dummy_api.commands[-1] + new_state = yaml.safe_load(state_update.stdin) + self.assertEqual(current_state, new_state) + + def test_removed_in_repo(self): + self._dummy_api.respond(['get', 'DeploymentConfig/ABC', '-o', 'json'], '', error=Exception('NotFound')) + self._dummy_api.respond(['get', 'ConfigMap/octoploy-state', '-o', 'json'], '''{ + "apiVersion": "v1", + "data": { + "state": "[{\\"apiVersion\\": \\"v1\\", \\"context\\": \\"ABC\\", \\"kind\\": \\"DeploymentConfig\\", \\"name\\": \\"ABC\\", \\"namespace\\": \\"oc-project\\"}]" + }, + "kind": "ConfigMap", + "metadata": { + "name": "octoploy-state" + } +}''') + + octoploy.octoploy._run_app_deploy('app_deploy_test_empty', 'app', self._mode) + + self.assertEqual(3, len(self._dummy_api.commands)) + self.assertEqual(['get', 'ConfigMap/octoploy-state', '-o', 'json'], self._dummy_api.commands[0].args) + self.assertEqual(['delete', 'DeploymentConfig/ABC'], self._dummy_api.commands[1].args) + + state_update = self._dummy_api.commands[2] + self.assertEqual(['apply', '-f', '-'], state_update.args) + self.assertStateEqual([], state_update.stdin) + + def assertStateEqual(self, expected: List[any], data: str): + k8s_object = yaml.safe_load(data) + state = yaml.safe_load(k8s_object['data']['state']) + self.assertEqual(len(expected), len(state)) + for entry in expected: + found_match = False + for existing in state: + if entry == existing: + found_match = True + break + self.assertTrue(found_match, f'{entry} not found in {state}') diff --git a/tests/TestUtils.py b/tests/TestUtils.py new file mode 100644 index 0000000..76dd17e --- /dev/null +++ b/tests/TestUtils.py @@ -0,0 +1,85 @@ +import io +import time +from typing import Optional, List +from unittest.mock import Mock + +from octoploy.api.Oc import Oc + +OCTOPLOY_KEY = 'key123' + + +class DummyCmd: + args = [] + namespace: None + stdin: None + + def __init__(self, args, stdin, namespace): + self.args = args + self.stdin = stdin + self.namespace = namespace + + def __repr__(self): + out = f'{self.args}' + if self.stdin is not None: + out += f' <- {self.stdin}' + return out + + +class DummyK8sApi(Oc): + def __init__(self): + super().__init__() + self.commands = [] + self.responds = [] + self._respond_not_found = False + + def not_found_by_default(self): + self._respond_not_found = True + + def respond(self, args: List[str], stdout: '', error=None): + self.responds.append((args, stdout, error)) + + def _exec(self, args, print_out: bool = False, stdin: str = None, namespace: Optional[str] = None) -> str: + self.commands.append(DummyCmd(args, stdin, namespace)) + for response in self.responds: + expected_args = response[0] + stdout = response[1] + error = response[2] + if expected_args == args: + if error is not None: + raise error + return stdout + if self._respond_not_found and args[0] == 'get': + raise Exception('NotFound') + return '{}' + + +class TestHelper: + @staticmethod + def mock_popen_success(mock_popen, stdout=b'output', stderr=b'error', sleep_time: int = 0, + return_code: int = 0): + """ + Modifies the Popen "poll" call to always return 0 + + :param mock_popen: mock of Popen + :param stdout: Std out that the process should return + :param stderr: Std err that the process should return + :param sleep_time: Number of seconds how long the poll() call should wait + :param return_code: Return code of the process + """ + + stdout_stream = io.BytesIO(stdout) + stderr_stream = io.BytesIO(stderr) + + def poll_sleep(): + if sleep_time > 0: + time.sleep(sleep_time) + return return_code + + process_mock = Mock() + attrs = {'communicate.return_value': (stdout, stderr), + 'stdout': stdout_stream, + 'stderr': stderr_stream, + 'poll': poll_sleep} + process_mock.configure_mock(**attrs) + # always return success for process execution + mock_popen.return_value = process_mock diff --git a/tests/app_deploy_test/_root.yml b/tests/app_deploy_test/_root.yml index 00c512b..6e9c9a9 100644 --- a/tests/app_deploy_test/_root.yml +++ b/tests/app_deploy_test/_root.yml @@ -1,3 +1,4 @@ namespace: 'oc-project' +stateName: '' vars: globalVar: 'global' \ No newline at end of file diff --git a/tests/app_deploy_test/app-for-each/_index.yml b/tests/app_deploy_test/app-for-each/_index.yml index e06c99e..46b3e32 100644 --- a/tests/app_deploy_test/app-for-each/_index.yml +++ b/tests/app_deploy_test/app-for-each/_index.yml @@ -5,9 +5,9 @@ vars: forEach: - APP_NAME: entity-compare-api - PARAM_A: 8080 + PARAM_A: "8080" SECOND: 1 - APP_NAME: favorite-api - PARAM_A: 8081 + PARAM_A: "8081" SECOND: 2 \ No newline at end of file diff --git a/tests/app_deploy_test/app-template-2/test.yml b/tests/app_deploy_test/app-template-2/test.yml index e171106..3bd5ab1 100644 --- a/tests/app_deploy_test/app-template-2/test.yml +++ b/tests/app_deploy_test/app-template-2/test.yml @@ -1,4 +1,4 @@ -kind: DeploymentConfig +kind: Deployment apiVersion: v1 metadata: name: "${PARAM_A}" diff --git a/tests/app_deploy_test/secrets/_index.yml b/tests/app_deploy_test/secrets/_index.yml index 4d05722..524d285 100644 --- a/tests/app_deploy_test/secrets/_index.yml +++ b/tests/app_deploy_test/secrets/_index.yml @@ -1,4 +1,4 @@ enabled: true -name: 'ABC' +name: 'ABC2' vars: ENCRYPTED: "OctoCrypt!p/qb2LYGbz2mJ5LVaNtItSgPGiN17OP1zUSp9ZKuWz1DI8iOG35xPA/CRTLYE1v3hZoDkjNEg0R77CzLDkw35g==" diff --git a/tests/app_deploy_test_empty/_root.yml b/tests/app_deploy_test_empty/_root.yml new file mode 100644 index 0000000..00c512b --- /dev/null +++ b/tests/app_deploy_test_empty/_root.yml @@ -0,0 +1,3 @@ +namespace: 'oc-project' +vars: + globalVar: 'global' \ No newline at end of file diff --git a/tests/app_deploy_test_empty/app/_index.yml b/tests/app_deploy_test_empty/app/_index.yml new file mode 100644 index 0000000..4edbe30 --- /dev/null +++ b/tests/app_deploy_test_empty/app/_index.yml @@ -0,0 +1,2 @@ +enabled: true +name: 'ABC' \ No newline at end of file diff --git a/tests/app_deploy_test_params/_root.yml b/tests/app_deploy_test_params/_root.yml new file mode 100644 index 0000000..00c512b --- /dev/null +++ b/tests/app_deploy_test_params/_root.yml @@ -0,0 +1,3 @@ +namespace: 'oc-project' +vars: + globalVar: 'global' \ No newline at end of file diff --git a/tests/app_deploy_test/app-params/_index.yml b/tests/app_deploy_test_params/app-params/_index.yml similarity index 100% rename from tests/app_deploy_test/app-params/_index.yml rename to tests/app_deploy_test_params/app-params/_index.yml diff --git a/tests/app_deploy_test/app-params/test.yml b/tests/app_deploy_test_params/app-params/test.yml similarity index 73% rename from tests/app_deploy_test/app-params/test.yml rename to tests/app_deploy_test_params/app-params/test.yml index c824d8e..4a0b5ce 100644 --- a/tests/app_deploy_test/app-params/test.yml +++ b/tests/app_deploy_test_params/app-params/test.yml @@ -1,3 +1,4 @@ kind: DC +apiVersion: v1 someObj: param: "${SomeParam}" \ No newline at end of file diff --git a/tests/lib/var-loader-app/dc.yml b/tests/lib/var-loader-app/dc.yml index eb332b0..8bfb87d 100644 --- a/tests/lib/var-loader-app/dc.yml +++ b/tests/lib/var-loader-app/dc.yml @@ -1,2 +1,3 @@ kind: Service +apiVersion: v1 KEY: ${CERT_KEY} \ No newline at end of file