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