From fd3783cc8d8b33c49102a2f8ddf63685b5bda3e2 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Sun, 10 Mar 2024 09:49:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(caprover):=C2=A0allow=20to=20trigger=20an?= =?UTF-8?q?=20application=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + added test coverage --- Makefile | 1 - bun.lockb | Bin 2699 -> 22951 bytes domain/AppQueries.test.ts | 43 +++++++ domain/AppQueries.ts | 7 +- domain/projections/ApplicationUpdates.ts | 1 - domain/testing/TestApp.ts | 44 +++++++ joe.ts | 2 +- package.json | 3 +- routes/applications.ts | 48 +++++++- routes/applications.update.ts | 7 +- services/Caprover.test.ts | 133 +++++++++++++-------- services/Caprover.ts | 38 ++++-- services/DockerHub.test.ts | 22 ++++ services/DockerHub.ts | 10 +- services/mocks/apps.fixtures.json | 145 +++++++++++++++++++++++ services/mocks/caproverServer.ts | 99 ++++++++++++++++ services/mocks/dockerHubServer.ts | 114 ++++++++++++++++++ tsconfig.json | 6 +- 18 files changed, 650 insertions(+), 73 deletions(-) create mode 100644 domain/AppQueries.test.ts create mode 100644 domain/testing/TestApp.ts create mode 100644 services/DockerHub.test.ts create mode 100644 services/mocks/apps.fixtures.json create mode 100644 services/mocks/caproverServer.ts create mode 100644 services/mocks/dockerHubServer.ts diff --git a/Makefile b/Makefile index a6ed5a2..66c58ea 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ dev: - @make tdd& bun --hot run joe.ts test: diff --git a/bun.lockb b/bun.lockb index b71b98bdbc53ce0dc36922325de0e5053f296fdd..f7b652c4d9ad89ae85150b6a62471c35dc6ca282 100755 GIT binary patch literal 22951 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 P39V6W(Gto3zW@IPY|$;p delta 613 zcmZ3!nXy}Rf}Uo)fqiwzyxONHPq`hOw{{NK6RVS5^&779{M0MHqS%^o^EW307_dzY zmuJdjo9xIUq5~9ahyaT60BL_94dS~1X>KHOuqGfeS%F1?gEQae*sH)qG7$`uecUBvaBBrB*x)9?RmK4^sf=UtB6kO_1yJs1&dG1w z6_}Gs^CnLWQ=DuOq0XC^pOWfOQdy8%tY2O{xjW2l^6ZF`$wvdEC)Y&CvzzIe>XoEd JO}?l-0|2SAfH437 diff --git a/domain/AppQueries.test.ts b/domain/AppQueries.test.ts new file mode 100644 index 0000000..053ad0b --- /dev/null +++ b/domain/AppQueries.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { ApplicationUpdateStarted } from "./events/ApplicationUpdateStarted"; +import { TestApp } from "./testing/TestApp"; + +describe("AppQueries", () => { + describe("pendingApplicationUpdates", () => { + let app: TestApp; + beforeEach(() => { + app = new TestApp([ + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.0" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "blog", newVersion: "10.0.1" }, + new Date() + ), + ]); + }); + + it("should return an empty array when there are no pending updates", () => { + const app = new TestApp(); + expect(app.queries.pendingApplicationUpdates()).toEqual([]); + }); + + it("should return all pending updates when no appName is provided", () => { + expect(app.queries.pendingApplicationUpdates()).toEqual([ + { id: "mail", newVersion: "1.0.0" }, + { id: "blog", newVersion: "10.0.1" }, + ]); + }); + + it("should return all pending updates for the provided application", () => { + expect(app.queries.pendingApplicationUpdates("mail")).toEqual([ + { id: "mail", newVersion: "1.0.0" }, + ]); + }); + + it("should return an empty array when there are no pending updates for the provided appName", () => { + expect(app.queries.pendingApplicationUpdates("unknown")).toEqual([]); + }); + }); +}); diff --git a/domain/AppQueries.ts b/domain/AppQueries.ts index 32042f2..86da569 100644 --- a/domain/AppQueries.ts +++ b/domain/AppQueries.ts @@ -5,7 +5,10 @@ export default class AppQueries { constructor(private readonly projections: AppProjections) {} pendingApplicationUpdates(appName?: string): UpdateDefinition[] { - // TODO: Implement filtering by appName - return this.projections.ApplicationUpdates.getPendingUpdates(); + const updates = this.projections.ApplicationUpdates.getPendingUpdates(); + if (!appName) { + return updates; + } + return updates.filter((update) => update.id === appName); } } diff --git a/domain/projections/ApplicationUpdates.ts b/domain/projections/ApplicationUpdates.ts index 34bfb51..8ceda41 100644 --- a/domain/projections/ApplicationUpdates.ts +++ b/domain/projections/ApplicationUpdates.ts @@ -7,7 +7,6 @@ export default class ApplicationUpdates implements DomainProjection { private readonly pendingUpdates: UpdateDefinition[] = []; handle(event: DomainEvent): void { if (event.type === "ApplicationUpdateStarted") { - console.log("ApplicationUpdateStarted", event.payload); this.pendingUpdates.push(event.payload); } } diff --git a/domain/testing/TestApp.ts b/domain/testing/TestApp.ts new file mode 100644 index 0000000..6e6e8f9 --- /dev/null +++ b/domain/testing/TestApp.ts @@ -0,0 +1,44 @@ +import AppProjections from "../AppProjections"; +import AppQueries from "../AppQueries"; +import EventStore from "../EventStore"; + +export class TestApp { + readonly eventStore: InMemoryEventStore = new InMemoryEventStore(); + readonly projections: AppProjections = new AppProjections(); + readonly queries: AppQueries = new AppQueries(this.projections); + + constructor(history: DomainEvent[] = []) { + this.projections.getAll().forEach((projection) => { + this.eventStore.subscribe(projection); + }); + + for (const event of history) { + this.eventStore.append(event); + } + } +} +class InMemoryEventStore implements EventStore { + private handlers: DomainProjection[] = []; + private readonly events: DomainEvent[] = []; + + async append(event: DomainEvent): Promise { + this.events.push(event); + for (const handler of this.handlers) { + handler.handle(event); + } + } + + subscribe(projection: DomainProjection): void { + this.handlers.push(projection); + } + + async replay(): Promise { + throw new Error( + "Replay is not relevant for InMemoryEventStore. Use append instead." + ); + } + + getAllEvents(): DomainEvent[] { + return this.events; + } +} diff --git a/joe.ts b/joe.ts index 638fb28..2f39bc7 100644 --- a/joe.ts +++ b/joe.ts @@ -39,7 +39,7 @@ const server = Bun.serve({ { headers: { "Content-Type": "text/html" } } ); case "/applications": - return applications(req, caprover); + return applications(req, caprover, queries); case "/applications/update": return applicationsUpdate(req, caprover, dockerHub, queries); default: diff --git a/package.json b/package.json index 59ae404..f5b2d56 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "devDependencies": { - "bun-types": "^1.0.25" + "bun-types": "^1.0.25", + "msw": "latest" } } diff --git a/routes/applications.ts b/routes/applications.ts index d57af8a..2c622c8 100644 --- a/routes/applications.ts +++ b/routes/applications.ts @@ -1,3 +1,5 @@ +import AppQueries from "../domain/AppQueries"; +import { UpdateDefinition } from "../domain/projections/ApplicationUpdates"; import Caprover, { Application } from "../services/Caprover"; import Layout, { html } from "../ui/Layout"; @@ -26,13 +28,44 @@ const ApplicationOverview = (application: Application) => { Docker hub ` : ""} + + `; }; +const Pending = (pendingUpdates: UpdateDefinition[]) => { + if (pendingUpdates.length === 0) { + return ""; + } + + return html`
+

