opencode-ruby-upgrader 0.1.9 → 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/README.md +18 -0
- package/package.json +2 -2
- package/src/controller.js +3 -4
- package/src/services.js +129 -0
- package/src/target-runtime.js +59 -7
package/README.md
CHANGED
|
@@ -86,6 +86,12 @@ Three flags bound how much a single run can change.
|
|
|
86
86
|
|
|
87
87
|
Use `/ruby-upgrade --dry-run` for a no-write inventory and proposed migration assessment; it creates no report, lock, checkpoint, or durable research evidence. Use `/ruby-upgrade --target <version>` (for example `/ruby-upgrade --target 3.4`) to pin an explicit final Ruby version, or `/ruby-upgrade --stop-after-hop` to validate and commit one hop before stopping.
|
|
88
88
|
|
|
89
|
+
### Local credentials and environment files
|
|
90
|
+
|
|
91
|
+
`git worktree add` only checks out files tracked by Git. Untracked files (such as `.env`, local credential files, `config/database.yml.local`, or project-specific test environment configuration) are **not** copied from your main worktree to the new linked worktree. If your specs or local test setup require environment variables or credentials to run, you must manually create or copy them into the new worktree (`../<repo>-ruby-<target>/`). The agent never creates, copies, or commits credentials or secrets.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
89
95
|
## Validated end-to-end run
|
|
90
96
|
|
|
91
97
|
The agent has completed a real end-to-end migration against a public fixture: [`ruby2-rails4-bootstrap-heroku`](https://github.com/lilla021/ruby2-rails4-bootstrap-heroku) (BSD-2-Clause) moved from **Ruby 2.4.10 / Rails 4.2.11.3** to **Ruby 3.4.10 / Rails 7.1.6** across 15 receipt-backed hops. Every hop was validated by `bundle exec rspec` in an isolated Docker container, then committed as a local checkpoint before the next hop began.
|
|
@@ -127,6 +133,18 @@ Both engines run credential-free on a per-run Docker network that carries two ow
|
|
|
127
133
|
|
|
128
134
|
Prepared resources are labelled and bound to the worktree, and validation refuses to use them if they carry extra network attachments, published ports, privileged mode, unexpected mounts or commands, or a drifted image. Docker resources are not removed automatically when a run finishes — they persist so later runs stay reproducible. When you no longer need them, remove the run's containers and network by name and delete `.ruby-upgrades/runtime.json`; the names are recorded in that manifest.
|
|
129
135
|
|
|
136
|
+
### Additional services (Redis, etc.)
|
|
137
|
+
|
|
138
|
+
The isolated runtime provisions only what the project's tests declare. If the suite requires Redis (Sidekiq, Resque, `gem "redis"`, or references to `REDIS_URL`), the runtime automatically detects it from the project's own declarations and starts an isolated `redis:7-alpine` container on the same per-run network. Other services may be added in the future using the same evidence-based adapter pattern.
|
|
139
|
+
|
|
140
|
+
- **Evidence-based detection only.** Redis is detected from concrete declarations: `gem "redis"`, `gem "sidekiq"`, `gem "resque"`, `gem "redis-rails"`, references to `Redis.new`, `Sidekiq.configure_server`, `REDIS_URL` in test/development config, or `redis (` entries in `Gemfile.lock`. Incidental strings do not trigger provisioning.
|
|
141
|
+
- **Credential-free and isolated.** The Redis container runs without authentication on the private network, has no published ports, no bind mounts, and carries the same ownership labels as the database and app containers (`runId` and worktree SHA-256). The app container receives `REDIS_URL=redis://<redis-container>:6379/0` at creation time.
|
|
142
|
+
- **Ownership and verification.** On every reuse, each service container is verified to belong to the same run and worktree, match the allowlisted image, run on only the isolated network, remain unprivileged with no published ports, and have the expected environment. Any drift causes safe replacement.
|
|
143
|
+
- **No secrets exposed.** Service URLs are derived at runtime from container names; they are not read from user environment, not persisted in committed evidence beyond runtime.json metadata, and not logged.
|
|
144
|
+
- **Automatic service provisioning.** Detects Redis (Sidekiq/Resque/redis gems) and browser/system test drivers (Selenium/Cuprite/Capybara system specs, `js: true`, `driven_by :selenium`) from project declarations. When detected, starts isolated allowlisted containers on the private network with per-run ownership labels, wires environment variables (e.g. `REDIS_URL`, `SELENIUM_URL`/`CHROME_REMOTE_URL`) into the app container, and verifies readiness before running tests.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
130
148
|
It writes nonsecret `.ruby-upgrades/runtime.json` with the run and worktree binding, the selected engine and container names, both resolved image IDs, and preparation digests; raw output, `DATABASE_URL`, and your local path are never persisted. `docker-bundle-rspec` reuses and verifies that manifest — including that it belongs to the run receiving the receipt, the exact requested Ruby execution, the exact requested database image, and the isolated network — before executing the fixed `docker exec --env DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true <container> bundle exec rspec`. The safeguard override is scoped to the verified isolated test process; no container name, report path, or environment value is needed from the user.
|
|
131
149
|
|
|
132
150
|
Receipts persist only an output digest and byte count, plus structured test metrics; raw validation output is deliberately not committed. Docker receipts additionally record the run identity, container IDs, image IDs, and network ID that produced them, so evidence cannot be silently re-pointed at another run or engine. Each receipt also binds to a non-evidence working-tree fingerprint, which the commit gate rechecks after final validation. A checkpoint commit carries the receipt digest.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ruby-upgrader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "A safe, evidence-driven Ruby runtime upgrade agent and local migration dashboard for OpenCode",
|
|
5
5
|
"author": "Priscilla Cournoyer (https://github.com/lilla021)",
|
|
6
6
|
"repository": {
|
|
@@ -57,4 +57,4 @@
|
|
|
57
57
|
"publishConfig": {
|
|
58
58
|
"access": "public"
|
|
59
59
|
}
|
|
60
|
-
}
|
|
60
|
+
}
|
package/src/controller.js
CHANGED
|
@@ -260,7 +260,7 @@ export function recordExecutedBundlerIteration({ root = process.cwd(), reportPat
|
|
|
260
260
|
const pinned = inventoryBundledWith(root);
|
|
261
261
|
if (bundlerSeries(pinned ?? "") !== bundlerSeries(iteration.to)) throw new Error(`Gemfile.lock still records Bundler ${pinned ?? "none"}; a hop to ${iteration.to} requires the BUNDLED WITH pin to be rewritten first.`);
|
|
262
262
|
if (bundlerSeries(receipt.environment?.bundlerVersion ?? "") !== bundlerSeries(iteration.to)) throw new Error(`Validation executed Bundler ${receipt.environment?.bundlerVersion ?? "an unrecorded version"}, not ${iteration.to}. Re-prepare the target runtime on the new Bundler before recording this hop.`);
|
|
263
|
-
run.iterations.push({ ...iteration, status: "complete", lockfilePin: pinned }); writeRun(root, reportPath, run); return run;
|
|
263
|
+
run.iterations.push({ ...iteration, status: "complete", lockfilePin: pinned, tests: { ...receipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [receipt] }); writeRun(root, reportPath, run); return run;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
export function recordDependencyReview({ root = process.cwd(), reportPath, compatibility, licenses }) {
|
|
@@ -333,14 +333,13 @@ export function resumeRun({ root = process.cwd(), reportPath, continueAfterHop =
|
|
|
333
333
|
const run = readRun(root, reportPath);
|
|
334
334
|
if (run.status === "complete") throw new Error("This run is complete; start a new run instead.");
|
|
335
335
|
if ((run.frameworkBridge || run.bundlerBridge) && run.phase === "blocked") throw new Error("This Ruby run is blocked by an approved compatibility bridge and cannot resume. Complete the linked bridge, then begin a fresh Ruby run.");
|
|
336
|
-
if (run.phase
|
|
336
|
+
if (run.phase !== "paused") throw new Error("Cannot resume a run that is not paused. If the lock is stale, use recovery (release-lock --force) after verifying no other process owns the worktree.");
|
|
337
|
+
if (!transitions.paused.includes(run.resumePhase)) throw new Error("Paused run has no safe resume phase.");
|
|
337
338
|
if (run.control.stopAfterHop && run.iterations.length >= 1 && run.resumePhase === "committed" && !continueAfterHop) throw new Error("This run stopped after its requested hop. Resume with explicit --continue-after-hop only after user review.");
|
|
338
339
|
if (continueAfterHop) run.control.stopAfterHop = false;
|
|
339
340
|
const lock = acquireRunLock(root, reportPath); run.lockNonce = lock.nonce;
|
|
340
341
|
try {
|
|
341
|
-
if (run.phase === "paused") {
|
|
342
342
|
run.phase = run.resumePhase; run.status = "in_progress"; delete run.resumePhase; writeRun(root, reportPath, run);
|
|
343
|
-
}
|
|
344
343
|
} catch (error) { releaseRunLock(root, reportPath, lock.nonce); throw error; }
|
|
345
344
|
return { lock, run: readRun(root, reportPath) };
|
|
346
345
|
}
|
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";
|
|
@@ -117,7 +118,11 @@ function appMatches(container, root, runtime, db) {
|
|
|
117
118
|
const env = environment(container);
|
|
118
119
|
const mounts = container?.Mounts ?? [];
|
|
119
120
|
const command = container?.Config?.Cmd ?? [];
|
|
120
|
-
|
|
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;
|
|
121
126
|
}
|
|
122
127
|
function databaseMatches(container, runtime, db) {
|
|
123
128
|
const env = environment(container);
|
|
@@ -142,9 +147,13 @@ function selectedRun(root, reportPath) {
|
|
|
142
147
|
if (!["in_progress", "paused"].includes(run.status)) throw new RunStateError("Target runtime preparation requires an active or paused upgrade run.", "run-not-resumable");
|
|
143
148
|
return { reportPath, run };
|
|
144
149
|
}
|
|
145
|
-
function names(runId, db) {
|
|
150
|
+
function names(runId, db, serviceList = []) {
|
|
146
151
|
const prefix = `ruby-upgrader-${runId}`;
|
|
147
|
-
|
|
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;
|
|
148
157
|
}
|
|
149
158
|
function writeRuntime(root, runtime) {
|
|
150
159
|
const file = runtimePath(root, true);
|
|
@@ -164,6 +173,7 @@ export function readTargetRuntime(root = process.cwd()) {
|
|
|
164
173
|
runtime = { ...legacy, database: "postgres", databaseContainer: postgresContainer };
|
|
165
174
|
}
|
|
166
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();
|
|
167
177
|
return runtime;
|
|
168
178
|
} catch { throw new Error("Target runtime metadata is invalid. Rerun prepare-target-runtime --ruby <x.y.z>."); }
|
|
169
179
|
}
|
|
@@ -172,10 +182,13 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
|
|
|
172
182
|
if (!rubyVersion.test(ruby ?? "")) throw new Error("--ruby must be an exact numeric Ruby version such as 3.4.1.");
|
|
173
183
|
const canonical = canonicalRoot(root);
|
|
174
184
|
const db = resolveDatabase(database ?? detectDatabase(canonical));
|
|
185
|
+
const detectedServices = detectServices(canonical);
|
|
186
|
+
const serviceList = resolveServices(detectedServices);
|
|
175
187
|
const selected = selectedRun(canonical, reportPath);
|
|
176
188
|
const run = readRun(canonical, selected.reportPath);
|
|
177
189
|
const bundlerToInstall = run.reportType === "bundler_bridge" ? run.targetBundler : (run.bundlerBridge ? run.bundlerBridge.bundlerTo : undefined) || "2.4.22";
|
|
178
|
-
const
|
|
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 };
|
|
179
192
|
const owner = identity(runtime.runId, canonical);
|
|
180
193
|
const existing = readTargetRuntime(canonical);
|
|
181
194
|
if (existing && existing.runId !== runtime.runId) {
|
|
@@ -187,7 +200,9 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
|
|
|
187
200
|
let app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
188
201
|
let network = docker(spawn, ["network", "inspect", runtime.network]);
|
|
189
202
|
const databaseInspections = new Map(databaseNames.map((name) => [name, docker(spawn, ["inspect", name])]));
|
|
190
|
-
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);
|
|
191
206
|
for (const resource of resources) {
|
|
192
207
|
const resourceLabels = labels(resource);
|
|
193
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.");
|
|
@@ -195,7 +210,7 @@ for (const resource of resources) {
|
|
|
195
210
|
const legacy = resources.some((resource) => labels(resource)[WORKTREE_LABEL] === undefined);
|
|
196
211
|
if (legacy && resources.length) {
|
|
197
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.");
|
|
198
|
-
for (const [name, result] of [[runtime.appContainer, app], ...databaseInspections]) {
|
|
213
|
+
for (const [name, result] of [[runtime.appContainer, app], ...databaseInspections, ...serviceInspections]) {
|
|
199
214
|
if (result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove a legacy target-runtime container.");
|
|
200
215
|
}
|
|
201
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.");
|
|
@@ -227,6 +242,37 @@ for (const resource of resources) {
|
|
|
227
242
|
try { docker(spawn, ["rm", "--force", runtime.databaseContainer]); } catch {}
|
|
228
243
|
throw error;
|
|
229
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
|
+
}
|
|
230
276
|
if (app.status === 0) {
|
|
231
277
|
const container = parseInspection(app);
|
|
232
278
|
if (!appMatches(container, canonical, runtime, db)) {
|
|
@@ -234,7 +280,13 @@ for (const resource of resources) {
|
|
|
234
280
|
app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
235
281
|
}
|
|
236
282
|
}
|
|
237
|
-
|
|
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.");
|
|
238
290
|
const prepared = bootstrap(spawn, runtime, db, railsProject(canonical));
|
|
239
291
|
const appInspection = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
240
292
|
const databaseInspection = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|