194 lines
8.8 KiB
JavaScript
194 lines
8.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { parse, parseAllDocuments } from "yaml";
|
|
|
|
import { materializeDifyComposeEnv } from "./dify-compose-config.mjs";
|
|
|
|
const compose = readFileSync(new URL("../infra/local/compose.yaml", import.meta.url), "utf8");
|
|
const difyComposeFiles = [
|
|
readFileSync(new URL("../../docker/docker-compose-template.yaml", import.meta.url), "utf8"),
|
|
readFileSync(new URL("../../docker/docker-compose.yaml", import.meta.url), "utf8"),
|
|
];
|
|
const difyApiEnv = readFileSync(
|
|
new URL("../../docker/envs/core-services/api.env.example", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const difyKnowledgeFsEnv = readFileSync(
|
|
new URL("../../docker/envs/core-services/knowledge-fs.env.example", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const kubernetesBaseline = readFileSync(
|
|
new URL("../infra/kubernetes/dify-integration-baseline.yaml", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
function serviceBlock(source, serviceName) {
|
|
const lines = source.split("\n");
|
|
const start = lines.findIndex((line) => line === ` ${serviceName}:`);
|
|
assert.notEqual(start, -1, `missing ${serviceName} service`);
|
|
const relativeEnd = lines
|
|
.slice(start + 1)
|
|
.findIndex((line) => /^ {2}[a-zA-Z0-9_-]+:$/.test(line));
|
|
const end = relativeEnd === -1 ? lines.length : start + 1 + relativeEnd;
|
|
return lines.slice(start, end).join("\n");
|
|
}
|
|
|
|
test("deployment Compose and Kubernetes artifacts are valid YAML", () => {
|
|
for (const source of [compose, ...difyComposeFiles]) {
|
|
const document = parse(source);
|
|
assert.equal(typeof document?.services, "object");
|
|
}
|
|
|
|
const documents = parseAllDocuments(kubernetesBaseline);
|
|
assert.equal(documents.length, 4);
|
|
for (const document of documents) {
|
|
assert.deepEqual(document.errors, []);
|
|
}
|
|
assert.deepEqual(
|
|
documents.map((document) => document.toJS().kind),
|
|
["ConfigMap", "Deployment", "Service", "NetworkPolicy"],
|
|
);
|
|
});
|
|
|
|
test("Dify Compose config materializes a missing ignored env without replacing user config", () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "dify-compose-config-"));
|
|
const envPath = join(directory, ".env");
|
|
const examplePath = join(directory, ".env.example");
|
|
|
|
try {
|
|
writeFileSync(examplePath, "BASELINE=true\n");
|
|
const removeMaterializedEnv = materializeDifyComposeEnv({ envPath, examplePath });
|
|
assert.equal(readFileSync(envPath, "utf8"), "BASELINE=true\n");
|
|
removeMaterializedEnv();
|
|
assert.equal(existsSync(envPath), false);
|
|
|
|
writeFileSync(envPath, "USER_CONFIG=true\n");
|
|
const preserveUserEnv = materializeDifyComposeEnv({ envPath, examplePath });
|
|
preserveUserEnv();
|
|
assert.equal(readFileSync(envPath, "utf8"), "USER_CONFIG=true\n");
|
|
} finally {
|
|
rmSync(directory, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
test("app compose profile builds the API image with development-only static auth", () => {
|
|
assert.match(compose, /^ {2}api:$/m);
|
|
assert.match(compose, /^ {4}build:$/m);
|
|
assert.match(compose, /^ {6}context: \.\.\/\.\.$/m);
|
|
assert.match(compose, /^ {6}dockerfile: apps\/api\/Dockerfile$/m);
|
|
assert.match(compose, /^ {4}image: knowledge-fs-api:local$/m);
|
|
assert.match(compose, /^ {4}profiles: \["apps"\]$/m);
|
|
assert.match(compose, /^ {6}NODE_ENV: development$/m);
|
|
assert.match(compose, /^ {6}PORT: 8787$/m);
|
|
assert.match(
|
|
compose,
|
|
/^ {6}KNOWLEDGE_DEV_AUTH_TOKEN: \$\{KNOWLEDGE_DEV_AUTH_TOKEN:-dev-token\}$/m,
|
|
);
|
|
assert.match(
|
|
compose,
|
|
/^ {6}KNOWLEDGE_EMBEDDING_PROVIDER: \$\{KNOWLEDGE_EMBEDDING_PROVIDER:-\}$/m,
|
|
);
|
|
assert.match(compose, /^ {6}KNOWLEDGE_EMBEDDING_MODEL: \$\{KNOWLEDGE_EMBEDDING_MODEL:-\}$/m);
|
|
assert.match(compose, /^ {6}OPENAI_EMBEDDING_BASE_URL: \$\{OPENAI_EMBEDDING_BASE_URL:-\}$/m);
|
|
});
|
|
|
|
test("app compose profile waits for durable middleware readiness before API startup", () => {
|
|
assert.match(compose, /^ {4}depends_on:$/m);
|
|
assert.match(compose, /^ {6}postgres:$/m);
|
|
assert.match(compose, /^ {8}condition: service_healthy$/m);
|
|
assert.match(compose, /^ {6}minio-bootstrap:$/m);
|
|
assert.match(compose, /^ {8}condition: service_completed_successfully$/m);
|
|
assert.match(compose, /^ {6}unstructured:$/m);
|
|
assert.match(compose, /^ {8}condition: service_started$/m);
|
|
});
|
|
|
|
test("app compose profile uses service-local middleware URLs inside the API container", () => {
|
|
assert.match(
|
|
compose,
|
|
/^ {6}DATABASE_URL: postgresql:\/\/\$\{POSTGRES_USER:-knowledge_fs\}:\$\{POSTGRES_PASSWORD:-knowledge_fs\}@postgres:5432\/\$\{POSTGRES_DB:-knowledge_fs\}$/m,
|
|
);
|
|
assert.match(compose, /^ {6}MINIO_ENDPOINT: http:\/\/minio:9000$/m);
|
|
assert.match(compose, /^ {6}UNSTRUCTURED_API_URL: http:\/\/unstructured:8000$/m);
|
|
});
|
|
|
|
test("app compose profile builds Admin as a production image after API readiness", () => {
|
|
assert.match(compose, /^ {2}admin:$/m);
|
|
assert.match(compose, /^ {6}dockerfile: apps\/admin\/Dockerfile$/m);
|
|
assert.match(compose, /^ {4}image: knowledge-fs-admin:local$/m);
|
|
assert.match(compose, /^ {4}profiles: \["apps"\]$/m);
|
|
assert.match(compose, /^ {6}api:$/m);
|
|
assert.match(compose, /^ {8}condition: service_healthy$/m);
|
|
assert.match(compose, /^ {6}HOSTNAME: 0\.0\.0\.0$/m);
|
|
assert.match(compose, /^ {6}KNOWLEDGE_API_BASE_URL: http:\/\/api:8787$/m);
|
|
assert.match(compose, /^ {6}NEXT_PUBLIC_API_BASE_URL: http:\/\/localhost:\$\{API_PORT:-8788\}$/m);
|
|
assert.match(compose, /^ {6}NODE_ENV: production$/m);
|
|
assert.match(compose, /^ {6}PORT: 3000$/m);
|
|
assert.doesNotMatch(compose, /pnpm --filter @knowledge\/admin dev/);
|
|
assert.doesNotMatch(compose, /^ {6}- \.:\/workspace$/m);
|
|
assert.doesNotMatch(compose, /^ {2}pnpm-store:$/m);
|
|
});
|
|
|
|
test("Dify compose keeps the integrated KnowledgeFS API internal and disabled by profile", () => {
|
|
for (const difyCompose of difyComposeFiles) {
|
|
const knowledgeFs = serviceBlock(difyCompose, "knowledge_fs");
|
|
assert.match(knowledgeFs, /^ {4}profiles: \["knowledge-fs"\]$/m);
|
|
assert.match(knowledgeFs, /^ {6}context: \.\.\/knowledge-fs$/m);
|
|
assert.match(knowledgeFs, /^ {6}dockerfile: apps\/api\/Dockerfile$/m);
|
|
assert.match(knowledgeFs, /^ {4}expose:$/m);
|
|
assert.match(knowledgeFs, /^ {6}- "8787"$/m);
|
|
assert.doesNotMatch(knowledgeFs, /^ {4}ports:$/m);
|
|
assert.match(
|
|
knowledgeFs,
|
|
/^ {6}KNOWLEDGE_INTEGRATED_MODE_ENABLED: \$\{KNOWLEDGE_INTEGRATED_MODE_ENABLED:-true\}$/m,
|
|
);
|
|
assert.doesNotMatch(knowledgeFs, /^ {6}PLUGIN_DAEMON_(?:URL|KEY):/m);
|
|
assert.doesNotMatch(knowledgeFs, /^ {6}plugin_daemon:$/m);
|
|
assert.match(knowledgeFs, /http:\/\/127\.0\.0\.1:8787\/ready/);
|
|
assert.match(knowledgeFs, /^ {6}- default$/m);
|
|
}
|
|
assert.match(difyApiEnv, /^KNOWLEDGE_FS_ENABLED=\$\{KNOWLEDGE_FS_ENABLED:-false\}$/m);
|
|
});
|
|
|
|
test("deployment examples expose every guarded KnowledgeFS rollout capability as disabled", () => {
|
|
for (const variable of [
|
|
"KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED",
|
|
"KNOWLEDGE_FS_INTEGRATED_PROVISION_READY",
|
|
"KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY",
|
|
]) {
|
|
assert.match(difyApiEnv, new RegExp(`^${variable}=false$`, "m"));
|
|
}
|
|
for (const variable of [
|
|
"KNOWLEDGE_FS_CAPABILITY_V2_ENABLED",
|
|
"KNOWLEDGE_INTEGRATED_MODE_ENABLED",
|
|
"KNOWLEDGE_LEGACY_ACL_READ_ONLY",
|
|
"KNOWLEDGE_LEGACY_AUTHORIZATION_REMOVED",
|
|
]) {
|
|
assert.match(difyKnowledgeFsEnv, new RegExp(`^${variable}=false$`, "m"));
|
|
}
|
|
assert.match(difyKnowledgeFsEnv, /^KNOWLEDGE_DIRECT_UPLOAD_ENABLED=off$/m);
|
|
assert.match(difyKnowledgeFsEnv, /^KNOWLEDGE_DIRECT_UPLOAD_ALLOWED_ORIGINS=$/m);
|
|
assert.match(difyKnowledgeFsEnv, /^KNOWLEDGE_DIRECT_STREAM_ENABLED=off$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}KNOWLEDGE_INTEGRATED_MODE_ENABLED: "false"$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}KNOWLEDGE_LEGACY_AUTHORIZATION_REMOVED: "false"$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}KNOWLEDGE_DIRECT_UPLOAD_ENABLED: "off"$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}KNOWLEDGE_DIRECT_UPLOAD_ALLOWED_ORIGINS: ""$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}KNOWLEDGE_DIRECT_STREAM_ENABLED: "off"$/m);
|
|
});
|
|
|
|
test("Kubernetes baseline starts at zero replicas with internal-only service and fail-closed probes", () => {
|
|
assert.match(kubernetesBaseline, /^kind: Deployment$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}replicas: 0$/m);
|
|
assert.match(kubernetesBaseline, /^ {14}path: \/health$/m);
|
|
assert.match(kubernetesBaseline, /^ {14}path: \/ready$/m);
|
|
assert.match(kubernetesBaseline, /^kind: Service$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}type: ClusterIP$/m);
|
|
assert.match(kubernetesBaseline, /^kind: NetworkPolicy$/m);
|
|
assert.match(kubernetesBaseline, /^ {2}policyTypes:$/m);
|
|
assert.match(kubernetesBaseline, /^ {4}- Ingress$/m);
|
|
assert.doesNotMatch(kubernetesBaseline, /^kind: Ingress$/m);
|
|
assert.doesNotMatch(kubernetesBaseline, /\b(?:LoadBalancer|NodePort)\b/);
|
|
});
|