feat(caprover): allow to trigger an application update

+ added test coverage
This commit is contained in:
Pierre Martin
2024-03-10 09:49:52 +01:00
parent df5bfe4e1a
commit fd3783cc8d
18 changed files with 650 additions and 73 deletions

View File

@@ -1,58 +1,23 @@
import { describe, expect, it } from "bun:test";
import { Application } from "./Caprover";
import {
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("Application", () => {
const anAppDefinition = {
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,
};
const anAppDefinition = appsFixtures[0];
it("should create an application from definition", () => {
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);
});
});
});
});

View File

@@ -70,17 +70,33 @@ class Caprover {
}
async getApps(): Promise<Application[]> {
const res = await this.fetch("/user/apps/appDefinitions").then((res) =>
res.json()
);
const res = await this.fetch("/user/apps/appDefinitions");
return (
res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
);
}
async updateApplication(appName: string, version: string) {
console.log("TODO: Implement remote call", appName, version); // TODO
async updateApplication(appName: string, version: string): Promise<void> {
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(
new ApplicationUpdateStarted(
{ id: appName, newVersion: version },
@@ -93,7 +109,6 @@ class Caprover {
if (this.authToken) {
return;
}
console.debug("Trying to authenticate at", this.apiUrl);
const authResponse = await fetch(`${this.apiUrl}/login`, {
@@ -117,7 +132,7 @@ class Caprover {
private async fetch(path: string, options?: RequestInit) {
await this.authenticate();
console.debug("-> Caprover Fetching", path);
console.debug("-> Caprover Fetching", options?.method, path);
return fetch(`${this.apiUrl}${path}`, {
...options,
headers: {
@@ -126,6 +141,15 @@ class Caprover {
"x-namespace": "captain",
"x-captain-auth": this.authToken,
},
}).then((res) => {
console.debug(
"\t<- Caprover Response",
options?.method,
path,
res.status,
res.statusText
);
return res.json();
});
}
}

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

View File

@@ -26,7 +26,15 @@ class DockerHub {
const latestTags = tags
.filter((tag) => tag.digest === latestTag.digest)
.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> => {

View 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
}
]

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

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