Skip to content

feat(cassandra): install arbitrary versions/branches onto a live cluster without an AMI rebuild - #878

Draft
rustyrazorblade wants to merge 20 commits into
mainfrom
worktree-issue-876
Draft

feat(cassandra): install arbitrary versions/branches onto a live cluster without an AMI rebuild#878
rustyrazorblade wants to merge 20 commits into
mainfrom
worktree-issue-876

Conversation

@rustyrazorblade

Copy link
Copy Markdown
Owner

Closes #876

Draft — implementation in progress. The unit tier runs locally; the full suite runs in CI on each push.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

withHosts(parallel = true) fired a bare thread per host and only join()ed
them, so an exception inside a host action died with its thread and the
caller reported success. Collect each host's outcome and rethrow the first
failure; add collectFromHosts() for callers that need to report per-host
success and failure instead of aborting on the first one.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review — spec-only PR (proposal/design/spec/tasks for issue #876)

This PR contains no implementation code yet, just the OpenSpec artifacts (proposal.md, design.md, specs/cassandra/spec.md, tasks.md) under openspec/changes/issue-876/. Reviewed accordingly — mostly checking factual accuracy of the design's claims about the existing codebase, internal consistency, and completeness of the task breakdown, since that's what determines whether implementation will go smoothly.

What I verified

design.md makes an unusually large number of specific, checkable claims about the current code (exact line ranges, function boundaries, existing mechanisms). I spot-checked essentially all of them directly against the repo:

  • install_cassandra_version() at install_cassandra.sh:138-301 (exact), handling all three modes (bare-version release, tarball url:, git url:+branch:) — confirmed.
  • Packer.kt:225-233 calling CassandraVersion.loadFromMainAndExtras for non-release builds — confirmed, and CassandraVersion.kt already has exactly the claimed fields (url, branch, antFlags, java, python) plus loadFromFile/loadFromMainAndExtras/write() — no lazy field yet, so that part of the proposal is correctly scoped as new.
  • use-cassandra line 6's ln -vfns has no existence check, and lines 20-27 hard-exit on empty java/python — confirmed.
  • Git-branch mode is genuinely unused in the current cassandra_versions.yaml (0 of 8 entries use branch:) — confirmed.
  • set-java-version:27-28's yq -i in-place edit precedent — confirmed.
  • rm -rf ~/.m2 runs once after the whole version-install loop (install_cassandra.sh:344), not inside install_cassandra_version() — confirmed, matches D3's claim precisely.
  • HostOperationsService.withHosts(parallel = true) — confirmed it spawns bare kotlin.concurrent.threads and just .join()s them with no result/exception channel; an uncaught exception in the action lambda is genuinely swallowed. This is a real bug and D4's fix is well-motivated.

All of it checked out. This is a well-researched design — I want to flag that positively since it's not always the case.

Findings

1. The parallel = true caller enumeration is incomplete. design.md and proposal.md both list the callers affected by the HostOperationsService.withHosts fix as UseCassandra, SetupInstance, ExecStop, ExecList, ExecRun. There's a sixth: commands/cassandra/Start.kt:80 also calls hostOperationsService.withHosts(clusterState.hosts, ServerType.Cassandra, "", parallel = true) { ... } to start axon-agent on all Cassandra nodes. It has the exact same swallowed-exception behavior today, and will be affected by D4's fix the same way the other five are (a previously-silent per-host axon-agent start failure will now surface). Worth adding to the caller list in both docs and to tasks.md 1.2's verification checklist, so it isn't missed during implementation/testing.

Related minor precision nit: ExecRun is listed alongside the other four as if it unconditionally uses parallel = true, but it actually passes through a user-controlled --parallel flag (ExecRun.kt:60,76, default false). Not wrong, just worth a word of clarification so a reader doesn't assume it's hardcoded like the others.

2. Shell-injection surface for the new CLI-flag path isn't addressed. --url, --branch, and --ant-flags are explicitly designed (D1) as a "zero-file-edit" path taking arbitrary operator-supplied strings, which then need to reach install-cassandra-version on the remote host via RemoteOperationsService.executeRemotely(host, command: String) — a plain string, per the interface. The existing precedent in UseCassandra.kt ("sudo use-cassandra $version") builds remote commands via raw string interpolation, but version there is a fairly constrained token. --branch/--url/--ant-flags are meaningfully freer-form (git branch names, arbitrary flags) and a bigger injection surface if the same raw-interpolation pattern gets reused for cassandra install. Given this repo's low-trust-boundary context (single operator, ephemeral clusters) this isn't a critical-severity issue, but it's worth a sentence in design.md/a task item specifying how these values get safely passed to the remote command (proper quoting, or restricting to an allow-listed character set) rather than leaving it to whoever implements task 5.4 to notice.

3. tasks.md verification section (8) doesn't reference the repo's existing packer script-testing tooling. packer/README.md/TESTING.md document ./gradlew testPackerScript -Pscript=... and testPackerCassandra for exactly this kind of Docker-based bash script testing, but tasks 8.4-8.11 are all framed as "Integration/manual" against a live cluster. Since install-cassandra-version (task 2.1) and the use-cassandra existence check (task 3.1) are both plain bash, it'd be worth adding explicit testPackerScript coverage for them as a cheaper, faster-feedback verification step before the live-cluster manual passes.

4. Minor wording nit — tasks.md 7.1: "Update openspec/specs/cassandra/spec.md: REQ-CA-001 wording (already applied via this change's spec delta at archive time)" reads confusingly as both an action item and a note that it's already handled by the standard archive workflow. Worth clarifying it's informational only (no manual action needed beyond what openspec-archive-change does automatically) so it isn't mistaken for a task someone needs to do by hand.

5. Minor — concurrent-install race on /etc/cassandra_versions.yaml. Task 5.3's read-modify-write (download file, check presence, append, re-upload) has an obvious TOCTOU race if two cassandra install invocations ever target the same host concurrently. Given this is a single-operator CLI tool and that's an unlikely scenario, I wouldn't block on it, but it might be worth one sentence in design.md's Risks section acknowledging it's understood and accepted rather than an oversight.

Test coverage

tasks.md section 8 is a solid split of unit vs. integration/manual tests, and correctly identifies the three units most worth testing in isolation (CassandraVersion.lazy round-trip, version-resolution precedence, withHosts result collection). Per finding #3 above, I'd add the packer script-testing gradle tasks to this list too.

Overall this is a strong, well-grounded design doc — the factual rigor about the existing codebase is genuinely above average for a proposal at this stage. The findings above are all refinements, not blockers, for moving into implementation.

…cript

install_cassandra_version() was trapped inside install_cassandra.sh's
bake-time loop. Move it, download_cassandra_version(), and the S3 cache
sourcing into packer/cassandra/bin/install-cassandra-version, a flag-driven
script baked onto the AMI by the existing bin/ catch-all provisioner, and
rewrite the bake-time loop to call it so bake time and runtime share one
install path.

Two deliberate divergences from the inline original: JAVA_HOME for the ant
build comes from the target version's own java field rather than the node's
default alternative (switching that default would disturb the running
version), and ~/.m2 cleanup stays a once-per-bake step in
install_cassandra.sh. The loop also skips entries marked lazy: true, and
cassandra.in.sh is now installed to a durable path since /tmp does not
survive a reboot.
ln -vfns was unconditional, so pointing 'current' at a version that was
never installed left a dangling symlink and surfaced as a confusing startup
failure later. Check the version directory first and direct the operator to
'cassandra install'.
A lazy entry ships in the AMI's cassandra_versions.yaml so it is
discoverable and installable at runtime, but the bake skips installing it.
Serialized only when true so rewriting a node's file does not add a
lazy: false line to every existing entry.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Reviewed the diff (openspec docs + install-cassandra-version extraction + HostOperationsService fix + CassandraVersion.lazy). This is a well-scoped draft covering tasks 1–4 of tasks.md; the cassandra install command itself, cassandra list extension, and docs (tasks 5–8) aren't in this PR yet, which lines up with the "Draft" label — flagging just so nobody reads the merged spec.md scenarios (CLI install, cassandra list distinguishing lazy entries, etc.) as already implemented.

Findings

1. packer/cassandra/bin/install-cassandra-version is missing its executable bit
The diff shows new file mode 100644 for this script, vs. its sibling use-cassandra which stays 100755. Packer's catch-all bin provisioner (cassandra.pkr.hcl:149-156) chmod +xs everything it copies to /usr/local/bin, so the AMI bake path masks this — but it means the script can't be run directly from a checkout (./install-cassandra-version, or any future local/packer-script test) without a manual chmod +x first, and it's inconsistent with every other script in bin/. Looks like a simple missed chmod +x before git add; git update-index --chmod=+x packer/cassandra/bin/install-cassandra-version would fix it.

2. The bake-time refactor isn't exercised by any automated test
packer/docker-compose.yml's test-cassandra service only runs prepare_instance.sh and install_cassandra_easy_stress.sh — it never invokes install_cassandra.sh (understandably, since a real run needs INSTALL_CASSANDRA=1 plus real downloads/builds). That means this PR's actual behavioral change — extracting install_cassandra_version() from an inline function called under the driver's set -x/set -euo pipefail into a separately-invoked script — isn't verified anywhere in CI or by ./gradlew testPackerCassandra. This is exactly the risk design.md's own "Risks / Trade-offs" section calls out ("a subtle behavioral difference between 'inline function call' and 'external script invocation' — working directory, environment variables, set -x/set -euo pipefail scoping... needs explicit bake-path verification (packer Docker test)"). Concretely, install_cassandra.sh:95's comment claims each per-version log captures "the set -x trace" — that now depends on whether bash's SHELLOPTS propagates -x into the child script process, which is worth confirming directly rather than assuming, since it's exactly the debugging signal the design doc says to budget time for on the git-branch/ant-build path. Given no test currently exercises this path, recommend either a manual bake verification before merge, or extending the Docker test-cassandra command to smoke-test install-cassandra-version directly (e.g. just the tarball path, to keep it fast).

3. Minor: tasks.md 2.5 reads as an open gap but is already done
"Bake install-cassandra-version onto the AMI alongside use-cassandra/set-java-version (cassandra.pkr.hcl)" is unchecked, but cassandra.pkr.hcl's existing catch-all bin provisioner already copies everything in packer/cassandra/bin/ and chmods it — no hcl change was needed. Worth checking off / annotating so it doesn't look outstanding.

4. Minor, defense-in-depth: use-cassandra's new existence check trusts $1 unconditionally
packer/cassandra/bin/use-cassandra:7[ ! -d "/usr/local/cassandra/$1" ]. If $1 is empty, that's [ ! -d "/usr/local/cassandra/" ], which is false (the dir always exists), so execution falls through to ln -vfns /usr/local/cassandra/ /usr/local/cassandra/current — symlinking the whole versions directory onto itself. Probably unreachable via the CLI today, but since this line is already being touched, a cheap [ -z "$1" ] guard would close it too.

Things done well

  • HostOperationsServiceTest.kt is a good example of the repo's "no mock-echo tests" rule — it verifies real thread-failure propagation (parallel withHosts still runs every host when one fails) and per-host result ordering, not just that a mock was invoked.
  • CassandraVersion.lazy's @get:JsonInclude(NON_DEFAULT) override on top of the class-level NON_EMPTY policy is a nice catch — plain false booleans aren't suppressed by NON_EMPTY alone, and the test actually asserts on the serialized bytes (doesNotContain("lazy: false")) rather than just round-tripping, so it'd actually fail if the annotation were dropped.
  • Good cleanup while extracting the script: the leftover mkdir cassandra/cd cassandra scaffolding in install_cassandra.sh (dead since the function has always used its own mktemp -d workdir) got removed rather than dragged along.
  • HostOperationsService.collectFromHosts is a minimal, additive way to get per-host reporting without changing withHosts's existing throw-on-first-failure contract for its five current callers (UseCassandra, SetupInstance, ExecStop, ExecList, ExecRun) — low blast radius for a shared-helper change.

I didn't run the full build/test suite myself here (worth a ./gradlew check pass and, given finding #2, a manual testPackerCassandra/bake check before this lands).

Installs one additional version onto a running cluster without an AMI
rebuild. Resolution takes CLI flags over a declared cassandra_versions.yaml
entry field by field, defaulting python to 3.11.9 since use-cassandra
hard-exits without it. Per host, the resolved entry is pushed into
/etc/cassandra_versions.yaml via the typed CassandraVersion loader (an
entry already there is the idempotency check), then install-cassandra-version
runs remotely. Outcomes are reported per host and a failed install rolls the
node's version list back so it can be retried.

shellQuote moves out of ExecRun to a top-level function so the remote
command is built with one quoting implementation, not two.
A lazy version is declared but never baked, so it is invisible until
installed. List it alongside the node's installed versions, marked
declared-but-not-installed, and share the declared-set lookup with
'cassandra install'.
Covers both resolution paths (declared entry and CLI options), --hosts
targeting, the no-op and per-host failure behavior, and the cost of a branch
build on a running node. Also points the build-cassandra-ref tarball workflow
at the runtime install, which skips the AMI rebuild entirely.
…ean up a failed install

Container-verified two flaws in the extracted script: the snippet check ran
only after a full tarball download, and a failure after the move left a
directory in /usr/local/cassandra that the already-installed guard would then
trust, making every retry a silent no-op.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Nice, well-scoped feature — the shared install-cassandra-version script (used at both bake time and runtime) and the typed collectFromHosts/per-host Outcome reporting are a clean design. Docs (docs/user-guide/installing-cassandra.md) are updated thoroughly and match the implementation. Test coverage for CassandraInstall and HostOperationsService is solid.

Two things worth a look before this lands:

1. UseCassandra can silently lose successful host state after this PR's withHosts fix

HostOperationsService.withHosts(parallel = true) used to spawn bare threads and just join() them — an exception inside a host action died with its thread and was silently swallowed, so the caller always reached its post-loop code. This PR (rightly) fixes that: withHosts now rethrows the first host failure via collectFromHosts(...).forEach { it.result.getOrThrow() }.

That changes the failure semantics for existing callers that weren't touched in this PR. UseCassandra.execute() (src/main/kotlin/.../commands/cassandra/UseCassandra.kt:64-73) mutates the shared in-memory state inside the per-host lambda (state.versions?.put(it.alias, version)) and only calls clusterStateManager.save(state) after withHosts returns:

hostOperationsService.withHosts(state.hosts, ServerType.Cassandra, hosts.hostList, parallel = true) { host ->
    ...
    remoteOps.executeRemotely(it, "sudo use-cassandra $version").text
    state.versions?.put(it.alias, version)
}
clusterStateManager.save(state)   // never reached if any host failed

With multiple hosts in parallel, if one host fails (e.g. the new "fail use-cassandra when the version isn't installed" check added in this same PR), withHosts now throws after all hosts have run — so hosts that succeeded and already mutated state.versions in memory never get persisted, since save() is never reached. A later cassandra start will read stale on-disk state and report "no version assigned" for a host that's actually running the new version fine.

This isn't a bug introduced by the diff to UseCassandra.kt (that file isn't even touched here) — it's a caller whose correctness assumption (withHosts always reaches the code after it) was silently broken by the HostOperationsService behavior change. Worth either fixing UseCassandra to save() per-host or after collecting results (mirroring the pattern CassandraInstall uses with collectFromHosts), or at least confirming in this PR that no other withHosts(parallel = true) caller has the same "mutate-then-save-after" shape.

