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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.env.local

9
Makefile Normal file
View File

@ -0,0 +1,9 @@
dev:
@make tdd&
bun --hot run joe.ts
test:
bun test
tdd:
bun test --watch

BIN
bun.lockb Executable file

Binary file not shown.

View File

@ -1,219 +0,0 @@
#!/usr/bin/env zx
$.verbose = argv.v || argv.verbose;
if (argv.help) {
console.log(`${chalk.white.bgBlue(
"🐿️ captain-update.mjs - Check for updates of all deployed images on CapRover"
)}
Usage: ${chalk.yellow(
"CAPTAIN_DOMAIN=captain.my.example.com CAPTAIN_PASSWORD='This is super S3cure!' ./captain-update.mjs [options]"
)}
${chalk.grey(
"CAPTAIN_DOMAIN must have the domain name of the CapRover instance"
)}
${chalk.grey(
"CAPTAIN_PASSWORD must have the password of the CapRover instance"
)}
Optional options:
${chalk.yellow("--help")} Display this help
${chalk.yellow("-v --verbose")} Verbose output
`);
process.exit(0);
}
const hasAllEnvVars =
process.env.CAPTAIN_DOMAIN && process.env.CAPTAIN_PASSWORD;
if (!hasAllEnvVars) {
console.error(
`Missing ${chalk.red("CAPTAIN_DOMAIN")} or ${chalk.red(
"CAPTAIN_PASSWORD"
)} environment variables. Use ${chalk.green("--help")} for help.`
);
process.exit(1);
}
const API_URL = `https://${process.env.CAPTAIN_DOMAIN}/api/v2`;
const authResponse = await fetch(`${API_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-namespace": "captain",
},
body: JSON.stringify({
password: process.env.CAPTAIN_PASSWORD,
}),
}).then((res) => res.json());
const authToken = authResponse?.data?.token;
if (!authToken) {
console.error(`Failed to authenticate at ${API_URL}`);
process.exit(1);
}
const fetchAuthenticatedApi = async (url, method = "GET", body = undefined) => {
const response = await fetch(`${API_URL}${url}`, {
method,
headers: {
"Content-Type": "application/json",
"x-namespace": "captain",
"x-captain-auth": authToken,
},
body: body ? JSON.stringify(body) : undefined,
}).then((res) => res.json());
return response;
};
const applicationsResponse = await fetchAuthenticatedApi(
"/user/apps/appDefinitions"
);
console.log(
chalk.green(
`Found ${applicationsResponse.data.appDefinitions.length} applications on ${applicationsResponse.data.rootDomain}`
)
);
const appStates = await Promise.all(
applicationsResponse.data.appDefinitions.map(async (app) => {
const currentVersion = app.versions[app.deployedVersion];
const [imageName, currentTag] = currentVersion.deployedImageName.split(":");
const imagePath = imageName.includes("/")
? imageName
: `library/${imageName}`;
let appState = {
name: app.appName,
lastDeployed: currentVersion.timeStamp,
currentImage: {
name: currentVersion.deployedImageName,
dockerHubUrl: imageName.includes("/")
? `https://hub.docker.com/r/${imageName}`
: `https://hub.docker.com/_/${imageName}`,
image: imageName,
tag: currentTag,
},
};
const tagsResponse = await fetch(
`https://hub.docker.com/v2/repositories/${imagePath}/tags?page_size=10&page=1&ordering=last_updated`
)
.then((res) => res.json())
.catch((error) => {
console.error(
"Impossible to get Docker tags for image",
chalk.red(imagePath)
);
return {};
});
const tags = tagsResponse.results;
if (!tags || tags.length === 0) {
return appState;
}
const latestTag = tags.find((tag) => tag.name === "latest");
const latestTags = tags
.filter((tag) => {
if (!latestTag) {
return true;
}
return tag.digest === latestTag.digest;
})
.map((tag) => tag.name);
const isLatest = latestTags.includes(currentTag);
appState.isLatest = latestTag && isLatest;
appState.latestTags = latestTags;
appState.recommendedVersion = `${imageName}:${latestTags.pop()}`;
return appState;
})
);
const appsUpToDate = appStates.filter((app) => app.isLatest);
const appsUnknown = appStates.filter((app) => !app.recommendedVersion);
const appsOutdated = appStates.filter(
(app) => app.recommendedVersion && !app.isLatest
);
console.log(
"\n",
chalk.green(`# Applications report (${new Date().toISOString()})`),
"\n\n"
);
console.log(chalk.green("## Applications up-to-date", "\n"));
console.log(
chalk.grey(
"These applications seems up-to-date, congrats! Please check for unsupported software not updated recently, and improve this script if possible."
),
"\n"
);
appsUpToDate
.sort((a, b) => new Date(a.lastDeployed) - new Date(b.lastDeployed))
.forEach((app) => {
console.log(
"- [x]",
chalk.blue(app.name),
`: ${app.currentImage.name}`,
"\n\t",
"- current:",
app.currentImage.name,
"\n\t",
"- last deployed:",
app.lastDeployed
);
});
console.log("\n\n", chalk.green("## Applications unknown", "\n"));
console.log(
chalk.grey(
"I couldn't automatically find the latest version for these apps. Please check manually, and improve this script if possible."
),
"\n"
);
appsUnknown.forEach((app) => {
console.log(
"- [ ]",
chalk.blue(app.name),
`: ${app.currentImage.name} -> TODO`,
"\n\t",
"- current:",
app.currentImage.name,
"\n\t",
"- docker hub URL:",
app.currentImage.dockerHubUrl
);
});
console.log("\n\n", chalk.green("## Applications outdated", "\n"));
console.log(
chalk.grey(
"These applications seems outdated. Please try to update to their latest versions, and improve this script if possible."
),
"\n"
);
appsOutdated.forEach((app) => {
console.log(
"- [ ]",
chalk.blue(app.name),
`: ${app.currentImage.name} -> ${app.recommendedVersion}`,
"\n\t",
"- current:",
app.currentImage.name,
"\n\t",
"- recommended:",
app.recommendedVersion,
"\n\t",
"- docker hub URL:",
app.currentImage.dockerHubUrl,
"\n\t",
"- tags:",
app.latestTags.join(", ")
);
});

