/** * MIT License * * Copyright (c) 2020-present, Elastic NV * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * */ import { URL } from 'url'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { bold, cyan, yellow } from 'kleur/colors'; import { join, relative, dirname, basename } from 'path'; // @ts-ignore-next-line: has no exported member 'Input' import { prompt, Input, Password } from 'enquirer'; import { getProjectApiKeyURL, progress, write as stdWrite } from '../helpers'; import { getPackageManager, replaceTemplates, runCommand, cloudIDToKibanaURL, } from './utils'; import { formatLocations, getLocations, groupLocations } from '../locations'; import { ALLOWED_SCHEDULES } from '../dsl/monitor'; import type { ProjectSettings } from '../common_types'; // Templates that are required for setting up new synthetics project const templateDir = join(__dirname, '..', '..', 'templates'); type PromptOptions = ProjectSettings & { locations: Array; privateLocations: Array; schedule: number; }; // exported for testing export const REGULAR_FILES_PATH = [ 'journeys/example.journey.ts', 'journeys/advanced-example-helpers.ts', 'journeys/advanced-example.journey.ts', 'lightweight/heartbeat.yml', '.github/workflows/run-synthetics.yml', 'README.md', ]; export const CONFIG_PATH = 'synthetics.config.ts'; const IS_URL = new RegExp('^(https?:\\/\\/)'); export class Generator { pkgManager = 'npm'; constructor(public projectDir: string) {} async directory() { progress( `Initializing Synthetics project in '${ relative(process.cwd(), this.projectDir) || '.' }'` ); if (!existsSync(this.projectDir)) { await mkdir(this.projectDir); } } async questions() { if (process.env.TEST_QUESTIONS) { return JSON.parse(process.env.TEST_QUESTIONS); } const url = await new Input({ name: 'url', message: 'Enter Elastic Kibana URL or Cloud ID', required: true, validate(value) { try { if (!IS_URL.test(value)) { value = cloudIDToKibanaURL(value); } new URL(value); return true; } catch (e) { return 'Invalid URL or Cloud ID'; } }, result(value: string) { if (!IS_URL.test(value)) { value = cloudIDToKibanaURL(value); } return value; }, }).run(); const auth = await new Password({ name: 'auth', header: yellow( `Generate API key from Kibana ${getProjectApiKeyURL(url)}` ), required: true, message: 'What is your API key', }).run(); const allLocations = await getLocations({ url, auth }); const { allLocations: locChoices } = formatLocations(allLocations); if (locChoices.length === 0) { throw 'Follow the docs to set up your first private locations - https://www.elastic.co/guide/en/observability/current/uptime-set-up-choose-agent.html#private-locations'; } const monitorQues = [ { type: 'select', name: 'locations', hint: '(Use to select, to submit)', message: 'Select the locations where you want to run monitors', choices: locChoices, multiple: true, validate(value) { return value.length === 0 ? `Select at least one option.` : true; }, }, { type: 'select', name: 'schedule', message: 'Set default schedule in minutes for all monitors', initial: '10', // set default schedule to 10 minutes choices: ALLOWED_SCHEDULES.map(String), required: true, }, { type: 'input', name: 'id', message: 'Choose project id to logically group monitors', initial: basename(this.projectDir), }, { type: 'input', name: 'space', message: 'Choose the target Kibana space', initial: 'default', }, ]; // Split and group private and public locations from the answered list. const answers = await prompt(monitorQues); // Casting the schedule value via the result() prompt option from enquirer // causes a misbehaviour in certain circumstances. That's why we perform // the casting here. Please see https://github.com/elastic/synthetics/pull/771#issuecomment-1551519148 for more context answers.schedule = Number(answers.schedule); const { locations, privateLocations } = groupLocations(answers.locations); return { ...answers, url, locations, privateLocations }; } async files(answers: PromptOptions) { const fileMap = new Map(); // Setup Synthetics config file fileMap.set( CONFIG_PATH, replaceTemplates( await readFile(join(templateDir, CONFIG_PATH), 'utf-8'), answers ) ); // Setup non-templated files Promise.all( REGULAR_FILES_PATH.map(async file => { fileMap.set(file, await readFile(join(templateDir, file), 'utf-8')); }) ).catch(e => { throw e; }); // Create files for (const [relativePath, content] of fileMap) { await this.createFile(relativePath, content); } } async createFile(relativePath: string, content: string, override = false) { const absolutePath = join(this.projectDir, relativePath); if (!override && existsSync(absolutePath)) { const { override } = await prompt<{ override: boolean }>({ type: 'confirm', name: 'override', message: `File ${relativePath} already exists. Override it?`, initial: false, }); if (!override) return; } progress(`Writing ${relative(process.cwd(), absolutePath)}.`); await mkdir(dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, content, 'utf-8'); } async package() { this.pkgManager = await getPackageManager(this.projectDir); const commands = new Map(); commands.set( `Setting up project using ${this.pkgManager == 'yarn' ? 'Yarn' : 'NPM'}`, this.pkgManager == 'yarn' ? 'yarn init -y' : 'npm init -y' ); const pkgName = '@elastic/synthetics'; commands.set( `Installing @elastic/synthetics library`, this.pkgManager == 'yarn' ? `yarn add -dev ${pkgName} --silent` : `npm i -d ${pkgName} --quiet` ); // Execute commands for (const [name, command] of commands) { progress(`${name}...`); execSync(command, { stdio: 'inherit', cwd: this.projectDir, }); } } async patchPkgJSON() { const filename = 'package.json'; const pkgJSON = JSON.parse( await readFile(join(this.projectDir, filename), 'utf-8') ); if (!pkgJSON.scripts) { pkgJSON.scripts = {}; } // Add test command if (!pkgJSON.scripts.test) { pkgJSON.scripts.test = 'npx @elastic/synthetics journeys'; } // Add push command if (!pkgJSON.scripts.push) { pkgJSON.scripts.push = `npx @elastic/synthetics push`; } await this.createFile( filename, JSON.stringify(pkgJSON, null, 2) + '\n', true ); } async patchGitIgnore() { const gitIgnorePath = join(this.projectDir, '.gitignore'); let gitIgnore = ''; if (existsSync(gitIgnorePath)) { const contents = await readFile(gitIgnorePath, 'utf-8'); gitIgnore += contents.trimEnd() + '\n'; } if (!gitIgnore.includes('node_modules')) { gitIgnore += 'node_modules/\n'; } if (!gitIgnore.includes('.synthetics')) { gitIgnore += '.synthetics/\n'; } await writeFile(gitIgnorePath, gitIgnore, 'utf-8'); } banner() { stdWrite( bold(` All set, you can run below commands inside: ${this.projectDir}: Run synthetic tests: ${cyan(runCommand(this.pkgManager, 'test'))} Push monitors to Kibana: ${cyan( 'SYNTHETICS_API_KEY= ' + runCommand(this.pkgManager, 'push') )} ${yellow( 'Configure API Key via `SYNTHETICS_API_KEY` env variable or --auth CLI flag.' )} Visit https://www.elastic.co/guide/en/observability/current/synthetic-run-tests.html to learn more. `) ); } async setup() { await this.directory(); const answers = await this.questions(); await this.package(); await this.files(answers); await this.patchPkgJSON(); await this.patchGitIgnore(); this.banner(); } }