diff --git a/docs/src/content/docs/targets/docker.md b/docs/src/content/docs/targets/docker.md index 40b663e8..9c6aac50 100644 --- a/docs/src/content/docs/targets/docker.md +++ b/docs/src/content/docs/targets/docker.md @@ -18,11 +18,21 @@ Copies an existing source image tagged with the revision SHA to a new target tag |----------|-------------| | `image` | Docker image path (e.g., `ghcr.io/org/image`) | | `registry` | Override the registry (auto-detected from `image`) | -| `format` | Format template. Default: `{{{source}}}:{{{revision}}}` for source | +| `format` | Format template (see below). Default: `{{{source}}}:{{{revision}}}` for source, `{{{target}}}:{{{version}}}` for target | | `usernameVar` | Env var name for username | | `passwordVar` | Env var name for password | | `skipLogin` | Skip `docker login` for this registry | +### Format Template Variables + +| Variable | Description | +|----------|-------------| +| `{{{image}}}` | The `image` value from the current block (source or target) | +| `{{{source}}}` | The source image path | +| `{{{target}}}` | The target image path | +| `{{{revision}}}` | Git revision SHA (source format only) | +| `{{{version}}}` | Release version (target format only) | + ## Environment Variables **Target Registry Credentials** (resolved in order): @@ -91,6 +101,25 @@ targets: target: getsentry/craft ``` +### Custom Format with "latest" Tag + +```yaml +targets: + # Versioned tag + - name: docker + id: versioned + source: ghcr.io/getsentry/spotlight + target: ghcr.io/getsentry/spotlight + + # Latest tag + - name: docker + id: latest + source: ghcr.io/getsentry/spotlight + target: + image: ghcr.io/getsentry/spotlight + format: "{{{image}}}:latest" +``` + ### Google Cloud Registries Craft auto-detects Google Cloud registries and uses `gcloud auth configure-docker`: diff --git a/src/targets/docker.ts b/src/targets/docker.ts index a4e950f4..5309b0b1 100644 --- a/src/targets/docker.ts +++ b/src/targets/docker.ts @@ -549,7 +549,9 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab } else if ( sourceRegistry && !source.skipLogin && - !gcrConfiguredRegistries.has(sourceRegistry) + !gcrConfiguredRegistries.has(sourceRegistry) && + // Don't warn if source and target share the same registry - target login will cover it + sourceRegistry !== targetRegistry ) { // Source registry needs auth but we couldn't configure it // This is okay - source might be public or already authenticated @@ -582,11 +584,13 @@ Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variab const { source, target } = this.dockerConfig; const sourceImage = renderTemplateSafe(source.format, { + image: source.image, source: source.image, target: target.image, revision: sourceRevision, }); const targetImage = renderTemplateSafe(target.format, { + image: target.image, source: source.image, target: target.image, version, diff --git a/src/utils/__tests__/strings.test.ts b/src/utils/__tests__/strings.test.ts index 017d4f04..b0fc140b 100644 --- a/src/utils/__tests__/strings.test.ts +++ b/src/utils/__tests__/strings.test.ts @@ -5,6 +5,7 @@ import { formatSize, formatJson, } from '../strings'; +import { ConfigurationError } from '../errors'; describe('sanitizeObject', () => { test('processes empty object', () => { @@ -69,8 +70,31 @@ describe('renderTemplateSafe', () => { ); }); - test('does not render globals', () => { - expect(renderTemplateSafe('{{ process }}', {})).toBe(''); + test('throws error on unknown variable', () => { + expect(() => renderTemplateSafe('{{ unknown }}', { known: 'value' })).toThrow( + ConfigurationError + ); + expect(() => renderTemplateSafe('{{ unknown }}', { known: 'value' })).toThrow( + /Unknown template variable\(s\): unknown/ + ); + }); + + test('throws error with available variables in message', () => { + expect(() => + renderTemplateSafe('{{ missing }}', { foo: 1, bar: 2 }) + ).toThrow(/Available variables: foo, bar/); + }); + + test('throws error for globals (prevents accidental access)', () => { + expect(() => renderTemplateSafe('{{ process }}', {})).toThrow( + ConfigurationError + ); + }); + + test('throws error listing all unknown variables', () => { + expect(() => + renderTemplateSafe('{{ a }} {{ b }} {{ c }}', { x: 1 }) + ).toThrow(/Unknown template variable\(s\): a, b, c/); }); }); diff --git a/src/utils/strings.ts b/src/utils/strings.ts index d74a237a..2f8fbb3a 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -1,6 +1,8 @@ import * as mustache from 'mustache'; import * as util from 'util'; +import { ConfigurationError } from './errors'; + /** * Sanitizes object attributes * @@ -41,21 +43,55 @@ export function sanitizeObject(obj: Record): any { return result; } +// Store the original lookup method +const originalLookup = mustache.Context.prototype.lookup; + /** * Renders the given template in a safe way * * No expressions or logic is allowed, only values and attribute access (via * dots) are allowed. Under the hood, Mustache templates are used. * + * This function throws a ConfigurationError if any template variable is not + * found in the context, preventing silent failures from typos or missing data. + * * @param template Mustache template * @param context Template data * @returns Rendered template + * @throws ConfigurationError if an unknown template variable is used */ export function renderTemplateSafe( template: string, context: Record ): string { - return mustache.render(template, sanitizeObject(context)); + const sanitizedContext = sanitizeObject(context); + const unknownVars: string[] = []; + + // Temporarily override lookup to collect unknown variables + mustache.Context.prototype.lookup = function (name: string) { + const value = originalLookup.call(this, name); + if (value === undefined) { + unknownVars.push(name); + } + return value; + }; + + try { + const result = mustache.render(template, sanitizedContext); + + if (unknownVars.length > 0) { + const availableVars = Object.keys(sanitizedContext).join(', '); + const unknownList = [...new Set(unknownVars)].join(', '); + throw new ConfigurationError( + `Unknown template variable(s): ${unknownList}. Available variables: ${availableVars}` + ); + } + + return result; + } finally { + // Always restore the original lookup + mustache.Context.prototype.lookup = originalLookup; + } } /** @@ -81,7 +117,7 @@ export function formatSize(size: number): string { * * @param obj Object to print out */ - + export function formatJson(obj: any): string { const result = JSON.stringify(obj, null, 4); if (obj instanceof Error && result === '{}') {