diff --git a/servers/ur/src/config.js b/servers/ur/src/config.js index 08e7cb99a..e5071b0de 100644 --- a/servers/ur/src/config.js +++ b/servers/ur/src/config.js @@ -32,14 +32,23 @@ const serverConfigSchema = z.object({ return typeof val === 'string' ? parseInt(val) : -1 }, z.number().positive()), processToHost: stringifiedJsonSchema.nullish(), + ownerToHost: stringifiedJsonSchema.nullish(), hosts: z.preprocess( (arg) => (typeof arg === 'string' ? arg.split(',').map(str => str.trim()) : arg), z.array(z.string().url()) ), aoUnit: z.enum(['cu', 'mu']), strategy: z.enum(['proxy', 'redirect']), - subrouterUrl: z.string().nullable().optional(), surUrl: z.string().nullable().optional(), + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ + subrouterUrl: z.string().nullable().optional(), + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ owners: z.preprocess( (arg) => (typeof arg === 'string' ? arg.split(',').map(str => str.trim()) : arg), z.array(z.string()) @@ -58,6 +67,7 @@ const CONFIG_ENVS = { port: process.env.PORT || 3005, hosts: process.env.HOSTS || ['http://127.0.0.1:3005'], processToHost: process.env.PROCESS_TO_HOST || JSON.stringify({}), + ownerToHost: process.env.OWNER_TO_HOST || JSON.stringify({}), /** * default to the CU for no hassle startup in development mode, @@ -67,8 +77,16 @@ const CONFIG_ENVS = { aoUnit: process.env.AO_UNIT || 'cu', strategy: process.env.STRATEGY || 'proxy', - subrouterUrl: process.env.SUBROUTER_URL, surUrl: process.env.SUR_URL, + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ + subrouterUrl: process.env.SUBROUTER_URL, + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ owners: process.env.OWNERS }, production: { @@ -76,12 +94,21 @@ const CONFIG_ENVS = { port: process.env.PORT || 3005, hosts: process.env.HOSTS, processToHost: process.env.PROCESS_TO_HOST || JSON.stringify({}), + ownerToHost: process.env.OWNER_TO_HOST || JSON.stringify({}), aoUnit: process.env.AO_UNIT, strategy: process.env.STRATEGY || 'proxy', - subrouterUrl: process.env.SUBROUTER_URL, surUrl: process.env.SUR_URL, + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ + subrouterUrl: process.env.SUBROUTER_URL, + /** + * @deprecated - use ownerToHost or processToHost to + * achieve subrouting + */ owners: process.env.OWNERS } } diff --git a/servers/ur/src/domain.js b/servers/ur/src/domain.js index e62df3305..1f8d3f77b 100644 --- a/servers/ur/src/domain.js +++ b/servers/ur/src/domain.js @@ -1,7 +1,10 @@ +import { defaultTo, isEmpty, complement, path } from 'ramda' import { LRUCache } from 'lru-cache' -export function bailoutWith ({ fetch, subrouterUrl, surUrl, owners, processToHost }) { - const cache = new LRUCache({ +const isNotEmpty = complement(isEmpty) + +export function bailoutWith ({ fetch, subrouterUrl, surUrl, owners, processToHost, ownerToHost }) { + const processToOwnerCache = new LRUCache({ /** * 10MB */ @@ -12,34 +15,49 @@ export function bailoutWith ({ fetch, subrouterUrl, surUrl, owners, processToHos sizeCalculation: () => 8 }) + async function findProcessOwner (processId) { + const owner = processToOwnerCache.get(processId) + if (owner) return owner + + return fetch(`${surUrl}/processes/${processId}`) + .then((res) => res.json()) + .then(defaultTo({})) + .then(path(['owner', 'address'])) + .then((owner) => { + if (!owner) return null + + processToOwnerCache.set(processId, owner) + return owner + }) + .catch((_e) => null) + } + return async (processId) => { /** * If a process has a specific mapping configured, * then immediately return it's mapping */ if (processToHost && processToHost[processId]) return processToHost[processId] + /** + * If there are owner -> host configured, then we lookup the process + * owner and return the specific host if found + */ + if (ownerToHost && isNotEmpty(ownerToHost)) { + const owner = await findProcessOwner(processId) + if (ownerToHost[owner]) return ownerToHost[owner] + } /** + * @deprecated - this functionality is subsumed by ownerToHost + * and will eventually be removed + * * All three of these must be set for the * subrouter logic to work so if any are * not set just return. */ if (!subrouterUrl || !surUrl || !owners) return - let owner = cache.get(processId) - if (!owner) { - const suResponse = await fetch(`${surUrl}/processes/${processId}`) - .then((res) => res.json()) - .catch((_e) => null) - if (!suResponse) return - if (!suResponse.owner) return - if (!suResponse.owner.address) return - cache.set(processId, suResponse.owner.address) - owner = suResponse.owner.address - } - - if (owners.includes(owner)) { - return subrouterUrl - } + const owner = await findProcessOwner(processId) + if (owners.includes(owner)) return subrouterUrl } } @@ -53,7 +71,10 @@ export function bailoutWith ({ fetch, subrouterUrl, surUrl, owners, processToHos * been attempted, and so return undefined, to be handled upstream */ export function determineHostWith ({ hosts = [], bailout }) { - const cache = new LRUCache({ + /** + * TODO: should we inject this cache? + */ + const processToHostCache = new LRUCache({ /** * 10MB */ @@ -75,13 +96,13 @@ export function determineHostWith ({ hosts = [], bailout }) { /** * Check cache, and hydrate if necessary */ - let hashSum = cache.get(processId) + let hashSum = processToHostCache.get(processId) if (!hashSum) { /** * Only perform the expensive computation of hash -> idx once and cache */ hashSum = computeHashSumFromProcessId({ processId, length: hosts.length }) - cache.set(processId, hashSum) + processToHostCache.set(processId, hashSum) } return hosts[(hashSum + failoverAttempt) % hosts.length] diff --git a/servers/ur/src/domain.test.js b/servers/ur/src/domain.test.js index 798f9adae..4bda0de1f 100644 --- a/servers/ur/src/domain.test.js +++ b/servers/ur/src/domain.test.js @@ -6,87 +6,152 @@ import { groupBy, identity } from 'ramda' import { determineHostWith, bailoutWith, computeHashSumFromProcessId } from './domain.js' const HOSTS = ['http://foo.bar', 'http://fizz.buzz'] -const cache = { - get: () => undefined, - set: () => undefined -} describe('domain', () => { describe('determineHostWith', () => { - test('should deterministically return a valid host', async () => { - const determineHost = determineHostWith({ hosts: HOSTS, cache }) + describe('compute or cached', () => { + test('should deterministically return a valid host', async () => { + const determineHost = determineHostWith({ hosts: HOSTS }) - assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), await determineHost({ processId: 'process-123', failoverAttempt: 0 })) - }) + assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) + assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), await determineHost({ processId: 'process-123', failoverAttempt: 0 })) + }) - test('should shift the determined host according to failoverAttempt', async () => { - const determineHost = determineHostWith({ hosts: HOSTS, cache }) + test('should shift the determined host according to failoverAttempt', async () => { + const determineHost = determineHostWith({ hosts: HOSTS }) - assert.notEqual(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), await determineHost({ processId: 'process-123', failoverAttempt: 1 })) + assert.notEqual(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), await determineHost({ processId: 'process-123', failoverAttempt: 1 })) + }) + + test('should return undefined if all hosts have been attempted', async () => { + const determineHost = determineHostWith({ hosts: HOSTS }) + assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: HOSTS.length }), undefined) + }) }) - test('should return undefined if all hosts have been attempted', async () => { - const determineHost = determineHostWith({ hosts: HOSTS, cache }) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: HOSTS.length }), undefined) + describe('processToHost', () => { + test('should redirect to the specific host for the process', async () => { + const bailout = bailoutWith({ processToHost: { 'process-123': 'https://specific.host' } }) + + const determineHost = determineHostWith({ hosts: HOSTS, bailout }) + assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'https://specific.host') + }) }) - test('should serve from the cache, if found', async () => { - const determineHost = determineHostWith({ - hosts: HOSTS, - cache: { ...cache, get: () => 10 } + describe('ownerToHost', () => { + test('should bailout if the process owner is mapped to a specific host', async () => { + const fetchMock = async (url) => { + assert.equal(url, 'surUrl1/processes/process-123') + return new Response(JSON.stringify({ owner: { address: 'owner2' } })) + } + + const bailout = bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + ownerToHost: { owner2: 'https://specific_owner.host' } + }) + + const determineHost = determineHostWith({ hosts: HOSTS, bailout }) + + const host = await determineHost({ processId: 'process-123', failoverAttempt: 0 }) + assert.equal(host, 'https://specific_owner.host') }) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: HOSTS.length }), HOSTS[HOSTS.length & 10]) - }) + test('should NOT bailout if the process owner is not mapped to a specific host', async () => { + const fetchMock = async (url) => { + assert.equal(url, 'surUrl1/processes/process-123') + return new Response(JSON.stringify({ owner: { address: 'owner2' } })) + } + + const bailout = bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + ownerToHost: { notOwner2: 'https://specific_owner.host' } + }) + + const determineHost = determineHostWith({ hosts: HOSTS, bailout }) - test('should redirect to the subrouterUrl', async () => { - const fetchMock = async (url) => { - assert.equal(url, 'surUrl1/processes/process-123') - return new Response(JSON.stringify({ owner: { address: 'owner2' } })) - } - - const bailout = bailoutWith({ - fetch: fetchMock, - surUrl: 'surUrl1', - subrouterUrl: 'subrouterUrl1', - owners: ['owner1', 'owner2'] + const host = await determineHost({ processId: 'process-123', failoverAttempt: 0 }) + assert.ok(host !== 'https://specific_owner.host') + assert.ok(HOSTS.includes(host)) }) - const determineHost = determineHostWith({ hosts: HOSTS, cache, bailout }) + test('should NOT bailout if no ownerToHost is provided', async () => { + const fetchMock = async (url) => { + assert.equal(url, 'surUrl1/processes/process-123') + return new Response(JSON.stringify({ owner: { address: 'owner2' } })) + } - assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'subrouterUrl1') + const determineHostEmptyMapping = determineHostWith({ + hosts: HOSTS, + bailout: bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + ownerToHost: {} + }) + }) + const host = await determineHostEmptyMapping({ processId: 'process-123', failoverAttempt: 0 }) + assert.ok(HOSTS.includes(host)) + + const determineHostNoMapping = determineHostWith({ + hosts: HOSTS, + bailout: bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + ownerToHost: undefined + }) + }) + const host1 = await determineHostNoMapping({ processId: 'process-123', failoverAttempt: 0 }) + assert.ok(HOSTS.includes(host1)) + }) }) - test('should not redirect to the subrouterUrl', async () => { - const fetchMock = async (url) => { - assert.equal(url, 'surUrl1/processes/process-123') - /** - * Here the owner does not match any in the list - * this will cause it to not redirect to the subrouter - */ - return new Response(JSON.stringify({ owner: { address: 'owner3' } })) - } - - const bailout = bailoutWith({ - fetch: fetchMock, - surUrl: 'surUrl1', - subrouterUrl: 'subrouterUrl1', - owners: ['owner1', 'owner2'] + /** + * @deprecated - this functionality is subsumed by ownerToHost + * and will eventually be removed, along with the tests + */ + describe('subRouter - DEPRECATED', () => { + test('should redirect to the subrouterUrl', async () => { + const fetchMock = async (url) => { + assert.equal(url, 'surUrl1/processes/process-123') + return new Response(JSON.stringify({ owner: { address: 'owner2' } })) + } + + const bailout = bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + subrouterUrl: 'subrouterUrl1', + owners: ['owner1', 'owner2'] + }) + + const determineHost = determineHostWith({ hosts: HOSTS, bailout }) + + assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) + assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'subrouterUrl1') }) - const determineHost = determineHostWith({ hosts: HOSTS, cache, bailout }) + test('should not redirect to the subrouterUrl', async () => { + const fetchMock = async (url) => { + assert.equal(url, 'surUrl1/processes/process-123') + /** + * Here the owner does not match any in the list + * this will cause it to not redirect to the subrouter + */ + return new Response(JSON.stringify({ owner: { address: 'owner3' } })) + } - assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'http://fizz.buzz') - }) + const bailout = bailoutWith({ + fetch: fetchMock, + surUrl: 'surUrl1', + subrouterUrl: 'subrouterUrl1', + owners: ['owner1', 'owner2'] + }) - test('should redirect to the specific host for the process', async () => { - const bailout = bailoutWith({ processToHost: { 'process-123': 'https://specific.host' } }) + const determineHost = determineHostWith({ hosts: HOSTS, bailout }) - const determineHost = determineHostWith({ hosts: HOSTS, cache, bailout }) - assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'https://specific.host') + assert(await determineHost({ processId: 'process-123', failoverAttempt: 0 })) + assert.equal(await determineHost({ processId: 'process-123', failoverAttempt: 0 }), 'http://fizz.buzz') + }) }) }) diff --git a/servers/ur/src/proxy.js b/servers/ur/src/proxy.js index 63b496502..1afb7b000 100644 --- a/servers/ur/src/proxy.js +++ b/servers/ur/src/proxy.js @@ -14,13 +14,13 @@ import { logger } from './logger.js' import { mountRoutesWithByAoUnit } from './routes/byAoUnit.js' -export function proxyWith ({ aoUnit, hosts, subrouterUrl, surUrl, owners, processToHost }) { +export function proxyWith ({ aoUnit, hosts, subrouterUrl, surUrl, owners, processToHost, ownerToHost }) { const _logger = logger.child('proxy') _logger('Configuring to reverse proxy ao %s units...', aoUnit) const proxy = httpProxy.createProxyServer({}) - const bailout = aoUnit === 'cu' ? bailoutWith({ fetch, subrouterUrl, surUrl, owners, processToHost }) : undefined + const bailout = aoUnit === 'cu' ? bailoutWith({ fetch, subrouterUrl, surUrl, owners, processToHost, ownerToHost }) : undefined const determineHost = determineHostWith({ hosts, bailout }) async function trampoline (init) { diff --git a/servers/ur/src/redirect.js b/servers/ur/src/redirect.js index e16df11a5..90c877c51 100644 --- a/servers/ur/src/redirect.js +++ b/servers/ur/src/redirect.js @@ -6,11 +6,11 @@ import { logger } from './logger.js' import { mountRoutesWithByAoUnit } from './routes/byAoUnit.js' -export function redirectWith ({ aoUnit, hosts, subrouterUrl, surUrl, owners, processToHost }) { +export function redirectWith ({ aoUnit, hosts, subrouterUrl, surUrl, owners, processToHost, ownerToHost }) { const _logger = logger.child('redirect') _logger('Configuring to redirect ao %s units...', aoUnit) - const bailout = aoUnit === 'cu' ? bailoutWith({ fetch, subrouterUrl, surUrl, owners, processToHost }) : undefined + const bailout = aoUnit === 'cu' ? bailoutWith({ fetch, subrouterUrl, surUrl, owners, processToHost, ownerToHost }) : undefined const determineHost = determineHostWith({ hosts, bailout }) /**