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
31 changes: 30 additions & 1 deletion docs/src/content/docs/targets/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`:
Expand Down
6 changes: 5 additions & 1 deletion src/targets/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 26 additions & 2 deletions src/utils/__tests__/strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatSize,
formatJson,
} from '../strings';
import { ConfigurationError } from '../errors';

describe('sanitizeObject', () => {
test('processes empty object', () => {
Expand Down Expand Up @@ -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/);
});
});

Expand Down
40 changes: 38 additions & 2 deletions src/utils/strings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as mustache from 'mustache';
import * as util from 'util';

import { ConfigurationError } from './errors';

/**
* Sanitizes object attributes
*
Expand Down Expand Up @@ -41,21 +43,55 @@ export function sanitizeObject(obj: Record<string, any>): 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, any>
): 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;
}
}

/**
Expand All @@ -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 === '{}') {
Expand Down
Loading