2. Minor: hardcoded /etc/cassandra_versions.yaml duplicated instead of centralized

CassandraInstall.kt declares private const val REMOTE_VERSIONS_FILE = "/etc/cassandra_versions.yaml" as a local constant, duplicating the same literal already hardcoded in Up.kt:642. Per CLAUDE.md, this belongs in Constants so the two copies can't drift.

Nit

install-cassandra-version's post-move steps (bundled-cqlsh removal for 2.x/3.x, chown -R) run after the directory is already in its final /usr/local/cassandra/$version location and aren't covered by the rollback block that wraps the conf-backup subshell just above them. Since the idempotency check is purely "does the directory exist", a failure in those trailing steps (unlikely, but chown/rm can fail) would leave a version marked "installed" without full configuration, and a retry would short-circuit as a no-op. Probably fine given how unlikely those specific commands are to fail, but worth a || guard for consistency with the rest of the function's error handling.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Nicely scoped PR — extracting install-cassandra-version as a shared script so bake-time and runtime use one install path is a good design, and the per-host HostResult/collectFromHosts fix for the swallowed-parallel-thread-exception bug is a real, well-justified correctness fix.

Bug: cassandra install is a permanent no-op for the exact lazy-declare workflow this PR adds

CassandraInstall.installOnHost (src/main/kotlin/.../cassandra/CassandraInstall.kt:159-178) decides whether a version is already installed by downloading /etc/cassandra_versions.yaml and checking whether the version is listed:

