Skip to content

Commit 00548e2

Browse files
committed
feat: add login and logout commands
1 parent 985c160 commit 00548e2

29 files changed

+3232
-2646
lines changed

package-lock.json

Lines changed: 2608 additions & 2499 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"fs-extra": "^5.0.0",
3535
"has-ansi": "^3.0.0",
3636
"ignore": "^3.3.7",
37+
"inquirer": "^5.2.0",
3738
"is-subset": "^0.1.1",
3839
"joi": "^13.2.0",
3940
"js-yaml": "^3.11.0",
@@ -63,6 +64,7 @@
6364
"@types/fs-extra": "^5.0.2",
6465
"@types/has-ansi": "^3.0.0",
6566
"@types/joi": "^13.0.7",
67+
"@types/inquirer": "0.0.41",
6668
"@types/js-yaml": "^3.11.1",
6769
"@types/klaw": "^2.1.1",
6870
"@types/log-symbols": "^2.0.0",

src/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import { LogLevel } from "./logger/types"
3535
import { ConfigCommand } from "./commands/config"
3636
import { StatusCommand } from "./commands/status"
3737
import { PushCommand } from "./commands/push"
38-
import { enumToArray } from "./types/common"
38+
import { LoginCommand } from "./commands/login"
39+
import { LogoutCommand } from "./commands/logout"
3940

