Skip to content

Commit

Permalink
google cloud driver - wip
Browse files Browse the repository at this point in the history
- temporarily switch ssh2 version due to mscdex/ssh2#989
  • Loading branch information
Roy Razon committed Apr 18, 2023
1 parent de0b7b8 commit de4ae0a
Show file tree
Hide file tree
Showing 21 changed files with 914 additions and 41 deletions.
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@aws-sdk/client-s3": "^3.271.0",
"@aws-sdk/client-sts": "^3.289.0",
"@aws-sdk/util-waiter": "^3.271.0",
"@google-cloud/compute": "^3.9.1",
"@oclif/core": "^2",
"@oclif/plugin-help": "^5",
"@oclif/plugin-plugins": "^2.3.0",
Expand All @@ -34,6 +35,7 @@
"commondir": "^1.0.1",
"fast-safe-stringify": "^2.1.1",
"glob": "^9.2.1",
"google-gax": "^3.6.0",
"inquirer": "^8.0.0",
"is-stream": "^2.0.1",
"iter-tools-es": "^7.5.1",
Expand All @@ -48,7 +50,7 @@
"rimraf": "^4.4.0",
"shell-escape": "^0.2.0",
"source-map-support": "^0.5.21",
"ssh2": "^1.11.0",
"ssh2": "github:mscdex/ssh2#ext_info",
"tar": "^6.1.13",
"yaml": "^2.2.1"
},
Expand Down
23 changes: 11 additions & 12 deletions packages/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ export default class Init extends BaseCommand {
type: 'list',
name: 'driver',
message: 'Which cloud provider do you want to use?',
choices: [{
value: 'lightsail',
name: 'AWS Lightsail',
}],
choices: [
{ value: 'lightsail', name: 'AWS Lightsail' },
{ value: 'gce', name: 'Google Compute Engine' },
],
}])

const driverStatic = machineDrivers[driver]
Expand All @@ -76,7 +76,9 @@ export default class Init extends BaseCommand {
type: 'input',
name: key,
message: flag.description,
default: ('flagHint' in driverStatic) ? driverStatic.flagHint(key as DriverFlagName<DriverName, 'flags'>) : '',
default: ('flagHint' in driverStatic)
? driverStatic.flagHint(key as DriverFlagName<DriverName, 'flags'>)
: undefined,
}))

