opencode-ruby-upgrader 0.1.8 → 0.1.10
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/PRIVACY.md +2 -0
- package/README.md +56 -7
- package/RELEASE_NOTES.md +39 -0
- package/agents/ruby-upgrade.md +4 -2
- package/bin/opencode-ruby-upgrader.mjs +18 -2
- package/docs/bundler-bridge.md +107 -0
- package/package.json +31 -5
- package/src/advisory.js +140 -0
- package/src/bundler-compat.js +68 -0
- package/src/commit-hop.js +24 -13
- package/src/controller.js +83 -10
- package/src/dashboard-page.js +1 -1
- package/src/dashboard.js +9 -1
- package/src/inventory.js +5 -1
- package/src/run-state.js +91 -2
- package/src/services.js +129 -0
- package/src/target-runtime.js +82 -14
package/src/services.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Service adapters for isolated target runtime.
|
|
2
|
+
//
|
|
3
|
+
// Each adapter defines container lifecycle (image, start args, readiness probe)
|
|
4
|
+
// and the environment variables the app container needs to reach the service
|
|
5
|
+
// over the per-run private Docker network. Only allowlisted adapter images are
|
|
6
|
+
// permitted; no user-supplied images or environment values.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
12
|
+
|
|
13
|
+
function waitSecond() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); }
|
|
14
|
+
|
|
15
|
+
function readyLoop({ probe, attempts, label }) {
|
|
16
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
17
|
+
if (probe().status === 0) return;
|
|
18
|
+
waitSecond();
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Target ${label} did not become ready within ${attempts} seconds. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const services = Object.freeze({
|
|
24
|
+
redis: Object.freeze({
|
|
25
|
+
type: "redis",
|
|
26
|
+
label: "Redis",
|
|
27
|
+
image: "redis:7-alpine",
|
|
28
|
+
port: 6379,
|
|
29
|
+
// Start Redis in the foreground with default config; no authentication in
|
|
30
|
+
// the isolated test network.
|
|
31
|
+
startArgs: () => ["redis:7-alpine", "redis-server", "--appendonly", "no"],
|
|
32
|
+
// Probe readiness with redis-cli against localhost.
|
|
33
|
+
readyArgs: (container) => ["exec", container, "redis-cli", "ping"],
|
|
34
|
+
// Expose REDIS_URL so the app reaches Redis over the private network by
|
|
35
|
+
// container name. Use DB 0 for test isolation by convention.
|
|
36
|
+
env: ({ container }) => ({ REDIS_URL: `redis://${container}:6379/0` }),
|
|
37
|
+
ensureReady: ({ probe }) => readyLoop({ probe, attempts: 30, label: "Redis" })
|
|
38
|
+
}),
|
|
39
|
+
chrome: Object.freeze({
|
|
40
|
+
type: "chrome",
|
|
41
|
+
label: "Chrome",
|
|
42
|
+
image: "selenium/standalone-chrome:latest",
|
|
43
|
+
port: 4444,
|
|
44
|
+
startArgs: () => ["--shm-size=2g", "selenium/standalone-chrome:latest"],
|
|
45
|
+
readyArgs: (container) => ["exec", container, "curl", "-s", "--fail", "http://localhost:4444/wd/hub/status"],
|
|
46
|
+
env: ({ container }) => ({
|
|
47
|
+
SELENIUM_URL: "http://chrome:4444/wd/hub",
|
|
48
|
+
CHROME_REMOTE_URL: "http://chrome:4444",
|
|
49
|
+
CAPYBARA_SERVER_HOST: "0.0.0.0"
|
|
50
|
+
}),
|
|
51
|
+
ensureReady: ({ probe }) => readyLoop({ probe, attempts: 120, label: "Chrome" })
|
|
52
|
+
}),
|
|
53
|
+
selenium: Object.freeze({
|
|
54
|
+
type: "selenium",
|
|
55
|
+
label: "Selenium",
|
|
56
|
+
image: "selenium/standalone-chrome:latest",
|
|
57
|
+
port: 4444,
|
|
58
|
+
startArgs: () => ["--shm-size=2g", "selenium/standalone-chrome:latest"],
|
|
59
|
+
readyArgs: (container) => ["exec", container, "curl", "-s", "--fail", "http://localhost:4444/wd/hub/status"],
|
|
60
|
+
env: ({ container }) => ({
|
|
61
|
+
SELENIUM_URL: "http://selenium:4444/wd/hub",
|
|
62
|
+
CHROME_REMOTE_URL: "http://selenium:4444",
|
|
63
|
+
CAPYBARA_SERVER_HOST: "0.0.0.0"
|
|
64
|
+
}),
|
|
65
|
+
ensureReady: ({ probe }) => readyLoop({ probe, attempts: 120, label: "Selenium" })
|
|
66
|
+
})
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export function resolveService(type) {
|
|
70
|
+
if (!type || !Object.hasOwn(services, type)) return undefined;
|
|
71
|
+
return services[type];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveServices(types = []) {
|
|
75
|
+
const result = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const type of types) {
|
|
78
|
+
if (seen.has(type)) continue;
|
|
79
|
+
const svc = resolveService(type);
|
|
80
|
+
if (svc) { result.push({ type, adapter: svc }); seen.add(type); }
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const REDIS_EVIDENCE = [
|
|
86
|
+
/\bgem\s*\(?\s*["']redis["']/i,
|
|
87
|
+
/\bgem\s*\(?\s*["']sidekiq["']/i,
|
|
88
|
+
/\bgem\s*\(?\s*["']resque["']/i,
|
|
89
|
+
/\bgem\s*\(?\s*["']redis-rails["']/i,
|
|
90
|
+
/\bredis\.yml\b/i,
|
|
91
|
+
/\bRedis\.new\b/i,
|
|
92
|
+
/\bSidekiq\.configure_server\b/i,
|
|
93
|
+
/\bREDIS_URL\b/i
|
|
94
|
+
];
|
|
95
|
+
const REDIS_LOCK = /^[ \t]*redis \(/m;
|
|
96
|
+
const REDIS_LOCK_SIDEKIQ = /^[ \t]*sidekiq \(/m;
|
|
97
|
+
const REDIS_LOCK_RESQUE = /^[ \t]*resque \(/m;
|
|
98
|
+
|
|
99
|
+
export function detectServices(root = process.cwd()) {
|
|
100
|
+
const read = (file) => {
|
|
101
|
+
try { return fs.readFileSync(path.join(root, file), "utf8"); } catch { return ""; }
|
|
102
|
+
};
|
|
103
|
+
const uncommented = (contents) => contents.replace(/#.*$/gm, "");
|
|
104
|
+
const contents = [
|
|
105
|
+
"config/database.yml",
|
|
106
|
+
"Gemfile",
|
|
107
|
+
"Gemfile.lock",
|
|
108
|
+
"config/application.rb",
|
|
109
|
+
"config/environments/test.rb",
|
|
110
|
+
"config/environments/development.rb",
|
|
111
|
+
"config/initializers/sidekiq.rb",
|
|
112
|
+
"config/sidekiq.yml"
|
|
113
|
+
].map(read).join("\n");
|
|
114
|
+
const text = uncommented(contents);
|
|
115
|
+
const lock = `${uncommented(read("Gemfile.lock"))}\n`;
|
|
116
|
+
const services = [];
|
|
117
|
+
const hasRedis = REDIS_EVIDENCE.some((re) => re.test(text)) ||
|
|
118
|
+
REDIS_LOCK.test(lock) ||
|
|
119
|
+
REDIS_LOCK_SIDEKIQ.test(lock) ||
|
|
120
|
+
REDIS_LOCK_RESQUE.test(lock);
|
|
121
|
+
if (hasRedis) services.push("redis");
|
|
122
|
+
const hasChrome = /driven_by\s*:\s*(?:selenium|cuprite|chrome)/i.test(text) ||
|
|
123
|
+
/selenium-webdriver|cuprite|capybara/i.test(text + lock);
|
|
124
|
+
if (hasChrome) services.push("chrome");
|
|
125
|
+
// De-duplicate while preserving order
|
|
126
|
+
return [...new Set(services)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { NAME };
|
package/src/target-runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { RunStateError, readRun } from "./run-state.js";
|
|
6
6
|
import { NAME, databases, detectDatabase, resolveDatabase } from "./databases.js";
|
|
7
|
+
import { services as serviceAdapters, detectServices, resolveServices } from "./services.js";
|
|
7
8
|
|
|
8
9
|
const RUNTIME_FILE = "runtime.json";
|
|
9
10
|
const LABEL = "io.opencode-ruby-upgrader.run-id";
|
|
@@ -46,27 +47,36 @@ function preparationResult(result) {
|
|
|
46
47
|
return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
|
|
47
48
|
}
|
|
48
49
|
function bootstrap(spawn, runtime, db, rails) {
|
|
49
|
-
const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation"];
|
|
50
|
+
const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation", "Bundler version attestation"];
|
|
50
51
|
const commands = [
|
|
51
52
|
["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
|
|
52
|
-
["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
|
|
53
|
-
["exec", runtime.appContainer, "bundle", "
|
|
53
|
+
["exec", runtime.appContainer, "gem", "install", "bundler", "-v", runtime.bundlerToInstall ?? "2.4.22", "--no-document"],
|
|
54
|
+
["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "install"],
|
|
54
55
|
...(rails ? [db.createArgs({ appContainer: runtime.appContainer, databaseContainer: runtime.databaseContainer })] : []),
|
|
55
|
-
["exec", runtime.appContainer, "ruby", "--version"]
|
|
56
|
+
["exec", runtime.appContainer, "ruby", "--version"],
|
|
57
|
+
// Attest the Bundler version actually installed, so the report can warn when
|
|
58
|
+
// CI/host bundler differs. `gem install` output is advisory; `--version` is
|
|
59
|
+
// authoritative for what `bundle exec` will actually resolve.
|
|
60
|
+
["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "--version"]
|
|
56
61
|
];
|
|
57
62
|
const results = commands.map((args, index) => {
|
|
58
63
|
const result = docker(spawn, args);
|
|
59
64
|
if (result.status !== 0) throw new Error(`Target runtime bootstrap failed during ${labels[index]}. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
60
65
|
return result;
|
|
61
66
|
});
|
|
62
|
-
|
|
67
|
+
// Index the attestations explicitly rather than from the end of the array:
|
|
68
|
+
// `ruby --version` is no longer the final command, and an off-by-one here would
|
|
69
|
+
// validate the Bundler banner against the Ruby regex.
|
|
70
|
+
const rubyIndex = 3 + (rails ? 1 : 0);
|
|
71
|
+
const rubyOutput = `${results[rubyIndex].stdout ?? ""}${results[rubyIndex].stderr ?? ""}`;
|
|
63
72
|
if (!new RegExp(`^ruby ${runtime.ruby.replaceAll(".", "\\.")}(?:p\\d+|\\s|$)`).test(rubyOutput.trim())) throw new Error("Target runtime did not execute the requested Ruby version.");
|
|
64
73
|
return {
|
|
65
74
|
node: preparationResult(results[0]),
|
|
66
75
|
bundler: preparationResult(results[1]),
|
|
67
76
|
bundleInstall: preparationResult(results[2]),
|
|
68
77
|
...(rails ? { databaseCreate: preparationResult(results[3]) } : {}),
|
|
69
|
-
rubyVersion: preparationResult(results
|
|
78
|
+
rubyVersion: preparationResult(results[rubyIndex]),
|
|
79
|
+
bundlerVersion: `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`.trim()
|
|
70
80
|
};
|
|
71
81
|
}
|
|
72
82
|
function railsProject(root) {
|
|
@@ -108,7 +118,11 @@ function appMatches(container, root, runtime, db) {
|
|
|
108
118
|
const env = environment(container);
|
|
109
119
|
const mounts = container?.Mounts ?? [];
|
|
110
120
|
const command = container?.Config?.Cmd ?? [];
|
|
111
|
-
|
|
121
|
+
const servicesMatch = !runtime.services || runtime.services.every((svc) => {
|
|
122
|
+
const expected = serviceAdapters[svc.type]?.env?.({ container: svc.container }) || {};
|
|
123
|
+
return Object.entries(expected).every(([k, v]) => env[k] === v);
|
|
124
|
+
});
|
|
125
|
+
return container?.State?.Running && container?.Config?.Image === `ruby:${runtime.ruby}` && container?.Config?.WorkingDir === "/app" && mounts.length === 1 && mountedFromRoot(container, root) && command.length === 2 && command[0] === "sleep" && command[1] === "infinity" && onlyNetwork(container, runtime.network) && isolatedContainer(container) && env.RAILS_ENV === "test" && env.DATABASE_URL === db.databaseUrl(runtime.databaseContainer) && servicesMatch;
|
|
112
126
|
}
|
|
113
127
|
function databaseMatches(container, runtime, db) {
|
|
114
128
|
const env = environment(container);
|
|
@@ -133,9 +147,13 @@ function selectedRun(root, reportPath) {
|
|
|
133
147
|
if (!["in_progress", "paused"].includes(run.status)) throw new RunStateError("Target runtime preparation requires an active or paused upgrade run.", "run-not-resumable");
|
|
134
148
|
return { reportPath, run };
|
|
135
149
|
}
|
|
136
|
-
function names(runId, db) {
|
|
150
|
+
function names(runId, db, serviceList = []) {
|
|
137
151
|
const prefix = `ruby-upgrader-${runId}`;
|
|
138
|
-
|
|
152
|
+
const out = { appContainer: `${prefix}-app`, databaseContainer: `${prefix}-${db.adapter}`, network: `${prefix}-network` };
|
|
153
|
+
for (const svc of serviceList) {
|
|
154
|
+
out[`${svc.type}Container`] = `${prefix}-${svc.type}`;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
139
157
|
}
|
|
140
158
|
function writeRuntime(root, runtime) {
|
|
141
159
|
const file = runtimePath(root, true);
|
|
@@ -155,6 +173,7 @@ export function readTargetRuntime(root = process.cwd()) {
|
|
|
155
173
|
runtime = { ...legacy, database: "postgres", databaseContainer: postgresContainer };
|
|
156
174
|
}
|
|
157
175
|
if (runtime?.version !== 2 || typeof runtime.runId !== "string" || !rubyVersion.test(runtime.ruby ?? "") || !/^sha256:[a-f0-9]{64}$/i.test(runtime.resolvedImageId ?? "") || (runtime.databaseImageId !== undefined && !/^sha256:[a-f0-9]{64}$/i.test(runtime.databaseImageId)) || !runtime.preparation || !Object.hasOwn(databases, runtime.database) || ![runtime.appContainer, runtime.databaseContainer, runtime.network].every((name) => NAME.test(name ?? ""))) throw new Error();
|
|
176
|
+
if (runtime.services && !Array.isArray(runtime.services)) throw new Error();
|
|
158
177
|
return runtime;
|
|
159
178
|
} catch { throw new Error("Target runtime metadata is invalid. Rerun prepare-target-runtime --ruby <x.y.z>."); }
|
|
160
179
|
}
|
|
@@ -163,8 +182,13 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
|
|
|
163
182
|
if (!rubyVersion.test(ruby ?? "")) throw new Error("--ruby must be an exact numeric Ruby version such as 3.4.1.");
|
|
164
183
|
const canonical = canonicalRoot(root);
|
|
165
184
|
const db = resolveDatabase(database ?? detectDatabase(canonical));
|
|
185
|
+
const detectedServices = detectServices(canonical);
|
|
186
|
+
const serviceList = resolveServices(detectedServices);
|
|
166
187
|
const selected = selectedRun(canonical, reportPath);
|
|
167
|
-
const
|
|
188
|
+
const run = readRun(canonical, selected.reportPath);
|
|
189
|
+
const bundlerToInstall = run.reportType === "bundler_bridge" ? run.targetBundler : (run.bundlerBridge ? run.bundlerBridge.bundlerTo : undefined) || "2.4.22";
|
|
190
|
+
const runtimeNames = names(selected.run.runId, db, serviceList);
|
|
191
|
+
const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, bundlerToInstall, services: [], ...runtimeNames };
|
|
168
192
|
const owner = identity(runtime.runId, canonical);
|
|
169
193
|
const existing = readTargetRuntime(canonical);
|
|
170
194
|
if (existing && existing.runId !== runtime.runId) {
|
|
@@ -176,7 +200,9 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
|
|
|
176
200
|
let app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
177
201
|
let network = docker(spawn, ["network", "inspect", runtime.network]);
|
|
178
202
|
const databaseInspections = new Map(databaseNames.map((name) => [name, docker(spawn, ["inspect", name])]));
|
|
179
|
-
const
|
|
203
|
+
const serviceNames = serviceList.map((svc) => runtimeNames[`${svc.type}Container`]).filter(Boolean);
|
|
204
|
+
const serviceInspections = new Map(serviceNames.map((name) => [name, docker(spawn, ["inspect", name])]));
|
|
205
|
+
const resources = [app.status === 0 && parseInspection(app), network.status === 0 && parseInspection(network), ...[...databaseInspections.values()].filter((result) => result.status === 0).map(parseInspection), ...[...serviceInspections.values()].filter((result) => result.status === 0).map(parseInspection)].filter(Boolean);
|
|
180
206
|
for (const resource of resources) {
|
|
181
207
|
const resourceLabels = labels(resource);
|
|
182
208
|
if (resourceLabels[LABEL] !== owner.runId || (resourceLabels[WORKTREE_LABEL] !== undefined && resourceLabels[WORKTREE_LABEL] !== owner.worktreeHash)) throw new Error("Refusing to use or replace a Docker resource not owned by this run and worktree.");
|
|
@@ -184,7 +210,7 @@ for (const resource of resources) {
|
|
|
184
210
|
const legacy = resources.some((resource) => labels(resource)[WORKTREE_LABEL] === undefined);
|
|
185
211
|
if (legacy && resources.length) {
|
|
186
212
|
if (app.status !== 0 || !mountedFromRoot(parseInspection(app), canonical)) throw new Error("Refusing to migrate legacy Docker resources because ownership by this worktree cannot be established.");
|
|
187
|
-
for (const [name, result] of [[runtime.appContainer, app], ...databaseInspections]) {
|
|
213
|
+
for (const [name, result] of [[runtime.appContainer, app], ...databaseInspections, ...serviceInspections]) {
|
|
188
214
|
if (result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove a legacy target-runtime container.");
|
|
189
215
|
}
|
|
190
216
|
if (network.status !== 0 && docker(spawn, ["network", "rm", runtime.network]).status !== 0) throw new Error("Could not remove the legacy target-runtime Docker network.");
|
|
@@ -216,6 +242,37 @@ for (const resource of resources) {
|
|
|
216
242
|
try { docker(spawn, ["rm", "--force", runtime.databaseContainer]); } catch {}
|
|
217
243
|
throw error;
|
|
218
244
|
}
|
|
245
|
+
// Provision additional service containers (evidence-based)
|
|
246
|
+
const serviceStates = [];
|
|
247
|
+
for (const svc of serviceList) {
|
|
248
|
+
const svcContainer = runtimeNames[`${svc.type}Container`];
|
|
249
|
+
let svcResult = serviceInspections.get(svcContainer) ?? { status: 1 };
|
|
250
|
+
if (svcResult.status === 0) {
|
|
251
|
+
const c = parseInspection(svcResult);
|
|
252
|
+
const expectedEnv = (serviceAdapters[svc.type]?.env?.({ container: svcContainer }) || {});
|
|
253
|
+
const env = environment(c);
|
|
254
|
+
const hasBindMount = (c?.Mounts ?? []).some((mount) => mount.Type === "bind");
|
|
255
|
+
const owned = hasOwnership(c, owner);
|
|
256
|
+
const matches = c?.State?.Running && c?.Config?.Image === svc.adapter.image && onlyNetwork(c, runtime.network) && isolatedContainer(c) && !hasBindMount && owned && Object.entries(expectedEnv).every(([k, v]) => env[k] === v);
|
|
257
|
+
if (!matches) {
|
|
258
|
+
if (docker(spawn, ["rm", "--force", svcContainer]).status !== 0) throw new Error(`Could not replace the prior target-runtime ${svc.adapter.label} container.`);
|
|
259
|
+
svcResult = docker(spawn, ["inspect", svcContainer]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (svcResult.status !== 0) {
|
|
263
|
+
if (docker(spawn, ["run", "--detach", "--name", svcContainer, ...ownershipArgs, "--network", runtime.network, ...svc.adapter.startArgs()]).status !== 0) throw new Error(`Could not start the isolated target-runtime ${svc.adapter.label} container.`);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
svc.adapter.ensureReady({ probe: () => docker(spawn, svc.adapter.readyArgs(svcContainer)) });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
try { docker(spawn, ["rm", "--force", svcContainer]); } catch {}
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
const svcInspection = inspect(spawn, svcContainer, `Target ${svc.adapter.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
272
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(svcInspection?.Image ?? "")) throw new Error(`Target ${svc.adapter.label} image ID is unavailable.`);
|
|
273
|
+
serviceStates.push({ type: svc.type, container: svcContainer, imageId: svcInspection.Image });
|
|
274
|
+
runtime.services = serviceStates;
|
|
275
|
+
}
|
|
219
276
|
if (app.status === 0) {
|
|
220
277
|
const container = parseInspection(app);
|
|
221
278
|
if (!appMatches(container, canonical, runtime, db)) {
|
|
@@ -223,7 +280,13 @@ for (const resource of resources) {
|
|
|
223
280
|
app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
224
281
|
}
|
|
225
282
|
}
|
|
226
|
-
|
|
283
|
+
const appEnv = ["--env", "RAILS_ENV=test", "--env", `DATABASE_URL=${db.databaseUrl(runtime.databaseContainer)}`];
|
|
284
|
+
for (const svc of serviceList) {
|
|
285
|
+
const svcContainer = runtimeNames[`${svc.type}Container`];
|
|
286
|
+
const envs = serviceAdapters[svc.type]?.env?.({ container: svcContainer }) || {};
|
|
287
|
+
for (const [k, v] of Object.entries(envs)) appEnv.push("--env", `${k}=${v}`);
|
|
288
|
+
}
|
|
289
|
+
if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer, ...ownershipArgs, "--network", runtime.network, "--mount", `type=bind,src=${canonical},dst=/app`, "--workdir", "/app", ...appEnv, `ruby:${ruby}`, "sleep", "infinity"]).status !== 0) throw new Error("Could not start the target Ruby app container.");
|
|
227
290
|
const prepared = bootstrap(spawn, runtime, db, railsProject(canonical));
|
|
228
291
|
const appInspection = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
229
292
|
const databaseInspection = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
@@ -250,5 +313,10 @@ export function validateTargetRuntime({ root = process.cwd(), runtime = readTarg
|
|
|
250
313
|
const expectedIds = [app?.Id, database?.Id].sort();
|
|
251
314
|
const valid = appMatches(app, canonical, runtime, db) && databaseMatches(database, runtime, db) && hasOwnership(network, owner) && hasOwnership(app, owner) && hasOwnership(database, owner) && connectedIds.length === 2 && connectedIds.every((id, index) => id === expectedIds[index]) && app?.Image === runtime.resolvedImageId && (!runtime.databaseImageId || database?.Image === runtime.databaseImageId) && env.RAILS_ENV === "test" && env.DATABASE_URL === db.databaseUrl(runtime.databaseContainer);
|
|
252
315
|
if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
253
|
-
|
|
316
|
+
// bundlerVersion is surfaced so the generated report can warn that the deploy
|
|
317
|
+
// host and CI may resolve a different Bundler than this container pinned. It
|
|
318
|
+
// comes from `bundle --version` in the prepared container, not from the
|
|
319
|
+
// bootstrap log, so it reflects what `bundle exec` actually resolved.
|
|
320
|
+
const bundlerVersion = /Bundler version (\d+(?:\.\d+)+)/.exec(runtime.preparation?.bundlerVersion ?? "")?.[1] ?? undefined;
|
|
321
|
+
return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, ...(bundlerVersion ? { bundlerVersion } : {}), database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
|
|
254
322
|
}
|