Add homelab installer and services (allprox, authelia, lldap)

This commit is contained in:
Ezequiel C. 2026-09-01 18:32:56 +02:00
parent 68b23f1cbe
commit bb754cdd8c
32 changed files with 2356 additions and 1 deletions

170
installer/src/github.ts Normal file
View file

@ -0,0 +1,170 @@
import { readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import { join } from "node:path";
import type { Catalog, CatalogEntry, ServiceMetadata } from "./types.ts";
import type { Config } from "./config.ts";
import { parseAndValidate } from "./metadata.ts";
const UA = "homelab-installer/1.0";
function rawUrl(cfg: Config, path: string): string {
return `https://raw.githubusercontent.com/${cfg.owner}/${cfg.repo}/${cfg.branch}/${path}`;
}
function apiUrl(cfg: Config, path: string): string {
return `https://api.github.com/repos/${cfg.owner}/${cfg.repo}/${path}`;
}
function authHeaders(cfg: Config, scheme: "token" | "bearer"): Record<string, string> {
return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {};
}
async function fetchText(
url: string,
cfg: Config,
scheme: "token" | "bearer" = "bearer",
): Promise<string> {
const res = await fetch(url, {
headers: {
"User-Agent": UA,
Accept: "application/vnd.github+json",
...authHeaders(cfg, scheme),
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return await res.text();
}
async function fetchJson<T>(url: string, cfg: Config): Promise<T> {
return JSON.parse(await fetchText(url, cfg)) as T;
}
/** Return the list of services (id + path) from the configured source. */
export async function listServices(cfg: Config): Promise<CatalogEntry[]> {
if (cfg.local && cfg.localPath) {
return listLocal(cfg.localPath);
}
// Preferred: a single committed catalog.json served from raw.githubusercontent.com.
try {
const catalog = await fetchJson<Catalog>(rawUrl(cfg, "services/catalog.json"), cfg);
if (Array.isArray(catalog.services)) return catalog.services;
} catch {
// Fall through to the git trees API below.
}
try {
return await listViaApi(cfg);
} catch (err) {
throw new Error(
`Could not read the catalog from ${cfg.owner}/${cfg.repo}@${cfg.branch}: ${(err as Error).message}. ` +
`Check --owner/--repo/--branch (and that the repo is pushed), set HOMELAB_GITHUB_TOKEN for a private repo, or use --local <path>.`,
);
}
}
function listLocal(dir: string): CatalogEntry[] {
const entries: CatalogEntry[] = [];
let names: string[] = [];
try {
names = readdirSync(dir);
} catch {
throw new Error(`Local services directory not found: ${dir}`);
}
for (const name of names.sort()) {
const metaPath = join(dir, name, "metadata.json");
if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue;
try {
const meta = parseAndValidate(readFileSync(metaPath, "utf8"), name);
entries.push({
id: meta.id,
version: meta.version,
path: `services/${name}/metadata.json`,
});
} catch {
// Ignore folders without a valid metadata.json.
}
}
return entries;
}
async function listViaApi(cfg: Config): Promise<CatalogEntry[]> {
const tree = await fetchJson<{ tree?: { path: string }[] }>(
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
cfg,
);
const paths = (tree.tree ?? [])
.map((t) => t.path)
.filter((p) => /^services\/[^/]+\/metadata\.json$/.test(p));
return paths.map((p) => {
const id = p.split("/")[1] ?? "unknown";
return { id, version: "unknown", path: p };
});
}
/** Fetch and validate a single service's metadata.json. */
export async function fetchMetadata(id: string, cfg: Config): Promise<ServiceMetadata> {
let raw: string;
if (cfg.local && cfg.localPath) {
raw = readFileSync(join(cfg.localPath, id, "metadata.json"), "utf8");
} else {
raw = await fetchText(rawUrl(cfg, `services/${id}/metadata.json`), cfg, "token");
}
return parseAndValidate(raw, id);
}
export interface ServiceFile {
path: string;
content: string;
}
/** Return every extra file in a service folder (excluding metadata.json). */
export async function fetchServiceFiles(id: string, cfg: Config): Promise<ServiceFile[]> {
if (cfg.local && cfg.localPath) {
return listLocalFiles(join(cfg.localPath, id));
}
const tree = await fetchJson<{ tree?: { path: string; type?: string }[] }>(
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
cfg,
);
const prefix = `services/${id}/`;
const paths = (tree.tree ?? [])
.filter((t) => t.type === "blob" && t.path.startsWith(prefix) && t.path !== `${prefix}metadata.json`)
.map((t) => t.path);
const files: ServiceFile[] = [];
for (const p of paths) {
const rel = p.slice(prefix.length);
const content = await fetchText(rawUrl(cfg, p), cfg, "token");
files.push({ path: rel, content });
}
return files;
}
function listLocalFiles(dir: string): ServiceFile[] {
const out: ServiceFile[] = [];
walk(dir, "", out);
return out;
}
function walk(base: string, rel: string, out: ServiceFile[]): void {
const full = rel ? join(base, rel) : base;
let entries: Dirent[];
try {
entries = readdirSync(full, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(base, relPath, out);
} else if (!(rel === "" && e.name === "metadata.json")) {
out.push({ path: relPath, content: readFileSync(join(base, relPath), "utf8") });
}
}
}