val existing = CassandraVersion.loadFromFile(localFile.toPath())
if (existing.any { it.version == resolved.version }) {
    return Outcome.ALREADY_PRESENT
}

But packer/cassandra/cassandra.pkr.hcl:172-185 uploads the repo's full cassandra_versions.yaml — including lazy: true entries — to /etc/cassandra_versions.yaml on every node at bake time. install_cassandra.sh's loop only skips installing the binaries for a lazy entry (continue); it does not strip the entry from the file. So a lazily-declared version is present in /etc/cassandra_versions.yaml on every node from the moment the AMI is baked — before anyone has run cassandra install.

The result: easy-db-lab cassandra install my-build on a version declared with lazy: true finds it already listed, reports "already installed, nothing to do" (Event.Cassandra.VersionAlreadyInstalled), and never runs install-cassandra-version. /usr/local/cassandra/my-build is never created. A subsequent cassandra use my-build then fails with the new guard added in this PR ("not installed... run cassandra install my-build"), directly contradicting the message cassandra install just printed.

This is exactly the scenario docs/user-guide/installing-cassandra.md's new "Declaring a version without baking it" section documents as the intended flow, and it's exactly what CassandraInstallTest's execute skips a host that already declares the version test exercises (DECLARED_ENTRY_YAML has lazy: true) — that test currently asserts the buggy behavior (skip) as correct, so it won't catch this.

