@open-mercato/cli 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5523.1.e223ca1915
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/single-instance-strategy-guard.js +89 -0
- package/dist/lib/single-instance-strategy-guard.js.map +7 -0
- package/dist/mercato.js +2 -0
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/lib/__tests__/single-instance-strategy-guard.test.ts +138 -0
- package/src/lib/single-instance-strategy-guard.ts +172 -0
- package/src/mercato.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cli",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5523.1.e223ca1915",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"@mikro-orm/decorators": "^7.1.4",
|
|
60
60
|
"@mikro-orm/migrations": "^7.1.4",
|
|
61
61
|
"@mikro-orm/postgresql": "^7.1.4",
|
|
62
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
63
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
62
|
+
"@open-mercato/queue": "0.6.6-develop.5523.1.e223ca1915",
|
|
63
|
+
"@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
|
|
64
64
|
"cross-spawn": "^7.0.6",
|
|
65
65
|
"pg": "8.21.0",
|
|
66
66
|
"semver": "^7.8.3",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"typescript": "^6.0.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
73
|
+
"@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.5523.1.e223ca1915",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
79
|
"ts-jest": "^29.4.11"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertSingleInstanceStrategies,
|
|
3
|
+
evaluateSingleInstanceGuard,
|
|
4
|
+
SingleInstanceStrategyError,
|
|
5
|
+
type InfraStrategySnapshot,
|
|
6
|
+
} from '../single-instance-strategy-guard'
|
|
7
|
+
|
|
8
|
+
const singleInstanceSnapshot: InfraStrategySnapshot = {
|
|
9
|
+
cacheStrategy: 'memory',
|
|
10
|
+
queueStrategy: 'local',
|
|
11
|
+
rateLimitStrategy: 'memory',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const multiInstanceSafeSnapshot: InfraStrategySnapshot = {
|
|
15
|
+
cacheStrategy: 'redis',
|
|
16
|
+
queueStrategy: 'async',
|
|
17
|
+
rateLimitStrategy: 'redis',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('evaluateSingleInstanceGuard', () => {
|
|
21
|
+
it('is a no-op outside production even with single-instance strategies', () => {
|
|
22
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
23
|
+
NODE_ENV: 'development',
|
|
24
|
+
OM_MULTI_INSTANCE: '1',
|
|
25
|
+
})
|
|
26
|
+
expect(result.action).toBe('ok')
|
|
27
|
+
expect(result.offenders).toHaveLength(3)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is a no-op in production when all strategies are multi-instance safe', () => {
|
|
31
|
+
const result = evaluateSingleInstanceGuard(multiInstanceSafeSnapshot, {
|
|
32
|
+
NODE_ENV: 'production',
|
|
33
|
+
OM_MULTI_INSTANCE: '1',
|
|
34
|
+
})
|
|
35
|
+
expect(result.action).toBe('ok')
|
|
36
|
+
expect(result.offenders).toHaveLength(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('fails in production when a multi-instance topology is declared via OM_MULTI_INSTANCE', () => {
|
|
40
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
41
|
+
NODE_ENV: 'production',
|
|
42
|
+
OM_MULTI_INSTANCE: '1',
|
|
43
|
+
})
|
|
44
|
+
expect(result.action).toBe('fail')
|
|
45
|
+
expect(result.offenders.map((offender) => offender.envVar)).toEqual([
|
|
46
|
+
'CACHE_STRATEGY',
|
|
47
|
+
'QUEUE_STRATEGY',
|
|
48
|
+
'RATE_LIMIT_STRATEGY',
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('treats OM_INSTANCE_COUNT > 1 as a multi-instance topology', () => {
|
|
53
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
54
|
+
NODE_ENV: 'production',
|
|
55
|
+
OM_INSTANCE_COUNT: '3',
|
|
56
|
+
})
|
|
57
|
+
expect(result.action).toBe('fail')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not treat OM_INSTANCE_COUNT=1 as multi-instance', () => {
|
|
61
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
62
|
+
NODE_ENV: 'production',
|
|
63
|
+
OM_INSTANCE_COUNT: '1',
|
|
64
|
+
})
|
|
65
|
+
expect(result.action).toBe('warn')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('only warns in production when no multi-instance topology is declared', () => {
|
|
69
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
70
|
+
NODE_ENV: 'production',
|
|
71
|
+
})
|
|
72
|
+
expect(result.action).toBe('warn')
|
|
73
|
+
expect(result.multiInstance).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('downgrades a hard failure to a warning when the override is set', () => {
|
|
77
|
+
const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
|
|
78
|
+
NODE_ENV: 'production',
|
|
79
|
+
OM_MULTI_INSTANCE: '1',
|
|
80
|
+
OM_ALLOW_SINGLE_INSTANCE_STRATEGIES: '1',
|
|
81
|
+
})
|
|
82
|
+
expect(result.action).toBe('warn')
|
|
83
|
+
expect(result.overridden).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('flags only the offending strategy when others are safe', () => {
|
|
87
|
+
const result = evaluateSingleInstanceGuard(
|
|
88
|
+
{ cacheStrategy: 'redis', queueStrategy: 'local', rateLimitStrategy: 'redis' },
|
|
89
|
+
{ NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
|
|
90
|
+
)
|
|
91
|
+
expect(result.action).toBe('fail')
|
|
92
|
+
expect(result.offenders).toHaveLength(1)
|
|
93
|
+
expect(result.offenders[0].component).toBe('queue')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('assertSingleInstanceStrategies', () => {
|
|
98
|
+
function createLogger() {
|
|
99
|
+
return {
|
|
100
|
+
warn: jest.fn(),
|
|
101
|
+
error: jest.fn(),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('throws and logs when the guard fails', () => {
|
|
106
|
+
const logger = createLogger()
|
|
107
|
+
expect(() =>
|
|
108
|
+
assertSingleInstanceStrategies(
|
|
109
|
+
{ NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
|
|
110
|
+
{ snapshot: singleInstanceSnapshot, logger },
|
|
111
|
+
),
|
|
112
|
+
).toThrow(SingleInstanceStrategyError)
|
|
113
|
+
expect(logger.error).toHaveBeenCalled()
|
|
114
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('logs a warning but does not throw when only warning', () => {
|
|
118
|
+
const logger = createLogger()
|
|
119
|
+
const result = assertSingleInstanceStrategies(
|
|
120
|
+
{ NODE_ENV: 'production' },
|
|
121
|
+
{ snapshot: singleInstanceSnapshot, logger },
|
|
122
|
+
)
|
|
123
|
+
expect(result.action).toBe('warn')
|
|
124
|
+
expect(logger.warn).toHaveBeenCalled()
|
|
125
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('stays silent and does not throw when everything is safe', () => {
|
|
129
|
+
const logger = createLogger()
|
|
130
|
+
const result = assertSingleInstanceStrategies(
|
|
131
|
+
{ NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
|
|
132
|
+
{ snapshot: multiInstanceSafeSnapshot, logger },
|
|
133
|
+
)
|
|
134
|
+
expect(result.action).toBe('ok')
|
|
135
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
136
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
import { resolveQueueStrategy } from '@open-mercato/queue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Boot-time guard that refuses to start (or warns) when an infrastructure
|
|
6
|
+
* strategy that is only safe for a single process/instance is configured under
|
|
7
|
+
* a declared multi-instance topology.
|
|
8
|
+
*
|
|
9
|
+
* Background: `CACHE_STRATEGY`, `RATE_LIMIT_STRATEGY` and `QUEUE_STRATEGY` all
|
|
10
|
+
* default to single-instance modes (`memory`/`memory`/`local`). Running more
|
|
11
|
+
* than one app instance against those defaults silently breaks three
|
|
12
|
+
* correctness invariants at once — stale RBAC caches after a privilege
|
|
13
|
+
* revocation, rate limits multiplied by the instance count, and duplicate job
|
|
14
|
+
* processing from the leaderless local file queue. None of them warn.
|
|
15
|
+
*
|
|
16
|
+
* Only strategies that coordinate across processes via shared external state
|
|
17
|
+
* are considered multi-instance safe. Everything else (the in-process memory
|
|
18
|
+
* strategies, the local file queue, and disk-backed caches that are not shared
|
|
19
|
+
* across hosts) is treated as single-instance.
|
|
20
|
+
*/
|
|
21
|
+
const MULTI_INSTANCE_SAFE_STRATEGIES = {
|
|
22
|
+
cache: ['redis'],
|
|
23
|
+
queue: ['async'],
|
|
24
|
+
rateLimit: ['redis'],
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export type SingleInstanceGuardComponent = 'cache' | 'queue' | 'rateLimit'
|
|
28
|
+
|
|
29
|
+
export type SingleInstanceGuardOffender = {
|
|
30
|
+
component: SingleInstanceGuardComponent
|
|
31
|
+
envVar: string
|
|
32
|
+
configured: string
|
|
33
|
+
safeValues: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SingleInstanceGuardAction = 'ok' | 'warn' | 'fail'
|
|
37
|
+
|
|
38
|
+
export type SingleInstanceGuardResult = {
|
|
39
|
+
action: SingleInstanceGuardAction
|
|
40
|
+
offenders: SingleInstanceGuardOffender[]
|
|
41
|
+
production: boolean
|
|
42
|
+
multiInstance: boolean
|
|
43
|
+
overridden: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type InfraStrategySnapshot = {
|
|
47
|
+
cacheStrategy: string
|
|
48
|
+
queueStrategy: string
|
|
49
|
+
rateLimitStrategy: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const COMPONENT_ENV_VARS: Record<SingleInstanceGuardComponent, string> = {
|
|
53
|
+
cache: 'CACHE_STRATEGY',
|
|
54
|
+
queue: 'QUEUE_STRATEGY',
|
|
55
|
+
rateLimit: 'RATE_LIMIT_STRATEGY',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SingleInstanceStrategyError extends Error {
|
|
59
|
+
readonly offenders: SingleInstanceGuardOffender[]
|
|
60
|
+
|
|
61
|
+
constructor(result: SingleInstanceGuardResult) {
|
|
62
|
+
super(
|
|
63
|
+
`Refusing to start: single-instance infrastructure strategies (${result.offenders
|
|
64
|
+
.map((offender) => `${offender.envVar}=${offender.configured}`)
|
|
65
|
+
.join(', ')}) are configured under a declared multi-instance topology. ` +
|
|
66
|
+
'Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.',
|
|
67
|
+
)
|
|
68
|
+
this.name = 'SingleInstanceStrategyError'
|
|
69
|
+
this.offenders = result.offenders
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the configured infrastructure strategies. Queue resolution reuses the
|
|
75
|
+
* canonical `resolveQueueStrategy()` so the default stays single-sourced; cache
|
|
76
|
+
* and rate-limit values are read directly with their documented defaults
|
|
77
|
+
* (`packages/cache/src/service.ts`, `packages/shared/src/lib/ratelimit/config.ts`)
|
|
78
|
+
* because the guard's safe-set policy — not the resolver default — decides what
|
|
79
|
+
* counts as single-instance.
|
|
80
|
+
*/
|
|
81
|
+
export function readInfraStrategySnapshot(env: NodeJS.ProcessEnv = process.env): InfraStrategySnapshot {
|
|
82
|
+
return {
|
|
83
|
+
cacheStrategy: env.CACHE_STRATEGY?.trim() || 'memory',
|
|
84
|
+
queueStrategy: resolveQueueStrategy(),
|
|
85
|
+
rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || 'memory',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveMultiInstanceHint(env: NodeJS.ProcessEnv): boolean {
|
|
90
|
+
if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true
|
|
91
|
+
const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? '', 10)
|
|
92
|
+
return Number.isFinite(instanceCount) && instanceCount > 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function evaluateSingleInstanceGuard(
|
|
96
|
+
snapshot: InfraStrategySnapshot,
|
|
97
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
98
|
+
): SingleInstanceGuardResult {
|
|
99
|
+
const configured: Record<SingleInstanceGuardComponent, string> = {
|
|
100
|
+
cache: snapshot.cacheStrategy,
|
|
101
|
+
queue: snapshot.queueStrategy,
|
|
102
|
+
rateLimit: snapshot.rateLimitStrategy,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const offenders: SingleInstanceGuardOffender[] = []
|
|
106
|
+
for (const component of Object.keys(configured) as SingleInstanceGuardComponent[]) {
|
|
107
|
+
const safeValues: readonly string[] = MULTI_INSTANCE_SAFE_STRATEGIES[component]
|
|
108
|
+
if (!safeValues.includes(configured[component])) {
|
|
109
|
+
offenders.push({
|
|
110
|
+
component,
|
|
111
|
+
envVar: COMPONENT_ENV_VARS[component],
|
|
112
|
+
configured: configured[component],
|
|
113
|
+
safeValues,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const production = (env.NODE_ENV ?? '').trim() === 'production'
|
|
119
|
+
const multiInstance = resolveMultiInstanceHint(env)
|
|
120
|
+
const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false)
|
|
121
|
+
|
|
122
|
+
let action: SingleInstanceGuardAction = 'ok'
|
|
123
|
+
if (offenders.length > 0 && production) {
|
|
124
|
+
action = multiInstance && !overridden ? 'fail' : 'warn'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { action, offenders, production, multiInstance, overridden }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatSingleInstanceGuardMessage(result: SingleInstanceGuardResult): string[] {
|
|
131
|
+
const offenderLines = result.offenders.map(
|
|
132
|
+
(offender) =>
|
|
133
|
+
` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(', ')})`,
|
|
134
|
+
)
|
|
135
|
+
const header =
|
|
136
|
+
result.action === 'fail'
|
|
137
|
+
? '[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology.'
|
|
138
|
+
: '[server] WARNING: single-instance infrastructure strategies detected in production.'
|
|
139
|
+
const guidance =
|
|
140
|
+
result.action === 'fail'
|
|
141
|
+
? '[server] Switch each strategy above to a multi-instance-safe value, or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override (accepting duplicate jobs, stale ACLs, and weakened rate limits).'
|
|
142
|
+
: result.multiInstance
|
|
143
|
+
? '[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set — proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits).'
|
|
144
|
+
: '[server] Running multiple instances against these strategies will cause duplicate jobs, stale ACLs, and weakened rate limits. Set OM_MULTI_INSTANCE=1 to make this a hard failure once you scale out.'
|
|
145
|
+
return [header, ...offenderLines, guidance]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type SingleInstanceGuardLogger = Pick<Console, 'warn' | 'error'>
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Evaluate the guard and enforce it: throw on `fail`, log prominently on
|
|
152
|
+
* `warn`, and stay silent on `ok`. Safe to call on every `start`; it never
|
|
153
|
+
* fires for dev boots or single-instance production deployments.
|
|
154
|
+
*/
|
|
155
|
+
export function assertSingleInstanceStrategies(
|
|
156
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
157
|
+
options?: { snapshot?: InfraStrategySnapshot; logger?: SingleInstanceGuardLogger },
|
|
158
|
+
): SingleInstanceGuardResult {
|
|
159
|
+
const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env)
|
|
160
|
+
const result = evaluateSingleInstanceGuard(snapshot, env)
|
|
161
|
+
const logger = options?.logger ?? console
|
|
162
|
+
|
|
163
|
+
if (result.action === 'fail') {
|
|
164
|
+
for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line)
|
|
165
|
+
throw new SingleInstanceStrategyError(result)
|
|
166
|
+
}
|
|
167
|
+
if (result.action === 'warn') {
|
|
168
|
+
for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result
|
|
172
|
+
}
|
package/src/mercato.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { parseModuleInstallArgs } from './lib/module-install-args'
|
|
33
33
|
import { resolveNextBuildIdCandidate } from './lib/next-build-id'
|
|
34
34
|
import { acquireServerStartLock } from './lib/server-start-lock'
|
|
35
|
+
import { assertSingleInstanceStrategies } from './lib/single-instance-strategy-guard'
|
|
35
36
|
import { createDevEnvReloader, watchDevEnvFiles } from './lib/dev-env-reload'
|
|
36
37
|
// Lazy-imported to avoid pulling in `testcontainers` (devDependency) at startup
|
|
37
38
|
const lazyIntegration = () => import('./lib/testing/integration')
|
|
@@ -2154,6 +2155,9 @@ export async function run(argv = process.argv) {
|
|
|
2154
2155
|
const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env)
|
|
2155
2156
|
const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
|
|
2156
2157
|
const runtimeEnv = buildServerProcessEnvironment(process.env)
|
|
2158
|
+
// Throws on single-instance strategies under a multi-instance topology,
|
|
2159
|
+
// aborting before the start lock is acquired or any process is spawned.
|
|
2160
|
+
assertSingleInstanceStrategies(runtimeEnv)
|
|
2157
2161
|
const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
|
|
2158
2162
|
const serverStartLock = acquireServerStartLock(appDir, {
|
|
2159
2163
|
port: runtimeEnv.PORT ?? process.env.PORT ?? null,
|