feat: replace CLI script with web UI (Joe) for listing outdated applications

This commit is contained in:
Pierre Martin
2024-01-30 00:36:12 +01:00
parent e4a01bb96c
commit 76a8909dc1
13 changed files with 531 additions and 219 deletions

106
services/Caprover.test.ts Normal file
View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "bun:test";
import { Application } from "./Caprover";
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,
};
it("should create an application from definition", () => {
const app = Application.createFromDefinition(anAppDefinition);
expect(app.name).toBe("adminer");
});
describe("docker image", () => {
it("should return the docker image name of the current version", () => {
const app = Application.createFromDefinition(anAppDefinition);
expect(app.dockerImage).toEqual({
name: "adminer",
tag: "4.8.1",
hubUrl: "https://hub.docker.com/_/adminer",
});
});
it("should parse names with organization namespace", () => {
const app = Application.createFromDefinition({
...anAppDefinition,
deployedVersion: 0,
versions: [
{
...anAppDefinition.versions[0],
deployedImageName: "vaultwarden/server:1.30.0-alpine",
},
],
});
expect(app.dockerImage).toEqual({
name: "vaultwarden/server",
tag: "1.30.0-alpine",
hubUrl: "https://hub.docker.com/r/vaultwarden/server",
});
});
it("should not parse custom built images", () => {
const app = Application.createFromDefinition({
...anAppDefinition,
deployedVersion: 0,
versions: [
{
...anAppDefinition.versions[0],
deployedImageName:
"registry.example.org:996/captain/img-captain-nextcloud-cron:4",
},
],
});
expect(app.dockerImage).toBeUndefined();
});
});
});
});

115
services/Caprover.ts Normal file
View File

@@ -0,0 +1,115 @@
type TODO_TypeDefinition = any;
class Application {
private constructor(private readonly data: any) {}
static createFromDefinition(data: any): Application {
return new Application(data);
}
get name(): string {
return this.data.appName;
}
get lastDeployedAt(): Date {
return new Date(this.currentVersion.timeStamp);
}
get imageName(): string {
return this.currentVersion.deployedImageName;
}
get dockerImage(): undefined | { name: string; tag: string; hubUrl: string } {
const match = this.imageName.match(/^(.*):(.*)$/);
if (!match || match[1].includes("/captain/")) {
return undefined;
}
const name = match[1];
return {
name: name,
tag: match[2],
hubUrl: name.includes("/")
? `https://hub.docker.com/r/${name}`
: `https://hub.docker.com/_/${name}`,
};
}
isOlderThan(days: number): boolean {
const daysInMs = days * 24 * 60 * 60 * 1000;
const now = Date.now();
return now - this.lastDeployedAt.getTime() > daysInMs;
}
toString(): string {
return JSON.stringify(this.data, null, 2);
}
private get currentVersion(): TODO_TypeDefinition {
return this.data.versions.find((version: { version: number }) => {
return version.version === this.data.deployedVersion;
});
}
}
class Caprover {
private authToken: string = "";
private readonly apiUrl: string;
constructor(readonly domain?: string, private readonly password?: string) {
if (!domain || !password) {
throw new Error("Missing domain or password");
}
this.apiUrl = `https://${domain}/api/v2`;
}
async getApps(): Promise<Application[]> {
const res = await this.fetch("/user/apps/appDefinitions").then((res) =>
res.json()
);
return (
res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
);
}
private async authenticate() {
if (this.authToken) {
return;
}
console.debug("Trying to authenticate at", this.apiUrl);
const authResponse = await fetch(`${this.apiUrl}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-namespace": "captain",
},
body: JSON.stringify({
password: this.password,
}),
}).then((res) => res.json());
this.authToken = authResponse?.data?.token;
if (!this.authToken) {
throw new Error(`Failed to authenticate at ${this.apiUrl}`);
}
console.debug("Authenticated successfully at", this.apiUrl, this.authToken);
}
private async fetch(path: string, options?: RequestInit) {
await this.authenticate();
console.debug("-> Caprover Fetching", path);
return fetch(`${this.apiUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options?.headers || {}),
"x-namespace": "captain",
"x-captain-auth": this.authToken,
},
});
}
}
export { Application };
export default Caprover;

39
services/DockerHub.ts Normal file
View File

@@ -0,0 +1,39 @@
type Tag = {
name: string;
digest: string;
};
class DockerHub {
getLatestVersions = async (imageName: string): Promise<string[]> => {
const imagePath = imageName.includes("/")
? imageName
: `library/${imageName}`;
const tagsResponse = await this.fetch(
`/${imagePath}/tags?page_size=10&ordering=last_updated`
).then((res) => res.json());
const tags: Tag[] = tagsResponse.results;
if (tags.length === 0) {
throw new Error(`No tags found for ${imageName}`);
}
const latestTag = tags.find((tag) => tag.name === "latest");
if (!latestTag) {
return [];
}
const latestTags = tags
.filter((tag) => tag.digest === latestTag.digest)
.map((tag) => tag.name);
return latestTags;
};
private fetch = async (url: string): Promise<Response> => {
const fullUrl = `https://hub.docker.com/v2/repositories${url}`;
console.debug("-> Docker: Fetching", fullUrl);
return fetch(fullUrl);
};
}
export default DockerHub;