| … | … | @@ -31,6 +31,8 @@ Node Management: |
| 31 | 31 | |
| 32 | 32 | Service Management: |
| 33 | 33 | 7rnx service add <node> <name> --port <port> [--prefix <path>] [--root-path <path>] |
| 34 | 7rnx service add-backend <service> --node <petname> --port <port> [--mode active|standby] | |
| 35 | 7rnx service remove-backend <service> --node <petname> | |
| 34 | 36 | 7rnx service list [node] |
| 35 | 37 | 7rnx service remove <node> <name> |
| 36 | 38 | 7rnx service status [node] Check which services are actually listening |
| … | … | @@ -156,7 +158,14 @@ function handleService (registry, sub, rest) { |
| 156 | 158 | } |
| 157 | 159 | for (const s of services) { |
| 158 | 160 | const urlPath = s.rootPath || `/${s.node}/${s.name}/` |
| 159 | console.log(` ${s.node}/${s.name}\t:${s.port}\t→ ${urlPath}`) | |
| 161 | if (s.backends && s.backends.length > 1) { | |
| 162 | console.log(` ${s.name}\t→ ${urlPath} (balancer)`) | |
| 163 | for (const b of s.backends) { | |
| 164 | console.log(` ${b.node}:${b.port}\t${b.mode}`) | |
| 165 | } | |
| 166 | } else { | |
| 167 | console.log(` ${s.node}/${s.name}\t:${s.port}\t→ ${urlPath}`) | |
| 168 | } | |
| 160 | 169 | } |
| 161 | 170 | break |
| 162 | 171 | } |
| … | … | @@ -171,6 +180,28 @@ function handleService (registry, sub, rest) { |
| 171 | 180 | console.log(`Removed service "${serviceName}" from "${nodePetname}"`) |
| 172 | 181 | break |
| 173 | 182 | } |
| 183 | case 'add-backend': { | |
| 184 | const serviceName = rest[0] | |
| 185 | const flags = parseFlags(rest.slice(1)) | |
| 186 | if (!serviceName || !flags.node || !flags.port) { | |
| 187 | console.error('Usage: 7rnx service add-backend <service> --node <petname> --port <port> [--mode active|standby]') | |
| 188 | process.exit(1) | |
| 189 | } | |
| 190 | registry.addBackend(serviceName, flags.node, flags.port, flags.mode || 'standby') | |
| 191 | console.log(`Added backend: ${flags.node}:${flags.port} (${flags.mode || 'standby'}) to service "${serviceName}"`) | |
| 192 | break | |
| 193 | } | |
| 194 | case 'remove-backend': { | |
| 195 | const serviceName = rest[0] | |
| 196 | const flags = parseFlags(rest.slice(1)) | |
| 197 | if (!serviceName || !flags.node) { | |
| 198 | console.error('Usage: 7rnx service remove-backend <service> --node <petname>') | |
| 199 | process.exit(1) | |
| 200 | } | |
| 201 | registry.removeBackend(serviceName, flags.node) | |
| 202 | console.log(`Removed backend "${flags.node}" from service "${serviceName}"`) | |
| 203 | break | |
| 204 | } | |
| 174 | 205 | case 'status': { |
| 175 | 206 | const nodePetname = rest[0] |
| 176 | 207 | const deployer = new Deployer(registry.data) |
| … | … | @@ -189,7 +220,7 @@ function handleService (registry, sub, rest) { |
| 189 | 220 | break |
| 190 | 221 | } |
| 191 | 222 | default: |
| 192 | console.error('Usage: 7rnx service <add|list|remove|status>') | |
| 223 | console.error('Usage: 7rnx service <add|list|remove|add-backend|remove-backend|status>') | |
| 193 | 224 | process.exit(1) |
| 194 | 225 | } |
| 195 | 226 | } |
| … | … | @@ -8,17 +8,87 @@ export function generateConfig (registry) { |
| 8 | 8 | lines.push('# Included from the SSL VirtualHost in 000-default-le-ssl.conf') |
| 9 | 9 | lines.push('') |
| 10 | 10 | |
| 11 | // Group services by node | |
| 11 | // Build nodeMap keyed by petname for IP lookup | |
| 12 | 12 | const nodeMap = new Map() |
| 13 | 13 | for (const node of registry.nodes) { |
| 14 | nodeMap.set(node.petname, { | |
| 15 | node, | |
| 16 | services: registry.services.filter(s => s.node === node.petname) | |
| 17 | }) | |
| 14 | nodeMap.set(node.petname, node) | |
| 18 | 15 | } |
| 19 | 16 | |
| 20 | for (const [petname, { node, services }] of nodeMap) { | |
| 17 | // Separate services into multi-backend and single-backend | |
| 18 | const multiBackend = [] | |
| 19 | const singleBackend = [] | |
| 20 | ||
| 21 | for (const svc of registry.services) { | |
| 22 | if (Array.isArray(svc.backends) && svc.backends.length > 1) { | |
| 23 | multiBackend.push(svc) | |
| 24 | } else { | |
| 25 | singleBackend.push(svc) | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | // --- Multi-backend services (mod_proxy_balancer) --- | |
| 30 | for (const svc of multiBackend) { | |
| 31 | const urlPath = svc.rootPath || `/${svc.name}` | |
| 32 | const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath | |
| 33 | const balancerName = svc.name | |
| 34 | ||
| 35 | lines.push(`# === ${svc.name} (balancer) ===`) | |
| 36 | lines.push('') | |
| 37 | ||
| 38 | // <Proxy balancer://name> block with health checks | |
| 39 | lines.push(`<Proxy balancer://${balancerName}>`) | |
| 40 | let firstActiveIp = null | |
| 41 | let firstActivePort = null | |
| 42 | const hc = 'hcmethod=TCP hcinterval=3 hcpasses=1 hcfails=2' | |
| 43 | for (const backend of svc.backends) { | |
| 44 | const node = nodeMap.get(backend.node) | |
| 45 | if (!node) continue | |
| 46 | const base = ` BalancerMember http://${node.ip}:${backend.port}` | |
| 47 | if (backend.mode === 'standby') { | |
| 48 | lines.push(`${base} status=+H ${hc}`) | |
| 49 | } else { | |
| 50 | lines.push(`${base} ${hc}`) | |
| 51 | if (!firstActiveIp) { | |
| 52 | firstActiveIp = node.ip | |
| 53 | firstActivePort = backend.port | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
| 57 | lines.push(' ProxySet failontimeout=On timeout=5') | |
| 58 | lines.push('</Proxy>') | |
| 59 | lines.push('') | |
| 60 | ||
| 61 | // <Location> block | |
| 62 | lines.push(`<Location ${urlPathClean}>`) | |
| 63 | lines.push(` ProxyPass balancer://${balancerName}`) | |
| 64 | lines.push(` ProxyPassReverse balancer://${balancerName}`) | |
| 65 | lines.push(' ProxyPreserveHost On') | |
| 66 | lines.push(' RequestHeader set X-Forwarded-Proto "https"') | |
| 67 | lines.push(' RequestHeader set X-Forwarded-Port "443"') | |
| 68 | lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`) | |
| 69 | lines.push('</Location>') | |
| 70 | lines.push('') | |
| 71 | ||
| 72 | // WebSocket rewrite to first active backend | |
| 73 | if (firstActiveIp) { | |
| 74 | lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]') | |
| 75 | lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${firstActiveIp}:${firstActivePort}$1 [P,L]`) | |
| 76 | lines.push('') | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | // --- Single-backend services (grouped by node, existing format) --- | |
| 81 | const byNode = new Map() | |
| 82 | for (const svc of singleBackend) { | |
| 83 | const petname = svc.node | |
| 84 | if (!byNode.has(petname)) byNode.set(petname, []) | |
| 85 | byNode.get(petname).push(svc) | |
| 86 | } | |
| 87 | ||
| 88 | for (const [petname, services] of byNode) { | |
| 21 | 89 | if (services.length === 0) continue |
| 90 | const node = nodeMap.get(petname) | |
| 91 | if (!node) continue | |
| 22 | 92 | |
| 23 | 93 | const hasRootPaths = services.some(s => s.rootPath) |
| 24 | 94 | const label = hasRootPaths |
| … | … | @@ -36,7 +106,7 @@ export function generateConfig (registry) { |
| 36 | 106 | lines.push(`<Location ${urlPathClean}>`) |
| 37 | 107 | lines.push(` ProxyPass ${backend}`) |
| 38 | 108 | lines.push(` ProxyPassReverse ${backend}`) |
| 39 | lines.push(' ProxyPreserveHost Off') | |
| 109 | lines.push(' ProxyPreserveHost On') | |
| 40 | 110 | lines.push(' RequestHeader set X-Forwarded-Proto "https"') |
| 41 | 111 | lines.push(' RequestHeader set X-Forwarded-Port "443"') |
| 42 | 112 | lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`) |
| … | … | @@ -44,7 +114,7 @@ export function generateConfig (registry) { |
| 44 | 114 | lines.push('') |
| 45 | 115 | |
| 46 | 116 | // WebSocket rewrite rule |
| 47 | lines.push(`RewriteCond %{HTTP:Upgrade} =websocket [NC]`) | |
| 117 | lines.push('RewriteCond %{HTTP:Upgrade} =websocket [NC]') | |
| 48 | 118 | lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${node.ip}:${svc.port}$1 [P,L]`) |
| 49 | 119 | lines.push('') |
| 50 | 120 | } |
| … | … | @@ -101,7 +101,7 @@ export class Deployer { |
| 101 | 101 | // tabertron is local — run directly |
| 102 | 102 | output = execSync(`bash -c '${cmd}'`, { encoding: 'utf8', timeout: 10000 }) |
| 103 | 103 | } else { |
| 104 | output = execSync(`ssh ${node.petname} "${cmd}"`, { encoding: 'utf8', timeout: 10000 }) | |
| 104 | output = execSync(`ssh ${node.petname}.local '${cmd}'`, { encoding: 'utf8', timeout: 10000 }) | |
| 105 | 105 | } |
| 106 | 106 | } catch { |
| 107 | 107 | // Node unreachable |
| … | … | @@ -16,22 +16,24 @@ const DEFAULT_REGISTRY = { |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | export class Registry { |
| 19 | constructor () { | |
| 19 | constructor (configDir) { | |
| 20 | this.configDir = configDir || CONFIG_DIR | |
| 21 | this.configFile = path.join(this.configDir, 'registry.json') | |
| 20 | 22 | this.data = null |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | load () { |
| 24 | if (!fs.existsSync(REGISTRY_PATH)) { | |
| 26 | if (!fs.existsSync(this.configFile)) { | |
| 25 | 27 | this.data = structuredClone(DEFAULT_REGISTRY) |
| 26 | 28 | return this |
| 27 | 29 | } |
| 28 | this.data = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')) | |
| 30 | this.data = JSON.parse(fs.readFileSync(this.configFile, 'utf8')) | |
| 29 | 31 | return this |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | save () { |
| 33 | fs.mkdirSync(CONFIG_DIR, { recursive: true }) | |
| 34 | fs.writeFileSync(REGISTRY_PATH, JSON.stringify(this.data, null, 2) + '\n') | |
| 35 | fs.mkdirSync(this.configDir, { recursive: true }) | |
| 36 | fs.writeFileSync(this.configFile, JSON.stringify(this.data, null, 2) + '\n') | |
| 35 | 37 | return this |
| 36 | 38 | } |
| 37 | 39 | |
| … | … | @@ -106,6 +108,86 @@ export class Registry { |
| 106 | 108 | return this |
| 107 | 109 | } |
| 108 | 110 | |
| 111 | addBackend (serviceName, nodePetname, port, mode) { | |
| 112 | const service = this.data.services.find(s => s.name === serviceName) | |
| 113 | if (!service) throw new Error(`Service "${serviceName}" not found`) | |
| 114 | ||
| 115 | const node = this.getNode(nodePetname) | |
| 116 | if (!node) throw new Error(`Node "${nodePetname}" not found`) | |
| 117 | ||
| 118 | if (!service.backends) { | |
| 119 | service.backends = [{ | |
| 120 | node: service.node, | |
| 121 | port: service.port, | |
| 122 | mode: 'active' | |
| 123 | }] | |
| 124 | } | |
| 125 | ||
| 126 | const duplicate = service.backends.find(b => b.node === nodePetname) | |
| 127 | if (duplicate) throw new Error(`Backend for node "${nodePetname}" already exists on service "${serviceName}"`) | |
| 128 | ||
| 129 | service.backends.push({ | |
| 130 | node: nodePetname, | |
| 131 | port: Number(port), | |
| 132 | mode: mode || 'active' | |
| 133 | }) | |
| 134 | this.save() | |
| 135 | return this | |
| 136 | } | |
| 137 | ||
| 138 | removeBackend (serviceName, nodePetname) { | |
| 139 | const service = this.data.services.find(s => s.name === serviceName) | |
| 140 | if (!service) throw new Error(`Service "${serviceName}" not found`) | |
| 141 | ||
| 142 | if (!service.backends) throw new Error(`Service "${serviceName}" has no backends`) | |
| 143 | ||
| 144 | const idx = service.backends.findIndex(b => b.node === nodePetname) | |
| 145 | if (idx === -1) throw new Error(`Backend for node "${nodePetname}" not found on service "${serviceName}"`) | |
| 146 | ||
| 147 | if (service.backends.length <= 1) throw new Error(`Cannot remove last backend from service "${serviceName}"`) | |
| 148 | ||
| 149 | service.backends.splice(idx, 1) | |
| 150 | ||
| 151 | if (service.backends.length === 1) { | |
| 152 | const remaining = service.backends[0] | |
| 153 | service.node = remaining.node | |
| 154 | service.port = remaining.port | |
| 155 | delete service.backends | |
| 156 | } | |
| 157 | ||
| 158 | this.save() | |
| 159 | return this | |
| 160 | } | |
| 161 | ||
| 162 | getServiceWithBackends (serviceName) { | |
| 163 | const service = this.data.services.find(s => s.name === serviceName) | |
| 164 | if (!service) throw new Error(`Service "${serviceName}" not found`) | |
| 165 | ||
| 166 | const result = { ...service } | |
| 167 | ||
| 168 | if (service.backends) { | |
| 169 | result.resolvedBackends = service.backends.map(b => { | |
| 170 | const node = this.getNode(b.node) | |
| 171 | return { | |
| 172 | node: b.node, | |
| 173 | ip: node ? node.ip : null, | |
| 174 | port: b.port, | |
| 175 | mode: b.mode | |
| 176 | } | |
| 177 | }) | |
| 178 | } else { | |
| 179 | const node = this.getNode(service.node) | |
| 180 | result.resolvedBackends = [{ | |
| 181 | node: service.node, | |
| 182 | ip: node ? node.ip : null, | |
| 183 | port: service.port, | |
| 184 | mode: 'active' | |
| 185 | }] | |
| 186 | } | |
| 187 | ||
| 188 | return result | |
| 189 | } | |
| 190 | ||
| 109 | 191 | listServices (nodePetname) { |
| 110 | 192 | if (nodePetname) { |
| 111 | 193 | return this.data.services.filter(s => s.node === nodePetname) |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | import { describe, it } from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { generateConfig } from '../lib/config-generator.js' | |
| 4 | ||
| 5 | describe('generateConfig', () => { | |
| 6 | it('generates single-backend config (backwards compat)', () => { | |
| 7 | const registry = { | |
| 8 | nodes: [{ petname: 'tabertron', ip: '10.22.0.1' }], | |
| 9 | services: [ | |
| 10 | { node: 'tabertron', name: 'viz', port: 7890, rootPath: '/viz' } | |
| 11 | ] | |
| 12 | } | |
| 13 | ||
| 14 | const output = generateConfig(registry) | |
| 15 | ||
| 16 | assert.ok(output.includes('ProxyPass http://10.22.0.1:7890'), 'should have direct ProxyPass') | |
| 17 | assert.ok(output.includes('ProxyPassReverse http://10.22.0.1:7890'), 'should have ProxyPassReverse') | |
| 18 | assert.ok(output.includes('<Location /viz>'), 'should have Location block') | |
| 19 | assert.ok(output.includes('ws://10.22.0.1:7890'), 'should have WebSocket rewrite') | |
| 20 | assert.ok(!output.includes('balancer://'), 'should NOT have balancer directives') | |
| 21 | }) | |
| 22 | ||
| 23 | it('generates balancer config for multi-backend', () => { | |
| 24 | const registry = { | |
| 25 | nodes: [ | |
| 26 | { petname: 'tabertron', ip: '10.22.0.1' }, | |
| 27 | { petname: 'stortron', ip: '10.22.0.2' } | |
| 28 | ], | |
| 29 | services: [ | |
| 30 | { | |
| 31 | name: 'pear-git', | |
| 32 | rootPath: '/git', | |
| 33 | backends: [ | |
| 34 | { node: 'tabertron', port: 5005, mode: 'active' }, | |
| 35 | { node: 'stortron', port: 5005, mode: 'standby' } | |
| 36 | ] | |
| 37 | } | |
| 38 | ] | |
| 39 | } | |
| 40 | ||
| 41 | const output = generateConfig(registry) | |
| 42 | ||
| 43 | assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have Proxy balancer block') | |
| 44 | assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member') | |
| 45 | assert.ok(output.includes('BalancerMember http://10.22.0.2:5005 status=+H'), 'standby should have status=+H') | |
| 46 | assert.ok(output.includes('ProxySet failontimeout=On timeout=30'), 'should have ProxySet') | |
| 47 | assert.ok(output.includes('<Location /git>'), 'should have Location block') | |
| 48 | assert.ok(output.includes('ProxyPass balancer://pear-git'), 'Location should proxy to balancer') | |
| 49 | assert.ok(output.includes('ProxyPassReverse balancer://pear-git'), 'should have ProxyPassReverse for balancer') | |
| 50 | assert.ok(output.includes('ws://10.22.0.1:5005'), 'WebSocket should point to active backend') | |
| 51 | }) | |
| 52 | ||
| 53 | it('mixes single and multi-backend', () => { | |
| 54 | const registry = { | |
| 55 | nodes: [ | |
| 56 | { petname: 'tabertron', ip: '10.22.0.1' }, | |
| 57 | { petname: 'stortron', ip: '10.22.0.2' } | |
| 58 | ], | |
| 59 | services: [ | |
| 60 | { node: 'tabertron', name: 'viz', port: 7890, rootPath: '/viz' }, | |
| 61 | { | |
| 62 | name: 'pear-git', | |
| 63 | rootPath: '/git', | |
| 64 | backends: [ | |
| 65 | { node: 'tabertron', port: 5005, mode: 'active' }, | |
| 66 | { node: 'stortron', port: 5005, mode: 'standby' } | |
| 67 | ] | |
| 68 | } | |
| 69 | ] | |
| 70 | } | |
| 71 | ||
| 72 | const output = generateConfig(registry) | |
| 73 | ||
| 74 | // Multi-backend present | |
| 75 | assert.ok(output.includes('<Proxy balancer://pear-git>'), 'should have balancer block') | |
| 76 | assert.ok(output.includes('BalancerMember http://10.22.0.1:5005'), 'should have active member') | |
| 77 | ||
| 78 | // Single-backend present | |
| 79 | assert.ok(output.includes('ProxyPass http://10.22.0.1:7890'), 'should have direct ProxyPass for single') | |
| 80 | assert.ok(output.includes('<Location /viz>'), 'should have Location for single backend') | |
| 81 | }) | |
| 82 | ||
| 83 | it('active-active mode has no status=+H', () => { | |
| 84 | const registry = { | |
| 85 | nodes: [ | |
| 86 | { petname: 'tabertron', ip: '10.22.0.1' }, | |
| 87 | { petname: 'stortron', ip: '10.22.0.2' } | |
| 88 | ], | |
| 89 | services: [ | |
| 90 | { | |
| 91 | name: 'api', | |
| 92 | rootPath: '/api', | |
| 93 | backends: [ | |
| 94 | { node: 'tabertron', port: 8080, mode: 'active' }, | |
| 95 | { node: 'stortron', port: 8080, mode: 'active' } | |
| 96 | ] | |
| 97 | } | |
| 98 | ] | |
| 99 | } | |
| 100 | ||
| 101 | const output = generateConfig(registry) | |
| 102 | ||
| 103 | assert.ok(output.includes('<Proxy balancer://api>'), 'should have balancer block') | |
| 104 | assert.ok(output.includes('BalancerMember http://10.22.0.1:8080'), 'should have first member') | |
| 105 | assert.ok(output.includes('BalancerMember http://10.22.0.2:8080'), 'should have second member') | |
| 106 | assert.ok(!output.includes('status=+H'), 'should NOT have status=+H for active-active') | |
| 107 | }) | |
| 108 | }) |
| … | … | @@ -0,0 +1,118 @@ |
| 1 | import { describe, it } from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import fs from 'node:fs' | |
| 4 | import path from 'node:path' | |
| 5 | import os from 'node:os' | |
| 6 | import { Registry } from '../lib/registry.js' | |
| 7 | ||
| 8 | function createTestRegistry () { | |
| 9 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '7rnx-test-')) | |
| 10 | const reg = new Registry(tmpDir) | |
| 11 | reg.load() | |
| 12 | return { reg, tmpDir } | |
| 13 | } | |
| 14 | ||
| 15 | describe('Registry multi-backend support', () => { | |
| 16 | it('addService still works with single node (backwards compat)', () => { | |
| 17 | const { reg } = createTestRegistry() | |
| 18 | reg.addNode('node1', '10.0.0.1') | |
| 19 | reg.addService('node1', 'web', 8080) | |
| 20 | ||
| 21 | const services = reg.listServices() | |
| 22 | assert.equal(services.length, 1) | |
| 23 | assert.equal(services[0].name, 'web') | |
| 24 | assert.equal(services[0].node, 'node1') | |
| 25 | assert.equal(services[0].port, 8080) | |
| 26 | assert.equal(services[0].backends, undefined) | |
| 27 | }) | |
| 28 | ||
| 29 | it('addBackend adds a second backend', () => { | |
| 30 | const { reg } = createTestRegistry() | |
| 31 | reg.addNode('node1', '10.0.0.1') | |
| 32 | reg.addNode('node2', '10.0.0.2') | |
| 33 | reg.addService('node1', 'web', 8080) | |
| 34 | ||
| 35 | reg.addBackend('web', 'node2', 9090, 'standby') | |
| 36 | ||
| 37 | const services = reg.listServices() | |
| 38 | const svc = services.find(s => s.name === 'web') | |
| 39 | assert.ok(svc.backends) | |
| 40 | assert.equal(svc.backends.length, 2) | |
| 41 | assert.equal(svc.backends[0].node, 'node1') | |
| 42 | assert.equal(svc.backends[0].port, 8080) | |
| 43 | assert.equal(svc.backends[0].mode, 'active') | |
| 44 | assert.equal(svc.backends[1].node, 'node2') | |
| 45 | assert.equal(svc.backends[1].port, 9090) | |
| 46 | assert.equal(svc.backends[1].mode, 'standby') | |
| 47 | }) | |
| 48 | ||
| 49 | it('addBackend throws if service does not exist', () => { | |
| 50 | const { reg } = createTestRegistry() | |
| 51 | reg.addNode('node1', '10.0.0.1') | |
| 52 | ||
| 53 | assert.throws( | |
| 54 | () => reg.addBackend('nonexistent', 'node1', 8080), | |
| 55 | { message: 'Service "nonexistent" not found' } | |
| 56 | ) | |
| 57 | }) | |
| 58 | ||
| 59 | it('addBackend throws if node does not exist', () => { | |
| 60 | const { reg } = createTestRegistry() | |
| 61 | reg.addNode('node1', '10.0.0.1') | |
| 62 | reg.addService('node1', 'web', 8080) | |
| 63 | ||
| 64 | assert.throws( | |
| 65 | () => reg.addBackend('web', 'ghost', 9090), | |
| 66 | { message: 'Node "ghost" not found' } | |
| 67 | ) | |
| 68 | }) | |
| 69 | ||
| 70 | it('removeBackend reverts to single-node if one backend left', () => { | |
| 71 | const { reg } = createTestRegistry() | |
| 72 | reg.addNode('node1', '10.0.0.1') | |
| 73 | reg.addNode('node2', '10.0.0.2') | |
| 74 | reg.addService('node1', 'web', 8080) | |
| 75 | reg.addBackend('web', 'node2', 9090, 'standby') | |
| 76 | ||
| 77 | reg.removeBackend('web', 'node2') | |
| 78 | ||
| 79 | const svc = reg.listServices().find(s => s.name === 'web') | |
| 80 | assert.equal(svc.backends, undefined) | |
| 81 | assert.equal(svc.node, 'node1') | |
| 82 | assert.equal(svc.port, 8080) | |
| 83 | }) | |
| 84 | ||
| 85 | it('getServiceWithBackends returns resolved info', () => { | |
| 86 | const { reg } = createTestRegistry() | |
| 87 | reg.addNode('node1', '10.0.0.1') | |
| 88 | reg.addNode('node2', '10.0.0.2') | |
| 89 | reg.addService('node1', 'web', 8080) | |
| 90 | reg.addBackend('web', 'node2', 9090, 'standby') | |
| 91 | ||
| 92 | const result = reg.getServiceWithBackends('web') | |
| 93 | assert.ok(result.resolvedBackends) | |
| 94 | assert.equal(result.resolvedBackends.length, 2) | |
| 95 | assert.equal(result.resolvedBackends[0].ip, '10.0.0.1') | |
| 96 | assert.equal(result.resolvedBackends[0].node, 'node1') | |
| 97 | assert.equal(result.resolvedBackends[0].port, 8080) | |
| 98 | assert.equal(result.resolvedBackends[0].mode, 'active') | |
| 99 | assert.equal(result.resolvedBackends[1].ip, '10.0.0.2') | |
| 100 | assert.equal(result.resolvedBackends[1].node, 'node2') | |
| 101 | assert.equal(result.resolvedBackends[1].port, 9090) | |
| 102 | assert.equal(result.resolvedBackends[1].mode, 'standby') | |
| 103 | }) | |
| 104 | ||
| 105 | it('getServiceWithBackends synthesizes array for single-node service', () => { | |
| 106 | const { reg } = createTestRegistry() | |
| 107 | reg.addNode('node1', '10.0.0.1') | |
| 108 | reg.addService('node1', 'web', 8080) | |
| 109 | ||
| 110 | const result = reg.getServiceWithBackends('web') | |
| 111 | assert.ok(result.resolvedBackends) | |
| 112 | assert.equal(result.resolvedBackends.length, 1) | |
| 113 | assert.equal(result.resolvedBackends[0].ip, '10.0.0.1') | |
| 114 | assert.equal(result.resolvedBackends[0].node, 'node1') | |
| 115 | assert.equal(result.resolvedBackends[0].port, 8080) | |
| 116 | assert.equal(result.resolvedBackends[0].mode, 'active') | |
| 117 | }) | |
| 118 | }) |