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 DomainEvent from "../DomainEvent";
|
||||||
import DomainProjection from "../DomainProjection";
|
import DomainProjection from "../DomainProjection";
|
||||||
|
import { ApplicationUpdateFinished } from "../events/ApplicationUpdateFinished";
|
||||||
|
import { ApplicationUpdateStarted } from "../events/ApplicationUpdateStarted";
|
||||||
|
|
||||||
export type UpdateDefinition = {
|
export type UpdateDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -7,10 +9,14 @@ export type UpdateDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default class ApplicationUpdates implements DomainProjection {
|
export default class ApplicationUpdates implements DomainProjection {
|
||||||
private readonly pendingUpdates: UpdateDefinition[] = [];
|
private pendingUpdates: UpdateDefinition[] = [];
|
||||||
handle(event: DomainEvent<any>): void {
|
handle(event: DomainEvent<any>): void {
|
||||||
if (event.type === "ApplicationUpdateStarted") {
|
if (event.type === "ApplicationUpdateStarted") {
|
||||||
this.pendingUpdates.push(event.payload);
|
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 { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted";
|
||||||
import DomainEvent from "../domain/DomainEvent";
|
import DomainEvent from "../domain/DomainEvent";
|
||||||
import DomainProjection from "../domain/DomainProjection";
|
import DomainProjection from "../domain/DomainProjection";
|
||||||
|
import { ApplicationUpdateFinished } from "../domain/events/ApplicationUpdateFinished";
|
||||||
|
|
||||||
export default class FileEventStore implements EventStore {
|
export default class FileEventStore implements EventStore {
|
||||||
private handlers: DomainProjection[] = [];
|
private handlers: DomainProjection[] = [];
|
||||||
@ -55,8 +56,15 @@ export default class FileEventStore implements EventStore {
|
|||||||
},
|
},
|
||||||
new Date(event.createdAt)
|
new Date(event.createdAt)
|
||||||
);
|
);
|
||||||
|
case "ApplicationUpdateFinished":
|
||||||
|
return new ApplicationUpdateFinished(
|
||||||
|
{
|
||||||
|
id: event.payload.id,
|
||||||
|
},
|
||||||
|
new Date(event.createdAt)
|
||||||
|
);
|
||||||
default:
|
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 applicationsUpdate from "./routes/applications.update";
|
||||||
import Layout, { html } from "./ui/Layout";
|
import Layout, { html } from "./ui/Layout";
|
||||||
import DockerHub from "./services/DockerHub";
|
import DockerHub from "./services/DockerHub";
|
||||||
|
import Human from "./services/Human";
|
||||||
import FileEventStore from "./infrastructure/FileEventStore";
|
import FileEventStore from "./infrastructure/FileEventStore";
|
||||||
import AppQueries from "./domain/AppQueries";
|
import AppQueries from "./domain/AppQueries";
|
||||||
import AppProjections from "./domain/AppProjections";
|
import AppProjections from "./domain/AppProjections";
|
||||||
@ -25,6 +26,7 @@ const caprover = new Caprover(
|
|||||||
process.env.CAPTAIN_PASSWORD
|
process.env.CAPTAIN_PASSWORD
|
||||||
);
|
);
|
||||||
const dockerHub = new DockerHub();
|
const dockerHub = new DockerHub();
|
||||||
|
const human = new Human(eventStore);
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@ -42,7 +44,7 @@ const server = Bun.serve({
|
|||||||
case "/applications":
|
case "/applications":
|
||||||
return applications(req, caprover, queries);
|
return applications(req, caprover, queries);
|
||||||
case "/applications/update":
|
case "/applications/update":
|
||||||
return applicationsUpdate(req, caprover, dockerHub, queries);
|
return applicationsUpdate(req, caprover, dockerHub, human, queries);
|
||||||
default:
|
default:
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import AppQueries from "../domain/AppQueries";
|
|||||||
import { UpdateDefinition } from "../domain/projections/ApplicationUpdates";
|
import { UpdateDefinition } from "../domain/projections/ApplicationUpdates";
|
||||||
import Caprover, { Application } from "../services/Caprover";
|
import Caprover, { Application } from "../services/Caprover";
|
||||||
import DockerHub from "../services/DockerHub";
|
import DockerHub from "../services/DockerHub";
|
||||||
|
import Human from "../services/Human";
|
||||||
import Layout, { html } from "../ui/Layout";
|
import Layout, { html } from "../ui/Layout";
|
||||||
|
|
||||||
const UpdateForm = (application: Application, latestVersions: string[]) => {
|
const UpdateForm = (application: Application, latestVersions: string[]) => {
|
||||||
@ -17,7 +18,7 @@ const UpdateForm = (application: Application, latestVersions: string[]) => {
|
|||||||
return html`<option value="${version}">${version}</option>`;
|
return html`<option value="${version}">${version}</option>`;
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Update</button>
|
<button type="submit" name="action" value="update">Update</button>
|
||||||
</form>`;
|
</form>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -28,11 +29,15 @@ const FreeUpdateForm = (application: Application) => {
|
|||||||
Manual version
|
Manual version
|
||||||
<input name="version" placeholder="hello-world:latest" type="text" />
|
<input name="version" placeholder="hello-world:latest" type="text" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Update</button>
|
<button type="submit" name="action" value="update">Update</button>
|
||||||
</form>`;
|
</form>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PendingUpdates = (pendingUpdates: UpdateDefinition[], logs: string) => {
|
const PendingUpdates = (
|
||||||
|
application: Application,
|
||||||
|
pendingUpdates: UpdateDefinition[],
|
||||||
|
logs: string
|
||||||
|
) => {
|
||||||
if (pendingUpdates.length === 0) {
|
if (pendingUpdates.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@ -47,13 +52,19 @@ const PendingUpdates = (pendingUpdates: UpdateDefinition[], logs: string) => {
|
|||||||
.join("")}
|
.join("")}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<pre>${logs}</pre>
|
<pre class="logs">${logs}</pre>
|
||||||
<button
|
<button
|
||||||
id="refresh"
|
id="refresh"
|
||||||
onclick="window.location.replace('#refresh'); window.location.reload();"
|
onclick="window.location.replace('#refresh'); window.location.reload();"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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>`;
|
</section>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,7 +77,7 @@ const Page = (
|
|||||||
return html`<div>
|
return html`<div>
|
||||||
<h1>Updating ${application.name}</h1>
|
<h1>Updating ${application.name}</h1>
|
||||||
|
|
||||||
${PendingUpdates(pendingUpdates, logs)}
|
${PendingUpdates(application, pendingUpdates, logs)}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<dl>
|
<dl>
|
||||||
@ -99,14 +110,34 @@ export default async (
|
|||||||
req: Request,
|
req: Request,
|
||||||
caprover: Caprover,
|
caprover: Caprover,
|
||||||
dockerHub: DockerHub,
|
dockerHub: DockerHub,
|
||||||
|
human: Human,
|
||||||
queries: AppQueries
|
queries: AppQueries
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const body = await req.formData();
|
const body = await req.formData();
|
||||||
await caprover.updateApplication(
|
switch (body.get("action")) {
|
||||||
body.get("appName") as string,
|
case "update":
|
||||||
body.get("version") as string
|
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();
|
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>
|
<title>Joe</title>
|
||||||
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
|
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
.logs {
|
||||||
|
font-size: 0.6em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
Loading…
Reference in New Issue
Block a user