feat(caprover): allow to trigger an application update
+ added test coverage
This commit is contained in:
parent
df5bfe4e1a
commit
fd3783cc8d
1
Makefile
1
Makefile
@ -1,5 +1,4 @@
|
|||||||
dev:
|
dev:
|
||||||
@make tdd&
|
|
||||||
bun --hot run joe.ts
|
bun --hot run joe.ts
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
43
domain/AppQueries.test.ts
Normal file
43
domain/AppQueries.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { ApplicationUpdateStarted } from "./events/ApplicationUpdateStarted";
|
||||||
|
import { TestApp } from "./testing/TestApp";
|
||||||
|
|
||||||
|
describe("AppQueries", () => {
|
||||||
|
describe("pendingApplicationUpdates", () => {
|
||||||
|
let app: TestApp;
|
||||||
|
beforeEach(() => {
|
||||||
|
app = new TestApp([
|
||||||
|
new ApplicationUpdateStarted(
|
||||||
|
{ id: "mail", newVersion: "1.0.0" },
|
||||||
|
new Date()
|
||||||
|
),
|
||||||
|
new ApplicationUpdateStarted(
|
||||||
|
{ id: "blog", newVersion: "10.0.1" },
|
||||||
|
new Date()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an empty array when there are no pending updates", () => {
|
||||||
|
const app = new TestApp();
|
||||||
|
expect(app.queries.pendingApplicationUpdates()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all pending updates when no appName is provided", () => {
|
||||||
|
expect(app.queries.pendingApplicationUpdates()).toEqual([
|
||||||
|
{ id: "mail", newVersion: "1.0.0" },
|
||||||
|
{ id: "blog", newVersion: "10.0.1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all pending updates for the provided application", () => {
|
||||||
|
expect(app.queries.pendingApplicationUpdates("mail")).toEqual([
|
||||||
|
{ id: "mail", newVersion: "1.0.0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an empty array when there are no pending updates for the provided appName", () => {
|
||||||
|
expect(app.queries.pendingApplicationUpdates("unknown")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -5,7 +5,10 @@ export default class AppQueries {
|
|||||||
constructor(private readonly projections: AppProjections) {}
|
constructor(private readonly projections: AppProjections) {}
|
||||||
|
|
||||||
pendingApplicationUpdates(appName?: string): UpdateDefinition[] {
|
pendingApplicationUpdates(appName?: string): UpdateDefinition[] {
|
||||||
// TODO: Implement filtering by appName
|
const updates = this.projections.ApplicationUpdates.getPendingUpdates();
|
||||||
return this.projections.ApplicationUpdates.getPendingUpdates();
|
if (!appName) {
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
return updates.filter((update) => update.id === appName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ export default class ApplicationUpdates implements DomainProjection {
|
|||||||
private readonly pendingUpdates: UpdateDefinition[] = [];
|
private readonly pendingUpdates: UpdateDefinition[] = [];
|
||||||
handle(event: DomainEvent<any>): void {
|
handle(event: DomainEvent<any>): void {
|
||||||
if (event.type === "ApplicationUpdateStarted") {
|
if (event.type === "ApplicationUpdateStarted") {
|
||||||
console.log("ApplicationUpdateStarted", event.payload);
|
|
||||||
this.pendingUpdates.push(event.payload);
|
this.pendingUpdates.push(event.payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
44
domain/testing/TestApp.ts
Normal file
44
domain/testing/TestApp.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import AppProjections from "../AppProjections";
|
||||||
|
import AppQueries from "../AppQueries";
|
||||||
|
import EventStore from "../EventStore";
|
||||||
|
|
||||||
|
export class TestApp {
|
||||||
|
readonly eventStore: InMemoryEventStore = new InMemoryEventStore();
|
||||||
|
readonly projections: AppProjections = new AppProjections();
|
||||||
|
readonly queries: AppQueries = new AppQueries(this.projections);
|
||||||
|
|
||||||
|
constructor(history: DomainEvent<any>[] = []) {
|
||||||
|
this.projections.getAll().forEach((projection) => {
|
||||||
|
this.eventStore.subscribe(projection);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of history) {
|
||||||
|
this.eventStore.append(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class InMemoryEventStore implements EventStore {
|
||||||
|
private handlers: DomainProjection[] = [];
|
||||||
|
private readonly events: DomainEvent<any>[] = [];
|
||||||
|
|
||||||
|
async append(event: DomainEvent<any>): Promise<void> {
|
||||||
|
this.events.push(event);
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
handler.handle(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(projection: DomainProjection): void {
|
||||||
|
this.handlers.push(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
async replay(): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
"Replay is not relevant for InMemoryEventStore. Use append instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEvents(): DomainEvent<any>[] {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
}
|
2
joe.ts
2
joe.ts
@ -39,7 +39,7 @@ const server = Bun.serve({
|
|||||||
{ headers: { "Content-Type": "text/html" } }
|
{ headers: { "Content-Type": "text/html" } }
|
||||||
);
|
);
|
||||||
case "/applications":
|
case "/applications":
|
||||||
return applications(req, caprover);
|
return applications(req, caprover, queries);
|
||||||
case "/applications/update":
|
case "/applications/update":
|
||||||
return applicationsUpdate(req, caprover, dockerHub, queries);
|
return applicationsUpdate(req, caprover, dockerHub, queries);
|
||||||
default:
|
default:
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.0.25"
|
"bun-types": "^1.0.25",
|
||||||
|
"msw": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import AppQueries from "../domain/AppQueries";
|
||||||
|
import { UpdateDefinition } from "../domain/projections/ApplicationUpdates";
|
||||||
import Caprover, { Application } from "../services/Caprover";
|
import Caprover, { Application } from "../services/Caprover";
|
||||||
import Layout, { html } from "../ui/Layout";
|
import Layout, { html } from "../ui/Layout";
|
||||||
|
|
||||||
@ -26,13 +28,44 @@ const ApplicationOverview = (application: Application) => {
|
|||||||
<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/>
|
<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/>
|
||||||
</a>`
|
</a>`
|
||||||
: ""}
|
: ""}
|
||||||
|
<a href="/applications/update?name=${application.name}">
|
||||||
|
<img
|
||||||
|
src="https://s2.svgbox.net/materialui.svg?ic=update&color=000"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
title="Update"
|
||||||
|
/></a>
|
||||||
</td>
|
</td>
|
||||||
</tr> `;
|
</tr> `;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Pending = (pendingUpdates: UpdateDefinition[]) => {
|
||||||
|
if (pendingUpdates.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div>
|
||||||
|
<h2>Pending updates (${pendingUpdates.length})</h2>
|
||||||
|
<ul>
|
||||||
|
${pendingUpdates
|
||||||
|
.map((update) => {
|
||||||
|
return html`<li>
|
||||||
|
<a href="/applications/update?name=${update.id}">${update.id}</a> ->
|
||||||
|
${update.newVersion}
|
||||||
|
</li>`;
|
||||||
|
})
|
||||||
|
.join("")}
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
type Sort = { field: string; order: "asc" | "desc" };
|
type Sort = { field: string; order: "asc" | "desc" };
|
||||||
|
|
||||||
const Page = (applications: Application[], currentSort: Sort) => {
|
const Page = (
|
||||||
|
applications: Application[],
|
||||||
|
currentSort: Sort,
|
||||||
|
pendingUpdates: UpdateDefinition[]
|
||||||
|
) => {
|
||||||
const sortLink = (field: string, title: string) => {
|
const sortLink = (field: string, title: string) => {
|
||||||
let url = `?sort=${field}`;
|
let url = `?sort=${field}`;
|
||||||
let className = "";
|
let className = "";
|
||||||
@ -50,6 +83,9 @@ const Page = (applications: Application[], currentSort: Sort) => {
|
|||||||
|
|
||||||
<a href="/applications/update" class="button">Update applications now!</a>
|
<a href="/applications/update" class="button">Update applications now!</a>
|
||||||
|
|
||||||
|
${Pending(pendingUpdates)}
|
||||||
|
|
||||||
|
<h2>All applications (${applications.length})</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -66,7 +102,11 @@ const Page = (applications: Application[], currentSort: Sort) => {
|
|||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async (req: Request, caprover: Caprover): Promise<Response> => {
|
export default async (
|
||||||
|
req: Request,
|
||||||
|
caprover: Caprover,
|
||||||
|
queries: AppQueries
|
||||||
|
): Promise<Response> => {
|
||||||
const applications = await caprover.getApps();
|
const applications = await caprover.getApps();
|
||||||
|
|
||||||
const sort: Sort = {
|
const sort: Sort = {
|
||||||
@ -85,7 +125,9 @@ export default async (req: Request, caprover: Caprover): Promise<Response> => {
|
|||||||
applications.reverse();
|
applications.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(Layout(Page(applications, sort)), {
|
const pendingUpdates = queries.pendingApplicationUpdates();
|
||||||
|
|
||||||
|
return new Response(Layout(Page(applications, sort, pendingUpdates)), {
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -79,7 +79,7 @@ export default async (
|
|||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const body = await req.formData();
|
const body = await req.formData();
|
||||||
caprover.updateApplication(
|
await caprover.updateApplication(
|
||||||
body.get("appName") as string,
|
body.get("appName") as string,
|
||||||
body.get("version") as string
|
body.get("version") as string
|
||||||
);
|
);
|
||||||
@ -87,12 +87,15 @@ export default async (
|
|||||||
|
|
||||||
const applications = await caprover.getApps();
|
const applications = await caprover.getApps();
|
||||||
|
|
||||||
|
const nameFilter = new URL(req.url).searchParams.get("name");
|
||||||
const appToUpdate = applications
|
const appToUpdate = applications
|
||||||
.filter((app) => {
|
.filter((app) => {
|
||||||
// can we help to update this app?
|
// can we help to update this app?
|
||||||
return app.dockerImage;
|
return app.dockerImage;
|
||||||
})
|
})
|
||||||
.find((app) => app.isOlderThan(30));
|
.find((app) => {
|
||||||
|
return nameFilter ? app.name === nameFilter : app.isOlderThan(30);
|
||||||
|
});
|
||||||
|
|
||||||
if (!appToUpdate) {
|
if (!appToUpdate) {
|
||||||
return new Response(Layout(html`<h1>No application to update 🎉</h1>`), {
|
return new Response(Layout(html`<h1>No application to update 🎉</h1>`), {
|
||||||
|
@ -1,58 +1,23 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import {
|
||||||
import { Application } from "./Caprover";
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from "bun:test";
|
||||||
|
import Caprover, { Application } from "./Caprover";
|
||||||
|
import { TestApp } from "../domain/testing/TestApp";
|
||||||
|
import server, {
|
||||||
|
CAPROVER_TEST_DOMAIN,
|
||||||
|
CAPROVER_TEST_PASSWORD,
|
||||||
|
} from "./mocks/caproverServer";
|
||||||
|
import appsFixtures from "./mocks/apps.fixtures.json";
|
||||||
|
|
||||||
describe("Caprover", () => {
|
describe("Caprover", () => {
|
||||||
describe("Application", () => {
|
describe("Application", () => {
|
||||||
const anAppDefinition = {
|
const anAppDefinition = appsFixtures[0];
|
||||||
hasPersistentData: false,
|
|
||||||
description: "",
|
|
||||||
instanceCount: 1,
|
|
||||||
captainDefinitionRelativeFilePath: "./captain-definition",
|
|
||||||
networks: ["captain-overlay-network"],
|
|
||||||
envVars: [
|
|
||||||
{
|
|
||||||
key: "ADMINER_PLUGINS",
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ADMINER_DESIGN",
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
volumes: [],
|
|
||||||
ports: [],
|
|
||||||
versions: [
|
|
||||||
{
|
|
||||||
version: 0,
|
|
||||||
timeStamp: "2020-08-02T01:25:07.232Z",
|
|
||||||
deployedImageName: "img-captain-adminer:0",
|
|
||||||
gitHash: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
timeStamp: "2021-03-19T10:04:54.823Z",
|
|
||||||
deployedImageName: "adminer:4.8.0",
|
|
||||||
gitHash: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 2,
|
|
||||||
timeStamp: "2021-12-04T10:24:48.757Z",
|
|
||||||
deployedImageName: "adminer:4.8.1",
|
|
||||||
gitHash: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
deployedVersion: 2,
|
|
||||||
notExposeAsWebApp: false,
|
|
||||||
customDomain: [],
|
|
||||||
hasDefaultSubDomainSsl: true,
|
|
||||||
forceSsl: true,
|
|
||||||
websocketSupport: false,
|
|
||||||
containerHttpPort: 8080,
|
|
||||||
preDeployFunction: "",
|
|
||||||
serviceUpdateOverride: "",
|
|
||||||
appName: "adminer",
|
|
||||||
isAppBuilding: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should create an application from definition", () => {
|
it("should create an application from definition", () => {
|
||||||
const app = Application.createFromDefinition(anAppDefinition);
|
const app = Application.createFromDefinition(anAppDefinition);
|
||||||
@ -103,4 +68,68 @@ describe("Caprover", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Initialization", () => {
|
||||||
|
it("should throw an error when no domain is provided", () => {
|
||||||
|
expect(() => new Caprover(new TestApp().eventStore, "")).toThrowError(
|
||||||
|
"Missing domain or password"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("should throw an error when no password is provided", () => {
|
||||||
|
expect(
|
||||||
|
() => new Caprover(new TestApp().eventStore, CAPROVER_TEST_DOMAIN)
|
||||||
|
).toThrowError("Missing domain or password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("API interactions", () => {
|
||||||
|
let app: TestApp, caprover: Caprover;
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
beforeEach(() => {
|
||||||
|
app = new TestApp();
|
||||||
|
caprover = new Caprover(
|
||||||
|
app.eventStore,
|
||||||
|
CAPROVER_TEST_DOMAIN,
|
||||||
|
CAPROVER_TEST_PASSWORD
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe("getApps", () => {
|
||||||
|
it("should return the list of applications", async () => {
|
||||||
|
const apps = await caprover.getApps();
|
||||||
|
expect(apps).toBeArrayOfSize(3);
|
||||||
|
expect(apps[0]).toBeInstanceOf(Application);
|
||||||
|
expect(apps[0].name).toBe("adminer");
|
||||||
|
expect(apps[1].name).toBe("mysql");
|
||||||
|
expect(apps[2].name).toBe("redis");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateApplication", () => {
|
||||||
|
it("should update the application with the provided version and emit an event upon success", async () => {
|
||||||
|
await caprover.updateApplication("adminer", "adminer:4.2.0");
|
||||||
|
|
||||||
|
const apps = await caprover.getApps();
|
||||||
|
expect(apps[0].imageName).toBe("adminer:4.2.0");
|
||||||
|
|
||||||
|
const events = app.eventStore.getAllEvents();
|
||||||
|
expect(events).toBeArrayOfSize(1);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "ApplicationUpdateStarted",
|
||||||
|
payload: { id: "adminer", newVersion: "adminer:4.2.0" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when the application does not exist and not emit any event", async () => {
|
||||||
|
await expect(
|
||||||
|
caprover.updateApplication("unknown", "adminer:4.2.0")
|
||||||
|
).rejects.toThrowError(/Failed to update application unknown/);
|
||||||
|
|
||||||
|
const events = app.eventStore.getAllEvents();
|
||||||
|
expect(events).toBeArrayOfSize(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -70,17 +70,33 @@ class Caprover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getApps(): Promise<Application[]> {
|
async getApps(): Promise<Application[]> {
|
||||||
const res = await this.fetch("/user/apps/appDefinitions").then((res) =>
|
const res = await this.fetch("/user/apps/appDefinitions");
|
||||||
res.json()
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
|
res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateApplication(appName: string, version: string) {
|
async updateApplication(appName: string, version: string): Promise<void> {
|
||||||
console.log("TODO: Implement remote call", appName, version); // TODO
|
console.debug("Caprover: Updating application", appName, "to", version);
|
||||||
|
const response = await this.fetch(`/user/apps/appData/${appName}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
captainDefinitionContent: JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
imageName: version,
|
||||||
|
}),
|
||||||
|
gitHash: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug("update application response", response);
|
||||||
|
if (response.status !== 100) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to update application ${appName} to ${version}: ${response.description}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.eventStore.append(
|
this.eventStore.append(
|
||||||
new ApplicationUpdateStarted(
|
new ApplicationUpdateStarted(
|
||||||
{ id: appName, newVersion: version },
|
{ id: appName, newVersion: version },
|
||||||
@ -93,7 +109,6 @@ class Caprover {
|
|||||||
if (this.authToken) {
|
if (this.authToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Trying to authenticate at", this.apiUrl);
|
console.debug("Trying to authenticate at", this.apiUrl);
|
||||||
|
|
||||||
const authResponse = await fetch(`${this.apiUrl}/login`, {
|
const authResponse = await fetch(`${this.apiUrl}/login`, {
|
||||||
@ -117,7 +132,7 @@ class Caprover {
|
|||||||
private async fetch(path: string, options?: RequestInit) {
|
private async fetch(path: string, options?: RequestInit) {
|
||||||
await this.authenticate();
|
await this.authenticate();
|
||||||
|
|
||||||
console.debug("-> Caprover Fetching", path);
|
console.debug("-> Caprover Fetching", options?.method, path);
|
||||||
return fetch(`${this.apiUrl}${path}`, {
|
return fetch(`${this.apiUrl}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@ -126,6 +141,15 @@ class Caprover {
|
|||||||
"x-namespace": "captain",
|
"x-namespace": "captain",
|
||||||
"x-captain-auth": this.authToken,
|
"x-captain-auth": this.authToken,
|
||||||
},
|
},
|
||||||
|
}).then((res) => {
|
||||||
|
console.debug(
|
||||||
|
"\t<- Caprover Response",
|
||||||
|
options?.method,
|
||||||
|
path,
|
||||||
|
res.status,
|
||||||
|
res.statusText
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
22
services/DockerHub.test.ts
Normal file
22
services/DockerHub.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import DockerHub from "./DockerHub";
|
||||||
|
import server from "./mocks/dockerHubServer";
|
||||||
|
|
||||||
|
describe("DockerHub", () => {
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe("getLatestVersions", () => {
|
||||||
|
it("should return the latest versions of an image", async () => {
|
||||||
|
const dockerHub = new DockerHub();
|
||||||
|
const versions = await dockerHub.getLatestVersions("vaultwarden/server");
|
||||||
|
expect(versions).toEqual([
|
||||||
|
"vaultwarden/server:1.30.5",
|
||||||
|
"vaultwarden/server:1.30.5-alpine",
|
||||||
|
"vaultwarden/server:latest",
|
||||||
|
"vaultwarden/server:latest-alpine",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -26,7 +26,15 @@ class DockerHub {
|
|||||||
const latestTags = tags
|
const latestTags = tags
|
||||||
.filter((tag) => tag.digest === latestTag.digest)
|
.filter((tag) => tag.digest === latestTag.digest)
|
||||||
.map((tag) => tag.name);
|
.map((tag) => tag.name);
|
||||||
return latestTags;
|
|
||||||
|
// Example: "1.0.0-alpine" is a variant of "1.0.0"
|
||||||
|
const isVariant = (tag: Tag) => {
|
||||||
|
return latestTags.some((latestTag) => tag.name.includes(latestTag + "-"));
|
||||||
|
};
|
||||||
|
return latestTags
|
||||||
|
.concat(tags.filter(isVariant).map((tag) => tag.name))
|
||||||
|
.map((tag) => `${imageName}:${tag}`)
|
||||||
|
.sort();
|
||||||
};
|
};
|
||||||
|
|
||||||
private fetch = async (url: string): Promise<Response> => {
|
private fetch = async (url: string): Promise<Response> => {
|
||||||
|
145
services/mocks/apps.fixtures.json
Normal file
145
services/mocks/apps.fixtures.json
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"hasPersistentData": false,
|
||||||
|
"description": "",
|
||||||
|
"instanceCount": 1,
|
||||||
|
"captainDefinitionRelativeFilePath": "./captain-definition",
|
||||||
|
"networks": ["captain-overlay-network"],
|
||||||
|
"envVars": [
|
||||||
|
{
|
||||||
|
"key": "ADMINER_PLUGINS",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ADMINER_DESIGN",
|
||||||
|
"value": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [],
|
||||||
|
"ports": [],
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"timeStamp": "2020-08-02T01:25:07.232Z",
|
||||||
|
"deployedImageName": "img-captain-adminer:0",
|
||||||
|
"gitHash": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timeStamp": "2021-03-19T10:04:54.823Z",
|
||||||
|
"deployedImageName": "adminer:4.8.0",
|
||||||
|
"gitHash": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"timeStamp": "2021-12-04T10:24:48.757Z",
|
||||||
|
"deployedImageName": "adminer:4.8.1",
|
||||||
|
"gitHash": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedVersion": 2,
|
||||||
|
"notExposeAsWebApp": false,
|
||||||
|
"customDomain": [],
|
||||||
|
"hasDefaultSubDomainSsl": true,
|
||||||
|
"forceSsl": true,
|
||||||
|
"websocketSupport": false,
|
||||||
|
"containerHttpPort": 8080,
|
||||||
|
"preDeployFunction": "",
|
||||||
|
"serviceUpdateOverride": "",
|
||||||
|
"appName": "adminer",
|
||||||
|
"isAppBuilding": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasPersistentData": false,
|
||||||
|
"description": "",
|
||||||
|
"instanceCount": 1,
|
||||||
|
"captainDefinitionRelativeFilePath": "./captain-definition",
|
||||||
|
"networks": ["captain-overlay-network"],
|
||||||
|
"envVars": [
|
||||||
|
{
|
||||||
|
"key": "MYSQL_ROOT_PASSWORD",
|
||||||
|
"value": "root"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "MYSQL_DATABASE",
|
||||||
|
"value": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "MYSQL_USER",
|
||||||
|
"value": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "MYSQL_PASSWORD",
|
||||||
|
"value": "test"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [],
|
||||||
|
"ports": [],
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"timeStamp": "2020-08-02T01:25:07.232Z",
|
||||||
|
"deployedImageName": "img-captain-mysql:0",
|
||||||
|
"gitHash": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timeStamp": "2021-03-19T10:04:54.823Z",
|
||||||
|
"deployedImageName": "mysql:5.7",
|
||||||
|
"gitHash": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"timeStamp": "2021-12-04T10:24:48.757Z",
|
||||||
|
"deployedImageName": "mysql:8.0",
|
||||||
|
"gitHash": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedVersion": 2,
|
||||||
|
"notExposeAsWebApp": false,
|
||||||
|
"customDomain": [],
|
||||||
|
"hasDefaultSubDomainSsl": true,
|
||||||
|
"forceSsl": true,
|
||||||
|
"websocketSupport": false,
|
||||||
|
"containerHttpPort": 3306,
|
||||||
|
"preDeployFunction": "",
|
||||||
|
"serviceUpdateOverride": "",
|
||||||
|
"appName": "mysql",
|
||||||
|
"isAppBuilding": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasPersistentData": false,
|
||||||
|
"description": "",
|
||||||
|
"instanceCount": 1,
|
||||||
|
"captainDefinitionRelativeFilePath": "./captain-definition",
|
||||||
|
"networks": ["captain-overlay-network"],
|
||||||
|
"envVars": [],
|
||||||
|
"volumes": [],
|
||||||
|
"ports": [],
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"timeStamp": "2020-08-02T01:25:07.232Z",
|
||||||
|
"deployedImageName": "img-captain-redis:0",
|
||||||
|
"gitHash": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timeStamp": "2021-03-19T10:04:54.823Z",
|
||||||
|
"deployedImageName": "redis:6.2.3",
|
||||||
|
"gitHash": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployedVersion": 1,
|
||||||
|
"notExposeAsWebApp": false,
|
||||||
|
"customDomain": [],
|
||||||
|
"hasDefaultSubDomainSsl": true,
|
||||||
|
"forceSsl": true,
|
||||||
|
"websocketSupport": false,
|
||||||
|
"containerHttpPort": 6379,
|
||||||
|
"preDeployFunction": "",
|
||||||
|
"serviceUpdateOverride": "",
|
||||||
|
"appName": "redis",
|
||||||
|
"isAppBuilding": false
|
||||||
|
}
|
||||||
|
]
|
99
services/mocks/caproverServer.ts
Normal file
99
services/mocks/caproverServer.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { http, HttpResponse, HttpResponseResolver, PathParams } from "msw";
|
||||||
|
import appsFixtures from "./apps.fixtures.json";
|
||||||
|
|
||||||
|
export const CAPROVER_TEST_DOMAIN = "caprover.test";
|
||||||
|
export const CAPROVER_TEST_PASSWORD = "password";
|
||||||
|
const TEST_TOKEN = "123";
|
||||||
|
const BASE_URI = `https://${CAPROVER_TEST_DOMAIN}/api/v2`;
|
||||||
|
|
||||||
|
const withAuth = (resolver: HttpResponseResolver): HttpResponseResolver => {
|
||||||
|
return (input) => {
|
||||||
|
const headers = input.request.headers;
|
||||||
|
if (
|
||||||
|
headers.get("x-namespace") !== "captain" ||
|
||||||
|
headers.get("x-captain-auth") !== TEST_TOKEN
|
||||||
|
) {
|
||||||
|
return new HttpResponse("", { status: 401 });
|
||||||
|
}
|
||||||
|
return resolver(input);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// @see https://github.com/caprover/caprover-cli/blob/master/src/api/ApiManager.ts
|
||||||
|
// @see https://github.com/caprover/caprover/tree/master/src/routes
|
||||||
|
const handlers = [
|
||||||
|
http.post<PathParams, { password: string }>(
|
||||||
|
`${BASE_URI}/login`,
|
||||||
|
async ({ request }) => {
|
||||||
|
const credentials = await request.json();
|
||||||
|
if (
|
||||||
|
credentials.password !== CAPROVER_TEST_PASSWORD ||
|
||||||
|
request.headers.get("x-namespace") !== "captain"
|
||||||
|
) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
status: 1106,
|
||||||
|
description: "Auth token corrupted",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ data: { token: TEST_TOKEN } });
|
||||||
|
}
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
`${BASE_URI}/user/apps/appDefinitions`,
|
||||||
|
withAuth(() => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
appDefinitions: appsFixtures,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.post<
|
||||||
|
{ name: string },
|
||||||
|
{
|
||||||
|
captainDefinitionContent?: string;
|
||||||
|
tarballFile?: string;
|
||||||
|
gitHash?: string;
|
||||||
|
}
|
||||||
|
>(`${BASE_URI}/user/apps/appData/:name`, async ({ request, params }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
if (/* !body.tarballFile && */ !body.captainDefinitionContent) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
status: 1100,
|
||||||
|
description:
|
||||||
|
"Either tarballfile or captainDefinitionContent should be present.",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = appsFixtures.find((app) => app.appName === params.name);
|
||||||
|
if (!app) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
status: 1000,
|
||||||
|
description: `App (${params.name}) could not be found. Make sure that you have created the app.`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVersion = JSON.parse(body.captainDefinitionContent).imageName;
|
||||||
|
app.deployedVersion = app.versions.length;
|
||||||
|
app.versions.push({
|
||||||
|
version: app.versions.length,
|
||||||
|
deployedImageName: newVersion,
|
||||||
|
gitHash: body.gitHash ?? "",
|
||||||
|
timeStamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
status: 100,
|
||||||
|
description: "Deploy is done",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const server = setupServer(...handlers);
|
||||||
|
|
||||||
|
export default server;
|
114
services/mocks/dockerHubServer.ts
Normal file
114
services/mocks/dockerHubServer.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { http, HttpResponse, HttpResponseResolver, PathParams } from "msw";
|
||||||
|
|
||||||
|
const BASE_URI = "https://hub.docker.com/v2";
|
||||||
|
|
||||||
|
const handlers = [
|
||||||
|
http.get<{ namespace: string; repository: string }>(
|
||||||
|
`${BASE_URI}/repositories/:namespace/:repository/tags`,
|
||||||
|
({ params }) => {
|
||||||
|
const { namespace, repository } = params;
|
||||||
|
if (namespace !== "vaultwarden" || repository !== "server") {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
message: "httperror 404: object not found",
|
||||||
|
errinfo: {
|
||||||
|
namespace,
|
||||||
|
repository,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HttpResponse.json({
|
||||||
|
count: 66,
|
||||||
|
next: "https://hub.docker.com/v2/repositories/vaultwarden/server/tags?ordering=last_updated&page=2&page_size=10",
|
||||||
|
previous: null,
|
||||||
|
results: [
|
||||||
|
createImageDescription(
|
||||||
|
"1.30.5-alpine",
|
||||||
|
"sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"latest-alpine",
|
||||||
|
"sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"alpine",
|
||||||
|
"sha256:6f6ec220ed300e1a11475a91d270985915083512f9fb60c1c25783faaa66eef5"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"1.30.5",
|
||||||
|
"sha256:edb8e2bab9cbca22e555638294db9b3657ffbb6e5d149a29d7ccdb243e3c71e0"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"latest",
|
||||||
|
"sha256:edb8e2bab9cbca22e555638294db9b3657ffbb6e5d149a29d7ccdb243e3c71e0"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"testing-alpine",
|
||||||
|
"sha256:af5021d1a4e5debd1dc16a2bef15993c07f93a0e3c6c4acfd1ffcdaaab71bd0d"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"testing",
|
||||||
|
"sha256:293f0127bc2fe0c59b26fea0ec0b990049a65b4f6f0c9f961e345276aadca3fd"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"1.30.4-alpine",
|
||||||
|
"sha256:743209ed6169e595f9fff2412619d6791002057e211f8725b779777e05066df4"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"1.30.4",
|
||||||
|
"sha256:b906f840f02ea481861cd90a4eafb92752f45afa1927a29406a26256f56271ed"
|
||||||
|
),
|
||||||
|
createImageDescription(
|
||||||
|
"1.30.3-alpine",
|
||||||
|
"sha256:153defd78a3ede850445d64d6fca283701d0c25978e513c61688cf63bd47a14a"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
function createImageDescription(name: string, digest: string) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
digest,
|
||||||
|
|
||||||
|
// fake data irrelevant for our use case
|
||||||
|
creator: 14287463,
|
||||||
|
id: 613523756,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
architecture: "amd64",
|
||||||
|
features: "",
|
||||||
|
variant: null,
|
||||||
|
digest:
|
||||||
|
"sha256:9f4c1ea3601e398656992f738af9f84faf7a6d68299b2deaf049580e5da0d37f",
|
||||||
|
os: "linux",
|
||||||
|
os_features: "",
|
||||||
|
os_version: null,
|
||||||
|
size: 31945148,
|
||||||
|
status: "active",
|
||||||
|
last_pulled: "2024-03-10T08:07:35.84822Z",
|
||||||
|
last_pushed: "2024-03-02T18:59:17.437196Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
last_updated: "2024-03-02T18:59:28.977584Z",
|
||||||
|
last_updater: 14287463,
|
||||||
|
last_updater_username: "vaultwardenbot",
|
||||||
|
repository: 12325169,
|
||||||
|
full_size: 31945148,
|
||||||
|
v2: true,
|
||||||
|
tag_status: "active",
|
||||||
|
tag_last_pulled: "2024-03-10T08:07:35.84822Z",
|
||||||
|
tag_last_pushed: "2024-03-02T18:59:28.977584Z",
|
||||||
|
media_type: "application/vnd.oci.image.index.v1+json",
|
||||||
|
content_type: "image",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer(...handlers);
|
||||||
|
|
||||||
|
export default server;
|
@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "NodeNext",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"],
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user