mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 22:11:37 +00:00
fix(docker): package selected external plugins in source-built images (#103629)
* build(docker): package selected external plugins * test(docker): auto-clean plugin fixture * fix(docker): preserve selected image provenance * fix(docker): accept manifest-only plugin selections * fix(docker): resolve selected plugin manifest ids * fix(docker): isolate resolved plugin selection
This commit is contained in:
committed by
GitHub
parent
13819996ad
commit
a238dead67
@@ -1,13 +1,17 @@
|
||||
// Bundled Plugin Build Entries tests cover bundled plugin build entries script behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectRootPackageExcludedExtensionDirs,
|
||||
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
|
||||
listBundledPluginBuildEntries,
|
||||
listBundledPluginPackArtifacts,
|
||||
} from "../../scripts/lib/bundled-plugin-build-entries.mjs";
|
||||
import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function expectNoPrefixMatches(values: string[], prefix: string) {
|
||||
expect(values.filter((value) => value.startsWith(prefix))).toEqual([]);
|
||||
@@ -201,6 +205,143 @@ describe("bundled plugin build entries", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("builds explicitly selected external plugins only for Docker", () => {
|
||||
const baselineEnv = { ...process.env };
|
||||
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
|
||||
const dockerEnv = {
|
||||
...baselineEnv,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack clickclack,slack,msteams",
|
||||
};
|
||||
const entries = listBundledPluginBuildEntries({ env: dockerEnv });
|
||||
const baselineArtifacts = listBundledPluginPackArtifacts({ env: baselineEnv });
|
||||
const artifacts = listBundledPluginPackArtifacts({ env: dockerEnv });
|
||||
const reorderedEntries = listBundledPluginBuildEntries({
|
||||
env: {
|
||||
...baselineEnv,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "msteams,clickclack slack",
|
||||
},
|
||||
});
|
||||
const entryKeys = Object.keys(entries);
|
||||
|
||||
expect(entries["extensions/clickclack/index"]).toBe("extensions/clickclack/index.ts");
|
||||
expect(entries["extensions/slack/index"]).toBe("extensions/slack/index.ts");
|
||||
expect(entries["extensions/slack/setup-entry"]).toBe("extensions/slack/setup-entry.ts");
|
||||
expect(entries["extensions/msteams/index"]).toBe("extensions/msteams/index.ts");
|
||||
expect(entries["extensions/clawrouter/index"]).toBe("extensions/clawrouter/index.ts");
|
||||
expect(entryKeys.findIndex((entry) => entry.startsWith("extensions/clickclack/"))).toBeLessThan(
|
||||
entryKeys.findIndex((entry) => entry.startsWith("extensions/slack/")),
|
||||
);
|
||||
expect(Object.keys(reorderedEntries)).toEqual(entryKeys);
|
||||
expect(artifacts).toEqual(baselineArtifacts);
|
||||
expectNoPrefixMatches(artifacts, "dist/extensions/clickclack/");
|
||||
expectNoPrefixMatches(artifacts, "dist/extensions/msteams/");
|
||||
expectNoPrefixMatches(artifacts, "dist/extensions/slack/");
|
||||
});
|
||||
|
||||
it("sorts Docker-selected build entries without git metadata", () => {
|
||||
const repoDir = tempDirs.make("openclaw-docker-build-entries-");
|
||||
const extensionsDir = path.join(repoDir, "extensions");
|
||||
|
||||
for (const pluginId of ["clickclack", "msteams", "slack"]) {
|
||||
const pluginDir = path.join(extensionsDir, pluginId);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {};\n");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id: pluginId })}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: `@openclaw/${pluginId}`,
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
build: { bundledDist: false },
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const unsortedDirents = fs.readdirSync(extensionsDir, { withFileTypes: true }).toReversed();
|
||||
const readdirSpy = vi
|
||||
.spyOn(fs, "readdirSync")
|
||||
.mockImplementationOnce(() => unsortedDirents as never);
|
||||
try {
|
||||
expect(
|
||||
Object.keys(
|
||||
listBundledPluginBuildEntries({
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...process.env,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack,msteams,clickclack",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"extensions/clickclack/index",
|
||||
"extensions/msteams/index",
|
||||
"extensions/slack/index",
|
||||
]);
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves known dependency-only Docker plugin selections", () => {
|
||||
const baselineEnv = { ...process.env };
|
||||
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
|
||||
const baselineEntries = listBundledPluginBuildEntries({ env: baselineEnv });
|
||||
const selectedEntries = listBundledPluginBuildEntries({
|
||||
env: {
|
||||
...baselineEnv,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "whatsapp,qqbot",
|
||||
},
|
||||
});
|
||||
|
||||
expect(selectedEntries).toEqual(baselineEntries);
|
||||
expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/qqbot/");
|
||||
expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/whatsapp/");
|
||||
});
|
||||
|
||||
it("preserves known package-less bundled Docker plugin selections", () => {
|
||||
const baselineEnv = { ...process.env };
|
||||
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
|
||||
const baselineEntries = listBundledPluginBuildEntries({ env: baselineEnv });
|
||||
const selectedEntries = listBundledPluginBuildEntries({
|
||||
env: {
|
||||
...baselineEnv,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "active-memory",
|
||||
},
|
||||
});
|
||||
|
||||
expect(selectedEntries).toEqual(baselineEntries);
|
||||
expect(selectedEntries["extensions/active-memory/index"]).toBe(
|
||||
"extensions/active-memory/index.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown and invalid Docker plugin selections", () => {
|
||||
for (const [selection, message] of [
|
||||
[
|
||||
"missing-plugin",
|
||||
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} references unknown plugin id(s): missing-plugin`,
|
||||
],
|
||||
[
|
||||
"../clickclack",
|
||||
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} contains invalid plugin id(s): ../clickclack`,
|
||||
],
|
||||
] as const) {
|
||||
expect(() =>
|
||||
listBundledPluginBuildEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: selection,
|
||||
},
|
||||
}),
|
||||
).toThrow(message);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Cohere bundled through the externalization transition", () => {
|
||||
const artifacts = listBundledPluginPackArtifacts();
|
||||
|
||||
|
||||
62
test/scripts/docker-plugin-selection.test.ts
Normal file
62
test/scripts/docker-plugin-selection.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
||||
const selectorScript = path.join(repoRoot, "scripts/lib/docker-plugin-selection.mjs");
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function writePlugin(extensionsRoot: string, dirName: string, manifestId?: string) {
|
||||
const pluginDir = path.join(extensionsRoot, dirName);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, "package.json"), `${JSON.stringify({ name: dirName })}\n`);
|
||||
if (manifestId) {
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id: manifestId })}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runSelector(extensionsRoot: string, selection: string) {
|
||||
return spawnSync(process.execPath, [selectorScript, extensionsRoot, selection], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Docker plugin selection", () => {
|
||||
it("resolves manifest ids and source directory names deterministically", () => {
|
||||
const extensionsRoot = tempDirs.make("openclaw-docker-plugin-selection-");
|
||||
writePlugin(extensionsRoot, "source-only");
|
||||
writePlugin(extensionsRoot, "provider-source", "provider-id");
|
||||
|
||||
const result = runSelector(
|
||||
extensionsRoot,
|
||||
"source-only,provider-id provider-source,provider-id",
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe("provider-source\nsource-only\n");
|
||||
});
|
||||
|
||||
it("fails closed for unknown, invalid, and ambiguous ids", () => {
|
||||
const extensionsRoot = tempDirs.make("openclaw-docker-plugin-selection-errors-");
|
||||
writePlugin(extensionsRoot, "shared");
|
||||
writePlugin(extensionsRoot, "other-source", "shared");
|
||||
|
||||
for (const [selection, message] of [
|
||||
["missing-plugin", "unknown OPENCLAW_EXTENSIONS plugin id: missing-plugin"],
|
||||
["../invalid", "invalid OPENCLAW_EXTENSIONS plugin id: ../invalid"],
|
||||
["shared", "ambiguous OPENCLAW_EXTENSIONS plugin id: shared"],
|
||||
] as const) {
|
||||
const result = runSelector(extensionsRoot, selection);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user