mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
refactor(openapi): resource-oriented paths for /openapi/v1 + difyctl version gate (#38367)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
abd720146d
commit
915655683c
@@ -32,14 +32,38 @@ describe('evaluateCompat', () => {
|
||||
expect(evaluateCompat('1.7.0', range).status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns unsupported when server is below minimum', () => {
|
||||
it('returns too_old when server is below minimum', () => {
|
||||
const v = evaluateCompat('1.5.9', range)
|
||||
expect(v.status).toBe('unsupported')
|
||||
expect(v.status).toBe('too_old')
|
||||
expect(v.detail).toContain('1.5.9')
|
||||
})
|
||||
|
||||
it('returns unsupported when server is above maximum', () => {
|
||||
expect(evaluateCompat('2.0.0', range).status).toBe('unsupported')
|
||||
it('returns too_new when server is above maximum', () => {
|
||||
expect(evaluateCompat('2.0.0', range).status).toBe('too_new')
|
||||
})
|
||||
|
||||
describe('ignores pre-release/channel suffixes (numeric-core comparison)', () => {
|
||||
it('treats a pre-release of the upper bound as compatible', () => {
|
||||
// 1.7.0-rc.1 has core 1.7.0 == maxDify; a suffix-sensitive range would push
|
||||
// it out of [1.6.0, 1.7.0], but its numeric core is in range.
|
||||
expect(evaluateCompat('1.7.0-rc.1', range).status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('treats a pre-release of the lower bound as compatible', () => {
|
||||
expect(evaluateCompat('1.6.0-alpha', range).status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('still flags a pre-release whose core is below the minimum as too_old', () => {
|
||||
expect(evaluateCompat('1.5.9-rc.1', range).status).toBe('too_old')
|
||||
})
|
||||
|
||||
it('still flags a pre-release whose core is above the maximum as too_new', () => {
|
||||
expect(evaluateCompat('2.0.0-alpha', range).status).toBe('too_new')
|
||||
})
|
||||
|
||||
it('strips suffixes on the range bounds too', () => {
|
||||
expect(evaluateCompat('1.6.5', { minDify: '1.6.0-alpha', maxDify: '1.7.0-rc' }).status).toBe('compatible')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown when server version is empty', () => {
|
||||
|
||||
+21
-19
@@ -1,4 +1,5 @@
|
||||
import { parseRange, satisfies, tryParse } from 'std-semver'
|
||||
import type { SemVer } from 'std-semver'
|
||||
import { compare, tryParse } from 'std-semver'
|
||||
|
||||
export type DifyCompat = {
|
||||
readonly minDify: string
|
||||
@@ -14,7 +15,7 @@ export function compatString(): string {
|
||||
return `dify >=${difyCompat.minDify}, <=${difyCompat.maxDify}`
|
||||
}
|
||||
|
||||
export type CompatStatus = 'compatible' | 'unsupported' | 'unknown'
|
||||
export type CompatStatus = 'compatible' | 'too_old' | 'too_new' | 'unknown'
|
||||
|
||||
export type CompatVerdict = {
|
||||
readonly status: CompatStatus
|
||||
@@ -27,6 +28,12 @@ function clamp(s: string): string {
|
||||
return s.length > DETAIL_MAX_LEN ? `${s.slice(0, DETAIL_MAX_LEN)}…` : s
|
||||
}
|
||||
|
||||
// Numeric core (major.minor.patch) with pre-release/build stripped, so ordering
|
||||
// ignores channel suffixes: a 0.2.0-rc.1 build compares equal to the 0.2.0 floor.
|
||||
function core(v: SemVer): SemVer {
|
||||
return { major: v.major, minor: v.minor, patch: v.patch, prerelease: [], build: [] }
|
||||
}
|
||||
|
||||
export function evaluateCompat(
|
||||
serverVersion: string | undefined,
|
||||
range: DifyCompat = difyCompat,
|
||||
@@ -34,25 +41,20 @@ export function evaluateCompat(
|
||||
if (serverVersion === undefined || serverVersion === '')
|
||||
return { status: 'unknown', detail: 'server version unknown' }
|
||||
|
||||
const parsedServer = tryParse(serverVersion)
|
||||
if (parsedServer === undefined)
|
||||
const server = tryParse(serverVersion)
|
||||
if (server === undefined)
|
||||
return { status: 'unknown', detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver` }
|
||||
|
||||
// The compat range is inclusive at both ends, exactly the format compatString prints.
|
||||
const expr = `>=${range.minDify} <=${range.maxDify}`
|
||||
const parsedRange = (() => {
|
||||
try {
|
||||
return parseRange(expr)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (parsedRange === undefined)
|
||||
return { status: 'unknown', detail: `compat range ${JSON.stringify(expr)} is not valid semver` }
|
||||
const min = tryParse(range.minDify)
|
||||
const max = tryParse(range.maxDify)
|
||||
if (min === undefined || max === undefined)
|
||||
return { status: 'unknown', detail: `compat range ${JSON.stringify(`>=${range.minDify} <=${range.maxDify}`)} is not valid semver` }
|
||||
|
||||
if (satisfies(parsedServer, parsedRange))
|
||||
return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` }
|
||||
if (compare(core(server), core(min)) < 0)
|
||||
return { status: 'too_old', detail: `server ${serverVersion} is older than the minimum ${range.minDify}` }
|
||||
|
||||
return { status: 'unsupported', detail: `server ${serverVersion} outside [${range.minDify}, ${range.maxDify}]` }
|
||||
if (compare(core(server), core(max)) > 0)
|
||||
return { status: 'too_new', detail: `server ${serverVersion} is newer than the tested maximum ${range.maxDify}` }
|
||||
|
||||
return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { enforceDifyVersion } from './enforce'
|
||||
|
||||
// Injected build range in tests is __DIFYCTL_MIN_DIFY__=1.6.0 / MAX=1.7.0 (test/setup.ts):
|
||||
// 1.5.0 → too_old, 1.6.4 → compatible, 99.0.0 → too_new, '' → unknown.
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } {
|
||||
const marked: string[] = []
|
||||
return {
|
||||
marked,
|
||||
isFreshCompatible: () => fresh,
|
||||
markCompatible: async (host) => {
|
||||
marked.push(host)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const server = (version: string): ServerVersionResponse => ({ version, edition: 'SELF_HOSTED' })
|
||||
|
||||
describe('enforceDifyVersion', () => {
|
||||
it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).rejects.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes and caches when the server is compatible', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('1.6.4'))
|
||||
|
||||
const res = await enforceDifyVersion(HOST, { store, probe })
|
||||
|
||||
expect(res?.version).toBe('1.6.4')
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('passes (soft, no throw) and caches when the server is too new', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server('99.0.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toEqual([HOST])
|
||||
})
|
||||
|
||||
it('skips the probe entirely when the host is fresh-compatible', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0')) // would throw if it ran
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-probes despite a fresh cache when forceFresh is set', async () => {
|
||||
const store = fakeStore(true)
|
||||
const probe = vi.fn(async () => server('1.5.0'))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe, forceFresh: true }))
|
||||
.rejects
|
||||
.toMatchObject({ code: ErrorCode.VersionSkew })
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails open (never blocks, never caches) when the probe errors', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => {
|
||||
throw new Error('net down')
|
||||
})
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeUndefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not block or cache on an unknown server version', async () => {
|
||||
const store = fakeStore()
|
||||
const probe = vi.fn(async () => server(''))
|
||||
|
||||
await expect(enforceDifyVersion(HOST, { store, probe })).resolves.toBeDefined()
|
||||
expect(store.marked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatStore } from '@/cache/compat-store'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { loadCompatStore } from '@/cache/compat-store'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { openAPIBase } from '@/util/host'
|
||||
import { difyCompat, evaluateCompat } from './compat'
|
||||
import { versionInfo } from './info'
|
||||
|
||||
export type ServerVersionProbe = (host: string) => Promise<ServerVersionResponse>
|
||||
|
||||
const UPGRADE_HINT
|
||||
= `upgrade the Dify server to >= ${difyCompat.minDify} `
|
||||
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
|
||||
|
||||
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
|
||||
const defaultProbe: ServerVersionProbe = async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
return new MetaClient(http).serverVersion()
|
||||
}
|
||||
|
||||
export type EnforceOptions = {
|
||||
readonly probe?: ServerVersionProbe
|
||||
readonly store?: CompatStore
|
||||
readonly forceFresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard version gate for the client → server direction: refuse a Dify server older
|
||||
* than this difyctl requires (its removed paths would only 404 otherwise).
|
||||
*
|
||||
* Cached: a host recently confirmed compatible is not re-probed for COMPAT_TTL_MS.
|
||||
* Only "compatible" is cached, so a just-upgraded server clears a previous block at
|
||||
* once. Fails open on any probe error — a flaky network never blocks a command.
|
||||
* Returns the probed server version when it actually probed (skipped/failed → undefined),
|
||||
* so the caller can reuse it.
|
||||
*/
|
||||
export async function enforceDifyVersion(
|
||||
host: string,
|
||||
opts: EnforceOptions = {},
|
||||
): Promise<ServerVersionResponse | undefined> {
|
||||
const store = opts.store ?? await loadCompatStore()
|
||||
if (opts.forceFresh !== true && store.isFreshCompatible(host))
|
||||
return undefined
|
||||
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
let server: ServerVersionResponse
|
||||
try {
|
||||
server = await probe(host)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
if (verdict.status === 'too_old') {
|
||||
throw newError(
|
||||
ErrorCode.VersionSkew,
|
||||
`Dify server ${server.version} is too old for difyctl ${versionInfo.version}: ${verdict.detail}`,
|
||||
).withHint(UPGRADE_HINT)
|
||||
}
|
||||
|
||||
if (verdict.status === 'compatible' || verdict.status === 'too_new')
|
||||
await store.markCompatible(host)
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise<v
|
||||
}
|
||||
|
||||
const verdict = evaluateCompat(server.version)
|
||||
if (verdict.status !== 'unsupported')
|
||||
// Only "too new" is a soft nudge here; "too old" is hard-failed up front by
|
||||
// enforceDifyVersion, so the command never reaches this path for it.
|
||||
if (verdict.status !== 'too_new')
|
||||
return
|
||||
|
||||
deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true))
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
it('returns too_new when server version is above range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
@@ -113,7 +113,7 @@ describe('runVersionProbe', () => {
|
||||
})
|
||||
|
||||
expect(report.server.reachable).toBe(true)
|
||||
expect(report.compat.status).toBe('unsupported')
|
||||
expect(report.compat.status).toBe('too_new')
|
||||
})
|
||||
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('renderVersionText', () => {
|
||||
compat: {
|
||||
minDify: '1.6.0',
|
||||
maxDify: '1.7.0',
|
||||
status: 'unsupported',
|
||||
status: 'too_new',
|
||||
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
|
||||
},
|
||||
}
|
||||
@@ -175,7 +175,7 @@ describe('renderVersionText', () => {
|
||||
compat: {
|
||||
minDify: '1.6.0',
|
||||
maxDify: '1.7.0',
|
||||
status: 'unsupported',
|
||||
status: 'too_new',
|
||||
detail: 'server 99.0.0 outside [1.6.0, 1.7.0]',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export type RenderOptions = {
|
||||
|
||||
const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = {
|
||||
compatible: 'ok',
|
||||
unsupported: 'incompatible',
|
||||
too_old: 'incompatible (server too old)',
|
||||
too_new: 'incompatible (server too new)',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
@@ -50,7 +51,8 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = {
|
||||
lines.push('')
|
||||
|
||||
const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}`
|
||||
lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText)
|
||||
const incompatible = compat.status === 'too_old' || compat.status === 'too_new'
|
||||
lines.push(incompatible ? c.yellow(verdictText) : verdictText)
|
||||
|
||||
if (client.channel !== 'stable') {
|
||||
lines.push('')
|
||||
|
||||
Reference in New Issue
Block a user