opencode-ruby-upgrader 0.1.6 → 0.1.8
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 +3 -1
- package/README.md +16 -4
- package/RELEASE_CHECKLIST.md +3 -0
- package/RELEASE_NOTES.md +48 -0
- package/agents/ruby-upgrade.md +1 -1
- package/bin/opencode-ruby-upgrader.mjs +8 -3
- package/docs/rails-bridge.md +98 -0
- package/package.json +5 -2
- package/src/controller.js +3 -3
- package/src/databases.js +120 -0
- package/src/run-state.js +11 -1
- package/src/target-runtime.js +127 -38
- package/src/validation-executor.js +5 -4
package/PRIVACY.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Privacy and Local Evidence
|
|
2
2
|
|
|
3
|
-
This package has no telemetry, analytics, or report-upload feature. It reads and writes migration evidence only in the current project worktree under `.ruby-upgrades/runs/` and serves the dashboard only on `127.0.0.1`.
|
|
3
|
+
This package has no telemetry, analytics, or report-upload feature. It reads and writes migration evidence only in the current project worktree — run reports under `.ruby-upgrades/runs/` and nonsecret runtime metadata in `.ruby-upgrades/runtime.json` — and serves the dashboard only on `127.0.0.1`.
|
|
4
4
|
|
|
5
5
|
Reports can contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata. Absolute local paths and recognized credentials are redacted, but redaction is best-effort. Do not place secrets, customer data, database dumps, or raw command output in report fields.
|
|
6
6
|
|
|
7
7
|
Reports may be staged into local checkpoint commits. Review them before committing, pushing, sharing, or opening the dashboard on a shared machine. Delete `.ruby-upgrades/` when evidence retention is no longer needed. The dashboard has no authentication; other local processes able to reach your loopback interface may read its displayed report data.
|
|
8
8
|
|
|
9
9
|
Dependency installation, tests, and Rails tooling execute project-controlled code with your local user permissions after confirmation. Use an isolated environment for repositories you do not trust.
|
|
10
|
+
|
|
11
|
+
Preparing an isolated runtime creates local Docker containers, a dedicated network, and pulled images that persist after the run finishes; nothing is uploaded and no port is published to your host. Those resources are labelled with the run ID and a SHA-256 hash of the worktree path, never the path itself. That hash exists only on the Docker resources — it is deliberately kept out of `.ruby-upgrades/` so that committing your upgrade evidence does not publish a guessable fingerprint of your filesystem path. Validation recomputes the hash from the current worktree and compares it against the live labels. Validation receipts written under `.ruby-upgrades/runs/` also record ephemeral Docker identifiers (container IDs, network ID, and image IDs) for traceability of the isolated run; these are local environment metadata, not personal data. Remove containers/networks with `docker rm`/`docker network rm` using the names recorded in `.ruby-upgrades/runtime.json`, and remove pulled images separately if you want the disk space back.
|
package/README.md
CHANGED
|
@@ -115,11 +115,21 @@ Every hop declares its expected changed files before commit. The commit gate blo
|
|
|
115
115
|
|
|
116
116
|
The agent defaults unknown shell commands to an OpenCode confirmation prompt. Git inspection is allowed, while direct Git mutation, GitHub CLI, publishing, and shell chaining/pipes/substitutions are denied. Dependency installation/updates, recognized tests, state writes, `commit-hop`, and `commit-rails-hop` require confirmation. This protects against accidental agent actions, not malicious project code: dependency installation and tests execute project-controlled code with your local user permissions. Use an isolated environment for repositories you do not trust, and review any command OpenCode asks you to approve.
|
|
117
117
|
|
|
118
|
-
New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. When the target Ruby is unavailable on your host, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labeled per-run Ruby and isolated
|
|
118
|
+
New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. When the target Ruby is unavailable on your host, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labeled per-run Ruby and isolated database Docker resources, installs Node, installs Bundler 2.4.22, runs `bundle install`, and, for Rails, creates the isolated test database.
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
The isolated test database is PostgreSQL by default. If the project declares MySQL through `mysql2` and not PostgreSQL, the runtime is prepared with an isolated MySQL 8.4 server instead. Detection reads declarations rather than incidental words: an `adapter:` line in `config/database.yml`, a `gem "mysql2"` / `gem "pg"` line, a resolved spec in `Gemfile.lock`, or a driver URL. `config/database.yml` wins over gem declarations, because a gem line only says a driver is *available* while the YAML says what the app connects to — so a PostgreSQL project that keeps `gem "mysql2"` for an unrelated import tool still resolves to PostgreSQL. The legacy `mysql` and `trilogy` gems are not recognized, and a project that declares both engines stops preparation and asks you to choose:
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
```bash
|
|
123
|
+
opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> --database mysql
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Both engines run credential-free on a per-run Docker network that carries two ownership labels: the run ID and a SHA-256 hash of the worktree path. Validation compares those labels against a freshly computed hash of the current worktree, so ownership is asserted against the live containers rather than trusted from a file — a copied manifest cannot vouch for another worktree's resources. The hash stays on the Docker labels and is never written into `.ruby-upgrades/`, which means committing your upgrade evidence does not publish a guessable fingerprint of your filesystem path. A network, container, or manifest belonging to a different run or worktree is refused rather than reused, so a second worktree cannot reach, reuse, or delete another worktree's database.
|
|
127
|
+
|
|
128
|
+
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
|
+
|
|
130
|
+
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
|
+
|
|
132
|
+
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.
|
|
123
133
|
|
|
124
134
|
This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
|
|
125
135
|
|
|
@@ -190,7 +200,7 @@ The credential scanner is heuristic: it recognizes common token formats and quot
|
|
|
190
200
|
|
|
191
201
|
## Privacy
|
|
192
202
|
|
|
193
|
-
No telemetry, no analytics, and no report uploads. Migration evidence is written only under `.ruby-upgrades
|
|
203
|
+
No telemetry, no analytics, and no report uploads. Migration evidence is written only under `.ruby-upgrades/` in the current worktree — run reports under `.ruby-upgrades/runs/` and nonsecret runtime metadata in `.ruby-upgrades/runtime.json` — and the dashboard binds to `127.0.0.1` only. Reports may contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata — absolute local paths and recognized credentials are redacted, but redaction is best-effort. Prepared Docker containers, networks, and images stay on your machine and are labelled with the run ID and a one-way hash of the worktree path, never the path itself.
|
|
194
204
|
|
|
195
205
|
Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/PRIVACY.md). To report a vulnerability, see [SECURITY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/SECURITY.md).
|
|
196
206
|
|
|
@@ -198,6 +208,8 @@ Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/b
|
|
|
198
208
|
|
|
199
209
|
Run the self-contained test suite with `npm test`. A CI environment that installs a supported OpenCode CLI can also run `OPENCODE_RUNTIME_E2E=1 npm run test:opencode`; this verifies the installed runtime is available and the plugin registers its agent/command contract before release.
|
|
200
210
|
|
|
211
|
+
Database-engine changes require real container evidence, not only mocked tests. `npm run test:docker:mysql` builds a throwaway Rails + `mysql2` project, prepares a real `mysql:8.4` runtime, proves the app connects over the isolated network with a passing RSpec example, checks the resulting receipt and that the persisted manifest stays secret-free, and verifies it left no containers, network, or worktree behind. CI runs this on every pull request, and the release workflow runs it again before publishing. It needs a running Docker daemon and network access to pull images.
|
|
212
|
+
|
|
201
213
|
CI runs that suite against Node 22, 24, and 26 so the declared `engines.node` range is exercised rather than assumed. Both workflows pin `npm@11.16.0` so the matrix varies Node rather than npm, because `allowScripts` — npm 11's dependency install-script approval gate, used here to narrowly permit the pinned `opencode-ai` postinstall — and npm trusted publishing both require npm 11 or newer. `RELEASING.md` covers the publish path.
|
|
202
214
|
|
|
203
215
|
## Acknowledgments
|
package/RELEASE_CHECKLIST.md
CHANGED
|
@@ -5,6 +5,9 @@ Complete every item before pushing a `v*` tag. The protected `npm-release` envir
|
|
|
5
5
|
## Before every release
|
|
6
6
|
|
|
7
7
|
- [ ] Bump the version and update [RELEASE_NOTES.md](RELEASE_NOTES.md): scope, supported adapters, known limitations, rollback.
|
|
8
|
+
- [ ] For every newly supported adapter — including a database engine — record real container evidence, not only mocked tests. `npm run test:docker` provisions a real `mysql:8.4` **and** a real `postgres:16-alpine`, connects from each app through its own driver, and checks the receipt. Do not ship a new adapter on unit tests alone. The `adapter-runtime` CI job runs both engines on every pull request and `release.yml` runs them again before `npm publish`, so this is enforced rather than advisory. Prove it for every adapter you ship, not just the one you changed: the adapter that has no container evidence is the one that surprises you.
|
|
9
|
+
- [ ] Review new or changed CLI options in `opencode-ruby-upgrader <command> --help`; users should not have to read the source to find a documented flag.
|
|
10
|
+
- [ ] Reconcile README, `agents/ruby-upgrade.md`, and release notes with the shipped behavior: defaults, detection and ambiguity behavior, resource lifecycle, and what is persisted.
|
|
8
11
|
- [ ] Repoint the README's tag-pinned links to the new version tag: `E2E_EVIDENCE.md`, `PRIVACY.md`, `SECURITY.md`, `docs/rails-bridge.md`, and both screenshot URLs. They are pinned to a tag so the registry page always shows the docs of the installed version rather than whatever `main` currently says; leaving them on the previous tag makes the published page drift behind the release.
|
|
9
12
|
- [ ] Review the package metadata (name, description, keywords, `repository`, `bugs`, `homepage`) and the packed-file list with `npm pack --dry-run`.
|
|
10
13
|
- [ ] Run `npm test`; confirm the release workflow's runtime smoke gate passes with the pinned OpenCode runtime.
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# opencode-ruby-upgrader — release notes
|
|
2
2
|
|
|
3
|
+
## v0.1.8 — real-adapter detection and PostgreSQL container evidence
|
|
4
|
+
|
|
5
|
+
v0.1.7 shipped database detection that had only ever been tested against hand-written fixtures. Running it against real Rails applications found three wrong answers, one of them silent. This release fixes detection and closes the container-evidence gap for the default adapter.
|
|
6
|
+
|
|
7
|
+
**Scope**
|
|
8
|
+
|
|
9
|
+
- Detection now matches declarations rather than tokens. Previously `mysql2` matched anywhere in `Gemfile`, `Gemfile.lock`, or `config/database.yml`, while PostgreSQL required an `adapter:` line. That asymmetry meant only a false *MySQL* answer was possible, and it happened: rails/rails ships `activerecord/test/config.example.yml` with `mysql2:` and `postgresql:` as YAML keys naming a profile under `connections:`, and every adapter actually selected is `sqlite3`. The old rule read the `mysql2:` key and silently reported MySQL. A key that names a profile is not a declaration, so it no longer counts.
|
|
10
|
+
- `config/database.yml` now takes precedence over gem declarations. A `gem` line only says a driver is *available*; the YAML says what the app connects to. Discourse is PostgreSQL throughout its `config/database.yml` while carrying `gem "mysql2"` behind an import-mode conditional — previously reported as ambiguous, forcing an explicit flag on an unambiguous project. Redmine's `database.yml.example` selects `mysql2` for every environment with `postgresql` commented out, while its `Gemfile` conditionally declares both engines; it now resolves to MySQL rather than refusing to answer.
|
|
11
|
+
- `Gemfile.lock` is read as a declaration source in its resolved-spec form (` mysql2 (0.5.6)`, ` pg (1.6.2)`), so a project with a generated or templated `Gemfile` still resolves correctly.
|
|
12
|
+
- Genuinely multi-engine projects still stop and ask. Spree declares both drivers and no `config/database.yml`, and continues to require an explicit choice.
|
|
13
|
+
|
|
14
|
+
**Evidence**
|
|
15
|
+
|
|
16
|
+
`npm run test:docker` now proves **both** adapters against real containers: a real `mysql:8.4` and a real `postgres:16-alpine`, each connected from a throwaway Rails app through its own driver, each asserting a passing RSpec receipt, a secret-free persisted manifest, and its own teardown. Detection is deliberately left to run rather than passed `--database`, so a leg that only worked because it was told the answer could not pass. The CI job is now `adapter-runtime` and `release.yml` runs both engines before publishing.
|
|
17
|
+
|
|
18
|
+
The smoke fixture is now a real Rails application rather than a bare `Gemfile`. That is not cosmetic: PostgreSQL's adapter creates its test database through the app's own `rake db:create`, so it requires a bootable app, while MySQL's creates it server-side and would accept a `Gemfile` alone. The MySQL leg had therefore been passing against a fixture that could never have exercised the PostgreSQL path. The fixture also pins `host: localhost` with a nonexistent user, so a passing run proves `DATABASE_URL` overrides the project's own configuration rather than accidentally agreeing with it.
|
|
19
|
+
|
|
20
|
+
Detection regressions from real projects are unit-tested from the actual file shapes: the rails/rails profile keys, the Discourse import-mode gem, the Redmine commented-out adapter, the Spree ambiguity, and both `Gemfile.lock` forms.
|
|
21
|
+
|
|
22
|
+
**Known limitations**
|
|
23
|
+
|
|
24
|
+
- Prepared containers, networks, and images are not torn down when a run completes or is paused. They persist for reproducibility and are removed manually; the names are recorded in `.ruby-upgrades/runtime.json`. Still no `dispose-target-runtime` command — cleanup remains documented rather than automated, and is the most requested follow-up.
|
|
25
|
+
- Detection is static text matching. A dynamically computed adapter, or a project whose test environment differs from its other environments, may need an explicit `--database`.
|
|
26
|
+
- `--database mysql2://` URLs are emitted for MySQL and `postgresql://` for PostgreSQL; a project relying on driver-specific `database.yml` keys beyond adapter, host, user, and password is not modelled.
|
|
27
|
+
|
|
28
|
+
## v0.1.7 — isolated MySQL runtime
|
|
29
|
+
|
|
30
|
+
The isolated validation runtime can now prepare MySQL as well as PostgreSQL.
|
|
31
|
+
|
|
32
|
+
**Scope**
|
|
33
|
+
|
|
34
|
+
- `prepare-target-runtime` accepts `--database postgres|mysql`. Without it, the engine is detected from `Gemfile`, `Gemfile.lock`, and `config/database.yml`; `mysql2` selects MySQL, PostgreSQL declarations select PostgreSQL, no declaration defaults to PostgreSQL, and a project declaring both engines stops and asks for an explicit choice instead of guessing.
|
|
35
|
+
- MySQL uses `mysql:8.4` with an empty root password inside the per-run network. Readiness is probed over TCP, because the official entrypoint briefly runs a socket-only server that answers `ping` before the network path works. The test database is created by the container's own client, so preparation never depends on a driver being compiled first.
|
|
36
|
+
- Only `mysql2` is supported. The legacy `mysql` adapter and `trilogy` are deliberately not recognized: this runtime emits `mysql2://` URLs, and claiming other drivers would produce a runtime that provisions successfully and then fails to load the adapter.
|
|
37
|
+
- The runtime manifest now records the selected engine, the database container, and a second image ID for the database alongside the Ruby image ID. Validation attests both, and Docker receipts record the run identity, container IDs, image IDs, and network ID.
|
|
38
|
+
- Ownership is bound to the worktree: resources carry the run ID and a SHA-256 hash of the canonical worktree path, and validation recomputes that hash to compare against the live container labels instead of trusting a value persisted in `runtime.json`. Binding to the running resources is the stronger check — a copied manifest cannot vouch for a foreign worktree — and it keeps a guessable fingerprint of the user's filesystem path out of a file intended to be committed. Legacy version-2 PostgreSQL manifests migrate in place when the existing app container proves the mount, and resources from another run or worktree are refused rather than reused or deleted.
|
|
39
|
+
- Isolation checks are now fail-closed. Validation rejects extra network attachments, published ports, privileged mode, unexpected mounts or commands, unexpected containers on the run network, and database containers with bind mounts — the properties that make a credential-free database safe to expose only to the app container.
|
|
40
|
+
|
|
41
|
+
**Evidence**
|
|
42
|
+
|
|
43
|
+
`npm run test:docker:mysql` provisions a real MySQL container, prepares the runtime, connects from a throwaway Rails app through its own `mysql2` driver, asserts a passing RSpec receipt and a secret-free persisted manifest, and verifies it left no resources behind. This is real container evidence, not a mocked unit test; the fixture is generated and deleted by the script. CI runs it on every pull request via the `mysql-runtime` job, and `release.yml` runs it again before publishing.
|
|
44
|
+
|
|
45
|
+
**Known limitations**
|
|
46
|
+
|
|
47
|
+
- Prepared containers, networks, and images are not torn down when a run completes or is paused. They persist for reproducibility and are removed manually; the names are recorded in `.ruby-upgrades/runtime.json`.
|
|
48
|
+
- Detection is static text matching. A dynamically computed adapter, or a project whose test environment differs from its other environments, may need an explicit `--database`.
|
|
49
|
+
- No teardown command exists yet; cleanup is documented rather than automated.
|
|
50
|
+
|
|
3
51
|
## v0.1.6 — reader path and CI coverage
|
|
4
52
|
|
|
5
53
|
No runtime change. The agent's commands, permission policy, commit gate, and report format are untouched. This release restructures the public documentation and closes a CI coverage gap.
|
package/agents/ruby-upgrade.md
CHANGED
|
@@ -12,7 +12,7 @@ You own a careful Ruby runtime migration. Be decisive on routine fixes and trans
|
|
|
12
12
|
|
|
13
13
|
- Begin by running `opencode-ruby-upgrader preflight --json`. If it does not return `ok: true`, do not inspect, edit, test, or resolve dependencies. Require the user to configure `git config opencode-ruby-upgrader.defaultBranch <branch>`; do not infer a default branch. Show the worktree instructions and ask the user to relaunch OpenCode from their user-created linked worktree. If it returns `mode: "non-git"`, permit dry-run inventory only and stop before any durable run, edit, test, or dependency resolution.
|
|
14
14
|
- Parse user controls before work: `--dry-run`, `--target <version>`, and `--stop-after-hop`. Run `opencode-ruby-upgrader inventory`, `opencode-ruby-upgrader supply-chain`, and `opencode-ruby-upgrader git-capabilities` first. Pause for explicit review on shallow clones, sparse checkout, submodules, or LFS configuration. If the project is unsupported or has no recognized test adapter, stop with the detected evidence and ask for a test command; do not invent one. After official research pins the target, initialize the durable run with `opencode-ruby-upgrader begin --target <version>` (append `--dry-run` or `--stop-after-hop` when requested). Use only the returned report path for this run.
|
|
15
|
-
- Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labeled app and
|
|
15
|
+
- Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. The isolated test database is PostgreSQL unless the project declares MySQL through `mysql2`, in which case the runtime prepares an isolated MySQL server; the engine is detected from the project's own declarations, so do not guess or pass `--database` on your own. If preparation reports that both MySQL and PostgreSQL were detected, stop and present that conflict with your evidence and ask the user to choose the engine once, then rerun with their answer. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labeled app and database containers to the selected run and worktree; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`. Prepared Docker resources persist after the run ends; report the container and network names from the manifest so the user can remove them, and never remove containers or networks that the tool did not create for this run.
|
|
16
16
|
- If the next Ruby hop is incompatible with the resolved Rails version, do not edit Rails as part of the Ruby hop. Cite the official compatibility evidence, explain the required Rails from/to versions, and obtain explicit approval before running `record-framework-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --rails-from <version> --rails-to <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-rails-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Rails-minor hops with `record-rails-research`, record each passing hop with `record-executed-rails-iteration` including reviewed `bin/rails app:update` evidence, transition to `hop_validated`, and checkpoint only with `commit-rails-hop`. Start a fresh Ruby run only after the Rails bridge is complete.
|
|
17
17
|
- Rails `app:update` executes with conflict-skipping semantics so existing application configuration is never overwritten noninteractively. Review every generated file before final tests. If a pending result is unsafe or superseded, revert only that unvalidated hop and run `discard-pending-app-update --report <report-path> --reason <review finding>`; the discarded digest remains durable evidence before a fresh attempt.
|
|
18
18
|
- When a validated hop changes a lockfile, inspect the resolved dependency delta and license findings, then durably record both with `record-dependency-review --report <report-path> --compatibility <finding> --licenses <finding>` before requesting the checkpoint. Never bypass this gate merely because tests pass.
|
|
@@ -14,7 +14,12 @@ const options = (name) => args.flatMap((argument, index) => argument === name &&
|
|
|
14
14
|
const reportOption = () => option("--report");
|
|
15
15
|
const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
|
|
16
16
|
const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
|
|
17
|
-
|
|
17
|
+
const prepareRuntimeHelp = `Usage: opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]
|
|
18
|
+
|
|
19
|
+
Prepares an isolated, run-bound Docker runtime. The database is detected from mysql2/PostgreSQL project declarations; absent evidence defaults to PostgreSQL, while conflicting evidence requires --database. Use --database only to override detection deliberately.`;
|
|
20
|
+
if (command === "prepare-target-runtime" && args.includes("--help")) {
|
|
21
|
+
console.log(prepareRuntimeHelp);
|
|
22
|
+
} else if (command === "help" || args.includes("--help")) {
|
|
18
23
|
console.log(`${usage}\n\nUse status --summary for a concise report view. release-lock is stale-session recovery only and requires --force.`);
|
|
19
24
|
} else if (command === "preflight") {
|
|
20
25
|
const result = inspectWorktree();
|
|
@@ -35,8 +40,8 @@ if (command === "help" || args.includes("--help")) {
|
|
|
35
40
|
catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
|
|
36
41
|
} else if (command === "prepare-target-runtime") {
|
|
37
42
|
try {
|
|
38
|
-
if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--report .ruby-upgrades/runs/<run>.json]");
|
|
39
|
-
console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), reportPath: option("--report") }), null, 2));
|
|
43
|
+
if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]");
|
|
44
|
+
console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), database: option("--database"), reportPath: option("--report") }), null, 2));
|
|
40
45
|
} catch (error) { console.error(`Target runtime preparation blocked: ${error.message}`); process.exitCode = 1; }
|
|
41
46
|
} else if (command === "status") {
|
|
42
47
|
try {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Rails bridge lifecycle
|
|
2
|
+
|
|
3
|
+
Reference for the rare case where the next Ruby hop is blocked by the resolved Rails version. The Ruby run is **not** repaired in place: it transitions to `blocked` and becomes terminal, and a separate Rails-bridge run takes over. See [Recovery](../README.md#recovery) in the README for the general pause/resume/revert path.
|
|
4
|
+
|
|
5
|
+
## When this applies
|
|
6
|
+
|
|
7
|
+
If the Ruby version you want to reach is incompatible with the Rails version your app currently pins, the agent will not edit Rails as part of a Ruby hop. Rails changes are a framework-major migration and require an explicit, evidence-backed decision from you.
|
|
8
|
+
|
|
9
|
+
The bridge exists to keep one concern per report: the blocked Ruby report records *why* it stopped, the Rails report records *how* the framework moved.
|
|
10
|
+
|
|
11
|
+
## 1. Record the bridge, then block the Ruby run
|
|
12
|
+
|
|
13
|
+
Cite the official compatibility evidence and state the required Rails from/to versions before anything runs. Approval is required; the agent does not infer it.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
opencode-ruby-upgrader record-framework-bridge \
|
|
17
|
+
--report .ruby-upgrades/runs/<ruby-report>.json \
|
|
18
|
+
--ruby-from <from> --ruby-to <to> \
|
|
19
|
+
--rails-from <from> --rails-to <to> \
|
|
20
|
+
--rationale "<why this Rails version is required>" \
|
|
21
|
+
--citation 'title|https://...'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then transition the Ruby run:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
opencode-ruby-upgrader transition \
|
|
28
|
+
--report .ruby-upgrades/runs/<ruby-report>.json \
|
|
29
|
+
--phase blocked
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**That Ruby report is terminal.** Do not attempt to resume it. Complete the Rails bridge, then start a fresh Ruby run.
|
|
33
|
+
|
|
34
|
+
## 2. Begin the Rails bridge
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
opencode-ruby-upgrader begin-rails-bridge \
|
|
38
|
+
--ruby-report .ruby-upgrades/runs/<blocked-ruby-report>.json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This creates a separate report for the framework migration.
|
|
42
|
+
|
|
43
|
+
## 3. Research contiguous Rails-minor hops
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
opencode-ruby-upgrader record-rails-research \
|
|
47
|
+
--report .ruby-upgrades/runs/<rails-report>.json \
|
|
48
|
+
--ladder <version>,<version>,<version> \
|
|
49
|
+
--citation 'title|https://...'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each hop is one contiguous minor version. Rails does not support skipping minor series the way Ruby hops do, so the ladder is denser.
|
|
53
|
+
|
|
54
|
+
## 4. Run `app:update`, then validate
|
|
55
|
+
|
|
56
|
+
Every Rails iteration executes `bin/rails app:update` **first**, records its receipt, and reviews that exact working-tree fingerprint before final tests run.
|
|
57
|
+
|
|
58
|
+
`app:update` runs with conflict-skipping semantics, so your existing application configuration is never overwritten non-interactively. **Review every generated file** before accepting the hop.
|
|
59
|
+
|
|
60
|
+
Record the iteration with its validation ID:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
opencode-ruby-upgrader record-executed-rails-iteration \
|
|
64
|
+
--report .ruby-upgrades/runs/<rails-report>.json \
|
|
65
|
+
--json '<iteration payload>' \
|
|
66
|
+
--validation <test-id>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Accepted validation IDs are the fixed no-shell commands, including `bundle-rails-test`, `bin-rails-test`, `bundle-rake-test`, and `rails-app-update` for the update step itself. Asserted results cannot be recorded.
|
|
70
|
+
|
|
71
|
+
Then transition and checkpoint:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
opencode-ruby-upgrader transition \
|
|
75
|
+
--report .ruby-upgrades/runs/<rails-report>.json \
|
|
76
|
+
--phase hop_validated
|
|
77
|
+
|
|
78
|
+
opencode-ruby-upgrader commit-rails-hop \
|
|
79
|
+
--report .ruby-upgrades/runs/<rails-report>.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`commit-rails-hop` is the only permitted commit path for a Rails hop. It is mutually exclusive with `commit-hop`, which is Ruby-only — the gate rejects the wrong one rather than producing a mislabelled checkpoint.
|
|
83
|
+
|
|
84
|
+
## Discarding a bad hop
|
|
85
|
+
|
|
86
|
+
If a pending `app:update` result is unsafe or superseded, revert only that unvalidated hop and discard it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
opencode-ruby-upgrader discard-pending-app-update \
|
|
90
|
+
--report .ruby-upgrades/runs/<rails-report>.json \
|
|
91
|
+
--reason "<review finding>"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The discarded digest remains durable evidence, so the report still accounts for the attempt. Use `discard-last-rails-iteration --reason "<finding>"` for a recorded iteration that turned out to be wrong.
|
|
95
|
+
|
|
96
|
+
## After the bridge completes
|
|
97
|
+
|
|
98
|
+
Start a fresh Ruby run. The new run picks up the upgraded Rails version and continues the original migration path from there.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ruby-upgrader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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": {
|
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "node --test tests/*.test.js",
|
|
21
|
+
"test:docker": "node tests/docker-adapter-smoke.mjs",
|
|
22
|
+
"test:docker:mysql": "node tests/docker-adapter-smoke.mjs mysql",
|
|
23
|
+
"test:docker:postgres": "node tests/docker-adapter-smoke.mjs postgres",
|
|
21
24
|
"test:opencode": "node tests/opencode-runtime-smoke.mjs"
|
|
22
25
|
},
|
|
23
26
|
"allowScripts": {
|
|
24
27
|
"opencode-ai@1.18.30": true
|
|
25
28
|
},
|
|
26
|
-
"files": ["src", "agents", "bin", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
|
|
29
|
+
"files": ["src", "agents", "bin", "docs/*.md", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
|
|
27
30
|
"keywords": ["opencode", "opencode-plugin", "ruby", "rails", "upgrade", "worktree", "migration"],
|
|
28
31
|
"engines": { "node": ">=22.5.0" },
|
|
29
32
|
"license": "MIT",
|
package/src/controller.js
CHANGED
|
@@ -142,7 +142,7 @@ function recordRailsIterationInternal({ root = process.cwd(), reportPath, iterat
|
|
|
142
142
|
export function recordRailsIteration(options = {}) { return recordRailsIterationInternal(options); }
|
|
143
143
|
|
|
144
144
|
export function recordExecutedIteration({ root = process.cwd(), reportPath, iteration, validationCommandId }) {
|
|
145
|
-
const run = validationPreflight(root, reportPath, false); const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId });
|
|
145
|
+
const run = validationPreflight(root, reportPath, false); const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId, expectedRuntime: { runId: run.runId, reportPath } });
|
|
146
146
|
if (receipt.kind !== "test" || receipt.exitCode !== 0 || receipt.testEvidence?.passed !== true) throw new Error(`Validation failed; receipt ${receipt.id} was not recorded as a passing hop.`);
|
|
147
147
|
return recordIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...receipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [receipt] } });
|
|
148
148
|
}
|
|
@@ -150,7 +150,7 @@ export function recordExecutedIteration({ root = process.cwd(), reportPath, iter
|
|
|
150
150
|
export function recordExecutedRailsIteration({ root = process.cwd(), reportPath, iteration, testValidationCommandId }) {
|
|
151
151
|
const run = validationPreflight(root, reportPath, true);
|
|
152
152
|
if (!run.pendingAppUpdate) {
|
|
153
|
-
const updateReceipt = executeValidation({ root, inventory: run.inventory, commandId: "rails-app-update" });
|
|
153
|
+
const updateReceipt = executeValidation({ root, inventory: run.inventory, commandId: "rails-app-update", expectedRuntime: { runId: run.runId, reportPath } });
|
|
154
154
|
if (updateReceipt.exitCode !== 0) throw new Error("app:update failed; Rails hop was not recorded.");
|
|
155
155
|
run.pendingAppUpdate = updateReceipt;
|
|
156
156
|
writeRun(root, reportPath, run);
|
|
@@ -159,7 +159,7 @@ export function recordExecutedRailsIteration({ root = process.cwd(), reportPath,
|
|
|
159
159
|
const updateReceipt = run.pendingAppUpdate;
|
|
160
160
|
const review = { ...iteration?.appUpdateReview, receiptId: updateReceipt.id, executedAt: updateReceipt.startedAt, worktree: updateReceipt.worktree };
|
|
161
161
|
if (!validRailsReviewForExecution(review, updateReceipt)) throw new Error("Review app:update after it executes and bind the review to its receipt and working-tree fingerprint.");
|
|
162
|
-
const testReceipt = executeValidation({ root, inventory: run.inventory, commandId: testValidationCommandId });
|
|
162
|
+
const testReceipt = executeValidation({ root, inventory: run.inventory, commandId: testValidationCommandId, expectedRuntime: { runId: run.runId, reportPath } });
|
|
163
163
|
if (testReceipt.kind !== "test" || testReceipt.exitCode !== 0 || testReceipt.testEvidence?.passed !== true) throw new Error("Final validation failed; Rails hop was not recorded.");
|
|
164
164
|
const recorded = recordRailsIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...testReceipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [updateReceipt, testReceipt], appUpdateReview: review } });
|
|
165
165
|
delete recorded.pendingAppUpdate;
|
package/src/databases.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Adapter-encapsulated database lifecycle for the isolated target runtime.
|
|
2
|
+
//
|
|
3
|
+
// Each adapter owns its container name suffix, image, health probe, test-database
|
|
4
|
+
// creation command, and DATABASE_URL format. target-runtime.js stays
|
|
5
|
+
// adapter-agnostic: it only reads `.image`, `.label`, `.port` and calls
|
|
6
|
+
// `.readyArgs` / `.createArgs` / `.databaseUrl`.
|
|
7
|
+
//
|
|
8
|
+
// Every step runs either inside the isolated database container or through the
|
|
9
|
+
// verified `docker exec` path, so the app image is never modified to support a
|
|
10
|
+
// database engine. The app reaches the database only over the per-run,
|
|
11
|
+
// run-id-labelled Docker network.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const DB_NAME = "ruby_upgrade_test";
|
|
17
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
18
|
+
|
|
19
|
+
function waitSecond() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); }
|
|
20
|
+
|
|
21
|
+
// `probe` returns a docker result; a zero status means the database is serving
|
|
22
|
+
// the kind of traffic the app will generate.
|
|
23
|
+
function readyLoop({ probe, attempts, label }) {
|
|
24
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
25
|
+
if (probe().status === 0) return;
|
|
26
|
+
waitSecond();
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Target ${label} did not become ready within ${attempts} seconds. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const databases = Object.freeze({
|
|
32
|
+
postgres: Object.freeze({
|
|
33
|
+
adapter: "postgres",
|
|
34
|
+
label: "PostgreSQL",
|
|
35
|
+
image: "postgres:16-alpine",
|
|
36
|
+
port: 5432,
|
|
37
|
+
requiredEnvironment: Object.freeze({ POSTGRES_HOST_AUTH_METHOD: "trust", POSTGRES_DB: DB_NAME }),
|
|
38
|
+
// `trust` keeps the isolated test database credential-free and unreachable
|
|
39
|
+
// from outside the per-run network.
|
|
40
|
+
startArgs: () => ["--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", `POSTGRES_DB=${DB_NAME}`, "postgres:16-alpine"],
|
|
41
|
+
readyArgs: (container) => ["exec", container, "pg_isready", "-U", "postgres", "-d", DB_NAME],
|
|
42
|
+
// Use the app's own Rails task so framework-specific database setup remains
|
|
43
|
+
// consistent with the project's PostgreSQL adapter. DATABASE_URL is already
|
|
44
|
+
// set on the app container and Rails prefers it over database.yml, so a
|
|
45
|
+
// project pinning `host: localhost` or a socket-only DSN still resolves to
|
|
46
|
+
// the isolated container -- no rewriting of the config under test, which
|
|
47
|
+
// would be a source change to the worktree.
|
|
48
|
+
createArgs: ({ appContainer }) => ["exec", appContainer, "bundle", "exec", "rake", "db:create"],
|
|
49
|
+
databaseUrl: (container) => `postgresql://postgres@${container}:5432/${DB_NAME}`,
|
|
50
|
+
ensureReady: ({ probe }) => readyLoop({ probe, attempts: 30, label: "PostgreSQL" })
|
|
51
|
+
}),
|
|
52
|
+
mysql: Object.freeze({
|
|
53
|
+
adapter: "mysql",
|
|
54
|
+
label: "MySQL",
|
|
55
|
+
image: "mysql:8.4",
|
|
56
|
+
port: 3306,
|
|
57
|
+
requiredEnvironment: Object.freeze({ MYSQL_ALLOW_EMPTY_PASSWORD: "yes", MYSQL_DATABASE: DB_NAME }),
|
|
58
|
+
// An empty root password keeps the isolated test database credential-free.
|
|
59
|
+
// The official entrypoint also provisions TCP access for that account.
|
|
60
|
+
startArgs: () => ["--env", "MYSQL_ALLOW_EMPTY_PASSWORD=yes", "--env", `MYSQL_DATABASE=${DB_NAME}`, "mysql:8.4"],
|
|
61
|
+
// Probe over TCP, not the unix socket. The entrypoint briefly runs a
|
|
62
|
+
// socket-only bootstrap server: `mysqladmin ping` and a local `SELECT 1`
|
|
63
|
+
// both succeed against it several seconds before the app could actually
|
|
64
|
+
// connect, which would surface later as a misleading connection failure.
|
|
65
|
+
readyArgs: (container) => ["exec", container, "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "--silent"],
|
|
66
|
+
// Created server-side with the container's own client so preparation never
|
|
67
|
+
// depends on the mysql2 native extension being built yet.
|
|
68
|
+
createArgs: ({ databaseContainer }) => ["exec", databaseContainer, "mysql", "-h", "127.0.0.1", "-u", "root", "-e", `CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4`],
|
|
69
|
+
databaseUrl: (container) => `mysql2://root@${container}:3306/${DB_NAME}`,
|
|
70
|
+
// First boot initializes the data directory, restarts the server to apply
|
|
71
|
+
// settings, then opens TCP; budget well beyond PostgreSQL's startup.
|
|
72
|
+
ensureReady: ({ probe }) => readyLoop({ probe, attempts: 90, label: "MySQL" })
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export function resolveDatabase(adapter) {
|
|
77
|
+
const key = adapter ?? "postgres";
|
|
78
|
+
if (!Object.hasOwn(databases, key)) throw new Error(`--database must be one of: ${Object.keys(databases).join(", ")}.`);
|
|
79
|
+
return databases[key];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Declarative forms only. A bare word is not a declaration: real
|
|
83
|
+
// `config/database.yml` files carry YAML keys that merely *name* a profile
|
|
84
|
+
// (rails/rails ships `connections:` with `mysql2:` and `postgresql:` keys and
|
|
85
|
+
// only `adapter: sqlite3` in play), and a Gemfile may carry a driver for an
|
|
86
|
+
// unrelated tool. Matching bare tokens made detection asymmetric -- mysql2
|
|
87
|
+
// matched anywhere while postgres required `adapter:` -- so the bias could only
|
|
88
|
+
// ever produce a false MySQL answer, silently.
|
|
89
|
+
const MYSQL = [/\badapter\s*:\s*mysql2\b/i, /\bgem\s*\(?\s*["']mysql2["']/i, /\bmysql2:\/\//i];
|
|
90
|
+
const POSTGRES = [/\badapter\s*:\s*(?:postgresql|postgres)\b/i, /\bgem\s*\(?\s*["']pg["']/i, /\b(?:postgresql|postgres):\/\//i];
|
|
91
|
+
// Gemfile.lock records resolved specs as ` mysql2 (0.5.6)` / ` pg (1.6.2)`.
|
|
92
|
+
const MYSQL_LOCK = /^[ \t]*mysql2 \(/m;
|
|
93
|
+
const POSTGRES_LOCK = /^[ \t]*pg \(/m;
|
|
94
|
+
|
|
95
|
+
// Best-effort adapter detection from the project's own declarations. An
|
|
96
|
+
// explicit `--database` always wins; this only fills the gap so the common case
|
|
97
|
+
// needs no flag.
|
|
98
|
+
export function detectDatabase(root = process.cwd()) {
|
|
99
|
+
const read = (file) => { try { return fs.readFileSync(path.join(root, file), "utf8"); } catch { return ""; } };
|
|
100
|
+
const uncommented = (contents) => contents.replace(/#.*$/gm, "");
|
|
101
|
+
const declares = (contents, lock) => ({
|
|
102
|
+
mysql: MYSQL.some((re) => re.test(contents)) || (lock && MYSQL_LOCK.test(contents)),
|
|
103
|
+
postgres: POSTGRES.some((re) => re.test(contents)) || (lock && POSTGRES_LOCK.test(contents)),
|
|
104
|
+
});
|
|
105
|
+
const resolve = ({ mysql, postgres }) => {
|
|
106
|
+
if (mysql && postgres) throw new Error("Both MySQL and PostgreSQL were detected. Specify --database mysql or --database postgres.");
|
|
107
|
+
return mysql ? "mysql" : postgres ? "postgres" : null;
|
|
108
|
+
};
|
|
109
|
+
// `config/database.yml` is what the app actually connects to; a `gem` line
|
|
110
|
+
// only says a driver is *available*. Discourse, for instance, is PostgreSQL
|
|
111
|
+
// (`adapter: postgresql` throughout) but carries `gem "mysql2"` behind an
|
|
112
|
+
// import-mode conditional. OR-ing both files reported that as ambiguous and
|
|
113
|
+
// needlessly forced an explicit flag on an unambiguous project.
|
|
114
|
+
const configured = resolve(declares(uncommented(read("config/database.yml")), false));
|
|
115
|
+
if (configured) return configured;
|
|
116
|
+
const gems = resolve(declares(`${uncommented(read("Gemfile"))}\n${uncommented(read("Gemfile.lock"))}`, true));
|
|
117
|
+
return gems ?? "postgres";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { DB_NAME, NAME };
|
package/src/run-state.js
CHANGED
|
@@ -34,7 +34,17 @@ function validCitation(value) { return value && typeof value.title === "string"
|
|
|
34
34
|
function safeFiles(files) { return Array.isArray(files) && files.every((file) => typeof file === "string" && !path.isAbsolute(file) && !file.includes("..")); }
|
|
35
35
|
function validAppUpdateReview(review) { return review && review.command === "bin/rails app:update" && typeof review.receiptId === "string" && !Number.isNaN(Date.parse(review.executedAt ?? "")) && !Number.isNaN(Date.parse(review.reviewedAt ?? "")) && validFingerprint(review.worktree) && ["no_changes", "changes_applied", "changes_deferred"].includes(review.outcome) && safeFiles(review.files) && typeof review.summary === "string" && Boolean(review.summary); }
|
|
36
36
|
function validFingerprint(fingerprint) { return fingerprint && fingerprint.algorithm === "sha256" && /^[a-f0-9]{40}$/i.test(fingerprint.headSha ?? "") && /^[a-f0-9]{64}$/i.test(fingerprint.diffSha256 ?? ""); }
|
|
37
|
-
function
|
|
37
|
+
function validDockerEnvironment(environment) {
|
|
38
|
+
const containerId = /^[a-f0-9]{64}$/i;
|
|
39
|
+
const imageId = /^sha256:[a-f0-9]{64}$/i;
|
|
40
|
+
const dockerName = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
41
|
+
return environment?.type === "docker" && /^[a-f0-9-]{36}$/i.test(environment.runId ?? "") && /^\.ruby-upgrades\/runs\/[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(environment.reportPath ?? "") && dockerName.test(environment.name ?? "") && containerId.test(environment.id ?? "") && imageId.test(environment.imageId ?? "") && typeof environment.imageRef === "string" && /^\d+\.\d+\.\d+$/.test(environment.ruby ?? "") && ["postgres", "mysql"].includes(environment.database) && dockerName.test(environment.databaseContainer ?? "") && containerId.test(environment.databaseContainerId ?? "") && imageId.test(environment.databaseImageId ?? "") && typeof environment.databaseImageRef === "string" && dockerName.test(environment.network ?? "") && containerId.test(environment.networkId ?? "");
|
|
42
|
+
}
|
|
43
|
+
function validReceipt(receipt) {
|
|
44
|
+
const dockerCommand = ["docker-bundle-rspec", "rails-app-update"].includes(receipt?.commandId);
|
|
45
|
+
const environmentValid = receipt?.receiptVersion === 1 ? true : receipt?.environment === undefined ? !dockerCommand : validDockerEnvironment(receipt.environment);
|
|
46
|
+
return receipt && [1, 2].includes(receipt.receiptVersion) && environmentValid && typeof receipt.id === "string" && /^[a-f0-9-]{36}$/i.test(receipt.id) && ["test", "rails_app_update"].includes(receipt.kind) && typeof receipt.commandId === "string" && Array.isArray(receipt.argv) && !Number.isNaN(Date.parse(receipt.startedAt ?? "")) && !Number.isNaN(Date.parse(receipt.finishedAt ?? "")) && typeof receipt.durationMs === "number" && (typeof receipt.exitCode === "number" || receipt.exitCode === null) && typeof receipt.timedOut === "boolean" && /^[a-f0-9]{64}$/i.test(receipt.output?.redactedSha256 ?? "") && Number.isSafeInteger(receipt.output?.bytes) && receipt.output.bytes >= 0 && receipt.output.summary === undefined && validFingerprint(receipt.worktree);
|
|
47
|
+
}
|
|
38
48
|
function receiptErrors(iteration, index, rails, required, errors) {
|
|
39
49
|
const receipts = iteration?.validationReceipts;
|
|
40
50
|
if (!required && receipts === undefined) return;
|
package/src/target-runtime.js
CHANGED
|
@@ -3,13 +3,22 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { RunStateError, readRun } from "./run-state.js";
|
|
6
|
+
import { NAME, databases, detectDatabase, resolveDatabase } from "./databases.js";
|
|
6
7
|
|
|
7
8
|
const RUNTIME_FILE = "runtime.json";
|
|
8
9
|
const LABEL = "io.opencode-ruby-upgrader.run-id";
|
|
10
|
+
const WORKTREE_LABEL = "io.opencode-ruby-upgrader.worktree-sha256";
|
|
9
11
|
const rubyVersion = /^\d+\.\d+\.\d+$/;
|
|
10
12
|
const NODE_INSTALL = "set -eu; . /etc/os-release; codename=${VERSION_CODENAME:-stretch}; printf '%s\\n' \"deb http://deb.debian.org/debian ${codename} main\" > /etc/apt/sources.list; printf '%s\\n' 'Acquire::Check-Valid-Until \"false\";' > /etc/apt/apt.conf.d/99archive; if ! apt-get update; then printf '%s\\n' \"deb http://archive.debian.org/debian ${codename} main\" > /etc/apt/sources.list; apt-get update; fi; apt-get install -y --no-install-recommends nodejs; if [ ! -x /usr/bin/node ]; then ln -sf /usr/bin/nodejs /usr/local/bin/node; fi; node --version";
|
|
11
13
|
|
|
12
14
|
function canonicalRoot(root) { return fs.realpathSync(root); }
|
|
15
|
+
function worktreeHash(root) { return crypto.createHash("sha256").update(root).digest("hex"); }
|
|
16
|
+
// Ownership is asserted against a freshly computed hash of the canonical root
|
|
17
|
+
// rather than a hash persisted in runtime.json. The Docker resources carry the
|
|
18
|
+
// label, so binding to the live containers is both stronger (a copied manifest
|
|
19
|
+
// cannot vouch for a foreign worktree) and keeps a dictionary-attackable
|
|
20
|
+
// fingerprint of the user's filesystem path out of a file meant to be committed.
|
|
21
|
+
function identity(runId, canonical) { return { runId, worktreeHash: worktreeHash(canonical) }; }
|
|
13
22
|
function upgradesDirectory(root, create = false) {
|
|
14
23
|
const directory = path.join(canonicalRoot(root), ".ruby-upgrades");
|
|
15
24
|
if (!fs.existsSync(directory) && create) fs.mkdirSync(directory, { mode: 0o700 });
|
|
@@ -32,25 +41,17 @@ function docker(spawn, args) {
|
|
|
32
41
|
if (result.error) throw new Error(`Docker command failed: ${result.error.message}`);
|
|
33
42
|
return result;
|
|
34
43
|
}
|
|
35
|
-
function waitForPostgres(spawn, runtime) {
|
|
36
|
-
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
37
|
-
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
38
|
-
if (docker(spawn, ["exec", runtime.postgresContainer, "pg_isready", "-U", "postgres", "-d", "ruby_upgrade_test"]).status === 0) return;
|
|
39
|
-
Atomics.wait(sleeper, 0, 0, 1000);
|
|
40
|
-
}
|
|
41
|
-
throw new Error("Target PostgreSQL did not become ready within 30 seconds. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
42
|
-
}
|
|
43
44
|
function preparationResult(result) {
|
|
44
45
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
45
46
|
return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
|
|
46
47
|
}
|
|
47
|
-
function bootstrap(spawn, runtime, rails) {
|
|
48
|
-
const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [
|
|
48
|
+
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"];
|
|
49
50
|
const commands = [
|
|
50
51
|
["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
|
|
51
52
|
["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
|
|
52
53
|
["exec", runtime.appContainer, "bundle", "_2.4.22_", "install"],
|
|
53
|
-
...(rails ? [
|
|
54
|
+
...(rails ? [db.createArgs({ appContainer: runtime.appContainer, databaseContainer: runtime.databaseContainer })] : []),
|
|
54
55
|
["exec", runtime.appContainer, "ruby", "--version"]
|
|
55
56
|
];
|
|
56
57
|
const results = commands.map((args, index) => {
|
|
@@ -75,8 +76,46 @@ function railsProject(root) {
|
|
|
75
76
|
function inspect(spawn, name, message) {
|
|
76
77
|
const result = docker(spawn, ["inspect", name]);
|
|
77
78
|
if (result.status !== 0) throw new Error(message);
|
|
79
|
+
return parseInspection(result);
|
|
80
|
+
}
|
|
81
|
+
function inspectNetwork(spawn, name, message) {
|
|
82
|
+
const result = docker(spawn, ["network", "inspect", name]);
|
|
83
|
+
if (result.status !== 0) throw new Error(message);
|
|
84
|
+
return parseInspection(result);
|
|
85
|
+
}
|
|
86
|
+
function parseInspection(result) {
|
|
78
87
|
try { const parsed = JSON.parse(result.stdout); return parsed[0]; } catch { throw new Error("Docker inspection returned invalid JSON."); }
|
|
79
88
|
}
|
|
89
|
+
function environment(container) {
|
|
90
|
+
return Object.fromEntries((container?.Config?.Env ?? []).map((entry) => { const index = entry.indexOf("="); return [entry.slice(0, index), entry.slice(index + 1)]; }));
|
|
91
|
+
}
|
|
92
|
+
function labels(resource) { return resource?.Config?.Labels ?? resource?.Labels ?? {}; }
|
|
93
|
+
function hasOwnership(resource, identity) {
|
|
94
|
+
const owned = labels(resource);
|
|
95
|
+
return owned[LABEL] === identity.runId && owned[WORKTREE_LABEL] === identity.worktreeHash;
|
|
96
|
+
}
|
|
97
|
+
function mountedFromRoot(container, root) {
|
|
98
|
+
return container?.Mounts?.some((mount) => mount.Type === "bind" && mount.Source === root && mount.Destination === "/app");
|
|
99
|
+
}
|
|
100
|
+
function onlyNetwork(container, network) {
|
|
101
|
+
const networks = Object.keys(container?.NetworkSettings?.Networks ?? {});
|
|
102
|
+
return networks.length === 1 && networks[0] === network;
|
|
103
|
+
}
|
|
104
|
+
function isolatedContainer(container) {
|
|
105
|
+
return container?.HostConfig?.Privileged !== true && Object.keys(container?.HostConfig?.PortBindings ?? {}).length === 0;
|
|
106
|
+
}
|
|
107
|
+
function appMatches(container, root, runtime, db) {
|
|
108
|
+
const env = environment(container);
|
|
109
|
+
const mounts = container?.Mounts ?? [];
|
|
110
|
+
const command = container?.Config?.Cmd ?? [];
|
|
111
|
+
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);
|
|
112
|
+
}
|
|
113
|
+
function databaseMatches(container, runtime, db) {
|
|
114
|
+
const env = environment(container);
|
|
115
|
+
const expectedEnvironment = Object.entries(db.requiredEnvironment).every(([key, value]) => env[key] === value);
|
|
116
|
+
const hasBindMount = (container?.Mounts ?? []).some((mount) => mount.Type === "bind");
|
|
117
|
+
return container?.State?.Running && container?.Config?.Image === db.image && onlyNetwork(container, runtime.network) && isolatedContainer(container) && !hasBindMount && expectedEnvironment;
|
|
118
|
+
}
|
|
80
119
|
function activeRun(root) {
|
|
81
120
|
const directory = path.join(upgradesDirectory(root), "runs");
|
|
82
121
|
if (!fs.existsSync(directory)) throw new RunStateError("No active or paused upgrade run exists. Start or resume a run first.", "no-active-run");
|
|
@@ -94,9 +133,9 @@ function selectedRun(root, reportPath) {
|
|
|
94
133
|
if (!["in_progress", "paused"].includes(run.status)) throw new RunStateError("Target runtime preparation requires an active or paused upgrade run.", "run-not-resumable");
|
|
95
134
|
return { reportPath, run };
|
|
96
135
|
}
|
|
97
|
-
function names(runId) {
|
|
136
|
+
function names(runId, db) {
|
|
98
137
|
const prefix = `ruby-upgrader-${runId}`;
|
|
99
|
-
return { appContainer: `${prefix}-app`,
|
|
138
|
+
return { appContainer: `${prefix}-app`, databaseContainer: `${prefix}-${db.adapter}`, network: `${prefix}-network` };
|
|
100
139
|
}
|
|
101
140
|
function writeRuntime(root, runtime) {
|
|
102
141
|
const file = runtimePath(root, true);
|
|
@@ -106,44 +145,92 @@ function writeRuntime(root, runtime) {
|
|
|
106
145
|
}
|
|
107
146
|
|
|
108
147
|
export function readTargetRuntime(root = process.cwd()) {
|
|
109
|
-
const
|
|
148
|
+
const canonical = canonicalRoot(root);
|
|
149
|
+
const file = runtimePath(canonical);
|
|
110
150
|
if (!fs.existsSync(file)) return undefined;
|
|
111
151
|
try {
|
|
112
|
-
|
|
113
|
-
if (runtime?.version
|
|
152
|
+
let runtime = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
153
|
+
if (runtime?.version === 2 && runtime.database === undefined && runtime.databaseContainer === undefined && NAME.test(runtime.postgresContainer ?? "")) {
|
|
154
|
+
const { postgresContainer, ...legacy } = runtime;
|
|
155
|
+
runtime = { ...legacy, database: "postgres", databaseContainer: postgresContainer };
|
|
156
|
+
}
|
|
157
|
+
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();
|
|
114
158
|
return runtime;
|
|
115
159
|
} catch { throw new Error("Target runtime metadata is invalid. Rerun prepare-target-runtime --ruby <x.y.z>."); }
|
|
116
160
|
}
|
|
117
161
|
|
|
118
|
-
export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, spawn = spawnSync }) {
|
|
162
|
+
export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, database, spawn = spawnSync }) {
|
|
119
163
|
if (!rubyVersion.test(ruby ?? "")) throw new Error("--ruby must be an exact numeric Ruby version such as 3.4.1.");
|
|
120
164
|
const canonical = canonicalRoot(root);
|
|
165
|
+
const db = resolveDatabase(database ?? detectDatabase(canonical));
|
|
121
166
|
const selected = selectedRun(canonical, reportPath);
|
|
122
|
-
const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, ...names(selected.run.runId) };
|
|
167
|
+
const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, ...names(selected.run.runId, db) };
|
|
168
|
+
const owner = identity(runtime.runId, canonical);
|
|
123
169
|
const existing = readTargetRuntime(canonical);
|
|
124
170
|
if (existing && existing.runId !== runtime.runId) {
|
|
125
171
|
const prior = readRun(canonical, existing.reportPath);
|
|
126
172
|
if (!["blocked", "complete"].includes(prior.status)) throw new Error("Target runtime metadata belongs to another resumable run. Pause and resolve that run before preparing this one.");
|
|
127
173
|
}
|
|
128
174
|
|
|
129
|
-
const
|
|
130
|
-
if (network.status !== 0 && docker(spawn, ["network", "create", "--label", `${LABEL}=${selected.run.runId}`, runtime.network]).status !== 0) throw new Error("Could not create the isolated target-runtime Docker network.");
|
|
131
|
-
const postgres = docker(spawn, ["inspect", runtime.postgresContainer]);
|
|
132
|
-
if (postgres.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.postgresContainer, "--label", `${LABEL}=${selected.run.runId}`, "--network", runtime.network, "--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", "POSTGRES_DB=ruby_upgrade_test", "postgres:16-alpine"]).status !== 0) throw new Error("Could not start the isolated target-runtime PostgreSQL container.");
|
|
133
|
-
waitForPostgres(spawn, runtime);
|
|
175
|
+
const databaseNames = [...new Set([...Object.values(databases).map((candidate) => names(runtime.runId, candidate).databaseContainer), existing?.databaseContainer].filter(Boolean))];
|
|
134
176
|
let app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
let network = docker(spawn, ["network", "inspect", runtime.network]);
|
|
178
|
+
const databaseInspections = new Map(databaseNames.map((name) => [name, docker(spawn, ["inspect", name])]));
|
|
179
|
+
const resources = [app.status === 0 && parseInspection(app), network.status === 0 && parseInspection(network), ...[...databaseInspections.values()].filter((result) => result.status === 0).map(parseInspection)].filter(Boolean);
|
|
180
|
+
for (const resource of resources) {
|
|
181
|
+
const resourceLabels = labels(resource);
|
|
182
|
+
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.");
|
|
183
|
+
}
|
|
184
|
+
const legacy = resources.some((resource) => labels(resource)[WORKTREE_LABEL] === undefined);
|
|
185
|
+
if (legacy && resources.length) {
|
|
186
|
+
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]) {
|
|
188
|
+
if (result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove a legacy target-runtime container.");
|
|
189
|
+
}
|
|
190
|
+
if (network.status !== 0 && docker(spawn, ["network", "rm", runtime.network]).status !== 0) throw new Error("Could not remove the legacy target-runtime Docker network.");
|
|
191
|
+
app = { status: 1 };
|
|
192
|
+
network = { status: 1 };
|
|
193
|
+
for (const name of databaseNames) databaseInspections.set(name, { status: 1 });
|
|
194
|
+
} else {
|
|
195
|
+
for (const resource of resources) {
|
|
196
|
+
if (!hasOwnership(resource, owner)) throw new Error("Refusing to use or replace a Docker resource without both ownership labels.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const ownershipArgs = ["--label", `${LABEL}=${owner.runId}`, "--label", `${WORKTREE_LABEL}=${owner.worktreeHash}`];
|
|
200
|
+
if (network.status !== 0 && docker(spawn, ["network", "create", ...ownershipArgs, runtime.network]).status !== 0) throw new Error("Could not create the isolated target-runtime Docker network.");
|
|
201
|
+
for (const [name, result] of databaseInspections) {
|
|
202
|
+
if (name !== runtime.databaseContainer && result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove the prior target-runtime database container.");
|
|
203
|
+
}
|
|
204
|
+
let databaseContainer = databaseInspections.get(runtime.databaseContainer) ?? { status: 1 };
|
|
205
|
+
if (databaseContainer.status === 0) {
|
|
206
|
+
const container = parseInspection(databaseContainer);
|
|
207
|
+
if (!databaseMatches(container, runtime, db)) {
|
|
208
|
+
if (docker(spawn, ["rm", "--force", runtime.databaseContainer]).status !== 0) throw new Error(`Could not replace the prior target-runtime ${db.label} container.`);
|
|
209
|
+
databaseContainer = docker(spawn, ["inspect", runtime.databaseContainer]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (databaseContainer.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.databaseContainer, ...ownershipArgs, "--network", runtime.network, ...db.startArgs()]).status !== 0) throw new Error(`Could not start the isolated target-runtime ${db.label} container.`);
|
|
213
|
+
try {
|
|
214
|
+
db.ensureReady({ probe: () => docker(spawn, db.readyArgs(runtime.databaseContainer)) });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
try { docker(spawn, ["rm", "--force", runtime.databaseContainer]); } catch {}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
if (app.status === 0) {
|
|
220
|
+
const container = parseInspection(app);
|
|
221
|
+
if (!appMatches(container, canonical, runtime, db)) {
|
|
222
|
+
if (docker(spawn, ["rm", "--force", runtime.appContainer]).status !== 0) throw new Error("Could not replace the prior target Ruby app container.");
|
|
223
|
+
app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
224
|
+
}
|
|
141
225
|
}
|
|
142
|
-
if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer,
|
|
143
|
-
const prepared = bootstrap(spawn, runtime, railsProject(canonical));
|
|
226
|
+
if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer, ...ownershipArgs, "--network", runtime.network, "--mount", `type=bind,src=${canonical},dst=/app`, "--workdir", "/app", "--env", "RAILS_ENV=test", "--env", `DATABASE_URL=${db.databaseUrl(runtime.databaseContainer)}`, `ruby:${ruby}`, "sleep", "infinity"]).status !== 0) throw new Error("Could not start the target Ruby app container.");
|
|
227
|
+
const prepared = bootstrap(spawn, runtime, db, railsProject(canonical));
|
|
144
228
|
const appInspection = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
229
|
+
const databaseInspection = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
145
230
|
if (!/^sha256:[a-f0-9]{64}$/i.test(appInspection?.Image ?? "")) throw new Error("Target Ruby image ID is unavailable.");
|
|
231
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(databaseInspection?.Image ?? "")) throw new Error(`Target ${db.label} image ID is unavailable.`);
|
|
146
232
|
runtime.resolvedImageId = appInspection.Image;
|
|
233
|
+
runtime.databaseImageId = databaseInspection.Image;
|
|
147
234
|
runtime.preparation = prepared;
|
|
148
235
|
validateTargetRuntime({ root: canonical, runtime, spawn });
|
|
149
236
|
writeRuntime(canonical, runtime);
|
|
@@ -153,13 +240,15 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, s
|
|
|
153
240
|
export function validateTargetRuntime({ root = process.cwd(), runtime = readTargetRuntime(root), spawn = spawnSync }) {
|
|
154
241
|
if (!runtime) return undefined;
|
|
155
242
|
const canonical = canonicalRoot(root);
|
|
243
|
+
const owner = identity(runtime.runId, canonical);
|
|
244
|
+
const db = resolveDatabase(runtime.database);
|
|
156
245
|
const app = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const valid = app
|
|
246
|
+
const database = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
247
|
+
const network = inspectNetwork(spawn, runtime.network, "Target runtime Docker network is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
248
|
+
const env = environment(app);
|
|
249
|
+
const connectedIds = Object.keys(network?.Containers ?? {}).sort();
|
|
250
|
+
const expectedIds = [app?.Id, database?.Id].sort();
|
|
251
|
+
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);
|
|
163
252
|
if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
164
|
-
return { name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby };
|
|
253
|
+
return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
|
|
165
254
|
}
|
|
@@ -23,23 +23,24 @@ const commands = Object.freeze({
|
|
|
23
23
|
"rails-app-update": { argv: ["bundle", "exec", "ruby", "-e", railsAppUpdateSkipExisting], kind: "rails_app_update", expected: "bin/rails app:update", docker: true }
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
function dockerContainer(root, spawn) {
|
|
26
|
+
function dockerContainer(root, spawn, expectedRuntime) {
|
|
27
27
|
const runtime = readTargetRuntime(root);
|
|
28
28
|
if (!runtime) throw new Error("Prepare the isolated target runtime with prepare-target-runtime --ruby <x.y.z> before Docker validation.");
|
|
29
|
+
if (!expectedRuntime || runtime.runId !== expectedRuntime.runId || runtime.reportPath !== expectedRuntime.reportPath) throw new Error("The prepared target runtime does not belong to the selected upgrade report. Rerun prepare-target-runtime for this report before Docker validation.");
|
|
29
30
|
return validateTargetRuntime({ root, runtime, spawn });
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export function executeValidation({ root = process.cwd(), inventory, commandId, timeoutMs = 600000, spawn = spawnSync }) {
|
|
33
|
+
export function executeValidation({ root = process.cwd(), inventory, commandId, expectedRuntime, timeoutMs = 600000, spawn = spawnSync }) {
|
|
33
34
|
const definition = commands[commandId];
|
|
34
35
|
if (!definition) throw new Error("Unknown validation command ID.");
|
|
35
36
|
if (definition.kind === "rails_app_update" ? inventory.framework !== "rails" : !inventory.recommendedCommands.includes(definition.expected)) throw new Error("Validation command is not supported by this project's detected adapter.");
|
|
36
|
-
const environment = definition.docker ? { type: "docker", ...dockerContainer(root, spawn) } : undefined;
|
|
37
|
+
const environment = definition.docker ? { type: "docker", ...dockerContainer(root, spawn, expectedRuntime) } : undefined;
|
|
37
38
|
const argv = definition.docker ? ["docker", "exec", "--env", "DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true", environment.name, ...definition.argv] : definition.argv;
|
|
38
39
|
const startedAt = new Date().toISOString(); const started = Date.now();
|
|
39
40
|
const result = spawn(argv[0], argv.slice(1), { cwd: root, shell: false, encoding: "utf8", timeout: timeoutMs, maxBuffer: 256 * 1024 });
|
|
40
41
|
const output = redact(`${result.stdout ?? ""}${result.stderr ?? ""}`).slice(0, 8192);
|
|
41
42
|
const durationMs = Date.now() - started; const timedOut = result.error?.code === "ETIMEDOUT";
|
|
42
|
-
const receipt = { receiptVersion:
|
|
43
|
+
const receipt = { receiptVersion: 2, id: crypto.randomUUID(), kind: definition.kind, commandId, argv, ...(environment ? { environment } : {}), startedAt, finishedAt: new Date().toISOString(), durationMs, exitCode: typeof result.status === "number" ? result.status : null, timedOut, output: { redactedSha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) }, worktree: worktreeFingerprint(root) };
|
|
43
44
|
if (definition.kind === "test") receipt.testEvidence = parseTestEvidence(output, definition.expected, durationMs / 1000);
|
|
44
45
|
return receipt;
|
|
45
46
|
}
|