const driverFlags = await inquirer.prompt<Record<string, string>>(questions)
Expand All @@ -88,13 +90,10 @@ export default class Init extends BaseCommand {
name: 'locationType',
message: 'Where do you want to store the profile?',
default: 'local',
choices: [{
value: 's3',
name: 's3',
}, {
value: 'local',
name: 'local',
}],
choices: [
{ value: 's3', name: 'AWS S3' },
{ value: 'local', name: 'local' },
],
}])
let location: string
if (locationType === 's3') {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/profile/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export default class CreateProfile extends DriverCommand<typeof CreateProfile> {
const alias = this.args.name
const driver = this.flags.driver as DriverName

const driverFlags = mapKeys(pickBy(this.flags, (v, k) => k.startsWith(`${driver}-`)), (v, k) => k.substring(`${driver}-`.length))
const driverPrefix = `${driver}-`
const driverFlags = mapKeys(
pickBy(this.flags, (v, k) => k.startsWith(driverPrefix)),
(_v, k) => k.substring(driverPrefix.length),
)

await this.profileConfig.create(alias, this.args.url, { driver }, async pStore => {
await pStore.setDefaultFlags(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/up/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default class Up extends MachineCreationDriverCommand<typeof Up> {
const keyStore = sshKeysStore(this.store)
let keyPair = await keyStore.getKey(keyAlias)
if (!keyPair) {
this.logger.info(`keypair ${keyAlias} not found, creating new one`)
this.logger.info(`key pair ${keyAlias} not found, creating a new key pair`)
keyPair = await driver.createKeyPair()
await keyStore.addKey(keyPair)
this.logger.info(`keypair ${keyAlias} created`)
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/driver-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ abstract class DriverCommand<T extends typeof Command> extends ProfileCommand<T>
driver: Flags.custom<DriverName>({
description: 'Machine driver to use',
char: 'd',
default: 'lightsail' as const,
options: Object.keys(machineDrivers),
required: false,
})(),
...flagsForAllDrivers,
}
Expand All @@ -42,8 +42,7 @@ abstract class DriverCommand<T extends typeof Command> extends ProfileCommand<T>
if (this.#driver) {
return this.#driver
}
const { profile } = this
const driverName = this.flags.driver as DriverName
const { profile, driverName } = this
const driverFlags = {
...await profileStore(this.store).defaultFlags(driverName),
...removeDriverPrefix<DriverFlags<DriverName, 'flags'>>(driverName, this.flags),
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/commands/up/machine.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EOL } from 'os'
import retry from 'p-retry'
import { Logger } from '../../../log'
import { MachineDriver, scripts } from '../../machine'
import { MachineDriver } from '../../machine'
import { connectSshClient } from '../../ssh/client'
import { SSHKeyConfig } from '../../ssh/keypair'
import { withSpinner } from '../../spinner'
Expand Down Expand Up @@ -99,7 +99,7 @@ export const ensureCustomizedMachine = async ({
try {
await withSpinner(async () => {
log.debug('Executing machine scripts')
for (const script of scripts.CUSTOMIZE_BARE_MACHINE) {
for (const script of machineDriver.customizationScripts ?? []) {
// eslint-disable-next-line no-await-in-loop
await sshClient.execScript(script)
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/machine/driver/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type SpecDiffItem = {
}

export type MachineDriver = {
customizationScripts?: string[]
friendlyName: string

getMachine: (args: { envId: string }) => Promise<Machine | undefined>
Expand Down
146 changes: 146 additions & 0 deletions packages/cli/src/lib/machine/drivers/gce/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { InstancesClient, ImagesClient, ZoneOperationsClient } from '@google-cloud/compute'
import { GoogleError, Status, operationsProtos } from 'google-gax'
import { asyncFirst } from 'iter-tools-es'
import { LABELS } from './labels'

async function extractFirst<T>(p: Promise<[T, ...unknown[]]>): Promise<T>
async function extractFirst<T>(p: Promise<T[]>): Promise<T>
async function extractFirst<T>(p: Promise<T[]> | Promise<[T, ...unknown[]]>) { return (await p)[0] }

type Operation = operationsProtos.google.longrunning.IOperation

const isNotFoundError = (e: Error) => e instanceof GoogleError && e.code === Status.NOT_FOUND
const undefinedForNotFound = <T>(p: Promise<T>): Promise<T | [undefined]> => p.catch(e => {
if (isNotFoundError(e)) {
return [undefined]
}
throw e
})

const client = ({
zone,
project,
profileId,
}: {
zone: string
project: string
profileId: string
}) => {
const ic = new InstancesClient()
const imc = new ImagesClient()
const zoc = new ZoneOperationsClient()

const waitForOperation = async (op: Operation) => {
let { done } = op
while (!done) {
// eslint-disable-next-line no-await-in-loop
const { status } = await extractFirst(zoc.wait({ zone, project, operation: op.name }))
done = status === 'DONE'
}
}

const labelFilter = (key: string, value: string) => `labels.${key} = "${value}"`
const baseFilter = labelFilter(LABELS.PROFILE_ID, profileId)
const envIdFilter = (envId: string) => labelFilter(LABELS.ENV_ID, envId)
const filter = (envId?: string) => [baseFilter, ...(envId ? [envIdFilter(envId)] : [])]
.map(s => `(${s})`)
.join(' ')

const instanceName = (envId: string) => `preevy-${profileId}-${envId}`

const normalizeMachineType = (machineType: string) => (
machineType.includes('/')
? machineType
: `https://www.googleapis.com/compute/v1/projects/${project}/zones/${zone}/machineTypes/${machineType}`
)

return {
getInstance: async (instance: string) => extractFirst(undefinedForNotFound(ic.get({ instance, zone, project }))),

findInstance: async (
envId: string,
) => extractFirst(undefinedForNotFound(ic.get({ zone, project, instance: instanceName(envId) }))),

listInstances: () => ic.listAsync({ zone, project, filter: filter() }),

createInstance: async ({
envId,
sshPublicKey,
username,
machineType: givenMachineType,
}: {
envId: string
sshPublicKey: string
username: string
machineType: string
}) => {
const image = await asyncFirst(imc.listAsync({
project: 'cos-cloud',
maxResults: 1,
filter: '(family = "cos-stable") (architecture = "X86_64") (NOT deprecated:*) (status = "READY")',
}))

if (!image) {
throw new Error('Could not find a suitable image in GCP')
}

const machineType = normalizeMachineType(givenMachineType)
const name = instanceName(envId)

const { latestResponse: operation } = await extractFirst(ic.insert({
project,
zone,
instanceResource: {
name,
labels: {
[LABELS.ENV_ID]: envId,
[LABELS.PROFILE_ID]: profileId,
},
machineType,
disks: [{
diskSizeGb: 10,
type: 'pd-standard',
boot: true,
autoDelete: true,
initializeParams: {
sourceImage: image.selfLink,
diskSizeGb: 10,
},
}],
metadata: {
items: [{ key: 'ssh-keys', value: `${username}:${sshPublicKey}` }],
},
networkInterfaces: [
{
name: 'global/networks/default',
accessConfigs: [
{
type: 'ONE_TO_ONE_NAT',
name: 'External NAT',
},
],
},
],
},
}))

await waitForOperation(operation)

return extractFirst(ic.get({ zone, project, instance: name }))
},

deleteInstance: async (name: string) => {
const { latestResponse: operation } = await extractFirst(ic.delete({ zone, project, instance: name }))
await waitForOperation(operation as unknown as Operation)
},

normalizeMachineType,
}
}

export type Client = ReturnType<typeof client>
export type Instance = NonNullable<Awaited<ReturnType<Client['findInstance']>>>

export const shortResourceName = (name: string) => name.split('/').pop() as string

export default client
Loading

0 comments on commit de4ae0a

Please sign in to comment.