feat(caprover): allow human to mark an app as successfully updated

This commit is contained in:
Pierre Martin 2024-03-10 12:22:38 +01:00
parent 5c599842f6
commit 204efe8a8b
9 changed files with 215 additions and 12 deletions

View File

@ -0,0 +1,15 @@
import DomainEvent from "../DomainEvent";
type ApplicationUpdateFinishedPayload = {
id: string;
};
export class ApplicationUpdateFinished
implements DomainEvent<ApplicationUpdateFinishedPayload>
{
readonly type = "ApplicationUpdateFinished" as const;
constructor(
public readonly payload: ApplicationUpdateFinishedPayload,
public readonly createdAt: Date
) {}
}

View File

@ -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" },
]);
});
});
});

View File

@ -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<any>): 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;
});
}
}

View File

@ -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);
}
}
}

4
joe.ts
View File

@ -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 });
}

View File

@ -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`<option value="${version}">${version}</option>`;
})}
</select>
<button type="submit">Update</button>
<button type="submit" name="action" value="update">Update</button>
</form>`;
};
@ -28,11 +29,15 @@ const FreeUpdateForm = (application: Application) => {
Manual version
<input name="version" placeholder="hello-world:latest" type="text" />
</label>
<button type="submit">Update</button>
<button type="submit" name="action" value="update">Update</button>
</form>`;
};
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("")}
</ul>
<pre>${logs}</pre>
<pre class="logs">${logs}</pre>
<button
id="refresh"
onclick="window.location.replace('#refresh'); window.location.reload();"
>
Refresh
</button>
<form method="POST">
<input type="hidden" name="appName" value="${application.name}" />
<button type="submit" name="action" value="mark-as-updated">
Mark as successfully updated!
</button>
</form>
</section>`;
};
@ -66,7 +77,7 @@ const Page = (
return html`<div>
<h1>Updating ${application.name}</h1>
${PendingUpdates(pendingUpdates, logs)}
${PendingUpdates(application, pendingUpdates, logs)}
<section>
<dl>
@ -99,14 +110,34 @@ export default async (
req: Request,
caprover: Caprover,
dockerHub: DockerHub,
human: Human,
queries: AppQueries
): Promise<Response> => {
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();

20
services/Human.test.ts Normal file
View File

@ -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" },
});
});
});

17
services/Human.ts Normal file
View File

@ -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;

View File

@ -6,6 +6,11 @@ const Layout = (content: string) => {
<title>Joe</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
<meta charset="utf-8" />
<style>
.logs {
font-size: 0.6em;
}
</style>
</head>
<body>
<header>