Compare with ListVersions.kt, which gets this right in the same PR: it determines "installed" from ls /usr/local/cassandra (the actual filesystem state) and cross-references CassandraVersion.lazy separately to report "declared, not installed". installOnHost's idempotency check should do the same — check whether /usr/local/cassandra/<version> exists on the host (or otherwise distinguish "listed" from "actually installed"), not just presence in cassandra_versions.yaml.

Minor

  • Event.Cassandra.VersionList (events/Event.kt) now formats a multi-line hint per declared-not-installed entry inside toDisplayString(). Per CLAUDE.md's events guidance this is fine since it's still a single structured event, just flagging that the string formatting logic living in the event is a bit more elaborate than the existing patterns in this file.
  • CassandraInstall.resolveVersion requires --java when a version isn't declared, but doesn't similarly require presence checks for other fields beyond the branch-without-url case — presumably intentional (tarball-only urls have sensible fallbacks), just worth a second look given how central version resolution is here.

Test coverage

Good coverage of resolveVersion precedence and the per-host push/install/rollback flow, and the new HostOperationsServiceTest for collectFromHosts looks appropriately behavior-focused (not mock-echo). The gap is the one above: no test drives installOnHost through the "lazy-declared-only, not yet installed" case against something that models actual on-disk install state (e.g. asserting install-cassandra-version does run when the node's file has a lazy: true entry but nothing exists under /usr/local/cassandra).

MINA's executeRemoteCommand builds its Response only after the call
returns, so a non-zero exit discarded both stdout and the captured stderr
and left the caller with 'Remote command failed (1): <command>'. That hid
use-cassandra's 'not installed, run cassandra install first' guidance
entirely, and reduced every install failure to the command string instead
of the ant/clone/download error that caused it.

Own both streams and rethrow as RemoteCommandFailedException carrying what
the command actually said. URL userinfo is stripped on the way out, since a
git URL can embed a token and that message reaches logs, MCP and Redis.

A non-zero exit is also excluded from the SSH retry policy: it is a
deterministic failure, not a transport fault, and re-running it repeats
minutes of build work.
…rsion list

Every AMI ships the whole cassandra_versions.yaml, lazy entries included,
so a lazy version is always already declared on a node that has never
installed it. Reading that file as the idempotency signal made
'cassandra install' a no-op on the first attempt for exactly the versions
the feature exists to install — and for any version declared after the AMI
was baked. Check /usr/local/cassandra/<version> instead and treat the
push-up as pure input to the install.

A rollback that fails no longer replaces the install failure: it is
recorded as a suppressed exception and the original is always rethrown.
The failure reason is redacted before it reaches the event bus, an empty
--hosts match now fails fast instead of installing nowhere and exiting 0,
and the tests cover --hosts targeting and the declared git-branch path.
The one script both the AMI bake and 'cassandra install' depend on had no
automated coverage. Add two suites in the repo's existing bash-test style:
install-cassandra-version's decisions before it touches the network
(argument handling, already-installed no-op, source-build guards, JDK
selection, and that it never switches the node's default JDK), and the bake
loop's yq-driven flag construction and lazy skip.

To make the loop testable, install_cassandra.sh regains the safe-to-source
function header it documents, with version resolution as functions above the
INSTALL_CASSANDRA guard. install-cassandra-version's install directory
becomes overridable so the script can run outside a node, and its ERROR
lines now go to stderr where a failure handler can find them.

Both suites are wired to Gradle tasks and to the packer-lint workflow.
Two hosts sharing an alias would overwrite each other's outcome and
misattribute a failure. Also drops HostOperationsServiceTest's unused
BaseKoinTest base — it resolves nothing from Koin.
…gnal

Every AMI ships the whole cassandra_versions.yaml, so a lazy entry is
always present on a node that has never installed it. Record why the
implementation checks the filesystem instead, so the next reader doesn't
restore the original design.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review: PR #878 - cassandra install (runtime version install without AMI rebuild)

Summary

This is a well-designed, well-tested draft. The core idea of extracting install_cassandra_version() into a standalone install-cassandra-version script so bake-time and runtime share one install path is sound, and the extraction looks behaviorally faithful (dead scaffolding correctly dropped, lazy: skip logic correctly added to the bake loop). The final commit fix that makes the already-installed check disk-based (test -d /usr/local/cassandra/) rather than yaml-based is the right call and closes a real bug from an earlier round of this PR. The HostOperationsService fix (rethrow-after-join instead of silently swallowing per-thread exceptions) is a genuine, well-justified correctness fix with good test coverage. ShellQuoting and Redaction are both solid, no bypass found in either. The main thing worth resolving before merge is the git clone / --url argument-injection angle below, plus a --hosts fail-fast edge case and a UseCassandra state-loss interaction.

Strengths

  • HostOperationsService.collectFromHosts (services/HostOperationsService.kt:99-127) is additive and does not change the existing throw-on-first-failure contract of withHosts for its other callers, so blast radius is low.
  • The rollback logic in CassandraInstall.installOnHost (CassandraInstall.kt:192-204) correctly attaches a rollback failure as a suppressed exception rather than masking the original install failure. Verified against the CassandraInstallTest case covering "a rollback failure never hides the install failure", a real (not mock-echo) test of that exact path.
  • Redaction.kt URL_CREDENTIALS regex correctly distinguishes user:pass@host (redacted) from scp-style user@host:path (left alone). RedactionTest.kt covers both plus the no-URL no-op case.
  • ShellQuoting.kt shellQuote() (safe-charset allowlist plus single-quote wrap with escaping) is the standard-correct POSIX approach, and reusing the existing ExecRun implementation instead of duplicating it is good cleanup.
  • CassandraVersion.lazy uses a @get:JsonInclude(NON_DEFAULT) override on top of the class-level NON_EMPTY policy, a nice catch since a plain false value is not suppressed by NON_EMPTY alone. CassandraVersionTest.kt asserts on the actual serialized bytes, so it would fail if the annotation were dropped.
  • CassandraInstallTest.kt and HostOperationsServiceTest.kt are good examples of no-mock-echo tests, exercising real decision points such as rollback, per-host failure isolation, host-filter scoping, and resolution precedence.
  • Docs (docs/user-guide/installing-cassandra.md) are thorough and match the implementation, including CLI-flags-override-declared-entry precedence and the lazy workflow.

Bugs / Correctness Issues

  1. The --hosts empty-match fail-fast only covers the non-blank-filter case (CassandraInstall.kt:84-88):

    val targeted = hostOperationsService.filteredHosts(state.hosts, ServerType.Cassandra, hosts.hostList)
    require(targeted.isNotEmpty() || hosts.hostList.isBlank()) { ... }

This correctly fails fast when --hosts foo,bar matches nothing. But when hosts.hostList is blank (no --hosts flag given at all) and state.hosts[ServerType.Cassandra] is empty or missing, targeted is empty and the isBlank() disjunct makes require pass trivially. The command then emits InstallingVersion(version, 0, ""), the results loop does nothing, failed stays empty, and the command exits 0 having done nothing. That is the same silent-zero-hosts-success class of bug the commit set out to fix, just not fully closed. Worth either an unconditional require(targeted.isNotEmpty()), or a comment explaining why the blank case is intentionally exempt.

  1. UseCassandra.kt:64-73 can now silently lose successful-host state as a consequence of the HostOperationsService fix in this PR:

    hostOperationsService.withHosts(state.hosts, ServerType.Cassandra, hosts.hostList, parallel = true) { host ->
    remoteOps.executeRemotely(it, "sudo use-cassandra $version").text
    state.versions?.put(it.alias, version)
    }
    clusterStateManager.save(state) // only reached if every host succeeds

Before this PR, withHosts(parallel = true) swallowed per-host exceptions, so save() always ran. This PR fix correctly makes withHosts rethrow the first host failure after every host has run, but that means if any host fails (for example, the new use-cassandra "version not installed" guard this same PR adds), save() is never reached, and every host that did succeed in that batch has its state.versions update discarded from disk. A later cassandra start would then read stale state for a host that is actually running the new version fine. Not a bug in the diff itself since UseCassandra.kt is not touched, but a direct, real behavioral consequence of the HostOperationsService change, in a caller with exactly the mutate-then-save-after shape. Worth fixing UseCassandra alongside this PR, for example via collectFromHosts plus save per successful host. Checked SetupInstance, ExecList, ExecStop, and Start.kt: none of them have this shape, so UseCassandra appears to be the only affected caller.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review: PR #878 continued (part 2/2)

Security Concerns

  1. git clone positional $URL argument is not protected against option injection - packer/cassandra/bin/install-cassandra-version:282

    git clone --depth=1 --single-branch --branch "$BRANCH" "$URL" "$version" || { ... }

$URL is passed as a bare positional argument. A value beginning with -- (for example --upload-pack=) is parsed by git as another option, not as the repository URL - this is the well-known git argument-injection RCE pattern (--upload-pack / --upload-archive let you specify an arbitrary program git runs). ShellQuoting.kt shellQuote() only protects the outer SSH command line from shell metacharacter injection; it does nothing to stop git itself from reinterpreting a value that merely starts with -- as a flag, since such a string is entirely within SAFE_UNQUOTED and passes through unmodified as a single argument either way.

This pattern already existed pre-PR (the old inline install_cassandra_version() had the identical git clone ... "$URL" "$version"), sourced only from a hand-curated cassandra_versions.yaml. What is new is that $URL is now also directly reachable via a live, operator-typed "cassandra install --url " CLI flag (CassandraInstall.kt:47-50 into installCommand() into the remote script), a meaningfully wider input surface than editing a YAML file ahead of an AMI bake.

Given this repo single-operator / ephemeral-cluster trust model this is not cross-tenant severity, but it is a cheap standard fix worth taking: insert the -- end-of-options sentinel before the positional args, e.g. git clone --depth=1 --single-branch --branch "$BRANCH" -- "$URL" "$version".

The same class of issue applies more mildly to the curl -fsSL --retry 3 "$1" -o "$3" fallback fetcher (install-cassandra-version:74) if $1/$URL begins with a dash - lower severity (curl option confusion vs git RCE-capable --upload-pack), but the same -- guard or an explicit case check on a leading dash would close it for both call sites.

  1. --ant-flags word-splitting (install-cassandra-version:302): ant -Dno-checkstyle=true $ANT_FLAGS is deliberately unquoted (documented via a shellcheck disable comment) so multiple flags can be passed in one string. This is intentional and does not look exploitable for command injection (bash does not re-parse an already-expanded variable for semicolons, backticks, $(), or pipes), but it is subject to glob expansion (a value containing *, ?, or [...] would expand against the temp workdir contents before reaching ant). Low severity, flagging only because it is operator-supplied and worth a one-line comment noting the tradeoff was intentional.

Test Coverage Gaps

  • No test in CassandraInstallTest.kt exercises the --hosts matched-nothing fail-fast path (the require at CassandraInstall.kt:85-88), despite it being called out explicitly as a fix in the final commit description. Also nothing exercises the blank-hosts-with-zero-targeted-hosts edge case from Bug 1 above.
  • DeclaredVersions.kt declaredCassandraVersions() (the main-file-missing branch, and the merge-with-extras path) has no direct test; its behavior is currently only indirectly covered through CassandraVersion.loadFromMainAndExtras own tests, which do not exercise the main-file-does-not-exist short-circuit at all.
  • The bake-time behavioral change in install_cassandra.sh (calling out to a separate install-cassandra-version process instead of an inlined function, meaning a different working directory, environment, and set -x propagation) is not exercised by testPackerCassandra or any Docker-based packer test. Given a real bake run needs network access and a real build, this should not block merge, but a fast tarball-path-only smoke test via testPackerScript would be cheap insurance given this is the actual behavioral risk that design.md own Risks section calls out.

Minor / Nits

  • packer/cassandra/bin/use-cassandra:7 - the check [ ! -d "/usr/local/cassandra/$1" ] - if $1 is empty this becomes [ ! -d "/usr/local/cassandra/" ] (false, the dir exists), so execution falls through to ln -vfns pointing the whole versions directory at itself. Almost certainly unreachable today since UseCassandra.version is a required positional parameter, but since this file is already being touched in this PR, a cheap empty-string guard alongside the new existence check would close it for good.
  • CassandraInstall.kt:231-239 (installCommand) never passes --python to install-cassandra-version (the script does not accept it either) - that appears fine, since python is only needed later by cassandra use and is already carried through via the pushed /etc/cassandra_versions.yaml entry, but worth a one-line comment noting that is intentional so a future reader does not assume it is a missed flag.
  • Event.Cassandra.VersionList.toDisplayString() in events/Event.kt now builds a multi-line per-entry hint string inline. This is fine per the project conventions (still one structured event with a declared-not-installed list field), just noting the formatting logic is a bit more elaborate than this file other toDisplayString() implementations.
  • Docs (docs/user-guide/installing-cassandra.md) describe the declared-entry default source as coming from the profile extras directory - technically declaredCassandraVersions() merges the packaged cassandra_versions.yaml and the extras directory, so a plain shipped entry with no extras file also works as the declared-entry path. Minor wording precision, not incorrect.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Reviewed the diff for the cassandra install <version> command plus the withHosts/URL-redaction work. Overall this is a solid, well-tested PR — the collectFromHosts/HostResult fix for the parallel withHosts bug (bare threads swallowing exceptions) is correct and covered by tests, the rollback-on-failed-install logic in CassandraInstall.installOnHost is a nice touch, and the use-cassandra dangling-symlink guard is a good fail-fast fix. A few things worth a look before merging:

1. redactUrlCredentials misses the bare-token URL form (src/main/kotlin/.../ssh/Redaction.kt:7)
The regex ([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\\s]*:[^/@\\s]*@ only matches user:password@. It deliberately (and correctly) leaves bare user@host alone for scp-style git remotes (git@github.com:...), but that same exemption also lets a bare-token HTTPS credential through, e.g. https://ghp_abc123token@github.com/... — a common way to embed a GitHub PAT. CassandraInstall.kt explicitly redacts failure messages before emitting Event.Cassandra.VersionInstallFailed because "a resolved git url can carry a token, and this event reaches MCP and Redis subscribers" — with this URL form the token would leak into that event, the exception message, and any subscriber. Worth tightening the regex to also redact scheme://token@host when there's no colon, distinguishing it from the scp-style user@host:path (no scheme://) case.

2. --java isn't validated against what use-cassandra actually supports (CassandraInstall.kt, resolveVersion)
resolveVersion only checks that a java version is non-blank, but packer/cassandra/bin/use-cassandra hardcodes 8/11/17/21 (else "Unknown java version $JAVA_VERSION"; exit 1). cassandra install foo --url ... --java 25 will build/install successfully and only fail later, with a confusing error, the first time someone runs cassandra use foo. Might be worth validating --java against the supported set at install time so the failure surfaces immediately.

3. Empty --hosts + zero Cassandra hosts silently "succeeds" (CassandraInstall.kt:85, require(targeted.isNotEmpty() || hosts.hostList.isBlank()))
If --hosts isn't passed and the cluster currently has zero db-type hosts, the guard passes via the second clause (hostList.isBlank()), InstallingVersion fires with 0 hosts, and collectFromHosts returns an empty list — so the command exits 0 having done nothing. Minor/edge-case, but a require(targeted.isNotEmpty()) unconditionally (with a clearer message when there are no db hosts at all) would avoid the misleading success.

4. resolveVersion drops several declared fields when pushing the resolved entry to the node (CassandraInstall.kt, resolveVersion)
The CassandraVersion built here only carries over jvmOptionsaxonops, javaBuild, and jvmConfig from the declared cassandra_versions.yaml entry aren't copied through, so a node's /etc/cassandra_versions.yaml ends up with those fields stripped for an installed version even though they were declared. Probably fine today if nothing downstream reads them yet, but worth confirming that's intentional (or copying them through for consistency with the rest of the entry).

Minor: CassandraVersion.kt continues to build on Jackson (adding the new lazy field with @JsonIgnoreProperties/@get:JsonInclude) rather than kotlinx.serialization — this is pre-existing (the class was already Jackson-based per configuration/CLAUDE.md), so not a regression from this PR, just flagging since CLAUDE.md calls Jackson usage deprecated project-wide.

Nothing here looks blocking — mostly edge cases and a couple of gaps in the redaction/validation logic worth tightening given this touches credential handling and a command whose failures only surface much later (cassandra use).

Round 1 redacted only SSHClient's failure branch, and three sinks were
missed: the raw command in SSHClient's own debug log, and — on a
SUCCESSFUL command — the returned Response and the Ssh.CommandOutput event,
which is @serializable and reaches every MCP and Redis subscriber.
install-cassandra-version echoes the clone URL it was handed, so a normal
successful install published the token. Redaction now happens once, inside
SSHClient, covering every sink.

The pattern also missed https://TOKEN@host — the single-field form GitHub
documents for personal access tokens, and the likeliest real value. The
password half is now optional.

A secret command's captured output is replaced wholesale rather than
attached: tailscale and the axonops setup script both echo the key back in
their own failure output.
…oll back

Three problems in one code path:

The push-up was gated on the version being absent from the node's list, so
a resolved --java 21 was silently dropped whenever the version was already
declared — the normal state for any baked lazy entry, since the whole yaml
ships in every AMI. The node kept saying java 11 and a later cassandra use
picked the wrong JDK. It now pushes whenever the node's entry differs from
what was resolved, replacing it in place.

The pushed entry carried the resolved url — including any embedded token —
and it stayed in /etc/cassandra_versions.yaml indefinitely after a
successful install. use-cassandra reads only java/python from that file, so
url and branch are stripped before it is written.

Rollback on failure is gone. It could strip the declaration back out after
the binary was already on disk, leaving a version installed but undeclared
— and every retry short-circuits on the disk check before it can
re-declare, so nothing could repair it. Declared-but-uninstalled is the
harmless state; it is what a lazy entry looks like.

Also: an empty target set now fails instead of reporting success for an
install that never ran, distinguishing an unmatched --hosts from a cluster
with no Cassandra nodes at all.
withHosts rethrew the first failure and dropped the rest, so an operator
fixing a multi-node problem found the next one only on the following run.
The remaining failures are attached as suppressed.
A failed git clone left the credentialed remote in <workdir>/.git/config,
because cleanup only ran on the success path — and a failed build is the
common case when testing an unmerged branch. An EXIT trap now removes it
however the script ends.

Also rejects --branch against a tarball --url instead of silently
installing the tarball, and adds use-cassandra.test.sh: the guard against
selecting a version the node never installed had no automated coverage at
all, only a manual container run.
… suites

packer-test.yml already runs resolve-build-plan/resolve-ref this way; my
round-1 job was in packer-lint.yml, which only lints. Also pins that
workflow's two third-party actions to release tags and gives it the
contents: read block every other workflow has, and syncs tasks.md 5.3/8.7
to the on-disk idempotency check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cassandra): install arbitrary versions/branches onto a live cluster without an AMI rebuild

1 participant