Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deploy preview with details which data is going to be updated for each file changed (similar to terraform plan command) #994

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ local
.tsbuildinfo
**/checkly-github-report.md
**/checkly-summary.md
**/e2e/__tests__/fixtures/empty-project/e2e-test-project-*
**/e2e/__tests__/fixtures/empty-project/e2e-test-project-*
storage
htpasswd
55 changes: 48 additions & 7 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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<string> {
// 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.
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/constructs/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,8 @@ export class Session {
return Session.privateLocations
}
}

export type ProjectPayload = {
project: Pick<Project, 'logicalId' | 'name' | 'repoUrl'>
resources: ResourceSync[]
}
39 changes: 39 additions & 0 deletions packages/cli/src/rest/alert-channels.ts
Original file line number Diff line number Diff line change
@@ -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<AlertChannelApi[]>('/v1/alert-channels')
}
}

export default AlertChannels
2 changes: 2 additions & 0 deletions packages/cli/src/rest/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions packages/cli/src/rest/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ResourceSync {
member: boolean,
payload: any,
}

export interface ProjectSync {
project: Project,
resources: Array<ResourceSync>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export interface CompareObjectsWithExistingKeysCase {
input: {
obj1: object
obj2: object
}
expected: Record<string, [any, any]> | 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,
},
]
Original file line number Diff line number Diff line change
@@ -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],
},
]
22 changes: 21 additions & 1 deletion packages/cli/src/services/__tests__/util.spec.ts
Original file line number Diff line number Diff line change
@@ -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()', () => {
Expand All @@ -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)
})
})
})
})
57 changes: 57 additions & 0 deletions packages/cli/src/services/deploy-preview.ts
Original file line number Diff line number Diff line change
@@ -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<DeployPreviewDiff[]> {
const resourcesTypes = this.getUniqueResourceType() as ResourcesTypes[]
await Promise.all(resourcesTypes.map(this.getServerStateByResourceType.bind(this)))
return this.resources.map(this.getResourcesAndServerStateDiff.bind(this))
}
}
Loading