feat: replace CLI script with web UI (Joe) for listing outdated applications
This commit is contained in:
106
services/Caprover.test.ts
Normal file
106
services/Caprover.test.ts
Normal 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
115
services/Caprover.ts
Normal 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
39
services/DockerHub.ts
Normal 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;
|
||||
Reference in New Issue
Block a user