diff --git a/core/package.json b/core/package.json index bc347f6885..4d219c5733 100644 --- a/core/package.json +++ b/core/package.json @@ -151,6 +151,7 @@ "devDependencies": { "@commitlint/cli": "^8.3.5", "@commitlint/config-conventional": "^8.3.4", + "@garden-io/platform-api-types": "1.14.0", "@google-cloud/kms": "^2.0.0", "@types/analytics-node": "^3.1.3", "@types/async": "^3.2.4", diff --git a/core/src/cli/params.ts b/core/src/cli/params.ts index d2aba49e09..b3ef63dd34 100644 --- a/core/src/cli/params.ts +++ b/core/src/cli/params.ts @@ -145,7 +145,7 @@ export class StringsParameter extends Parameter { } else if (!isArray(input)) { input = [input] } - return input.flatMap((v) => v.split(this.delimiter)) + return input.flatMap((v) => String(v).split(this.delimiter)) } } diff --git a/core/src/commands/commands.ts b/core/src/commands/commands.ts index f286b20498..b30f6a4cc6 100644 --- a/core/src/commands/commands.ts +++ b/core/src/commands/commands.ts @@ -14,6 +14,7 @@ import { DeleteCommand } from "./delete" import { DeployCommand } from "./deploy" import { DevCommand } from "./dev" import { GetCommand } from "./get/get" +import { EnterpriseCommand } from "./enterprise/enterprise" import { LinkCommand } from "./link/link" import { LogsCommand } from "./logs" import { MigrateCommand } from "./migrate" @@ -44,6 +45,7 @@ export const getCoreCommands = (): (Command | CommandGroup)[] => [ new DeployCommand(), new DevCommand(), new ExecCommand(), + new EnterpriseCommand(), new GetCommand(), new LinkCommand(), new LoginCommand(), diff --git a/core/src/commands/enterprise/enterprise.ts b/core/src/commands/enterprise/enterprise.ts new file mode 100644 index 0000000000..6b3444555e --- /dev/null +++ b/core/src/commands/enterprise/enterprise.ts @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { dedent } from "../../util/string" +import { CommandGroup } from "../base" +import { GroupsCommand } from "./groups/groups" +import { SecretsCommand } from "./secrets/secrets" +import { UsersCommand } from "./users/users" + +export class EnterpriseCommand extends CommandGroup { + name = "enterprise" + help = dedent` + [EXPERIMENTAL] Manage Garden Enterprise resources such as users, groups and secrets. Requires + Garden Enterprise 1.14.0 or higher. + ` + + subCommands = [SecretsCommand, UsersCommand, GroupsCommand] +} diff --git a/core/src/commands/enterprise/groups/groups.ts b/core/src/commands/enterprise/groups/groups.ts new file mode 100644 index 0000000000..7f5275107c --- /dev/null +++ b/core/src/commands/enterprise/groups/groups.ts @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { GetAllGroupsResponse } from "@garden-io/platform-api-types" +import chalk from "chalk" +import { sortBy } from "lodash" +import { StringsParameter } from "../../../cli/params" +import { ConfigurationError } from "../../../exceptions" +import { printHeader } from "../../../logger/util" +import { dedent, deline, renderTable } from "../../../util/string" +import { Command, CommandGroup, CommandParams, CommandResult } from "../../base" +import { noApiMsg, applyFilter } from "../helpers" + +// TODO: Add created at and updated at timestamps. Need to add it to the API response first. +interface Groups { + id: number + name: string + description: string + defaultAdminGroup: boolean +} + +export class GroupsCommand extends CommandGroup { + name = "groups" + help = "[EXPERIMENTAL] List groups." + + subCommands = [GroupsListCommand] +} + +export const groupsListOpts = { + "filter-names": new StringsParameter({ + help: deline`Filter on group name. Use comma as a separator to filter on multiple names. Accepts glob patterns.`, + }), +} + +type Opts = typeof groupsListOpts + +export class GroupsListCommand extends Command<{}, Opts> { + name = "list" + help = "[EXPERIMENTAL] List groups." + description = dedent` + List all groups from Garden Enterprise. This is useful for getting the group IDs when creating + users via the \`garden enterprise users create\` coomand. + + Examples: + garden enterprise groups list # list all groups + garden enterprise groups list --filter-names dev-* # list all groups that start with 'dev-' + ` + + options = groupsListOpts + + printHeader({ headerLog }) { + printHeader(headerLog, "List groups", "balloon") + } + + async action({ garden, log, opts }: CommandParams<{}, Opts>): Promise> { + const nameFilter = opts["filter-names"] || [] + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("list", "users"), {}) + } + + const res = await api.get(`/groups`) + const groups: Groups[] = res.data.map((group) => ({ + name: group.name, + id: group.id, + description: group.description, + defaultAdminGroup: group.defaultAdminGroup, + })) + + log.info("") + + if (groups.length === 0) { + log.info("No groups found in project.") + return { result: [] } + } + + const filtered = sortBy(groups, "name").filter((user) => applyFilter(nameFilter, user.name)) + + if (filtered.length === 0) { + log.info("No groups found in project that match filters.") + return { result: [] } + } + + const heading = ["Name", "ID", "Default Admin Group"].map((s) => chalk.bold(s)) + const rows: string[][] = filtered.map((g) => { + return [chalk.cyan.bold(g.name), String(g.id), String(g.defaultAdminGroup)] + }) + + log.info(renderTable([heading].concat(rows))) + + return { result: filtered } + } +} diff --git a/core/src/commands/enterprise/helpers.ts b/core/src/commands/enterprise/helpers.ts new file mode 100644 index 0000000000..bd100e8e7f --- /dev/null +++ b/core/src/commands/enterprise/helpers.ts @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { GetProjectResponse } from "@garden-io/platform-api-types" +import { EnterpriseApi } from "../../enterprise/api" +import { dedent } from "../../util/string" + +import { LogEntry } from "../../logger/log-entry" +import { capitalize } from "lodash" +import minimatch from "minimatch" +import pluralize from "pluralize" +import chalk from "chalk" +import inquirer from "inquirer" + +export interface ApiCommandError { + identifier: string | number + message?: string +} + +export const noApiMsg = (action: string, resource: string) => dedent` + Unable to ${action} ${resource}. Make sure the project is configured for Garden Enterprise and that you're logged in. +` + +export async function getProject(api: EnterpriseApi, projectUid: string) { + const res = await api.get(`/projects/uid/${projectUid}`) + return res.data +} + +export function handleBulkOperationResult({ + log, + errors, + action, + cmdLog, + resource, + successCount, +}: { + log: LogEntry + cmdLog: LogEntry + errors: ApiCommandError[] + action: "create" | "delete" + successCount: number + resource: "secret" | "user" +}) { + const totalCount = errors.length + successCount + + log.info("") + + if (errors.length > 0) { + cmdLog.setError({ msg: "Error", append: true }) + + const actionVerb = action === "create" ? "creating" : "deleting" + const errorMsgs = errors + .map((e) => { + // Identifier could be an ID, a name or empty. + const identifier = Number.isInteger(e.identifier) + ? `with ID ${e.identifier} ` + : e.identifier === "" + ? "" + : `"${e.identifier} "` + return `→ ${capitalize(actionVerb)} ${resource} ${identifier}failed with error: ${e.message}` + }) + .join("\n") + log.error(dedent` + Failed ${actionVerb} ${errors.length}/${totalCount} ${pluralize(resource)}. + + See errors below: + + ${errorMsgs}\n + `) + } else { + cmdLog.setSuccess() + } + + if (successCount > 0) { + const resourceStr = successCount === 1 ? resource : pluralize(resource) + log.info({ + msg: `Successfully ${action === "create" ? "created" : "deleted"} ${successCount} ${resourceStr}!`, + }) + } +} + +export function applyFilter(filter: string[], val?: string | string[]) { + if (filter.length === 0) { + return true + } + if (Array.isArray(val)) { + return filter.find((f) => val.some((v) => minimatch(v.toLowerCase(), f.toLowerCase()))) + } + return val && filter.find((f) => minimatch(val.toLowerCase(), f.toLowerCase())) +} + +export async function confirmDelete(resource: string, count: number) { + const msg = chalk.yellow(dedent` + Warning: you are about to delete ${count} ${ + count === 1 ? resource : pluralize(resource) + }. This operation cannot be undone. + Are you sure you want to continue? (run the command with the "--yes" flag to skip this check). + `) + + const answer: any = await inquirer.prompt({ + name: "continue", + message: msg, + type: "confirm", + default: false, + }) + + return answer.continue +} diff --git a/core/src/commands/enterprise/secrets/secrets-create.ts b/core/src/commands/enterprise/secrets/secrets-create.ts new file mode 100644 index 0000000000..d25aef4f41 --- /dev/null +++ b/core/src/commands/enterprise/secrets/secrets-create.ts @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommandError, ConfigurationError, EnterpriseApiError } from "../../../exceptions" +import { CreateSecretResponse } from "@garden-io/platform-api-types" +import dotenv = require("dotenv") +import { readFile } from "fs-extra" + +import { printHeader } from "../../../logger/util" +import { Command, CommandParams, CommandResult } from "../../base" +import { ApiCommandError, getProject, handleBulkOperationResult, noApiMsg } from "../helpers" +import { dedent, deline } from "../../../util/string" +import { StringsParameter, PathParameter, IntegerParameter, StringParameter } from "../../../cli/params" +import { StringMap } from "../../../config/common" + +export interface SecretsCreateCommandResult { + errors: ApiCommandError[] + results: CreateSecretResponse[] +} + +export const secretsCreateArgs = { + secrets: new StringsParameter({ + help: deline`The names and values of the secrets to create, separated by '='. + Use comma as a separator to specify multiple secret name/value pairs. Note + that you can also leave this empty and have Garden read the secrets from file.`, + }), +} + +export const secretsCreateOpts = { + "scope-to-user-id": new IntegerParameter({ + help: deline`Scope the secret to a user with the given ID. User scoped secrets must be scoped to an environment as well.`, + }), + "scope-to-env": new StringParameter({ + help: deline`Scope the secret to an environment. Note that this does not default to the environment + that the command runs in (i.e. the one set via the --env flag) and that you need to set this explicitly if + you want to create an environment scoped secret. + `, + }), + "from-file": new PathParameter({ + help: deline`Read the secrets from the file at the given path. The file should have standard "dotenv" + format, as defined by [dotenv](https://github.com/motdotla/dotenv#rules).`, + }), +} + +type Args = typeof secretsCreateArgs +type Opts = typeof secretsCreateOpts + +export class SecretsCreateCommand extends Command { + name = "create" + help = "[EXPERIMENTAL] Create secrets" + description = dedent` + Create secrets in Garden Enterprise. You can create project wide secrets or optionally scope + them to an environment, or an environment and a user. + + To scope secrets to a user, you will need the user's ID which you can get from the + \`garden enterprise users list\` command. + + You can optionally read the secrets from a file. + + Examples: + garden enterprise secrets create DB_PASSWORD=my-pwd,ACCESS_KEY=my-key # create two secrets + garden enterprise secrets create ACCESS_KEY=my-key --scope-to-env ci # create a secret and scope it to the ci environment + garden enterprise secrets create ACCESS_KEY=my-key --scope-to-env ci --scope-to-user 9 # create a secret and scope it to the ci environment and user with ID 9 + garden enterprise secrets create --from-file /path/to/secrets.txt # create secrets from the key value pairs in the secrets.txt file + ` + + arguments = secretsCreateArgs + options = secretsCreateOpts + + printHeader({ headerLog }) { + printHeader(headerLog, "Create secrets", "lock") + } + + async action({ + garden, + log, + opts, + args, + }: CommandParams): Promise> { + // Apparently TS thinks that optional params are always defined so we need to cast them to their + // true type here. + const envName = opts["scope-to-env"] as string | undefined + const userId = opts["scope-to-user-id"] as number | undefined + const fromFile = opts["from-file"] as string | undefined + let secrets: StringMap + + if (userId !== undefined && !envName) { + throw new CommandError( + `Got user ID but not environment name. Secrets scoped to users must be scoped to environments as well.`, + { + args, + opts, + } + ) + } + + if (fromFile) { + try { + secrets = dotenv.parse(await readFile(fromFile)) + } catch (err) { + throw new CommandError(`Unable to read secrets from file at path ${fromFile}: ${err.message}`, { + args, + opts, + }) + } + } else if (args.secrets) { + secrets = args.secrets.reduce((acc, keyValPair) => { + const parts = keyValPair.split("=") + acc[parts[0]] = parts[1] + return acc + }, {}) + } else { + throw new CommandError( + dedent` + No secrets provided. Either provide secrets directly to the command or via the --from-file flag. + `, + { args, opts } + ) + } + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("create", "secrets"), {}) + } + + const project = await getProject(api, api.projectId) + let environmentId: number | undefined + + if (envName) { + const environment = project.environments.find((e) => e.name === envName) + if (!environment) { + throw new EnterpriseApiError(`Environment with name ${envName} not found in project`, { + environmentName: envName, + availableEnvironmentNames: project.environments.map((e) => e.name), + }) + } + environmentId = environment.id + } + + // Validate that a user with this ID exists + if (userId) { + const user = await api.get(`/users/${userId}`) + if (!user) { + throw new EnterpriseApiError(`User with ID ${userId} not found.`, { + userId, + }) + } + } + + const secretsToCreate = Object.entries(secrets) + const cmdLog = log.info({ status: "active", section: "secrets-command", msg: "Creating secrets..." }) + + let count = 1 + const errors: ApiCommandError[] = [] + const results: CreateSecretResponse[] = [] + for (const [name, value] of secretsToCreate) { + cmdLog.setState({ msg: `Creating secrets... → ${count}/${secretsToCreate.length}` }) + count++ + try { + const body = { environmentId, userId, projectId: project.id, name, value } + const res = await api.post(`/secrets`, { body }) + results.push(res) + } catch (err) { + errors.push({ + identifier: name, + message: err?.response?.body?.message || err.messsage, + }) + } + } + + handleBulkOperationResult({ + log, + cmdLog, + action: "create", + resource: "secret", + errors, + successCount: results.length, + }) + + return { result: { errors, results } } + } +} diff --git a/core/src/commands/enterprise/secrets/secrets-delete.ts b/core/src/commands/enterprise/secrets/secrets-delete.ts new file mode 100644 index 0000000000..1b9f09d8c2 --- /dev/null +++ b/core/src/commands/enterprise/secrets/secrets-delete.ts @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { BaseResponse } from "@garden-io/platform-api-types" +import { StringsParameter } from "../../../cli/params" +import { CommandError, ConfigurationError } from "../../../exceptions" +import { printHeader } from "../../../logger/util" +import { dedent, deline } from "../../../util/string" +import { Command, CommandParams, CommandResult } from "../../base" +import { ApiCommandError, confirmDelete, handleBulkOperationResult, noApiMsg } from "../helpers" + +export interface SecretsDeleteCommandResult { + errors: ApiCommandError[] + results: BaseResponse[] +} + +export const secretsDeleteArgs = { + ids: new StringsParameter({ + help: deline`The IDs of the secrets to delete.`, + }), +} + +type Args = typeof secretsDeleteArgs + +export class SecretsDeleteCommand extends Command { + name = "delete" + help = "[EXPERIMENTAL] Delete secrets." + description = dedent` + Delete secrets in Garden Enterprise. You will nee the IDs of the secrets you want to delete, + which you which you can get from the \`garden enterprise secrets list\` command. + + Examples: + garden enterprise secrets delete 1,2,3 # delete secrets with IDs 1,2, and 3. + ` + + arguments = secretsDeleteArgs + + printHeader({ headerLog }) { + printHeader(headerLog, "Delete secrets", "lock") + } + + async action({ garden, args, log }: CommandParams): Promise> { + const secretsToDelete = (args.ids || []).map((id) => parseInt(id, 10)) + if (secretsToDelete.length === 0) { + throw new CommandError(`No secret IDs provided.`, { + args, + }) + } + + if (!(await confirmDelete("secret", secretsToDelete.length))) { + return {} + } + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("delete", "secrets"), {}) + } + + const cmdLog = log.info({ status: "active", section: "secrets-command", msg: "Deleting secrets..." }) + + let count = 1 + const errors: ApiCommandError[] = [] + const results: BaseResponse[] = [] + for (const id of secretsToDelete) { + cmdLog.setState({ msg: `Deleting secrets... → ${count}/${secretsToDelete.length}` }) + count++ + try { + const res = await api.delete(`/secrets/${id}`) + results.push(res) + } catch (err) { + errors.push({ + identifier: id, + message: err?.response?.body?.message || err.messsage, + }) + } + } + + handleBulkOperationResult({ + log, + cmdLog, + errors, + action: "delete", + resource: "secret", + successCount: results.length, + }) + + return { result: { errors, results } } + } +} diff --git a/core/src/commands/enterprise/secrets/secrets-list.ts b/core/src/commands/enterprise/secrets/secrets-list.ts new file mode 100644 index 0000000000..e79aecae1e --- /dev/null +++ b/core/src/commands/enterprise/secrets/secrets-list.ts @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { stringify } from "query-string" +import { ConfigurationError } from "../../../exceptions" +import { ListSecretsResponse } from "@garden-io/platform-api-types" + +import { printHeader } from "../../../logger/util" +import { dedent, deline, renderTable } from "../../../util/string" +import { Command, CommandParams, CommandResult } from "../../base" +import { applyFilter, getProject, noApiMsg } from "../helpers" +import chalk from "chalk" +import { sortBy } from "lodash" +import { StringsParameter } from "../../../cli/params" + +interface Secret { + id: number + createdAt: string + updatedAt: string + name: string + environment?: { + name: string + id: number + } + user?: { + name: string + id: number + vcsUsername: string + } +} + +export const secretsListOpts = { + "filter-envs": new StringsParameter({ + help: deline`Filter on environment. Use comma as a separator to filter on multiple environments. + Accepts glob patterns."`, + }), + "filter-user-ids": new StringsParameter({ + help: deline`Filter on user ID. Use comma as a separator to filter on multiple user IDs. Accepts glob patterns.`, + }), + "filter-names": new StringsParameter({ + help: deline`Filter on secret name. Use comma as a separator to filter on multiple secret names. Accepts glob patterns.`, + }), +} + +type Opts = typeof secretsListOpts + +export class SecretsListCommand extends Command<{}, Opts> { + name = "list" + help = "[EXPERIMENTAL] List secrets." + description = dedent` + List all secrets from Garden Enterprise. Optionally filter on environment, user IDs, or secret names. + + Examples: + garden enterprise secrets list # list all secrets + garden enterprise secrets list --filter-envs dev # list all secrets from the dev environment + garden enterprise secrets list --filter-envs dev --filter-names *_DB_* # list all secrets from the dev environment that have '_DB_' in their name. + ` + + options = secretsListOpts + + printHeader({ headerLog }) { + printHeader(headerLog, "List secrets", "lock") + } + + async action({ garden, log, opts }: CommandParams<{}, Opts>): Promise> { + const envFilter = opts["filter-envs"] || [] + const nameFilter = opts["filter-names"] || [] + const userFilter = opts["filter-user-ids"] || [] + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("list", "secrets"), {}) + } + + const project = await getProject(api, api.projectId) + + const q = stringify({ projectId: project.id }) + const res = await api.get(`/secrets?${q}`) + const secrets = res.data.map((secret) => { + const ret: Secret = { + name: secret.name, + id: secret.id, + updatedAt: secret.updatedAt, + createdAt: secret.createdAt, + } + if (secret.environment) { + ret["environment"] = { + name: secret.environment.name, + id: secret.environment.id, + } + } + if (secret.user) { + ret["user"] = { + name: secret.user.name, + id: secret.user.id, + vcsUsername: secret.user.vcsUsername, + } + } + return ret + }) + + log.info("") + + if (secrets.length === 0) { + log.info("No secrets found in project.") + return { result: [] } + } + + const filtered = sortBy(secrets, "name") + .filter((secret) => applyFilter(envFilter, secret.environment?.name)) + .filter((secret) => applyFilter(userFilter, String(secret.user?.id))) + .filter((secret) => applyFilter(nameFilter, secret.name)) + + if (filtered.length === 0) { + log.info("No secrets found in project that match filters.") + return { result: [] } + } + + const heading = ["Name", "ID", "Environment", "User", "Created At"].map((s) => chalk.bold(s)) + const rows: string[][] = filtered.map((s) => { + return [ + chalk.cyan.bold(s.name), + String(s.id), + s.environment?.name || "[none]", + s.user?.name || "[none]", + new Date(s.createdAt).toUTCString(), + ] + }) + + log.info(renderTable([heading].concat(rows))) + + return { result: filtered } + } +} diff --git a/core/src/commands/enterprise/secrets/secrets.ts b/core/src/commands/enterprise/secrets/secrets.ts new file mode 100644 index 0000000000..05353ba7d5 --- /dev/null +++ b/core/src/commands/enterprise/secrets/secrets.ts @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommandGroup } from "../../base" +import { SecretsCreateCommand } from "./secrets-create" +import { SecretsDeleteCommand } from "./secrets-delete" +import { SecretsListCommand } from "./secrets-list" + +export class SecretsCommand extends CommandGroup { + name = "secrets" + help = "[EXPERIMENTAL] List, create, and delete secrets." + + subCommands = [SecretsListCommand, SecretsCreateCommand, SecretsDeleteCommand] +} diff --git a/core/src/commands/enterprise/users/users-create.ts b/core/src/commands/enterprise/users/users-create.ts new file mode 100644 index 0000000000..8524fa4a87 --- /dev/null +++ b/core/src/commands/enterprise/users/users-create.ts @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommandError, ConfigurationError } from "../../../exceptions" +import { CreateUserBulkRequest, CreateUserBulkResponse, UserResponse } from "@garden-io/platform-api-types" +import dotenv = require("dotenv") +import { readFile } from "fs-extra" + +import { printHeader } from "../../../logger/util" +import { Command, CommandParams, CommandResult } from "../../base" +import { ApiCommandError, handleBulkOperationResult, noApiMsg } from "../helpers" +import { dedent, deline } from "../../../util/string" +import { StringsParameter, PathParameter } from "../../../cli/params" +import { StringMap } from "../../../config/common" +import { chunk } from "lodash" +import Bluebird = require("bluebird") + +const MAX_USERS_PER_REQUEST = 100 + +export interface ErrorResponse { + message: string + status: string +} + +export interface UsersCreateCommandResult { + errors: ApiCommandError[] + results: UserResponse[] +} + +export const secretsCreateArgs = { + users: new StringsParameter({ + help: deline`The VCS usernames and the names of the users to create, separated by '='. + Use comma as a separator to specify multiple VCS username/name pairs. Note + that you can also leave this empty and have Garden read the users from file.`, + }), +} + +export const secretsCreateOpts = { + "add-to-groups": new StringsParameter({ + help: deline`Add the user to the group with the given ID. Use comma as a separator to add the user to multiple groups.`, + }), + "from-file": new PathParameter({ + help: deline`Read the users from the file at the given path. The file should have standard "dotenv" + format (as defined by [dotenv](https://github.com/motdotla/dotenv#rules)) where the VCS username is the key and the + name is the value.`, + }), +} + +type Args = typeof secretsCreateArgs +type Opts = typeof secretsCreateOpts + +export class UsersCreateCommand extends Command { + name = "create" + help = "[EXPERIMENTAL] Create users" + description = dedent` + Create users in Garden Enterprise and optionally add the users to specific groups. + You can get the group IDs from the \`garden enterprise users list\` command. + + To create a user, you'll need their GitHub or GitLab username, depending on which one is your VCS provider, and the name + they should have in Garden Enterprise. Note that it **must** the their GitHub/GitLab username, not their email, as people + can have several emails tied to their GitHub/GitLab accounts. + + You can optionally read the users from a file. The file must have the format vcs-username="Actual Username". For example: + + fatema_m="Fatema M" + gordon99="Gordon G" + + Examples: + garden enterprise users create fatema_m="Fatema M",gordon99="Gordon G" # create two users + garden enterprise users create fatema_m="Fatema M" --add-to-groups 1,2 # create a user and add two groups with IDs 1,2 + garden enterprise users create --from-file /path/to/users.txt # create users from the key value pairs in the users.txt file + ` + + arguments = secretsCreateArgs + options = secretsCreateOpts + + printHeader({ headerLog }) { + printHeader(headerLog, "Create users", "lock") + } + + async action({ + garden, + log, + opts, + args, + }: CommandParams): Promise> { + const addToGroups = (opts["add-to-groups"] || []).map((groupId) => parseInt(groupId, 10)) + const fromFile = opts["from-file"] as string | undefined + let users: StringMap + + if (fromFile) { + try { + users = dotenv.parse(await readFile(fromFile)) + } catch (err) { + throw new CommandError(`Unable to read users from file at path ${fromFile}: ${err.message}`, { + args, + opts, + }) + } + } else if (args.users) { + users = args.users.reduce((acc, keyValPair) => { + const parts = keyValPair.split("=") + acc[parts[0]] = parts[1] + return acc + }, {}) + } else { + throw new CommandError( + dedent` + No users provided. Either provide users directly to the command or via the --from-file flag. + `, + { args, opts } + ) + } + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("create", "users"), {}) + } + + const cmdLog = log.info({ status: "active", section: "groups-command", msg: "Creating users..." }) + + const usersToCreate = Object.entries(users).map(([vcsUsername, name]) => ({ + name, + vcsUsername, + })) + const batches = chunk(usersToCreate, MAX_USERS_PER_REQUEST) + // This pretty arbitrary, but the bulk action can create 100 users at a time + // so the queue shouldn't ever get very long. + const concurrency = 2 + const nAsyncBatches = Math.ceil(batches.length / concurrency) + let currentAsyncBatch = 0 + let count = 1 + + const errors: ApiCommandError[] = [] + const results: UserResponse[] = [] + await Bluebird.map( + batches, + async (userBatch) => { + const asyncBatch = Math.ceil(count / nAsyncBatches) + if (asyncBatch > currentAsyncBatch) { + currentAsyncBatch = asyncBatch + cmdLog.setState({ msg: `Creating users... → Batch ${currentAsyncBatch}/${nAsyncBatches}` }) + } + count++ + try { + const body: CreateUserBulkRequest = { + users: userBatch, + addToGroups, + } + const res = await api.post(`/users/bulk`, { body }) + const successes = res.data.filter((d) => d.statusCode === 200).map((d) => d.user) as UserResponse[] + results.push(...successes) + + const failures = res.data + .filter((d) => d.statusCode !== 200) + .map((d) => ({ + message: d.message, + identifier: d.user.vcsUsername, + })) + errors.push(...failures) + } catch (err) { + errors.push({ + identifier: "", + message: err?.response?.body?.message || err.messsage, + }) + } + }, + { concurrency } + ) + + handleBulkOperationResult({ + log, + cmdLog, + errors, + action: "create", + resource: "user", + successCount: results.length, + }) + + return { result: { errors, results } } + } +} diff --git a/core/src/commands/enterprise/users/users-delete.ts b/core/src/commands/enterprise/users/users-delete.ts new file mode 100644 index 0000000000..8db406b8d3 --- /dev/null +++ b/core/src/commands/enterprise/users/users-delete.ts @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { BaseResponse } from "@garden-io/platform-api-types" +import { StringsParameter } from "../../../cli/params" +import { CommandError, ConfigurationError } from "../../../exceptions" +import { printHeader } from "../../../logger/util" +import { dedent, deline } from "../../../util/string" +import { Command, CommandParams, CommandResult } from "../../base" +import { ApiCommandError, confirmDelete, handleBulkOperationResult, noApiMsg } from "../helpers" + +export interface UsersDeleteCommandResult { + errors: ApiCommandError[] + results: BaseResponse[] +} + +export const usersDeleteArgs = { + ids: new StringsParameter({ + help: deline`The IDs of the users to delete.`, + }), +} + +type Args = typeof usersDeleteArgs + +export class UsersDeleteCommand extends Command { + name = "delete" + help = "[EXPERIMENTAL] Delete users." + description = dedent` + Delete users in Garden Enterprise. You will nee the IDs of the users you want to delete, + which you which you can get from the \`garden enterprise users list\` command. + + Examples: + garden enterprise users delete 1,2,3 # delete users with IDs 1,2, and 3. + ` + + arguments = usersDeleteArgs + + printHeader({ headerLog }) { + printHeader(headerLog, "Delete users", "lock") + } + + async action({ garden, args, log }: CommandParams): Promise> { + const usersToDelete = (args.ids || []).map((id) => parseInt(id, 10)) + if (usersToDelete.length === 0) { + throw new CommandError(`No user IDs provided.`, { + args, + }) + } + + if (!(await confirmDelete("user", usersToDelete.length))) { + return {} + } + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("delete", "user"), {}) + } + + const cmdLog = log.info({ status: "active", section: "users-command", msg: "Deleting users..." }) + + let count = 1 + const errors: ApiCommandError[] = [] + const results: BaseResponse[] = [] + for (const id of usersToDelete) { + cmdLog.setState({ msg: `Deleting users... → ${count}/${usersToDelete.length}` }) + count++ + try { + const res = await api.delete(`/users/${id}`) + results.push(res) + } catch (err) { + errors.push({ + identifier: id, + message: err?.response?.body?.message || err.messsage, + }) + } + } + + handleBulkOperationResult({ + log, + cmdLog, + errors, + action: "delete", + resource: "user", + successCount: results.length, + }) + + return { result: { errors, results } } + } +} diff --git a/core/src/commands/enterprise/users/users-list.ts b/core/src/commands/enterprise/users/users-list.ts new file mode 100644 index 0000000000..9ab1d4eda8 --- /dev/null +++ b/core/src/commands/enterprise/users/users-list.ts @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ConfigurationError } from "../../../exceptions" +import { GetAllUsersResponse } from "@garden-io/platform-api-types" + +import { printHeader } from "../../../logger/util" +import { dedent, deline, renderTable } from "../../../util/string" +import { Command, CommandParams, CommandResult } from "../../base" +import { applyFilter, getProject, noApiMsg } from "../helpers" +import chalk from "chalk" +import { sortBy } from "lodash" +import { StringsParameter } from "../../../cli/params" + +interface User { + id: number + createdAt: string + updatedAt: string + name: string + vcsUsername: string + groups: string[] +} + +export const usersListOpts = { + "filter-names": new StringsParameter({ + help: deline`Filter on user name. Use comma as a separator to filter on multiple names. Accepts glob patterns.`, + }), + "filter-groups": new StringsParameter({ + help: deline`Filter on the groups the user belongs to. Use comma as a separator to filter on multiple groups. Accepts glob patterns.`, + }), +} + +type Opts = typeof usersListOpts + +export class UsersListCommand extends Command<{}, Opts> { + name = "list" + help = "[EXPERIMENTAL] List users." + description = dedent` + List all users from Garden Enterprise. Optionally filter on group names or user names. + + Examples: + garden enterprise users list # list all users + garden enterprise users list --filter-names Gordon* # list all the Gordons in Garden Enterprise. Useful if you have a lot of Gordons. + garden enterprise users list --filter-groups devs-* # list all users in groups that with names that start with 'dev-' + ` + + options = usersListOpts + + printHeader({ headerLog }) { + printHeader(headerLog, "List users", "information_desk_person") + } + + async action({ garden, log, opts }: CommandParams<{}, Opts>): Promise> { + const nameFilter = opts["filter-names"] || [] + const groupFilter = opts["filter-groups"] || [] + + const api = garden.enterpriseApi + if (!api) { + throw new ConfigurationError(noApiMsg("list", "users"), {}) + } + + const project = await getProject(api, api.projectId) + // Make a best effort VCS provider guess. We should have an API endpoint for this or return with the response. + const vcsProviderTitle = project.repositoryUrl.includes("github.com") + ? "GitHub" + : project.repositoryUrl.includes("gitlab.com") + ? "GitLab" + : "VCS" + + let page = 0 + let users: User[] = [] + let hasMore = true + while (hasMore) { + const res = await api.get(`/users?page=${page}`) + users.push( + ...res.data.map((user) => ({ + id: user.id, + name: user.name, + vcsUsername: user.vcsUsername, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + groups: user.groups.map((g) => g.name), + })) + ) + if (res.data.length === 0) { + hasMore = false + } else { + page++ + } + } + + log.info("") + + if (users.length === 0) { + log.info("No users found in project.") + return { result: [] } + } + + const filtered = sortBy(users, "name") + .filter((user) => applyFilter(nameFilter, user.name)) + .filter((user) => applyFilter(groupFilter, user.groups)) + + if (filtered.length === 0) { + log.info("No users found in project that match filters.") + return { result: [] } + } + + const heading = ["Name", "ID", `${vcsProviderTitle} Username`, "Groups", "Created At"].map((s) => chalk.bold(s)) + const rows: string[][] = filtered.map((u) => { + return [ + chalk.cyan.bold(u.name), + String(u.id), + u.vcsUsername, + u.groups.join(", "), + new Date(u.createdAt).toUTCString(), + ] + }) + + log.info(renderTable([heading].concat(rows))) + + return { result: filtered } + } +} diff --git a/core/src/commands/enterprise/users/users.ts b/core/src/commands/enterprise/users/users.ts new file mode 100644 index 0000000000..1a799ea974 --- /dev/null +++ b/core/src/commands/enterprise/users/users.ts @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2018-2021 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { CommandGroup } from "../../base" +import { UsersCreateCommand } from "./users-create" +import { UsersDeleteCommand } from "./users-delete" +import { UsersListCommand } from "./users-list" + +export class UsersCommand extends CommandGroup { + name = "users" + help = "[EXPERIMENTAL] List, create, and delete users." + + subCommands = [UsersListCommand, UsersCreateCommand, UsersDeleteCommand] +} diff --git a/core/src/enterprise/api.ts b/core/src/enterprise/api.ts index 154ef2ed4b..419a107f07 100644 --- a/core/src/enterprise/api.ts +++ b/core/src/enterprise/api.ts @@ -333,6 +333,13 @@ export class EnterpriseApi { }) } + async delete(path: string, headers?: GotHeaders) { + return await this.apiFetch(path, { + headers: headers || {}, + method: "DELETE", + }) + } + async post(path: string, payload: { body?: any; headers?: GotHeaders } = { body: {} }) { const { headers, body } = payload return this.apiFetch(path, { diff --git a/core/src/enterprise/get-secrets.ts b/core/src/enterprise/get-secrets.ts index 0d9ed0ab93..5ff35b2567 100644 --- a/core/src/enterprise/get-secrets.ts +++ b/core/src/enterprise/get-secrets.ts @@ -9,6 +9,7 @@ import { LogEntry } from "../logger/log-entry" import { StringMap } from "../config/common" import { EnterpriseApi } from "./api" +import { BaseResponse } from "@garden-io/platform-api-types" export interface GetSecretsParams { log: LogEntry @@ -20,7 +21,7 @@ export async function getSecrets({ log, environmentName, enterpriseApi }: GetSec let secrets: StringMap = {} try { - const res = await enterpriseApi.get<{ status: string; data: StringMap }>( + const res = await enterpriseApi.get( `/secrets/projectUid/${enterpriseApi.projectId}/env/${environmentName}` ) secrets = res.data diff --git a/core/src/enterprise/workflow-lifecycle.ts b/core/src/enterprise/workflow-lifecycle.ts index 0794a8f5f5..4fbf194299 100644 --- a/core/src/enterprise/workflow-lifecycle.ts +++ b/core/src/enterprise/workflow-lifecycle.ts @@ -12,6 +12,7 @@ import { EnterpriseApiError } from "../exceptions" import { gardenEnv } from "../constants" import { Garden } from "../garden" import { ApiFetchResponse } from "./api" +import { RegisterWorkflowRunResponse } from "@garden-io/platform-api-types" export interface RegisterWorkflowRunParams { workflowConfig: WorkflowConfig @@ -43,7 +44,7 @@ export async function registerWorkflowRun({ } if (enterpriseApi) { // TODO: Use API types package here. - let res: ApiFetchResponse<{ workflowRunUid: string; status: string }> + let res: ApiFetchResponse try { res = await enterpriseApi.post("workflow-runs", { body: requestData }) } catch (err) { diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5b1caa45c8..3a0dcc4a89 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -890,6 +890,223 @@ stdout: stderr: ``` +### garden enterprise secrets list + +**[EXPERIMENTAL] List secrets.** + +List all secrets from Garden Enterprise. Optionally filter on environment, user IDs, or secret names. + +Examples: + garden enterprise secrets list # list all secrets + garden enterprise secrets list --filter-envs dev # list all secrets from the dev environment + garden enterprise secrets list --filter-envs dev --filter-names *_DB_* # list all secrets from the dev environment that have '_DB_' in their name. + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise secrets list [options] + +#### Options + +| Argument | Alias | Type | Description | +| -------- | ----- | ---- | ----------- | + | `--filter-envs` | | array:string | Filter on environment. Use comma as a separator to filter on multiple environments. Accepts glob patterns." + | `--filter-user-ids` | | array:string | Filter on user ID. Use comma as a separator to filter on multiple user IDs. Accepts glob patterns. + | `--filter-names` | | array:string | Filter on secret name. Use comma as a separator to filter on multiple secret names. Accepts glob patterns. + + +### garden enterprise secrets create + +**[EXPERIMENTAL] Create secrets** + +Create secrets in Garden Enterprise. You can create project wide secrets or optionally scope +them to an environment, or an environment and a user. + +To scope secrets to a user, you will need the user's ID which you can get from the +`garden enterprise users list` command. + +You can optionally read the secrets from a file. + +Examples: + garden enterprise secrets create DB_PASSWORD=my-pwd,ACCESS_KEY=my-key # create two secrets + garden enterprise secrets create ACCESS_KEY=my-key --scope-to-env ci # create a secret and scope it to the ci environment + garden enterprise secrets create ACCESS_KEY=my-key --scope-to-env ci --scope-to-user 9 # create a secret and scope it to the ci environment and user with ID 9 + garden enterprise secrets create --from-file /path/to/secrets.txt # create secrets from the key value pairs in the secrets.txt file + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise secrets create [secrets] [options] + +#### Arguments + +| Argument | Required | Description | +| -------- | -------- | ----------- | + | `secrets` | No | The names and values of the secrets to create, separated by '='. Use comma as a separator to specify multiple secret name/value pairs. Note that you can also leave this empty and have Garden read the secrets from file. + +#### Options + +| Argument | Alias | Type | Description | +| -------- | ----- | ---- | ----------- | + | `--scope-to-user-id` | | number | Scope the secret to a user with the given ID. User scoped secrets must be scoped to an environment as well. + | `--scope-to-env` | | string | Scope the secret to an environment. Note that this does not default to the environment that the command runs in (i.e. the one set via the --env flag) and that you need to set this explicitly if you want to create an environment scoped secret. + | `--from-file` | | path | Read the secrets from the file at the given path. The file should have standard "dotenv" format, as defined by [dotenv](https://github.com/motdotla/dotenv#rules). + + +### garden enterprise secrets delete + +**[EXPERIMENTAL] Delete secrets.** + +Delete secrets in Garden Enterprise. You will nee the IDs of the secrets you want to delete, +which you which you can get from the `garden enterprise secrets list` command. + +Examples: + garden enterprise secrets delete 1,2,3 # delete secrets with IDs 1,2, and 3. + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise secrets delete [ids] + +#### Arguments + +| Argument | Required | Description | +| -------- | -------- | ----------- | + | `ids` | No | The IDs of the secrets to delete. + + + +### garden enterprise users list + +**[EXPERIMENTAL] List users.** + +List all users from Garden Enterprise. Optionally filter on group names or user names. + +Examples: + garden enterprise users list # list all users + garden enterprise users list --filter-names Gordon* # list all the Gordons in Garden Enterprise. Useful if you have a lot of Gordons. + garden enterprise users list --filter-groups devs-* # list all users in groups that with names that start with 'dev-' + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise users list [options] + +#### Options + +| Argument | Alias | Type | Description | +| -------- | ----- | ---- | ----------- | + | `--filter-names` | | array:string | Filter on user name. Use comma as a separator to filter on multiple names. Accepts glob patterns. + | `--filter-groups` | | array:string | Filter on the groups the user belongs to. Use comma as a separator to filter on multiple groups. Accepts glob patterns. + + +### garden enterprise users create + +**[EXPERIMENTAL] Create users** + +Create users in Garden Enterprise and optionally add the users to specific groups. +You can get the group IDs from the `garden enterprise users list` command. + +To create a user, you'll need their GitHub or GitLab username, depending on which one is your VCS provider, and the name +they should have in Garden Enterprise. Note that it **must** the their GitHub/GitLab username, not their email, as people +can have several emails tied to their GitHub/GitLab accounts. + +You can optionally read the users from a file. The file must have the format vcs-username="Actual Username". For example: + +fatema_m="Fatema M" +gordon99="Gordon G" + +Examples: + garden enterprise users create fatema_m="Fatema M",gordon99="Gordon G" # create two users + garden enterprise users create fatema_m="Fatema M" --add-to-groups 1,2 # create a user and add two groups with IDs 1,2 + garden enterprise users create --from-file /path/to/users.txt # create users from the key value pairs in the users.txt file + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise users create [users] [options] + +#### Arguments + +| Argument | Required | Description | +| -------- | -------- | ----------- | + | `users` | No | The VCS usernames and the names of the users to create, separated by '='. Use comma as a separator to specify multiple VCS username/name pairs. Note that you can also leave this empty and have Garden read the users from file. + +#### Options + +| Argument | Alias | Type | Description | +| -------- | ----- | ---- | ----------- | + | `--add-to-groups` | | array:string | Add the user to the group with the given ID. Use comma as a separator to add the user to multiple groups. + | `--from-file` | | path | Read the users from the file at the given path. The file should have standard "dotenv" format (as defined by [dotenv](https://github.com/motdotla/dotenv#rules)) where the VCS username is the key and the name is the value. + + +### garden enterprise users delete + +**[EXPERIMENTAL] Delete users.** + +Delete users in Garden Enterprise. You will nee the IDs of the users you want to delete, +which you which you can get from the `garden enterprise users list` command. + +Examples: + garden enterprise users delete 1,2,3 # delete users with IDs 1,2, and 3. + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise users delete [ids] + +#### Arguments + +| Argument | Required | Description | +| -------- | -------- | ----------- | + | `ids` | No | The IDs of the users to delete. + + + +### garden enterprise groups list + +**[EXPERIMENTAL] List groups.** + +List all groups from Garden Enterprise. This is useful for getting the group IDs when creating +users via the `garden enterprise users create` coomand. + +Examples: + garden enterprise groups list # list all groups + garden enterprise groups list --filter-names dev-* # list all groups that start with 'dev-' + +| Supported in workflows | | +| ---------------------- |---| +| No | | + +#### Usage + + garden enterprise groups list [options] + +#### Options + +| Argument | Alias | Type | Description | +| -------- | ----- | ---- | ----------- | + | `--filter-names` | | array:string | Filter on group name. Use comma as a separator to filter on multiple names. Accepts glob patterns. + + ### garden get graph **Outputs the dependency relationships specified in this project's garden.yml files.** diff --git a/yarn.lock b/yarn.lock index 12a546ebff..fcb4839285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1373,6 +1373,11 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@garden-io/platform-api-types@1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@garden-io/platform-api-types/-/platform-api-types-1.14.0.tgz#711ddeb9d73e9149a89d9c9bd32ff9e018cfe42b" + integrity sha512-LOT815/pkIa2EIfHjecn9TW3S24dzsl6m8RODFhTAKrQlj7NAO+iWw9iznaPNIU6dCGpHJnvENRTc409TRzUMw== + "@google-cloud/kms@^1.6.3": version "1.6.3" resolved "https://registry.yarnpkg.com/@google-cloud/kms/-/kms-1.6.3.tgz#bdfe617e813f940018fb50e51e4025af68c3f885"