39
joe.ts Normal file
View File

@ -0,0 +1,39 @@
import Caprover from "./services/Caprover";
import applications from "./routes/applications";
import applicationsUpdate from "./routes/applications.update";
import Layout, { html } from "./ui/Layout";
import DockerHub from "./services/DockerHub";
console.log("Hello Pierrot!");
const caprover = new Caprover(
process.env.CAPTAIN_DOMAIN,
process.env.CAPTAIN_PASSWORD
);
const dockerHub = new DockerHub();
const server = Bun.serve({
port: 3000,
fetch(req: Request) {
const url = new URL(req.url);
switch (url.pathname) {
case "/":
return new Response(
Layout(
html`<h1>Hello World.</h1>
<p>I'm Joe, your personal assistant.</p>`
),
{ headers: { "Content-Type": "text/html" } }
);
case "/applications":
return applications(req, caprover);
case "/applications/update":
return applicationsUpdate(req, caprover, dockerHub);
default:
return new Response("Not Found", { status: 404 });
}
},
});
console.log(`Server started at http://${server.hostname}:${server.port}`);

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"devDependencies": {
"bun-types": "^1.0.25"
}
}

91
routes/applications.ts Normal file
View File

@ -0,0 +1,91 @@
import Caprover, { Application } from "../services/Caprover";
import Layout, { html } from "../ui/Layout";
const OLD_PERIOD_IN_DAYS = 60;
const ApplicationOverview = (application: Application) => {
const deployedAt = application.lastDeployedAt.toLocaleDateString("fr-FR");
return html`<tr>
<td>${application.name}</td>
<td>
<details>
<summary>
<code>${application.imageName}</code>
</summary>
<pre style="user-select: all;">${application.toString()}</pre>
</details>
</td>
<td>
${application.isOlderThan(OLD_PERIOD_IN_DAYS)
? `<mark>${deployedAt}</mark>`
: deployedAt}
</td>
<td>
${application.dockerImage
? `<a href="${application.dockerImage.hubUrl}/tags">
<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/>
</a>`
: ""}
</td>
</tr> `;
};
type Sort = { field: string; order: "asc" | "desc" };
const Page = (applications: Application[], currentSort: Sort) => {
const sortLink = (field: string, title: string) => {
let url = `?sort=${field}`;
let className = "";
if (currentSort.field === field) {
className = "current";
title += currentSort.order === "asc" ? " ▲" : " ▼";
url += `&order=${currentSort.order === "asc" ? "desc" : "asc"}`;
}
return `<a href="${url}" class="${className}">${title}</a>`;
};
return html`<div>
<h1>Applications</h1>
<a href="/applications/update" class="button">Update applications now!</a>
<table>
<thead>
<tr>
<th>${sortLink("name", "Name")}</th>
<th>Deployment</th>
<th>${sortLink("deployed", "Last deployed")}</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${applications.map((app) => ApplicationOverview(app)).join("")}
</tbody>
</table>
</div>`;
};
export default async (req: Request, caprover: Caprover): Promise<Response> => {
const applications = await caprover.getApps();
const sort: Sort = {
field: new URL(req.url).searchParams.get("sort") ?? "name",
order:
new URL(req.url).searchParams.get("order") === "desc" ? "desc" : "asc",
};
if (sort.field === "name") {
applications.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort.field === "deployed") {
applications.sort(
(a, b) => a.lastDeployedAt.getTime() - b.lastDeployedAt.getTime()
);
}
if (sort.order === "desc") {
applications.reverse();
}
return new Response(Layout(Page(applications, sort)), {
headers: { "Content-Type": "text/html" },
});
};

