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

refactor: Convert lib/config to TypeScript #1392

Merged
merged 1 commit into from
Jun 1, 2023
Merged
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
2 changes: 0 additions & 2 deletions src/lib/cli/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @flow

/**
* External dependencies
*/
Expand Down
19 changes: 19 additions & 0 deletions src/lib/config/software.generated.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Types from '../../../graphqlTypes';

export type UpdateSoftwareSettingsMutationVariables = Types.Exact<{
appId: Types.Scalars['Int']['input'];
envId: Types.Scalars['Int']['input'];
component: Types.Scalars['String']['input'];
version: Types.Scalars['String']['input'];
}>;


export type UpdateSoftwareSettingsMutation = { __typename?: 'Mutation', updateSoftwareSettings?: { __typename?: 'AppEnvironmentSoftwareSettings', php?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, wordpress?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, muplugins?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, nodejs?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null } | null };

export type UpdateJobQueryVariables = Types.Exact<{
appId: Types.Scalars['Int']['input'];
envId: Types.Scalars['Int']['input'];
}>;


export type UpdateJobQuery = { __typename?: 'Query', app?: { __typename?: 'App', environments?: Array<{ __typename?: 'AppEnvironment', jobs?: Array<{ __typename?: 'Job', type?: string | null, completedAt?: string | null, createdAt?: string | null, inProgressLock?: boolean | null, progress?: { __typename?: 'JobProgress', status?: string | null, steps?: Array<{ __typename?: 'JobProgressStep', step?: string | null, name?: string | null, status?: string | null } | null> | null } | null } | { __typename?: 'PrimaryDomainSwitchJob', type?: string | null, completedAt?: string | null, createdAt?: string | null, inProgressLock?: boolean | null, progress?: { __typename?: 'JobProgress', status?: string | null, steps?: Array<{ __typename?: 'JobProgressStep', step?: string | null, name?: string | null, status?: string | null } | null> | null } | null } | null> | null } | null> | null } | null };
120 changes: 85 additions & 35 deletions src/lib/config/software.js → src/lib/config/software.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// @flow

/**
* External dependencies
*/
import { setTimeout } from 'node:timers/promises';
import { Select, Confirm } from 'enquirer';
import gql from 'graphql-tag';
import debugLib from 'debug';
Expand All @@ -13,6 +12,8 @@ import debugLib from 'debug';
import { isAppNodejs, isAppWordPress } from '../app';
import API from '../api';
import UserError from '../user-error';
import { UpdateJobQueryVariables } from './software.generated';
import { JobInterface, Query } from '../../graphqlTypes';

const UPDATE_PROGRESS_POLL_INTERVAL = 5;
const debug = debugLib( '@automattic/vip:bin:config-software' );
Expand Down Expand Up @@ -131,11 +132,44 @@ const COMPONENT_NAMES = {
nodejs: 'Node.js',
};

type ComponentName = keyof typeof COMPONENT_NAMES;

const MANAGED_OPTION_KEY = 'managed_latest';

