Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/vscode/src/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`,
// of going untested.
const UNCHECKED_VERDICTS: Record<UncheckedReason, ReferenceVerdict> = {
'no-tag': { kind: 'unverifiable', reason: 'no-tag' },
'no-registry': { kind: 'unverifiable', reason: 'no-registry' },
'guessed-registry': { kind: 'unverifiable', reason: 'guessed-registry' },
'needs-login': { kind: 'unverifiable', reason: 'needs-login', registry: REPOSITORY },
'authentication-failure': { kind: 'unverifiable', reason: 'authentication-failure' },
'network-error': { kind: 'unverifiable', reason: 'network-error' },
Expand Down Expand Up @@ -41,6 +41,7 @@ function createCheck(verdict: ReferenceVerdict): ReferenceCheck {
reference: {
repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) },
tag: { text: TAG, range: rangeOfText(TAG) },
registry: undefined,
},
verdict,
};
Expand Down
6 changes: 4 additions & 2 deletions apps/vscode/src/hover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ const VERIFIED_VERDICT: ReferenceVerdict = { kind: 'exists', registry: 'mirror.e
// the build here instead of hovering with someone else's explanation.
const UNCHECKED_REASON_SENTENCES: Record<UncheckedReason, string> = {
'no-tag': 'the reference names no tag to check.',
'no-registry': 'the repository names no registry host.',
'guessed-registry': 'nothing here names a registry, and Docker Hub — the only one left to try — does not have it.',
'needs-login': 'no local Docker credential for that registry. Run `docker login` against it.',
'authentication-failure': 'the registry refused the local Docker credential for it.',
'network-error': 'the registry could not be reached.',
'unexpected-response': 'the registry answered in a form this extension does not understand.',
'malformed-reference': 'the tag is not a valid OCI tag.',
'malformed-reference': 'the reference is not a valid image reference.',
};

/** The unverifiable verdict a reason produces. Only `needs-login` carries a registry, so the shape cannot be built generically. */
Expand All @@ -41,6 +41,7 @@ function createCheck(verdict: ReferenceVerdict): ReferenceCheck {
reference: {
repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) },
tag: { text: TAG, range: rangeOfText(TAG) },
registry: undefined,
},
verdict,
};
Expand All @@ -52,6 +53,7 @@ function createTaglessCheck(verdict: ReferenceVerdict): ReferenceCheck {
reference: {
repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) },
tag: undefined,
registry: undefined,
},
verdict,
};
Expand Down
4 changes: 2 additions & 2 deletions apps/vscode/src/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { containsOffset } from './source-range';
// instead of hovering with no explanation.
const UNCHECKED_REASON_TEXT: Record<UncheckedReason, string> = {
'no-tag': 'the reference names no tag to check.',
'no-registry': 'the repository names no registry host.',
'guessed-registry': 'nothing here names a registry, and Docker Hub — the only one left to try — does not have it.',
'needs-login': 'no local Docker credential for that registry. Run `docker login` against it.',
'authentication-failure': 'the registry refused the local Docker credential for it.',
'network-error': 'the registry could not be reached.',
'unexpected-response': 'the registry answered in a form this extension does not understand.',
'malformed-reference': 'the tag is not a valid OCI tag.',
'malformed-reference': 'the reference is not a valid image reference.',
};

/**
Expand Down
33 changes: 33 additions & 0 deletions apps/vscode/src/marks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function createCheck(verdict: ReferenceVerdict): ReferenceCheck {
reference: {
repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) },
tag: { text: TAG, range: rangeOfText(TAG) },
registry: undefined,
},
verdict,
};
Expand Down Expand Up @@ -81,6 +82,38 @@ describe('marks', () => {
expect(otherHost?.renderOptions?.after?.contentText).toBe(' ✓ mirror.example.com');
});

it('should stay silent when the answering registry is the one the file declared', () => {
const declaredYaml = ['global:', ' imageRegistry: ghcr.io', 'image:', ' repository: my-org/svc', ' tag: "1.0"', ''].join('\n');
const document = createFakeDocument('/repo/chart/values.yaml', declaredYaml);
const start = declaredYaml.indexOf('my-org/svc');
const reference = {
repository: { text: 'my-org/svc', range: { start, end: start + 'my-org/svc'.length } },
tag: undefined,
registry: 'ghcr.io',
};

const [mark] = marksFor(document, [{ reference, verdict: { kind: 'exists', registry: 'ghcr.io' } }]);

// The file says `ghcr.io` two lines up. Appending it restates the file.
expect(mark?.renderOptions?.after?.contentText).toBe(' ✓');
});

it('should name the answering registry for a repository that names no host at all', () => {
const bareYaml = ['image:', ' repository: nginx', ' tag: "1.0"', ''].join('\n');
const document = createFakeDocument('/repo/chart/values.yaml', bareYaml);
const reference = {
repository: { text: 'nginx', range: { start: bareYaml.indexOf('nginx'), end: bareYaml.indexOf('nginx') + 'nginx'.length } },
tag: undefined,
registry: undefined,
};

const [mark] = marksFor(document, [{ reference, verdict: { kind: 'exists', registry: 'docker.io' } }]);

// A file that spells out no registry cannot have the answer restated to
// it, so naming Docker Hub is the only way the mark says where it looked.
expect(mark?.renderOptions?.after?.contentText).toBe(' ✓ docker.io');
});

it('should place the mark on the repository value', () => {
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
const [mark] = marksFor(document, [createCheck(VERIFIED_VERDICT)]);
Expand Down
14 changes: 12 additions & 2 deletions apps/vscode/src/marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,21 @@ function markKindOf(verdict: ReferenceVerdict): MarkKind {
}

/**
* The answering registry, named only when it differs from the host the file
* The registry the file itself names for a reference: a host spelled out in
* the repository, else the one the document declared for it. `undefined` when the
* file names none, which is not the same as naming Docker Hub — the check
* only guessed it, and the mark is the one place that difference shows.
*/
function registryNamedByFile(reference: ImageReference): string | undefined {
return resolveExplicitHost(reference.repository.text)?.host ?? reference.registry;
}

/**
* The answering registry, named only when it differs from the one the file
* names, so the mark carries information instead of restating the line.
*/
function registrySuffixOf(reference: ImageReference, verdict: ReferenceVerdict): string {
if (verdict.kind !== 'exists' || verdict.registry === resolveExplicitHost(reference.repository.text)?.host) {
if (verdict.kind !== 'exists' || verdict.registry === registryNamedByFile(reference)) {
return '';
}

Expand Down
39 changes: 39 additions & 0 deletions apps/vscode/src/reference-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const REPOSITORY = 'docker.io/library/nginx';
const TAG = '1.19';
const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: ${TAG}`, ''].join('\n');
const TAGLESS_VALUES_YAML = ['image:', ' repository: registry.example.com/svc', ' pullPolicy: IfNotPresent', ''].join('\n');
// A public registry, because a private one is settled as `needs-login`
// before any request and would say nothing about where the check was sent.
const DECLARED_REGISTRY_VALUES_YAML = ['global:', ' imageRegistry: ghcr.io', 'image:', ' repository: my-org/my-service', ' tag: 1.0.0', ''].join(
'\n'
);
const BARE_VALUES_YAML = ['image:', ' repository: my-service', ' tag: 1.0.0', ''].join('\n');
const PUBLIC_BARE_VALUES_YAML = ['image:', ' repository: nginx', ' tag: 1.19', ''].join('\n');

