diff --git a/README.md b/README.md index c3ff23d..6157b47 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Tools -Tools allowing to keep sans.pub up-to-date and automate common recurring actions. \ No newline at end of file +Tools allowing to keep sans.pub up-to-date and automate common recurring actions. + +## `captain-update.mjs` + +Allow to generate a report of all versions of applications deployed on sans.pub, with the current status of each application and whether they need an update or not. + +It can serve as a base for a changelog entry, and as a TODO list for updating applications. + +Example: +```shell +CAPTAIN_DOMAIN=xxxx CAPTAIN_PASSWORD='yyy' ./captain-update.mjs > /path/to/site/src/changelog/2023-05-08.md +``` + +### TODO + +- [ ] try to update applications automatically from a prompt \ No newline at end of file diff --git a/captain-update.mjs b/captain-update.mjs new file mode 100755 index 0000000..b432fc1 --- /dev/null +++ b/captain-update.mjs @@ -0,0 +1,219 @@ +#!/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(", ") + ); +});