Skip to content

Commit

Permalink
feat: add 'cloudypad update' command to update some instance configur…
Browse files Browse the repository at this point in the history
…ations (eg. disk size or instance type)
  • Loading branch information
PierreBeucher committed Dec 30, 2024
1 parent 8faf51f commit cbd2f4e
Show file tree
Hide file tree
Showing 20 changed files with 335 additions and 97 deletions.
4 changes: 2 additions & 2 deletions src/core/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ export class InteractiveInstanceInitializer {
}
}

const state = new StateInitializer({
const state = await new StateInitializer({
input: input,
provider: this.provider,
}).initializeState()

const manager = new InstanceManagerBuilder().buildManagerForState(state)
const manager = await new InstanceManagerBuilder().buildInstanceManager(state.name)
const instanceName = state.name
const autoApprove = cliArgs.yes

Expand Down
26 changes: 25 additions & 1 deletion src/core/input/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@ import { PUBLIC_IP_TYPE, PUBLIC_IP_TYPE_DYNAMIC, PUBLIC_IP_TYPE_STATIC } from ".

//
// Common CLI Option each providers can re-use
///
//

/**
* Arguments any Provider can take as parameter for create command
*/
export interface CreateCliArgs {
name?: string
privateSshKey?: string
yes?: boolean // auto approve
overwriteExisting?: boolean
}

/**
* Arguments any Provider can take as parameter for update command
*/
export type UpdateCliArgs = Omit<CreateCliArgs, "name" | "privateSshKey">


export const CLI_OPTION_INSTANCE_NAME = new Option('--name <name>', 'Instance name')
export const CLI_OPTION_PRIVATE_SSH_KEY = new Option('--private-ssh-key <path>', 'Path to private SSH key to use to connect to instance')
export const CLI_OPTION_AUTO_APPROVE = new Option('--yes', 'Do not prompt for approval, automatically approve and continue')
Expand Down Expand Up @@ -39,11 +48,26 @@ export abstract class CliCommandGenerator {
.addOption(CLI_OPTION_OVERWRITE_EXISTING)
}

/**
* Create a base 'update' command for a given provider name with possibilities to chain with additional options.
*/
protected getBaseUpdateCommand(provider: string){
return new Command(provider)
.description(`Update an existing Cloudy Pad instance using ${provider} provider.`)
.requiredOption('--name <name>', 'Instance name')
.addOption(CLI_OPTION_AUTO_APPROVE)
}

/**
* Build a 'create' Command for Commander CLI using provided Command
*/
abstract buildCreateCommand(): Command<[]>

/**
* Build an 'update' Command for Commander CLI using provided Command
*/
abstract buildUpdateCommand(): Command<[]>

}

export function parsePublicIpType(value: string): PUBLIC_IP_TYPE {
Expand Down
15 changes: 14 additions & 1 deletion src/core/input/prompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,20 @@ export abstract class AbstractInputPrompter<A extends CreateCliArgs, I extends C
/**
* Transform CLI arguments into known Input interface
*/
abstract cliArgsIntoInput(cliArgs: A): PartialDeep<I>
cliArgsIntoInput(cliArgs: A): PartialDeep<I> {
this.logger.debug(`Parsing CLI args ${JSON.stringify(cliArgs)} into Input interface...`)

const result = this.doTransformCliArgsIntoInput(cliArgs)

this.logger.debug(`Parsed CLI args ${JSON.stringify(cliArgs)} into ${JSON.stringify(input)}`)

return result
}

/**
* Transform CLI arguments into known Input interface
*/
protected abstract doTransformCliArgsIntoInput(cliArgs: A): PartialDeep<I>

async completeCliInput(cliArgs: A): Promise<I> {
const partialInput = this.cliArgsIntoInput(cliArgs)
Expand Down
116 changes: 66 additions & 50 deletions src/core/manager-builder.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { CLOUDYPAD_PROVIDER_AWS, CLOUDYPAD_PROVIDER_AZURE, CLOUDYPAD_PROVIDER_GCP, CLOUDYPAD_PROVIDER_PAPERSPACE } from './const';
import { getLogger } from '../log/utils';
import { AwsInstanceStateV1 } from '../providers/aws/state';
import { AwsSubManagerFactory } from '../providers/aws/factory';
import { AzureInstanceStateV1 } from '../providers/azure/state';
import { PaperspaceInstanceStateV1 } from '../providers/paperspace/state';
import { GcpInstanceStateV1 } from '../providers/gcp/state';
import { GcpSubManagerFactory } from '../providers/gcp/factory';
import { AzureSubManagerFactory } from '../providers/azure/factory';
import { PaperspaceSubManagerFactory } from '../providers/paperspace/factory';
Expand All @@ -14,6 +10,11 @@ import { StateParser } from './state/parser';
import { StateWriter } from './state/writer';
import { InstanceStateV1 } from './state/state';
import { StateMigrator } from './state/migrator';
import { AwsInstanceStateV1 } from '../providers/aws/state';
import { AzureInstanceStateV1 } from '../providers/azure/state';
import { GcpInstanceStateV1 } from '../providers/gcp/state';
import { PaperspaceInstanceStateV1 } from '../providers/paperspace/state';
import { InstanceUpdater } from './updater';

export class InstanceManagerBuilder {

Expand All @@ -23,66 +24,81 @@ export class InstanceManagerBuilder {
return new StateLoader().listInstances()
}

/**
* Build an InstanceManager for a instance. Load instance state from disk,
* check for migration and create a related InstanceManager.
*/
async buildManagerForInstance(name: string): Promise<InstanceManager>{

// Migrate State to V1 if needed
const migrator = new StateMigrator()
await migrator.ensureInstanceStateV1(name)
private async loadAndMigrateState(instanceName: string): Promise<InstanceStateV1>{
await new StateMigrator().ensureInstanceStateV1(instanceName)

// Build manager
const state = await new StateLoader().loadInstanceStateSafe(name)
return this.buildManagerForState(state)
const state = await new StateLoader().loadInstanceStateSafe(instanceName)
return state
}

buildManagerForState(state: InstanceStateV1): InstanceManager {
const stateParser = new StateParser()
private parseAwsState(rawState: InstanceStateV1): AwsInstanceStateV1 {
return new StateParser().parseAwsStateV1(rawState)
}

private parseAzureState(rawState: InstanceStateV1): AzureInstanceStateV1 {
return new StateParser().parseAzureStateV1(rawState)
}

private parseGcpState(rawState: InstanceStateV1): GcpInstanceStateV1 {
return new StateParser().parseGcpStateV1(rawState)
}

private parsePaperspaceState(rawState: InstanceStateV1): PaperspaceInstanceStateV1 {
return new StateParser().parsePaperspaceStateV1(rawState)
}

async buildInstanceManager(name: string): Promise<InstanceManager>{
const state = await this.loadAndMigrateState(name)

if (state.provision.provider === CLOUDYPAD_PROVIDER_AWS) {
const awsState: AwsInstanceStateV1 = stateParser.parseAwsStateV1(state)
return this.buildAwsInstanceManager(awsState)
return new GenericInstanceManager({
stateWriter: new StateWriter({ state: this.parseAwsState(state)}),
factory: new AwsSubManagerFactory()
})
} else if (state.provision.provider === CLOUDYPAD_PROVIDER_GCP) {
const gcpState: GcpInstanceStateV1 = stateParser.parseGcpStateV1(state)
return this.buildGcpInstanceManager(gcpState)
return new GenericInstanceManager({
stateWriter: new StateWriter({ state: this.parseGcpState(state)}),
factory: new GcpSubManagerFactory()
})
} else if (state.provision.provider === CLOUDYPAD_PROVIDER_AZURE) {
const azureState: AzureInstanceStateV1 = stateParser.parseAzureStateV1(state)
return this.buildAzureInstanceManager(azureState)
return new GenericInstanceManager({
stateWriter: new StateWriter({ state: this.parseAzureState(state)}),
factory: new AzureSubManagerFactory()
})
} else if (state.provision.provider === CLOUDYPAD_PROVIDER_PAPERSPACE) {
const paperspaceState: PaperspaceInstanceStateV1 = stateParser.parsePaperspaceStateV1(state)
return this.buildPaperspaceInstanceManager(paperspaceState)
return new GenericInstanceManager({
stateWriter: new StateWriter({ state: this.parsePaperspaceState(state)}),
factory: new PaperspaceSubManagerFactory()
})
} else {
throw new Error(`Unknown provider '${state.provision.provider}' in state: ${JSON.stringify(state)}`)
}
}
}

buildAwsInstanceManager(state: AwsInstanceStateV1){
return new GenericInstanceManager({
stateWriter: new StateWriter<AwsInstanceStateV1>({ state: state}),
factory: new AwsSubManagerFactory()
})
async buildAwsInstanceUpdater(instanceName: string): Promise<InstanceUpdater<AwsInstanceStateV1>> {
const rawState = await this.loadAndMigrateState(instanceName)
const stateWriter = new StateWriter({ state: this.parseAwsState(rawState) })
return new InstanceUpdater({ stateWriter: stateWriter })
}

buildGcpInstanceManager(state: GcpInstanceStateV1){
return new GenericInstanceManager({
stateWriter: new StateWriter<GcpInstanceStateV1>({ state: state}),
factory: new GcpSubManagerFactory()
})

async buildGcpInstanceUpdater(instanceName: string): Promise<InstanceUpdater<GcpInstanceStateV1>> {
const rawState = await this.loadAndMigrateState(instanceName)
const stateWriter = new StateWriter({ state: this.parseGcpState(rawState) })
return new InstanceUpdater({ stateWriter: stateWriter })
}

buildAzureInstanceManager(state: AzureInstanceStateV1){
return new GenericInstanceManager({
stateWriter: new StateWriter<AzureInstanceStateV1>({ state: state}),
factory: new AzureSubManagerFactory()
})

async buildAzureInstanceUpdater(instanceName: string): Promise<InstanceUpdater<AzureInstanceStateV1>> {
const rawState = await this.loadAndMigrateState(instanceName)
const stateWriter = new StateWriter({ state: this.parseAzureState(rawState) })
return new InstanceUpdater({ stateWriter: stateWriter })
}

buildPaperspaceInstanceManager(state: PaperspaceInstanceStateV1){
return new GenericInstanceManager({
stateWriter: new StateWriter<PaperspaceInstanceStateV1>({ state: state}),
factory: new PaperspaceSubManagerFactory()
})

async buildPaperspaceInstanceUpdater(instanceName: string): Promise<InstanceUpdater<PaperspaceInstanceStateV1>> {
const rawState = await this.loadAndMigrateState(instanceName)
const stateWriter = new StateWriter({ state: this.parsePaperspaceState(rawState) })
return new InstanceUpdater({ stateWriter: stateWriter })
}



}
2 changes: 1 addition & 1 deletion src/core/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface InstanceManagerArgs<ST extends InstanceStateV1> {
* The concrete instance type is not known by this class: a per-provider factory is used
* to build each sub-managers.
*/
export class GenericInstanceManager<ST extends InstanceStateV1> {
export class GenericInstanceManager<ST extends InstanceStateV1> implements InstanceManager {

protected readonly logger
protected readonly stateWriter: StateWriter<ST>
Expand Down
8 changes: 7 additions & 1 deletion src/core/state/initializer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CommonInstanceInput, InstanceStateV1 } from './state';
import { getLogger } from '../../log/utils';
import { CLOUDYPAD_CONFIGURATOR_ANSIBLE, CLOUDYPAD_PROVIDER } from '../const';
import { StateWriter } from './writer';

export interface StateInitializerArgs {
provider: CLOUDYPAD_PROVIDER,
Expand All @@ -26,7 +27,7 @@ export class StateInitializer {
* - Optionally pair instance
* @param opts
*/
public initializeState(): InstanceStateV1{
public async initializeState(): Promise<InstanceStateV1> {

const instanceName = this.args.input.instanceName
const input = this.args.input
Expand All @@ -49,6 +50,11 @@ export class StateInitializer {
}
}

const writer = new StateWriter({
state: initialState,
})
await writer.persistStateNow()

return initialState
}

Expand Down
7 changes: 4 additions & 3 deletions src/core/state/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import * as fs from 'fs'
import * as yaml from 'js-yaml'
import * as path from 'path'
import { getLogger } from '../../log/utils'
import { AnyInstanceStateV1, StateParser } from './parser'
import { StateParser } from './parser'
import { BaseStateManager } from './base-manager'
import { InstanceStateV1 } from './state'

export interface StateLoaderArgs {

Expand Down Expand Up @@ -74,7 +75,7 @@ export class StateLoader extends BaseStateManager {
}
}

async loadInstanceStateSafe(instanceName: string): Promise<AnyInstanceStateV1> {
async loadInstanceStateSafe(instanceName: string): Promise<InstanceStateV1> {
// state is unchecked, any is acceptable at this point
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawState = await this.loadRawInstanceState(instanceName) as any
Expand All @@ -84,6 +85,6 @@ export class StateLoader extends BaseStateManager {
}

const parser = new StateParser()
return parser.parseAnyStateV1(rawState)
return parser.parseBaseStateV1(rawState)
}
}
8 changes: 7 additions & 1 deletion src/core/state/migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export class StateMigrator extends BaseStateManager {

const v0InstancePath = this.getInstanceStateV0Path(instanceName)

this.logger.debug(`Checking if instance ${instanceName} needs migration using ${v0InstancePath}`)

if(fs.existsSync(this.getInstanceDir(instanceName)) && fs.existsSync(v0InstancePath)) {
return true
}
Expand All @@ -32,16 +34,20 @@ export class StateMigrator extends BaseStateManager {
}

async ensureInstanceStateV1(instanceName: string){
if(!this.needMigration(instanceName)){
const needsMigration = await this.needMigration(instanceName)
if(!needsMigration){
return
}

const v0StatePath = this.getInstanceStateV0Path(instanceName)
this.logger.debug(`Migrating instance ${instanceName} state V0 to V1 state using V0 state ${v0StatePath}`)

this.logger.debug(`Loading instance V0 state for ${instanceName} at ${v0StatePath}`)

const rawState = yaml.load(fs.readFileSync(v0StatePath, 'utf8'))

this.logger.debug(`Loaded state of ${instanceName} for migration: ${v0StatePath}`)

// Migrate state and persist
this.logger.debug(`Migrating instance V0 state to V1 for ${instanceName} at ${v0StatePath}`)
const result = await this.doMigrateStateV0toV1(rawState)
Expand Down
23 changes: 15 additions & 8 deletions src/core/state/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { AwsInstanceStateV1, AwsInstanceStateV1Schema } from "../../providers/aw
import { AzureInstanceStateV1, AzureInstanceStateV1Schema } from "../../providers/azure/state";
import { GcpInstanceStateV1, GcpInstanceStateV1Schema } from "../../providers/gcp/state";
import { PaperspaceInstanceStateV1, PaperspaceInstanceStateV1Schema } from "../../providers/paperspace/state";
import { InstanceStateV1, InstanceStateV1Schema } from "./state";

const AnyInstanceStateV1Schema =
AwsInstanceStateV1Schema
.or(AzureInstanceStateV1Schema)
.or(GcpInstanceStateV1Schema)
.or(PaperspaceInstanceStateV1Schema)
// const AnyInstanceStateV1Schema =
// AwsInstanceStateV1Schema
// .or(AzureInstanceStateV1Schema)
// .or(GcpInstanceStateV1Schema)
// .or(PaperspaceInstanceStateV1Schema)

export type AnyInstanceStateV1 = AwsInstanceStateV1 |
AzureInstanceStateV1 |
Expand All @@ -23,8 +24,14 @@ export class StateParser {

private logger = getLogger(StateParser.name)

parseAnyStateV1(rawState: unknown): AnyInstanceStateV1 {
const result = this.zodParseSafe(rawState, AnyInstanceStateV1Schema)
/**
* Parse a raw State into known State schema. The State is validated only for base InstanceStateV1Schema
* but unknown keys are kept, allowing any provider-specific state to be passed and it will retain all elements
* not present on InstanceStateV1Schema.
* Not suitable for use with provider-specific component. A second parsing round is required with parsePROVIDERStateV1 functions.
*/
parseBaseStateV1(rawState: unknown): InstanceStateV1 {
const result = this.zodParseSafe(rawState, InstanceStateV1Schema)
return result
}

Expand All @@ -48,7 +55,7 @@ export class StateParser {
return result
}

private zodParseSafe<T extends z.ZodTypeAny>(data: unknown, schema: T){
private zodParseSafe<T extends z.AnyZodObject>(data: unknown, schema: T){
const result = schema.safeParse(data)
if(result.success){
return result.data as z.infer<T>
Expand Down
Loading

0 comments on commit cbd2f4e

Please sign in to comment.