feat: replace CLI script with web UI (Joe) for listing outdated applications
This commit is contained in:
parent
e4a01bb96c
commit
76a8909dc1
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
.env.local
|
9
Makefile
Normal file
9
Makefile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
dev:
|
||||||
|
@make tdd&
|
||||||
|
bun --hot run joe.ts
|
||||||
|
|
||||||
|
test:
|
||||||
|
bun test
|
||||||
|
|
||||||
|
tdd:
|
||||||
|
bun test --watch
|
@ -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
39
joe.ts
Normal 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
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "^1.0.25"
|
||||||
|
}
|
||||||
|
}
|
91
routes/applications.ts
Normal file
91
routes/applications.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
};
|
82
routes/applications.update.ts
Normal file
82
routes/applications.update.ts
Normal 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
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;
|
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"module": "commonjs",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
33
ui/Layout.ts
Normal file
33
ui/Layout.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user