1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user