This commit is contained in:
jarek committed 2026-06-17 08:21:38 +02:00
1 parent aa45be6844
commit efb634701c
56 files changed
+9101 -117

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
/**
* Resolve a changelog / release-notes URL for a container image (#538).
*
* Three tiers in priority order:
* 1. `dockhand.changelog.url` label — explicit override set by the image
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
* everything. Pattern matches the existing dockhand.* label convention.
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
* points at github.com the canonical changelog page is `<source>/releases`.
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
* `github.com/<owner>/<repo>`, so the release page is deterministic.
*
* No tier 3-style fuzzy match against Docker Hub. Wrong-repo matches are a
* worse UX than no link, and there is no good answer for unlabelled
* upstream images like `nginx:latest` (nginx's changelog isn't on GitHub).
*
* Pure function: deterministic, no I/O, safe to call from a render loop.
*/
const GITHUB_HOST = 'github.com';
const GHCR_PREFIX = 'ghcr.io/';
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function stripImageTag(image: string): string {
// "image@sha256:…" — split on @ first so we don't lose the digest fragment.
const atIdx = image.indexOf('@');
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
const colonIdx = withoutDigest.lastIndexOf(':');
// A colon before a slash is a port in a registry hostname, not a tag.
if (colonIdx > withoutDigest.lastIndexOf('/')) {
return withoutDigest.slice(0, colonIdx);
}
return withoutDigest;
}
export function resolveChangelogUrl(
imageName: string | null | undefined,
labels?: Record<string, string> | null
): string | null {
if (!imageName) return null;
const override = labels?.['dockhand.changelog.url'];
if (override && override.trim()) return override.trim();
const source = labels?.['org.opencontainers.image.source'];
if (source && source.includes(GITHUB_HOST)) {
return stripTrailingSlash(source) + '/releases';
}
if (imageName.startsWith(GHCR_PREFIX)) {
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
// Sanity guard: GHCR images are always owner/repo. Single-segment values
// like `ghcr.io/something` are malformed; skip rather than emit a bad URL.
if (repo.split('/').length >= 2) {
return `https://github.com/${repo}/releases`;
}
}
return null;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Pangolin label → public URL extraction (#2 follow-up).
*
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
* a container with one or more proxy resources. The relevant labels for URL
* extraction are:
*
* pangolin.proxy-resources.<name>.name human-friendly label
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
*
* The `targets[N].port` family is intentionally ignored — Pangolin terminates
* the public connection at full-domain; the internal target port is not part
* of the URL a user sees.
*
* Returns one URL per resource that declares a full-domain. Multiple
* resources on the same container yield multiple URLs. Identical URLs across
* different resources are deduped.
*
* dockhand.url labels override this — Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
*/
export interface PangolinUrl {
url: string;
/** The Pangolin resource key (the `<name>` in the label key). */
resource: string;
/** Optional human-friendly name from the `.name` label, if set. */
displayName?: string;
}
const RESOURCE_KEY_RE =
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
export function extractPangolinUrls(
labels: Record<string, string> | undefined | null
): PangolinUrl[] {
if (!labels) return [];
// Group label values by resource key.
const byResource = new Map<
string,
{ fullDomain?: string; protocol?: string; name?: string }
>();
for (const [key, value] of Object.entries(labels)) {
const m = key.match(RESOURCE_KEY_RE);
if (!m) continue;
const [, resource, field] = m;
let entry = byResource.get(resource);
if (!entry) {
entry = {};
byResource.set(resource, entry);
}
const v = (value ?? '').trim();
if (!v) continue;
if (field === 'full-domain') entry.fullDomain = v;
else if (field === 'protocol') entry.protocol = v.toLowerCase();
else if (field === 'name') entry.name = v;
}
const out: PangolinUrl[] = [];
const seen = new Set<string>();
for (const [resource, entry] of byResource) {
if (!entry.fullDomain) continue;
const proto =
entry.protocol === 'http' || entry.protocol === 'https'
? entry.protocol
: 'https';
const url = `${proto}://${entry.fullDomain}`;
if (seen.has(url)) continue;
seen.add(url);
out.push({
url,
resource,
displayName: entry.name
});
}
return out;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Traefik label → public URL extraction (#2).
*
* Parses standard Traefik v2/v3 router labels on a container:
* traefik.http.routers.<name>.rule e.g. Host(`app.example.com`)
* traefik.http.routers.<name>.entrypoints e.g. websecure
* traefik.http.routers.<name>.tls e.g. true
*
* Returns one URL per router whose rule contains at least one Host() match.
* Multiple Host() entries on the same router yield multiple URLs. Path
* prefixes (e.g. `Host(`x`) && PathPrefix(`/api`)`) are appended so the link
* lands on the actual served path.
*
* Scheme inference (in order):
* 1. explicit `tls=true` → https
* 2. entrypoints contains "websecure" → https
* 3. entrypoints contains "web" (and no secure variant) → http
* 4. default → https (the overwhelmingly common production setup)
*
* dockhand.url labels override this — Traefik extraction is a fallback,
* never a winner over an explicit user-provided URL.
*/
export interface TraefikUrl {
url: string;
router: string;
}
const ROUTER_RULE_RE = /^traefik\.http\.routers\.([^.]+)\.rule$/;
// Host(`a.b.c`) or Host("a.b.c") or Host('a.b.c') — Traefik accepts all three quotings.
const HOST_RE = /Host\(\s*[`"']([^`"']+)[`"']\s*\)/g;
// PathPrefix(`/api`) — same quoting variations.
const PATH_PREFIX_RE = /PathPrefix\(\s*[`"']([^`"']+)[`"']\s*\)/;
export function extractTraefikUrls(
labels: Record<string, string> | undefined | null
): TraefikUrl[] {
if (!labels) return [];
const out: TraefikUrl[] = [];
const seen = new Set<string>();
for (const [key, rule] of Object.entries(labels)) {
const m = key.match(ROUTER_RULE_RE);
if (!m) continue;
const router = m[1];
const hosts: string[] = [];
let hostMatch: RegExpExecArray | null;
HOST_RE.lastIndex = 0;
while ((hostMatch = HOST_RE.exec(rule)) !== null) {
hosts.push(hostMatch[1]);
}
if (hosts.length === 0) continue;
const pathMatch = rule.match(PATH_PREFIX_RE);
const path = pathMatch ? pathMatch[1] : '';
const scheme = inferScheme(labels, router);
for (const host of hosts) {
const url = `${scheme}://${host}${path}`;
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, router });
}
}
return out;
}
function inferScheme(labels: Record<string, string>, router: string): 'http' | 'https' {
const tls = labels[`traefik.http.routers.${router}.tls`];
if (tls && /^true$/i.test(tls.trim())) return 'https';
const entrypoints = labels[`traefik.http.routers.${router}.entrypoints`] || '';
const eps = entrypoints
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (eps.includes('websecure') || eps.includes('https')) return 'https';
if (eps.length > 0 && (eps.includes('web') || eps.includes('http'))) return 'http';
return 'https';
}