mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +00:00
feat: workspace settings as one document, applied server-side (#11740)
* feat: add the workspace settings document, applied server-side * feat: add wmill workspace get-settings and apply-settings * fix: warn on unknown settings keys and refuse ee-only ones in the dry run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: stub the settings document endpoint in the push settings unit tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse mistyped settings and keep 4xx status on a partial settings apply Reject non-boolean switches and non-string Slack credentials instead of coercing them into destructive writes, preserve the failing endpoint's status when wrapping a partial-apply error, keep the stored Slack secret when an older CLI pushes one encrypted under another workspace's key, print the plan's warnings in apply-settings, and state the admin check load_settings_document leaves to its caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: compare handler arguments verbatim and type-check scalar settings A handler's extra_args is stored as sent and its nulls reach the job as arguments, so an added or removed null argument is now a change. Scalar settings are type-checked in the plan so a dry run never reports a change the endpoint then refuses, and an error handler's omitted mute flags compare as the false the endpoint stores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: require a 32-bit integer rate limit in the settings plan Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: data table permissions and protection rules in the settings document The settings document gains datatable_permissions, each data table's roles named by their Postgres role name and written by catalog id, and protection_rules, keyed by rule name. Both are managed only when stated and stated as a whole. The dev workspace lock stays with the dev workspace setting. wmill sync carries datatable_permissions only with includeDatatablePermissions (or --include-datatable-permissions), since tenants and roles are specific to a workspace and an instance. protection_rules stay out of settings.yaml: protection-rules.yaml syncs them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: validate the default data table role and plan permissions against a replaced datatable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
17fefcf777
commit
9a5ec223bc
@@ -109,6 +109,9 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
|
||||
commented: true, templateValue: "true" },
|
||||
{ name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync",
|
||||
commented: true, templateValue: "true" },
|
||||
{ name: "includeDatatablePermissions", type: "boolean", default: "false",
|
||||
description: "With includeSettings, also sync each data table's roles and who may connect as them",
|
||||
commented: true, templateValue: "true" },
|
||||
{ name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync",
|
||||
commented: true, templateValue: "true" },
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ export async function downloadZip(
|
||||
skipWorkspaceDependencies?: boolean,
|
||||
skipDatatableMigrations?: boolean,
|
||||
defaultTs?: "bun" | "deno",
|
||||
syncBehavior?: string
|
||||
syncBehavior?: string,
|
||||
includeDatatablePermissions?: boolean,
|
||||
): Promise<JSZip | TarAsZip | undefined> {
|
||||
const requestHeaders = new Headers();
|
||||
requestHeaders.set("Authorization", "Bearer " + workspace.token);
|
||||
@@ -112,6 +113,7 @@ export async function downloadZip(
|
||||
}&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false
|
||||
}&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false
|
||||
}&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false
|
||||
}&include_datatable_permissions=${includeDatatablePermissions ?? false
|
||||
}&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&skip_datatable_migrations=${skipDatatableMigrations ?? false}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true&sync_behavior_version=${syncBehavior ?? "v0"}`;
|
||||
|
||||
const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?";
|
||||
|
||||
@@ -14,6 +14,7 @@ import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts";
|
||||
import * as path from "node:path";
|
||||
import { sep as SEP } from "node:path";
|
||||
import {
|
||||
@@ -2576,6 +2577,14 @@ const isWorkspaceSettingsFile = (p: string) =>
|
||||
function stripUnappliedSettingsFields(local: any, remote: any) {
|
||||
delete local?.name;
|
||||
delete remote?.name;
|
||||
for (const key of ["operator_settings", ...SETTINGS_MANAGED_ONLY_WHEN_PRESENT]) {
|
||||
if (local?.[key] === undefined) delete remote?.[key];
|
||||
}
|
||||
// push clears an omitted mute_critical_alerts to false
|
||||
if (local?.mute_critical_alerts == null && remote?.mute_critical_alerts === false) {
|
||||
delete local?.mute_critical_alerts;
|
||||
delete remote.mute_critical_alerts;
|
||||
}
|
||||
if (local?.color == null) {
|
||||
delete local?.color;
|
||||
delete remote?.color;
|
||||
@@ -3843,6 +3852,7 @@ export async function pull(
|
||||
opts.skipDatatableMigrations,
|
||||
opts.defaultTs,
|
||||
opts.syncBehavior,
|
||||
opts.includeDatatablePermissions,
|
||||
);
|
||||
|
||||
const remote = ZipFSElement(
|
||||
@@ -4946,6 +4956,7 @@ export async function push(
|
||||
opts.skipDatatableMigrations,
|
||||
opts.defaultTs,
|
||||
opts.syncBehavior,
|
||||
opts.includeDatatablePermissions,
|
||||
))!,
|
||||
!opts.json,
|
||||
opts.defaultTs ?? "bun",
|
||||
@@ -6049,6 +6060,7 @@ export async function push(
|
||||
(opts.yes ?? false) || !process.stdin.isTTY,
|
||||
skipReencrypt: opts.skipReencryptOnKeyChange,
|
||||
},
|
||||
includeDatatablePermissions: opts.includeDatatablePermissions,
|
||||
defaultTs: opts.defaultTs,
|
||||
},
|
||||
);
|
||||
@@ -6187,6 +6199,7 @@ export async function push(
|
||||
(opts.yes ?? false) || !process.stdin.isTTY,
|
||||
skipReencrypt: opts.skipReencryptOnKeyChange,
|
||||
},
|
||||
includeDatatablePermissions: opts.includeDatatablePermissions,
|
||||
defaultTs: opts.defaultTs,
|
||||
},
|
||||
);
|
||||
@@ -6815,6 +6828,10 @@ const command = new Command()
|
||||
.option("--include-users", "Include syncing users")
|
||||
.option("--include-groups", "Include syncing groups")
|
||||
.option("--include-settings", "Include syncing workspace settings")
|
||||
.option(
|
||||
"--include-datatable-permissions",
|
||||
"With --include-settings, also sync who may connect to each data table as which role",
|
||||
)
|
||||
.option("--include-key", "Include workspace encryption key")
|
||||
.option(
|
||||
"--keep-deleted",
|
||||
@@ -6878,6 +6895,10 @@ const command = new Command()
|
||||
.option("--include-users", "Include syncing users")
|
||||
.option("--include-groups", "Include syncing groups")
|
||||
.option("--include-settings", "Include syncing workspace settings")
|
||||
.option(
|
||||
"--include-datatable-permissions",
|
||||
"With --include-settings, also sync who may connect to each data table as which role",
|
||||
)
|
||||
.option("--include-key", "Include workspace encryption key")
|
||||
.option(
|
||||
"--skip-reencrypt-on-key-change",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { yamlParseFile } from "../../utils/yaml.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
export async function getSettings(
|
||||
opts: GlobalOptions & { encrypted?: boolean; json?: boolean },
|
||||
) {
|
||||
await requireLogin(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
const document = await wmill.getWorkspaceSettingsDocument({
|
||||
workspace: workspace.workspaceId,
|
||||
secrets: opts.encrypted ? "encrypted" : "redacted",
|
||||
});
|
||||
console.log(
|
||||
opts.json ? JSON.stringify(document, null, 2) : yamlStringify(document),
|
||||
);
|
||||
}
|
||||
|
||||
export async function applySettings(
|
||||
opts: GlobalOptions & { dryRun?: boolean; replace?: boolean; yes?: boolean },
|
||||
file: string,
|
||||
) {
|
||||
await requireLogin(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
const document = await yamlParseFile(file);
|
||||
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
||||
throw new Error(`${file} must hold a settings document (a mapping of setting to value)`);
|
||||
}
|
||||
const apply = (dry_run: boolean) =>
|
||||
wmill.applyWorkspaceSettingsDocument({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: { document, replace: opts.replace ?? false, dry_run },
|
||||
});
|
||||
|
||||
const plan = await apply(true);
|
||||
for (const warning of plan.warnings ?? []) {
|
||||
log.warn(warning);
|
||||
}
|
||||
if (plan.changes.length === 0) {
|
||||
log.info(colors.green("Workspace settings are already up to date"));
|
||||
return;
|
||||
}
|
||||
for (const change of plan.changes) {
|
||||
log.info(
|
||||
`${colors.bold(change.key)}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`,
|
||||
);
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!opts.yes &&
|
||||
!(await Confirm.prompt({
|
||||
message: `Apply ${plan.changes.length} change(s) to workspace ${workspace.workspaceId}?`,
|
||||
default: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = await apply(false);
|
||||
for (const warning of result.warnings ?? []) {
|
||||
log.warn(warning);
|
||||
}
|
||||
log.info(
|
||||
colors.bold.underline.green(
|
||||
`Applied ${result.changes.length} change(s) to workspace ${workspace.workspaceId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { requireLogin } from "../../core/auth.ts";
|
||||
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
||||
import { mergeWorkspaces } from "./merge.ts";
|
||||
import { connectSlack, disconnectSlack } from "./slack.ts";
|
||||
import { applySettings, getSettings } from "./settings.ts";
|
||||
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
@@ -905,6 +906,22 @@ Arguments (omit both to be prompted interactively):
|
||||
.description(
|
||||
"Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push."
|
||||
)
|
||||
.action(disconnectSlack as any);
|
||||
.action(disconnectSlack as any)
|
||||
.command("get-settings")
|
||||
.description(
|
||||
"Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only."
|
||||
)
|
||||
.option("--encrypted", "Write secrets encrypted with the workspace key, as 'wmill sync pull' does")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(getSettings as any)
|
||||
.command("apply-settings")
|
||||
.description(
|
||||
"Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only."
|
||||
)
|
||||
.arguments("<file:string>")
|
||||
.option("--dry-run", "Only show the changes")
|
||||
.option("--replace", "Also clear the settings the file omits, as 'wmill sync push' does")
|
||||
.option("-y --yes", "Apply without asking")
|
||||
.action(applySettings as any);
|
||||
|
||||
export default command;
|
||||
|
||||
Reference in New Issue
Block a user