@elastic/synthetics
Version:
Elastic synthetic monitoring agent
312 lines (277 loc) • 9.57 kB
text/typescript
/**
* 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<string>;
privateLocations: Array<string>;
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 <space> to select, <return> 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<PromptOptions>(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<string, string>();
// 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<string, string>();
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=<value> ' + 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();
}
}