@yamf/services-deploy-router 0.1.0
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/package.json +25 -0
- package/placement.js +26 -0
- package/service.js +106 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yamf/services-deploy-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Registry plugin: deploy-plan + deploy-bundle (slice C3)",
|
|
6
|
+
"main": "service.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"service.js",
|
|
9
|
+
"placement.js"
|
|
10
|
+
],
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@yamf/test": "0.1.4"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@yamf/core": "0.8.1"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/mcbrumagin/yamf"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "yamf test -d ."
|
|
24
|
+
}
|
|
25
|
+
}
|
package/placement.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { HttpError } from '@yamf/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* C4: least-loaded pm3 node by `replicaMetadata.node` tallies.
|
|
5
|
+
* @param {{ _state?: { replicaMetadata: Map<unknown, { node?: string }> }, listHealthyLocations?: (name: string) => string[] }} registry
|
|
6
|
+
* @param {string} pm3ServiceName
|
|
7
|
+
* @param {{ excludeNodes?: string[] }} [opts]
|
|
8
|
+
*/
|
|
9
|
+
export function pickNode (registry, pm3ServiceName, { excludeNodes = [] } = {}) {
|
|
10
|
+
const nodes = (registry.listHealthyLocations?.(pm3ServiceName) || [])
|
|
11
|
+
.filter((n) => !excludeNodes.includes(n))
|
|
12
|
+
if (!nodes.length) {
|
|
13
|
+
throw new HttpError(503, 'no-placement')
|
|
14
|
+
}
|
|
15
|
+
const meta = registry._state?.replicaMetadata
|
|
16
|
+
if (meta) {
|
|
17
|
+
const load = new Map(nodes.map((n) => [n, 0]))
|
|
18
|
+
for (const [, row] of meta) {
|
|
19
|
+
if (row?.node && load.has(row.node)) {
|
|
20
|
+
load.set(row.node, (load.get(row.node) || 0) + 1)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return [...load.entries()].sort((a, b) => a[1] - b[1])[0][0]
|
|
24
|
+
}
|
|
25
|
+
return nodes[0]
|
|
26
|
+
}
|
package/service.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deployDecisionFromReplicas,
|
|
3
|
+
HEADERS,
|
|
4
|
+
HttpError,
|
|
5
|
+
publishMessage,
|
|
6
|
+
streamBundleToFileWithHashCheck,
|
|
7
|
+
enforceDeployBundleEd25519Policy
|
|
8
|
+
} from '@yamf/core'
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { pickNode } from './placement.js'
|
|
11
|
+
|
|
12
|
+
const PLUGIN_SERVICE = 'yamf-deploy-router'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} registry - server from `registryServer()`; must expose `registerCommand`, `getReplicasFor`, optional `_bundleStore`
|
|
16
|
+
* @param {{ bundleStore?: object, location: string, pm3ServiceName?: string }} options
|
|
17
|
+
* @param {string} options.location - own URL (used for `registerCommand` cleanup key; often `YAMF_REGISTRY_URL`)
|
|
18
|
+
*/
|
|
19
|
+
export function attachDeployRouter (registry, { bundleStore, location, pm3ServiceName = 'pm3-service' } = {}) {
|
|
20
|
+
if (!location) {
|
|
21
|
+
throw new Error('attachDeployRouter: `location` (registry public URL) is required')
|
|
22
|
+
}
|
|
23
|
+
if (!bundleStore && !registry?._bundleStore) {
|
|
24
|
+
throw new Error('attachDeployRouter: pass bundleStore or start registry with a bundle store')
|
|
25
|
+
}
|
|
26
|
+
const store = bundleStore || registry._bundleStore
|
|
27
|
+
|
|
28
|
+
// Server-side plan (auth: deploy token). The `yamf deploy` CLI still uses REGISTRY_PULL + planAndApply
|
|
29
|
+
// client-side for parity; keep this for HTTP API consumers and future "registry as source of truth" flows.
|
|
30
|
+
registry.registerCommand(
|
|
31
|
+
'deploy-plan',
|
|
32
|
+
async ({ body, headers }) => {
|
|
33
|
+
const out = { decisions: [] }
|
|
34
|
+
for (const s of body?.services || []) {
|
|
35
|
+
if (!s?.name || !s?.hash) {
|
|
36
|
+
throw new HttpError(400, 'Each service needs name and hash')
|
|
37
|
+
}
|
|
38
|
+
const reps = registry.getReplicasFor(s.name) || []
|
|
39
|
+
const { decision } = deployDecisionFromReplicas(reps, s.hash, s.replicas ?? 1)
|
|
40
|
+
out.decisions.push({ service: s.name, hash: s.hash, decision })
|
|
41
|
+
const fromH = (reps || []).map((r) => r.sourceHash).filter(Boolean).join(',') || null
|
|
42
|
+
try {
|
|
43
|
+
await publishMessage('yamf:deploy', {
|
|
44
|
+
service: s.name,
|
|
45
|
+
fromHash: fromH,
|
|
46
|
+
toHash: s.hash,
|
|
47
|
+
decision,
|
|
48
|
+
at: Date.now(),
|
|
49
|
+
deployer: headers[HEADERS.DEPLOYER] || null
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
/* no pub */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
service: PLUGIN_SERVICE,
|
|
59
|
+
location,
|
|
60
|
+
requireDeployToken: true,
|
|
61
|
+
requireRegistryToken: false,
|
|
62
|
+
parseJsonBody: true
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
registry.registerCommand(
|
|
67
|
+
'deploy-bundle',
|
|
68
|
+
async ({ request, headers }) => {
|
|
69
|
+
const hash = (headers[HEADERS.DEPLOY_HASH] || headers['yamf-deploy-hash'] || '').trim()
|
|
70
|
+
if (!hash) {
|
|
71
|
+
throw new HttpError(400, 'yamf-deploy-hash required')
|
|
72
|
+
}
|
|
73
|
+
const out = store.pathFor(hash)
|
|
74
|
+
if (existsSync(out)) {
|
|
75
|
+
return { stored: hash, deduped: true }
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await streamBundleToFileWithHashCheck(request, hash, out)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e?.code === 'BUNDLE_HASH_MISMATCH' || e?.status === 422) {
|
|
81
|
+
const err = new HttpError(422, e.message || 'bundle-hash-mismatch')
|
|
82
|
+
err.code = 'bundle-hash-mismatch'
|
|
83
|
+
throw err
|
|
84
|
+
}
|
|
85
|
+
throw e
|
|
86
|
+
}
|
|
87
|
+
const policy = enforceDeployBundleEd25519Policy({
|
|
88
|
+
hash,
|
|
89
|
+
headers: request?.headers || {}
|
|
90
|
+
})
|
|
91
|
+
if (policy && 'status' in policy) {
|
|
92
|
+
throw new HttpError(policy.status, policy.message)
|
|
93
|
+
}
|
|
94
|
+
return { stored: hash }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
service: PLUGIN_SERVICE,
|
|
98
|
+
location,
|
|
99
|
+
requireDeployToken: true,
|
|
100
|
+
requireRegistryToken: false,
|
|
101
|
+
parseJsonBody: false
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return { pickNode: (o) => pickNode(registry, pm3ServiceName, o) }
|
|
106
|
+
}
|