diff --git a/.gitignore b/.gitignore index f4bc382c..aca3b6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ local .tsbuildinfo **/checkly-github-report.md **/checkly-summary.md -**/e2e/__tests__/fixtures/empty-project/e2e-test-project-* \ No newline at end of file +**/e2e/__tests__/fixtures/empty-project/e2e-test-project-* +storage +htpasswd diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 07992498..ae9771e9 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -1,22 +1,34 @@ import * as api from '../rest/api' +import { runtimes } from '../rest/api' import config from '../services/config' import prompts from 'prompts' import { Flags, ux } from '@oclif/core' import { AuthCommand } from './authCommand' import { parseProject } from '../services/project-parser' import { loadChecklyConfig } from '../services/checkly-config-loader' -import { runtimes } from '../rest/api' import type { Runtime } from '../rest/runtimes' import { - Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard, - MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, - Project, ProjectData, BrowserCheck, + AlertChannel, + AlertChannelSubscription, + BrowserCheck, + Check, + CheckGroup, + Dashboard, + MaintenanceWindow, + PrivateLocation, + PrivateLocationCheckAssignment, + PrivateLocationGroupAssignment, + Project, + ProjectData, + ProjectPayload, } from '../constructs' import chalk from 'chalk' -import { splitConfigFilePath, getGitInformation } from '../services/util' +import { getGitInformation, splitConfigFilePath } from '../services/util' import commonMessages from '../messages/common-messages' import { ProjectDeployResponse } from '../rest/projects' import { uploadSnapshots } from '../services/snapshot-service' +import { DeployPreview } from '../services/deploy-preview' +import { TableCli } from '../services/table-cli' // eslint-disable-next-line no-restricted-syntax enum ResourceDeployStatus { @@ -136,7 +148,8 @@ export default class Deploy extends AuthCommand { try { const { data } = await api.projects.deploy({ ...projectPayload, repoInfo }, { dryRun: preview, scheduleOnDeploy }) if (preview || output) { - this.log(this.formatPreview(data, project)) + const preview = await this.formatPreview(data, project, projectPayload) + this.log(preview) } if (!preview) { await ux.wait(500) @@ -161,7 +174,11 @@ export default class Deploy extends AuthCommand { } } - private formatPreview (previewData: ProjectDeployResponse, project: Project): string { + private async formatPreview ( + previewData: ProjectDeployResponse, + project: Project, + projectPayload: ProjectPayload, + ): Promise { // Current format of the data is: { checks: { logical-id-1: 'UPDATE' }, groups: { another-logical-id: 'CREATE' } } // We convert it into update: [{ logicalId, resourceType, construct }, ...], create: [], delete: [] // This makes it easier to display. @@ -257,10 +274,34 @@ export default class Deploy extends AuthCommand { output.push('') } if (sortedUpdating.length) { + const deployPreviewInst = new DeployPreview(projectPayload) + const deployPreviewDiff = await deployPreviewInst.getDiff() + const table = new TableCli<{ + paramName: string; + currentValue: string; + newValue: string; + }>() output.push(chalk.bold.magenta('Update and Unchanged:')) for (const { logicalId, construct } of sortedUpdating) { output.push(` ${construct.constructor.name}: ${logicalId}`) } + deployPreviewDiff.forEach((resource) => { + if (resource.diffResult) { + output.push(` ${resource.resourceType}: ${resource.logicalId}`) + const outputTable = table.drawTable( + Object.entries(resource.diffResult).map(([key, value]) => { + return { + paramName: key, + currentValue: value?.[0] !== undefined ? String(value[0]) : '', + newValue: value?.[1] !== undefined ? String(value[1]) : '', + } + }), + ['paramName', 'currentValue', 'newValue'], + ['Param Name', 'Current Value', 'New Value'], + ) + output.push(outputTable.join('\n')) + } + }) output.push('') } if (skipping.length) { diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 8d9d798a..5e66aff0 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -183,3 +183,8 @@ export class Session { return Session.privateLocations } } + +export type ProjectPayload = { + project: Pick + resources: ResourceSync[] +} diff --git a/packages/cli/src/rest/alert-channels.ts b/packages/cli/src/rest/alert-channels.ts new file mode 100644 index 00000000..d94aab09 --- /dev/null +++ b/packages/cli/src/rest/alert-channels.ts @@ -0,0 +1,39 @@ +import type { AxiosInstance } from 'axios' + +interface Subscription { + id: number; + checkId: string; + activated: boolean; + groupId: string | null; +} + +export interface AlertChannelApi { + id: number; + type: string; + config: { + number?: string; + address?: string; + }; + created_at: string; + updated_at: string | null; + sendRecovery: boolean; + sendFailure: boolean; + sendDegraded: boolean; + sslExpiry: boolean; + sslExpiryThreshold: number; + autoSubscribe: boolean; + subscriptions: Subscription[]; +} + +class AlertChannels { + protected api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll () { + return this.api.get('/v1/alert-channels') + } +} + +export default AlertChannels diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index 3f6cd398..1b432d79 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -13,6 +13,7 @@ import TestSessions from './test-sessions' import EnvironmentVariables from './environment-variables' import HeartbeatChecks from './heartbeat-checks' import ChecklyStorage from './checkly-storage' +import AlertChannels from './alert-channels' export function getDefaults () { const apiKey = config.getApiKey() @@ -100,3 +101,4 @@ export const testSessions = new TestSessions(api) export const environmentVariables = new EnvironmentVariables(api) export const heartbeatCheck = new HeartbeatChecks(api) export const checklyStorage = new ChecklyStorage(api) +export const alertChannels = new AlertChannels(api) diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 0d6900ac..52b203a6 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -24,6 +24,7 @@ export interface ResourceSync { member: boolean, payload: any, } + export interface ProjectSync { project: Project, resources: Array, diff --git a/packages/cli/src/services/__tests__/__testcases__/compareobjectswithexistingkeys.case.ts b/packages/cli/src/services/__tests__/__testcases__/compareobjectswithexistingkeys.case.ts new file mode 100644 index 00000000..2058b4e3 --- /dev/null +++ b/packages/cli/src/services/__tests__/__testcases__/compareobjectswithexistingkeys.case.ts @@ -0,0 +1,38 @@ +export interface CompareObjectsWithExistingKeysCase { + input: { + obj1: object + obj2: object + } + expected: Record | null +} + +export const compareObjectsWithExistingKeysCases: CompareObjectsWithExistingKeysCase[] = [ + { + input: { + obj1: { a: 1, b: { c: 2, d: 3 } }, + obj2: { a: 1, b: { c: 2, d: 4 } }, + }, + expected: { 'b.d': [4, 3] }, + }, + { + input: { + obj1: { a: 1, b: 2 }, + obj2: { a: 1, b: 3 }, + }, + expected: { b: [3, 2] }, + }, + { + input: { + obj1: { a: { b: { c: 1 } } }, + obj2: { a: { b: { c: 2 } } }, + }, + expected: { 'a.b.c': [2, 1] }, + }, + { + input: { + obj1: { a: 1, b: 2 }, + obj2: { a: 1, b: 2 }, + }, + expected: null, + }, +] diff --git a/packages/cli/src/services/__tests__/__testcases__/uniqvalromarrbykey.case.ts b/packages/cli/src/services/__tests__/__testcases__/uniqvalromarrbykey.case.ts new file mode 100644 index 00000000..a318f2fd --- /dev/null +++ b/packages/cli/src/services/__tests__/__testcases__/uniqvalromarrbykey.case.ts @@ -0,0 +1,43 @@ +export interface UniqValFromArrByKeyCase { + input: { + arr: any[] + key: string + } + expected: any[] +} + +export const uniqValFromArrByKeyCases: UniqValFromArrByKeyCase[] = [ + { + input: { + arr: [ + { value1: 'alert-channel', id: 1 }, + { value1: 'check', id: 2 }, + { value1: 'alert-channel', id: 3 }, + ], + key: 'value1', + }, + expected: ['alert-channel', 'check'], + }, + { + input: { + arr: [ + { value2: 'A', value: 10 }, + { value2: 'B', value: 20 }, + { value2: 'A', value: 30 }, + ], + key: 'value2', + }, + expected: ['A', 'B'], + }, + { + input: { + arr: [ + { value2: 'A', value: 10 }, + { value2: 'B', value: 20 }, + { value2: 'A', value: 30 }, + ], + key: 'value', + }, + expected: [10, 20, 30], + }, +] diff --git a/packages/cli/src/services/__tests__/util.spec.ts b/packages/cli/src/services/__tests__/util.spec.ts index 56d123a9..bdf120db 100644 --- a/packages/cli/src/services/__tests__/util.spec.ts +++ b/packages/cli/src/services/__tests__/util.spec.ts @@ -1,5 +1,7 @@ import * as path from 'path' -import { pathToPosix, isFileSync } from '../util' +import { pathToPosix, isFileSync, uniqValFromArrByKey, compareObjectsWithExistingKeys } from '../util' +import { uniqValFromArrByKeyCases } from './__testcases__/uniqvalromarrbykey.case' +import { compareObjectsWithExistingKeysCases } from './__testcases__/compareobjectswithexistingkeys.case' describe('util', () => { describe('pathToPosix()', () => { @@ -21,4 +23,22 @@ describe('util', () => { expect(isFileSync('some random string')).toBeFalsy() }) }) + + describe('uniqValFromArrByKey()', () => { + uniqValFromArrByKeyCases.forEach(({ input, expected }, index) => { + it(`should return unique values from array grouped by key for test case ${index + 1}`, () => { + const result = uniqValFromArrByKey(input.arr, input.key) + expect(result).toEqual(expected) + }) + }) + }) + + describe('compareObjectsWithExistingKeys()', () => { + compareObjectsWithExistingKeysCases.forEach(({ input, expected }, index) => { + it(`should compare objects and return differences for test case ${index + 1}`, () => { + const result = compareObjectsWithExistingKeys(input.obj1, input.obj2) + expect(result).toEqual(expected) + }) + }) + }) }) diff --git a/packages/cli/src/services/deploy-preview.ts b/packages/cli/src/services/deploy-preview.ts new file mode 100644 index 00000000..8d516c90 --- /dev/null +++ b/packages/cli/src/services/deploy-preview.ts @@ -0,0 +1,57 @@ +import * as api from '../rest/api' +import { DiffResult, utilsService } from './util' +import { ProjectData, ProjectPayload } from '../constructs' +import { ResourceSync } from '../rest/projects' +import { AlertChannelApi } from '../rest/alert-channels' + +export type ResourcesTypes = keyof ProjectData + +export interface DeployPreviewDiff { + resourceType: ResourcesTypes + logicalId: string + diffResult: DiffResult +} + +export class DeployPreview { + readonly resources: ResourceSync[] = [] + private serverStateAlertChannel: AlertChannelApi[] = [] + constructor (projectPayload: ProjectPayload) { + this.resources = projectPayload.resources + } + + private async getServerStateByResourceType (resourceType: ResourcesTypes) { + if (resourceType === 'alert-channel') { + const { data: serverStateAlertChannel } = await this.getAlertChannelsServerState() + this.serverStateAlertChannel = serverStateAlertChannel + } + } + + public getUniqueResourceType (): string[] { + return utilsService.uniqValFromArrByKey(this.resources, 'type') + } + + private getAlertChannelsServerState () { + return api.alertChannels.getAll() + } + + private getResourcesAndServerStateDiff (resource: ResourceSync): DeployPreviewDiff { + let diffResult: DiffResult = null + if (resource.type === 'alert-channel' && this.serverStateAlertChannel.length) { + const serverStateItem = this.serverStateAlertChannel.find((item) => item.type === resource.payload.type) + if (serverStateItem) { + diffResult = utilsService.compareObjectsWithExistingKeys(resource.payload, serverStateItem) + } + } + return { + resourceType: resource.type as ResourcesTypes, + logicalId: resource.logicalId, + diffResult, + } + } + + public async getDiff (): Promise { + const resourcesTypes = this.getUniqueResourceType() as ResourcesTypes[] + await Promise.all(resourcesTypes.map(this.getServerStateByResourceType.bind(this))) + return this.resources.map(this.getResourcesAndServerStateDiff.bind(this)) + } +} diff --git a/packages/cli/src/services/table-cli.ts b/packages/cli/src/services/table-cli.ts new file mode 100644 index 00000000..b6d5901d --- /dev/null +++ b/packages/cli/src/services/table-cli.ts @@ -0,0 +1,52 @@ +import chalk from 'chalk' + +export class TableCli> { + private drawSeparator (columnCount: number): string { + const separator = Array(columnCount) + .fill('-'.repeat(30)) + .join(' | ') + return ` ${chalk.grey(separator)}` + } + + private drawTableRow (row: T, columns: Array, ind: number): string[] { + const output: string[] = [] + const rowData = columns.map((col) => String(row[col]).padEnd(30)) + output.push( + ` ${ + ind % 2 ? chalk.hex('#a1a1a1')(rowData[0]) : chalk.hex('#ffffff')(rowData[0]) + } ${chalk.grey('|')} ${rowData + .slice(1) + .map((col, i) => + i === rowData.length - 2 ? chalk.green(col) : chalk.grey(col), + ) + .join(` ${chalk.grey('|')} `)}`, + ) + output.push(this.drawSeparator(columns.length)) + return output + } + + public drawTable ( + rows: T[], + columns: Array, + columnHeaders: string[], + ): string[] { + const output: string[] = [] + + // Header + output.push( + ` ${columnHeaders + .map((header) => header.padEnd(30)) + .join(` ${chalk.grey('|')} `)}`, + ) + + // separator + output.push(this.drawSeparator(columnHeaders.length)) + + // Rows + rows.forEach((row, index) => { + output.push(...this.drawTableRow(row, columns, index)) + }) + + return output + } +} diff --git a/packages/cli/src/services/util.ts b/packages/cli/src/services/util.ts index 388e7a99..6ffe474b 100644 --- a/packages/cli/src/services/util.ts +++ b/packages/cli/src/services/util.ts @@ -25,6 +25,8 @@ export interface CiInformation { environment: string | null } +export type DiffResult = Record | null; + export function findFilesRecursively (directory: string, ignoredPaths: Array = []) { if (!fsSync.statSync(directory, { throwIfNoEntry: false })?.isDirectory()) { return [] @@ -223,3 +225,43 @@ export function assignProxy (baseURL: string, axiosConfig: CreateAxiosDefaults) axiosConfig.proxy = false return axiosConfig } + +export function uniqValFromArrByKey< + T extends object, + K extends keyof T +> (arr: T[], key: K): T[K][] { + const resourcesTypesSet = new Set() + arr.forEach((item) => { + if (item?.[key]) resourcesTypesSet.add(item[key]) + }) + return Array.from(resourcesTypesSet) +} + +export function compareObjectsWithExistingKeys< + T extends object, + U extends object +> (obj1: T, obj2: U, parentKey = ''): DiffResult { + const result: DiffResult = {} + + Object.keys(obj1 as Record).forEach((key) => { + if (key in obj2) { + const fullKey = parentKey ? `${parentKey}.${key}` : key + + const value1 = (obj1 as Record)[key] + const value2 = (obj2 as Record)[key] + + const isObject1 = value1 && typeof value1 === 'object' && !Array.isArray(value1) + const isObject2 = value2 && typeof value2 === 'object' && !Array.isArray(value2) + + if (isObject1 && isObject2) { + Object.assign(result, compareObjectsWithExistingKeys(value1, value2, fullKey)) + } else if (value1 !== value2) { + result[fullKey] = [value2, value1] + } + } + }) + + return Object.keys(result).length ? result : null +} + +export * as utilsService from './util'