From 4692ee77ad92e4a474506429840a1394749c55da Mon Sep 17 00:00:00 2001 From: Henok Date: Tue, 20 Aug 2024 14:13:40 +0300 Subject: [PATCH] feat(#8551): removes python from cht-deploy --- package.json | 3 +- scripts/deploy/.eslintrc | 8 + scripts/deploy/.gitignore | 2 + scripts/deploy/cht-deploy | 124 ++++++--- scripts/deploy/package.json | 54 ++++ scripts/deploy/src/certificate.js | 122 +++++++++ scripts/deploy/src/config.js | 11 + scripts/deploy/src/error.js | 13 + scripts/deploy/src/install.js | 168 ++++++++++++ scripts/deploy/{ => src}/prepare.sh | 0 scripts/deploy/tasks.py | 240 ------------------ scripts/deploy/tests/helm.test.js | 159 ++++++++++++ .../tests/package-json-validate.test.js | 65 +++++ .../deploy/tests/validate-arguments.test.js | 74 ++++++ scripts/deploy/troubleshooting/get-all-logs | 68 +++++ 15 files changed, 829 insertions(+), 282 deletions(-) create mode 100644 scripts/deploy/.eslintrc create mode 100644 scripts/deploy/.gitignore create mode 100644 scripts/deploy/package.json create mode 100644 scripts/deploy/src/certificate.js create mode 100644 scripts/deploy/src/config.js create mode 100644 scripts/deploy/src/error.js create mode 100644 scripts/deploy/src/install.js rename scripts/deploy/{ => src}/prepare.sh (100%) delete mode 100644 scripts/deploy/tasks.py create mode 100644 scripts/deploy/tests/helm.test.js create mode 100644 scripts/deploy/tests/package-json-validate.test.js create mode 100644 scripts/deploy/tests/validate-arguments.test.js create mode 100755 scripts/deploy/troubleshooting/get-all-logs diff --git a/package.json b/package.json index 35c5d37758e..8be3e628e9b 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,12 @@ "unit-nginx": "cd nginx/tests && make test", "unit-haproxy": "cd haproxy/tests && make test", "unit-haproxy-healthcheck": "cd haproxy-healthcheck && make test", + "unit-cht-deploy": "cd scripts/deploy && npm test", "wdio-default-mobile-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/default-mobile/wdio.conf.js --suite=all", "wdio-local": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && wdio run ./tests/e2e/default/wdio.conf.js", "-- CI SCRIPTS ": "-----------------------------------------------------------------------------------------------", "build": "./scripts/build/build-ci.sh", - "ci-compile": "node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run unit-nginx && npm run unit-haproxy && npm run unit-haproxy-healthcheck && npm run build && npm run integration-api && npm run unit", + "ci-compile": "node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run unit-nginx && npm run unit-haproxy && npm run unit-haproxy-healthcheck && npm run unit-cht-deploy && npm run build && npm run integration-api && npm run unit", "ci-integration-all": "mocha --config tests/integration/.mocharc-all.js", "ci-integration-all-k3d": "mocha --config tests/integration/.mocharc-k3d.js", "ci-integration-sentinel": "mocha --config tests/integration/.mocharc-sentinel.js", diff --git a/scripts/deploy/.eslintrc b/scripts/deploy/.eslintrc new file mode 100644 index 00000000000..1b60f2fa62c --- /dev/null +++ b/scripts/deploy/.eslintrc @@ -0,0 +1,8 @@ +{ + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "no-console": "off" + } +} diff --git a/scripts/deploy/.gitignore b/scripts/deploy/.gitignore new file mode 100644 index 00000000000..9db4ee631e1 --- /dev/null +++ b/scripts/deploy/.gitignore @@ -0,0 +1,2 @@ +*.tar.gz +*.tgz diff --git a/scripts/deploy/cht-deploy b/scripts/deploy/cht-deploy index 4b120b0e3a2..97c8f4e3fdf 100755 --- a/scripts/deploy/cht-deploy +++ b/scripts/deploy/cht-deploy @@ -1,41 +1,83 @@ -#!/bin/bash - -# Check if Python is installed -if ! command -v python3 &> /dev/null ; then - echo "Python is not installed. Please install Python 3." - exit 1 -fi - -# Check if pip is installed -if ! command -v pip3 &> /dev/null ; then - echo "pip is not installed. Please install pip for Python 3." - exit 1 -fi - -# Check if Invoke is installed -if ! python3 -c "import invoke" &> /dev/null ; then - echo "Invoke is not installed. Installing it..." - pip3 install invoke --quiet -fi - -# Check if PyYAML is installed -if ! python3 -c "import yaml" &> /dev/null ; then - echo "PyYAML is not installed. Installing it..." - pip3 install PyYAML --quiet -fi - -# Check if requests is installed -if ! python3 -c "import requests" &> /dev/null ; then - echo "Requests is not installed. Installing it..." - pip3 install requests --quiet -fi - -# Validate that -f argument is provided -if [[ $1 != "-f" || -z $2 ]]; then - echo "No values file provided. Please specify a values file using -f " - exit 1 -fi - -# Pass command line arguments to invoke script -# shellcheck disable=SC2068 # wontfix script will be replaced "soon" -invoke install $@ +#!/usr/bin/env node + +import { install } from './src/install.js'; +import fs from 'fs'; +import semver from 'semver'; +import path from 'path'; + +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const validateNodeVersion = function() { + const packageJsonPath = path.resolve(__dirname, 'package.json'); + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const requiredNodeVersion = packageJson.engines && packageJson.engines.node; + + if (requiredNodeVersion && !semver.satisfies(process.version, requiredNodeVersion)) { + console.error(`Invalid Node.js version. Required: ${requiredNodeVersion}. Current: ${process.version}`); + process.exit(1); + } + } catch (err) { + console.error('Error reading package.json:', err.message); + process.exit(1); + } +}; + +const validateArguments = function() { + const args = process.argv.slice(2); + if (args.length < 2 || args[0] !== '-f' || !args[1]) { + console.error('No values file provided. Please specify a values file using -f '); + process.exit(1); + } + if (validateFileExists(args[1])) { + return args; + } +}; + +const validateFileExists = function(filePath) { + try { + fs.accessSync(filePath); + return true; + } catch (err) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } +}; + +const runInstallScript = async function(args) { + try { + const valuesFilePath = args[1]; + await install(valuesFilePath); + } catch (err) { + console.error('Error executing the install script:', err.message); + console.error(JSON.stringify(err)); + process.exit(1); + } +}; + +const main = async function() { + validateNodeVersion(); + const args = validateArguments(); + await runInstallScript(args); +}; + +if (import.meta.url === `file://${process.argv[1]}`) { //Make sure the script is being run directly and not being imported + main().catch((err) => { + console.error('An error occurred:', err.message); + console.error(JSON.stringify(err)); + process.exit(1); + }); +} + +export { + main, + validateNodeVersion, + validateArguments, + validateFileExists, + runInstallScript +}; diff --git a/scripts/deploy/package.json b/scripts/deploy/package.json new file mode 100644 index 00000000000..01d1d074f5c --- /dev/null +++ b/scripts/deploy/package.json @@ -0,0 +1,54 @@ +{ + "name": "@medic/cht-deploy", + "version": "1.0.10", + "description": "## Deploy the CHT", + "main": "src/install.js", + "directories": { + "test": "tests" + }, + "engines": { + "node": ">=20.11.0", + "npm": ">=10.2.4" + }, + "bin": { + "cht-deploy": "./cht-deploy", + "upload": "./cht-deploy", + "get-all-logs": "./troubleshooting/get-all-logs", + "describe-deployment": "./troubleshooting/describe-deployment", + "list-all-resources": "./troubleshooting/list-all-resources", + "list-deployments": "./troubleshooting/list-deployments", + "restart-deployment": "./troubleshooting/restart-deployment", + "view-logs": "./troubleshooting/view-logs" + }, + "scripts": { + "test": "mocha 'tests/*.js'" + }, + "mocha": { + "timeout": 5000, + "recursive": true + }, + "files": [ + "src", + "cht-deploy", + "troubleshooting", + "README.md", + "package.json" + ], + "keywords": [], + "author": "", + "license": "AGPL-3.0-only", + "type": "module", + "devDependencies": { + "@kubernetes/client-node": "^0.19.0", + "@sinonjs/fake-timers": "^11.2.2", + "chai": "^4.4.1", + "chai-as-promised": "^7.1.2", + "mocha": "^10.4.0", + "sinon": "^17.0.1" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "node-fetch": "^3.3.2", + "semver": "^7.6.2" + } +} diff --git a/scripts/deploy/src/certificate.js b/scripts/deploy/src/certificate.js new file mode 100644 index 00000000000..6d2cd8598c7 --- /dev/null +++ b/scripts/deploy/src/certificate.js @@ -0,0 +1,122 @@ +import fs from 'fs'; +import { execSync } from 'child_process'; +import fetch from 'node-fetch'; +import { UserRuntimeError, CertificateError } from './error.js'; +import config from './config.js'; + +const CERT_SOURCES = { + FILE: 'specify-file-path', + MY_IP: 'my-ip-co', + EKS: 'eks-medic' +}; + +const cert = config.CERT_FILE; +const key = config.KEY_FILE; + +const obtainCertificateAndKey = async function(values) { + console.log('Obtaining certificate...'); + const certSource = values.cert_source || ''; + + const handlers = { + [CERT_SOURCES.FILE]: () => handleFileSource(values), + [CERT_SOURCES.MY_IP]: () => handleMyIpSource(), + [CERT_SOURCES.EKS]: () => {} // No action needed for 'eks-medic' + }; + + const handler = handlers[certSource]; + if (!handler) { + throw new UserRuntimeError(`cert_source must be one of: ${Object.values(CERT_SOURCES).join(', ')}`); + } + + await handler(); +}; + +const handleFileSource = function({ certificate_crt_file_path, certificate_key_file_path }) { + if (!certificate_crt_file_path || !certificate_key_file_path) { + throw new CertificateError('certificate_crt_file_path and certificate_key_file_path must be set for file source'); + } + + copyFile(certificate_crt_file_path, cert); + copyFile(certificate_key_file_path, key); +}; + +const handleMyIpSource = async function() { + const [crtData, keyData] = await Promise.all([ + fetchData(`${config.CERT_API_URL}/fullchain`), + fetchData(`${config.CERT_API_URL}/key`) + ]); + + writeFile(cert, crtData); + writeFile(key, keyData); +}; + +const copyFile = function(src, dest) { + try { + fs.copyFileSync(src, dest); + } catch (error) { + throw new CertificateError(`Failed to copy file from ${src} to ${dest}: ${error.message}`); + } +}; + +const writeFile = function(filename, data) { + try { + fs.writeFileSync(filename, data); + } catch (error) { + throw new CertificateError(`Failed to write file ${filename}: ${error.message}`); + } +}; + +const fetchData = async function(url) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.FETCH_TIMEOUT); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + return await response.text(); + } catch (error) { + if (error.name === 'AbortError') { + throw new CertificateError(`Request to ${url} timed out`); + } + throw new CertificateError(`Failed to fetch data from ${url}: ${error.message}`); + } +}; + +const createSecret = async function (namespace, values) { + console.log('Checking if secret api-tls-secret already exists...'); + try { + execSync(`kubectl get secret api-tls-secret -n ${namespace}`, { stdio: 'inherit' }); //NoSONAR + console.log('TLS secret already exists. Skipping creation.'); + return; + } catch (err) { + if (err.message.includes('NotFound')) { + console.log('Secret does not exist. Creating Secret from certificate and key...'); + } else { + throw err; + } + } + + await obtainCertificateAndKey(values); + + execSync( //NoSONAR + `kubectl -n ${namespace} create secret tls api-tls-secret --cert=${cert} --key=${key}`, //NoSONAR + { stdio: 'inherit' } + ); + cleanupFiles(); +}; + +const cleanupFiles = function() { + deleteFile(cert); + deleteFile(key); +}; + +const deleteFile = function(filename) { + try { + if (fs.existsSync(filename)) { + fs.unlinkSync(filename); + } + } catch (error) { + console.error(`Failed to delete file ${filename}: ${error.message}`); + } +}; + +export { obtainCertificateAndKey, createSecret }; diff --git a/scripts/deploy/src/config.js b/scripts/deploy/src/config.js new file mode 100644 index 00000000000..e2a9c1090bb --- /dev/null +++ b/scripts/deploy/src/config.js @@ -0,0 +1,11 @@ +export default { + MEDIC_REPO_NAME: process.env.MEDIC_REPO_NAME || 'medic', + MEDIC_REPO_URL: process.env.MEDIC_REPO_URL || 'https://docs.communityhealthtoolkit.org/helm-charts', + CHT_CHART_NAME: process.env.CHT_CHART_NAME || 'medic/cht-chart-4x', + DEFAULT_CHART_VERSION: process.env.DEFAULT_CHART_VERSION || '1.0.*', + IMAGE_TAG_API_URL: process.env.IMAGE_TAG_API_URL || 'https://staging.dev.medicmobile.org/_couch/builds_4', + CERT_FILE: process.env.CERT_FILE || 'certificate.crt', + KEY_FILE: process.env.KEY_FILE || 'private.key', + CERT_API_URL: process.env.CERT_API_URL || 'https://local-ip.medicmobile.org', + FETCH_TIMEOUT: parseInt(process.env.FETCH_TIMEOUT, 10) || 5000, +}; diff --git a/scripts/deploy/src/error.js b/scripts/deploy/src/error.js new file mode 100644 index 00000000000..9eb79ad6f9c --- /dev/null +++ b/scripts/deploy/src/error.js @@ -0,0 +1,13 @@ +export class UserRuntimeError extends Error { + constructor(message) { + super(message); + this.name = 'UserRuntimeError'; + } +} + +export class CertificateError extends Error { + constructor(message) { + super(message); + this.name = 'CertificateError'; + } +} diff --git a/scripts/deploy/src/install.js b/scripts/deploy/src/install.js new file mode 100644 index 00000000000..a15c8a06140 --- /dev/null +++ b/scripts/deploy/src/install.js @@ -0,0 +1,168 @@ +import fs from 'fs'; +import child_process from 'child_process'; +import fetch from 'node-fetch'; +import yaml from 'js-yaml'; +import path from 'path'; + +const MEDIC_REPO_NAME = 'medic'; +const MEDIC_REPO_URL = 'https://docs.communityhealthtoolkit.org/helm-charts'; +const CHT_CHART_NAME = `${MEDIC_REPO_NAME}/cht-chart-4x`; +const DEFAULT_CHART_VERSION = '1.0.*'; + +import { fileURLToPath } from 'url'; +import { obtainCertificateAndKey, createSecret } from './certificate.js'; +import { UserRuntimeError } from './error.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const readFile = function(f) { + try { + return yaml.load(fs.readFileSync(f, 'utf8')); + } catch (err) { + console.error(err); + process.exit(1); + } +}; + +const prepare = function(f) { + const values = readFile(f); + const environment = values.environment || ''; + const scriptPath = path.join(__dirname, 'prepare.sh'); + child_process.execSync(`${scriptPath} ${environment}`, { stdio: 'inherit' }); //NoSONAR +}; + +const loadValues = function(f) { + if (!f) { + console.error('No values file provided. Please specify a values file using -f '); + process.exit(1); + } + return readFile(f); +}; + +const determineNamespace = function(values) { + const namespace = values.namespace || ''; + if (!namespace) { + console.error('Namespace is not specified.'); + process.exit(1); + } + return namespace; +}; + +const getImageTag = async function(chtversion) { + const response = await fetch(`https://staging.dev.medicmobile.org/_couch/builds_4/medic:medic:${chtversion}`); + const data = await response.json(); + const tag = data.tags && data.tags[0]; + if (!tag) { + return Promise.reject(new UserRuntimeError('cht image tag not found')); + } + return tag.image.split(':').pop(); +}; + +const getChartVersion = function(values) { + return values.cht_chart_version || DEFAULT_CHART_VERSION; +}; + +const helmCmd = (action, positionalArgs, params) => { + const flagsArray = Object.entries(params).map(([key, value]) => { + if (value === true) { + return `--${key}`; + } + if (value) { + return `--${key} ${value}`; + } + return ''; //If value is falsy, don't include the flag + }).filter(Boolean); + + const command = `helm ${action} ${positionalArgs.join(' ')} ${flagsArray.join(' ')}`; + return child_process.execSync(command, { stdio: 'inherit' }); //NoSONAR +}; + +const helmInstallOrUpdate = function(valuesFile, namespace, values, imageTag) { + const chartVersion = getChartVersion(values); + ensureMedicHelmRepo(); + const projectName = values.project_name || ''; + const namespaceExists = checkNamespaceExists(namespace); + + try { + const releaseExists = child_process.execSync(`helm list -n ${namespace}`).toString() //NoSONAR + .includes(projectName); + + const commonOpts = { + 'version': chartVersion, + 'namespace': namespace, + 'values': valuesFile, + 'set': `cht_image_tag=${imageTag}` + }; + + if (releaseExists) { + console.log('Release exists. Performing upgrade.'); + helmCmd('upgrade', [projectName, CHT_CHART_NAME], { + install: true, + ...commonOpts + }); + console.log(`Instance at https://${values.ingress.host} upgraded successfully.`); + return; + } + + console.log('Release does not exist. Performing install.'); + helmCmd('install', [projectName, CHT_CHART_NAME], { + ...commonOpts, + 'create-namespace': !namespaceExists + }); + console.log(`Instance installed successfully: https://${values.ingress.host}`); + + } catch (err) { + console.error(err.message); + if (err.stack) { + console.error(err.stack); + } + process.exit(1); + } +}; + +const checkNamespaceExists = function(namespace) { + try { + const result = child_process.execSync(`kubectl get namespace ${namespace}`).toString(); //NoSONAR + return result.includes(namespace); //NoSONAR + } catch (err) { + return false; + } +}; + +const ensureMedicHelmRepo = function() { + try { + const repoList = child_process.execSync(`helm repo list -o json`).toString(); + const repos = JSON.parse(repoList); + const medicRepo = repos.find(repo => repo.name === MEDIC_REPO_NAME); + if (!medicRepo) { + console.log(`Helm repo ${MEDIC_REPO_NAME} not found, adding..`); + child_process.execSync(`helm repo add ${MEDIC_REPO_NAME} ${MEDIC_REPO_URL}`, { stdio: 'inherit' }); //NoSONAR + return; + } else if (medicRepo.url.replace(/\/$/, '') !== MEDIC_REPO_URL) { + throw new UserRuntimeError(`Medic repo found but url not matching '${MEDIC_REPO_URL}', see: helm repo list`); + } + // Get the latest + child_process.execSync(`helm repo update ${MEDIC_REPO_NAME}`, { stdio: 'inherit' }); //NoSONAR + } catch (err) { + console.error(err.message); + if (err.stack) { + console.error(err.stack); + } + process.exit(1); + } +}; + +const install = async function(f) { + prepare(f); + const values = loadValues(f); + const namespace = determineNamespace(values); + if (values.cluster_type === 'k3s-k3d') { + await obtainCertificateAndKey(values); + createSecret(namespace, values); + } + const imageTag = await getImageTag(values.chtversion); + helmInstallOrUpdate(f, namespace, values, imageTag); +}; + +export { install, helmInstallOrUpdate, ensureMedicHelmRepo }; diff --git a/scripts/deploy/prepare.sh b/scripts/deploy/src/prepare.sh similarity index 100% rename from scripts/deploy/prepare.sh rename to scripts/deploy/src/prepare.sh diff --git a/scripts/deploy/tasks.py b/scripts/deploy/tasks.py deleted file mode 100644 index 72421be4307..00000000000 --- a/scripts/deploy/tasks.py +++ /dev/null @@ -1,240 +0,0 @@ -from invoke import task -import os -import subprocess -import yaml -import requests -import re -import json - -MEDIC_REPO_NAME = "medic" -MEDIC_REPO_URL = "https://docs.communityhealthtoolkit.org/helm-charts" -CHT_CHART_NAME = f"{MEDIC_REPO_NAME}/cht-chart-4x" -DEFAULT_CHART_VERSION = "0.2.3" - -class UserRuntimeError(RuntimeError): - """ - An unrecoverable RuntimeError due to an users configuration or environment - that the user is expected to address - """ - pass - -@task -def prepare(c, f): - with open(f, 'r') as stream: - try: - values = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - exit(1) - environment = values.get('environment', '') - subprocess.run(["./prepare.sh", environment], check=True) - -@task -def load_values(c, f): - if not f: - print("No values file provided. Please specify a values file using -f ") - exit(1) - with open(f, 'r') as stream: - try: - values = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - exit(1) - return values - -@task -def determine_namespace(c, f, values): - namespace = values.get('namespace', '') - if not namespace: - print("Namespace is not specified.") - exit(1) - return namespace - -@task -def check_namespace_exists(c, namespace): - script_dir = os.path.dirname(os.path.abspath(__file__)) - namespace_exists = subprocess.run(["kubectl", "get", "namespace", namespace], capture_output=True, text=True).returncode - if namespace_exists != 0: - print(f"Namespace {namespace} does not exist. Creating the namespace.") - namespace_manifest = f''' -apiVersion: v1 -kind: Namespace -metadata: - name: {namespace} -''' - with open(os.path.join(script_dir, "helm", "namespace.yaml"), 'w') as manifest_file: # NOSONAR - manifest_file.write(namespace_manifest) - subprocess.run(["kubectl", "apply", "-f", os.path.join(script_dir, "helm", "namespace.yaml")], check=True) # NOSONAR - # Delete the namespace file after creation - os.remove(os.path.join(script_dir, "helm", "namespace.yaml")) # NOSONAR - else: - print(f"Namespace {namespace} already exists.") - -@task -def obtain_certificate_and_key(c, values): # NOSONAR - print("Obtaining certificate...") - if values.get('cert_source', '') == 'specify-file-path': - crt_file_path = values.get('certificate_crt_file_path', '') - key_file_path = values.get('certificate_key_file_path', '') - if crt_file_path and key_file_path: - subprocess.run(["cp", crt_file_path, "certificate.crt"], check=True) # NOSONAR - subprocess.run(["cp", key_file_path, "private.key"], check=True) # NOSONAR - else: - raise Exception("certificate_crt_file_path and certificate_key_file_path must be set in values when cert_source is 'specify-file-path'") # NOSONAR - elif values.get('cert_source', '') == 'my-ip-co': - subprocess.run(["curl", "https://local-ip.medicmobile.org/fullchain", "-o", "certificate.crt"], check=True) # NOSONAR - subprocess.run(["curl", "https://local-ip.medicmobile.org/key", "-o", "private.key"], check=True) # NOSONAR - elif values.get('cert_source', '') == 'eks-medic': - print("Moving on. Certificate provided by the eks cluster.") - else: - raise UserRuntimeError("cert_source must be either 'specify-file-path', 'my-ip-co', or 'eks-medic'") - -@task -def create_secret(c, namespace, values): - print("Checking if secret api-tls-secret already exists...") - secret_exists = subprocess.run(["kubectl", "get", "secret", "api-tls-secret", "-n", namespace], capture_output=True, text=True).returncode - if secret_exists != 0: - print("Secret does not exist. Creating Secret from certificate and key...") - obtain_certificate_and_key(c, values) # Ensure the certificate and key are available before creating the secret - subprocess.run(["kubectl", "-n", namespace, "create", "secret", "tls", "api-tls-secret", "--cert=certificate.crt", "--key=private.key"], check=True) - os.remove("certificate.crt") - os.remove("private.key") - else: - print("Secret api-tls-secret already exists.") - -@task -def get_image_tag(c, chtversion): - response = requests.get(f'https://staging.dev.medicmobile.org/_couch/builds_4/medic:medic:{chtversion}') - response.raise_for_status() - data = response.json() - - for tag in data['tags']: - if tag['container_name'] == 'cht-api': - image_tag = tag['image'].split(':')[-1] - return image_tag - - raise Exception('cht image tag not found') # NOSONAR - -def setup_etc_hosts(c, values): # NOSONAR - # If the cluster_type is k3s-k3d and cert_source is my-ip-co, add the host to /etc/hosts - if values.get('cluster_type', '') == 'k3s-k3d' and values.get('cert_source', '') == 'my-ip-co': - host = values.get('ingress', {}).get('host', '') - proc = subprocess.Popen(['sudo', 'cat', '/etc/hosts'], stdout=subprocess.PIPE) - lines = [line.decode('utf-8') for line in proc.stdout.readlines()] - - # Regular expression for a host entry line - host_re = re.compile(r'^127\.0\.0\.1\s+' + re.escape(host) + r'(\s|$)') - - # Check if the host line exists and points to 127.0.0.1 - if not any(host_re.match(line) for line in lines): - command = ['sudo', 'bash', '-c', f'echo "127.0.0.1 {host}" >> /etc/hosts'] - subprocess.run(command) - else: - print("Cluster type is not k3s-k3d or ingress choice is not my-ip-co. Skipping /etc/hosts setup.") - -@task -def add_route53_entry(c, f): # NOSONAR - values = load_values(c, f) - host = values.get('ingress', {}).get('host', '') - load_balancer = values.get('ingress', {}).get('load_balancer', '') - hosted_zone_id = values.get('ingress', {}).get('hostedZoneId', '') - - if host and load_balancer and hosted_zone_id: - # Check if the record already exists - check_cmd = f"aws route53 list-resource-record-sets --hosted-zone-id {hosted_zone_id}" - result = subprocess.run(check_cmd, shell=True, capture_output=True, text=True) - records = json.loads(result.stdout)["ResourceRecordSets"] - - record_exists = any(record["Name"] == host and record["Type"] == "CNAME" for record in records) - if not record_exists: - # Add the record - add_cmd = f"aws route53 change-resource-record-sets --hosted-zone-id {hosted_zone_id} --change-batch '{{\"Changes\": [{{\"Action\": \"CREATE\", \"ResourceRecordSet\": {{\"Name\": \"{host}\", \"Type\": \"CNAME\", \"TTL\": 300, \"ResourceRecords\": [{{\"Value\": \"{load_balancer}\"}}]}}}}]}}'" - subprocess.run(add_cmd, shell=True) - print(f"Route53 entry added for {host}") - else: - print(f"Route53 entry for {host} already exists") - -@task -def helm_install_or_upgrade(c, f, namespace, values, image_tag): # NOSONAR - chart_version = _get_chart_version(values) - _ensure_medic_helm_repo(chart_version) - project_name = values.get("project_name", "") - release_exists = subprocess.run( - ["helm", "list", "-n", namespace], capture_output=True, text=True - ).stdout - if project_name in release_exists: - print("Release exists. Performing upgrade.") - subprocess.run( - [ - "helm", - "upgrade", - "--install", - project_name, - CHT_CHART_NAME, - "--version", - chart_version, - "--namespace", - namespace, - "--values", - f, - "--set", - f"cht_image_tag={image_tag}", - ], - check=True, - ) - else: - print("Release does not exist. Performing install.") - subprocess.run( - [ - "helm", - "install", - project_name, - CHT_CHART_NAME, - "--version", - chart_version, - "--namespace", - namespace, - "--values", - f, - "--set", - f"cht_image_tag={image_tag}", - ], - check=True, - ) - - -@task -def install(c, f): - prepare(c, f) - values = load_values(c, f) - namespace = determine_namespace(c, f, values) - check_namespace_exists(c, namespace) - if values.get('cluster_type', '') == 'k3s-k3d': - obtain_certificate_and_key(c, values) - create_secret(c, namespace, values) - if values.get('environment', '') == 'local': - setup_etc_hosts(c, values) - image_tag = get_image_tag(c, values.get('chtversion', '')) - helm_install_or_upgrade(c, f, namespace, values, image_tag) - add_route53_entry(c, f) - - -def _ensure_medic_helm_repo(chart_version): - "Make sure helm repo 'medic' is added and updated if needed" - repo_list_command = subprocess.run(["helm", "repo", "list", "-o", "json"], capture_output=True, check=True) - repo_list = json.loads(repo_list_command.stdout) - medic_repo = next((i for i in repo_list if i["name"] == MEDIC_REPO_NAME), None) - if medic_repo is None: - print(f"Helm repo {MEDIC_REPO_NAME} not found, adding..") - subprocess.run(["helm", "repo", "add", MEDIC_REPO_NAME, MEDIC_REPO_URL], check=True) - elif medic_repo["url"].rstrip("/") != MEDIC_REPO_URL: - raise UserRuntimeError(f"Medic repo found but url not matching '{MEDIC_REPO_URL}', see: helm repo list") - - show_chart_command = subprocess.run(["helm", "pull", CHT_CHART_NAME, "--version", chart_version], check=False) - if show_chart_command.returncode != 0: - print(f"Chart version {chart_version} not found, trying repo update...") - subprocess.run(["helm", "repo", "update", MEDIC_REPO_NAME], check=True) - - -def _get_chart_version(values): - return values.get("cht_chart_version", DEFAULT_CHART_VERSION) diff --git a/scripts/deploy/tests/helm.test.js b/scripts/deploy/tests/helm.test.js new file mode 100644 index 00000000000..218dbf302b1 --- /dev/null +++ b/scripts/deploy/tests/helm.test.js @@ -0,0 +1,159 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import child_process from 'child_process'; +import { helmInstallOrUpdate, ensureMedicHelmRepo } from '../src/install.js'; + +const MEDIC_REPO_NAME = 'medic'; +const MEDIC_REPO_URL = 'https://docs.communityhealthtoolkit.org/helm-charts'; +const CHT_CHART_NAME = `${MEDIC_REPO_NAME}/cht-chart-4x`; + +describe('helmInstallOrUpdate function', () => { + const fakeValuesFile = 'path_to_fake_values.yaml'; + const fakeNamespace = 'test-namespace'; + const fakeValues = { + project_name: 'test-project', + ingress: { + host: 'test.host.com' + }, + cht_chart_version: '5.0.0' + }; + const fakeImageTag = 'latest'; + + beforeEach(() => { + sinon.stub(child_process, 'execSync'); + sinon.stub(process, 'exit'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should upgrade an existing release', () => { + // Mock the responses + child_process.execSync.withArgs('helm repo list -o json') + .returns(Buffer.from(JSON.stringify([{ name: MEDIC_REPO_NAME, url: MEDIC_REPO_URL }]))); + child_process.execSync.withArgs(`helm repo update ${MEDIC_REPO_NAME}`) + .returns(Buffer.from('Successfully got an update from the "medic" chart repository')); + child_process.execSync.withArgs(`kubectl get namespace ${fakeNamespace}`) + .returns(Buffer.from(`NAME STATUS AGE\n${fakeNamespace} Active 10d`)); + child_process.execSync.withArgs(`helm list -n ${fakeNamespace}`) + .returns(Buffer.from('NAME REVISION UPDATED ' + + 'STATUS CHART APP VERSION NAMESPACE\n' + + 'test-project 1 2023-07-05 10:00:00.000000000 +0000 UTC deployed ' + + 'cht-chart-4x-1.0.0 1.0.0 test-namespace')); + const upgradeCommand = `helm upgrade test-project ${CHT_CHART_NAME} --install --version 5.0.0 ` + + `--namespace ${fakeNamespace} --values ${fakeValuesFile} --set cht_image_tag=${fakeImageTag}`; + child_process.execSync.withArgs(upgradeCommand, { stdio: 'inherit' }) + .returns(Buffer.from('Release "test-project" has been upgraded. Happy Helming!')); + + helmInstallOrUpdate(fakeValuesFile, fakeNamespace, fakeValues, fakeImageTag); + + expect(child_process.execSync.callCount).to.equal(5); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + expect(child_process.execSync.getCall(1).args[0]).to.equal(`helm repo update ${MEDIC_REPO_NAME}`); + expect(child_process.execSync.getCall(2).args[0]).to.equal(`kubectl get namespace ${fakeNamespace}`); + expect(child_process.execSync.getCall(3).args[0]).to.equal(`helm list -n ${fakeNamespace}`); + expect(child_process.execSync.getCall(4).args[0]).to.equal(upgradeCommand); + expect(child_process.execSync.getCall(4).args[1]).to.deep.equal({ stdio: 'inherit' }); + }); + + it('should install a new release when no release exists', () => { + // Mock the responses + child_process.execSync.withArgs('helm repo list -o json') + .returns(Buffer.from(JSON.stringify([{ name: MEDIC_REPO_NAME, url: MEDIC_REPO_URL }]))); + child_process.execSync.withArgs(`helm repo update ${MEDIC_REPO_NAME}`) + .returns(Buffer.from('Successfully got an update from the "medic" chart repository')); + child_process.execSync.withArgs(`kubectl get namespace ${fakeNamespace}`) + .throws(new Error('Error from server (NotFound): namespaces "test-namespace" not found')); + child_process.execSync.withArgs(`helm list -n ${fakeNamespace}`) + .returns(Buffer.from('')); + + const installCommand = `helm install test-project ${CHT_CHART_NAME} --version 5.0.0 --namespace ${fakeNamespace} ` + + `--values ${fakeValuesFile} --set cht_image_tag=${fakeImageTag} --create-namespace`; + child_process.execSync.withArgs(installCommand, { stdio: 'inherit' }) + .returns(Buffer.from('Release "test-project" has been installed. Happy Helming!')); + + helmInstallOrUpdate(fakeValuesFile, fakeNamespace, fakeValues, fakeImageTag); + + expect(child_process.execSync.callCount).to.equal(5); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + expect(child_process.execSync.getCall(1).args[0]).to.equal(`helm repo update ${MEDIC_REPO_NAME}`); + expect(child_process.execSync.getCall(2).args[0]).to.equal(`kubectl get namespace ${fakeNamespace}`); + expect(child_process.execSync.getCall(3).args[0]).to.equal(`helm list -n ${fakeNamespace}`); + expect(child_process.execSync.getCall(4).args[0]).to.equal(installCommand); + expect(child_process.execSync.getCall(4).args[1]).to.deep.equal({ stdio: 'inherit' }); + }); + + it('should exit when error thrown during helm list', () => { + // Mock the responses + child_process.execSync.withArgs('helm repo list -o json') + .returns(Buffer.from(JSON.stringify([{ name: MEDIC_REPO_NAME, url: MEDIC_REPO_URL }]))); + child_process.execSync.withArgs(`helm repo update ${MEDIC_REPO_NAME}`) + .returns(Buffer.from('Successfully got an update from the "medic" chart repository')); + child_process.execSync.withArgs(`kubectl get namespace ${fakeNamespace}`) + .returns(Buffer.from(`NAME STATUS AGE\n${fakeNamespace} Active 10d`)); + child_process.execSync.withArgs(`helm list -n ${fakeNamespace}`) + .throws(new Error('Error: could not find tiller')); + + helmInstallOrUpdate(fakeValuesFile, fakeNamespace, fakeValues, fakeImageTag); + + expect(process.exit.calledWith(1)).to.be.true; + expect(child_process.execSync.callCount).to.equal(4); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + expect(child_process.execSync.getCall(1).args[0]).to.equal(`helm repo update ${MEDIC_REPO_NAME}`); + expect(child_process.execSync.getCall(2).args[0]).to.equal(`kubectl get namespace ${fakeNamespace}`); + expect(child_process.execSync.getCall(3).args[0]).to.equal(`helm list -n ${fakeNamespace}`); + }); +}); + +describe('ensureMedicHelmRepo function', () => { + beforeEach(() => { + sinon.stub(child_process, 'execSync'); + sinon.stub(process, 'exit'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should handle helm repo not found and add it', () => { + // Mock the responses + child_process.execSync.withArgs('helm repo list -o json').returns(Buffer.from('[]')); + child_process.execSync.withArgs(`helm repo add ${MEDIC_REPO_NAME} ${MEDIC_REPO_URL}`, { stdio: 'inherit' }) + .returns(Buffer.from('"medic" has been added to your repositories')); + + ensureMedicHelmRepo(); + + expect(child_process.execSync.callCount).to.equal(2); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + expect(child_process.execSync.getCall(1).args[0]).to.equal(`helm repo add ${MEDIC_REPO_NAME} ${MEDIC_REPO_URL}`); + expect(child_process.execSync.getCall(1).args[1]).to.deep.equal({ stdio: 'inherit' }); + }); + + it('should handle existing helm repo and update it', () => { + // Mock the responses + const fakeRepoList = JSON.stringify([{ name: MEDIC_REPO_NAME, url: MEDIC_REPO_URL }]); + child_process.execSync.withArgs('helm repo list -o json').returns(Buffer.from(fakeRepoList)); + child_process.execSync.withArgs(`helm repo update ${MEDIC_REPO_NAME}`, { stdio: 'inherit' }) + .returns(Buffer.from('Successfully got an update from the "medic" chart repository')); + + ensureMedicHelmRepo(); + + expect(child_process.execSync.callCount).to.equal(2); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + expect(child_process.execSync.getCall(1).args[0]).to.equal(`helm repo update ${MEDIC_REPO_NAME}`); + expect(child_process.execSync.getCall(1).args[1]).to.deep.equal({ stdio: 'inherit' }); + }); + + it('should exit if helm repo url does not match', () => { + // Mock the responses + const fakeRepoList = JSON.stringify([{ name: MEDIC_REPO_NAME, url: 'https://wrong.url' }]); + child_process.execSync.withArgs('helm repo list -o json').returns(Buffer.from(fakeRepoList)); + + ensureMedicHelmRepo(); + + expect(process.exit.calledWith(1)).to.be.true; + expect(child_process.execSync.callCount).to.equal(1); + expect(child_process.execSync.getCall(0).args[0]).to.equal('helm repo list -o json'); + }); +}); diff --git a/scripts/deploy/tests/package-json-validate.test.js b/scripts/deploy/tests/package-json-validate.test.js new file mode 100644 index 00000000000..d00d022fd81 --- /dev/null +++ b/scripts/deploy/tests/package-json-validate.test.js @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import fs from 'fs'; +import semver from 'semver'; +import { validateNodeVersion } from '../cht-deploy'; + +describe('validateNodeVersion', () => { + beforeEach(() => { + sinon.stub(fs, 'readFileSync'); + sinon.stub(semver, 'satisfies'); + sinon.stub(console, 'error'); + sinon.stub(process, 'exit'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should exit with code 1 if package.json cannot be read', () => { + fs.readFileSync.throws(new Error('File not found')); + + validateNodeVersion(); + + expect(console.error.calledWith('Error reading package.json:', 'File not found')).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + }); + + it('should exit with code 1 if Node.js version does not satisfy the required version', () => { + fs.readFileSync.returns(JSON.stringify({ + engines: { + node: '>=12.0.0' + } + })); + semver.satisfies.returns(false); + + validateNodeVersion(); + + expect(console.error + .calledWith(`Invalid Node.js version. Required: >=12.0.0. Current: ${process.version}`)).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + }); + + it('should not exit if Node.js version satisfies the required version', () => { + fs.readFileSync.returns(JSON.stringify({ + engines: { + node: '>=12.0.0' + } + })); + semver.satisfies.returns(true); + + validateNodeVersion(); + + expect(console.error.called).to.be.false; + expect(process.exit.called).to.be.false; + }); + + it('should not exit if no engines field is present in package.json', () => { + fs.readFileSync.returns(JSON.stringify({})); + + validateNodeVersion(); + + expect(console.error.called).to.be.false; + expect(process.exit.called).to.be.false; + }); +}); diff --git a/scripts/deploy/tests/validate-arguments.test.js b/scripts/deploy/tests/validate-arguments.test.js new file mode 100644 index 00000000000..e48c530a3a8 --- /dev/null +++ b/scripts/deploy/tests/validate-arguments.test.js @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import fs from 'fs'; +import { validateArguments } from '../cht-deploy'; + +describe('validateArguments', () => { + let processArgvBackup; + + beforeEach(() => { + sinon.stub(console, 'error'); + sinon.stub(process, 'exit'); + processArgvBackup = process.argv; + }); + + afterEach(() => { + sinon.restore(); + process.argv = processArgvBackup; + }); + + it('should exit with code 1 if no arguments are provided', () => { + process.argv = ['node', 'cht-deploy']; + + validateArguments(); + + expect(console.error + .calledWith('No values file provided. Please specify a values file using -f ')).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + }); + + it('should exit with code 1 if only one argument is provided', () => { + process.argv = ['node', 'cht-deploy', '-f']; + + validateArguments(); + + expect(console.error + .calledWith('No values file provided. Please specify a values file using -f ')).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + }); + + it('should exit with code 1 if the first argument is not -f', () => { + process.argv = ['node', 'cht-deploy', '-x', 'values.yaml']; + + validateArguments(); + + expect(console.error + .calledWith('No values file provided. Please specify a values file using -f ')).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + }); + + it('should exit with code 1 if the specified file does not exist', () => { + sinon.stub(fs, 'accessSync').throws(new Error('File not found')); + process.argv = ['node', 'cht-deploy', '-f', 'values.yaml']; + + validateArguments(); + + expect(console.error.calledWith('File not found: values.yaml')).to.be.true; + expect(process.exit.calledWith(1)).to.be.true; + + fs.accessSync.restore(); + }); + + it('should return arguments if -f and a valid file are provided', () => { + sinon.stub(fs, 'accessSync').returns(undefined); + process.argv = ['node', 'cht-deploy', '-f', 'values.yaml']; + + const args = validateArguments(); + + expect(args).to.deep.equal(['-f', 'values.yaml']); + expect(console.error.called).to.be.false; + expect(process.exit.called).to.be.false; + + fs.accessSync.restore(); + }); +}); diff --git a/scripts/deploy/troubleshooting/get-all-logs b/scripts/deploy/troubleshooting/get-all-logs new file mode 100755 index 00000000000..8e8979bd54d --- /dev/null +++ b/scripts/deploy/troubleshooting/get-all-logs @@ -0,0 +1,68 @@ +#!/bin/bash + +function usage() { + echo "Usage: $0 [since]" + echo + echo "This script saves current and previous logs from all pods within the specified namespace into local files and then creates a tar.gz archive of these logs." + echo + echo "Arguments:" + echo " namespace - The namespace from which to retrieve and archive pod logs." + echo " since - (Optional) Only return logs newer than a relative duration like 5s, 2m, or 3h. Defaults to all logs." + echo + echo "Example: $0 mynamespace 24h" + echo + echo "Note: Logs may contain Personally Identifiable Information (PII). Handle and store them securely." + exit 1 +} + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + usage +fi + +NAMESPACE=$1 +SINCE=${2:-} # Optional since parameter +TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") +LOG_DIR=$(mktemp -d "${NAMESPACE}-logs_${TIMESTAMP}-XXXXXX") + +trap 'rm -rf "$LOG_DIR"' EXIT + +# Get a list of all pod names in the namespace +PODS=$(kubectl -n "$NAMESPACE" get pods -o jsonpath="{.items[*].metadata.name}") + +if [ -z "$PODS" ]; then + echo "No Pods found in Namespace $NAMESPACE." + exit 1 +fi + +# Loop through all pod names and fetch logs, saving them to individual files +for POD in $PODS; do + echo "Fetching logs for pod: $POD" + + # Fetch current logs + if [ -z "$SINCE" ]; then + kubectl -n "$NAMESPACE" logs "$POD" > "$LOG_DIR/${POD}_current.log" + else + kubectl -n "$NAMESPACE" logs --since="$SINCE" "$POD" > "$LOG_DIR/${POD}_current.log" + fi + + # Fetch previous logs + if [ -z "$SINCE" ]; then + kubectl -n "$NAMESPACE" logs --previous "$POD" > "$LOG_DIR/${POD}_previous.log" 2>/dev/null + else + kubectl -n "$NAMESPACE" logs --since="$SINCE" --previous "$POD" > "$LOG_DIR/${POD}_previous.log" 2>/dev/null + fi + + # If previous log file is empty, remove it + if [ ! -s "$LOG_DIR/${POD}_previous.log" ]; then + rm "$LOG_DIR/${POD}_previous.log" + fi +done + +# Create a tar.gz archive of the log files +if tar -czf "$NAMESPACE-logs_$TIMESTAMP.tar.gz" "$LOG_DIR"; then + echo "Logs archived in $NAMESPACE-logs_$TIMESTAMP.tar.gz" + echo "Note: Logs may contain Personally Identifiable Information (PII). Handle and store them securely." +else + echo "Failed to create archive." + exit 1 +fi