/** Checks a document this feature is expected to have something to say about. */
async function checkValuesFile(document: vscode.TextDocument, fetch: FetchLike): Promise<DocumentChecks> {
Expand Down Expand Up @@ -75,6 +82,38 @@ describe('reference-check', () => {
expect(fetch).toHaveBeenCalledTimes(1);
});

it('should check a bare repository against the registry its document declares', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
const document = createFakeDocument('/repo/chart/values.yaml', DECLARED_REGISTRY_VALUES_YAML);

const { checks } = await checkValuesFile(document, fetch);

expect(fetch).toHaveBeenCalledWith('https://ghcr.io/v2/my-org/my-service/manifests/1.0.0', expect.anything());
expect(checks[0]?.verdict).toEqual({ kind: 'exists', registry: 'ghcr.io' });
});

it('should leave a bare repository Docker Hub has never heard of unverifiable rather than missing', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(404, { errors: [{ code: 'NAME_UNKNOWN' }] }));
const document = createFakeDocument('/repo/chart/values.yaml', BARE_VALUES_YAML);

const { checks } = await checkValuesFile(document, fetch);

// An internal service name is the ordinary case in this organisation's
// charts, and Docker Hub was only ever a guess about where to look.
// `diagnostics.test.ts` is where that reason is pinned to no diagnostic.
expect(checks[0]?.verdict).toEqual({ kind: 'unverifiable', reason: 'guessed-registry' });
});