4041
const GLOBAL_OPTIONS = {
4142
root: new StringParameter({
@@ -223,6 +224,8 @@ export class GardenCli {
223224
new ValidateCommand(),
224225
new StatusCommand(),
225226
new PushCommand(),
227+
new LoginCommand(),
228+
new LogoutCommand(),
226229
]
227230
const globalOptions = Object.entries(GLOBAL_OPTIONS)
228231

src/commands/environment/destroy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class EnvironmentDestroyCommand extends Command {
3737
result = await ctx.destroyEnvironment()
3838

3939
if (!providersTerminated(result)) {
40-
ctx.log.info("\nWaiting for providers to terminate")
40+
ctx.log.info("Waiting for providers to terminate")
4141
logEntries = reduce(result, (acc: LogEntryMap, status: EnvironmentStatus, provider: string) => {
4242
if (status.configured) {
4343
acc[provider] = ctx.log.info({

src/commands/login.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright (C) 2018 Garden Technologies, Inc. <info@garden.io>
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { Command } from "./base"
10+
import { EntryStyle } from "../logger/types"
11+
import { PluginContext } from "../plugin-context"
12+
import { LoginStatusMap } from "../types/plugin"
13+
14+
export class LoginCommand extends Command {
15+
name = "login"
16+
help = "Log into the Garden framework"
17+
18+
async action(ctx: PluginContext): Promise<LoginStatusMap> {
19+
ctx.log.header({ emoji: "unlock", command: "Login" })
20+
ctx.log.info({ msg: "Logging in...", entryStyle: EntryStyle.activity })
21+
22+
const result = await ctx.login()
23+
24+
ctx.log.info("\nLogin success!")
25+
26+
return result
27+
}
28+
}

src/commands/logout.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright (C) 2018 Garden Technologies, Inc. <info@garden.io>
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { Command } from "./base"
10+
import { EntryStyle } from "../logger/types"
11+
import { PluginContext } from "../plugin-context"
12+
import { LoginStatusMap } from "../types/plugin"
13+
14+
export class LogoutCommand extends Command {
15+
name = "logout"
16+
help = "Log into the Garden framework"
17+
18+
async action(ctx: PluginContext): Promise<LoginStatusMap> {
19+
20+
ctx.log.header({ emoji: "lock", command: "Logout" })
21+
22+
const entry = ctx.log.info({ msg: "Logging out...", entryStyle: EntryStyle.activity })
23+
24+
const result = await ctx.logout()
25+
26+
entry.setSuccess("Logged out successfully")
27+
28+
return result
29+
}
30+
}

src/config-store.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* Copyright (C) 2018 Garden Technologies, Inc. <info@garden.io>
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { resolve } from "path"
10+
import { ensureFile, readFile } from "fs-extra"
11+
import * as Joi from "joi"
12+
import * as yaml from "js-yaml"
13+
import { get, isPlainObject, unset } from "lodash"
14+
import { joiIdentifier, Primitive, validate } from "./types/common"
15+
import { LocalConfigError } from "./exceptions"
16+
import { dumpYaml } from "./util"
17+
18+
export type ConfigValue = Primitive | Primitive[]
19+
20+
export type SetManyParam = { keyPath: Array<string>, value: ConfigValue }[]
21+
22+
export abstract class ConfigStore<T extends object = any> {
23+
private cached: null | T
24+
protected configPath: string
25+
26+
constructor(projectPath: string) {
27+
this.configPath = this.setConfigPath(projectPath)
28+
this.cached = null
29+
}
30+
31+
abstract setConfigPath(projectPath: string): string
32+
abstract validate(config): T
33+
34+
/**
35+
* Would've been nice to allow something like: set(["path", "to", "valA", valA], ["path", "to", "valB", valB]...)
36+
* but Typescript support is missing at the moment
37+
*/
38+
public async set(param: SetManyParam)
39+
public async set(keyPath: string[], value: ConfigValue)
40+
public async set(...args) {
41+
let config = await this.getConfig()
42+
let entries: SetManyParam
43+
44+
if (args.length === 1) {
45+
entries = args[0]
46+
} else {
47+
entries = [{ keyPath: args[0], value: args[1] }]
48+
}
49+
50+
for (const { keyPath, value } of entries) {
51+
config = this.updateConfig(config, keyPath, value)
52+
}
53+
54+
return this.saveLocalConfig(config)
55+
}
56+
57+
public async get(): Promise<T>
58+
public async get(keyPath: string[]): Promise<Object | ConfigValue>
59+
public async get(keyPath?: string[]): Promise<Object | ConfigValue> {
60+
const config = await this.getConfig()
61+
62+
if (keyPath) {
63+
const value = get(config, keyPath)
64+
65+
if (value === undefined) {
66+
this.throwKeyNotFound(config, keyPath)
67+
}
68+
69+
return value
70+
}
71+
72+
return config
73+
}
74+
75+
public async clear() {
76+
return this.saveLocalConfig(<T>{})
77+
}
78+
79+
public async delete(keyPath: string[]) {
80+
let config = await this.getConfig()
81+
if (get(config, keyPath) === undefined) {
82+
this.throwKeyNotFound(config, keyPath)
83+
}
84+
const success = unset(config, keyPath)
85+
if (!success) {
86+
throw new LocalConfigError(`Unable to delete key ${keyPath.join(".")} in user config`, {
87+
keyPath,
88+
config,
89+
})
90+
}
91+
return this.saveLocalConfig(config)
92+
}
93+
94+
private async getConfig(): Promise<T> {
95+
let config: T
96+
if (this.cached) {
97+
// Spreading does not work on generic types, see: https://github.com/Microsoft/TypeScript/issues/13557
98+
config = Object.assign(this.cached, {})
99+
} else {
100+
config = await this.loadConfig()
101+
}
102+
return config
103+
}
104+
105+
private updateConfig(config: T, keyPath: string[], value: ConfigValue): T {
106+
let currentValue = config
107+
108+
for (let i = 0; i < keyPath.length; i++) {
109+
const k = keyPath[i]
110+
111+
if (i === keyPath.length - 1) {
112+
currentValue[k] = value
113+
} else if (currentValue[k] === undefined) {
114+
currentValue[k] = {}
115+
} else if (!isPlainObject(currentValue[k])) {
116+
const path = keyPath.slice(i + 1).join(".")
117+
118+
throw new LocalConfigError(
119+
`Attempting to assign a nested key on non-object (current value at ${path}: ${currentValue[k]})`,
120+
{
121+
currentValue: currentValue[k],
122+
path,
123+
},
124+
)
125+
}
126+
127+
currentValue = currentValue[k]
128+
}
129+
return config
130+
}
131+
132+
private async ensureConfigFileExists() {
133+
return ensureFile(this.configPath)
134+
}
135+
136+
private async loadConfig(): Promise<T> {
137+
await this.ensureConfigFileExists()
138+
const config = await yaml.safeLoad((await readFile(this.configPath)).toString()) || {}
139+
140+
this.cached = this.validate(config)
141+
142+
return this.cached
143+
}
144+
145+
private async saveLocalConfig(config: T) {
146+
this.cached = null
147+
const validated = this.validate(config)
148+
await dumpYaml(this.configPath, validated)
149+
this.cached = config
150+
}
151+
152+
private throwKeyNotFound(config: T, keyPath: string[]) {
153+
throw new LocalConfigError(`Could not find key ${keyPath.join(".")} in user config`, {
154+
keyPath,
155+
config,
156+
})
157+
}
158+
159+
}
160+
161+
export interface KubernetesLocalConfig {
162+
username?: string
163+
"previous-usernames"?: Array<string>
164+
}
165+
166+
export interface LocalConfig {
167+
kubernetes?: KubernetesLocalConfig
168+
}
169+
170+
const kubernetesLocalConfigSchema = Joi.object().keys({
171+
username: joiIdentifier().allow("").optional(),
172+
"previous-usernames": Joi.array().items(joiIdentifier()).optional(),
173+
})
174+
175+
// TODO: Dynamically populate schema with all possible provider keys?
176+
const localConfigSchema = Joi.object().keys({
177+
kubernetes: kubernetesLocalConfigSchema,
178+
})
179+
180+
export class LocalConfigStore extends ConfigStore<LocalConfig> {
181+
182+
setConfigPath(projectPath): string {
183+
return resolve(projectPath, ".garden", "local-config.yml")
184+
}
185+
186+
validate(config): LocalConfig {
187+
return validate(
188+
config,
189+
localConfigSchema,
190+
{ context: this.configPath, ErrorClass: LocalConfigError },
191+
)
192+
}
193+
194+
}

src/exceptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ export abstract class GardenError extends Error {
1616
}
1717
}
1818

19+
export class AuthenticationError extends GardenError {
20+
type = "authentication"
21+
}
22+
1923
export class ConfigurationError extends GardenError {
2024
type = "configuration"
2125
}
2226

27+
export class LocalConfigError extends GardenError {
28+
type = "local-config"
29+
}
30+
2331
export class ValidationError extends GardenError {
2432
type = "validation"
2533
}

src/garden.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ import {
9090
loadConfig,
9191
} from "./types/config"
9292
import { Task } from "./types/task"
93+
import {
94+
LocalConfigStore,
95+
} from "./config-store"
9396

9497
export interface ModuleMap<T extends Module> {
9598
[key: string]: T
@@ -149,6 +152,7 @@ export class Garden {
149152
private readonly environment: string,
150153
private readonly namespace: string,
151154
public readonly config: EnvironmentConfig,
155+
public readonly localConfigStore: LocalConfigStore,
152156
logger?: RootLogNode,
153157
) {
154158
this.modulesScanned = false
@@ -175,11 +179,12 @@ export class Garden {
175179
}
176180

177181
static async factory(projectRoot: string, { env, config, logger, plugins = [] }: ContextOpts = {}) {
178-
// const localConfig = new LocalConfig(projectRoot)
179182
let parsedConfig: GardenConfig
180183

184+
const localConfigStore = new LocalConfigStore(projectRoot)
185+
181186
if (config) {
182-
parsedConfig = <GardenConfig>validate(config, configSchema, "root configuration")
187+
parsedConfig = <GardenConfig>validate(config, configSchema, { context: "root configuration" })
183188

184189
if (!parsedConfig.project) {
185190
throw new ConfigurationError(`Supplied config does not contain a project configuration`, {
@@ -238,7 +243,7 @@ export class Garden {
238243
// Resolve the project configuration based on selected environment
239244
const projectConfig = merge({}, globalConfig, envConfig)
240245

241-
const garden = new Garden(projectRoot, projectName, environment, namespace, projectConfig, logger)
246+
const garden = new Garden(projectRoot, projectName, environment, namespace, projectConfig, localConfigStore, logger)
242247

243248
// Register plugins
244249
for (const plugin of builtinPlugins.concat(plugins)) {
@@ -307,7 +312,11 @@ export class Garden {
307312
}
308313

309314
try {
310-
pluginModule = validate(pluginModule, pluginModuleSchema, `plugin module "${moduleNameOrLocation}"`)
315+
pluginModule = validate(
316+
pluginModule,
317+
pluginModuleSchema,
318+
{ context: `plugin module "${moduleNameOrLocation}"` },
319+
)
311320

312321
if (pluginModule.name) {
313322
name = pluginModule.name
@@ -320,7 +329,7 @@ export class Garden {
320329
}
321330
}
322331

323-
validate(name, joiIdentifier(), `name of plugin "${moduleNameOrLocation}"`)
332+
validate(name, joiIdentifier(), { context: `name of plugin "${moduleNameOrLocation}"` })
324333
} catch (err) {
325334
throw new PluginError(`Unable to load plugin: ${err}`, {
326335
moduleNameOrLocation,
@@ -361,7 +370,7 @@ export class Garden {
361370
})
362371
}
363372

364-
plugin = validate(plugin, pluginSchema, `plugin "${pluginName}"`)
373+
plugin = validate(plugin, pluginSchema, { context: `plugin "${pluginName}"` })
365374

366375
this.loadedPlugins[pluginName] = plugin
367376

0 commit comments

Comments
 (0)