mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 17:07:03 +08:00
Signed-off-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: 盐粒 Yanli <yanli@dify.ai> Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
611 lines
15 KiB
TypeScript
611 lines
15 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { waitForUrl } from '../support/process'
|
|
import {
|
|
apiDir,
|
|
apiEnvExampleFile,
|
|
difyAgentDir,
|
|
dockerDir,
|
|
e2eDir,
|
|
e2eWebEnvOverrides,
|
|
ensureFileExists,
|
|
ensureLineInFile,
|
|
getTcpPortListenerDescription,
|
|
getWebEnvLocalHash,
|
|
isMainModule,
|
|
isTcpPortReachable,
|
|
middlewareComposeFile,
|
|
middlewareEnvExampleFile,
|
|
middlewareEnvFile,
|
|
readSimpleDotenv,
|
|
rootDir,
|
|
runCommand,
|
|
runCommandOrThrow,
|
|
runForegroundProcess,
|
|
waitForCondition,
|
|
webDir,
|
|
} from './common'
|
|
|
|
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
|
|
const webBuildStampPath = path.join(webDir, '.next', 'e2e-web-build.sha256')
|
|
const apiHost = '127.0.0.1'
|
|
const apiPort = 5001
|
|
const agentBackendHost = '127.0.0.1'
|
|
const agentBackendPort = Number(process.env.E2E_AGENT_BACKEND_PORT || 5050)
|
|
const shellctlHost = '127.0.0.1'
|
|
const shellctlPort = Number(process.env.E2E_SHELLCTL_PORT || 5004)
|
|
const shellctlContainerName = process.env.E2E_SHELLCTL_CONTAINER_NAME || 'dify-agent-e2e-shellctl'
|
|
const shellctlImage = process.env.E2E_SHELLCTL_IMAGE || 'dify-agent-local-sandbox:e2e'
|
|
const shellctlUrl = `http://${shellctlHost}:${shellctlPort}`
|
|
const defaultPluginDaemonKey = 'lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi'
|
|
const defaultInnerApiKeyForPlugin = 'QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1'
|
|
|
|
const middlewareDataPaths = [
|
|
path.join(dockerDir, 'volumes', 'db', 'data'),
|
|
path.join(dockerDir, 'volumes', 'plugin_daemon'),
|
|
path.join(dockerDir, 'volumes', 'redis', 'data'),
|
|
path.join(dockerDir, 'volumes', 'weaviate'),
|
|
]
|
|
|
|
const e2eStatePaths = [
|
|
path.join(e2eDir, '.auth'),
|
|
path.join(e2eDir, 'cucumber-report'),
|
|
path.join(e2eDir, '.logs'),
|
|
path.join(e2eDir, 'playwright-report'),
|
|
path.join(e2eDir, 'test-results'),
|
|
]
|
|
|
|
const composeArgs = [
|
|
'compose',
|
|
'-f',
|
|
middlewareComposeFile,
|
|
'--profile',
|
|
'postgresql',
|
|
'--profile',
|
|
'weaviate',
|
|
]
|
|
|
|
const getApiEnvironment = async (): Promise<Record<string, string>> => {
|
|
const envFromExample = await readSimpleDotenv(apiEnvExampleFile)
|
|
const agentBackendBaseUrl = getAgentBackendBaseUrl()
|
|
|
|
return {
|
|
...envFromExample,
|
|
...(agentBackendBaseUrl ? { AGENT_BACKEND_BASE_URL: agentBackendBaseUrl } : {}),
|
|
FLASK_APP: 'app.py',
|
|
}
|
|
}
|
|
|
|
function getAgentBackendBaseUrl() {
|
|
const explicitApiUrl = process.env.AGENT_BACKEND_BASE_URL?.trim()
|
|
if (explicitApiUrl)
|
|
return explicitApiUrl
|
|
|
|
const explicitE2EUrl = process.env.E2E_AGENT_BACKEND_URL?.trim()
|
|
if (explicitE2EUrl)
|
|
return explicitE2EUrl.replace(/\/$/, '')
|
|
|
|
if (process.env.E2E_START_AGENT_BACKEND === '1' || process.env.E2E_START_AGENT_BACKEND === 'true')
|
|
return `http://${agentBackendHost}:${agentBackendPort}`
|
|
|
|
return undefined
|
|
}
|
|
|
|
const getAgentBackendEnvironment = async () => {
|
|
const apiEnv = await getApiEnvironment()
|
|
const redisPassword = process.env.REDIS_PASSWORD || apiEnv.REDIS_PASSWORD || 'difyai123456'
|
|
|
|
return {
|
|
DIFY_AGENT_INNER_API_KEY:
|
|
process.env.DIFY_AGENT_INNER_API_KEY
|
|
|| process.env.INNER_API_KEY_FOR_PLUGIN
|
|
|| process.env.PLUGIN_DIFY_INNER_API_KEY
|
|
|| apiEnv.INNER_API_KEY_FOR_PLUGIN
|
|
|| defaultInnerApiKeyForPlugin,
|
|
DIFY_AGENT_INNER_API_URL: process.env.DIFY_AGENT_INNER_API_URL || `http://${apiHost}:${apiPort}`,
|
|
DIFY_AGENT_PLUGIN_DAEMON_API_KEY:
|
|
process.env.DIFY_AGENT_PLUGIN_DAEMON_API_KEY
|
|
|| process.env.PLUGIN_DAEMON_KEY
|
|
|| apiEnv.PLUGIN_DAEMON_KEY
|
|
|| defaultPluginDaemonKey,
|
|
DIFY_AGENT_PLUGIN_DAEMON_URL:
|
|
process.env.DIFY_AGENT_PLUGIN_DAEMON_URL
|
|
|| process.env.PLUGIN_DAEMON_URL
|
|
|| 'http://127.0.0.1:5002',
|
|
DIFY_AGENT_REDIS_PREFIX: process.env.DIFY_AGENT_REDIS_PREFIX || 'dify-agent-e2e',
|
|
DIFY_AGENT_REDIS_URL:
|
|
process.env.DIFY_AGENT_REDIS_URL
|
|
|| `redis://:${redisPassword}@127.0.0.1:6379/0`,
|
|
DIFY_AGENT_SHELLCTL_AUTH_TOKEN:
|
|
process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN
|
|
|| process.env.E2E_SHELLCTL_AUTH_TOKEN
|
|
|| '',
|
|
DIFY_AGENT_SHELLCTL_ENTRYPOINT:
|
|
process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT
|
|
|| process.env.E2E_SHELLCTL_URL
|
|
|| shellctlUrl,
|
|
}
|
|
}
|
|
|
|
const getServiceContainerId = async (service: string) => {
|
|
const result = await runCommandOrThrow({
|
|
command: 'docker',
|
|
args: ['compose', '-f', middlewareComposeFile, 'ps', '-q', service],
|
|
cwd: dockerDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
return result.stdout.trim()
|
|
}
|
|
|
|
const getContainerHealth = async (containerId: string) => {
|
|
const result = await runCommand({
|
|
command: 'docker',
|
|
args: ['inspect', '-f', '{{.State.Health.Status}}', containerId],
|
|
cwd: dockerDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
if (result.exitCode !== 0)
|
|
return ''
|
|
|
|
return result.stdout.trim()
|
|
}
|
|
|
|
const printComposeLogs = async (services: string[]) => {
|
|
await runCommand({
|
|
command: 'docker',
|
|
args: ['compose', '-f', middlewareComposeFile, 'logs', ...services],
|
|
cwd: dockerDir,
|
|
})
|
|
}
|
|
|
|
const waitForDependency = async ({
|
|
description,
|
|
services,
|
|
wait,
|
|
}: {
|
|
description: string
|
|
services: string[]
|
|
wait: () => Promise<void>
|
|
}) => {
|
|
console.log(`Waiting for ${description}...`)
|
|
|
|
try {
|
|
await wait()
|
|
}
|
|
catch (error) {
|
|
await printComposeLogs(services)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const webBuildSourcePaths = [
|
|
'package.json',
|
|
'pnpm-lock.yaml',
|
|
'pnpm-workspace.yaml',
|
|
'packages',
|
|
'web',
|
|
]
|
|
|
|
const getWebBuildSourceHash = async () => {
|
|
const hash = createHash('sha256')
|
|
const gitArgsSuffix = ['--', ...webBuildSourcePaths]
|
|
const commands = [
|
|
['rev-parse', 'HEAD'],
|
|
['diff', '--binary', ...gitArgsSuffix],
|
|
['diff', '--cached', '--binary', ...gitArgsSuffix],
|
|
]
|
|
|
|
for (const args of commands) {
|
|
const result = await runCommandOrThrow({
|
|
command: 'git',
|
|
args,
|
|
cwd: rootDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
hash.update(args.join(' '))
|
|
hash.update('\n')
|
|
hash.update(result.stdout)
|
|
hash.update('\n')
|
|
}
|
|
|
|
const untrackedFiles = await runCommandOrThrow({
|
|
command: 'git',
|
|
args: ['ls-files', '--others', '--exclude-standard', '-z', ...gitArgsSuffix],
|
|
cwd: rootDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
for (const file of untrackedFiles.stdout.split('\0').filter(Boolean)) {
|
|
hash.update(file)
|
|
hash.update('\0')
|
|
hash.update(await readFile(path.join(rootDir, file)))
|
|
hash.update('\0')
|
|
}
|
|
|
|
return hash.digest('hex')
|
|
}
|
|
|
|
export const ensureWebBuild = async () => {
|
|
const envHash = await getWebEnvLocalHash()
|
|
const sourceHash = await getWebBuildSourceHash()
|
|
const buildStamp = createHash('sha256')
|
|
.update(envHash)
|
|
.update('\n')
|
|
.update(sourceHash)
|
|
.digest('hex')
|
|
const buildEnv = {
|
|
...e2eWebEnvOverrides,
|
|
}
|
|
|
|
if (process.env.E2E_FORCE_WEB_BUILD === '1') {
|
|
await runCommandOrThrow({
|
|
command: 'pnpm',
|
|
args: ['run', 'build'],
|
|
cwd: webDir,
|
|
env: buildEnv,
|
|
})
|
|
await writeFile(webBuildStampPath, `${buildStamp}\n`, 'utf8')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const [buildExists, previousBuildStamp] = await Promise.all([
|
|
access(buildIdPath)
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
readFile(webBuildStampPath, 'utf8')
|
|
.then(value => value.trim())
|
|
.catch(() => ''),
|
|
])
|
|
|
|
if (buildExists && previousBuildStamp === buildStamp) {
|
|
console.log('Reusing existing web build artifact.')
|
|
return
|
|
}
|
|
}
|
|
catch {
|
|
// Fall through to rebuild when the existing build cannot be verified.
|
|
}
|
|
|
|
await runCommandOrThrow({
|
|
command: 'pnpm',
|
|
args: ['run', 'build'],
|
|
cwd: webDir,
|
|
env: buildEnv,
|
|
})
|
|
await writeFile(webBuildStampPath, `${buildStamp}\n`, 'utf8')
|
|
}
|
|
|
|
export const startWeb = async () => {
|
|
await ensureWebBuild()
|
|
|
|
await runForegroundProcess({
|
|
command: 'pnpm',
|
|
args: ['run', 'start'],
|
|
cwd: webDir,
|
|
env: {
|
|
...e2eWebEnvOverrides,
|
|
HOSTNAME: '127.0.0.1',
|
|
PORT: '3000',
|
|
},
|
|
})
|
|
}
|
|
|
|
export const startApi = async () => {
|
|
if (await isTcpPortReachable(apiHost, apiPort)) {
|
|
const listenerDescription = await getTcpPortListenerDescription(apiPort)
|
|
const listenerMessage = listenerDescription
|
|
? `\n\nPort listener:\n${listenerDescription}`
|
|
: ''
|
|
|
|
throw new Error(
|
|
`Cannot start the E2E API server because ${apiHost}:${apiPort} is already in use.${listenerMessage}`,
|
|
)
|
|
}
|
|
|
|
const env = await getApiEnvironment()
|
|
|
|
await runCommandOrThrow({
|
|
command: 'uv',
|
|
args: ['run', '--project', '.', '--no-sync', 'flask', 'upgrade-db'],
|
|
cwd: apiDir,
|
|
env,
|
|
})
|
|
|
|
await runForegroundProcess({
|
|
command: 'uv',
|
|
args: [
|
|
'run',
|
|
'--project',
|
|
'.',
|
|
'--no-sync',
|
|
'flask',
|
|
'run',
|
|
'--host',
|
|
apiHost,
|
|
'--port',
|
|
String(apiPort),
|
|
],
|
|
cwd: apiDir,
|
|
env,
|
|
})
|
|
}
|
|
|
|
export const startAgentBackend = async () => {
|
|
if (await isTcpPortReachable(agentBackendHost, agentBackendPort)) {
|
|
const listenerDescription = await getTcpPortListenerDescription(agentBackendPort)
|
|
const listenerMessage = listenerDescription
|
|
? `\n\nPort listener:\n${listenerDescription}`
|
|
: ''
|
|
|
|
throw new Error(
|
|
`Cannot start the E2E Agent backend because ${agentBackendHost}:${agentBackendPort} is already in use.${listenerMessage}`,
|
|
)
|
|
}
|
|
|
|
await runForegroundProcess({
|
|
command: 'uv',
|
|
args: [
|
|
'run',
|
|
'--project',
|
|
'.',
|
|
'--extra',
|
|
'server',
|
|
'uvicorn',
|
|
'dify_agent.server.app:app',
|
|
'--host',
|
|
agentBackendHost,
|
|
'--port',
|
|
String(agentBackendPort),
|
|
],
|
|
cwd: difyAgentDir,
|
|
env: await getAgentBackendEnvironment(),
|
|
})
|
|
}
|
|
|
|
const ensureShellctlSandboxImage = async () => {
|
|
const inspectResult = await runCommand({
|
|
command: 'docker',
|
|
args: ['image', 'inspect', shellctlImage],
|
|
cwd: rootDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
if (inspectResult.exitCode === 0 && process.env.E2E_FORCE_SHELLCTL_BUILD !== '1')
|
|
return
|
|
|
|
await runCommandOrThrow({
|
|
command: 'docker',
|
|
args: [
|
|
'build',
|
|
'-f',
|
|
path.join(difyAgentDir, 'docker', 'local-sandbox', 'Dockerfile'),
|
|
'-t',
|
|
shellctlImage,
|
|
'.',
|
|
],
|
|
cwd: difyAgentDir,
|
|
})
|
|
}
|
|
|
|
export const startShellctlSandbox = async () => {
|
|
if (await isTcpPortReachable(shellctlHost, shellctlPort)) {
|
|
const listenerDescription = await getTcpPortListenerDescription(shellctlPort)
|
|
const listenerMessage = listenerDescription
|
|
? `\n\nPort listener:\n${listenerDescription}`
|
|
: ''
|
|
|
|
throw new Error(
|
|
`Cannot start the E2E shellctl sandbox because ${shellctlHost}:${shellctlPort} is already in use.${listenerMessage}`,
|
|
)
|
|
}
|
|
|
|
await runCommand({
|
|
command: 'docker',
|
|
args: ['rm', '-f', shellctlContainerName],
|
|
cwd: rootDir,
|
|
stdio: 'pipe',
|
|
})
|
|
|
|
await ensureShellctlSandboxImage()
|
|
|
|
await runForegroundProcess({
|
|
command: 'docker',
|
|
args: [
|
|
'run',
|
|
'--rm',
|
|
'--name',
|
|
shellctlContainerName,
|
|
'-p',
|
|
`${shellctlHost}:${shellctlPort}:5004`,
|
|
...(process.env.E2E_SHELLCTL_AUTH_TOKEN
|
|
? ['-e', `SHELLCTL_AUTH_TOKEN=${process.env.E2E_SHELLCTL_AUTH_TOKEN}`]
|
|
: []),
|
|
shellctlImage,
|
|
],
|
|
cwd: rootDir,
|
|
})
|
|
}
|
|
|
|
export const startCelery = async ({ queues = 'workflow_based_app_execution' } = {}) => {
|
|
const env = await getApiEnvironment()
|
|
|
|
await runForegroundProcess({
|
|
command: 'uv',
|
|
args: [
|
|
'run',
|
|
'--project',
|
|
'.',
|
|
'--no-sync',
|
|
'celery',
|
|
'-A',
|
|
'app.celery',
|
|
'worker',
|
|
'--pool',
|
|
'solo',
|
|
'--loglevel',
|
|
'INFO',
|
|
'-Q',
|
|
queues,
|
|
],
|
|
cwd: apiDir,
|
|
env,
|
|
})
|
|
}
|
|
|
|
export const stopMiddleware = async () => {
|
|
await runCommandOrThrow({
|
|
command: 'docker',
|
|
args: [...composeArgs, 'down', '--remove-orphans'],
|
|
cwd: dockerDir,
|
|
})
|
|
}
|
|
|
|
export const resetState = async () => {
|
|
console.log('Stopping middleware services...')
|
|
try {
|
|
await stopMiddleware()
|
|
}
|
|
catch {
|
|
// Reset should continue even if middleware is already stopped.
|
|
}
|
|
|
|
console.log('Removing persisted middleware data...')
|
|
await Promise.all(
|
|
middlewareDataPaths.map(async (targetPath) => {
|
|
await rm(targetPath, { force: true, recursive: true })
|
|
await mkdir(targetPath, { recursive: true })
|
|
}),
|
|
)
|
|
|
|
console.log('Removing E2E local state...')
|
|
await Promise.all(
|
|
e2eStatePaths.map(targetPath => rm(targetPath, { force: true, recursive: true })),
|
|
)
|
|
|
|
console.log('E2E state reset complete.')
|
|
}
|
|
|
|
export const startMiddleware = async () => {
|
|
await ensureFileExists(middlewareEnvFile, middlewareEnvExampleFile)
|
|
await ensureLineInFile(middlewareEnvFile, 'COMPOSE_PROFILES=postgresql,weaviate')
|
|
|
|
console.log('Starting middleware services...')
|
|
await runCommandOrThrow({
|
|
command: 'docker',
|
|
args: [
|
|
...composeArgs,
|
|
'up',
|
|
'-d',
|
|
'db_postgres',
|
|
'redis',
|
|
'weaviate',
|
|
'sandbox',
|
|
'ssrf_proxy',
|
|
'plugin_daemon',
|
|
],
|
|
cwd: dockerDir,
|
|
})
|
|
|
|
const [postgresContainerId, redisContainerId] = await Promise.all([
|
|
getServiceContainerId('db_postgres'),
|
|
getServiceContainerId('redis'),
|
|
])
|
|
|
|
await waitForDependency({
|
|
description: 'PostgreSQL and Redis health checks',
|
|
services: ['db_postgres', 'redis'],
|
|
wait: () =>
|
|
waitForCondition({
|
|
check: async () => {
|
|
const [postgresStatus, redisStatus] = await Promise.all([
|
|
getContainerHealth(postgresContainerId),
|
|
getContainerHealth(redisContainerId),
|
|
])
|
|
|
|
return postgresStatus === 'healthy' && redisStatus === 'healthy'
|
|
},
|
|
description: 'PostgreSQL and Redis health checks',
|
|
intervalMs: 2_000,
|
|
timeoutMs: 240_000,
|
|
}),
|
|
})
|
|
|
|
await waitForDependency({
|
|
description: 'Weaviate readiness',
|
|
services: ['weaviate'],
|
|
wait: () => waitForUrl('http://127.0.0.1:8080/v1/.well-known/ready', 120_000, 2_000),
|
|
})
|
|
|
|
await waitForDependency({
|
|
description: 'sandbox health',
|
|
services: ['sandbox', 'ssrf_proxy'],
|
|
wait: () => waitForUrl('http://127.0.0.1:8194/health', 120_000, 2_000),
|
|
})
|
|
|
|
await waitForDependency({
|
|
description: 'plugin daemon port',
|
|
services: ['plugin_daemon'],
|
|
wait: () =>
|
|
waitForCondition({
|
|
check: async () => isTcpPortReachable('127.0.0.1', 5002),
|
|
description: 'plugin daemon port',
|
|
intervalMs: 2_000,
|
|
timeoutMs: 120_000,
|
|
}),
|
|
})
|
|
|
|
console.log('Full middleware stack is ready.')
|
|
}
|
|
|
|
const printUsage = () => {
|
|
console.log('Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|shellctl-sandbox|agent-backend|api|celery [--queues queues]|web>')
|
|
}
|
|
|
|
const main = async () => {
|
|
const command = process.argv[2]
|
|
const queuesIndex = process.argv.indexOf('--queues')
|
|
const queues = queuesIndex === -1 ? undefined : process.argv[queuesIndex + 1]
|
|
|
|
switch (command) {
|
|
case 'agent-backend':
|
|
await startAgentBackend()
|
|
return
|
|
case 'api':
|
|
await startApi()
|
|
return
|
|
case 'celery':
|
|
await startCelery({ queues })
|
|
return
|
|
case 'middleware-down':
|
|
await stopMiddleware()
|
|
return
|
|
case 'middleware-up':
|
|
await startMiddleware()
|
|
return
|
|
case 'reset':
|
|
await resetState()
|
|
return
|
|
case 'shellctl-sandbox':
|
|
await startShellctlSandbox()
|
|
return
|
|
case 'web':
|
|
await startWeb()
|
|
return
|
|
default:
|
|
printUsage()
|
|
process.exitCode = 1
|
|
}
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
void main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
process.exit(1)
|
|
})
|
|
}
|