# Copyright (C) 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os.path import stat import shutil import subprocess import sys import zipfile import _jsonnet import requests import yaml from ._globals import HELM_CHARTS TEMPLATES = [ "charts/namespace.yaml", "charts/prometheus", "charts/promtail", "charts/loki", "charts/grafana", "promtail", ] HELM_REPOS = { "grafana": "https://grafana.github.io/helm-charts", "loki": "https://grafana.github.io/loki/charts", "prometheus-community": "https://prometheus-community.github.io/helm-charts", } LOOSE_RESOURCES = [ "namespace.yaml", "configuration", "dashboards", "storage", ] def _create_dashboard_configmaps(output_dir, namespace): dashboards_dir = os.path.abspath("./dashboards") output_dir = os.path.join(output_dir, "dashboards") if not os.path.exists(output_dir): os.mkdir(output_dir) for dir_path, _, files in os.walk(dashboards_dir): for dashboard in files: dashboard_path = os.path.join(dir_path, dashboard) dashboard_name, ext = os.path.splitext(dashboard) if ext == ".json": source = f"--from-file={dashboard_path}" elif ext == ".jsonnet": json = _jsonnet.evaluate_file(dashboard_path, ext_codes={"publish": "false"}) source = f"--from-literal={dashboard_name}.json='{json}'" else: continue output_file = f"{output_dir}/{dashboard_name}.dashboard.yaml" command = ( f"kubectl create configmap {dashboard_name} -o yaml " f"{source} --dry-run=client --namespace={namespace} " f"> {output_file}" ) try: subprocess.check_output(command, shell=True) except subprocess.CalledProcessError as err: print(err.output) with open(output_file, "r") as f: dashboard_cm = yaml.load(f, Loader=yaml.SafeLoader) dashboard_cm["metadata"]["labels"] = dict() dashboard_cm["metadata"]["labels"]["grafana_dashboard"] = dashboard_name dashboard_cm["data"][f"{dashboard_name}.json"] = dashboard_cm["data"][ f"{dashboard_name}.json" ].replace('"${DS_PROMETHEUS}"', "null") with open(output_file, "w") as f: yaml.dump(dashboard_cm, f) def _create_promtail_configs(config, output_dir): if not os.path.exists(os.path.join(output_dir, "promtail")): os.mkdir(os.path.join(output_dir, "promtail")) with open(os.path.join(output_dir, "promtailLocalConfig.yaml")) as f: for promtail_config in yaml.load_all(f, Loader=yaml.SafeLoader): with open( os.path.join( output_dir, "promtail", "promtail-%s" % promtail_config["scrape_configs"][0]["static_configs"][0][ "labels" ]["host"], ), "w", ) as f: yaml.dump(promtail_config, f) os.remove(os.path.join(output_dir, "promtailLocalConfig.yaml")) if not config["tls"]["skipVerify"]: try: with open( os.path.join(output_dir, "promtail", "promtail.ca.crt"), "w" ) as f: f.write(config["tls"]["caCert"]) except TypeError: print("CA certificate for TLS verification has to be given.") def _download_promtail(output_dir): with open(os.path.abspath("./promtail/VERSION"), "r") as f: promtail_version = f.readlines()[0].strip() output_dir = os.path.join(output_dir, "promtail") output_zip = os.path.join(output_dir, "promtail.zip") response = requests.get( "https://github.com/grafana/loki/releases/download/v%s/promtail-linux-amd64.zip" % promtail_version, stream=True, ) with open(output_zip, "wb") as f: for chunk in response.iter_content(chunk_size=512): f.write(chunk) with zipfile.ZipFile(output_zip) as f: f.extractall(output_dir) promtail_exe = os.path.join(output_dir, "promtail-linux-amd64") os.chmod( promtail_exe, os.stat(promtail_exe).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH, ) os.remove(output_zip) def _run_ytt(config, output_dir): config_string = "#@data/values\n---\n" config_string += yaml.dump(config) command = [ "ytt", ] for template in TEMPLATES: command += ["-f", template] command += [ "--output-files", output_dir, "--ignore-unknown-comments", "-f", "-", ] try: # pylint: disable=E1123 print(subprocess.check_output(command, input=config_string, text=True)) except subprocess.CalledProcessError as err: print(err.output) def _update_helm_repos(): for repo, url in HELM_REPOS.items(): command = ["helm", "repo", "add", repo, url] try: subprocess.check_output(" ".join(command), shell=True) except subprocess.CalledProcessError as err: print(err.output) try: print(subprocess.check_output(["helm", "repo", "update"]).decode("utf-8")) except subprocess.CalledProcessError as err: print(err.output) def _deploy_loose_resources(output_dir): for resource in LOOSE_RESOURCES: command = [ "kubectl", "apply", "-f", f"{output_dir}/{resource}", ] print(subprocess.check_output(command).decode("utf-8")) def _get_installed_charts_in_namespace(namespace): command = ["helm", "ls", "-n", namespace, "--short"] return subprocess.check_output(command).decode("utf-8").split("\n") def _install_or_update_charts(output_dir, namespace): installed_charts = _get_installed_charts_in_namespace(namespace) charts_path = os.path.abspath("./charts") for chart, repo in HELM_CHARTS.items(): chart_name = chart + "-" + namespace with open(f"{charts_path}/{chart}/VERSION", "r") as f: chart_version = f.readlines()[0].strip() command = ["helm"] command.append("upgrade" if chart_name in installed_charts else "install") command += [ chart_name, repo, "--version", chart_version, "--values", f"{output_dir}/{chart}.yaml", "--namespace", namespace, ] try: print(subprocess.check_output(command).decode("utf-8")) except subprocess.CalledProcessError as err: print(err.output) def install(config_manager, output_dir, dryrun, update_repo): """Create the final configuration for the helm charts and Kubernetes resources and install them to Kubernetes, if not run in --dryrun mode. Arguments: config_manager {AbstractConfigManager} -- ConfigManager that contains the configuration of the monitoring setup to be uninstalled. output_dir {string} -- Path to the directory where the generated files should be safed in dryrun {boolean} -- Whether the installation will be run in dryrun mode update_repo {boolean} -- Whether to update the helm repositories locally """ config = config_manager.get_config() if not os.path.exists(output_dir): os.mkdir(output_dir) elif os.listdir(output_dir): while True: response = input( ( "Output directory already exists. This may lead to file conflicts " "and unwanted configuration applied to the cluster. Do you want " "to empty the directory? [y/n] " ) ) if response == "y": shutil.rmtree(output_dir) os.mkdir(output_dir) break if response == "n": print("Aborting installation. Please provide empty directory.") sys.exit(1) print("Unknown input.") _run_ytt(config, output_dir) namespace = config_manager.get_config()["namespace"] _create_dashboard_configmaps(output_dir, namespace) if os.path.exists(os.path.join(output_dir, "promtailLocalConfig.yaml")): _create_promtail_configs(config, output_dir) if not dryrun: _download_promtail(output_dir) if not dryrun: if update_repo: _update_helm_repos() _deploy_loose_resources(output_dir) _install_or_update_charts(output_dir, namespace)