feat(caprover): allow human to mark an app as successfully updated
This commit is contained in:
parent
5c599842f6
commit
204efe8a8b
15
domain/events/ApplicationUpdateFinished.ts
Normal file
15
domain/events/ApplicationUpdateFinished.ts
Normal 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
|
||||
) {}
|
||||
}
|
99
domain/projections/ApplicationUpdates.test.ts
Normal file
99
domain/projections/ApplicationUpdates.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
4
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 });
|
||||
}
|
||||
|
@ -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
20
services/Human.test.ts
Normal 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
17
services/Human.ts
Normal 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;
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user