@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.
Files changed (3) hide show
  1. package/package.json +25 -0
  2. package/placement.js +26 -0
  3. 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
+ }