From 76a8909dc12544dd07bc87f7e7b1ffe1b6d5896e Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 30 Jan 2024 00:36:12 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20replace=20CLI=C2=A0script=20with=20web?= =?UTF-8?q?=20UI=20(Joe)=20for=20listing=20outdated=20applications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Makefile | 9 ++ bun.lockb | Bin 0 -> 2699 bytes captain-update.mjs | 219 ---------------------------------- joe.ts | 39 ++++++ package.json | 5 + routes/applications.ts | 91 ++++++++++++++ routes/applications.update.ts | 82 +++++++++++++ services/Caprover.test.ts | 106 ++++++++++++++++ services/Caprover.ts | 115 ++++++++++++++++++ services/DockerHub.ts | 39 ++++++ tsconfig.json | 9 ++ ui/Layout.ts | 33 +++++ 13 files changed, 531 insertions(+), 219 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100755 bun.lockb delete mode 100755 captain-update.mjs create mode 100644 joe.ts create mode 100644 package.json create mode 100644 routes/applications.ts create mode 100644 routes/applications.update.ts create mode 100644 services/Caprover.test.ts create mode 100644 services/Caprover.ts create mode 100644 services/DockerHub.ts create mode 100644 tsconfig.json create mode 100644 ui/Layout.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0988099 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules + +.env.local diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9c39e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +dev: + @make tdd& + bun --hot run joe.ts + +test: + bun test + +tdd: + bun test --watch \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..b71b98bdbc53ce0dc36922325de0e5053f296fdd GIT binary patch literal 2699 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+B1N-Wb zd9_bZo^m@lZ|xkeCsrrB>Ni~H`KecYMX@#G=5J1*A|POcPz)SsbOV&12U7s$Lnxr; zh6o^&2T1z^X%OE9NOL2JLsT*_RKH0-+adH?i8J5k*sH)BQszYcs9v?L zww5q)MwkXNX^^i$0OT=ipgUmZNrFWQ6EOWLK&6gA{j5M*28dzegiL^`TMATY0@M!* zQIHv2gfv3sVft?Z`IbQaB0w4#o($|zc|sISzYx&lF!xIVHG$kfEC7WW2r~mQ3se(` z28n^p0nx|_f;9F2`2T@4P#G{XK+Hupn&n$eT%i0pwbM^i>ewu^<2u+2-)U-l3rvaM zl6_%P-th;o-HYz^bx+k7lALQk;jZ(6wC?v_OpErWx7*C!Fs<*u7SN3_b7A2MayCo! zUO#x-p><}z z*L%L#*t7b0$aJN%;if7`=7Pc)23Yo`2n8O$HEojCMft3EPhQ*#ee?2R$Flnc+luZQ z{r3)=q|CwpZ|Aqb#g?H9!_u6TFyV|6mE}xuQTJH92t53_{Ia?j( zJ091V+$CT9;ib+8By*9|5sO+tkM;9~B~Dzc0t+WEk`HST3Cl2kd(`@j{ll+iC9BO> zY*c&}FxBs@ivHe~8IhH&G5Q@fnso_nvQ~rLSjy4dR~4SxY+ym9|AxQ zf|Vn%@PzR>paq-^l;*N2H8!#X8id_Qu-`ytfbu0M4}sF92UKqY5oUn&g6szA1(^Y2 zgUpx!)vE)o3b|~ejm%<8Er8xLgS*8L#e3j-4_3c%Fvc0`8R!|AGPnRu1J#7E8jgt( zRGxqUtgeLBfe0B`Z3?RmnHb|t^^DB)Oz@b7sizLArwr-`7>ycx#i_YvsYQCO6(y-f zd5Jl}B}JKe>2?Z+2ya@zy}1Bt(r0LU0T?^L5HZ9OAfU!bNo7H5aYji=L9vy-eo<&sZeHlacN#kW^yJ$qmh*X zO-JVH0z<_@&j@4z9=E|Q1nMp?*0s2;feqN literal 0 HcmV?d00001 diff --git a/captain-update.mjs b/captain-update.mjs deleted file mode 100755 index b432fc1..0000000 --- a/captain-update.mjs +++ /dev/null @@ -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(", ") - ); -}); diff --git a/joe.ts b/joe.ts new file mode 100644 index 0000000..b4d4421 --- /dev/null +++ b/joe.ts @@ -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`

