diff --git a/CHANGELOG.md b/CHANGELOG.md index 558ba88..ec0677c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `docker_auth:` top-level block for declaring host-level container registry credentials. On `up`, dokku-compose runs `dokku registry:login` for each entry so the docker daemon can pull from private registries — useful with `dokku git:from-image` flows. Passwords are masked in `--dry-run` output. Distinct from the per-app `apps..registry:` block, which configures push behavior. See `docs/reference/docker_auth.md`. + ## [0.9.3] - 2026-04-08 ### Fixed diff --git a/README.md b/README.md index 6b56415..61d2365 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ All features are idempotent — running `up` twice produces no changes. | Zero-Downtime Checks | Configure deploy checks, disable per process type | [checks](docs/reference/checks.md) | | Log Management | Log retention and vector sink configuration | [logs](docs/reference/logs.md) | | Plugins | Install Dokku plugins declaratively | [plugins](docs/reference/plugins.md) | +| Docker Auth | Authenticate the host docker daemon to private registries (e.g. ghcr.io) for `git:from-image` flows | [docker_auth](docs/reference/docker_auth.md) | | Postgres | Postgres services with optional S3 backups | [postgres](docs/reference/postgres.md) | | Redis | Redis service instances | [redis](docs/reference/redis.md) | | Service Links | Link postgres/redis services to apps | [plugins](docs/reference/plugins.md#linking-services-to-apps-appsapplinks) | diff --git a/docs/reference/docker_auth.md b/docs/reference/docker_auth.md new file mode 100644 index 0000000..b94e018 --- /dev/null +++ b/docs/reference/docker_auth.md @@ -0,0 +1,59 @@ +# Docker Auth + +Dokku docs: https://dokku.com/docs/deployment/registry-management/ + +Module: `src/modules/docker-auth.ts` + +## YAML Keys + +### Registry Login (`docker_auth.`) + +Authenticate the host docker daemon to one or more container registries. Useful when an app is deployed via `dokku git:from-image` against a private registry — without prior `docker login`, the daemon can't pull the image. + +```yaml +docker_auth: + ghcr.io: + username: "${GHCR_USERNAME}" + password: "${GHCR_PAT}" + + registry.example.com: + username: "ci-user" + password: "${REGISTRY_PASSWORD}" +``` + +On each `up` run, dokku-compose runs `dokku registry:login ` for every entry. Docker stores credentials in `~/.docker/config.json` and overwrites in place, so re-running is idempotent and rotation is just an `up` away. + +| Field | Required | Description | +|-------|----------|-------------| +| `username` | yes | Registry username. For GHCR, the GitHub user the PAT is scoped to. | +| `password` | yes | Registry password or PAT. Use `${VAR}` interpolation and an `op://` reference rather than committing the value. | + +## Distinct from `apps..registry` + +This top-level `docker_auth:` is host-global pull authentication. It is **not** the same as the per-app `apps..registry:` block, which sets `registry:set ` properties (push destinations and image-build behavior). The two work together: `docker_auth` lets the daemon pull, `registry` configures where built images get pushed. + +## Removal Semantics + +Removing an entry from `docker_auth:` does **not** automatically log out — host credentials persist. To revoke, run `dokku registry:logout ` manually on the host. Same posture as `plugins:` (installed plugins aren't auto-removed). + +## Secrets + +Passwords are masked in `--dry-run` output (last 4 characters preserved). Pass `--sensitive` to dump the unmasked command list — useful for debugging, dangerous to copy/paste. + +## Common Use Case: Private GHCR + +```yaml +docker_auth: + ghcr.io: + username: "${GHCR_USERNAME}" + password: "${GHCR_PAT}" +``` + +With a `qlustr.env`-style file: + +``` +GHCR_USERNAME=op://strates-${APP_ENV}/github/user +GHCR_PAT=op://strates-${APP_ENV}/github/packages-pat +``` + +And `op run --env-file=qlustr.env -- dokku-compose up`, the values are resolved from 1Password at apply time and never written to disk on the host. diff --git a/dokku-compose.yml.example b/dokku-compose.yml.example index a05bde2..596ba3f 100644 --- a/dokku-compose.yml.example +++ b/dokku-compose.yml.example @@ -17,6 +17,13 @@ plugins: url: https://github.com/dokku/dokku-letsencrypt.git script: scripts/letsencrypt.sh # custom handler (not a service plugin) +# Authenticate host docker daemon to private registries (for `git:from-image`). +# See docs/reference/docker_auth.md. +docker_auth: + ghcr.io: + username: "${GHCR_USERNAME}" + password: "${GHCR_PAT}" + # Shared Docker networks for inter-app communication networks: - backend-net diff --git a/src/commands/up.ts b/src/commands/up.ts index 816b985..4fae11a 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -13,6 +13,7 @@ import { Git } from '../resources/git.js' import { Checks } from '../resources/checks.js' import { Networks, NetworkProps } from '../resources/network.js' import { ensurePlugins } from '../modules/plugins.js' +import { ensureDockerAuth } from '../modules/docker-auth.js' import { ensureNetworks } from '../modules/network.js' import { ensurePostgres, ensurePostgresBackups } from '../modules/postgres.js' import { ensureRedis } from '../modules/redis.js' @@ -31,8 +32,9 @@ export async function runUp( ? appFilter : Object.keys(config.apps) - // Phase 1: Plugins + // Phase 1: Plugins & host-level auth if (config.plugins) await ensurePlugins(ctx, config.plugins) + if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) // Phase 2: Global config if (config.domains !== undefined) await ensureGlobalDomains(ctx, config.domains) diff --git a/src/core/mask.test.ts b/src/core/mask.test.ts index 16b5e4b..220bba2 100644 --- a/src/core/mask.test.ts +++ b/src/core/mask.test.ts @@ -65,6 +65,21 @@ describe('maskSensitiveArgs', () => { expect(maskSensitiveArgs('apps:create myapp')) .toBe('apps:create myapp') }) + + it('masks positional password in registry:login', () => { + expect(maskSensitiveArgs('registry:login ghcr.io octocat ghp_abcdefgh')) + .toBe('registry:login ghcr.io octocat ****efgh') + }) + + it('masks short positional password in registry:login', () => { + expect(maskSensitiveArgs('registry:login ghcr.io octocat pw')) + .toBe('registry:login ghcr.io octocat ****') + }) + + it('does not affect other registry: subcommands', () => { + expect(maskSensitiveArgs('registry:set myapp server registry.example.com')) + .toBe('registry:set myapp server registry.example.com') + }) }) describe('maskSensitiveData', () => { diff --git a/src/core/mask.ts b/src/core/mask.ts index 713633e..1a30239 100644 --- a/src/core/mask.ts +++ b/src/core/mask.ts @@ -7,9 +7,18 @@ function maskValue(value: string): string { /** Mask KEY=VALUE pairs in a command string */ export function maskSensitiveArgs(cmd: string): string { - return cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => { + let masked = cmd.replace(/([^\s=]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL)[^\s=]*)=(\S+)/gi, (_, key, value) => { return `${key}=${maskValue(value)}` }) + + // Special-case `registry:login ` — password is + // a positional arg, not KEY=VALUE, so the regex above can't catch it. + masked = masked.replace( + /^(registry:login\s+\S+\s+\S+\s+)(\S+)/, + (_, prefix, password) => `${prefix}${maskValue(password)}` + ) + + return masked } /** Deep-mask sensitive values in data structures (for diff/export output) */ diff --git a/src/core/schema.test.ts b/src/core/schema.test.ts index 3329b66..6e97a44 100644 --- a/src/core/schema.test.ts +++ b/src/core/schema.test.ts @@ -50,6 +50,33 @@ describe('PostgresSchema backup', () => { }) }) +describe('docker_auth in schema', () => { + it('accepts docker_auth with username and password', () => { + const result = parseConfig({ + apps: {}, + docker_auth: { + 'ghcr.io': { username: 'octocat', password: 'ghp_secret' }, + }, + }) + expect(result.docker_auth?.['ghcr.io']?.username).toBe('octocat') + expect(result.docker_auth?.['ghcr.io']?.password).toBe('ghp_secret') + }) + + it('rejects docker_auth entry missing password', () => { + expect(() => parseConfig({ + apps: {}, + docker_auth: { 'ghcr.io': { username: 'octocat' } }, + })).toThrow() + }) + + it('rejects docker_auth entry with empty password', () => { + expect(() => parseConfig({ + apps: {}, + docker_auth: { 'ghcr.io': { username: 'octocat', password: '' } }, + })).toThrow() + }) +}) + describe('parseConfig', () => { it('parses minimal config', () => { const result = parseConfig({ diff --git a/src/core/schema.ts b/src/core/schema.ts index 699c73b..d419d24 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -90,11 +90,17 @@ const PluginSchema = z.object({ version: z.string().optional(), }) +const DockerAuthEntrySchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}) + export const ConfigSchema = z.object({ dokku: z.object({ version: z.string().optional(), }).optional(), plugins: z.record(z.string(), PluginSchema).optional(), + docker_auth: z.record(z.string(), DockerAuthEntrySchema).optional(), networks: z.array(z.string()).optional(), postgres: z.record(z.string(), PostgresSchema).optional(), redis: z.record(z.string(), RedisSchema).optional(), @@ -112,6 +118,7 @@ export type PostgresConfig = z.infer export type RedisConfig = z.infer export type ServiceBackupConfig = z.infer export type PluginConfig = z.infer +export type DockerAuthEntry = z.infer export type GitConfig = z.infer export function parseConfig(raw: unknown): Config { diff --git a/src/modules/docker-auth.test.ts b/src/modules/docker-auth.test.ts new file mode 100644 index 0000000..5637cc1 --- /dev/null +++ b/src/modules/docker-auth.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest' +import { createRunner } from '../core/dokku.js' +import { createContext } from '../core/context.js' +import { ensureDockerAuth } from './docker-auth.js' + +describe('ensureDockerAuth', () => { + it('logs in to each configured registry', async () => { + const runner = createRunner({ dryRun: false }) + runner.run = vi.fn() + const ctx = createContext(runner) + await ensureDockerAuth(ctx, { + 'ghcr.io': { username: 'octocat', password: 'ghp_secret' }, + 'registry.example.com': { username: 'svc', password: 'pw' }, + }) + expect(runner.run).toHaveBeenCalledWith( + 'registry:login', 'ghcr.io', 'octocat', 'ghp_secret' + ) + expect(runner.run).toHaveBeenCalledWith( + 'registry:login', 'registry.example.com', 'svc', 'pw' + ) + expect(runner.run).toHaveBeenCalledTimes(2) + }) + + it('does nothing for an empty config', async () => { + const runner = createRunner({ dryRun: false }) + runner.run = vi.fn() + const ctx = createContext(runner) + await ensureDockerAuth(ctx, {}) + expect(runner.run).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/docker-auth.ts b/src/modules/docker-auth.ts new file mode 100644 index 0000000..022e86e --- /dev/null +++ b/src/modules/docker-auth.ts @@ -0,0 +1,14 @@ +import type { Context } from '../core/context.js' +import type { DockerAuthEntry } from '../core/schema.js' +import { logAction, logDone } from '../core/logger.js' + +export async function ensureDockerAuth( + ctx: Context, + config: Record +): Promise { + for (const [server, creds] of Object.entries(config)) { + logAction('docker-auth', `Logging in to ${server} as ${creds.username}`) + await ctx.run('registry:login', server, creds.username, creds.password) + logDone() + } +} diff --git a/src/tests/fixtures/full.yml b/src/tests/fixtures/full.yml index bfd1b9e..0767e1b 100644 --- a/src/tests/fixtures/full.yml +++ b/src/tests/fixtures/full.yml @@ -8,6 +8,11 @@ plugins: redis: url: https://github.com/dokku/dokku-redis.git +docker_auth: + ghcr.io: + username: "ci-user" + password: "test-pat" + networks: - studio-net - qultr-net