Repository navigation
Expand file tree
/
Copy pathcompose-database.ts
More file actions
156 lines (144 loc) · 5.9 KB
/
Copy pathcompose-database.ts
File metadata and controls
156 lines (144 loc) · 5.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { generateSecret } from './env-files'
import { SetupError } from './errors'
import * as p from './prompter'
/**
* The password Postgres was initialized with on a Compose install created while
* `docker-compose.prod.yml` still defaulted it. Postgres applies
* `POSTGRES_PASSWORD` only when it creates the data volume, so such a volume
* keeps this password until it is rotated inside the database.
*/
export const LEGACY_POSTGRES_PASSWORD = 'postgres'
/** The volume key the Sim Compose files declare for Postgres data. */
const POSTGRES_VOLUME = 'postgres_data'
/**
* Whether a Compose file refuses to start without `POSTGRES_PASSWORD`. Only the
* production file does, and the version that requires it is also the one that
* stops publishing the database, so older files and dev stacks are left alone.
*/
export function composeFileRequiresPostgresPassword(composeFile: string): boolean {
return readFileSync(composeFile, 'utf8').includes('${POSTGRES_PASSWORD:?')
}
/** Where a chosen `POSTGRES_PASSWORD` came from. */
export type PostgresPasswordSource = 'generated' | 'legacy'
export interface PostgresPasswordChoice {
value: string
source: PostgresPasswordSource
}
interface ChooseOptions {
/** The shell environment, whose `POSTGRES_PASSWORD` Compose interpolates over `.env`. */
shell?: NodeJS.ProcessEnv
hasDatabaseVolume?: (project: string) => boolean
}
/**
* Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null
* when nothing should be written. The production Compose file requires the
* variable, and the value must match what the data volume was created with —
* Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a
* wrong value locks the app out of its own database:
*
* - A value exported in the shell is the operator's to manage: Compose
* interpolates it over `.env`, so nothing is written. An empty export is
* refused, because Compose would use the empty value and refuse to start
* however good the one in `.env` is.
* - Otherwise a value in `.env` stands, an existing volume was created with the
* legacy password the file used to default to, and a project with no volume
* yet gets a generated one.
*/
export function choosePostgresPassword(
envFileValue: string | undefined,
project: string,
{ shell = process.env, hasDatabaseVolume = composeDatabaseVolumeExists }: ChooseOptions = {}
): PostgresPasswordChoice | null {
const shellValue = shell.POSTGRES_PASSWORD
if (shellValue === '') {
throw new SetupError(
'POSTGRES_PASSWORD is exported but empty, and Compose uses it over .env.',
['unset it (unset POSTGRES_PASSWORD) so the value in .env applies']
)
}
if (shellValue !== undefined || envFileValue) return null
return hasDatabaseVolume(project)
? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }
: { value: generateSecret(), source: 'generated' }
}
/** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */
export function composeDatabaseVolumeExists(project: string): boolean {
const result = spawnSync(
'docker',
[
'volume',
'ls',
'-q',
'--filter',
`label=com.docker.compose.project=${project}`,
'--filter',
`label=com.docker.compose.volume=${POSTGRES_VOLUME}`,
],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new SetupError(
`could not check for an existing database volume: ${result.stderr.trim() || result.stdout.trim()}`
)
}
return result.stdout.trim().length > 0
}
/**
* The project name Compose resolves for a file run from `cwd` — from `-p`,
* `COMPOSE_PROJECT_NAME`, or the directory. Read without interpolation, so it
* works before the required variables exist.
*/
export function composeProjectName(composeFile: string, cwd: string): string {
const result = spawnSync(
'docker',
['compose', '-f', composeFile, 'config', '--no-interpolate', '--format', 'json'],
{ cwd, encoding: 'utf8' }
)
const name = result.status === 0 ? parseProjectName(result.stdout) : null
if (name) return name
throw new SetupError(
`could not resolve the Compose project name for ${composeFile}: ${result.stderr.trim() || 'no name in docker compose config'}`
)
}
function parseProjectName(stdout: string): string | null {
try {
const { name } = JSON.parse(stdout) as { name?: unknown }
return typeof name === 'string' && name ? name : null
} catch {
return null
}
}
/**
* Reports what was written. `compose` is the pinned `docker compose -p … -f …`
* prefix for the install, and `user` the effective `POSTGRES_USER`, both used
* in the legacy rotation steps.
*/
export function reportPostgresPasswordChoice(
choice: PostgresPasswordChoice,
{ compose, user, envPath }: { compose: string; user: string; envPath: string }
): void {
if (choice.source === 'generated') {
p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`)
return
}
p.note(
[
`This database was created with the password "${LEGACY_POSTGRES_PASSWORD}", so ${envPath}`,
`now sets POSTGRES_PASSWORD=${LEGACY_POSTGRES_PASSWORD} to keep it working. The database is not`,
'published to the host, so only the containers in this stack can reach it. To rotate it:',
` ${compose} exec db psql -U ${user} -c "ALTER ROLE CURRENT_USER PASSWORD '<new password>'"`,
' then set POSTGRES_PASSWORD=<new password> in .env and run: npx sim-setup start',
].join('\n'),
'Database password'
)
}
/**
* The Postgres role Compose initializes from `${POSTGRES_USER:-postgres}`. A shell
* export overrides `.env` even when empty, and an empty value takes the default.
*/
export function postgresUser(envFileValue: string | undefined): string {
const shellUser = process.env.POSTGRES_USER
return shellUser === undefined ? envFileValue || 'postgres' : shellUser || 'postgres'
}