it('should verify a bare public name against Docker Hub under the library namespace', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
const document = createFakeDocument('/repo/chart/values.yaml', PUBLIC_BARE_VALUES_YAML);

const { checks } = await checkValuesFile(document, fetch);

expect(fetch).toHaveBeenCalledWith('https://registry-1.docker.io/v2/library/nginx/manifests/1.19', expect.anything());
expect(checks[0]?.verdict).toEqual({ kind: 'exists', registry: 'docker.io' });
});

it('should tag the checks with the version of the document they describe', async () => {
const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200));
const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML);
Expand Down
8 changes: 7 additions & 1 deletion apps/vscode/src/reference-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ async function checkImageReferencesInDocument(document: vscode.TextDocument, dep
verdict:
reference.tag === undefined
? ({ kind: 'unverifiable', reason: 'no-tag' } as const)
: await checkImageExistence({ repository: reference.repository.text, tag: reference.tag.text, fetch, credentials }),
: await checkImageExistence({
repository: reference.repository.text,
tag: reference.tag.text,
declaredRegistry: reference.registry,
fetch,
credentials,
}),
}))
);

Expand Down
108 changes: 108 additions & 0 deletions packages/helm/src/extract-image-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,114 @@ describe('extractImageReferences', () => {
});
});

describe('registry', () => {
it('should report a sibling registry key as the reference registry', () => {
const source = ['image:', ' repository: my-service', ' registry: sibling.example.com', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('sibling.example.com');
});

it('should apply a top-level registry key to a reference with no sibling registry', () => {
const source = ['registry: top.example.com', 'image:', ' repository: my-service', ' tag: 1.0', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('top.example.com');
});

it('should prefer global.imageRegistry over a top-level registry', () => {
const source = ['global:', ' imageRegistry: global.example.com', 'registry: top.example.com', 'image:', ' repository: my-service', ''].join(
'\n'
);

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('global.example.com');
});

it('should apply global.registry when global.imageRegistry is absent', () => {
const source = ['global:', ' registry: global.example.com', 'image:', ' repository: my-service', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('global.example.com');
});

it('should prefer a sibling registry over a document-level one', () => {
const source = [
'global:',
' imageRegistry: global.example.com',
'image:',
' repository: my-service',
' registry: sibling.example.com',
'',
].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('sibling.example.com');
});

it('should fall through to the document-level registry when the sibling registry is templated', () => {
const source = [
'global:',
' imageRegistry: global.example.com',
'image:',
' repository: my-service',
' registry: "{{ .Values.global.registry }}"',
'',
].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('global.example.com');
});

it('should fall through to the next document-level path when the first holds a non-scalar value', () => {
const source = [
'global:',
' imageRegistry:',
' host: global.example.com',
'registry: top.example.com',
'image:',
' repository: my-service',
'',
].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('top.example.com');
});

it('should report no registry for a document that declares none', () => {
const source = ['image:', ' repository: docker.io/library/nginx', ' tag: 1.19', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBeUndefined();
});

it('should report the registry as written, with one layer of quotes stripped', () => {
const source = ['image:', ' repository: my-service', ' registry: "myreg.example.com"', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.registry).toBe('myreg.example.com');
});

it('should report the repository and tag unchanged for a reference under a declared registry', () => {
const source = ['global:', ' imageRegistry: global.example.com', 'image:', ' repository: my-service', ' tag: 1.10', ''].join('\n');

const [reference] = extractImageReferences(source);

expect(reference?.repository.text).toBe('my-service');
expect(reference?.tag?.text).toBe('1.10');
expect(reference?.registry).toBe('global.example.com');
});
});

describe('Helm template syntax', () => {
it('should produce no reference when the repository contains template syntax', () => {
const source = ['image:', ' repository: "{{ .Values.global.registry }}/nginx"', ' tag: 1.19', ''].join('\n');
Expand Down
Loading