From f0cbde33068be7133496cce493c6bb3d9b82c0db Mon Sep 17 00:00:00 2001 From: Marek Fedorovic Date: Tue, 13 Feb 2024 15:47:18 +1100 Subject: [PATCH] fix: Improve Lock Manager HTTP layer --- README.md | 59 +++--- k8s-deployer/src/locks/http-client.ts | 91 +++++++++ .../src/locks/lock-manager-api-client.ts | 42 ---- k8s-deployer/src/locks/lock-manager.ts | 43 ++-- k8s-deployer/src/locks/schema-v1.ts | 31 +++ k8s-deployer/test/locks/http-client.spec.ts | 100 ++++++++++ .../locks/lock-manager-api-client.spec.ts | 56 ------ k8s-deployer/test/locks/lock-manager.spec.ts | 10 +- lock-manager/TODO.md | 1 - lock-manager/package-lock.json | 184 +++++++++++++++++- lock-manager/package.json | 19 +- lock-manager/src/api-routes.ts | 26 +-- lock-manager/src/configuration.ts | 6 +- lock-manager/src/db/db.ts | 41 ++-- lock-manager/src/db/pg.ts | 25 ++- lock-manager/src/index.ts | 16 +- lock-manager/src/lock-operations.ts | 43 ++-- .../src/web-api/v1/open-api-schema-v1.yml | 158 +++++++++++++++ lock-manager/src/web-api/v1/schema-v1.ts | 31 +++ 19 files changed, 754 insertions(+), 228 deletions(-) create mode 100644 k8s-deployer/src/locks/http-client.ts delete mode 100644 k8s-deployer/src/locks/lock-manager-api-client.ts create mode 100644 k8s-deployer/src/locks/schema-v1.ts create mode 100644 k8s-deployer/test/locks/http-client.spec.ts delete mode 100644 k8s-deployer/test/locks/lock-manager-api-client.spec.ts delete mode 100644 lock-manager/TODO.md create mode 100644 lock-manager/src/web-api/v1/open-api-schema-v1.yml create mode 100644 lock-manager/src/web-api/v1/schema-v1.ts diff --git a/README.md b/README.md index 5031bc9..bf0cd9c 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,6 @@ At this point we have: Upon deployment, Lock Manager will prepare its database. It is expected that permanent database server prepared upfront and is accessible from the namespace. This DB is permanent it survives the lifespan of temporary namespace where Lock Manager is running. The DB is used to implement exclusivity over components (graph) used in the tests. Multiple instances of Lock Manager may be present in the K8s cluster each sitting in its own temporary namespace. With the help of locking only one test suite will ever run at the same time unless there is no dependency between tests. -There could be multiple Test Runner Apps deployed in the namespace. These apps may be designed to test different graphs. In such setup the invocation of these multiple Test Runners is orchestrated by PIT and subject to locking strategy. - All tests are divided into test suites as defined in the relevant section of pitfile. Pitfile may contain a mixed definition of local and remote test suites. _Local_ test suites are defined in the same repository as pitfile. @@ -35,7 +33,7 @@ _Remote_ test suites are defined as reference to remote pitfile. In this case PI Once Test Runner App finishes executing the test report is available via dedicated endpoint, for example via `GET /reports/{$execution_id}` -Test reports are stored permanently. Multiple reports which are obtained from different Test Runner Apps but produced in the same test session may be stitched together before storing. +Test reports are stored permanently. The responsibilities of all mentioned components are defined as: @@ -48,9 +46,9 @@ The responsibilities of all mentioned components are defined as: - Parses `pitfile.yml` - Executes main PIT logic described above -- Creates K8s namepsace +- Creates K8s namespace (one for each test suite) - Deploys Lock Manager -- Deploys Test Runner App +- Deploys Test Runner App (one for each test suite) - Deploys graph - Collects test report and stores it permanently - Cleans up namespace @@ -67,9 +65,8 @@ The responsibilities of all mentioned components are defined as: **The YAML specification (pitfile)** -- Controls whether PIT should run at all based on optional filters -- Encapsulates the location of graph within each test suite -- Defines what to do with test report +- Defines test suites +- Describes components graph for each test suite ![](./docs/arch.png) @@ -120,37 +117,51 @@ testSuites: deployment: graph: testApp: - componentName: Talos Certifier Test App + name: Talos Certifier Test App + id: talos-certifier-test-app location: type: LOCAL # optional, defautls to 'LOCAL' deploy: - command: deployment/talos-certifier-test-app/pit/deploy.sh + timeoutSeconds: 120 + command: deployment/pit/deploy.sh params: # Optional command line parameters - param1 - param2 statusCheck: - command: deployment/talos-certifier-test-app/pit/is-deployment-ready.sh + command: deployment/pit/is-deployment-ready.sh + undeploy: + timeoutSeconds: 120 + command: deployment/pit/undeploy.sh components: - - componentName: Talos Certifier" + - name: Talos Certifier" + id: talos-certifier # Lets assume that pipeline fired as a result of push into Talos Certifier project location: type: LOCAL deploy: - command: deployment/talos-certifier/pit/deploy.sh + command: deployment/pit/deploy.sh statusCheck: - command: deployment/talos-certifier/pit/is-deployment-ready.sh + command: deployment/pit/is-deployment-ready.sh + undeploy: + timeoutSeconds: 120 + command: deployment/pit/undeploy.sh - - componentName: Talos Replicator + - name: Talos Replicator + id: talos-replicator # Lets assume Talos Certifier and Replicator (made for testing Talos Certifier) are in the same repository location: type: LOCAL deploy: - command: deployment/talos-replicator/pit/deploy.sh + command: deployment/pit/deploy.sh statusCheck: - command: deployment/talos-replicator/pit/is-deployment-ready.sh + command: deployment/pit/is-deployment-ready.sh + undeploy: + timeoutSeconds: 120 + command: deployment/pit/undeploy.sh - - componentName: Some Other Component + - name: Some Other Component + id: some-id # This is an example how to define the remote component location: type: REMOTE @@ -158,7 +169,11 @@ testSuites: gitRef: # Optional, defaults to "refs/remotes/origin/master" deploy: command: deployment/pit/deploy.sh - + statusCheck: + command: deployment/pit/is-deployment-ready.sh + undeploy: + timeoutSeconds: 120 + command: deployment/pit/undeploy.sh # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - name: Testset for Talos Certifier integrated with Messenger id: testset-talos-certifier-and-messenger @@ -220,11 +235,11 @@ testSuites: - Ingress Controller named "kubernetes-ingress". [See instructions here](https://kubernetes.github.io/ingress-nginx/deploy/_) - All your namespaces are being observed by HNC system. When installing NGINX ingress controller it will create its own namespace and HNC system will complain. To prevent that we need to exclude namespace used by NGINX insgress controller from HNC. - Use inline editing method: `kubectl edit -n hnc-system deploy hnc-controller-manager` find deployment with name "name: hnc-controller-manager" and add one more entry into the list under `spec/containers/args`. Entry looks like this: `--excluded-namespace=ingress-nginx` -- Custom resource definition "External secrets" is installed in your local cluster: +- Custom resource definition "External secrets" is installed in your local cluster: - `helm repo add external-secrets https://charts.external-secrets.io` - `helm install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace` - Also exclude namespace "external-secrets" from hnc-controller-manager as you did with nginx controller. - + - The port 80 is free. Port 80 is used by ingress controller in your local desktop-docker. ## Ports used @@ -408,7 +423,7 @@ Similarly run git server for "remote test app" scripts/host-project-in-git.sh /tmp/remote-sample $(pwd)/../examples/graph-perf-test-app ``` -Please note that when these projects will be feetched from local git by `k8-deployer`, the fetched project will have no `.env` file! However, our deployment scripts under `deployment/pit/*` can locate `.env` file by reading global environment variables. This is intended for local development. Below is the list of global environment variables +Please note that when these projects will be fetched from local git by `k8-deployer`, the fetched project will have no `.env` file! However, our deployment scripts under `deployment/pit/*` can locate `.env` file by reading global environment variables. This is intended for local development. Below is the list of global environment variables controlling the location of `.env` files. Export these either globally or in the terminal where you will be launching `k8-deployer` | Project | Variable | diff --git a/k8s-deployer/src/locks/http-client.ts b/k8s-deployer/src/locks/http-client.ts new file mode 100644 index 0000000..055107b --- /dev/null +++ b/k8s-deployer/src/locks/http-client.ts @@ -0,0 +1,91 @@ +import fetch, { RequestInit, Response } from "node-fetch" + +import { logger } from "../logger.js" + +export type FetchParams = { + endpoint: string, + options: RequestInit +} + +export type RetryOptions = { + retries: number, + retryDelay: number, + fetchParams: FetchParams +} + +export class HttpErrorOptions { + cause?: Error + responseData?: string | unknown +} + +export class HttpError extends Error { + readonly type: string = "HttpError" + responseData?: string | unknown + constructor( + readonly method: string, + readonly endpoint: string, + readonly status: number, + readonly text: string, + private options: HttpErrorOptions + ){ + super(text, { cause: options.cause }) + this.responseData = options.responseData + } + + toString(): string { + return JSON.stringify({ method: this.method, endpoint: this.endpoint, status: this.status, text: this.text, responseData: this.responseData, cause: this.cause }) + } +} + +export const invoke = async (options: RetryOptions, apiBody: Object) => { + let attempts = 1 + while (options.retries > 0) { + logger.info("http-client.invoke(): attempt %s of %s invoking %s", attempts, options.retries, JSON.stringify(options.fetchParams)) + try { + return await invokeApi(options.fetchParams, apiBody) + } catch (error: unknown) { + logger.warn("http-client.invoke(): attempt %s of %s invoking %s. Error: %s", attempts, options.retries, JSON.stringify(options.fetchParams), error) + if (attempts < options.retries) { + attempts++ + await sleep(options.retryDelay) + continue + } + + logger.warn("http-client.invoke(): failed to invoke %s. No more attempts left, giving up", JSON.stringify(options.fetchParams)) + throw new Error(`Failed to fetch ${ options.fetchParams.endpoint } after ${ attempts } attempts`, { cause: error }) + } + } +} + +const invokeApi = async (params: FetchParams, apiBody: Object): Promise => { + const resp = await fetch(params.endpoint, { ...params.options, body: JSON.stringify(apiBody) }) + const readPayload = async (response: Response): Promise => { + const ctHeader = "content-type" + const hdrs = response.headers + if (hdrs.has(ctHeader) && `${ hdrs.get(ctHeader) }`.toLowerCase().startsWith("application/json")) { + try { + return await response.json() + } catch (e) { + logger.warn("Unable to parse JSON when handling response from '%s', handling will fallback to reading the payload as text. Parsing error: %s", params.endpoint, e) + return await response.text() + } + } else { + return await response.text() + } + } + + if (resp.ok) { + logger.info("invokeApi(): resp was ok, reading payload") + return await readPayload(resp) + } else { + logger.info("invokeApi(): resp was not ok, raising error") + throw new HttpError( + params.options.method, + params.endpoint, + resp.status, + resp.statusText, + { responseData: await readPayload(resp) }) + } +} + +export const sleep = (time: number) => new Promise(resolve => setTimeout(resolve, time)) diff --git a/k8s-deployer/src/locks/lock-manager-api-client.ts b/k8s-deployer/src/locks/lock-manager-api-client.ts deleted file mode 100644 index 291573e..0000000 --- a/k8s-deployer/src/locks/lock-manager-api-client.ts +++ /dev/null @@ -1,42 +0,0 @@ -import fetch from "node-fetch" - -import { logger } from "../logger.js" - -export type FetchParams = { - endpoint: string, - options: Object -} - -export type RetryOptions = { - retries: number, - retryDelay: number, - fetchParams: FetchParams -} - -export const invoke = async (options: RetryOptions, apiBody: Object) => { - let attempts = 1 - while (options.retries > 0) { - logger.info("lock-manager-api-client.invoke(): attempt %s of %s invoking %s", attempts, options.retries, JSON.stringify(options.fetchParams)) - try { - return await invokeApi(options.fetchParams, apiBody) - } catch (error) { - logger.warn("lock-manager-api-client.invoke(): attempt %s of %s invoking %s. Error: %s", attempts, options.retries, JSON.stringify(options.fetchParams), error) - if (attempts < options.retries) { - attempts++ - await sleep(options.retryDelay) - continue - } - - logger.warn("lock-manager-api-client.invoke(): failed to invoke %s. No more attempts left, giving up", JSON.stringify(options.fetchParams)) - throw new Error(`Failed to fetch ${ options.fetchParams.endpoint } after ${ attempts } attempts`, { cause: error }) - } - } -} - -const invokeApi = async (params: FetchParams, apiBody: Object) =>{ - let resp = await fetch(params.endpoint, { ...params.options, body: JSON.stringify(apiBody) }) - // TODO: check for JSON header before decoding - return await resp.json() -} - -export const sleep = (time: number) => new Promise(resolve => setTimeout(resolve, time)) diff --git a/k8s-deployer/src/locks/lock-manager.ts b/k8s-deployer/src/locks/lock-manager.ts index 4ffc56a..784116c 100644 --- a/k8s-deployer/src/locks/lock-manager.ts +++ b/k8s-deployer/src/locks/lock-manager.ts @@ -1,6 +1,8 @@ import {logger} from "../logger.js" import {Schema} from "../model.js" -import * as LockManagerApi from "./lock-manager-api-client.js" +import * as HttpClient from "./http-client.js" +import { FetchParams } from "./http-client.js" +import { AcquireResponse, KeepAliveRequest, KeepAliveResponse, ReleaseResponse } from "./schema-v1.js" const KEEP_ALIVE_INTERVAL = 10 // seconds const RETRY_TIMEOUT = 1000 // milliseconds @@ -10,10 +12,10 @@ export class LockManager { } private api = { - check: { endpoint: "", options: {} }, - acquire: { endpoint: "", options: {} }, - keepAlive: { endpoint: "", options: {} }, - release: { endpoint: "", options: {} }, + acquire: {} as FetchParams, + check: {} as FetchParams, + keepAlive: {} as FetchParams, + release: {} as FetchParams } // Handle to the timer @@ -44,9 +46,9 @@ export class LockManager { this.validateArgs(this.lockOwner, lock) lock.ids = this.cleanupIds(lock.ids) - let locksAcquired = new Array() + const locksAcquired = new Array() - let retryOptions: LockManagerApi.RetryOptions = { + const retryOptions: HttpClient.RetryOptions = { retries: this.apiRetries, retryDelay: RETRY_TIMEOUT, fetchParams: this.api.acquire, @@ -60,7 +62,7 @@ export class LockManager { while (locksAcquired.indexOf(lockId) == -1) { try { - let response = await LockManagerApi.invoke(retryOptions, { owner: this.lockOwner, lockId }) as any + const response = await HttpClient.invoke(retryOptions, { owner: this.lockOwner, lockId }) as AcquireResponse logger.info("LockManager.lock(): %s of %s. Outcome: %s", i, lock.ids.length, JSON.stringify({ request: { owner: this.lockOwner, lockId }, response })) if (response.acquired) { locksAcquired.push(response.lockId) @@ -69,9 +71,6 @@ export class LockManager { await this.startKeepAliveJob(this.lockOwner, lock, new Date(Date.parse(response.lockExpiry))) } break - } else { - const sleep = new Promise(resolve => setTimeout(resolve, 2_000)) - await sleep } } catch (error) { logger.error("LockManager.lock(): Failed to acquire lock for %s", lockId) @@ -93,7 +92,7 @@ export class LockManager { this.validateArgs(this.lockOwner, lock) lock.ids = this.cleanupIds(lock.ids) - let retryOptions: LockManagerApi.RetryOptions = { + const retryOptions: HttpClient.RetryOptions = { retries: this.apiRetries, retryDelay: RETRY_TIMEOUT, fetchParams: this.api.release, @@ -105,9 +104,9 @@ export class LockManager { logger.info("LockManager.release(): Releasing lock for %s", lock.ids) try { - let respJson = await LockManagerApi.invoke(retryOptions, { owner: this.lockOwner, lockIds: lock.ids }) + const lockIds = await HttpClient.invoke(retryOptions, { owner: this.lockOwner, lockIds: lock.ids }) as ReleaseResponse logger.info("LockManager.release(): %s is released for %s", lock.ids, this.lockOwner) - return respJson + return lockIds } catch (error) { logger.error("LockManager.release(): Failed to release lock for %s", lock.ids, error) throw new Error(`Failed to release lock for ${ lock.ids }`, { cause: error }) @@ -133,15 +132,15 @@ export class LockManager { expiryInSec = timeValue } - let retryOptions: LockManagerApi.RetryOptions = { + let retryOptions: HttpClient.RetryOptions = { retries: this.apiRetries, retryDelay: RETRY_TIMEOUT, fetchParams: this.api.keepAlive, } try { - let params = { owner: this.lockOwner, lockIds: lock.ids, expiryInSec } - let resp = (await LockManagerApi.invoke(retryOptions, params)) as any - logger.debug("LockManager.keepAliveFetch(): keepAlive api for: %s with resp %s", JSON.stringify(params), resp) + const request: KeepAliveRequest = { owner: this.lockOwner, lockIds: lock.ids, expiryInSec } + const resp = (await HttpClient.invoke(retryOptions, request)) as KeepAliveResponse + logger.debug("LockManager.keepAliveFetch(): keepAlive api for: %s with resp %s", JSON.stringify(request), resp) return resp } catch (error) { logger.error("LockManager.keepAliveFetch(): failed to keep alive for %s with %s", { lock, owner: this.lockOwner }, error) @@ -165,8 +164,8 @@ export class LockManager { this.keepAliveJobHandle = setInterval(async () => { try { - logger.info("LockManager. Heartbeat for: %s", JSON.stringify({ owner, lock })) - return await this.keepAliveFetch(lock) + logger.info("LockManager. Heartbeat for: %s", JSON.stringify({ owner, lock })) + return await this.keepAliveFetch(lock) } catch (error) { clearInterval(this.keepAliveJobHandle) logger.error("LockManager. Heartbeat failed for %s with %s", JSON.stringify({ lock, owner }), error) @@ -190,10 +189,10 @@ export class LockManager { const msg = `Timeout ${ lock.timeout } should be a string eg 1h, 1m or 1s` if (typeof(lock.timeout) !== "string") throw new Error(msg) - let timeUnit = lock.timeout.trim().slice(-1) + const timeUnit = lock.timeout.trim().slice(-1) if (![ "h", "m", "s" ].includes(timeUnit)) throw new Error(msg) - let timeValue = parseInt(lock.timeout.slice(0, -1), 10) + const timeValue = parseInt(lock.timeout.slice(0, -1), 10) if (isNaN(timeValue) || timeValue < 0) throw new Error(msg) } } diff --git a/k8s-deployer/src/locks/schema-v1.ts b/k8s-deployer/src/locks/schema-v1.ts new file mode 100644 index 0000000..c170a74 --- /dev/null +++ b/k8s-deployer/src/locks/schema-v1.ts @@ -0,0 +1,31 @@ +export type ErrorDetails = { + error: string +} + +export type AcquireRequest = { + lockId: string + owner: string + expiryInSec?: number +} + +export type AcquireResponse = { + lockId: string + acquired: boolean + lockExpiry?: string // string formatted as date +} + +export type KeepAliveRequest = { + lockIds: Array + owner: string + expiryInSec?: number +} + +export type LockId = string +export type KeepAliveResponse = Array + +export type ReleaseRequest = { + lockIds: Array + owner: string +} + +export type ReleaseResponse = Array \ No newline at end of file diff --git a/k8s-deployer/test/locks/http-client.spec.ts b/k8s-deployer/test/locks/http-client.spec.ts new file mode 100644 index 0000000..46d97da --- /dev/null +++ b/k8s-deployer/test/locks/http-client.spec.ts @@ -0,0 +1,100 @@ +import esmock from "esmock" +import * as chai from "chai" +import chaiAsPromised from 'chai-as-promised' + +import * as sinon from "sinon" + +import { describe, it } from "mocha" + +import { RetryOptions } from "../../src/locks/http-client.js" + +chai.use(chaiAsPromised) + +describe("lock-api-fetch", async () => { + const createHttpErrorWithJson = (status: number, text: string, body: unknown): unknown => { + return { + ok: false, + status: status, + statusText: text, + headers: new Map([ [ "content-type", "application/json" ] ]), + json: () => { return body } + } + } + + const createJsonResponseTemplate = (response: unknown): unknown => { + return { + ok: true, + headers: new Map([ [ "content-type", "application/json" ] ]), + json: () => { return response } + } + } + + const mockHttpCall = async (stubFunction: any): Promise => { + return await esmock( + "../../src/locks/http-client.js", + { + 'node-fetch': { + default: () => { return stubFunction() } + } + } + ) + } + + const mockResponse = async (response: unknown): Promise => { + return mockHttpCall(sinon.stub().returns(response)) + } + + const mockJsonResponse = async (response: unknown): Promise => { + return await mockResponse(createJsonResponseTemplate(response)) + } + + it("invoke() should return parsed JSON response", async () => { + const fetchParams = { endpoint: "http://foobar:8080", options: {} } + const apiBody = { lockId: "id1", owner: "owner1" } + const retryOptions: RetryOptions = { retries: 3, retryDelay: 1, fetchParams } + const expectedResponse = { lockId: "id1", acquired: true } + const $resp = (await mockJsonResponse(expectedResponse)).invoke(retryOptions, apiBody) + chai.expect(await $resp).deep.eq(expectedResponse) + }) + + it("invoke() should retry failing HTTP call", async () => { + const fetchParams = { endpoint: "http://foobar:8080/fail", options: {} } + const expectedResponse = { lockId: "id1", acquired: true } + const invokeStub = sinon.stub() + const errorResp = createHttpErrorWithJson(500, "Internal server error", { error: "Some serverside error" }) + invokeStub.onFirstCall().returns(errorResp) + invokeStub.onSecondCall().returns(errorResp) + invokeStub.onThirdCall().returns(createJsonResponseTemplate(expectedResponse)) + + const retryOptions: RetryOptions = { retries: 3, retryDelay: 1, fetchParams } + const $resp = (await mockHttpCall(invokeStub)).invoke(retryOptions, { some: "input-payload" }) + chai.expect(await $resp).deep.eq(expectedResponse) + chai.expect(invokeStub.callCount).eq(3) + }) + + it("invoke() should giveup failing HTTP call", async () => { + const fetchParams = { endpoint: "http://foobar:8080/fail", options: {} } + const invokeStub = sinon.stub() + const errorResp = createHttpErrorWithJson(500, "Internal server error", { error: "Some serverside error" }) + invokeStub.onFirstCall().returns(errorResp) + invokeStub.onSecondCall().returns(errorResp) + invokeStub.onThirdCall().returns(errorResp) + const retryOptions: RetryOptions = { retries: 3, retryDelay: 1, fetchParams } + const $resp = (await mockHttpCall(invokeStub)).invoke(retryOptions, { some: "input-payload" }) + + let wasError: boolean = false + try { + await $resp + } catch (error) { + wasError = true + chai.expect(error.cause).not.undefined + chai.expect(error.cause).property("type", "HttpError") + chai.expect(error.cause).property("status", 500) + chai.expect(error.cause).property("text", "Internal server error") + chai.expect(error.cause.responseData).deep.eq({ error: 'Some serverside error' }) + } + if (!wasError) chai.expect.fail("invoke() is epexcted to thorw an error") + chai.expect(invokeStub.callCount).eq(3) + }) + +}) \ No newline at end of file diff --git a/k8s-deployer/test/locks/lock-manager-api-client.spec.ts b/k8s-deployer/test/locks/lock-manager-api-client.spec.ts deleted file mode 100644 index 4e0d6cd..0000000 --- a/k8s-deployer/test/locks/lock-manager-api-client.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import esmock from "esmock" - -import { describe, it } from "mocha" -import { assert } from "chai" - -import { logger } from "../../src/logger.js" -import { RetryOptions } from "../../src/locks/lock-manager-api-client.js" - -describe("lock-api-fetch", async () => { - let mockedLockManagerApi = await esmock("../../src/locks/lock-manager-api-client.js", { - 'node-fetch': { - default: () => { - return { - json: () => { - return { lockId: "id1", acquired: true } - } - } - } - } - }) - - let esmockedLockFailFetch = await esmock("../../src/locks/lock-manager-api-client.js", { - 'node-fetch': { - default: () => { - throw new Error("fetch error") - } - } - }) - - it("should test retryFetch", async () => { - let fetchParams = { endpoint: "http://foobar:8080", options: {} } - let apiBody = { lockId: "id1", owner: "owner1" } - let retryOptions: RetryOptions = { retries: 3, retryDelay: 1, fetchParams } - let resp = await mockedLockManagerApi.invoke(retryOptions, apiBody) - assert.deepEqual(resp, { lockId: "id1", acquired: true }) - }) - - it("should retry failed fetch", async () => { - let baseUrl = "http://localhost:60001" - let fetchParams = { - endpoint: `${baseUrl}/locks/acquire`, - options: { - method: "POST", - headers: {"Content-Type": "application/json"}, - }, - } - let apiBody = { lockId: "id1", owner: "owner1" } - let retryOptions: RetryOptions = { retries: 3, retryDelay: 1, fetchParams } - try { - let _resp = await mockedLockManagerApi.invoke(retryOptions, apiBody) - } catch (error) { - logger.info("*****************",error) - assert.equal(error, `Error: Failed to fetch ${ fetchParams.endpoint } after ${ retryOptions.retries } retries`) - } - }) -}) \ No newline at end of file diff --git a/k8s-deployer/test/locks/lock-manager.spec.ts b/k8s-deployer/test/locks/lock-manager.spec.ts index b0403df..7d7ba71 100644 --- a/k8s-deployer/test/locks/lock-manager.spec.ts +++ b/k8s-deployer/test/locks/lock-manager.spec.ts @@ -9,7 +9,7 @@ describe("LockManager", () => { it("should acquire and release lock", async () => { const lockId = "id1" const LockManagerModule = await esmock("../../src/locks/lock-manager.js", { - '../../src/locks/lock-manager-api-client.js': { + '../../src/locks/http-client.js': { invoke: ()=> ({ lockId, acquired: true }) } }) @@ -35,7 +35,7 @@ describe("LockManager", () => { .onCall(3).returns({ lockIds: [ 'id1' ] }) const LockManagerModule = await esmock("../../src/locks/lock-manager.js", { - "../../src/locks/lock-manager-api-client.js": apiClientStub + "../../src/locks/http-client.js": apiClientStub }) const lockManger = new LockManagerModule.LockManager("test-owner", "http://foobar:8080") @@ -59,7 +59,7 @@ describe("LockManager", () => { it("should throw error if lock is not acquired", async () => { const LockManagerModule = await esmock("../../src/locks/lock-manager.js", { - "../../src/locks/lock-manager-api-client.js": { + "../../src/locks/http-client.js": { invoke: ()=> { throw new Error("Failed to acquire lock") } } }) @@ -77,7 +77,7 @@ describe("LockManager", () => { const mockIdList = [ "id1", "id2" ] const lock = { ids: mockIdList, timeout: "1h" } const LockManagerModule = await esmock("../../src/locks/lock-manager.js", { - '../../src/locks/lock-manager-api-client.js': { + '../../src/locks/http-client.js': { invoke: ()=> (mockIdList) } }) @@ -90,7 +90,7 @@ describe("LockManager", () => { it("should throw error if lock is not released", async () => { const mockIdList = [ "id1", "id2" ] const LockManagerModule = await esmock("../../src/locks/lock-manager.js", { - '../../src/locks/lock-manager-api-client.js': { + '../../src/locks/http-client.js': { invoke: ()=> { throw new Error("Failed to release lock") } } }) diff --git a/lock-manager/TODO.md b/lock-manager/TODO.md deleted file mode 100644 index 521ea0e..0000000 --- a/lock-manager/TODO.md +++ /dev/null @@ -1 +0,0 @@ -1. Unit test to cover exhaustive case example more than one lock being acquired with a failure \ No newline at end of file diff --git a/lock-manager/package-lock.json b/lock-manager/package-lock.json index 470724e..ec6b868 100644 --- a/lock-manager/package-lock.json +++ b/lock-manager/package-lock.json @@ -12,7 +12,9 @@ "express": "^4.18.2", "pg": "^8.11.3", "pg-format": "^1.0.4", - "winston": "^3.11.0" + "swagger-ui-express": "^5.0.0", + "winston": "^3.11.0", + "yaml": "^2.3.4" }, "devDependencies": { "@types/chai": "^4.3.11", @@ -21,8 +23,10 @@ "@types/node": "^20.8.9", "@types/pg-format": "^1.0.5", "@types/sinon": "^17.0.2", + "@types/swagger-ui-express": "^4.1.6", "c8": "^8.0.1", "chai": "^4.3.10", + "copyfiles": "^2.4.1", "esmock": "^2.6.0", "mocha": "^10.2.0", "node-pg-migrate": "^6.2.2", @@ -280,6 +284,16 @@ "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", "dev": true }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", + "integrity": "sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -758,6 +772,58 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/copyfiles": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", + "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", + "dev": true, + "dependencies": { + "glob": "^7.0.5", + "minimatch": "^3.0.3", + "mkdirp": "^1.0.4", + "noms": "0.0.0", + "through2": "^2.0.1", + "untildify": "^4.0.0", + "yargs": "^16.1.0" + }, + "bin": { + "copyfiles": "copyfiles", + "copyup": "copyfiles" + } + }, + "node_modules/copyfiles/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/copyfiles/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1914,6 +1980,34 @@ "pg": ">=4.3.0 <9.0.0" } }, + "node_modules/noms": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", + "integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "~1.0.31" + } + }, + "node_modules/noms/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/noms/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2259,6 +2353,12 @@ "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==", "dev": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2708,6 +2808,25 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.11.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.11.3.tgz", + "integrity": "sha512-vQ+Pe73xt7vMVbX40L6nHu4sDmNCM6A+eMVJPGvKrifHQ4LO3smH0jCiiefKzsVl7OlOcVEnrZ9IFzYwElfMkA==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.0.tgz", + "integrity": "sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -2727,6 +2846,52 @@ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2803,6 +2968,15 @@ "node": ">= 0.8" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2939,6 +3113,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", diff --git a/lock-manager/package.json b/lock-manager/package.json index 4a18283..14d67b3 100644 --- a/lock-manager/package.json +++ b/lock-manager/package.json @@ -18,8 +18,10 @@ "@types/node": "^20.8.9", "@types/pg-format": "^1.0.5", "@types/sinon": "^17.0.2", + "@types/swagger-ui-express": "^4.1.6", "c8": "^8.0.1", "chai": "^4.3.10", + "copyfiles": "^2.4.1", "esmock": "^2.6.0", "mocha": "^10.2.0", "node-pg-migrate": "^6.2.2", @@ -29,24 +31,27 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && npx tsc", + "build.copy-resources": "copyfiles -u 1 src/**/*.yml dist/", + "build": "npm run clean && npx tsc && npm run build.copy-resources", "test": "npm run build && c8 --reporter=html --reporter=text mocha --timeout 10000 --recursive dist/test/ --loader=esmock", "watch": "npx tsc --watch", - "dev.start": "set -o allexport; source .env ; set +o allexport;npm run build && npm run start", + "dev.start": "set -o allexport; source .env ; set +o allexport; npm run build && npm run start", "dev.build-image": "source .env; docker build -t $REGISTRY_URL/$SERVICE_NAME:$IMAGE_TAG .", "dev.k8-deploy": "./deployment/pit/deploy.sh", "start": "node dist/index.js", "migrate": "node-pg-migrate", - "migrate:up": "node-pg-migrate up ", - "migrate:down": "node-pg-migrate down", - "migrate:redo": "node-pg-migrate redo", - "migrate:create": "node-pg-migrate create", + "migrate:up": "npm run migrate up", + "migrate:down": "npm run migrate down", + "migrate:redo": "npm run migrate redo", + "migrate:create": "npm run migrate create", "migrate_and_start": "npm run migrate:up && npm run start" }, "dependencies": { "express": "^4.18.2", "pg": "^8.11.3", "pg-format": "^1.0.4", - "winston": "^3.11.0" + "swagger-ui-express": "^5.0.0", + "winston": "^3.11.0", + "yaml": "^2.3.4" } } diff --git a/lock-manager/src/api-routes.ts b/lock-manager/src/api-routes.ts index 67883e4..5690ec7 100644 --- a/lock-manager/src/api-routes.ts +++ b/lock-manager/src/api-routes.ts @@ -1,7 +1,8 @@ -import {Express, Request, Response} from "express" +import { Express, Request, Response } from "express" import LockFactory from "./lock-operations.js" -import {Db, LockAcquireObject, LockKeepAlive, ReleaseLocks} from "./db/db.js" -import {logger} from "./logger.js" +import { Db } from "./db/db.js" +import { logger } from "./logger.js" +import { AcquireRequest, AcquireResponse, KeepAliveRequest, KeepAliveResponse, ReleaseRequest, ReleaseResponse } from "./web-api/v1/schema-v1.js" export class ApiRoutes { private operations = LockFactory.instantiate() @@ -25,15 +26,15 @@ export class ApiRoutes { } private async acquire(req: Request, res: Response) { - let locks = req.body as LockAcquireObject + let locks = req.body as AcquireRequest try { this.checkEmptyReqBody(locks, 'acquire') this.checkEmptyString(locks.owner, 'owner') - this.checkEmptyString(locks?.lockId, 'lockId') + this.checkEmptyString(locks.lockId, 'lockId') this.validateExpiryTime(locks.expiryInSec) let keysSaved = await this.operations.acquire(locks, this.db) - res.status(200).send(keysSaved) + res.status(200).send(keysSaved as AcquireResponse) } catch (error) { logger.error("ApiRoutes.acquire():error %s", error) if (error.message.includes("duplicate key value violates unique constraint")) { @@ -45,14 +46,13 @@ export class ApiRoutes { } private async keepAlive(req: Request, res: Response) { - let keepAlive = req.body as LockKeepAlive + let keepAlive = req.body as KeepAliveRequest try { this.checkEmptyReqBody(keepAlive, 'keepAlive') this.validateLockIds(keepAlive.lockIds) - this.validateExpiryTime(keepAlive.expiryInSec) this.checkEmptyString(keepAlive.owner, 'owner') let keysSaved = await this.operations.keepAlive(keepAlive, this.db) - res.status(200).send(keysSaved) + res.status(200).send(keysSaved as KeepAliveResponse) } catch (error) { logger.error("ApiRoutes.keepAlive() %s", error) res.status(400).send({ error: error.message }) @@ -60,13 +60,13 @@ export class ApiRoutes { } private async release(req: Request, res: Response) { - let locksRelease = req.body as ReleaseLocks + let locksRelease = req.body as ReleaseRequest try { this.checkEmptyReqBody(locksRelease, 'release') this.validateLockIds(locksRelease.lockIds) this.checkEmptyString(locksRelease?.owner, 'owner') let keyRemoved = await this.operations.release(locksRelease, this.db) - res.status(200).send(keyRemoved) + res.status(200).send(keyRemoved as ReleaseResponse) } catch (error) { logger.error("ApiRoutes.release() %s", error) res.status(400).send({ error: error.message }) @@ -81,13 +81,13 @@ export class ApiRoutes { } } - private checkEmptyString(str: String, field: String) { + private checkEmptyString(str: string, field: string) { if (str === undefined || str === null || str === "" || typeof str !== "string") { throw new Error(`${ field } should be a non empty string field in request body`) } } - private validateLockIds(lockIds: Array) { + private validateLockIds(lockIds: Array) { if (!Array.isArray(lockIds) || lockIds?.length === 0) { throw new Error("lockIds should be a non empty array in request body") } diff --git a/lock-manager/src/configuration.ts b/lock-manager/src/configuration.ts index f68ac12..ba1f941 100644 --- a/lock-manager/src/configuration.ts +++ b/lock-manager/src/configuration.ts @@ -2,7 +2,7 @@ import { logger } from "./logger.js" export let PG_POOL_CLIENT; -const getParam = (name: String, defaultValue: string | number): string | number => { +const getConfigParam = (name: String, defaultValue: string | number): string | number => { if (process.argv.length > 2) { for (let i = 2; i + 1 < process.argv.length; i++) { if (process.argv[i].toLowerCase() !== name) continue @@ -23,7 +23,7 @@ const getParam = (name: String, defaultValue: string | number): string | number const envValue = process.env[envName] logger.debug(envName,'-->',envValue); - + if (!envValue) return defaultValue if (typeof(defaultValue) == 'string') return envValue + "" @@ -36,4 +36,4 @@ const getParam = (name: String, defaultValue: string | number): string | number return numValue } -export { getParam } \ No newline at end of file +export { getConfigParam } \ No newline at end of file diff --git a/lock-manager/src/db/db.ts b/lock-manager/src/db/db.ts index f602e13..75f45d5 100644 --- a/lock-manager/src/db/db.ts +++ b/lock-manager/src/db/db.ts @@ -1,9 +1,9 @@ export interface DbConfig { - user: String - host: String - database: String - password: String + user: string + host: string + database: string + password: string port: number } @@ -22,43 +22,42 @@ export interface DbPool { export interface Db extends DbPool { release(): Promise execute( query: { - name?: String - text: String - values: any // TODO fix type(String | Date)[] + name?: string + text: string + values: any // TODO fix type(string | Date)[] }): Promise format_nd_execute( query: { - name?: String - text: String - values: any // TODO fix type(String | Date)[] + name?: string + text: string + values: any // TODO fix type(string | Date)[] }): Promise } export type LockAcquireObject = { - lockId: String - owner: String + lockId: string + owner: string expiryInSec?: number } export type LockMetadata = { - lockOwner: String - lockExpiry: Date - lockCreated: Date + lockOwner: string + lockExpiry: Date + lockCreated: Date } export type LockManagerResponse = { - lockId: String + lockId: string acquired: boolean lockExpiry?: Date } export type LockKeepAlive = { - lockIds: Array - owner: String - expiryInSec?: number + lockIds: Array + owner: string } export type ReleaseLocks = { - lockIds: Array - owner: String + lockIds: Array + owner: string } diff --git a/lock-manager/src/db/pg.ts b/lock-manager/src/db/pg.ts index 61720d1..7fa9ae1 100644 --- a/lock-manager/src/db/pg.ts +++ b/lock-manager/src/db/pg.ts @@ -1,19 +1,18 @@ import pg, { PoolConfig } from "pg" import {Db} from "./db.js" -import {getParam} from "../configuration.js" +import {getConfigParam} from "../configuration.js" import format from "pg-format" import { logger } from "../logger.js" let getPoolConfig = ()=>{ - let host = getParam("PGHOST", "localhost") as string - let port = getParam("PGPORT", 5432) as number + let host = getConfigParam("PGHOST", "localhost") as string + let port = getConfigParam("PGPORT", 5432) as number - let user = getParam("PGUSER", "") as string - let password = getParam("PGPASSWORD", "") as string - let database = getParam("PGDATABASE", "") as string - let poolSizeMax = getParam("PGMAXPOOLSIZE", 10) as number - let poolSizeMin = getParam("PGMINPOOLSIZE", 10) as number + let user = getConfigParam("PGUSER", "") as string + let password = getConfigParam("PGPASSWORD", "") as string + let database = getConfigParam("PGDATABASE", "") as string + let poolSizeMin = getConfigParam("PGMINPOOLSIZE", 10) as number const config:PoolConfig = { user, @@ -39,7 +38,7 @@ export class PostgresDb implements Db { throw new Error("Method not implemented.") } - + async connect(): Promise{ const client = new pg.Client(getPoolConfig()) @@ -58,7 +57,7 @@ export class PostgresDb implements Db { }): Promise { const start = Date.now() let result - let client + let client try { client = await this.pg_pool.connect() await client.query("BEGIN") @@ -73,7 +72,7 @@ export class PostgresDb implements Db { client?.release() } const in_duration = Date.now() - start - logger.debug("executed query %s in duration %s millis and rows returned %s", + logger.debug("executed query %s in duration %s millis and rows returned %s", query, in_duration, result.rowCount, @@ -83,10 +82,10 @@ export class PostgresDb implements Db { async format_nd_execute(query: { text: string - values: any + values: any }): Promise { const start = Date.now() - let client + let client let result let sql try { diff --git a/lock-manager/src/index.ts b/lock-manager/src/index.ts index 54e6032..cad347d 100644 --- a/lock-manager/src/index.ts +++ b/lock-manager/src/index.ts @@ -1,4 +1,8 @@ import express, { Express } from 'express' +import swaggerUi from 'swagger-ui-express' + +import * as fs from "fs" +import YAML from "yaml" import { logger } from "./logger.js" import * as ConfigReader from "./configuration.js" @@ -17,8 +21,16 @@ const main = async () => { app.use(jsonParser) const _apiRoutes = new ApiRoutes(app, db) - - const servicePort = ConfigReader.getParam("--container-port", DEFAULT_PORT) + const oasFilePath = new URL("web-api/v1/open-api-schema-v1.yml", import.meta.url).pathname + try { + await fs.promises.access(oasFilePath, fs.constants.R_OK) + } catch (e) { + throw new Error(`There is no OAS schema file or it is not readable. File: "${ oasFilePath }"`, { cause: e }) + } + const oasSchema = YAML.parse(fs.readFileSync(oasFilePath, "utf8")) + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(oasSchema)); + + const servicePort = ConfigReader.getConfigParam("--container-port", DEFAULT_PORT) app.listen(servicePort, () => { logger.info("HTTP server is running at http://localhost:%d", servicePort) diff --git a/lock-manager/src/lock-operations.ts b/lock-manager/src/lock-operations.ts index a1af369..cd3c448 100644 --- a/lock-manager/src/lock-operations.ts +++ b/lock-manager/src/lock-operations.ts @@ -6,13 +6,13 @@ import { LockMetadata, ReleaseLocks, } from "./db/db.js" -import {getParam} from "./configuration.js" +import { getConfigParam } from "./configuration.js" import {logger} from "./logger.js" export interface Storage { acquire(lock: LockAcquireObject, db: Db): Promise - keepAlive(locks: LockKeepAlive, db: Db): Promise> - release(releaseLocks: ReleaseLocks, db: Db): Promise> + keepAlive(locks: LockKeepAlive, db: Db): Promise> + release(releaseLocks: ReleaseLocks, db: Db): Promise> } class LockFactory { @@ -22,44 +22,48 @@ class LockFactory { } class DatabaseStorage implements Storage { + readonly renewByInSec: number + constructor() { + this.renewByInSec = getConfigParam("RENEW_BY_IN_SEC", 60) as number + } + async acquire(lock: LockAcquireObject, db: Db): Promise { - let { expiryInSec = 10 } = lock // start only with keepAlive + const { expiryInSec = 10 } = lock const currentTime = Date.now() - const expirationTime = new Date(currentTime + expiryInSec * 1000) // Add seconds to current time - logger.info("acquire(): acquire lock with args lock %s currentTime %s expiryInSec %s expirationTime %s", lock, currentTime, expiryInSec, expirationTime) - let lockMetadata: LockMetadata = { + const expirationTime = new Date(currentTime + expiryInSec * 1000) + logger.info("acquire(): acquire lock with args lock %s currentTime %s expiryInSec %s expirationTime %s", lock, new Date(currentTime), expiryInSec, expirationTime) + const lockMetadata: LockMetadata = { lockOwner: lock.owner, lockExpiry: expirationTime, lockCreated: new Date(currentTime), } - let query = { + const query = { name: "insert-lock", text: `INSERT INTO locks (lock_id, lock_metadata) VALUES ($1, $2) - ON CONFLICT (lock_id) DO UPDATE SET lock_metadata = $2 WHERE locks.lock_id = $1 AND locks.lock_metadata ->> 'lockExpiry' < $3 + ON CONFLICT (lock_id) DO UPDATE SET lock_metadata = $2 WHERE locks.lock_id = $1 AND (locks.lock_metadata->>'lockExpiry')::timestamp with time zone < ($3)::timestamp with time zone RETURNING lock_id`, - values: [ lock.lockId, JSON.stringify(lockMetadata), new Date() ], + values: [ lock.lockId, JSON.stringify(lockMetadata), new Date(currentTime) ], } const result = await db.execute(query) if (result?.rows.length > 0) { - let lock_id = result?.rows[0].lock_id - let acquired = !!lock_id + const lock_id = result?.rows[0].lock_id + const acquired = !!lock_id return { lockId: lock_id, acquired, lockExpiry: expirationTime } } else { return { lockId: lock.lockId, acquired: false } } } - async keepAlive(keepAlive: LockKeepAlive, db: Db): Promise> { + async keepAlive(keepAlive: LockKeepAlive, db: Db): Promise> { logger.debug("keepAlive lock %s", keepAlive) - let renewByInSec = getParam("RENEW_BY_IN_SEC", 60) as number - let expiryAt = new Date(Date.now() + renewByInSec * 1000).toISOString() + let expiryAt = new Date(Date.now() + this.renewByInSec * 1000).toISOString() const query = { name: "update-key", text: `UPDATE locks SET lock_metadata = jsonb_set(lock_metadata, '{lockExpiry}', $1) - WHERE lock_id = ANY($2) AND lock_metadata ->> 'lockOwner' = $3 RETURNING *`, + WHERE lock_id = ANY($2) AND lock_metadata->>'lockOwner' = $3 RETURNING *`, values: [ `"${ expiryAt }"`, keepAlive.lockIds, keepAlive.owner ], } @@ -73,21 +77,20 @@ class DatabaseStorage implements Storage { } } - async release(releaseReq: ReleaseLocks, db: Db): Promise> { + async release(releaseReq: ReleaseLocks, db: Db): Promise> { logger.debug("release keys: %s", JSON.stringify(releaseReq)) - let { lockIds: keys, owner } = releaseReq const query = { name: "delete-key", text: `DELETE FROM locks WHERE lock_id = ANY ($1) AND EXISTS (SELECT lock_id FROM locks WHERE lock_id = ANY ($1) ) AND lock_metadata ->> 'lockOwner' = $2 RETURNING lock_id`, - values: [ keys, owner ], + values: [ releaseReq.lockIds, releaseReq.owner ], } const result = await db.execute(query) let unlockedKeys = result?.rows?.map(({ lock_id }) => lock_id) if (unlockedKeys?.length === 0) { - throw new Error("release(): No valid lock and owner combination found in database to delete") + throw new Error("No valid lock and owner combination found in database to delete") } logger.debug("release lock ids %s ", unlockedKeys) return unlockedKeys diff --git a/lock-manager/src/web-api/v1/open-api-schema-v1.yml b/lock-manager/src/web-api/v1/open-api-schema-v1.yml new file mode 100644 index 0000000..05e908f --- /dev/null +++ b/lock-manager/src/web-api/v1/open-api-schema-v1.yml @@ -0,0 +1,158 @@ +openapi: "3.1.0" +info: + title: PIT Lock Manager API + version: "1.0" +components: + headers: + "Content-Type": + required: true + schema: + type: "string" + schemas: + + AcquireRequest: + type: object + properties: + lockId: + type: string + owner: + type: string + expiryInSec: + type: integer + required: + - lockId + - owner + + LockAcquiredResponse: + type: object + properties: + lockId: + type: string + acquired: + type: boolean + lockExpiry: + type: string + format: "date-time" + required: + - lockId + - acquired + - lockExpiry + + LockNotAcquiredResponse: + type: object + properties: + lockId: + type: string + acquired: + type: boolean + required: + - lockId + - acquired + + ErrorResponse: + type: object + properties: + error: + type: string + required: + - error + + KeepAliveRequest: + type: object + properties: + lockIds: + type: array + items: + type: string + owner: + type: string + required: + - lockIds + - owner + + ReleaseRequest: + type: object + properties: + lockIds: + type: array + items: + type: string + owner: + type: string + required: + - lockIds + - owner + + SetOfLocks: + type: array + items: + type: string + +paths: + "/locks/acquire": + post: + summary: Obtains a lock on the given resource + operationId: acquire + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AcquireRequest" + responses: + "200": + content: + "application/json": + schema: + oneOf: + - $ref: "#/components/schemas/LockAcquiredResponse" + - $ref: "#/components/schemas/LockNotAcquiredResponse" + "4XX": + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + + "/locks/keep-alive": + post: + summary: Maintains the ownership of previously acquired lock + operationId: keepAlive + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/KeepAliveRequest" + responses: + "200": + content: + "application/json": + schema: + $ref: "#/components/schemas/SetOfLocks" + "4XX": + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + + "/locks/release": + post: + summary: Releases lock of the given resource + operationId: release + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReleaseRequest" + responses: + "200": + content: + "application/json": + schema: + $ref: "#/components/schemas/SetOfLocks" + "4XX": + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" \ No newline at end of file diff --git a/lock-manager/src/web-api/v1/schema-v1.ts b/lock-manager/src/web-api/v1/schema-v1.ts new file mode 100644 index 0000000..d3aba4a --- /dev/null +++ b/lock-manager/src/web-api/v1/schema-v1.ts @@ -0,0 +1,31 @@ +export type ErrorDetails = { + error: string +} + +export type AcquireRequest = { + lockId: string + owner: string + expiryInSec?: number +} + +export type AcquireResponse = { + lockId: string + acquired: boolean + lockExpiry: Date +} + +export type KeepAliveRequest = { + lockIds: Array + owner: string + expiryInSec?: number +} + +export type LockId = string +export type KeepAliveResponse = Array + +export type ReleaseRequest = { + lockIds: Array + owner: string +} + +export type ReleaseResponse = Array \ No newline at end of file