compose generated from a container now captures network config and resource limits
This commit is contained in:
11 files changed
+831
-39
No files matched your search
@@ -514,6 +514,50 @@ export const RULES: RuleDefinition[] = [
|
||||
return out;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'UNUSED_VOLUME',
|
||||
description: 'A top-level named volume is defined but never mounted by any service',
|
||||
group: 'correctness',
|
||||
defaultSeverity: 'info',
|
||||
check(p) {
|
||||
const definedVolumes = asRecord(p.doc?.volumes) ?? {};
|
||||
const definedNames = Object.keys(definedVolumes);
|
||||
if (definedNames.length === 0) return [];
|
||||
|
||||
// Collect every named-volume source referenced by any service (same extraction
|
||||
// as UNDEFINED_VOLUME_REF: a bare token, or a long-form { type: volume, source }).
|
||||
const used = new Set<string>();
|
||||
for (const svc of Object.values(services(p))) {
|
||||
const vols = Array.isArray(svc.volumes) ? svc.volumes : [];
|
||||
for (const v of vols) {
|
||||
if (typeof v === 'string') {
|
||||
const left = v.split(':')[0];
|
||||
if (left && !left.startsWith('/') && !left.startsWith('.') && !left.startsWith('~')) {
|
||||
used.add(left);
|
||||
}
|
||||
} else {
|
||||
const o = asRecord(v);
|
||||
if (o?.type === 'volume' && typeof o.source === 'string') used.add(o.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out: RuleFinding[] = [];
|
||||
for (const vol of definedNames) {
|
||||
if (used.has(vol)) continue;
|
||||
// An external volume is often declared just so compose won't try to create it;
|
||||
// it may be mounted elsewhere, so don't flag it as unused.
|
||||
if (asRecord(definedVolumes[vol])?.external) continue;
|
||||
out.push({
|
||||
ruleId: 'UNUSED_VOLUME',
|
||||
message: `Named volume "${vol}" is defined but no service mounts it`,
|
||||
hint: `Mount it from a service under volumes:, or remove it from the top-level volumes: if it is not needed.`,
|
||||
line: p.lineOf(['volumes', vol]) ?? p.lineOf(['volumes'])
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'CONTAINER_NAME_COLLISION',
|
||||
description: 'container_name is already used by a container on the environment',
|
||||
@@ -1041,6 +1085,31 @@ export const RULES: RuleDefinition[] = [
|
||||
return out;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'MISSING_HEALTHCHECK',
|
||||
description: 'A service has no healthcheck, so Docker cannot tell whether it is actually working',
|
||||
group: 'reliability',
|
||||
defaultSeverity: 'info',
|
||||
check(p) {
|
||||
const out: RuleFinding[] = [];
|
||||
for (const [name, svc] of Object.entries(services(p))) {
|
||||
// A compose-declared healthcheck, or one explicitly turned off, is a
|
||||
// deliberate choice - don't nag either way. We only see the compose file,
|
||||
// not the image's own HEALTHCHECK, so this stays an info-level hint.
|
||||
const hc = asRecord(svc.healthcheck);
|
||||
if (hc) continue;
|
||||
if ('healthcheck' in svc) continue;
|
||||
out.push({
|
||||
ruleId: 'MISSING_HEALTHCHECK',
|
||||
service: name,
|
||||
message: `"${name}" has no healthcheck - Docker reports it as running even if the app inside has stopped responding`,
|
||||
hint: 'Add a healthcheck so restart policies and depends_on: condition: service_healthy can react to a hung container. The image may already ship one.',
|
||||
line: p.lineOf(['services', name])
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'SENSITIVE_SERVICE_BROAD_EXPOSURE',
|
||||
description: 'An admin/management UI publishes a port on all interfaces',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { parseTag, compareParts } from './tag-parser';
|
||||
import type { ReleaseSource } from './release-source';
|
||||
import { resolveReleaseSourceCandidates } from './release-source';
|
||||
import { isSafeNotificationUrl } from '../url-safety';
|
||||
|
||||
export interface ReleaseNote {
|
||||
@@ -152,3 +153,40 @@ export async function fetchReleaseNotes(
|
||||
|
||||
return { notes: matchReleasesToVersions(collected, wanted), rateLimited };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a release-note source for an image and fetch notes, with a validated
|
||||
* fallback when the image carries no `org.opencontainers.image.source` label.
|
||||
*
|
||||
* A CONFIDENT source (label / ghcr name) is used directly. Otherwise each GUESSED
|
||||
* candidate (image-name slug, github URL in another label) is tried in order and
|
||||
* ACCEPTED only if the forge returns at least one release matching a wanted version
|
||||
* - so a wrong same-named repo yields no notes and is discarded (never guess a repo
|
||||
* blind). Returns the accepted source's slug/releasesUrl too, so the caller can
|
||||
* surface the correct "View releases" link.
|
||||
*/
|
||||
export async function resolveAndFetchReleaseNotes(
|
||||
imageName: string | null | undefined,
|
||||
labels: Record<string, string> | null | undefined,
|
||||
wanted: string[],
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ReleaseNotesResult & { source: ReleaseSource | null }> {
|
||||
const candidates = resolveReleaseSourceCandidates(imageName, labels);
|
||||
|
||||
// Preserve a rate-limit signal even when every guessed candidate is discarded:
|
||||
// the notes may be hidden purely because GitHub's unauthenticated limit is spent,
|
||||
// and the UI's "set a GitHub token" hint keys off this flag.
|
||||
let sawRateLimit = false;
|
||||
for (const src of candidates) {
|
||||
const result = await fetchReleaseNotes(src, wanted, fetchImpl);
|
||||
sawRateLimit ||= result.rateLimited;
|
||||
// A confident source is authoritative even with no matched notes (the newer
|
||||
// tag may simply predate the API's recent-releases window). A guessed source
|
||||
// is only trusted once a note actually matches a wanted version.
|
||||
if (!src.needsValidation || result.notes.length > 0) {
|
||||
return { ...result, source: src };
|
||||
}
|
||||
}
|
||||
|
||||
return { notes: [], rateLimited: sawRateLimit, source: null };
|
||||
}
|
||||
@@ -12,8 +12,11 @@
|
||||
* `org.opencontainers.image.source` label wins, then a `ghcr.io/<owner>/<repo>`
|
||||
* image name (always GitHub).
|
||||
*
|
||||
* Pure, no I/O, unit-tested. Images with no recognizable source resolve to null
|
||||
* (we never guess a repo - a wrong repo is worse than no notes).
|
||||
* Pure, no I/O, unit-tested. `resolveReleaseSource` returns only a CONFIDENT source
|
||||
* (label or ghcr name) or null - it never guesses. `resolveReleaseSourceCandidates`
|
||||
* additionally offers GUESSED fallbacks (image name / other labels) that the caller
|
||||
* must validate against the wanted version before trusting (a wrong repo is worse
|
||||
* than no notes).
|
||||
*/
|
||||
|
||||
const GHCR_PREFIX = 'ghcr.io/';
|
||||
@@ -28,6 +31,10 @@ export interface ReleaseSource {
|
||||
apiBase: string;
|
||||
/** Human-facing releases page (for the "View releases" fallback link). */
|
||||
releasesUrl: string;
|
||||
/** True for a GUESSED source (image name / a non-source label). A guessed repo is
|
||||
* only trusted once the API confirms a release matching the wanted version -
|
||||
* the caller drops it if no note matches (a wrong repo is worse than no notes). */
|
||||
needsValidation?: boolean;
|
||||
}
|
||||
|
||||
/** Pull `owner/repo` out of a forge URL, tolerating trailing slash / `.git`. */
|
||||
@@ -99,6 +106,70 @@ export function resolveReleaseSource(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pull an `owner/repo` slug from a github.com or ghcr.io URL anywhere in a string. */
|
||||
function githubSlugFromText(text: string): string | null {
|
||||
const m = text.match(/(?:github\.com|ghcr\.io)\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/);
|
||||
if (!m || !m[1] || !m[2]) return null;
|
||||
return `${m[1].toLowerCase()}/${m[2].replace(/\.git$/, '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** `owner/repo` from an image name, stripping registry host, tag and digest. */
|
||||
function slugFromImageName(imageName: string): string | null {
|
||||
const repo = stripImageTag(imageName);
|
||||
const parts = repo.split('/');
|
||||
// Drop a leading registry host segment (has a dot or a port), e.g. ghcr.io, docker.io.
|
||||
if (parts.length > 2 && /[.:]/.test(parts[0])) parts.shift();
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve release-note sources for an image, best-first. The first entry (if any)
|
||||
* is the CONFIDENT source (`org.opencontainers.image.source` label, or a ghcr image
|
||||
* name). Any following entries are GUESSED fallbacks (`needsValidation: true`), used
|
||||
* only when no confident source exists: an `owner/repo` slug taken from the image
|
||||
* name treated as a GitHub repo, and any github.com / ghcr.io URL found in OTHER
|
||||
* labels/annotations. Guessed candidates are deduped, capped (a crafted label set
|
||||
* must not fan out into many outbound API calls), and must be validated by the
|
||||
* caller (a release tag matching the wanted version) before their notes are shown.
|
||||
*/
|
||||
export function resolveReleaseSourceCandidates(
|
||||
imageName: string | null | undefined,
|
||||
labels?: Record<string, string> | null
|
||||
): ReleaseSource[] {
|
||||
const confident = resolveReleaseSource(imageName, labels);
|
||||
if (confident) return [confident];
|
||||
|
||||
// Cap guessed candidates: each becomes one outbound GitHub API call at fetch time,
|
||||
// and a container can carry arbitrarily many labels, so a crafted label set must
|
||||
// not amplify into a fan-out. The real source is almost always the first one or two.
|
||||
const MAX_GUESSES = 5;
|
||||
const seen = new Set<string>();
|
||||
const candidates: ReleaseSource[] = [];
|
||||
const add = (slug: string | null) => {
|
||||
if (!slug || candidates.length >= MAX_GUESSES) return;
|
||||
const key = slug.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push({ ...githubSource(key), needsValidation: true });
|
||||
};
|
||||
|
||||
// (a) owner/repo from the image name, as a GitHub repo (covers Docker Hub
|
||||
// images whose name mirrors their GitHub slug, e.g. grafana/grafana).
|
||||
if (imageName) add(slugFromImageName(imageName));
|
||||
|
||||
// (b) any github.com / ghcr.io URL in a NON-source label or annotation
|
||||
// (url, documentation, vendor-specific), first match per label.
|
||||
if (labels) {
|
||||
for (const [k, v] of Object.entries(labels)) {
|
||||
if (k === 'org.opencontainers.image.source' || !v) continue;
|
||||
add(githubSlugFromText(v));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function githubSource(slug: string): ReleaseSource {
|
||||
return {
|
||||
kind: 'github',
|
||||
|
||||
Reference in new issue
Block a user