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 b71b98b..f7b652c 100755 Binary files a/bun.lockb and b/bun.lockb differ 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