Hello World.

+

I'm Joe, your personal assistant.

` + ), + { 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}`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..59ae404 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "bun-types": "^1.0.25" + } +} diff --git a/routes/applications.ts b/routes/applications.ts new file mode 100644 index 0000000..d57af8a --- /dev/null +++ b/routes/applications.ts @@ -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` + ${application.name} + +
+ + ${application.imageName} + +
${application.toString()}
+
+ + + ${application.isOlderThan(OLD_PERIOD_IN_DAYS) + ? `${deployedAt}` + : deployedAt} + + + ${application.dockerImage + ? `
+ Docker hub + ` + : ""} + + `; +}; + +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 `${title}`; + }; + + return html`
+

Applications

+ + Update applications now! + + + + + + + + + + + + ${applications.map((app) => ApplicationOverview(app)).join("")} + +
${sortLink("name", "Name")}Deployment${sortLink("deployed", "Last deployed")}Actions
+
`; +}; + +export default async (req: Request, caprover: Caprover): Promise => { + 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" }, + }); +}; diff --git a/routes/applications.update.ts b/routes/applications.update.ts new file mode 100644 index 0000000..1367943 --- /dev/null +++ b/routes/applications.update.ts @@ -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`
+ + + +
`; +}; + +const Page = (application: Application, latestVersions: string[]) => { + return html`
+

Updating ${application.name}

+ +
+
Last deployment
+
${application.lastDeployedAt.toLocaleString("fr-FR")}
+ +
Current version
+
+ ${application.dockerImage + ? `Docker hub + ${application.dockerImage.name}:${application.dockerImage.tag} + ` + : `${application.imageName}… check yourself!`} +
+ +
Latest versions
+
+ ${latestVersions.length === 0 + ? "No version found" + : UpdateForm(application, latestVersions)} +
+
+
`; +}; + +export default async ( + req: Request, + caprover: Caprover, + dockerHub: DockerHub +): Promise => { + 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`

No application to update 🎉

`), { + 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" }, + }); +}; diff --git a/services/Caprover.test.ts b/services/Caprover.test.ts new file mode 100644 index 0000000..28841da --- /dev/null +++ b/services/Caprover.test.ts @@ -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(); + }); + }); + }); +}); diff --git a/services/Caprover.ts b/services/Caprover.ts new file mode 100644 index 0000000..e8bafd0 --- /dev/null +++ b/services/Caprover.ts @@ -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 { + 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; diff --git a/services/DockerHub.ts b/services/DockerHub.ts new file mode 100644 index 0000000..36b229e --- /dev/null +++ b/services/DockerHub.ts @@ -0,0 +1,39 @@ +type Tag = { + name: string; + digest: string; +}; + +class DockerHub { + getLatestVersions = async (imageName: string): Promise => { + 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 => { + const fullUrl = `https://hub.docker.com/v2/repositories${url}`; + console.debug("-> Docker: Fetching", fullUrl); + return fetch(fullUrl); + }; +} + +export default DockerHub; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5b26ed3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "types": ["bun-types"] + } +} \ No newline at end of file diff --git a/ui/Layout.ts b/ui/Layout.ts new file mode 100644 index 0000000..6fdb48e --- /dev/null +++ b/ui/Layout.ts @@ -0,0 +1,33 @@ +export const html = String.raw; + +const Layout = (content: string) => { + return html` + + Joe + + + + +
+ +
+
${content}
+ + + + `; +}; + +export default Layout;