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 0000000..b71b98b Binary files /dev/null and b/bun.lockb differ 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;