View File

@ -0,0 +1,82 @@
import Caprover, { Application } from "../services/Caprover";
import DockerHub from "../services/DockerHub";
import Layout, { html } from "../ui/Layout";
const UpdateForm = (application: Application, latestVersions: string[]) => {
// sort by number of "dots" in the version to have the most specific version first
latestVersions.sort((a, b) => {
return b.split(".").length - a.split(".").length;
});
return html`<form method="POST">
<input type="hidden" name="appName" value="${application.name}" />
<select name="version">
${latestVersions.map((version) => {
return html`<option value="${version}">${version}</option>`;
})}
</select>
<button type="submit">Update</button>
</form>`;
};
const Page = (application: Application, latestVersions: string[]) => {
return html`<div>
<h1>Updating ${application.name}</h1>
<dl>
<dt>Last deployment</dt>
<dd>${application.lastDeployedAt.toLocaleString("fr-FR")}</dd>
<dt>Current version</dt>
<dd>
${application.dockerImage
? `<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/> <a href="${application.dockerImage.hubUrl}/tags">
${application.dockerImage.name}:${application.dockerImage.tag}
</a>`
: `<code>${application.imageName}</code>… check yourself!`}
</dd>
<dt>Latest versions</dt>
<dd>
${latestVersions.length === 0
? "No version found"
: UpdateForm(application, latestVersions)}
</dd>
</dl>
</div>`;
};
export default async (
req: Request,
caprover: Caprover,
dockerHub: DockerHub
): Promise<Response> => {
if (req.method === "POST") {
const body = await req.formData();
console.log("TODO Implement application update", [...body.entries()]);
throw new Error("Not implemented");
}
const applications = await caprover.getApps();
const appToUpdate = applications
.filter((app) => {
// can we help to update this app?
return app.dockerImage;
})
.find((app) => app.isOlderThan(30));
if (!appToUpdate) {
return new Response(Layout(html`<h1>No application to update 🎉</h1>`), {
headers: { "Content-Type": "text/html" },
});
}
const latestVersions = await dockerHub.getLatestVersions(
appToUpdate.dockerImage!.name
);
return new Response(Layout(Page(appToUpdate, latestVersions)), {
headers: { "Content-Type": "text/html" },
});
};

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;

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"types": ["bun-types"]
}
}

33
ui/Layout.ts Normal file
View File

@ -0,0 +1,33 @@
export const html = String.raw;
const Layout = (content: string) => {
return html`<html>
<head>
<title>Joe</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
<meta charset="utf-8" />
</head>
<body>
<header>
<nav>
<a href="/"><code>/</code></a>
<a href="/applications">Applications</a>
</nav>
</header>
<main>${content}</main>
<script>
// add class "current" to all links matching the current URL
const links = document.querySelectorAll("a");
const currentUrl = window.location.pathname;
links.forEach((link) => {
if (link.getAttribute("href") === currentUrl) {
link.classList.add("current");
}
});
</script>
</body>
</html>`;
};
export default Layout;