Skip to content
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ All features are idempotent — running `up` twice produces no changes.
| Feature | Description | Reference |
|---------|-------------|-----------|
| Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) |
| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) |
| Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) |
| Build | Dockerfile path, build context, app.json, build args | [builder](docs/reference/builder.md) |
| Docker Options | Custom Docker options per phase (build/deploy/run) | [docker_options](docs/reference/docker_options.md) |
Expand Down
38 changes: 38 additions & 0 deletions docs/reference/dokku.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Dokku Version

Dokku docs: https://dokku.com/docs/getting-started/upgrading/

Module: `src/modules/version.ts`

## YAML Keys

### Pinned Version (`dokku.version`)

Declare the minimum Dokku server version your config requires. On `up` and
`diff`, dokku-compose queries the server with `dokku version` and warns if
the server is older than the pinned value. The check is opt-in — if the key
is absent, no check runs.

The value is interpreted as a **minimum**, not an exact match: a server
running a newer version than pinned is silent. Pre-release suffixes
(`-rc1`, `-beta`) are tolerated on both the server output and the pinned
value itself — only the `X.Y.Z` numeric part is compared.

The `export` command writes the running server's version into this field
automatically, so round-tripping `export` → edit → `up` produces a useful
floor without manual effort.

```yaml
dokku:
version: "0.37.9" # warn if server is < 0.37.9
```

| Value | Behavior |
|-------|----------|
| `"X.Y.Z"` | `dokku version` (query)<br>warn if server version `< X.Y.Z` |
| absent | no action |

If the server's `dokku version` output cannot be parsed as `X.Y.Z`,
dokku-compose raises an error rather than continuing silently — that
indicates either the runner is talking to something that isn't Dokku, or
Dokku has changed its output format.
3 changes: 3 additions & 0 deletions src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Config, AppConfig } from '../core/schema.js'
import { computeChange } from '../core/change.js'
import { ALL_APP_RESOURCES } from '../resources/index.js'
import { maskSensitiveData } from '../core/mask.js'
import { ensureDokkuVersion } from '../modules/version.js'
import chalk from 'chalk'

