@o/scripts 0.16.19-canary.1790165522484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +55 -0
- package/src/build-initial.ts +93 -0
- package/src/clean.ts +29 -0
- package/src/cmd.ts +105 -0
- package/src/dev-tunnel.ts +162 -0
- package/src/ensure-port.ts +79 -0
- package/src/ensure-tunnel.ts +19 -0
- package/src/ensure-zero-sqlite.ts +31 -0
- package/src/env-pull.ts +58 -0
- package/src/env-update.ts +342 -0
- package/src/exec-with-env.ts +61 -0
- package/src/helpers/args.ts +97 -0
- package/src/helpers/check-port.ts +22 -0
- package/src/helpers/deploy-health.ts +227 -0
- package/src/helpers/deploy-lock.ts +231 -0
- package/src/helpers/ensure-s3-bucket.ts +88 -0
- package/src/helpers/env-load.ts +43 -0
- package/src/helpers/get-docker-host.ts +37 -0
- package/src/helpers/get-test-env.ts +46 -0
- package/src/helpers/github-tail.ts +249 -0
- package/src/helpers/multipass.ts +174 -0
- package/src/helpers/parse-env-file.ts +71 -0
- package/src/helpers/process-compose-env.ts +57 -0
- package/src/helpers/resolve-dep-version.ts +11 -0
- package/src/helpers/run.ts +340 -0
- package/src/helpers/ssh.ts +50 -0
- package/src/helpers/uncloud-deploy.ts +162 -0
- package/src/helpers/uncloud.ts +197 -0
- package/src/helpers/wait-for-port.ts +33 -0
- package/src/helpers/zero-get-version.ts +8 -0
- package/src/release.ts +544 -0
- package/src/sst-get-environment.ts +38 -0
- package/src/typecheck.ts +21 -0
- package/src/up.ts +440 -0
- package/src/update-changelog.ts +92 -0
- package/src/wait-for-dev.ts +41 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deployment health check helpers for uncloud deployments
|
|
3
|
+
* shared across repos via @o/scripts/helpers/deploy-health
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SSHContext {
|
|
7
|
+
sshOpts: string
|
|
8
|
+
deployUser: string
|
|
9
|
+
deployHost: string
|
|
10
|
+
$: (
|
|
11
|
+
strings: TemplateStringsArray,
|
|
12
|
+
...values: any[]
|
|
13
|
+
) => { quiet: () => Promise<{ stdout: Buffer }> }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ContainerInfo {
|
|
17
|
+
id: string
|
|
18
|
+
status: string
|
|
19
|
+
state: string
|
|
20
|
+
names: string
|
|
21
|
+
isRestarting: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* check for containers stuck in restart loop
|
|
26
|
+
* fails fast if any container is crash-looping
|
|
27
|
+
* excludes init containers (minio-init, migrate) that are expected to exit
|
|
28
|
+
*/
|
|
29
|
+
export async function checkRestartLoops(
|
|
30
|
+
ctx: SSHContext,
|
|
31
|
+
options?: { excludePatterns?: string[] }
|
|
32
|
+
): Promise<{
|
|
33
|
+
hasLoop: boolean
|
|
34
|
+
services: string[]
|
|
35
|
+
}> {
|
|
36
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
37
|
+
// default exclude patterns for init/oneshot containers
|
|
38
|
+
const excludePatterns = options?.excludePatterns ?? ['init', 'migrate']
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
42
|
+
const result =
|
|
43
|
+
await $`${sshCmd.split(' ')} "docker ps -a --format '{{.Names}} {{.Status}}'"`.quiet()
|
|
44
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
45
|
+
|
|
46
|
+
const loopingServices: string[] = []
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (line.includes('Restarting')) {
|
|
49
|
+
const name = line.split(' ')[0] || ''
|
|
50
|
+
// skip init/oneshot containers
|
|
51
|
+
const isExcluded = excludePatterns.some((pattern) => name.includes(pattern))
|
|
52
|
+
if (!isExcluded) {
|
|
53
|
+
loopingServices.push(name)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { hasLoop: loopingServices.length > 0, services: loopingServices }
|
|
59
|
+
} catch {
|
|
60
|
+
return { hasLoop: false, services: [] }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* get container status for all running containers
|
|
66
|
+
* extracts service name from container names (handles various naming patterns)
|
|
67
|
+
*/
|
|
68
|
+
export async function getContainerStatus(
|
|
69
|
+
ctx: SSHContext,
|
|
70
|
+
options?: { verbose?: boolean }
|
|
71
|
+
): Promise<Map<string, ContainerInfo>> {
|
|
72
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
73
|
+
try {
|
|
74
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
75
|
+
// use -a to see all containers including stopped/restarting
|
|
76
|
+
const result = await $`${sshCmd.split(' ')} "docker ps -a --format json"`.quiet()
|
|
77
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
78
|
+
|
|
79
|
+
const containers = new Map<string, ContainerInfo>()
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
try {
|
|
82
|
+
const container = JSON.parse(line)
|
|
83
|
+
const name = container.Names || ''
|
|
84
|
+
let serviceName: string | null = null
|
|
85
|
+
|
|
86
|
+
// try various naming patterns
|
|
87
|
+
// pattern 1: "projectname-service-1" or "projectname_service_1"
|
|
88
|
+
let match = name.match(/[^_-]+[_-]([^_-]+)[_-]\w+/)
|
|
89
|
+
if (match) {
|
|
90
|
+
serviceName = match[1]
|
|
91
|
+
} else {
|
|
92
|
+
// pattern 2: "service-xxxx" where xxxx is alphanumeric
|
|
93
|
+
match = name.match(/^([^_-]+)[_-]\w+$/)
|
|
94
|
+
if (match) {
|
|
95
|
+
serviceName = match[1]
|
|
96
|
+
} else {
|
|
97
|
+
// pattern 3: just the name before any dash or underscore
|
|
98
|
+
match = name.match(/^([^_-]+)/)
|
|
99
|
+
if (match) {
|
|
100
|
+
serviceName = match[1]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (serviceName) {
|
|
106
|
+
const status = container.Status || ''
|
|
107
|
+
const state = container.State || ''
|
|
108
|
+
const isRestarting = state === 'restarting' || status.includes('Restarting')
|
|
109
|
+
|
|
110
|
+
containers.set(serviceName, {
|
|
111
|
+
id: container.ID,
|
|
112
|
+
status,
|
|
113
|
+
state,
|
|
114
|
+
names: container.Names,
|
|
115
|
+
isRestarting,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// skip invalid json lines
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return containers
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (options?.verbose) {
|
|
126
|
+
console.error('failed to get container status:', error)
|
|
127
|
+
}
|
|
128
|
+
return new Map()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* check health status of a specific container
|
|
134
|
+
* returns 'healthy', 'checking' (mid-probe), or 'unhealthy'
|
|
135
|
+
*/
|
|
136
|
+
export async function checkContainerHealth(
|
|
137
|
+
ctx: SSHContext,
|
|
138
|
+
containerId: string
|
|
139
|
+
): Promise<'healthy' | 'checking' | 'unhealthy'> {
|
|
140
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
141
|
+
try {
|
|
142
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
143
|
+
const healthResult =
|
|
144
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}'"`.quiet()
|
|
145
|
+
const healthStatus = healthResult.stdout.toString().trim()
|
|
146
|
+
|
|
147
|
+
if (healthStatus === 'healthy') {
|
|
148
|
+
return 'healthy'
|
|
149
|
+
} else if (healthStatus === 'no-healthcheck') {
|
|
150
|
+
// fall back to checking if running for containers without healthcheck
|
|
151
|
+
const statusResult =
|
|
152
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{.State.Status}}'"`.quiet()
|
|
153
|
+
const status = statusResult.stdout.toString().trim()
|
|
154
|
+
return status === 'running' ? 'healthy' : 'unhealthy'
|
|
155
|
+
} else if (healthStatus === 'starting') {
|
|
156
|
+
// mid-healthcheck probe, don't treat as failure yet
|
|
157
|
+
return 'checking'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return 'unhealthy'
|
|
161
|
+
} catch {
|
|
162
|
+
return 'unhealthy'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* check http health endpoint from inside web container
|
|
168
|
+
*/
|
|
169
|
+
export async function checkHttpHealth(
|
|
170
|
+
ctx: SSHContext,
|
|
171
|
+
options?: { endpoint?: string; containerFilter?: string }
|
|
172
|
+
): Promise<{
|
|
173
|
+
healthy: boolean
|
|
174
|
+
status?: number
|
|
175
|
+
message?: string
|
|
176
|
+
}> {
|
|
177
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
178
|
+
const endpoint = options?.endpoint || 'http://localhost:8081/'
|
|
179
|
+
const containerFilter = options?.containerFilter || 'web'
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
183
|
+
const containersResult =
|
|
184
|
+
await $`${sshCmd.split(' ')} "docker ps --filter 'name=${containerFilter}' --format '{{.Names}}'"`.quiet()
|
|
185
|
+
const webContainer = containersResult.stdout.toString().trim()
|
|
186
|
+
|
|
187
|
+
if (!webContainer) {
|
|
188
|
+
return { healthy: false, message: 'web container not found' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result =
|
|
192
|
+
await $`${sshCmd.split(' ')} "docker exec ${webContainer} curl -so /dev/null -w '%{http_code}' -m 5 ${endpoint}"`.quiet()
|
|
193
|
+
const output = result.stdout.toString().trim()
|
|
194
|
+
const statusCode = parseInt(output, 10)
|
|
195
|
+
|
|
196
|
+
if (statusCode >= 200 && statusCode < 400) {
|
|
197
|
+
return { healthy: true, status: statusCode }
|
|
198
|
+
} else if (statusCode >= 400 && statusCode < 500) {
|
|
199
|
+
return { healthy: true, status: statusCode, message: 'server running' }
|
|
200
|
+
} else if (statusCode >= 500) {
|
|
201
|
+
return { healthy: false, status: statusCode, message: 'server error' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { healthy: false, message: 'app starting...' }
|
|
205
|
+
} catch {
|
|
206
|
+
return { healthy: false, message: 'app starting...' }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* get recent logs from a container
|
|
212
|
+
*/
|
|
213
|
+
export async function getContainerLogs(
|
|
214
|
+
ctx: SSHContext,
|
|
215
|
+
containerId: string,
|
|
216
|
+
lines = 20
|
|
217
|
+
): Promise<string[]> {
|
|
218
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
219
|
+
try {
|
|
220
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
221
|
+
const result =
|
|
222
|
+
await $`${sshCmd.split(' ')} "docker logs ${containerId} --tail ${lines} 2>&1"`.quiet()
|
|
223
|
+
return result.stdout.toString().trim().split('\n')
|
|
224
|
+
} catch {
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy lock helper — prevents concurrent deploys with auto-expiry
|
|
3
|
+
* shared across repos via @o/scripts/helpers/deploy-lock
|
|
4
|
+
*
|
|
5
|
+
* uses mkdir for atomic lock acquisition (no race window).
|
|
6
|
+
* writes a meta.json inside the lock dir so stale locks can self-diagnose:
|
|
7
|
+
* when a deploy dies without releasing, the next deploy prints who left the
|
|
8
|
+
* lock (pid, host, git sha, CI run URL) instead of just a timestamp.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
|
|
13
|
+
import { run } from './run'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_LOCK_PATH = '/tmp/deploy.lock'
|
|
16
|
+
const DEFAULT_MAX_AGE_MIN = 15
|
|
17
|
+
|
|
18
|
+
interface DeployLockOptions {
|
|
19
|
+
path?: string
|
|
20
|
+
maxAgeMin?: number
|
|
21
|
+
/**
|
|
22
|
+
* called before lock release on all exit paths: normal return, thrown
|
|
23
|
+
* error, and signal (SIGTERM/SIGINT/SIGHUP). use this to roll back
|
|
24
|
+
* partial state — e.g. restart a service that was stopped mid-deploy.
|
|
25
|
+
* cleanup errors are logged but do not prevent lock release.
|
|
26
|
+
*/
|
|
27
|
+
cleanup?: () => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LockMeta {
|
|
31
|
+
pid: number
|
|
32
|
+
host: string
|
|
33
|
+
startedAt: string
|
|
34
|
+
gitSha: string
|
|
35
|
+
ciRunId: string
|
|
36
|
+
ciRunUrl: string
|
|
37
|
+
ciWorkflow: string
|
|
38
|
+
ciActor: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildMeta(): LockMeta {
|
|
42
|
+
const repo = process.env.GITHUB_REPOSITORY || ''
|
|
43
|
+
const runId = process.env.GITHUB_RUN_ID || ''
|
|
44
|
+
const server = process.env.GITHUB_SERVER_URL || 'https://github.com'
|
|
45
|
+
const ciRunUrl = repo && runId ? `${server}/${repo}/actions/runs/${runId}` : ''
|
|
46
|
+
return {
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
host: os.hostname(),
|
|
49
|
+
startedAt: new Date().toISOString(),
|
|
50
|
+
gitSha:
|
|
51
|
+
process.env.GIT_SHA || process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || '',
|
|
52
|
+
ciRunId: runId,
|
|
53
|
+
ciRunUrl,
|
|
54
|
+
ciWorkflow: process.env.GITHUB_WORKFLOW || '',
|
|
55
|
+
ciActor: process.env.GITHUB_ACTOR || '',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toBase64(s: string): string {
|
|
60
|
+
return Buffer.from(s, 'utf-8').toString('base64')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function sshBash(ssh: string, script: string, captureOutput = true) {
|
|
64
|
+
// base64-encode the script so we never have to escape quotes, $, backticks,
|
|
65
|
+
// newlines etc. at multiple layers (local bash → ssh → remote bash).
|
|
66
|
+
const b64 = toBase64(script)
|
|
67
|
+
return run(`${ssh} "echo ${b64} | base64 -d | bash"`, {
|
|
68
|
+
captureOutput,
|
|
69
|
+
silent: true,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function acquireDeployLock(
|
|
74
|
+
ssh: string,
|
|
75
|
+
opts?: DeployLockOptions
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const lockPath = opts?.path || DEFAULT_LOCK_PATH
|
|
78
|
+
const maxAge = opts?.maxAgeMin || DEFAULT_MAX_AGE_MIN
|
|
79
|
+
|
|
80
|
+
// atomic cleanup-and-acquire in one round-trip:
|
|
81
|
+
// 1. if a stale lock exists (older than maxAge), print its metadata so the
|
|
82
|
+
// caller can log who left it behind, then rm it.
|
|
83
|
+
// 2. try mkdir — atomic; succeeds iff no concurrent deploy holds the lock.
|
|
84
|
+
const acquireScript = `
|
|
85
|
+
set -u
|
|
86
|
+
LOCK=${lockPath}
|
|
87
|
+
MAXAGE=${maxAge}
|
|
88
|
+
if [ -d "$LOCK" ]; then
|
|
89
|
+
if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +$MAXAGE 2>/dev/null)" ]; then
|
|
90
|
+
echo "STALE_META_START"
|
|
91
|
+
cat "$LOCK/meta.json" 2>/dev/null || echo "(no metadata file)"
|
|
92
|
+
echo "STALE_META_END"
|
|
93
|
+
rm -rf "$LOCK"
|
|
94
|
+
fi
|
|
95
|
+
fi
|
|
96
|
+
if mkdir "$LOCK" 2>/dev/null; then
|
|
97
|
+
echo ACQUIRED
|
|
98
|
+
else
|
|
99
|
+
echo LOCKED
|
|
100
|
+
fi
|
|
101
|
+
`
|
|
102
|
+
|
|
103
|
+
const { stdout: acquireOut } = await sshBash(ssh, acquireScript)
|
|
104
|
+
|
|
105
|
+
// log any stale cleanup so the next deploy's logs show who left it
|
|
106
|
+
const staleMatch = acquireOut.match(/STALE_META_START\n([\s\S]*?)\nSTALE_META_END/)
|
|
107
|
+
if (staleMatch?.[1]) {
|
|
108
|
+
console.warn(`deploy-lock: cleared stale lock (older than ${maxAge}m) left by:`)
|
|
109
|
+
for (const line of staleMatch[1].split('\n')) {
|
|
110
|
+
console.warn(` ${line}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!acquireOut.trim().endsWith('ACQUIRED')) {
|
|
115
|
+
// read the live holder's metadata so the error tells the user exactly
|
|
116
|
+
// which deploy is blocking — not just "a lock file exists".
|
|
117
|
+
const diagScript = `
|
|
118
|
+
LOCK=${lockPath}
|
|
119
|
+
echo "meta.json:"
|
|
120
|
+
cat "$LOCK/meta.json" 2>/dev/null || echo " (no metadata)"
|
|
121
|
+
echo "mtime:"
|
|
122
|
+
stat -c " %y" "$LOCK" 2>/dev/null || echo " (unknown)"
|
|
123
|
+
`
|
|
124
|
+
let heldBy = '(diagnostics unavailable)'
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await sshBash(ssh, diagScript)
|
|
127
|
+
heldBy = stdout.trim() || heldBy
|
|
128
|
+
} catch {
|
|
129
|
+
// best-effort
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new Error(
|
|
133
|
+
`another deploy is in progress (${lockPath} exists on server)\n` +
|
|
134
|
+
` held by:\n${heldBy
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((l) => ` ${l}`)
|
|
137
|
+
.join('\n')}\n` +
|
|
138
|
+
` will auto-expire after ${maxAge} minutes, or remove manually: ${ssh} "rm -rf ${lockPath}"`
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// write metadata inside the lock dir (best-effort; lock is already held
|
|
143
|
+
// so metadata failures are non-fatal — they only hurt future diagnostics).
|
|
144
|
+
const meta = buildMeta()
|
|
145
|
+
const metaJson = JSON.stringify(meta, null, 2)
|
|
146
|
+
const writeScript = `
|
|
147
|
+
LOCK=${lockPath}
|
|
148
|
+
cat > "$LOCK/meta.json" << 'DEPLOY_LOCK_META_EOF'
|
|
149
|
+
${metaJson}
|
|
150
|
+
DEPLOY_LOCK_META_EOF
|
|
151
|
+
`
|
|
152
|
+
try {
|
|
153
|
+
await sshBash(ssh, writeScript, false)
|
|
154
|
+
} catch {
|
|
155
|
+
// non-fatal
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function releaseDeployLock(
|
|
160
|
+
ssh: string,
|
|
161
|
+
opts?: DeployLockOptions
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const lockPath = opts?.path || DEFAULT_LOCK_PATH
|
|
164
|
+
try {
|
|
165
|
+
await run(`${ssh} "rm -rf ${lockPath}"`, { silent: true })
|
|
166
|
+
} catch {
|
|
167
|
+
// best-effort, lock will auto-expire
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* run `fn` with the deploy lock held, releasing on ANY exit path —
|
|
173
|
+
* normal return, thrown error, or process signal (SIGTERM from CI cancel,
|
|
174
|
+
* SIGINT from ctrl-c, SIGHUP from terminal close).
|
|
175
|
+
*
|
|
176
|
+
* prefer this over raw acquire/release: a bare try/finally does NOT catch
|
|
177
|
+
* signals, so a CI-cancelled deploy would leak the lock for 15 minutes and
|
|
178
|
+
* block the next run. this helper installs signal handlers that release
|
|
179
|
+
* before exit, then removes them once `fn` completes.
|
|
180
|
+
*
|
|
181
|
+
* if `opts.cleanup` is provided, it runs before lock release on every exit
|
|
182
|
+
* path. use it to roll back partial deploy state (e.g. restart a stopped
|
|
183
|
+
* service) so a mid-flight crash doesn't leave prod in a broken state.
|
|
184
|
+
*/
|
|
185
|
+
export async function withDeployLock<T>(
|
|
186
|
+
ssh: string,
|
|
187
|
+
fn: () => Promise<T>,
|
|
188
|
+
opts?: DeployLockOptions
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
await acquireDeployLock(ssh, opts)
|
|
191
|
+
|
|
192
|
+
let released = false
|
|
193
|
+
const runCleanupAndRelease = async () => {
|
|
194
|
+
if (released) return
|
|
195
|
+
released = true
|
|
196
|
+
if (opts?.cleanup) {
|
|
197
|
+
try {
|
|
198
|
+
await opts.cleanup()
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.warn('deploy-lock: cleanup threw (non-fatal):', err)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await releaseDeployLock(ssh, opts)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// signal handlers — fire-and-forget cleanup+release with a safety timeout
|
|
207
|
+
// so the process always exits even if ssh hangs. the 15s budget covers
|
|
208
|
+
// user cleanup (e.g. restarting a service) plus the release rm call.
|
|
209
|
+
const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP']
|
|
210
|
+
const handlers = new Map<NodeJS.Signals, () => void>()
|
|
211
|
+
for (const sig of signals) {
|
|
212
|
+
const handler = () => {
|
|
213
|
+
const done = runCleanupAndRelease().catch(() => {})
|
|
214
|
+
const timeout = new Promise<void>((r) => setTimeout(r, 15_000))
|
|
215
|
+
Promise.race([done, timeout]).finally(() => {
|
|
216
|
+
process.exit(sig === 'SIGINT' ? 130 : 143)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
handlers.set(sig, handler)
|
|
220
|
+
process.once(sig, handler)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
return await fn()
|
|
225
|
+
} finally {
|
|
226
|
+
for (const [sig, handler] of handlers) {
|
|
227
|
+
process.off(sig, handler)
|
|
228
|
+
}
|
|
229
|
+
await runCleanupAndRelease()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateBucketCommand,
|
|
3
|
+
HeadBucketCommand,
|
|
4
|
+
PutBucketLifecycleConfigurationCommand,
|
|
5
|
+
PutBucketVersioningCommand,
|
|
6
|
+
S3Client,
|
|
7
|
+
} from '@aws-sdk/client-s3'
|
|
8
|
+
|
|
9
|
+
export async function ensureS3Bucket(
|
|
10
|
+
bucketName: string,
|
|
11
|
+
options?: {
|
|
12
|
+
region?: string
|
|
13
|
+
retentionDays?: number
|
|
14
|
+
}
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const region = options?.region ?? 'us-west-1'
|
|
17
|
+
const retentionDays = options?.retentionDays ?? 30
|
|
18
|
+
|
|
19
|
+
const s3Client = new S3Client({
|
|
20
|
+
region,
|
|
21
|
+
credentials: {
|
|
22
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
23
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// check if bucket exists
|
|
29
|
+
await s3Client.send(new HeadBucketCommand({ Bucket: bucketName }))
|
|
30
|
+
console.info(`✅ S3 bucket exists: ${bucketName}`)
|
|
31
|
+
} catch (error: any) {
|
|
32
|
+
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
|
|
33
|
+
console.info(`📦 Creating S3 bucket: ${bucketName}`)
|
|
34
|
+
|
|
35
|
+
// create bucket
|
|
36
|
+
await s3Client.send(
|
|
37
|
+
new CreateBucketCommand({
|
|
38
|
+
Bucket: bucketName,
|
|
39
|
+
...(region !== 'us-east-1' && {
|
|
40
|
+
CreateBucketConfiguration: {
|
|
41
|
+
LocationConstraint: region as any,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// enable versioning for safety
|
|
48
|
+
await s3Client.send(
|
|
49
|
+
new PutBucketVersioningCommand({
|
|
50
|
+
Bucket: bucketName,
|
|
51
|
+
VersioningConfiguration: {
|
|
52
|
+
Status: 'Enabled',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// set lifecycle policy to automatically delete old backups
|
|
58
|
+
await s3Client.send(
|
|
59
|
+
new PutBucketLifecycleConfigurationCommand({
|
|
60
|
+
Bucket: bucketName,
|
|
61
|
+
LifecycleConfiguration: {
|
|
62
|
+
Rules: [
|
|
63
|
+
{
|
|
64
|
+
ID: 'delete-old-backups',
|
|
65
|
+
Status: 'Enabled',
|
|
66
|
+
Filter: {
|
|
67
|
+
Prefix: 'db-backups/',
|
|
68
|
+
},
|
|
69
|
+
Expiration: {
|
|
70
|
+
Days: retentionDays,
|
|
71
|
+
},
|
|
72
|
+
NoncurrentVersionExpiration: {
|
|
73
|
+
NoncurrentDays: 7,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
console.info(
|
|
82
|
+
`✅ S3 bucket created with ${retentionDays} day retention: ${bucketName}`
|
|
83
|
+
)
|
|
84
|
+
} else {
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export async function loadEnv(
|
|
4
|
+
environment: 'development' | 'production' | 'dev-prod',
|
|
5
|
+
options?: { optional?: string[] }
|
|
6
|
+
) {
|
|
7
|
+
const { loadEnv: vxrnLoadEnv } = await import('vxrn/loadEnv')
|
|
8
|
+
|
|
9
|
+
// loads env into process.env
|
|
10
|
+
await vxrnLoadEnv(environment)
|
|
11
|
+
|
|
12
|
+
const previousMode = process.env.TAKEOUT_ENV_MODE
|
|
13
|
+
if (environment === 'development' || environment === 'production') {
|
|
14
|
+
process.env.TAKEOUT_ENV_MODE = environment
|
|
15
|
+
} else {
|
|
16
|
+
delete process.env.TAKEOUT_ENV_MODE
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let Environment: Record<string, string>
|
|
20
|
+
try {
|
|
21
|
+
// import src/env.ts to get the env config applied (side effect: populates process.env)
|
|
22
|
+
const envModule = await import(join(process.cwd(), 'src/env.ts'))
|
|
23
|
+
Environment = envModule.server || {}
|
|
24
|
+
} finally {
|
|
25
|
+
if (previousMode === undefined) {
|
|
26
|
+
delete process.env.TAKEOUT_ENV_MODE
|
|
27
|
+
} else {
|
|
28
|
+
process.env.TAKEOUT_ENV_MODE = previousMode
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// validate
|
|
33
|
+
for (const key in Environment) {
|
|
34
|
+
if (options?.optional?.includes(key)) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (typeof Environment[key as keyof typeof Environment] === 'undefined') {
|
|
38
|
+
console.warn(`Missing key: ${key}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Environment
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
4
|
+
import { platform } from 'node:os'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gets the appropriate host address for Docker containers to connect back to the host machine
|
|
8
|
+
* - On macOS/Windows: host.docker.internal works out of the box
|
|
9
|
+
* - On Linux: We need to get the actual host IP from the docker bridge network
|
|
10
|
+
*/
|
|
11
|
+
export function getDockerHost(): string {
|
|
12
|
+
// In CI or on Linux, we need the actual host IP
|
|
13
|
+
if (platform() === 'linux') {
|
|
14
|
+
try {
|
|
15
|
+
// Get the host IP from docker network
|
|
16
|
+
const result = execSync(
|
|
17
|
+
"docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'",
|
|
18
|
+
{ encoding: 'utf-8' }
|
|
19
|
+
).trim()
|
|
20
|
+
|
|
21
|
+
if (result) {
|
|
22
|
+
console.info(`Using Docker bridge gateway IP: ${result}`)
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.warn('Failed to get Docker bridge IP, falling back to host.docker.internal')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Default for macOS/Windows or fallback
|
|
31
|
+
return 'host.docker.internal'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// If run directly, output the host
|
|
35
|
+
if (import.meta.main) {
|
|
36
|
+
console.info(getDockerHost())
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { loadEnv } from './env-load'
|
|
4
|
+
import { getDockerHost } from './get-docker-host'
|
|
5
|
+
|
|
6
|
+
export async function getTestEnv() {
|
|
7
|
+
const dockerHost = getDockerHost()
|
|
8
|
+
const devEnv = await loadEnv('development')
|
|
9
|
+
|
|
10
|
+
// import src/env.ts for server env fallback values
|
|
11
|
+
const envModule = await import(join(process.cwd(), 'src/env.ts'))
|
|
12
|
+
const serverEnvFallback = envModule.server || {}
|
|
13
|
+
|
|
14
|
+
// ports come from VITE_PORT_* in .env.development (loaded by loadEnv)
|
|
15
|
+
const dbPort = process.env.VITE_PORT_POSTGRES || '5433'
|
|
16
|
+
const appPort = process.env.VITE_PORT_WEB || '8081'
|
|
17
|
+
const minioPort = process.env.VITE_PORT_MINIO || '9200'
|
|
18
|
+
|
|
19
|
+
const dockerDbBase = `postgresql://user:password@127.0.0.1:${dbPort}`
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...serverEnvFallback,
|
|
23
|
+
...devEnv,
|
|
24
|
+
CI: 'true',
|
|
25
|
+
// Child test processes may import src/env.ts directly. Keep them pinned to
|
|
26
|
+
// development mode even when the parent release flow started from
|
|
27
|
+
// production-flavored env.
|
|
28
|
+
TAKEOUT_ENV_MODE: 'development',
|
|
29
|
+
...(!process.env.DEBUG_BACKEND && {
|
|
30
|
+
ZERO_LOG_LEVEL: 'warn',
|
|
31
|
+
}),
|
|
32
|
+
DO_NOT_TRACK: '1',
|
|
33
|
+
// force localhost urls so cross-subdomain cookies don't activate in test
|
|
34
|
+
BETTER_AUTH_URL: `http://localhost:${appPort}`,
|
|
35
|
+
ONE_SERVER_URL: `http://localhost:${appPort}`,
|
|
36
|
+
ZERO_MUTATE_URL: `http://${dockerHost}:${appPort}/api/zero/push`,
|
|
37
|
+
ZERO_QUERY_URL: `http://${dockerHost}:${appPort}/api/zero/pull`,
|
|
38
|
+
ZERO_UPSTREAM_DB: `${dockerDbBase}/postgres`,
|
|
39
|
+
ZERO_CVR_DB: `${dockerDbBase}/zero_cvr`,
|
|
40
|
+
ZERO_CHANGE_DB: `${dockerDbBase}/zero_cdb`,
|
|
41
|
+
CLOUDFLARE_R2_ENDPOINT: `http://127.0.0.1:${minioPort}`,
|
|
42
|
+
CLOUDFLARE_R2_PUBLIC_URL: `http://127.0.0.1:${minioPort}`,
|
|
43
|
+
CLOUDFLARE_R2_ACCESS_KEY: 'minio',
|
|
44
|
+
CLOUDFLARE_R2_SECRET_KEY: 'minio_password',
|
|
45
|
+
}
|
|
46
|
+
}
|