Skip to content

Commit

Permalink
Merge pull request #93 from garden-io/cli-fixes
Browse files Browse the repository at this point in the history
Cli fixes
  • Loading branch information
edvald authored May 15, 2018
2 parents b486fd8 + 200fd01 commit 5d7e064
Show file tree
Hide file tree
Showing 5 changed files with 71 additions and 137 deletions.
77 changes: 37 additions & 40 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const GLOBAL_OPTIONS = {
output: new ChoicesParameter({
alias: "o",
choices: Object.keys(OUTPUT_RENDERERS),
help: "output command result in specified format (note: disable progress logging)",
help: "output command result in specified format (note: disables progress logging)",
}),
}
const GLOBAL_OPTIONS_GROUP_NAME = "Global options"
Expand Down Expand Up @@ -144,7 +144,7 @@ export interface ParseResults {

interface SywacParseResults extends ParseResults {
output: string
details: any
details: { result: any }
}

function makeOptSynopsis(key: string, param: Parameter<any>): string {
Expand Down Expand Up @@ -292,7 +292,7 @@ export class GardenCli {
throw new PluginError(`Global option(s) ${dupKeys.join(" ")} cannot be redefined`, {})
}

const action = async argv => {
const action = async (argv, cliContext) => {
// Sywac returns positional args and options in a single object which we separate into args and opts
const argsForAction = filterByArray(argv, argKeys)
const optsForAction = filterByArray(argv, optKeys.concat(globalKeys))
Expand All @@ -315,32 +315,12 @@ export class GardenCli {

const garden = await Garden.factory(root, { env, logger })

try {
// TODO: enforce that commands always output DeepPrimitiveMap
const result = await command.action(garden.pluginContext, argsForAction, optsForAction)
// TODO: enforce that commands always output DeepPrimitiveMap
const result = await command.action(garden.pluginContext, argsForAction, optsForAction)

if (output && result !== undefined) {
const renderer = OUTPUT_RENDERERS[output]
console.log(renderer({ success: true, result }))
// Note: this circumvents an issue where the process exits before the output is fully flushed
await sleep(100)
}
// We attach the action result to cli context so that we can process it in the parse method
cliContext.details.result = result

return result

} catch (error) {
// TODO: we can likely do this better
const result = { error }

if (output) {
const renderer = OUTPUT_RENDERERS[output]
console.error(renderer(result))
// Note: this circumvents an issue where the process exits before the output is fully flushed
await sleep(100)
}

return result
}
}

// Command specific positional args and options are set inside the builder function
Expand All @@ -361,24 +341,41 @@ export class GardenCli {
}

async parse(): Promise<ParseResults> {
return this.program.parse().then((result: SywacParseResults) => {
const { argv, errors, code, output } = result

// --help or --version options were called
if (output && !(errors.length > 0)) {
this.logger.stop()
console.log(output)
process.exit(result.code)
}
const parseResult: SywacParseResults = await this.program.parse()
const { argv, details, errors, code, output: cliOutput } = parseResult
const { result } = details
const { output } = argv

// --help or --version options were called so we log the cli output and exit
if (cliOutput && errors.length < 1) {
this.logger.stop()
console.log(cliOutput)
process.exit(parseResult.code)
}

// --output option set and there is content to output
if (output && (result !== undefined || errors.length > 0)) {
const renderer = OUTPUT_RENDERERS[output]
if (errors.length > 0) {
errors.forEach(err => this.logger.error({ msg: err.message, error: err }))
const msg = errors.join("\n")
console.error(renderer({ result: msg }))
} else {
console.log(renderer({ success: true, result }))
}
// Note: this circumvents an issue where the process exits before the output is fully flushed
await sleep(100)
}

if (errors.length > 0) {
errors.forEach(err => this.logger.error({ msg: err.message, error: err }))

if (this.logger.writers.find(w => w instanceof FileWriter)) {
this.logger.info(`See ${ERROR_LOG_FILENAME} for detailed error message`)
}
}

this.logger.stop()
return { argv, code, errors }
})
this.logger.stop()
return { argv, code, errors }
}

}
Expand Down
66 changes: 3 additions & 63 deletions src/commands/environment/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,85 +6,25 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { every, reduce } from "lodash"
import { PluginContext } from "../../plugin-context"

import { Command } from "../base"
import { EntryStyle } from "../../logger/types"
import { EnvironmentStatus, EnvironmentStatusMap } from "../../types/plugin"
import { LogEntry } from "../../logger"
import { sleep } from "../../util"
import { TimeoutError } from "../../exceptions"

const WAIT_FOR_SHUTDOWN_TIMEOUT = 600

export type LogEntryMap = { [key: string]: LogEntry }

const providersTerminated = (status: EnvironmentStatusMap): boolean => every(status, s => s.configured === false)
import { EnvironmentStatusMap } from "../../types/plugin"

export class EnvironmentDestroyCommand extends Command {
name = "destroy"
alias = "d"
help = "Destroy environment"

async action(ctx: PluginContext) {
async action(ctx: PluginContext): Promise<EnvironmentStatusMap> {
const { name } = ctx.getEnvironment()
ctx.log.header({ emoji: "skull_and_crossbones", command: `Destroying ${name} environment` })

let result: EnvironmentStatusMap
let logEntries: LogEntryMap = {}

result = await ctx.destroyEnvironment()

if (!providersTerminated(result)) {
ctx.log.info("Waiting for providers to terminate")
logEntries = reduce(result, (acc: LogEntryMap, status: EnvironmentStatus, provider: string) => {
if (status.configured) {
acc[provider] = ctx.log.info({
section: provider,
msg: "Terminating",
entryStyle: EntryStyle.activity,
})
}
return acc
}, {})

result = await this.waitForShutdown(ctx, name, logEntries)
}
const result = await ctx.destroyEnvironment()

ctx.log.finish()

return result
}

async waitForShutdown(ctx: PluginContext, name: string, logEntries: LogEntryMap) {
const startTime = new Date().getTime()
let result: EnvironmentStatusMap

while (true) {
await sleep(2000)

result = await ctx.getEnvironmentStatus()

Object.keys(result).forEach(key => {
if (result[key].configured && logEntries[key]) {
logEntries[key].setSuccess("Terminated")
}
})

if (providersTerminated(result)) {
break
}

const now = new Date().getTime()
if (now - startTime > WAIT_FOR_SHUTDOWN_TIMEOUT * 1000) {
throw new TimeoutError(
`Timed out waiting for ${name} delete to complete`,
{ environmentStatus: result },
)
}
}

return result
}
}
38 changes: 31 additions & 7 deletions src/plugins/kubernetes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import * as inquirer from "inquirer"
import * as Joi from "joi"

import { DeploymentError, NotFoundError } from "../../exceptions"
import { DeploymentError, NotFoundError, TimeoutError } from "../../exceptions"
import {
ConfigureEnvironmentParams,
DeleteConfigParams,
Expand All @@ -34,7 +34,7 @@ import {
ContainerModule,
} from "../container"
import { values, every, uniq } from "lodash"
import { deserializeKeys, prompt, serializeKeys, splitFirst } from "../../util"
import { deserializeKeys, prompt, serializeKeys, splitFirst, sleep } from "../../util"
import { ServiceStatus } from "../../types/service"
import { joiIdentifier } from "../../types/common"
import {
Expand All @@ -49,6 +49,7 @@ import {
getAllAppNamespaces,
} from "./namespace"
import {
KUBECTL_DEFAULT_TIMEOUT,
kubectl,
} from "./kubectl"
import { DEFAULT_TEST_TIMEOUT } from "../../constants"
Expand Down Expand Up @@ -134,22 +135,45 @@ export async function getServiceStatus(params: GetServiceStatusParams<ContainerM
return await checkDeploymentStatus(params)
}

export async function destroyEnvironment({ ctx, provider }: DestroyEnvironmentParams) {
const context = provider.config.context
export async function destroyEnvironment({ ctx, provider, env }: DestroyEnvironmentParams) {
const { context } = provider.config
const namespace = await getAppNamespace(ctx, provider)
const entry = ctx.log.info({
section: "kubernetes",
msg: `Deleting namespace ${namespace}`,
entryStyle: EntryStyle.activity,
})

try {
await coreApi(context).namespace(namespace).delete(namespace)
entry.setSuccess("Finished")
// Note: Need to call the delete method with an empty object
await coreApi(context).namespaces(namespace).delete({})
} catch (err) {
entry.setError(err.message)
const availableNamespaces = getAllAppNamespaces(context)
const availableNamespaces = await getAllAppNamespaces(context)
throw new NotFoundError(err, { namespace, availableNamespaces })
}

// Wait until namespace has been deleted
const startTime = new Date().getTime()
while (true) {
await sleep(2000)

const status = await getEnvironmentStatus({ ctx, provider, env })

if (!status.configured) {
entry.setSuccess("Finished")
break
}

const now = new Date().getTime()
if (now - startTime > KUBECTL_DEFAULT_TIMEOUT * 1000) {
throw new TimeoutError(
`Timed out waiting for namespace ${namespace} delete to complete`,
{ status },
)
}
}

}

export async function getServiceOutputs({ service }: GetServiceOutputsParams<ContainerModule>) {
Expand Down
12 changes: 0 additions & 12 deletions src/plugins/kubernetes/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import { PluginContext } from "../../plugin-context"
import {
apiGetOrNull,
coreApi,
} from "./api"
import { KubernetesProvider } from "./index"
Expand All @@ -19,17 +18,6 @@ import {
import { name as providerName } from "./index"
import { AuthenticationError } from "../../exceptions"

export async function namespaceReady(context: string, namespace: string) {
/**
* This is an issue with kubernetes-client where it fetches all namespaces instead of the requested one.
* Is fixed in v4.0.0. See https://github.com/godaddy/kubernetes-client/issues/187 and
* https://github.com/godaddy/kubernetes-client/pull/190
*/
const allNamespaces = await apiGetOrNull(coreApi(context).namespaces, namespace)
const ns = allNamespaces.items.find(n => n.metadata.name === namespace)
return ns && ns.status.phase === "Active"
}

export async function createNamespace(context: string, namespace: string) {
await coreApi(context).namespaces.post({
body: {
Expand Down
15 changes: 0 additions & 15 deletions test/src/commands/environment/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,4 @@ describe("EnvironmentDestroyCommand", () => {
expect(result["test-plugin"]["configured"]).to.be.false
})

it("should wait until each provider is no longer configured", async () => {
const garden = await Garden.factory(projectRootB, { plugins: [testProvider] })

td.replace(
command,
"waitForShutdown",
async () => ({
"test-plugin": { configured: false },
}),
)

const result = await command.action(garden.pluginContext)

expect(result["test-plugin"]["configured"]).to.be.false
})
})

0 comments on commit 5d7e064

Please sign in to comment.