def get_certification_issuer(self, track: str) -> Optional[str]: logger.info(icon=f"{self.ICON} 🏵️️", title="Checking certification issuer", end="") raise_exception = False if settings.K8S_CLUSTER_ISSUER: cert_issuer: str = settings.K8S_CLUSTER_ISSUER logger.info(message=" (settings): ", end="") raise_exception = True else: cert_issuer = f"certificate-letsencrypt-{track}" logger.info(message=" (track): ", end="") os_command = ["kubectl", "get", "clusterissuer", cert_issuer] result = run_os_command(os_command, shell=True) if not result.return_code: logger.success(message=cert_issuer) return cert_issuer else: error_message = f'No issuer "{cert_issuer}" found, using cluster defaults' if raise_exception: logger.error(message=error_message, raise_exception=True) else: logger.info(message=error_message) return None
def _handle_api_error(error: ApiException, raise_client_exception: bool = False) -> Any: """ Handle a ApiException from the Kubernetes client Args: error: ApiException to handle raise_client_exception: Should the method raise an error on client errors Returns: The the stringified version of the errors JSON body Raises: ApiException: If the ``raise_client_exception`` argument is set to ``True`` """ error_body = loads_json(error.body) if not error_body: error_body = {"message": "An unknown error occurred"} if Kubernetes._is_client_error(error.status): reason = camel_case_split(str(error_body.get("reason", "Unknown"))) logger.info( title=f"{cf.yellow}{cf.bold}{reason}{cf.reset}", message= f" ({error.status} {cf.italic}{error_body['message'].capitalize()}{cf.reset})", ) if raise_client_exception: raise error else: logger.error(error=error, raise_exception=True) return error_body
def _parse_file_secrets( self, filesecrets: Dict[str, str]) -> Tuple[Dict[str, str], Dict[str, str]]: if settings.active_ci: valid_prefixes = settings.active_ci.VALID_FILE_SECRET_PATH_PREFIXES else: raise ImproperlyConfigured("An active CI is needed") filecontents = {} mapping = {} for name, filename in filesecrets.items(): path = Path(filename) if not validate_file_secret_path(path, valid_prefixes): # TODO: This needs refactoring. Variable names do not match the contents. # If path-variable doesn't contain a valid path, we expect it to contain # the value for the secret file and we can use it directly. logger.warning( f'Not a valid file path for a file "{name}". Using contents as a secret value.' ) # Here we expect that filename-variable contains the secret filecontents[name] = b64encode( filename.encode("UTF-8")).decode("UTF-8") mapping[name] = f"{settings.K8S_FILE_SECRET_MOUNTPATH}/{name}" continue try: filecontents[name] = self._b64_encode_file(path) mapping[name] = f"{settings.K8S_FILE_SECRET_MOUNTPATH}/{name}" except OSError as e: logger.error(f'Error while reading a file: "{path}"', error=e) return filecontents, mapping
def login( self, ci_jwt: str = settings.VAULT_JWT, ci_jwt_private_key: str = settings.VAULT_JWT_PRIVATE_KEY, ) -> None: if self.initialized: try: secret_path = f"{settings.PROJECT_NAME}-{self.track}" if ci_jwt_private_key: ci_jwt = encode( { "user": secret_path, "aud": secret_path, "exp": datetime.utcnow() + timedelta(seconds=60), "iat": datetime.utcnow(), }, ci_jwt_private_key, algorithm="RS256", ) response = self.client.auth.jwt.jwt_login( role=secret_path, jwt=ci_jwt, path=settings.VAULT_JWT_AUTH_PATH, ) self.client.token = response["auth"]["client_token"] except hvac.exceptions.Unauthorized as e: logger.error( icon=f"{self.ICON} 🔑", message="Vault login failed!", error=e, raise_exception=True, )
def project_deployment_complete( self, exception: Optional[Exception], namespace: str, project: "Project", track: str, ) -> Optional[bool]: if not self.configured or exception is not None: return None deployment_message = new_environment_message(track, project) try: self.client.chat_postMessage( channel=self.SLACK_CHANNEL, blocks=deployment_message, username="******", icon_emoji=":rocket:", ) except SlackApiError as e: logger.error( message=f"Could not send slack message -> {e.response['error']}" ) return True
def get_chart_name(chart: str) -> str: chart_name = chart.split("/")[-1:] if not chart_name or not chart_name[0]: logger.error( message=f"No chart name found in {chart}", error=ValueError(), raise_exception=True, ) return chart_name[0]
def get_helm_path() -> Path: application_path = Path(settings.PROJECT_DIR) helm_path = application_path / "helm" auto_helm_path = settings.devops_root_path / "helm" if not helm_path.exists() and auto_helm_path.exists(): shutil.copytree(auto_helm_path, helm_path) elif not helm_path.exists(): logger.error( message="Could not find Helm chart to use", error=OSError(), raise_exception=True, ) return helm_path
def _create_basic_auth_data( self, basic_auth_users: List[BasicAuthUser] = settings.K8S_INGRESS_BASIC_AUTH ) -> Dict[str, str]: """ Create secret data from list of `BasicAuthUser` The user credentials from the list of users will be encrypted and added to a temporary file using the `htpasswd` tool from Apache. The file is then read and base64 encoded (as required by Kubernetes secrets). Args: basic_auth_users: List of `BasicAuthUser`s Returns: A dict with the key `auth` and base64 content of a htpasswd file as value """ logger.info(icon=f"{self.ICON} 🔨", title="Generating basic auth data: ", end="") if not basic_auth_users: return {} with tempfile.NamedTemporaryFile() as f: passwd_path = Path(f.name) for i, user in enumerate(basic_auth_users): os_command = ["htpasswd", "-b"] if i == 0: os_command.append("-c") os_command += [str(passwd_path), user.username, user.password] result = run_os_command(os_command) if result.return_code: logger.error( message= "The 'htpasswd' command failed to create an entry", raise_exception=True, ) encoded_file = self._b64_encode_file(passwd_path) logger.success() logger.info( message= f"\t {len(settings.K8S_INGRESS_BASIC_AUTH)} users will be added to basic auth" ) return {"auth": encoded_file}
def get_secrets(self) -> Dict[str, str]: if self.initialized: secrets_list = {} secret_path = ( settings.VAULT_PROJECT_SECRET_NAME if settings.VAULT_PROJECT_SECRET_NAME else f"{settings.PROJECT_NAME}-{self.track}" ) try: logger.info( icon=f"{self.ICON} 🔑", message=f"Checking for secrets in {settings.VAULT_KV_SECRET_MOUNT_POINT}/{secret_path}", ) secrets = {} if settings.VAULT_KV_VERSION == 2: secrets = self.client.secrets.kv.read_secret_version( path=secret_path, mount_point=settings.VAULT_KV_SECRET_MOUNT_POINT, ) secrets_list = secrets["data"]["data"] else: secrets = self.client.secrets.kv.v1.read_secret( path=secret_path, mount_point=settings.VAULT_KV_SECRET_MOUNT_POINT, ) secrets_list = secrets["data"] # Check secrets defined by Terraform if settings.VAULT_TF_SECRETS and settings.VAULT_KV_VERSION == 2: secrets_list = self._read_tf_secrets(secret_path, secrets_list) # Check for file type secrets for key, value in list(secrets_list.items()): if key.startswith(settings.K8S_FILE_SECRET_PREFIX): secrets_list.pop(key) self._create_file_secrets(key, value) except hvac.exceptions.InvalidPath as e: logger.error( icon=f"{self.ICON} 🔑", message="Secrets not found ", error=e, raise_exception=False, ) return secrets_list return {}
def create_client(self, track: str) -> k8s_client.ApiClient: try: kubeconfig, method = settings.setup_kubeconfig(track) except NoClusterConfigError as exc: logger.error( icon=f"{self.ICON} 🔑", message="Can't log in to Kubernetes cluster, all auth methods exhausted", error=exc, raise_exception=True, ) logger.success( icon=f"{self.ICON} 🔑", message=f"Using {method} for Kubernetes auth" ) config = k8s_client.Configuration() k8s_config.load_kube_config(client_configuration=config, config_file=kubeconfig) return k8s_client.ApiClient(configuration=config)
def _parse_file_secrets( self, filesecrets: Dict[str, str]) -> Tuple[Dict[str, str], Dict[str, str]]: if settings.active_ci: valid_prefixes = settings.active_ci.VALID_FILE_SECRET_PATH_PREFIXES else: raise ImproperlyConfigured("An active CI is needed") filecontents = {} mapping = {} for name, filename in filesecrets.items(): path = Path(filename) if not validate_file_secret_path(path, valid_prefixes): logger.warning(f'Not a valid file path: "{path}". Skipping.') continue try: filecontents[name] = self._b64_encode_file(path) mapping[name] = f"{settings.K8S_FILE_SECRET_MOUNTPATH}/{name}" except OSError as e: logger.error(f'Error while reading a file: "{path}"', error=e) return filecontents, mapping
def upgrade_chart( self, name: str, values: HelmValues, namespace: str, chart: str = "", chart_path: Optional[Path] = None, values_files: Optional[List[Path]] = None, install: bool = True, version: Optional[str] = None, raise_exception: bool = True, ) -> SubprocessResult: if chart_path: if not chart_path.is_absolute(): chart_path = settings.devops_root_path / chart_path if not chart_path.exists(): logger.error( message=f"Path '{str(chart_path)}' does not exist", error=OSError(), raise_exception=True, ) chart = str(chart_path) logger.info( icon=f"{self.ICON} 📄", title=f"Upgrading chart from '{chart}': ", end="", ) replica_timeout_multiplier = 2 if settings.K8S_REPLICACOUNT > 1 else 1 timeout = ( (settings.K8S_PROBE_INITIAL_DELAY * replica_timeout_multiplier) + (settings.K8S_PROBE_FAILURE_THRESHOLD * settings.K8S_PROBE_PERIOD) + 120 # Buffer time ) # Construct initial helm upgrade command install_arg = "--install" if install else "" helm_command = [ "helm", "upgrade", "--atomic", "--timeout", f"{timeout}s", "--history-max", "30", install_arg, "--namespace", f"{namespace}", ] if version: helm_command += ["--version", version] # Add values files if values_files: helm_command += self.get_chart_params(flag="--values", values=values_files) safe_name = kubernetes_safe_name(name=name) values_yaml = yaml.dump(values) with NamedTemporaryFile(buffering=0) as fobj: fobj.write(values_yaml.encode()) result = run_os_command( [*helm_command, "--values", fobj.name, f"{safe_name}", f"{chart}"], ) if result.return_code: logger.std(result, raise_exception=raise_exception) return result logger.success() logger.info(f"\tName: {safe_name} (orig: {name})") logger.info(f"\tNamespace: {namespace}") return result