Pending updates (${pendingUpdates.length})

+
    + ${pendingUpdates + .map((update) => { + return html`
  • + ${update.id} -> + ${update.newVersion} +
  • `; + }) + .join("")} +
+
`; +}; + type Sort = { field: string; order: "asc" | "desc" }; -const Page = (applications: Application[], currentSort: Sort) => { +const Page = ( + applications: Application[], + currentSort: Sort, + pendingUpdates: UpdateDefinition[] +) => { const sortLink = (field: string, title: string) => { let url = `?sort=${field}`; let className = ""; @@ -50,6 +83,9 @@ const Page = (applications: Application[], currentSort: Sort) => { Update applications now! + ${Pending(pendingUpdates)} + +

All applications (${applications.length})

@@ -66,7 +102,11 @@ const Page = (applications: Application[], currentSort: Sort) => { `; }; -export default async (req: Request, caprover: Caprover): Promise => { +export default async ( + req: Request, + caprover: Caprover, + queries: AppQueries +): Promise => { const applications = await caprover.getApps(); const sort: Sort = { @@ -85,7 +125,9 @@ export default async (req: Request, caprover: Caprover): Promise => { applications.reverse(); } - return new Response(Layout(Page(applications, sort)), { + const pendingUpdates = queries.pendingApplicationUpdates(); + + return new Response(Layout(Page(applications, sort, pendingUpdates)), { headers: { "Content-Type": "text/html" }, }); }; diff --git a/routes/applications.update.ts b/routes/applications.update.ts index 65a86ea..1e159dd 100644 --- a/routes/applications.update.ts +++ b/routes/applications.update.ts @@ -79,7 +79,7 @@ export default async ( ): Promise => { if (req.method === "POST") { const body = await req.formData(); - caprover.updateApplication( + await caprover.updateApplication( body.get("appName") as string, body.get("version") as string ); @@ -87,12 +87,15 @@ export default async ( const applications = await caprover.getApps(); + const nameFilter = new URL(req.url).searchParams.get("name"); const appToUpdate = applications .filter((app) => { // can we help to update this app? return app.dockerImage; }) - .find((app) => app.isOlderThan(30)); + .find((app) => { + return nameFilter ? app.name === nameFilter : app.isOlderThan(30); + }); if (!appToUpdate) { return new Response(Layout(html`

No application to update 🎉

`), { diff --git a/services/Caprover.test.ts b/services/Caprover.test.ts index 28841da..51bdfce 100644 --- a/services/Caprover.test.ts +++ b/services/Caprover.test.ts @@ -1,58 +1,23 @@ -import { describe, expect, it } from "bun:test"; -import { Application } from "./Caprover"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import Caprover, { Application } from "./Caprover"; +import { TestApp } from "../domain/testing/TestApp"; +import server, { + CAPROVER_TEST_DOMAIN, + CAPROVER_TEST_PASSWORD, +} from "./mocks/caproverServer"; +import appsFixtures from "./mocks/apps.fixtures.json"; describe("Caprover", () => { describe("Application", () => { - const anAppDefinition = { - hasPersistentData: false, - description: "", - instanceCount: 1, - captainDefinitionRelativeFilePath: "./captain-definition", - networks: ["captain-overlay-network"], - envVars: [ - { - key: "ADMINER_PLUGINS", - value: "", - }, - { - key: "ADMINER_DESIGN", - value: "", - }, - ], - volumes: [], - ports: [], - versions: [ - { - version: 0, - timeStamp: "2020-08-02T01:25:07.232Z", - deployedImageName: "img-captain-adminer:0", - gitHash: "", - }, - { - version: 1, - timeStamp: "2021-03-19T10:04:54.823Z", - deployedImageName: "adminer:4.8.0", - gitHash: "", - }, - { - version: 2, - timeStamp: "2021-12-04T10:24:48.757Z", - deployedImageName: "adminer:4.8.1", - gitHash: "", - }, - ], - deployedVersion: 2, - notExposeAsWebApp: false, - customDomain: [], - hasDefaultSubDomainSsl: true, - forceSsl: true, - websocketSupport: false, - containerHttpPort: 8080, - preDeployFunction: "", - serviceUpdateOverride: "", - appName: "adminer", - isAppBuilding: false, - }; + const anAppDefinition = appsFixtures[0]; it("should create an application from definition", () => { const app = Application.createFromDefinition(anAppDefinition); @@ -103,4 +68,68 @@ describe("Caprover", () => { }); }); }); + + describe("Initialization", () => { + it("should throw an error when no domain is provided", () => { + expect(() => new Caprover(new TestApp().eventStore, "")).toThrowError( + "Missing domain or password" + ); + }); + it("should throw an error when no password is provided", () => { + expect( + () => new Caprover(new TestApp().eventStore, CAPROVER_TEST_DOMAIN) + ).toThrowError("Missing domain or password"); + }); + }); + + describe("API interactions", () => { + let app: TestApp, caprover: Caprover; + beforeAll(() => server.listen()); + beforeEach(() => { + app = new TestApp(); + caprover = new Caprover( + app.eventStore, + CAPROVER_TEST_DOMAIN, + CAPROVER_TEST_PASSWORD + ); + }); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + describe("getApps", () => { + it("should return the list of applications", async () => { + const apps = await caprover.getApps(); + expect(apps).toBeArrayOfSize(3); + expect(apps[0]).toBeInstanceOf(Application); + expect(apps[0].name).toBe("adminer"); + expect(apps[1].name).toBe("mysql"); + expect(apps[2].name).toBe("redis"); + }); + }); + + describe("updateApplication", () => { + it("should update the application with the provided version and emit an event upon success", async () => { + await caprover.updateApplication("adminer", "adminer:4.2.0"); + + const apps = await caprover.getApps(); + expect(apps[0].imageName).toBe("adminer:4.2.0"); + + const events = app.eventStore.getAllEvents(); + expect(events).toBeArrayOfSize(1); + expect(events[0]).toMatchObject({ + type: "ApplicationUpdateStarted", + payload: { id: "adminer", newVersion: "adminer:4.2.0" }, + }); + }); + + it("should throw an error when the application does not exist and not emit any event", async () => { + await expect( + caprover.updateApplication("unknown", "adminer:4.2.0") + ).rejects.toThrowError(/Failed to update application unknown/); + + const events = app.eventStore.getAllEvents(); + expect(events).toBeArrayOfSize(0); + }); + }); + }); }); diff --git a/services/Caprover.ts b/services/Caprover.ts index 545b86d..d44f06a 100644 --- a/services/Caprover.ts +++ b/services/Caprover.ts @@ -70,17 +70,33 @@ class Caprover { } async getApps(): Promise { - const res = await this.fetch("/user/apps/appDefinitions").then((res) => - res.json() - ); + const res = await this.fetch("/user/apps/appDefinitions"); return ( res.data?.appDefinitions?.map(Application.createFromDefinition) ?? [] ); } - async updateApplication(appName: string, version: string) { - console.log("TODO: Implement remote call", appName, version); // TODO + async updateApplication(appName: string, version: string): Promise { + console.debug("Caprover: Updating application", appName, "to", version); + const response = await this.fetch(`/user/apps/appData/${appName}`, { + method: "POST", + body: JSON.stringify({ + captainDefinitionContent: JSON.stringify({ + schemaVersion: 2, + imageName: version, + }), + gitHash: "", + }), + }); + + console.debug("update application response", response); + if (response.status !== 100) { + throw new Error( + `Failed to update application ${appName} to ${version}: ${response.description}.` + ); + } + this.eventStore.append( new ApplicationUpdateStarted( { id: appName, newVersion: version }, @@ -93,7 +109,6 @@ class Caprover { if (this.authToken) { return; } - console.debug("Trying to authenticate at", this.apiUrl); const authResponse = await fetch(`${this.apiUrl}/login`, { @@ -117,7 +132,7 @@ class Caprover { private async fetch(path: string, options?: RequestInit) { await this.authenticate(); - console.debug("-> Caprover Fetching", path); + console.debug("-> Caprover Fetching", options?.method, path); return fetch(`${this.apiUrl}${path}`, { ...options, headers: { @@ -126,6 +141,15 @@ class Caprover { "x-namespace": "captain", "x-captain-auth": this.authToken, }, + }).then((res) => { + console.debug( + "\t<- Caprover Response", + options?.method, + path, + res.status, + res.statusText + ); + return res.json(); }); } } diff --git a/services/DockerHub.test.ts b/services/DockerHub.test.ts new file mode 100644 index 0000000..c5fb02e --- /dev/null +++ b/services/DockerHub.test.ts @@ -0,0 +1,22 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import DockerHub from "./DockerHub"; +import server from "./mocks/dockerHubServer"; + +describe("DockerHub", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + describe("getLatestVersions", () => { + it("should return the latest versions of an image", async () => { + const dockerHub = new DockerHub(); + const versions = await dockerHub.getLatestVersions("vaultwarden/server"); + expect(versions).toEqual([ + "vaultwarden/server:1.30.5", + "vaultwarden/server:1.30.5-alpine", + "vaultwarden/server:latest", + "vaultwarden/server:latest-alpine", + ]); + }); + }); +}); diff --git a/services/DockerHub.ts b/services/DockerHub.ts index 36b229e..7a46266 100644 --- a/services/DockerHub.ts +++ b/services/DockerHub.ts @@ -26,7 +26,15 @@ class DockerHub { const latestTags = tags .filter((tag) => tag.digest === latestTag.digest) .map((tag) => tag.name); - return latestTags; + + // Example: "1.0.0-alpine" is a variant of "1.0.0" + const isVariant = (tag: Tag) => { + return latestTags.some((latestTag) => tag.name.includes(latestTag + "-")); + }; + return latestTags + .concat(tags.filter(isVariant).map((tag) => tag.name)) + .map((tag) => `${imageName}:${tag}`) + .sort(); }; private fetch = async (url: string): Promise => { diff --git a/services/mocks/apps.fixtures.json b/services/mocks/apps.fixtures.json new file mode 100644 index 0000000..f6979d7 --- /dev/null +++ b/services/mocks/apps.fixtures.json @@ -0,0 +1,145 @@ +[ + { + "hasPersistentData": false, + "description": "", + "instanceCount": 1, + "captainDefinitionRelativeFilePath": "./captain-definition", + "networks": ["captain-overlay-network"], + "envVars": [ + { + "key": "ADMINER_PLUGINS", + "value": "" + }, + { + "key": "ADMINER_DESIGN", + "value": "" + } + ], + "volumes": [], + "ports": [], + "versions": [ + { + "version": 0, + "timeStamp": "2020-08-02T01:25:07.232Z", + "deployedImageName": "img-captain-adminer:0", + "gitHash": "" + }, + { + "version": 1, + "timeStamp": "2021-03-19T10:04:54.823Z", + "deployedImageName": "adminer:4.8.0", + "gitHash": "" + }, + { + "version": 2, + "timeStamp": "2021-12-04T10:24:48.757Z", + "deployedImageName": "adminer:4.8.1", + "gitHash": "" + } + ], + "deployedVersion": 2, + "notExposeAsWebApp": false, + "customDomain": [], + "hasDefaultSubDomainSsl": true, + "forceSsl": true, + "websocketSupport": false, + "containerHttpPort": 8080, + "preDeployFunction": "", + "serviceUpdateOverride": "", + "appName": "adminer", + "isAppBuilding": false + }, + { + "hasPersistentData": false, + "description": "", + "instanceCount": 1, + "captainDefinitionRelativeFilePath": "./captain-definition", + "networks": ["captain-overlay-network"], + "envVars": [ + { + "key": "MYSQL_ROOT_PASSWORD", + "value": "root" + }, + { + "key": "MYSQL_DATABASE", + "value": "test" + }, + { + "key": "MYSQL_USER", + "value": "test" + }, + { + "key": "MYSQL_PASSWORD", + "value": "test" + } + ], + "volumes": [], + "ports": [], + "versions": [ + { + "version": 0, + "timeStamp": "2020-08-02T01:25:07.232Z", + "deployedImageName": "img-captain-mysql:0", + "gitHash": "" + }, + { + "version": 1, + "timeStamp": "2021-03-19T10:04:54.823Z", + "deployedImageName": "mysql:5.7", + "gitHash": "" + }, + { + "version": 2, + "timeStamp": "2021-12-04T10:24:48.757Z", + "deployedImageName": "mysql:8.0", + "gitHash": "" + } + ], + "deployedVersion": 2, + "notExposeAsWebApp": false, + "customDomain": [], + "hasDefaultSubDomainSsl": true, + "forceSsl": true, + "websocketSupport": false, + "containerHttpPort": 3306, + "preDeployFunction": "", + "serviceUpdateOverride": "", + "appName": "mysql", + "isAppBuilding": false + }, + { + "hasPersistentData": false, + "description": "", + "instanceCount": 1, + "captainDefinitionRelativeFilePath": "./captain-definition", + "networks": ["captain-overlay-network"], + "envVars": [], + "volumes": [], + "ports": [], + "versions": [ + { + "version": 0, + "timeStamp": "2020-08-02T01:25:07.232Z", + "deployedImageName": "img-captain-redis:0", + "gitHash": "" + }, + { + "version": 1, + "timeStamp": "2021-03-19T10:04:54.823Z", + "deployedImageName": "redis:6.2.3", + "gitHash": "" + } + ], + "deployedVersion": 1, + "notExposeAsWebApp": false, + "customDomain": [], + "hasDefaultSubDomainSsl": true, + "forceSsl": true, + "websocketSupport": false, + "containerHttpPort": 6379, + "preDeployFunction": "", + "serviceUpdateOverride": "", + "appName": "redis", + "isAppBuilding": false + } +] diff --git a/services/mocks/caproverServer.ts b/services/mocks/caproverServer.ts new file mode 100644 index 0000000..a914f19 --- /dev/null +++ b/services/mocks/caproverServer.ts @@ -0,0 +1,99 @@ +import { setupServer } from "msw/node"; +import { http, HttpResponse, HttpResponseResolver, PathParams } from "msw"; +import appsFixtures from "./apps.fixtures.json"; + +export const CAPROVER_TEST_DOMAIN = "caprover.test"; +export const CAPROVER_TEST_PASSWORD = "password"; +const TEST_TOKEN = "123"; +const BASE_URI = `https://${CAPROVER_TEST_DOMAIN}/api/v2`; + +const withAuth = (resolver: HttpResponseResolver): HttpResponseResolver => { + return (input) => { + const headers = input.request.headers; + if ( + headers.get("x-namespace") !== "captain" || + headers.get("x-captain-auth") !== TEST_TOKEN + ) { + return new HttpResponse("", { status: 401 }); + } + return resolver(input); + }; +}; + +// @see https://github.com/caprover/caprover-cli/blob/master/src/api/ApiManager.ts +// @see https://github.com/caprover/caprover/tree/master/src/routes +const handlers = [ + http.post( + `${BASE_URI}/login`, + async ({ request }) => { + const credentials = await request.json(); + if ( + credentials.password !== CAPROVER_TEST_PASSWORD || + request.headers.get("x-namespace") !== "captain" + ) { + return HttpResponse.json({ + status: 1106, + description: "Auth token corrupted", + data: {}, + }); + } + return HttpResponse.json({ data: { token: TEST_TOKEN } }); + } + ), + http.get( + `${BASE_URI}/user/apps/appDefinitions`, + withAuth(() => { + return HttpResponse.json({ + data: { + appDefinitions: appsFixtures, + }, + }); + }) + ), + http.post< + { name: string }, + { + captainDefinitionContent?: string; + tarballFile?: string; + gitHash?: string; + } + >(`${BASE_URI}/user/apps/appData/:name`, async ({ request, params }) => { + const body = await request.json(); + if (/* !body.tarballFile && */ !body.captainDefinitionContent) { + return HttpResponse.json({ + status: 1100, + description: + "Either tarballfile or captainDefinitionContent should be present.", + data: {}, + }); + } + + const app = appsFixtures.find((app) => app.appName === params.name); + if (!app) { + return HttpResponse.json({ + status: 1000, + description: `App (${params.name}) could not be found. Make sure that you have created the app.`, + data: {}, + }); + } + + const newVersion = JSON.parse(body.captainDefinitionContent).imageName; + app.deployedVersion = app.versions.length; + app.versions.push({ + version: app.versions.length, + deployedImageName: newVersion, + gitHash: body.gitHash ?? "", + timeStamp: new Date().toISOString(), + }); + + return HttpResponse.json({ + status: 100, + description: "Deploy is done", + data: {}, + }); + }), +]; + +const server = setupServer(...handlers); + +export default server; diff --git a/services/mocks/dockerHubServer.ts b/services/mocks/dockerHubServer.ts new file mode 100644 index 0000000..264d56d --- /dev/null +++ b/services/mocks/dockerHubServer.ts @@ -0,0 +1,114 @@ +import { setupServer } from "msw/node"; +import { http, HttpResponse, HttpResponseResolver, PathParams } from "msw"; + +const BASE_URI = "https://hub.docker.com/v2"; + +const handlers = [ + http.get<{ namespace: string; repository: string }>( + `${BASE_URI}/repositories/:namespace/:repository/tags`, + ({ params }) => { + const { namespace, repository } = params; + if (namespace !== "vaultwarden" || repository !== "server") { + return HttpResponse.json( + { + message: "httperror 404: object not found", + errinfo: { + namespace, + repository, + }, + }, + { status: 404 } + ); + } + return HttpResponse.json({ + count: 66, + next: "https://hub.docker.com/v2/repositories/vaultwarden/server/tags?ordering=last_updated&page=2&page_size=10", + previous: null, + results: [ + createImageDescription( + "1.30.5-alpine", + "sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5" + ), + createImageDescription( + "latest-alpine", + "sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5" + ), + createImageDescription( + "alpine", + "sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5" + ), + createImageDescription( + "1.30.5", + "sha256:edb8e2bab9cbca22e555638294db9b3657ffbb6e5d149a29d7ccdb243e3c71e0" + ), + createImageDescription( + "latest", + "sha256:edb8e2bab9cbca22e555638294db9b3657ffbb6e5d149a29d7ccdb243e3c71e0" + ), + createImageDescription( + "testing-alpine", + "sha256:af5021d1a4e5debd1dc16a2bef15993c07f93a0e3c6c4acfd1ffcdaaab71bd0d" + ), + createImageDescription( + "testing", + "sha256:293f0127bc2fe0c59b26fea0ec0b990049a65b4f6f0c9f961e345276aadca3fd" + ), + createImageDescription( + "1.30.4-alpine", + "sha256:743209ed6169e595f9fff2412619d6791002057e211f8725b779777e05066df4" + ), + createImageDescription( + "1.30.4", + "sha256:b906f840f02ea481861cd90a4eafb92752f45afa1927a29406a26256f56271ed" + ), + createImageDescription( + "1.30.3-alpine", + "sha256:153defd78a3ede850445d64d6fca283701d0c25978e513c61688cf63bd47a14a" + ), + ], + }); + } + ), +]; + +function createImageDescription(name: string, digest: string) { + return { + name, + digest, + + // fake data irrelevant for our use case + creator: 14287463, + id: 613523756, + images: [ + { + architecture: "amd64", + features: "", + variant: null, + digest: + "sha256:9f4c1ea3601e398656992f738af9f84faf7a6d68299b2deaf049580e5da0d37f", + os: "linux", + os_features: "", + os_version: null, + size: 31945148, + status: "active", + last_pulled: "2024-03-10T08:07:35.84822Z", + last_pushed: "2024-03-02T18:59:17.437196Z", + }, + ], + last_updated: "2024-03-02T18:59:28.977584Z", + last_updater: 14287463, + last_updater_username: "vaultwardenbot", + repository: 12325169, + full_size: 31945148, + v2: true, + tag_status: "active", + tag_last_pulled: "2024-03-10T08:07:35.84822Z", + tag_last_pushed: "2024-03-02T18:59:28.977584Z", + media_type: "application/vnd.oci.image.index.v1+json", + content_type: "image", + }; +} + +const server = setupServer(...handlers); + +export default server; diff --git a/tsconfig.json b/tsconfig.json index 8102931..1f0f047 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "module": "ES2022", + "module": "NodeNext", "forceConsistentCasingInFileNames": true, "strict": true, - "types": ["bun-types"] + "types": ["bun-types"], + "moduleResolution": "NodeNext", + "resolveJsonModule": true, } } \ No newline at end of file