type DiffStatus = 'in-sync' | 'changed' | 'missing' | 'extra'
Expand All @@ -26,6 +27,8 @@ interface DiffResult {
export async function computeDiff(ctx: Context, config: Config): Promise<DiffResult> {
const result: DiffResult = { apps: {}, services: {}, inSync: true }

await ensureDokkuVersion(ctx, config.dokku?.version)

// Bulk prefetch: run all readAll queries in parallel
const prefetched = new Map<string, Map<string, unknown>>()
await Promise.all(
Expand Down
7 changes: 3 additions & 4 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { exportPostgres } from '../modules/postgres.js'
import { exportRedis } from '../modules/redis.js'
import { exportAppLinks } from '../modules/links.js'
import { exportNetworks } from '../modules/network.js'
import { extractServerVersion } from '../modules/version.js'

export interface ExportOptions {
appFilter?: string[]
Expand All @@ -14,10 +15,8 @@ export interface ExportOptions {
export async function runExport(ctx: Context, opts: ExportOptions): Promise<Config> {
const config: Config = { apps: {} }

// Dokku version
const versionOutput = await ctx.query('version')
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+)/)
if (versionMatch) config.dokku = { version: versionMatch[1] }
const version = extractServerVersion(await ctx.query('version'))
if (version) config.dokku = { version }

// Apps
const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx)
Expand Down
5 changes: 4 additions & 1 deletion src/commands/up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ describe('runUp', () => {
const runner = createRunner({ dryRun: false })
runner.check = vi.fn().mockResolvedValue(false)
runner.run = vi.fn()
runner.query = vi.fn().mockResolvedValue('')
runner.query = vi.fn().mockImplementation(async (...args: string[]) => {
if (args[0] === 'version') return 'dokku version 0.35.12'
return ''
})
const ctx = createContext(runner)
const config = loadConfig(path.join(FIXTURES, 'full.yml'))
await runUp(ctx, config, ['funqtion'])
Expand Down
4 changes: 4 additions & 0 deletions src/commands/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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'
import { ensureDokkuVersion } from '../modules/version.js'
import { ensureAppLinks } from '../modules/links.js'
import { ensureGlobalDomains } from '../modules/domains.js'
import { ensureGlobalConfig } from '../modules/config.js'
Expand All @@ -32,6 +33,9 @@ export async function runUp(
? appFilter
: Object.keys(config.apps)

// Phase 0: Version check
await ensureDokkuVersion(ctx, config.dokku?.version)

// Phase 1: Plugins & host-level auth
if (config.plugins) await ensurePlugins(ctx, config.plugins)
if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth)
Expand Down
88 changes: 88 additions & 0 deletions src/modules/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest'
import { createRunner } from '../core/dokku.js'
import { createContext } from '../core/context.js'
import { compareSemver, ensureDokkuVersion } from './version.js'

describe('compareSemver', () => {
it('returns 0 for equal versions', () => {
expect(compareSemver('0.37.9', '0.37.9')).toBe(0)
})

it('returns -1 when a is older (patch)', () => {
expect(compareSemver('0.37.9', '0.37.10')).toBe(-1)
})

it('returns 1 when a is newer (minor)', () => {
expect(compareSemver('0.38.0', '0.37.99')).toBe(1)
})

it('returns 1 when a is newer (major)', () => {
expect(compareSemver('1.0.0', '0.99.99')).toBe(1)
})

it('ignores pre-release suffix when otherwise equal', () => {
expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0)
})

it('ignores pre-release suffix on both sides', () => {
expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0)
})

it('throws when input is not parseable', () => {
expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i)
})

it('throws when only major.minor (missing patch)', () => {
expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i)
})
})

describe('ensureDokkuVersion', () => {
it('does nothing when pinned is undefined (no query)', async () => {
const runner = createRunner({ dryRun: false })
runner.query = vi.fn()
const ctx = createContext(runner)
await ensureDokkuVersion(ctx, undefined)
expect(runner.query).not.toHaveBeenCalled()
})

it('is silent when server version equals pinned', async () => {
const runner = createRunner({ dryRun: false })
runner.query = vi.fn().mockResolvedValue('dokku version 0.37.9')
const ctx = createContext(runner)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await ensureDokkuVersion(ctx, '0.37.9')
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})

it('is silent when server version is newer than pinned', async () => {
const runner = createRunner({ dryRun: false })
runner.query = vi.fn().mockResolvedValue('0.38.0')
const ctx = createContext(runner)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await ensureDokkuVersion(ctx, '0.37.9')
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})

it('warns when server version is older than pinned', async () => {
const runner = createRunner({ dryRun: false })
runner.query = vi.fn().mockResolvedValue('dokku version 0.36.4')
const ctx = createContext(runner)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await ensureDokkuVersion(ctx, '0.37.9')
expect(warnSpy).toHaveBeenCalledTimes(1)
const msg = warnSpy.mock.calls[0][0] as string
expect(msg).toContain('0.36.4')
expect(msg).toContain('0.37.9')
warnSpy.mockRestore()
})

it('throws when server output is unparseable', async () => {
const runner = createRunner({ dryRun: false })
runner.query = vi.fn().mockResolvedValue('something weird')
const ctx = createContext(runner)
await expect(ensureDokkuVersion(ctx, '0.37.9')).rejects.toThrow(/parse/i)
})
})
44 changes: 44 additions & 0 deletions src/modules/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Context } from '../core/context.js'
import { logWarn } from '../core/logger.js'

const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/

function parseSemver(input: string): [number, number, number] {
const m = input.match(SEMVER_RE)
if (!m) throw new Error(`Cannot parse version: ${input}`)
return [Number(m[1]), Number(m[2]), Number(m[3])]
}

export function extractServerVersion(output: string): string | null {
const match = output.match(/(\d+\.\d+\.\d+)/)
return match ? match[1] : null
}

export function compareSemver(a: string, b: string): -1 | 0 | 1 {
const [aMaj, aMin, aPatch] = parseSemver(a)
const [bMaj, bMin, bPatch] = parseSemver(b)
if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1
if (aMin !== bMin) return aMin < bMin ? -1 : 1
if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1
return 0
}

export async function ensureDokkuVersion(
ctx: Context,
pinned: string | undefined
): Promise<void> {
if (!pinned) return

const output = await ctx.query('version')
const server = extractServerVersion(output)
if (!server) {
throw new Error(`Cannot parse Dokku server version from output: ${output}`)
}

if (compareSemver(server, pinned) === -1) {
logWarn(
'dokku',
`server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.`
)
}
}
Loading