From 9ac421cecdeb1c5e9ee57c9feb83b8c8c125dba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BE=C3=B3r=20Magn=C3=BAsson?= Date: Fri, 19 Mar 2021 10:57:00 +0100 Subject: [PATCH] feat(enterprise): add utility commands to manage enterprise resources This PR contains a handful of experimental utility commands to manage enterprise resources such as users and secrets. This is flagged as experimental for now, since it requires GE 1.13.1 or higher and probably some more iteration from our end. But the core functionality is all there. --- core/package.json | 1 + core/src/cli/params.ts | 2 +- core/src/commands/commands.ts | 2 + core/src/commands/enterprise/enterprise.ts | 23 ++ core/src/commands/enterprise/groups/groups.ts | 99 ++++++++ core/src/commands/enterprise/helpers.ts | 113 +++++++++ .../enterprise/secrets/secrets-create.ts | 187 +++++++++++++++ .../enterprise/secrets/secrets-delete.ts | 94 ++++++++ .../enterprise/secrets/secrets-list.ts | 139 +++++++++++ .../commands/enterprise/secrets/secrets.ts | 19 ++ .../commands/enterprise/users/users-create.ts | 187 +++++++++++++++ .../commands/enterprise/users/users-delete.ts | 94 ++++++++ .../commands/enterprise/users/users-list.ts | 128 +++++++++++ core/src/commands/enterprise/users/users.ts | 19 ++ core/src/enterprise/api.ts | 7 + core/src/enterprise/get-secrets.ts | 3 +- core/src/enterprise/workflow-lifecycle.ts | 3 +- docs/reference/commands.md | 217 ++++++++++++++++++ yarn.lock | 5 + 19 files changed, 1339 insertions(+), 3 deletions(-) create mode 100644 core/src/commands/enterprise/enterprise.ts create mode 100644 core/src/commands/enterprise/groups/groups.ts create mode 100644 core/src/commands/enterprise/helpers.ts create mode 100644 core/src/commands/enterprise/secrets/secrets-create.ts create mode 100644 core/src/commands/enterprise/secrets/secrets-delete.ts create mode 100644 core/src/commands/enterprise/secrets/secrets-list.ts create mode 100644 core/src/commands/enterprise/secrets/secrets.ts create mode 100644 core/src/commands/enterprise/users/users-create.ts create mode 100644 core/src/commands/enterprise/users/users-delete.ts create mode 100644 core/src/commands/enterprise/users/users-list.ts create mode 100644 core/src/commands/enterprise/users/users.ts 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"