From 204efe8a8b52f902318ba9195d1c80554f6d752b Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Sun, 10 Mar 2024 12:22:38 +0100 Subject: [PATCH] feat(caprover): allow human to mark an app as successfully updated --- domain/events/ApplicationUpdateFinished.ts | 15 +++ domain/projections/ApplicationUpdates.test.ts | 99 +++++++++++++++++++ domain/projections/ApplicationUpdates.ts | 8 +- infrastructure/FileEventStore.ts | 10 +- joe.ts | 4 +- routes/applications.update.ts | 49 +++++++-- services/Human.test.ts | 20 ++++ services/Human.ts | 17 ++++ ui/Layout.ts | 5 + 9 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 domain/events/ApplicationUpdateFinished.ts create mode 100644 domain/projections/ApplicationUpdates.test.ts create mode 100644 services/Human.test.ts create mode 100644 services/Human.ts diff --git a/domain/events/ApplicationUpdateFinished.ts b/domain/events/ApplicationUpdateFinished.ts new file mode 100644 index 0000000..0e1068b --- /dev/null +++ b/domain/events/ApplicationUpdateFinished.ts @@ -0,0 +1,15 @@ +import DomainEvent from "../DomainEvent"; + +type ApplicationUpdateFinishedPayload = { + id: string; +}; + +export class ApplicationUpdateFinished + implements DomainEvent +{ + readonly type = "ApplicationUpdateFinished" as const; + constructor( + public readonly payload: ApplicationUpdateFinishedPayload, + public readonly createdAt: Date + ) {} +} diff --git a/domain/projections/ApplicationUpdates.test.ts b/domain/projections/ApplicationUpdates.test.ts new file mode 100644 index 0000000..1af13af --- /dev/null +++ b/domain/projections/ApplicationUpdates.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { TestApp } from "../testing/TestApp"; +import { ApplicationUpdateStarted } from "../events/ApplicationUpdateStarted"; +import { ApplicationUpdateFinished } from "../events/ApplicationUpdateFinished"; + +describe("ApplicationUpdates", () => { + describe("getPendingUpdates", () => { + it("should return an empty array when there are no pending updates", () => { + const app = new TestApp(); + expect(app.projections.ApplicationUpdates.getPendingUpdates()).toEqual( + [] + ); + }); + + it("should return all pending updates when several started", () => { + const app = new TestApp([ + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.0" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "blog", newVersion: "10.0.1" }, + new Date() + ), + ]); + + const updates = app.projections.ApplicationUpdates.getPendingUpdates(); + expect(updates).toEqual([ + { id: "mail", newVersion: "1.0.0" }, + { id: "blog", newVersion: "10.0.1" }, + ]); + }); + + it("should return all pending updates for an application while it isn't successfull", () => { + const app = new TestApp([ + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.0" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "blog", newVersion: "10.0.1" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.1" }, + new Date() + ), + ]); + + const updates = app.projections.ApplicationUpdates.getPendingUpdates(); + expect(updates).toEqual([ + { id: "mail", newVersion: "1.0.0" }, + { id: "blog", newVersion: "10.0.1" }, + { id: "mail", newVersion: "1.0.1" }, + ]); + }); + + it("should not return updates for applications after they were marked as successful", () => { + const app = new TestApp([ + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.0" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "blog", newVersion: "10.0.1" }, + new Date() + ), + new ApplicationUpdateFinished({ id: "mail" }, new Date()), + ]); + + const updates = app.projections.ApplicationUpdates.getPendingUpdates(); + expect(updates).toEqual([{ id: "blog", newVersion: "10.0.1" }]); + }); + + it("should consider the latest update for an application", () => { + const app = new TestApp([ + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.0" }, + new Date() + ), + new ApplicationUpdateStarted( + { id: "blog", newVersion: "4.2.0" }, + new Date() + ), + new ApplicationUpdateFinished({ id: "mail" }, new Date()), + new ApplicationUpdateStarted( + { id: "mail", newVersion: "1.0.1" }, + new Date() + ), + ]); + + const updates = app.projections.ApplicationUpdates.getPendingUpdates(); + expect(updates).toEqual([ + { id: "blog", newVersion: "4.2.0" }, + { id: "mail", newVersion: "1.0.1" }, + ]); + }); + }); +}); diff --git a/domain/projections/ApplicationUpdates.ts b/domain/projections/ApplicationUpdates.ts index 984b745..aaf7890 100644 --- a/domain/projections/ApplicationUpdates.ts +++ b/domain/projections/ApplicationUpdates.ts @@ -1,5 +1,7 @@ import DomainEvent from "../DomainEvent"; import DomainProjection from "../DomainProjection"; +import { ApplicationUpdateFinished } from "../events/ApplicationUpdateFinished"; +import { ApplicationUpdateStarted } from "../events/ApplicationUpdateStarted"; export type UpdateDefinition = { id: string; @@ -7,10 +9,14 @@ export type UpdateDefinition = { }; export default class ApplicationUpdates implements DomainProjection { - private readonly pendingUpdates: UpdateDefinition[] = []; + private pendingUpdates: UpdateDefinition[] = []; handle(event: DomainEvent): void { if (event.type === "ApplicationUpdateStarted") { this.pendingUpdates.push(event.payload); + } else if (event.type === "ApplicationUpdateFinished") { + this.pendingUpdates = this.pendingUpdates.filter((pendingUpdate) => { + return pendingUpdate.id !== event.payload.id; + }); } } diff --git a/infrastructure/FileEventStore.ts b/infrastructure/FileEventStore.ts index a251626..e24a9b6 100644 --- a/infrastructure/FileEventStore.ts +++ b/infrastructure/FileEventStore.ts @@ -3,6 +3,7 @@ import EventStore from "../domain/EventStore"; import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted"; import DomainEvent from "../domain/DomainEvent"; import DomainProjection from "../domain/DomainProjection"; +import { ApplicationUpdateFinished } from "../domain/events/ApplicationUpdateFinished"; export default class FileEventStore implements EventStore { private handlers: DomainProjection[] = []; @@ -55,8 +56,15 @@ export default class FileEventStore implements EventStore { }, new Date(event.createdAt) ); + case "ApplicationUpdateFinished": + return new ApplicationUpdateFinished( + { + id: event.payload.id, + }, + new Date(event.createdAt) + ); default: - throw new Error("Unknown event type" + event.type); + throw new Error("Unknown event type: " + event.type); } } } diff --git a/joe.ts b/joe.ts index 7842e77..5f11c24 100644 --- a/joe.ts +++ b/joe.ts @@ -3,6 +3,7 @@ import applications from "./routes/applications"; import applicationsUpdate from "./routes/applications.update"; import Layout, { html } from "./ui/Layout"; import DockerHub from "./services/DockerHub"; +import Human from "./services/Human"; import FileEventStore from "./infrastructure/FileEventStore"; import AppQueries from "./domain/AppQueries"; import AppProjections from "./domain/AppProjections"; @@ -25,6 +26,7 @@ const caprover = new Caprover( process.env.CAPTAIN_PASSWORD ); const dockerHub = new DockerHub(); +const human = new Human(eventStore); const server = Bun.serve({ port: 3000, @@ -42,7 +44,7 @@ const server = Bun.serve({ case "/applications": return applications(req, caprover, queries); case "/applications/update": - return applicationsUpdate(req, caprover, dockerHub, queries); + return applicationsUpdate(req, caprover, dockerHub, human, queries); default: return new Response("Not Found", { status: 404 }); } diff --git a/routes/applications.update.ts b/routes/applications.update.ts index d4e08b1..9b80386 100644 --- a/routes/applications.update.ts +++ b/routes/applications.update.ts @@ -2,6 +2,7 @@ import AppQueries from "../domain/AppQueries"; import { UpdateDefinition } from "../domain/projections/ApplicationUpdates"; import Caprover, { Application } from "../services/Caprover"; import DockerHub from "../services/DockerHub"; +import Human from "../services/Human"; import Layout, { html } from "../ui/Layout"; const UpdateForm = (application: Application, latestVersions: string[]) => { @@ -17,7 +18,7 @@ const UpdateForm = (application: Application, latestVersions: string[]) => { return html``; })} - + `; }; @@ -28,11 +29,15 @@ const FreeUpdateForm = (application: Application) => { Manual version - + `; }; -const PendingUpdates = (pendingUpdates: UpdateDefinition[], logs: string) => { +const PendingUpdates = ( + application: Application, + pendingUpdates: UpdateDefinition[], + logs: string +) => { if (pendingUpdates.length === 0) { return ""; } @@ -47,13 +52,19 @@ const PendingUpdates = (pendingUpdates: UpdateDefinition[], logs: string) => { .join("")} -
${logs}
+
${logs}
+
+ + +
`; }; @@ -66,7 +77,7 @@ const Page = ( return html`

Updating ${application.name}

- ${PendingUpdates(pendingUpdates, logs)} + ${PendingUpdates(application, pendingUpdates, logs)}
@@ -99,14 +110,34 @@ export default async ( req: Request, caprover: Caprover, dockerHub: DockerHub, + human: Human, queries: AppQueries ): Promise => { if (req.method === "POST") { const body = await req.formData(); - await caprover.updateApplication( - body.get("appName") as string, - body.get("version") as string - ); + switch (body.get("action")) { + case "update": + const appName = body.get("appName") as string; + await caprover.updateApplication( + appName, + body.get("version") as string + ); + + const url = new URL(req.url); + url.searchParams.set("name", appName); + return new Response(null, { + status: 301, + headers: { Location: url.toString() }, + }); + case "mark-as-updated": + human.markApplicationAsUpdated(body.get("appName") as string); + return new Response(null, { + status: 301, + headers: { Location: "/applications" }, + }); + default: + throw new Error(`Unsupported action ${body.get("action")}`); + } } const applications = await caprover.getApps(); diff --git a/services/Human.test.ts b/services/Human.test.ts new file mode 100644 index 0000000..bdd31b7 --- /dev/null +++ b/services/Human.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { TestApp } from "../domain/testing/TestApp"; +import Human from "./Human"; + +describe("Human", () => { + it("should mark the application as updated", () => { + const eventStore = new TestApp().eventStore; + const human = new Human(eventStore); + + const appName = "MyApp"; + human.markApplicationAsUpdated(appName); + + const events = eventStore.getAllEvents(); + expect(events).toBeArrayOfSize(1); + expect(events[0]).toMatchObject({ + type: "ApplicationUpdateFinished", + payload: { id: "MyApp" }, + }); + }); +}); diff --git a/services/Human.ts b/services/Human.ts new file mode 100644 index 0000000..27658fb --- /dev/null +++ b/services/Human.ts @@ -0,0 +1,17 @@ +import EventStore from "../domain/EventStore"; +import { ApplicationUpdateFinished } from "../domain/events/ApplicationUpdateFinished"; + +/** + * Actions done by a Human in the application + */ +class Human { + constructor(private eventStore: EventStore) {} + + markApplicationAsUpdated(appName: string) { + this.eventStore.append( + new ApplicationUpdateFinished({ id: appName }, new Date()) + ); + } +} + +export default Human; diff --git a/ui/Layout.ts b/ui/Layout.ts index 6fdb48e..c863fdd 100644 --- a/ui/Layout.ts +++ b/ui/Layout.ts @@ -6,6 +6,11 @@ const Layout = (content: string) => { Joe +