mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 08:03:02 +00:00
* fix(cli): keep a raw app's custom_path on sync pull The pull rendered raw_app.yaml without custom_path, so every pull removed the line and every push --dry-run listed the file as modified, while the deployed app still had it and push still sent it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): send custom_path on app update only when it changed The backend requires admin for any update carrying custom_path, so the value a pull now writes into raw_app.yaml made every non-admin push of that app fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cli): sync an app's custom path as a workspace-specific file Unless app routes are workspace-prefixed, a custom path is unique across the instance, so a value carried in the app file every workspace shares can never be promoted. Sync now renders it as <app>.<workspace>.custom_path.yaml next to the app folder, always workspace-specific. A push applies only the target workspace's file and leaves the deployed custom path alone without one; a checkout that still has custom_path in its app file converges on push, and the next pull moves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cli): drop the custom_path rationale repeated from changedCustomPath Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): apply a custom path pushed with its raw app, and converge a cleared one The raw-app bundle collapse kept one change per group and dropped the custom-path file grouped with it, so a push editing both deployed only the app. App files and their custom-path file now group under the app path in both folder layouts, and the collapse keeps the custom-path change. A cleared custom path (custom_path: '') has no file in the export, so the push diff listed it as added forever; an empty value with nothing deployed is now in sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): never read a file inside a flow or app folder as workspace-specific The workspace-name segment of the workspace-specific patterns spans '/', so a raw-app asset such as settings.prod.custom_path.json was taken for a workspace's file and dropped from the pull. Like fileset children, files inside a flow or app folder are content of that item, whatever their name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): recognize a custom-path file only beside its app isCustomPathFile excluded app and flow folders but still matched a fileset child such as data.fileset/settings.custom_path.json, so a push dropped edits to it and a pull renamed it. A custom-path file is now identified by where it sits: its name is <app>[.<workspace>].custom_path.<ext> and no part of its directory is an item's folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
import JSZip from "jszip";
|
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, sep } from "node:path";
|
|
import {
|
|
checkoutInlineNames,
|
|
compareDynFSElement,
|
|
ZipFSElement,
|
|
} from "../src/commands/sync/sync.ts";
|
|
import { changedCustomPath } from "../src/commands/app/app.ts";
|
|
import { isCustomPathFile } from "../src/core/specific_items.ts";
|
|
|
|
// The differ also reads the working tree (shared lockfiles, dependency files);
|
|
// an empty one keeps that out of the picture.
|
|
const originalCwd = process.cwd();
|
|
beforeAll(() => {
|
|
process.chdir(mkdtempSync(join(tmpdir(), "wmill-push-diff-")));
|
|
});
|
|
afterAll(() => {
|
|
process.chdir(originalCwd);
|
|
});
|
|
|
|
// A push is only useful when a second run of it finds nothing left to do.
|
|
// These pin the two shapes that used to be listed on every run of a push into
|
|
// a fork while the push itself either applied nothing or aborted.
|
|
|
|
type Mock = {
|
|
isDirectory: boolean;
|
|
path: string;
|
|
getContentText(): Promise<string>;
|
|
getChildren(): AsyncIterable<Mock>;
|
|
};
|
|
|
|
// Both sides of the differ use the OS separator; fixtures are written with
|
|
// "/" and rows are read back the same way.
|
|
const osPath = (p: string) => p.split("/").join(sep);
|
|
const slashPath = (p: string) => p.split(sep).join("/");
|
|
|
|
function local(files: Record<string, string>): Mock {
|
|
return {
|
|
isDirectory: true,
|
|
path: "",
|
|
async getContentText() {
|
|
return "";
|
|
},
|
|
async *getChildren() {
|
|
for (const [path, content] of Object.entries(files)) {
|
|
yield {
|
|
isDirectory: false,
|
|
path: osPath(path),
|
|
async getContentText() {
|
|
return content;
|
|
},
|
|
async *getChildren() {},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
const noIgnore = () => false;
|
|
|
|
async function diff(
|
|
localEl: Mock,
|
|
remoteEl: Mock,
|
|
skips: Record<string, unknown>,
|
|
parentOwnsScheduleEnabled?: (scheduleFilePath: string) => boolean,
|
|
isEls1Remote = false,
|
|
) {
|
|
const { changes } = await compareDynFSElement(
|
|
localEl as any,
|
|
remoteEl as any,
|
|
noIgnore,
|
|
false,
|
|
skips as any,
|
|
true,
|
|
[],
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
isEls1Remote,
|
|
false,
|
|
parentOwnsScheduleEnabled,
|
|
);
|
|
return changes.map((c) => `${c.name} ${slashPath(c.path)}`);
|
|
}
|
|
|
|
const SCHEDULE = (enabled: string) =>
|
|
`summary: nightly\nargs: {}\nenabled: ${enabled}\nis_flow: true\nschedule: 0 0 0 * * *\nscript_path: f/mail/flow\ntimezone: UTC\n`;
|
|
|
|
test("push into a fork: a schedule the parent also has compares without `enabled`", async () => {
|
|
const remote = local({ "f/mail/nightly.schedule.yaml": SCHEDULE("true") });
|
|
const skips = { includeSchedules: true };
|
|
// The parent has `f/mail/nightly`; any other schedule is the fork's own.
|
|
const parentHas = (filePath: string) =>
|
|
slashPath(filePath) === "f/mail/nightly.schedule.yaml";
|
|
|
|
// Not a fork: `enabled` is compared like any other field.
|
|
expect(
|
|
await diff(
|
|
local({ "f/mail/nightly.schedule.yaml": SCHEDULE("false") }),
|
|
remote,
|
|
skips,
|
|
),
|
|
).toEqual(["edited f/mail/nightly.schedule.yaml"]);
|
|
expect(
|
|
await diff(
|
|
local({ "f/mail/nightly.schedule.yaml": SCHEDULE("false") }),
|
|
remote,
|
|
skips,
|
|
parentHas,
|
|
),
|
|
).toEqual([]);
|
|
// The key being absent is the same case as it differing.
|
|
expect(
|
|
await diff(
|
|
local({
|
|
"f/mail/nightly.schedule.yaml": SCHEDULE("false").replace(
|
|
"enabled: false\n",
|
|
"",
|
|
),
|
|
}),
|
|
remote,
|
|
skips,
|
|
parentHas,
|
|
),
|
|
).toEqual([]);
|
|
// Only `enabled` is set aside.
|
|
expect(
|
|
await diff(
|
|
local({
|
|
"f/mail/nightly.schedule.yaml": SCHEDULE("false").replace(
|
|
"0 0 0 * * *",
|
|
"0 0 1 * * *",
|
|
),
|
|
}),
|
|
remote,
|
|
skips,
|
|
parentHas,
|
|
),
|
|
).toEqual(["edited f/mail/nightly.schedule.yaml"]);
|
|
// A schedule only the fork has keeps toggling from the file.
|
|
expect(
|
|
await diff(
|
|
local({ "f/mail/fork_only.schedule.yaml": SCHEDULE("true") }),
|
|
local({ "f/mail/fork_only.schedule.yaml": SCHEDULE("false") }),
|
|
skips,
|
|
parentHas,
|
|
),
|
|
).toEqual(["edited f/mail/fork_only.schedule.yaml"]);
|
|
});
|
|
|
|
const SUMMARY = "process one mail end-to-end (spam check, classify)";
|
|
|
|
function remoteFlow(content: string) {
|
|
const zip = new JSZip();
|
|
zip.file(
|
|
"f/mail/flow_v2.flow.json",
|
|
JSON.stringify({
|
|
summary: "Flow V2",
|
|
description: "",
|
|
value: {
|
|
modules: [
|
|
{
|
|
id: "a",
|
|
summary: SUMMARY,
|
|
value: {
|
|
type: "rawscript",
|
|
content,
|
|
input_transforms: {},
|
|
language: "python3",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
schema: { type: "object", properties: {} },
|
|
}),
|
|
// The backend's archive carries no directory entries.
|
|
{ createFolders: false },
|
|
);
|
|
return zip;
|
|
}
|
|
|
|
function localFlow(content: string) {
|
|
return local({
|
|
"f/mail/flow_v2.flow/flow.yaml": `summary: Flow V2\ndescription: ''\nvalue:\n modules:\n - id: a\n summary: ${SUMMARY}\n value:\n type: rawscript\n content: '!inline process_mail.inline_script.py'\n input_transforms: {}\n language: python3\nschema:\n type: object\n properties: {}\n`,
|
|
"f/mail/flow_v2.flow/process_mail.inline_script.py": content,
|
|
});
|
|
}
|
|
|
|
// The checkout's `!inline` references, as `push` reads them from its flow.yaml.
|
|
const checkoutNames = async (flowDir: string) =>
|
|
slashPath(flowDir) === "f/mail/flow_v2.flow"
|
|
? { a: "process_mail.inline_script.py" }
|
|
: {};
|
|
|
|
test("push: an inline script the checkout names differently from the step summary is not a rename", async () => {
|
|
const skips = { includeSchedules: false };
|
|
const render = (content: string, withCheckout: boolean) =>
|
|
ZipFSElement(
|
|
remoteFlow(content),
|
|
true,
|
|
"bun",
|
|
{},
|
|
{},
|
|
false,
|
|
true,
|
|
withCheckout ? checkoutNames : undefined,
|
|
) as any;
|
|
|
|
// Same content, file named by hand: three rows before, none after.
|
|
expect(
|
|
await diff(
|
|
localFlow("def main():\n return 1\n"),
|
|
render("def main():\n return 1\n", false),
|
|
skips,
|
|
),
|
|
).toEqual([
|
|
"deleted f/mail/flow_v2.flow/process_one_mail_end-to-end_(spam_check,_classify).inline_script.py",
|
|
"edited f/mail/flow_v2.flow/flow.yaml",
|
|
"added f/mail/flow_v2.flow/process_mail.inline_script.py",
|
|
]);
|
|
expect(
|
|
await diff(
|
|
localFlow("def main():\n return 1\n"),
|
|
render("def main():\n return 1\n", true),
|
|
skips,
|
|
),
|
|
).toEqual([]);
|
|
|
|
// A real edit is still one.
|
|
expect(
|
|
await diff(
|
|
localFlow("def main():\n return 2\n"),
|
|
render("def main():\n return 1\n", true),
|
|
skips,
|
|
),
|
|
).toEqual(["edited f/mail/flow_v2.flow/process_mail.inline_script.py"]);
|
|
});
|
|
|
|
test("push: a checkout name that collides with another step's summary-derived name keeps two files", async () => {
|
|
const zip = new JSZip();
|
|
zip.file(
|
|
"f/mail/flow_v2.flow.json",
|
|
JSON.stringify({
|
|
summary: "Flow V2",
|
|
description: "",
|
|
value: {
|
|
modules: [
|
|
{
|
|
id: "a",
|
|
summary: SUMMARY,
|
|
value: {
|
|
type: "rawscript",
|
|
content: "a",
|
|
input_transforms: {},
|
|
language: "python3",
|
|
},
|
|
},
|
|
{
|
|
id: "b",
|
|
summary: "process_mail",
|
|
value: {
|
|
type: "rawscript",
|
|
content: "b",
|
|
input_transforms: {},
|
|
language: "python3",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
schema: { type: "object", properties: {} },
|
|
}),
|
|
{ createFolders: false },
|
|
);
|
|
// Nothing local: every rendered file is a "deleted" row, one per path.
|
|
const rows = await diff(
|
|
local({}),
|
|
ZipFSElement(zip, true, "bun", {}, {}, false, true, checkoutNames) as any,
|
|
{ includeSchedules: false },
|
|
);
|
|
expect(rows.filter((r) => r.endsWith(".py")).sort()).toEqual([
|
|
"deleted f/mail/flow_v2.flow/process_mail.inline_script.py",
|
|
"deleted f/mail/flow_v2.flow/process_one_mail_end-to-end_(spam_check,_classify).inline_script.py",
|
|
]);
|
|
});
|
|
|
|
test("push: checkout inline names stay inside the flow folder", async () => {
|
|
const flowYaml = join(process.cwd(), "flow.yaml");
|
|
writeFileSync(
|
|
flowYaml,
|
|
`summary: x\nvalue:\n modules:\n - id: a\n value:\n type: rawscript\n content: '!inline a.inline_script.py'\n language: python3\n - id: b\n value:\n type: rawscript\n content: '!inline ../shared/b.py'\n language: python3\n - id: c\n value:\n type: rawscript\n content: '!inline /tmp/c.py'\n language: python3\n`,
|
|
);
|
|
expect(await checkoutInlineNames(flowYaml)).toEqual({
|
|
a: "a.inline_script.py",
|
|
});
|
|
expect(
|
|
await checkoutInlineNames(join(process.cwd(), "missing.yaml")),
|
|
).toEqual({});
|
|
});
|
|
|
|
// A push never applies the workspace's display name and applies its color only
|
|
// when the local file carries one (see pushWorkspaceSettings), so a file that
|
|
// differs only in what would not be applied is not a push change; a pull still
|
|
// rewrites the file.
|
|
test("push: settings.yaml differing only by name or an unset color is not a change", async () => {
|
|
const remote = local({
|
|
"settings.yaml": "name: prod\ncolor: '#ff0000'\nerror_handler: null\n",
|
|
});
|
|
const unsetColor = local({
|
|
"settings.yaml": "name: staging\nerror_handler: null\n",
|
|
});
|
|
const skips = { includeSettings: true };
|
|
expect(await diff(unsetColor, remote, skips)).toEqual([]);
|
|
expect(await diff(remote, unsetColor, skips, undefined, true)).toEqual([
|
|
"edited settings.yaml",
|
|
]);
|
|
|
|
const otherColor = local({
|
|
"settings.yaml": "name: staging\ncolor: '#00ff00'\nerror_handler: null\n",
|
|
});
|
|
expect(await diff(otherColor, remote, skips)).toEqual(["edited settings.yaml"]);
|
|
});
|
|
|
|
// A push applies auto_invite.instance_groups only when the local file declares
|
|
// them (see pushWorkspaceSettings).
|
|
test("push: settings.yaml without instance_groups is not a change", async () => {
|
|
const remote = local({
|
|
"settings.yaml":
|
|
"name: prod\nauto_invite:\n enabled: false\n instance_groups:\n - eng\n instance_groups_roles:\n eng: developer\n",
|
|
});
|
|
const undeclared = local({
|
|
"settings.yaml": "name: prod\nauto_invite:\n enabled: false\n",
|
|
});
|
|
const skips = { includeSettings: true };
|
|
expect(await diff(undeclared, remote, skips)).toEqual([]);
|
|
|
|
const groupsOnlyRemote = local({
|
|
"settings.yaml": "name: prod\nauto_invite:\n instance_groups:\n - eng\n",
|
|
});
|
|
const noAutoInvite = local({ "settings.yaml": "name: prod\n" });
|
|
expect(await diff(noAutoInvite, groupsOnlyRemote, skips)).toEqual([]);
|
|
|
|
const otherGroups = local({
|
|
"settings.yaml":
|
|
"name: prod\nauto_invite:\n enabled: false\n instance_groups: []\n",
|
|
});
|
|
expect(await diff(otherGroups, remote, skips)).toEqual(["edited settings.yaml"]);
|
|
});
|
|
|
|
function remoteRawApp(customPath?: string, files: Record<string, string> = {}) {
|
|
const zip = new JSZip();
|
|
zip.file(
|
|
"f/test/myapp.raw_app.json",
|
|
JSON.stringify({
|
|
summary: "My app",
|
|
policy: { execution_mode: "publisher" },
|
|
...(customPath ? { custom_path: customPath } : {}),
|
|
value: { files, runnables: {} },
|
|
}),
|
|
{ createFolders: false },
|
|
);
|
|
return ZipFSElement(zip, true, "bun", {}, {}, false, true) as any;
|
|
}
|
|
|
|
async function rendered(el: Mock): Promise<Record<string, string>> {
|
|
const files: Record<string, string> = {};
|
|
const walk = async (el: Mock) => {
|
|
for await (const child of el.getChildren()) {
|
|
if (child.isDirectory) await walk(child);
|
|
else files[slashPath(child.path)] = await child.getContentText();
|
|
}
|
|
};
|
|
await walk(el);
|
|
return files;
|
|
}
|
|
|
|
// Workspace-named as push and pull name it, without any specificItems.
|
|
async function diffIn(ws: string, localEl: Mock, remoteEl: Mock, pull: boolean) {
|
|
const { changes } = await compareDynFSElement(
|
|
(pull ? remoteEl : localEl) as any,
|
|
(pull ? localEl : remoteEl) as any,
|
|
noIgnore,
|
|
false,
|
|
{} as any,
|
|
!pull,
|
|
[],
|
|
pull,
|
|
undefined,
|
|
ws,
|
|
pull,
|
|
false,
|
|
);
|
|
return changes.map((c) => `${c.name} ${slashPath(c.path)}`);
|
|
}
|
|
|
|
test("a custom path is its own workspace-specific file", async () => {
|
|
const files = await rendered(remoteRawApp("my-dashboard"));
|
|
expect(files["f/test/myapp.custom_path.yaml"]).toBe(
|
|
"custom_path: my-dashboard\n",
|
|
);
|
|
const rawAppYaml = files["f/test/myapp.raw_app/raw_app.yaml"];
|
|
expect(rawAppYaml).toBeDefined();
|
|
expect(rawAppYaml).not.toContain("custom_path");
|
|
|
|
const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files;
|
|
const checkout = (extra: Record<string, string>) =>
|
|
local({ ...appFiles, ...extra });
|
|
|
|
// The workspace's own file is the one compared; another's is ignored.
|
|
const own = checkout({
|
|
"f/test/myapp.prod.custom_path.yaml": "custom_path: my-dashboard\n",
|
|
"f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n",
|
|
});
|
|
for (const pull of [true, false]) {
|
|
expect(await diffIn("prod", own, remoteRawApp("my-dashboard"), pull)).toEqual([]);
|
|
}
|
|
expect(
|
|
await diffIn("staging", own, remoteRawApp("my-dashboard"), false),
|
|
).toEqual(["edited f/test/myapp.custom_path.yaml"]);
|
|
|
|
// Promotion: a checkout without the target's file leaves its path alone on
|
|
// push, and a pull brings it in as the target's file.
|
|
const promoted = checkout({
|
|
"f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n",
|
|
});
|
|
expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), false)).toEqual([]);
|
|
expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), true)).toEqual([
|
|
"added f/test/myapp.custom_path.yaml",
|
|
]);
|
|
// A pull mirrors a cleared custom path by deleting the workspace's file.
|
|
expect(await diffIn("prod", own, remoteRawApp(), true)).toEqual([
|
|
"deleted f/test/myapp.custom_path.yaml",
|
|
]);
|
|
// `custom_path: ''` clears it on push, and is in sync once it is cleared.
|
|
const cleared = checkout({
|
|
"f/test/myapp.prod.custom_path.yaml": "custom_path: ''\n",
|
|
});
|
|
expect(await diffIn("prod", cleared, remoteRawApp("my-dashboard"), false)).toEqual([
|
|
"edited f/test/myapp.custom_path.yaml",
|
|
]);
|
|
expect(await diffIn("prod", cleared, remoteRawApp(), false)).toEqual([]);
|
|
});
|
|
|
|
test("an app file named like a custom-path file is app content", async () => {
|
|
const remote = () =>
|
|
remoteRawApp(undefined, { "/settings.prod.custom_path.json": "{}" });
|
|
const files = await rendered(remote());
|
|
expect(files["f/test/myapp.raw_app/settings.prod.custom_path.json"]).toBe("{}");
|
|
expect(await diffIn("prod", local({}), remote(), true)).toContain(
|
|
"added f/test/myapp.raw_app/settings.prod.custom_path.json",
|
|
);
|
|
expect(await diffIn("prod", local(files), remote(), false)).toEqual([]);
|
|
});
|
|
|
|
test("a fileset child named like a custom-path file is resource content", async () => {
|
|
const child = "f/res/data.fileset/settings.prod.custom_path.json";
|
|
expect(isCustomPathFile(child)).toBe(false);
|
|
expect(isCustomPathFile("f/test/myapp.prod.custom_path.yaml")).toBe(true);
|
|
expect(
|
|
await diffIn("prod", local({ [child]: "{\"v\":2}" }), local({ [child]: "{\"v\":1}" }), false),
|
|
).toEqual([`edited ${child}`]);
|
|
});
|
|
|
|
// A checkout from before custom paths had their own file keeps the value in
|
|
// raw_app.yaml: a push must converge on it, and a pull moves it out.
|
|
test("an in-file custom path converges on push", async () => {
|
|
const files = await rendered(remoteRawApp("my-dashboard"));
|
|
const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files;
|
|
const inFile = (value: string) =>
|
|
local({
|
|
...appFiles,
|
|
"f/test/myapp.raw_app/raw_app.yaml":
|
|
files["f/test/myapp.raw_app/raw_app.yaml"] + `custom_path: ${value}\n`,
|
|
});
|
|
|
|
expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), false)).toEqual([]);
|
|
expect(await diffIn("prod", inFile("other"), remoteRawApp("my-dashboard"), false)).toEqual([
|
|
"edited f/test/myapp.custom_path.yaml",
|
|
]);
|
|
expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), true)).toEqual([
|
|
"edited f/test/myapp.raw_app/raw_app.yaml",
|
|
"added f/test/myapp.custom_path.yaml",
|
|
]);
|
|
});
|
|
|
|
test("push: an unchanged custom_path is not sent", () => {
|
|
expect(
|
|
changedCustomPath("my-dashboard", { custom_path: "my-dashboard" }),
|
|
).toBeUndefined();
|
|
expect(changedCustomPath(undefined, { custom_path: "my-dashboard" })).toBeUndefined();
|
|
expect(changedCustomPath("", undefined)).toBeUndefined();
|
|
expect(changedCustomPath("new", { custom_path: "my-dashboard" })).toBe("new");
|
|
expect(changedCustomPath("", { custom_path: "my-dashboard" })).toBe("");
|
|
expect(changedCustomPath("new", undefined)).toBe("new");
|
|
});
|