const _optionsForVersion = softwareSettings => {
interface SoftwareSetting {
options: Option[];
current: {
version: string;
};
pinned: boolean;
name: string;
slug: string;
}

type SoftwareSettings = Record<ComponentName, SoftwareSetting>;

interface Option {
deprecated: boolean;
unstable: boolean;
version: string;
}

interface VersionChoice {
message: string;
value: string;
disabled?: boolean;
}

interface VersionChoices {
managed: VersionChoice[];
supported: VersionChoice[];
test: VersionChoice[];
deprecated: VersionChoice[];
}

const _optionsForVersion = ( softwareSettings: SoftwareSetting ): VersionChoice[] => {
const { options, current, pinned, slug } = softwareSettings;
const versionChoices = {
const versionChoices: VersionChoices = {
managed: [],
supported: [],
test: [],
Expand Down Expand Up @@ -188,8 +222,8 @@ const _optionsForVersion = softwareSettings => {
} );
};

const _processComponent = async ( appTypeId: number, userProvidedComponent: string | undefined ) => {
const validComponents = [];
const _processComponent = ( appTypeId: number, userProvidedComponent?: ComponentName ): Promise<ComponentName> => {
const validComponents: ComponentName[] = [];
if ( isAppWordPress( appTypeId ) ) {
validComponents.push( 'wordpress', 'php', 'muplugins' );
} else if ( isAppNodejs( appTypeId ) ) {
Expand All @@ -200,39 +234,42 @@ const _processComponent = async ( appTypeId: number, userProvidedComponent: stri
if ( ! validComponents.includes( userProvidedComponent ) ) {
throw new UserError( `Component ${ userProvidedComponent } is not supported. Use one of: ${ validComponents.join( ',' ) }` );
}
return userProvidedComponent;

return Promise.resolve( userProvidedComponent );
}

if ( validComponents.length === 0 ) {
throw new UserError( 'No components are supported for this application' );
}

if ( validComponents.length === 1 ) {
return validComponents[ 0 ];
return Promise.resolve( validComponents[ 0 ] );
}

const choices = validComponents.map( item => ( {
message: COMPONENT_NAMES[ item ],
value: item,
} ) );

const select = new Select( {
message: 'Component to update',
choices,
} );
return select.run().catch( () => {
throw new UserError( 'Command cancelled by user.' );
} );
} ) as Promise<ComponentName>;
};

const _processComponentVersion = async ( softwareSettings, component: string, userProvidedVersion: string | undefined ) => {
const _processComponentVersion = ( softwareSettings: SoftwareSettings, component: ComponentName, userProvidedVersion?: string ): Promise<string> => {
const versionChoices = _optionsForVersion( softwareSettings[ component ] );

if ( userProvidedVersion ) {
const validValues = versionChoices.map( item => item.value );
if ( ! validValues.includes( userProvidedVersion ) ) {
throw new UserError( `Version ${ userProvidedVersion } is not supported for ${ COMPONENT_NAMES[ component ] }. Use one of: ${ validValues.join( ',' ) }` );
}
return userProvidedVersion;

return Promise.resolve( userProvidedVersion );
}

const versionSelect = new Select( {
Expand All @@ -241,25 +278,26 @@ const _processComponentVersion = async ( softwareSettings, component: string, us
} );
return versionSelect.run().catch( () => {
throw new UserError( 'Command cancelled by user.' );
} );
} ) ;
};

interface UpdateData {
component: string,
version: string,
component: ComponentName;
version: string;
}

export interface UpdatePromptOptions {
component?: string,
version?: string,
force?: boolean,
component?: ComponentName;
version?: string;
force?: boolean;
}

export const promptForUpdate = async ( appTypeId: number, opts: UpdatePromptOptions, softwareSettings ): Promise<UpdateData> => {
export const promptForUpdate = async ( appTypeId: number, opts: UpdatePromptOptions, softwareSettings: SoftwareSettings ): Promise<UpdateData> => {
const component = await _processComponent( appTypeId, opts.component );
const version = await _processComponentVersion( softwareSettings, component, opts.version );

const confirm = opts.force || await new Confirm( {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const confirm: boolean = opts.force || await new Confirm( { // NOSONAR
message: `Are you sure you want to upgrade ${ COMPONENT_NAMES[ component ] } to ${ version }?`,
} ).run().catch( () => {
throw new UserError( 'Command cancelled by user.' );
Expand Down Expand Up @@ -289,62 +327,74 @@ export const triggerUpdate = async ( variables: TrigerUpdateOptions ) => {
return api.mutate( { mutation: updateSoftwareMutation, variables } );
};

const _getLatestJob = async ( appId: number, envId: number ) => {
const _getLatestJob = async ( appId: number, envId: number ): Promise<JobInterface | null> => {
const api = await API();
const result = await api.query( { query: updateJobQuery, variables: { appId, envId }, fetchPolicy: 'network-only' } );
const jobs = result?.data?.app?.environments[ 0 ].jobs || [];
const result = await api.query<Query, UpdateJobQueryVariables>( { query: updateJobQuery, variables: { appId, envId }, fetchPolicy: 'network-only' } );
const jobs = result.data.app?.environments?.[0]?.jobs ?? [];

if ( jobs.length ) {
return jobs.reduce( ( prev, current ) => ( prev.createdAt > current.createdAt ) ? prev : current );
return jobs.reduce( ( prev, current ) => ( ( prev?.createdAt || '' ) > ( current?.createdAt || '' ) ) ? prev : current );
}
return null;
};

const _getCompletedJob = async ( appId: number, envId: number ) => {
const _getCompletedJob = async ( appId: number, envId: number ): Promise<JobInterface | null> => {
const latestJob = await _getLatestJob( appId, envId );
debug( 'Latest job result:', latestJob );

if ( ! latestJob || ! latestJob.inProgressLock ) {
if ( ! latestJob?.inProgressLock ) {
return latestJob;
}

debug( `Sleep for ${ UPDATE_PROGRESS_POLL_INTERVAL } seconds` );
await new Promise( resolve => setTimeout( resolve, UPDATE_PROGRESS_POLL_INTERVAL * 1000 ) );

await setTimeout( UPDATE_PROGRESS_POLL_INTERVAL * 1000 );
return _getCompletedJob( appId, envId );
};

interface UpdateResult {
ok: boolean;
errorMessage?: string;
interface UpdateResultSuccess {
ok: true;
}

interface UpdateResultError {
ok: false;
errorMessage: string;
}

type UpdateResult = UpdateResultSuccess | UpdateResultError;

export const getUpdateResult = async ( appId: number, envId: number ): Promise<UpdateResult> => {
debug( 'Getting update result', { appId, envId } );

const completedJob = await _getCompletedJob( appId, envId );

const success = ! completedJob || completedJob?.progress?.status === 'success';
const success = ! completedJob || completedJob.progress?.status === 'success';
if ( success ) {
return {
ok: true,
};
}

const failedStep = completedJob?.progress?.steps?.find( step => step.status === 'failed' );
const error = failedStep ? `Failed during step: ${ failedStep.name }` : 'Software update failed';
const failedStep = completedJob.progress?.steps?.find( step => step?.status === 'failed' );
const error = failedStep ? `Failed during step: ${ failedStep.name! }` : 'Software update failed';
return {
ok: false,
errorMessage: error,
};
};

export const formatSoftwareSettings = ( softwareSetting: SoftwareSettings, includes: string[], format: string ) => {
interface FormatSoftwareSettingsResult {
name: string;
slug: string;
version: string;
available_versions?: string | string[];
}

export const formatSoftwareSettings = ( softwareSetting: SoftwareSetting, includes: string[], format: string ): FormatSoftwareSettingsResult => {
let version = softwareSetting.current.version;
if ( softwareSetting.slug === 'wordpress' && ! softwareSetting.pinned ) {
version += ' (managed updates)';
}
const result = {
const result: FormatSoftwareSettingsResult = {
name: softwareSetting.name,
slug: softwareSetting.slug,
version,
Expand Down
21 changes: 21 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ArrayPromptOptions } from 'enquirer';

// These types are not 100% correct, but they are close enough for now.
declare module 'enquirer' {
interface ConfirmOption {
name?: string | (() => string);
message: string | (() => string);
initial?: boolean | (() => boolean);
actions?: {'ctrl': {[key: string]: string}}
}

class Confirm extends NodeJS.EventEmitter {
constructor(option: ConfirmOption);
run: () => Promise<boolean>;
}

class Select extends NodeJS.EventEmitter {
constructor(option: ArrayPromptOptions);
run: () => Promise<string>;
}
}