Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,53 @@ mb git-sync remove-collection 12
mb git-sync remove-collection 12 --json --profile prod
```

## Workspaces

Lifecycle for EE workspaces via `/api/ee/workspace-manager` (Metabase v62+, `workspaces` premium feature). A workspace attaches databases and provisions warehouse isolation — a temporary schema + user per database — so work runs against real data without touching prod schemas. Alias: `mb ws`.

### `mb workspace list`

```sh
mb workspace list
mb workspace list --json
```

### `mb workspace get <id>`

Includes per-database provisioning status (`unprovisioned` | `provisioning` | `provisioned` | `deprovisioning`), input schemas, and the isolated output namespace.

```sh
mb workspace get 1 --json
```

### `mb workspace create`

Creates the workspace and provisions isolation for each database (blocking). Each database must be eligible for workspaces (driver support + the `database-enable-workspaces` database setting).

```sh
mb workspace create --name ws-reports --database-ids 1
mb workspace create --name ws-etl --database-ids 1,2 --json
```

| Flag | Description |
| ---------------------- | ---------------------------------------- |
| `--name <name>` | Workspace name. |
| `--database-ids <ids>` | Database ids to attach, comma separated. |

### `mb workspace destroy <id>`

Tears down each provisioned database's warehouse isolation, then removes the workspace. Prompts unless `--yes`. Refuses (409) while any database is still provisioning/deprovisioning unless `--ignore-pending`, which removes the records and leaves those warehouse objects behind; unreachable-warehouse leftovers are reported as `orphaned_resources`.

```sh
mb workspace destroy 1 --yes
mb workspace destroy 1 --yes --ignore-pending
```

| Flag | Description |
| ------------------ | ----------------------------------------------------------------------- |
| `--yes` | Skip confirmation. |
| `--ignore-pending` | Remove workspace records even while databases are mid-(de)provisioning. |

## Instance setup

Bootstrapping a fresh, not-yet-configured Metabase instance.
Expand Down
39 changes: 39 additions & 0 deletions src/commands/parse-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import { ConfigError } from "../core/errors";

import { parseIdCsv } from "./parse-id";

describe("parseIdCsv", () => {
it("parses a comma-separated list, trimming whitespace", () => {
expect(parseIdCsv("1, 2,3", "database id")).toEqual([1, 2, 3]);
});

it("parses a single id", () => {
expect(parseIdCsv("7", "database id")).toEqual([7]);
});

it("throws ConfigError when the list is empty", () => {
expect(() => parseIdCsv("", "database id")).toThrow(
new ConfigError("expected at least one database id (comma separated)"),
);
});

it("throws ConfigError when the list is only separators", () => {
expect(() => parseIdCsv(",,", "database id")).toThrow(
new ConfigError("expected at least one database id (comma separated)"),
);
});

it("throws ConfigError on a non-integer entry, preserving the raw value", () => {
expect(() => parseIdCsv("1,abc", "database id")).toThrow(
new ConfigError(`invalid database id: "abc" (expected integer)`),
);
});

it("throws ConfigError on a zero id (ids start at 1)", () => {
expect(() => parseIdCsv("0", "database id")).toThrow(
new ConfigError("invalid database id: 0 (must be ≥ 1)"),
);
});
});
11 changes: 11 additions & 0 deletions src/commands/parse-id.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { ConfigError } from "../core/errors";
import { parseCsv } from "../runtime/csv";

import { parseInteger } from "./parse-integer";

export function parseId(value: string, name = "id"): number {
return parseInteger(value, { name, min: 1 });
}

export function parseIdCsv(value: string, name: string): number[] {
const ids = parseCsv(value).map((part) => parseId(part, name));
if (ids.length === 0) {
throw new ConfigError(`expected at least one ${name} (comma separated)`);
}
return ids;
}
42 changes: 42 additions & 0 deletions src/commands/workspace/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Workspace, workspaceView } from "../../domain/workspace";
import { renderSummary } from "../../output/render";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseIdCsv } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "create", description: "Create and provision a workspace" },
details:
"Attaches the given databases and provisions warehouse isolation (a temporary schema + user per database) before returning. Each database must be eligible for workspaces; provisioning is blocking, so the response carries the final per-database status.",
capabilities: { minVersion: 62, tokenFeature: "workspaces" },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
name: { type: "string", description: "Workspace name", required: true },
"database-ids": {
type: "string",
description: "Database ids to attach, comma separated",
required: true,
},
},
outputSchema: Workspace,
examples: [
"mb workspace create --name ws-reports --database-ids 1",
"mb workspace create --name ws-etl --database-ids 1,2 --json",
],
async run({ args, ctx, getClient }) {
const databaseIds = parseIdCsv(args["database-ids"], "database id");
const client = await getClient();
const created = await client.requestParsed(Workspace, "/api/ee/workspace-manager/", {
method: "POST",
body: { name: args.name, database_ids: databaseIds },
});
renderSummary(
created,
workspaceView,
`Created workspace ${created.id} "${created.name}".`,
ctx,
);
},
});
103 changes: 103 additions & 0 deletions src/commands/workspace/destroy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { z } from "zod";

import { ConfigError } from "../../core/errors";
import type { ResourceView } from "../../domain/view";
import { promptConfirm } from "../../output/prompt";
import { renderSummary } from "../../output/render";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

const WorkspaceOrphanedResource = z
.object({
workspace_database_id: z.number().int(),
database_id: z.number().int(),
driver: z.string(),
schema: z.string(),
user: z.string(),
reason: z.string().nullable().optional(),
})
.loose();

const WorkspaceDeleteResponse = z
.object({
id: z.number().int(),
deleted: z.boolean(),
message: z.string().optional(),
orphaned_resources: z.array(WorkspaceOrphanedResource).optional(),
})
.loose();

export const WorkspaceDestroyResult = WorkspaceDeleteResponse.extend({
aborted: z.boolean(),
});
export type WorkspaceDestroyResultJson = z.infer<typeof WorkspaceDestroyResult>;

const workspaceDestroyView: ResourceView<WorkspaceDestroyResultJson> = {
compactPick: WorkspaceDestroyResult,
tableColumns: [
{ key: "id", label: "ID" },
{ key: "deleted", label: "Destroyed" },
{ key: "aborted", label: "Aborted" },
{ key: "message", label: "Message" },
],
};

export default defineMetabaseCommand({
meta: { name: "destroy", description: "Destroy a workspace and its warehouse isolation" },
details:
"Tears down each provisioned database's warehouse isolation (temporary schema + user) before removing the workspace. Refuses with a 409 while any database is still provisioning/deprovisioning unless --ignore-pending is passed, which removes the workspace records and leaves those warehouse objects behind. If the warehouse was unreachable, the result lists the leftover schema/user objects under orphaned_resources.",
capabilities: { minVersion: 62, tokenFeature: "workspaces" },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
yes: { type: "boolean", description: "Skip confirmation", default: false },
"ignore-pending": {
type: "boolean",
description: "Remove workspace records even while databases are provisioning/deprovisioning",
default: false,
},
id: { type: "positional", description: "Workspace id", required: true },
},
outputSchema: WorkspaceDestroyResult,
examples: ["mb workspace destroy 1 --yes", "mb workspace destroy 1 --yes --ignore-pending"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
if (!args.yes) {
if (process.stdin.isTTY !== true) {
throw new ConfigError(
`refusing to destroy workspace ${id} without confirmation — pass --yes to proceed non-interactively`,
);
}
const ok = await promptConfirm({
message: `Destroy workspace ${id}? Its warehouse isolation (schema + user) is dropped.`,
initialValue: false,
});
if (!ok) {
renderSummary(
{ id, deleted: false, aborted: true },
workspaceDestroyView,
`Aborted; workspace ${id} was not destroyed.`,
ctx,
);
return;
}
}
const client = await getClient();
const response = await client.requestParsed(
WorkspaceDeleteResponse,
`/api/ee/workspace-manager/${id}`,
{
method: "DELETE",
query: { "ignore-pending": args["ignore-pending"] ? true : undefined },
},
);
renderSummary(
{ ...response, aborted: false },
workspaceDestroyView,
response.message ?? `Destroyed workspace ${id}.`,
ctx,
);
},
});
24 changes: 24 additions & 0 deletions src/commands/workspace/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Workspace, workspaceView } from "../../domain/workspace";
import { renderItem } from "../../output/render";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "get", description: "Get a workspace by id" },
capabilities: { minVersion: 62, tokenFeature: "workspaces" },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
id: { type: "positional", description: "Workspace id", required: true },
},
outputSchema: Workspace,
examples: ["mb workspace get 1", "mb workspace get 1 --json"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
const workspace = await client.requestParsed(Workspace, `/api/ee/workspace-manager/${id}`);
renderItem(workspace, workspaceView, ctx);
},
});
11 changes: 11 additions & 0 deletions src/commands/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineCommand } from "citty";

export default defineCommand({
meta: { name: "workspace", description: "Manage Metabase workspaces", alias: "ws" },
subCommands: {
list: () => import("./list").then((mod) => mod.default),
get: () => import("./get").then((mod) => mod.default),
create: () => import("./create").then((mod) => mod.default),
destroy: () => import("./destroy").then((mod) => mod.default),
},
});
24 changes: 24 additions & 0 deletions src/commands/workspace/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from "zod";

import { Workspace, WorkspaceCompact, workspaceView } from "../../domain/workspace";
import { renderList } from "../../output/render";
import { listEnvelopeSchema, wrapList } from "../../output/types";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";

const WorkspaceApiList = z.array(Workspace);

export const WorkspaceListEnvelope = listEnvelopeSchema(WorkspaceCompact);

export default defineMetabaseCommand({
meta: { name: "list", description: "List workspaces" },
capabilities: { minVersion: 62, tokenFeature: "workspaces" },
args: { ...outputFlags, ...profileFlag, ...connectionFlags },
outputSchema: WorkspaceListEnvelope,
examples: ["mb workspace list", "mb workspace list --json"],
async run({ ctx, getClient }) {
const client = await getClient();
const items = await client.requestParsed(WorkspaceApiList, "/api/ee/workspace-manager/");
renderList(wrapList(items), workspaceView, ctx);
},
});
76 changes: 76 additions & 0 deletions src/domain/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { z } from "zod";

import type { ResourceView } from "./view";

const WorkspaceDatabaseStatus = z.enum([
"unprovisioned",
"provisioning",
"provisioned",
"deprovisioning",
]);

const WorkspaceCreator = z
.object({
id: z.number().int(),
first_name: z.string().nullable(),
last_name: z.string().nullable(),
email: z.string(),
common_name: z.string().nullable().optional(),
})
.loose();

const WorkspaceDatabase = z
.object({
database_id: z.number().int(),
input_schemas: z.array(z.string()),
output_namespace: z.string(),
status: WorkspaceDatabaseStatus,
database: z.object({ id: z.number().int(), name: z.string() }).loose().nullable().optional(),
})
.loose();

const WorkspaceDatabaseCompact = WorkspaceDatabase.pick({
database_id: true,
input_schemas: true,
output_namespace: true,
status: true,
}).strip();

export const Workspace = z
.object({
id: z.number().int(),
name: z.string(),
creator: WorkspaceCreator.nullable(),
created_at: z.string(),
updated_at: z.string(),
databases: z.array(WorkspaceDatabase).optional(),
})
.loose();
export type Workspace = z.infer<typeof Workspace>;

export const WorkspaceCompact = Workspace.pick({
id: true,
name: true,
created_at: true,
})
.strip()
.extend({ databases: z.array(WorkspaceDatabaseCompact).optional() });
export type WorkspaceCompact = z.infer<typeof WorkspaceCompact>;

export const workspaceView: ResourceView<Workspace> = {
compactPick: WorkspaceCompact,
tableColumns: [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "created_at", label: "Created" },
{ key: "databases", label: "Databases", format: (value) => formatDatabases(value) },
],
};

function formatDatabases(value: unknown): string {
const parsed = z.array(WorkspaceDatabaseCompact).safeParse(value);
if (!parsed.success) {
return "";
}
return parsed.data.map((db) => `${db.database_id}:${db.status}`).join(", ");
}
Loading
Loading