diff --git a/.gitignore b/.gitignore index 0988099..3ee52a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .env.local +data/ diff --git a/Makefile b/Makefile index c9c39e6..a6ed5a2 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,7 @@ test: bun test tdd: - bun test --watch \ No newline at end of file + bun test --watch + +lint: + bun lint \ No newline at end of file diff --git a/domain/AppProjections.ts b/domain/AppProjections.ts new file mode 100644 index 0000000..55cc1b0 --- /dev/null +++ b/domain/AppProjections.ts @@ -0,0 +1,9 @@ +import ApplicationUpdates from "./projections/ApplicationUpdates"; + +export default class AppProjections { + public readonly ApplicationUpdates = new ApplicationUpdates(); + + getAll(): DomainProjection[] { + return [this.ApplicationUpdates]; + } +} diff --git a/domain/AppQueries.ts b/domain/AppQueries.ts new file mode 100644 index 0000000..32042f2 --- /dev/null +++ b/domain/AppQueries.ts @@ -0,0 +1,11 @@ +import AppProjections from "./AppProjections"; +import { UpdateDefinition } from "./projections/ApplicationUpdates"; + +export default class AppQueries { + constructor(private readonly projections: AppProjections) {} + + pendingApplicationUpdates(appName?: string): UpdateDefinition[] { + // TODO: Implement filtering by appName + return this.projections.ApplicationUpdates.getPendingUpdates(); + } +} diff --git a/domain/DomainEvent.ts b/domain/DomainEvent.ts new file mode 100644 index 0000000..708dfd2 --- /dev/null +++ b/domain/DomainEvent.ts @@ -0,0 +1,5 @@ +interface DomainEvent { + readonly type: string; + readonly createdAt: Date; + readonly payload: T; +} diff --git a/domain/DomainProjection.ts b/domain/DomainProjection.ts new file mode 100644 index 0000000..b37bb18 --- /dev/null +++ b/domain/DomainProjection.ts @@ -0,0 +1,3 @@ +interface DomainProjection { + handle(event: DomainEvent): void; +} diff --git a/domain/EventStore.ts b/domain/EventStore.ts new file mode 100644 index 0000000..6edfdb1 --- /dev/null +++ b/domain/EventStore.ts @@ -0,0 +1,5 @@ +export default interface EventStore { + append(event: DomainEvent): void; + subscribe(projection: DomainProjection): void; + replay(): Promise; +} diff --git a/domain/events/ApplicationUpdateStarted.ts b/domain/events/ApplicationUpdateStarted.ts new file mode 100644 index 0000000..b1ebf78 --- /dev/null +++ b/domain/events/ApplicationUpdateStarted.ts @@ -0,0 +1,14 @@ +type ApplicationUpdateStartedPayload = { + id: string; + newVersion: string; +}; + +export class ApplicationUpdateStarted + implements DomainEvent +{ + readonly type = "ApplicationUpdateStarted" as const; + constructor( + public readonly payload: ApplicationUpdateStartedPayload, + public readonly createdAt: Date + ) {} +} diff --git a/domain/projections/ApplicationUpdates.ts b/domain/projections/ApplicationUpdates.ts new file mode 100644 index 0000000..34bfb51 --- /dev/null +++ b/domain/projections/ApplicationUpdates.ts @@ -0,0 +1,18 @@ +export type UpdateDefinition = { + id: string; + newVersion: string; +}; + +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); + } + } + + getPendingUpdates(): UpdateDefinition[] { + return this.pendingUpdates; + } +} diff --git a/infrastructure/FileEventStore.ts b/infrastructure/FileEventStore.ts new file mode 100644 index 0000000..a5d0bbc --- /dev/null +++ b/infrastructure/FileEventStore.ts @@ -0,0 +1,60 @@ +import { appendFile } from "node:fs/promises"; +import EventStore from "../domain/EventStore"; +import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted"; + +export default class FileEventStore implements EventStore { + private handlers: DomainProjection[] = []; + constructor(private readonly filePath: string) {} + + append(event: DomainEvent): void { + appendFile(this.filePath, this.serialize(event) + "\n"); + this.emit(event); + } + + subscribe(projection: DomainProjection) { + this.handlers.push(projection); + } + + async replay() { + // TODO Improve this with streaming + console.log("Replaying events from", this.filePath); + const file = Bun.file(this.filePath); + const content = await file.text(); + const lines = content.split("\n"); + for (const line of lines) { + if (!line) { + continue; + } + console.log("Deserializing", line); + const event = this.deserialize(line); + this.emit(event); + } + console.log("Replaying done"); + } + + private emit(event: DomainEvent) { + for (const handler of this.handlers) { + handler.handle(event); + } + } + + private serialize(event: DomainEvent) { + return JSON.stringify(event); + } + + private deserialize(line: string) { + const event = JSON.parse(line); + switch (event.type) { + case "ApplicationUpdateStarted": + return new ApplicationUpdateStarted( + { + id: event.payload.id, + newVersion: event.payload.newVersion, + }, + new Date(event.createdAt) + ); + default: + throw new Error("Unknown event type" + event.type); + } + } +} diff --git a/joe.ts b/joe.ts index b4d4421..638fb28 100644 --- a/joe.ts +++ b/joe.ts @@ -3,14 +3,26 @@ import applications from "./routes/applications"; import applicationsUpdate from "./routes/applications.update"; import Layout, { html } from "./ui/Layout"; import DockerHub from "./services/DockerHub"; +import FileEventStore from "./infrastructure/FileEventStore"; +import AppQueries from "./domain/AppQueries"; +import AppProjections from "./domain/AppProjections"; -console.log("Hello Pierrot!"); +// Domain +const eventStore = new FileEventStore("./data/events.jsonl"); +const projections = new AppProjections(); +const queries = new AppQueries(projections); +projections.getAll().forEach((projection) => { + eventStore.subscribe(projection); +}); +await eventStore.replay(); + +// External services const caprover = new Caprover( + eventStore, process.env.CAPTAIN_DOMAIN, process.env.CAPTAIN_PASSWORD ); - const dockerHub = new DockerHub(); const server = Bun.serve({ @@ -29,7 +41,7 @@ const server = Bun.serve({ case "/applications": return applications(req, caprover); case "/applications/update": - return applicationsUpdate(req, caprover, dockerHub); + return applicationsUpdate(req, caprover, dockerHub, queries); default: return new Response("Not Found", { status: 404 }); } diff --git a/routes/applications.update.ts b/routes/applications.update.ts index 1367943..65a86ea 100644 --- a/routes/applications.update.ts +++ b/routes/applications.update.ts @@ -1,3 +1,5 @@ +import AppQueries from "../domain/AppQueries"; +import { UpdateDefinition } from "../domain/projections/ApplicationUpdates"; import Caprover, { Application } from "../services/Caprover"; import DockerHub from "../services/DockerHub"; import Layout, { html } from "../ui/Layout"; @@ -19,10 +21,33 @@ const UpdateForm = (application: Application, latestVersions: string[]) => { `; }; -const Page = (application: Application, latestVersions: string[]) => { +const PendingUpdates = (pendingUpdates: UpdateDefinition[]) => { + if (pendingUpdates.length === 0) { + return ""; + } + + return html`
+

Pending updates

+
    + ${pendingUpdates + .map((update) => { + return html`
  • ${update.newVersion}
  • `; + }) + .join("")} +
+
`; +}; + +const Page = ( + application: Application, + latestVersions: string[], + pendingUpdates: UpdateDefinition[] +) => { return html`

Updating ${application.name}

+ ${PendingUpdates(pendingUpdates)} +
Last deployment
${application.lastDeployedAt.toLocaleString("fr-FR")}
@@ -49,12 +74,15 @@ const Page = (application: Application, latestVersions: string[]) => { export default async ( req: Request, caprover: Caprover, - dockerHub: DockerHub + dockerHub: DockerHub, + queries: AppQueries ): Promise => { if (req.method === "POST") { const body = await req.formData(); - console.log("TODO Implement application update", [...body.entries()]); - throw new Error("Not implemented"); + caprover.updateApplication( + body.get("appName") as string, + body.get("version") as string + ); } const applications = await caprover.getApps(); @@ -76,7 +104,12 @@ export default async ( appToUpdate.dockerImage!.name ); - return new Response(Layout(Page(appToUpdate, latestVersions)), { - headers: { "Content-Type": "text/html" }, - }); + const pendingUpdates = queries.pendingApplicationUpdates(appToUpdate.name); + + return new Response( + Layout(Page(appToUpdate, latestVersions, pendingUpdates)), + { + headers: { "Content-Type": "text/html" }, + } + ); }; diff --git a/services/Caprover.ts b/services/Caprover.ts index e8bafd0..545b86d 100644 --- a/services/Caprover.ts +++ b/services/Caprover.ts @@ -1,3 +1,6 @@ +import EventStore from "../domain/EventStore"; +import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted"; + type TODO_TypeDefinition = any; class Application { @@ -52,12 +55,18 @@ class Application { class Caprover { private authToken: string = ""; private readonly apiUrl: string; + private readonly eventStore: EventStore; - constructor(readonly domain?: string, private readonly password?: string) { + constructor( + eventStore: EventStore, + readonly domain?: string, + private readonly password?: string + ) { if (!domain || !password) { throw new Error("Missing domain or password"); } this.apiUrl = `https://${domain}/api/v2`; + this.eventStore = eventStore; } async getApps(): Promise { @@ -70,6 +79,16 @@ class Caprover { ); } + async updateApplication(appName: string, version: string) { + console.log("TODO: Implement remote call", appName, version); // TODO + this.eventStore.append( + new ApplicationUpdateStarted( + { id: appName, newVersion: version }, + new Date() + ) + ); + } + private async authenticate() { if (this.authToken) { return; diff --git a/tsconfig.json b/tsconfig.json index 5b26ed3..8102931 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "es2017", - "module": "commonjs", + "target": "ES2022", + "module": "ES2022", "forceConsistentCasingInFileNames": true, "strict": true, "types": ["bun-types"]