@rpcbase/cli 0.182.0 → 0.183.0
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/package.json +1 -1
- package/src/cmd-deploy/detect-compose-changes.js +23 -14
- package/src/cmd-deploy/index.js +379 -43
- package/src/cmd-deploy/private-api.js +26 -0
- package/src/index.js +35 -0
package/package.json
CHANGED
|
@@ -53,11 +53,10 @@ const hashTree = (rootDir) => {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const buildRsyncCmd = ({
|
|
56
|
+
const buildRsyncCmd = ({ filterArgs, keyPath, source, dest, remote }) => {
|
|
57
57
|
const parts = [
|
|
58
58
|
"rsync -az --delete --filter=':- .gitignore'",
|
|
59
|
-
|
|
60
|
-
excludeArgs,
|
|
59
|
+
filterArgs,
|
|
61
60
|
remote ? `-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"` : null,
|
|
62
61
|
source,
|
|
63
62
|
dest,
|
|
@@ -75,15 +74,27 @@ const runRsync = (cmd) => {
|
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
// Pull
|
|
77
|
+
// Pull tracked deployment files into temp dirs, then hash the trees to detect diffs.
|
|
79
78
|
// Callers await this check; errors bubble up except for benign rsync statuses handled in runRsync.
|
|
80
|
-
export const detectComposeChanges = async ({
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
export const detectComposeChanges = async ({
|
|
80
|
+
keyPath,
|
|
81
|
+
user,
|
|
82
|
+
host,
|
|
83
|
+
deployDir,
|
|
84
|
+
localSource = "./",
|
|
85
|
+
log,
|
|
86
|
+
extraExcludes = [],
|
|
87
|
+
includePatterns = ["*compose*.yml"],
|
|
88
|
+
}) => {
|
|
89
|
+
const excludePatterns = baseExcludeList
|
|
83
90
|
// dev compose files are not relevant for prod deploy checks
|
|
84
91
|
.concat(["compose.dev.yml"])
|
|
85
92
|
.concat(extraExcludes)
|
|
86
|
-
|
|
93
|
+
|
|
94
|
+
const filterArgs = excludePatterns.map((pattern) => `--exclude='${pattern}'`)
|
|
95
|
+
.concat(["--include='*/'"])
|
|
96
|
+
.concat(includePatterns.map((pattern) => `--include='${pattern}'`))
|
|
97
|
+
.concat(["--exclude='*'"])
|
|
87
98
|
.join(" ")
|
|
88
99
|
|
|
89
100
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rb-compose-"))
|
|
@@ -102,8 +113,7 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
|
|
|
102
113
|
|
|
103
114
|
try {
|
|
104
115
|
const remoteSyncCmd = buildRsyncCmd({
|
|
105
|
-
|
|
106
|
-
excludeArgs,
|
|
116
|
+
filterArgs,
|
|
107
117
|
keyPath,
|
|
108
118
|
source: `${user}@${host}:~/apps/${deployDir}/`,
|
|
109
119
|
dest: `${remoteDir}/`,
|
|
@@ -113,10 +123,9 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
|
|
|
113
123
|
runRsync(remoteSyncCmd)
|
|
114
124
|
|
|
115
125
|
const localSyncCmd = buildRsyncCmd({
|
|
116
|
-
|
|
117
|
-
excludeArgs,
|
|
126
|
+
filterArgs,
|
|
118
127
|
keyPath,
|
|
119
|
-
source: "
|
|
128
|
+
source: `${localSource.replace(/\/$/, "")}/`,
|
|
120
129
|
dest: `${localDir}/`,
|
|
121
130
|
remote: false,
|
|
122
131
|
})
|
|
@@ -150,7 +159,7 @@ export const detectComposeChanges = async ({ keyPath, user, host, deployDir, log
|
|
|
150
159
|
if (missingLocal.length) summary.push(`Only on remote: ${missingLocal.join(", ")}`)
|
|
151
160
|
if (missingRemote.length) summary.push(`Only on local: ${missingRemote.join(", ")}`)
|
|
152
161
|
if (changed.length) summary.push(`Content differs: ${changed.join(", ")}`)
|
|
153
|
-
if (!hasChanges) summary.push("
|
|
162
|
+
if (!hasChanges) summary.push("Tracked deployment files are identical.")
|
|
154
163
|
|
|
155
164
|
return { hasChanges, output: summary.join("\n") }
|
|
156
165
|
} finally {
|
package/src/cmd-deploy/index.js
CHANGED
|
@@ -7,6 +7,180 @@ import validator from "validator"
|
|
|
7
7
|
|
|
8
8
|
import { purgeCloudflareCaches } from "./purge-cloudflare.js"
|
|
9
9
|
import { baseExcludeList, detectComposeChanges } from "./detect-compose-changes.js"
|
|
10
|
+
import { createPrivateApiClient } from "./private-api.js"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const deploymentInstances = ["a", "b"]
|
|
14
|
+
|
|
15
|
+
const normalizeInstance = (instance) => {
|
|
16
|
+
if (!instance) return null
|
|
17
|
+
const normalized = String(instance).trim().toLowerCase()
|
|
18
|
+
return deploymentInstances.includes(normalized) ? normalized : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parseInstance = (instance) => {
|
|
22
|
+
const normalized = normalizeInstance(instance)
|
|
23
|
+
if (!normalized) throw new Error(`Invalid deployment instance: ${instance}`)
|
|
24
|
+
return normalized
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const getOtherInstance = (instance) => instance === "a" ? "b" : "a"
|
|
28
|
+
|
|
29
|
+
const getInstanceDeployDir = (baseDeployDir, instance) => `${baseDeployDir}-${instance}`
|
|
30
|
+
|
|
31
|
+
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
32
|
+
|
|
33
|
+
const sharedInfrastructureIncludePatterns = [
|
|
34
|
+
"infrastructure/compose.yml",
|
|
35
|
+
"infrastructure/compose.instances.yml",
|
|
36
|
+
"infrastructure/package.json",
|
|
37
|
+
"infrastructure/package-lock.json",
|
|
38
|
+
"infrastructure/ensure-*",
|
|
39
|
+
"infrastructure/mongot/***",
|
|
40
|
+
".env",
|
|
41
|
+
".env.*",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const sharedInfrastructureExcludePatterns = [
|
|
45
|
+
"infrastructure/data/***",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const toArray = (value) => {
|
|
49
|
+
if (value == null) return []
|
|
50
|
+
return Array.isArray(value) ? value : [value]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`
|
|
54
|
+
|
|
55
|
+
const withTrailingSlash = (value) => `${String(value).replace(/\/$/, "")}/`
|
|
56
|
+
|
|
57
|
+
const resolveFrom = (baseDir, value) => (
|
|
58
|
+
path.isAbsolute(value) ? value : path.resolve(baseDir, value)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const toRelativePattern = (baseDir, value) => {
|
|
62
|
+
const resolved = resolveFrom(baseDir, value)
|
|
63
|
+
const relative = path.relative(baseDir, resolved)
|
|
64
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null
|
|
65
|
+
return relative.split(path.sep).join("/")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const buildRsyncFilterArgs = ({ includes = [], excludes = [] }) => {
|
|
69
|
+
const excludeArgs = excludes.map((pattern) => `--exclude=${shellQuote(pattern)}`)
|
|
70
|
+
|
|
71
|
+
if (includes.length === 0) return excludeArgs.join(" ")
|
|
72
|
+
|
|
73
|
+
return excludeArgs
|
|
74
|
+
.concat(["--include='*/'"])
|
|
75
|
+
.concat(includes.map((pattern) => `--include=${shellQuote(pattern)}`))
|
|
76
|
+
.concat(["--exclude='*'"])
|
|
77
|
+
.join(" ")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const prepareDeploySource = ({
|
|
81
|
+
sourceDir,
|
|
82
|
+
infrastructureDir,
|
|
83
|
+
sharedEnvFiles,
|
|
84
|
+
includePatterns,
|
|
85
|
+
excludePatterns,
|
|
86
|
+
log,
|
|
87
|
+
}) => {
|
|
88
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
89
|
+
throw new Error(`Deploy source directory does not exist: ${sourceDir}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const defaultInfrastructureDir = path.resolve(sourceDir, "infrastructure")
|
|
93
|
+
const needsStaging = (
|
|
94
|
+
sourceDir !== process.cwd() ||
|
|
95
|
+
infrastructureDir !== defaultInfrastructureDir ||
|
|
96
|
+
sharedEnvFiles.length > 0 ||
|
|
97
|
+
includePatterns.length > 0
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if (!needsStaging) {
|
|
101
|
+
return {
|
|
102
|
+
deployRoot: sourceDir,
|
|
103
|
+
sharedEnvFileNames: [],
|
|
104
|
+
cleanup: () => {},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rb-deploy-"))
|
|
109
|
+
const deployRoot = path.join(tempRoot, "root")
|
|
110
|
+
fs.mkdirSync(deployRoot)
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
try {
|
|
113
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(`Cleanup failed for ${tempRoot}: ${err}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const sharedEnvSourceExcludes = sharedEnvFiles
|
|
121
|
+
.map((filePath) => toRelativePattern(sourceDir, filePath))
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
|
|
124
|
+
const sourceFilterArgs = buildRsyncFilterArgs({
|
|
125
|
+
includes: includePatterns,
|
|
126
|
+
excludes: baseExcludeList.concat(sharedEnvSourceExcludes).concat(excludePatterns),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const sourceRsyncCmd = [
|
|
130
|
+
"rsync -a",
|
|
131
|
+
includePatterns.length > 0 ? "--prune-empty-dirs" : null,
|
|
132
|
+
"--delete",
|
|
133
|
+
sourceFilterArgs,
|
|
134
|
+
shellQuote(withTrailingSlash(sourceDir)),
|
|
135
|
+
shellQuote(withTrailingSlash(deployRoot)),
|
|
136
|
+
].filter(Boolean).join(" ")
|
|
137
|
+
|
|
138
|
+
log(`Preparing deploy source: ${sourceRsyncCmd}`)
|
|
139
|
+
execSync(sourceRsyncCmd, { stdio: "ignore" })
|
|
140
|
+
|
|
141
|
+
if (fs.existsSync(infrastructureDir)) {
|
|
142
|
+
const deployInfrastructureDir = path.join(deployRoot, "infrastructure")
|
|
143
|
+
fs.mkdirSync(deployInfrastructureDir, { recursive: true })
|
|
144
|
+
|
|
145
|
+
const infrastructureRsyncCmd = [
|
|
146
|
+
"rsync -a",
|
|
147
|
+
"--delete",
|
|
148
|
+
buildRsyncFilterArgs({ excludes: baseExcludeList.concat(["data/"]).concat(excludePatterns) }),
|
|
149
|
+
shellQuote(withTrailingSlash(infrastructureDir)),
|
|
150
|
+
shellQuote(withTrailingSlash(deployInfrastructureDir)),
|
|
151
|
+
].filter(Boolean).join(" ")
|
|
152
|
+
|
|
153
|
+
log(`Preparing infrastructure source: ${infrastructureRsyncCmd}`)
|
|
154
|
+
execSync(infrastructureRsyncCmd, { stdio: "ignore" })
|
|
155
|
+
} else if (infrastructureDir !== defaultInfrastructureDir) {
|
|
156
|
+
throw new Error(`Infrastructure directory does not exist: ${infrastructureDir}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sharedEnvFileNames = []
|
|
160
|
+
for (const sharedEnvFile of sharedEnvFiles) {
|
|
161
|
+
const sourcePath = resolveFrom(sourceDir, sharedEnvFile)
|
|
162
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
|
|
163
|
+
throw new Error(`Shared env file does not exist: ${sourcePath}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const fileName = path.basename(sourcePath)
|
|
167
|
+
if (sharedEnvFileNames.includes(fileName)) {
|
|
168
|
+
throw new Error(`Multiple shared env files resolve to ${fileName}`)
|
|
169
|
+
}
|
|
170
|
+
sharedEnvFileNames.push(fileName)
|
|
171
|
+
fs.copyFileSync(sourcePath, path.join(deployRoot, fileName))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
deployRoot,
|
|
176
|
+
sharedEnvFileNames,
|
|
177
|
+
cleanup,
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
cleanup()
|
|
181
|
+
throw err
|
|
182
|
+
}
|
|
183
|
+
}
|
|
10
184
|
|
|
11
185
|
|
|
12
186
|
export const deploy = async (argv) => {
|
|
@@ -50,12 +224,17 @@ export const deploy = async (argv) => {
|
|
|
50
224
|
|
|
51
225
|
const host = RB_SSH_HOST
|
|
52
226
|
const user = RB_SSH_USER
|
|
53
|
-
const
|
|
227
|
+
const baseDeployDir = RB_DEPLOY_DIR
|
|
228
|
+
const sourceDir = resolveFrom(process.cwd(), argv.sourceDir || ".")
|
|
229
|
+
const infrastructureDir = resolveFrom(sourceDir, argv.infrastructureDir || "infrastructure")
|
|
230
|
+
const sharedEnvFiles = toArray(argv.sharedEnvFile)
|
|
231
|
+
const deployIncludePatterns = toArray(argv.include)
|
|
232
|
+
const deployExcludePatterns = toArray(argv.exclude).concat(toArray(argv.ignore))
|
|
54
233
|
let keyPath = RB_SSH_KEY
|
|
55
234
|
|
|
56
235
|
let tempKeyPath = null
|
|
236
|
+
let preparedDeploy = null
|
|
57
237
|
|
|
58
|
-
// Create an SSH function that can be reused
|
|
59
238
|
const ssh = async (command, stdioInherit = false) => {
|
|
60
239
|
const sshCommand = `ssh -i "${keyPath}" -o StrictHostKeyChecking=no ${user}@${host} "${command}"`
|
|
61
240
|
log(`${command}`)
|
|
@@ -66,6 +245,47 @@ export const deploy = async (argv) => {
|
|
|
66
245
|
return res
|
|
67
246
|
}
|
|
68
247
|
|
|
248
|
+
const syncSharedEnvFiles = (deployRoot, sharedEnvFileNames) => {
|
|
249
|
+
const envFiles = sharedEnvFileNames.length > 0
|
|
250
|
+
? sharedEnvFileNames
|
|
251
|
+
: fs.readdirSync(deployRoot)
|
|
252
|
+
.filter((name) => name === ".env" || name.startsWith(".env."))
|
|
253
|
+
|
|
254
|
+
if (envFiles.length === 0) return
|
|
255
|
+
|
|
256
|
+
const rsyncCmd = [
|
|
257
|
+
"rsync -avz",
|
|
258
|
+
`-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
|
|
259
|
+
...envFiles.map((name) => shellQuote(path.join(deployRoot, name))),
|
|
260
|
+
`${user}@${host}:~/apps/${baseDeployDir}/`,
|
|
261
|
+
].join(" ")
|
|
262
|
+
|
|
263
|
+
log(`Running: ${rsyncCmd}`)
|
|
264
|
+
execSync(rsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const runInstanceAction = async (action, instance) => {
|
|
268
|
+
await ssh(`cd ~/apps/${baseDeployDir}/infrastructure && node ctrl.js ${action}-instance ${instance} prod`, true)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const waitForInstance = async (instance, target) => {
|
|
272
|
+
const port = Number(target?.port)
|
|
273
|
+
if (!Number.isFinite(port) || port <= 0) throw new Error(`Cannot healthcheck instance ${instance}: invalid port`)
|
|
274
|
+
|
|
275
|
+
const url = `http://127.0.0.1:${port}/`
|
|
276
|
+
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
|
277
|
+
try {
|
|
278
|
+
await ssh(`node -e "fetch('${url}').then((res)=>process.exit(res.status < 500 ? 0 : 1)).catch(()=>process.exit(1))"`)
|
|
279
|
+
console.log(`Instance ${instance} is healthy at ${url}`)
|
|
280
|
+
return
|
|
281
|
+
} catch {
|
|
282
|
+
await sleep(1000)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw new Error(`Instance ${instance} did not become healthy at ${url}`)
|
|
287
|
+
}
|
|
288
|
+
|
|
69
289
|
try {
|
|
70
290
|
if (keyPath.startsWith("~")) keyPath = keyPath.replace("~", os.homedir())
|
|
71
291
|
|
|
@@ -86,57 +306,169 @@ export const deploy = async (argv) => {
|
|
|
86
306
|
log(`Connecting to ${host} as ${user} using key at ${keyPath}`)
|
|
87
307
|
const res = await ssh("echo connection-ok")
|
|
88
308
|
console.log(`${user}@${host}`, res)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const { hasChanges: infrastructureChanged, output: infrastructureDiff } = await detectComposeChanges({
|
|
92
|
-
keyPath,
|
|
93
|
-
user,
|
|
94
|
-
host,
|
|
95
|
-
deployDir,
|
|
96
|
-
log,
|
|
97
|
-
extraExcludes: argv.ignore || [],
|
|
98
|
-
})
|
|
309
|
+
const useBlueGreen = argv.blueGreen || argv.rollback
|
|
310
|
+
const siteId = baseDeployDir
|
|
99
311
|
|
|
100
|
-
|
|
101
|
-
console.log("Infrastructure compose files differ between local and remote (info only; deploy proceeds).")
|
|
102
|
-
if (argv.verbose) console.log(infrastructureDiff)
|
|
103
|
-
}
|
|
312
|
+
const privateApi = useBlueGreen ? createPrivateApiClient({env: argv.env}) : null
|
|
104
313
|
|
|
105
|
-
|
|
314
|
+
if (argv.rollback) {
|
|
315
|
+
const statusRes = await privateApi.get(`/_private/v1/sites/${siteId}/deployment`)
|
|
316
|
+
const deployment = statusRes.data?.deployment
|
|
317
|
+
const activeInstance = parseInstance(deployment?.activeInstance)
|
|
318
|
+
const previousInstance = parseInstance(deployment?.previousInstance)
|
|
106
319
|
|
|
107
|
-
|
|
320
|
+
const previousTarget = deployment?.instances?.[previousInstance]
|
|
321
|
+
const activeTarget = deployment?.instances?.[activeInstance]
|
|
322
|
+
if (!previousTarget) throw new Error(`Cannot rollback ${siteId}: instance ${previousInstance} is not configured`)
|
|
323
|
+
if (!activeTarget) throw new Error(`Cannot rollback ${siteId}: active instance ${activeInstance} is not configured`)
|
|
108
324
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
325
|
+
console.log(`Starting rollback instance ${previousInstance}`)
|
|
326
|
+
await runInstanceAction("up", previousInstance)
|
|
327
|
+
await waitForInstance(previousInstance, previousTarget)
|
|
328
|
+
console.log(`Rolling back ${siteId}: ${activeInstance} -> ${previousInstance}`)
|
|
329
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/lock`)
|
|
330
|
+
try {
|
|
331
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/deployment/rollback`)
|
|
332
|
+
} finally {
|
|
333
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/unlock`)
|
|
334
|
+
}
|
|
335
|
+
await runInstanceAction("down", activeInstance)
|
|
336
|
+
console.log(`Rollback complete: ${siteId} is on instance ${previousInstance}`)
|
|
337
|
+
} else {
|
|
338
|
+
preparedDeploy = prepareDeploySource({
|
|
339
|
+
sourceDir,
|
|
340
|
+
infrastructureDir,
|
|
341
|
+
sharedEnvFiles,
|
|
342
|
+
includePatterns: deployIncludePatterns,
|
|
343
|
+
excludePatterns: deployExcludePatterns,
|
|
344
|
+
log,
|
|
345
|
+
})
|
|
117
346
|
|
|
118
|
-
|
|
119
|
-
|
|
347
|
+
const deployRoot = preparedDeploy.deployRoot
|
|
348
|
+
const deployInfrastructureDir = path.join(deployRoot, "infrastructure")
|
|
349
|
+
let deployDir = baseDeployDir
|
|
350
|
+
let targetInstance = null
|
|
351
|
+
let activeInstance = null
|
|
352
|
+
let target = null
|
|
120
353
|
|
|
121
|
-
|
|
354
|
+
if (useBlueGreen) {
|
|
355
|
+
const statusRes = await privateApi.get(`/_private/v1/sites/${siteId}/deployment`)
|
|
356
|
+
const deployment = statusRes.data?.deployment
|
|
357
|
+
activeInstance = parseInstance(deployment?.activeInstance)
|
|
358
|
+
targetInstance = getOtherInstance(activeInstance)
|
|
359
|
+
target = deployment?.instances?.[targetInstance]
|
|
360
|
+
if (!target) throw new Error(`Cannot deploy ${siteId}: instance ${targetInstance} is not configured`)
|
|
122
361
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js up prod`, true)
|
|
362
|
+
deployDir = getInstanceDeployDir(baseDeployDir, targetInstance)
|
|
363
|
+
console.log(`Deploying ${siteId} to inactive instance ${targetInstance} at ${deployDir}`)
|
|
364
|
+
}
|
|
127
365
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
await ssh("docker image prune -f")
|
|
132
|
-
} catch (pruneError) {
|
|
133
|
-
console.warn(
|
|
134
|
-
`Warning: docker image prune failed (deploy continues): ${pruneError instanceof Error ? pruneError.message : pruneError}`,
|
|
135
|
-
)
|
|
366
|
+
await ssh(`mkdir -p ~/apps/${deployDir}`)
|
|
367
|
+
if (useBlueGreen) {
|
|
368
|
+
await ssh(`mkdir -p ~/apps/${baseDeployDir}`)
|
|
136
369
|
}
|
|
137
|
-
}
|
|
138
370
|
|
|
139
|
-
|
|
371
|
+
const hasInfrastructure = fs.existsSync(deployInfrastructureDir)
|
|
372
|
+
if (!useBlueGreen || hasInfrastructure) {
|
|
373
|
+
const infrastructureDeployDir = useBlueGreen ? baseDeployDir : deployDir
|
|
374
|
+
const { hasChanges: infrastructureChanged, output: infrastructureDiff } = await detectComposeChanges({
|
|
375
|
+
keyPath,
|
|
376
|
+
user,
|
|
377
|
+
host,
|
|
378
|
+
deployDir: infrastructureDeployDir,
|
|
379
|
+
localSource: deployRoot,
|
|
380
|
+
log,
|
|
381
|
+
extraExcludes: useBlueGreen
|
|
382
|
+
? sharedInfrastructureExcludePatterns.concat(deployExcludePatterns)
|
|
383
|
+
: deployExcludePatterns,
|
|
384
|
+
includePatterns: useBlueGreen
|
|
385
|
+
? sharedInfrastructureIncludePatterns.concat(preparedDeploy.sharedEnvFileNames)
|
|
386
|
+
: undefined,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
if (infrastructureChanged) {
|
|
390
|
+
if (useBlueGreen) {
|
|
391
|
+
if (argv.verbose) console.log(infrastructureDiff)
|
|
392
|
+
throw new Error("Shared infrastructure files changed. Deploy or restart the shared infrastructure explicitly before running a blue/green app deploy.")
|
|
393
|
+
}
|
|
394
|
+
console.log("Infrastructure compose files differ between local and remote (info only; deploy proceeds).")
|
|
395
|
+
if (argv.verbose) console.log(infrastructureDiff)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (useBlueGreen && hasInfrastructure) {
|
|
400
|
+
await ssh(`mkdir -p ~/apps/${baseDeployDir}/infrastructure`)
|
|
401
|
+
syncSharedEnvFiles(deployRoot, preparedDeploy.sharedEnvFileNames)
|
|
402
|
+
|
|
403
|
+
const infrastructureExcludeArgs = baseExcludeList
|
|
404
|
+
.concat(["data/"])
|
|
405
|
+
.concat(deployExcludePatterns)
|
|
406
|
+
|
|
407
|
+
const infrastructureRsyncCmd = [
|
|
408
|
+
"rsync -avz -C",
|
|
409
|
+
buildRsyncFilterArgs({ excludes: infrastructureExcludeArgs }),
|
|
410
|
+
"--delete",
|
|
411
|
+
`-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
|
|
412
|
+
shellQuote(withTrailingSlash(deployInfrastructureDir)),
|
|
413
|
+
`${user}@${host}:~/apps/${baseDeployDir}/infrastructure/`,
|
|
414
|
+
].join(" ")
|
|
415
|
+
|
|
416
|
+
log(`Running: ${infrastructureRsyncCmd}`)
|
|
417
|
+
execSync(infrastructureRsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
|
|
418
|
+
await ssh(`cd ~/apps/${baseDeployDir}/infrastructure && [ -f package.json ] && npm ci || true`)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const excludeList = baseExcludeList
|
|
422
|
+
.concat(useBlueGreen ? ["infrastructure/"] : [])
|
|
423
|
+
.concat(useBlueGreen ? preparedDeploy.sharedEnvFileNames : [])
|
|
424
|
+
.concat(deployExcludePatterns)
|
|
425
|
+
|
|
426
|
+
const rsyncCmd = [
|
|
427
|
+
"rsync -avz -C",
|
|
428
|
+
buildRsyncFilterArgs({ excludes: excludeList }),
|
|
429
|
+
"--delete",
|
|
430
|
+
`-e "ssh -i '${keyPath}' -o StrictHostKeyChecking=no"`,
|
|
431
|
+
shellQuote(withTrailingSlash(deployRoot)),
|
|
432
|
+
`${user}@${host}:~/apps/${deployDir}/`,
|
|
433
|
+
].join(" ")
|
|
434
|
+
|
|
435
|
+
log(`Running: ${rsyncCmd}`)
|
|
436
|
+
execSync(rsyncCmd, { stdio: argv.verbose ? "inherit" : "ignore" })
|
|
437
|
+
|
|
438
|
+
if (useBlueGreen) {
|
|
439
|
+
console.log(`Starting instance ${targetInstance}`)
|
|
440
|
+
await runInstanceAction("up", targetInstance)
|
|
441
|
+
await waitForInstance(targetInstance, target)
|
|
442
|
+
|
|
443
|
+
console.log(`Switching ${siteId}: ${activeInstance} -> ${targetInstance}`)
|
|
444
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/lock`)
|
|
445
|
+
try {
|
|
446
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/deployment/switch`, {instance: targetInstance})
|
|
447
|
+
} finally {
|
|
448
|
+
await privateApi.post(`/_private/v1/sites/${siteId}/unlock`)
|
|
449
|
+
}
|
|
450
|
+
await runInstanceAction("down", activeInstance)
|
|
451
|
+
} else {
|
|
452
|
+
await ssh(`cd ~/apps/${deployDir}/infrastructure && [ -f package.json ] && npm ci || true`)
|
|
453
|
+
log("Restarting application...")
|
|
454
|
+
await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js down prod`, true)
|
|
455
|
+
log("Application stopped. Starting it up again...")
|
|
456
|
+
await ssh(`cd ~/apps/${deployDir}/infrastructure && node ctrl.js up prod`, true)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (argv.pruneDocker !== false) {
|
|
460
|
+
log("Pruning dangling docker images on the remote host...")
|
|
461
|
+
try {
|
|
462
|
+
await ssh("docker image prune -f")
|
|
463
|
+
} catch (pruneError) {
|
|
464
|
+
console.warn(
|
|
465
|
+
`Warning: docker image prune failed (deploy continues): ${pruneError instanceof Error ? pruneError.message : pruneError}`,
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
log("Deployment complete ✅")
|
|
471
|
+
}
|
|
140
472
|
} catch (error) {
|
|
141
473
|
console.error(
|
|
142
474
|
`Deployment failed: ${error instanceof Error ? error.message : error}`,
|
|
@@ -151,6 +483,10 @@ export const deploy = async (argv) => {
|
|
|
151
483
|
console.warn(`Failed to remove temporary key file: ${cleanupError}`)
|
|
152
484
|
}
|
|
153
485
|
}
|
|
486
|
+
|
|
487
|
+
if (preparedDeploy) {
|
|
488
|
+
preparedDeploy.cleanup()
|
|
489
|
+
}
|
|
154
490
|
}
|
|
155
491
|
|
|
156
492
|
try {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import https from "https"
|
|
3
|
+
|
|
4
|
+
import axios from "axios"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const requiredEnv = (env, name) => {
|
|
8
|
+
const value = env[name]
|
|
9
|
+
if (!value) throw new Error(`Missing required environment variable: ${name}`)
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createPrivateApiClient = ({env, timeoutMs = 15_000}) => {
|
|
14
|
+
const httpsAgent = new https.Agent({
|
|
15
|
+
cert: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CLIENT_CERT")),
|
|
16
|
+
key: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CLIENT_KEY")),
|
|
17
|
+
ca: fs.readFileSync(requiredEnv(env, "RB_PROXY_PRIVATE_API_CA")),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return axios.create({
|
|
21
|
+
baseURL: requiredEnv(env, "RB_PROXY_PRIVATE_API_URL"),
|
|
22
|
+
timeout: timeoutMs,
|
|
23
|
+
httpsAgent,
|
|
24
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
25
|
+
})
|
|
26
|
+
}
|
package/src/index.js
CHANGED
|
@@ -74,6 +74,41 @@ yargs(hideBin(process.argv))
|
|
|
74
74
|
type: "array",
|
|
75
75
|
default: [],
|
|
76
76
|
})
|
|
77
|
+
.option("source-dir", {
|
|
78
|
+
describe: "Directory to use as the deployment source",
|
|
79
|
+
type: "string",
|
|
80
|
+
default: ".",
|
|
81
|
+
})
|
|
82
|
+
.option("infrastructure-dir", {
|
|
83
|
+
describe: "Infrastructure directory, relative to source-dir unless absolute",
|
|
84
|
+
type: "string",
|
|
85
|
+
default: "infrastructure",
|
|
86
|
+
})
|
|
87
|
+
.option("shared-env-file", {
|
|
88
|
+
describe: "Env file to sync to the shared deployment directory",
|
|
89
|
+
type: "array",
|
|
90
|
+
default: [],
|
|
91
|
+
})
|
|
92
|
+
.option("include", {
|
|
93
|
+
describe: "Only include matching source paths in the deployment root",
|
|
94
|
+
type: "array",
|
|
95
|
+
default: [],
|
|
96
|
+
})
|
|
97
|
+
.option("exclude", {
|
|
98
|
+
describe: "Exclude matching source paths from the deployment root and rsync commands",
|
|
99
|
+
type: "array",
|
|
100
|
+
default: [],
|
|
101
|
+
})
|
|
102
|
+
.option("blue-green", {
|
|
103
|
+
describe: "Deploy to the inactive blue/green instance and switch the reverse proxy",
|
|
104
|
+
type: "boolean",
|
|
105
|
+
default: false,
|
|
106
|
+
})
|
|
107
|
+
.option("rollback", {
|
|
108
|
+
describe: "Rollback the reverse proxy to the previous instance",
|
|
109
|
+
type: "boolean",
|
|
110
|
+
default: false,
|
|
111
|
+
})
|
|
77
112
|
},
|
|
78
113
|
async (argv) => {
|
|
79
114
|
await deploy(argv)
|