fix(advisor): own the web-targets allowlist, and carry enrolment across an upgrade - #1482
Open
traviswu-bigstack wants to merge 15 commits into
Open
traviswu-bigstack wants to merge 15 commits into
traviswu-bigstack wants to merge 15 commits into
Conversation
traviswu-bigstack
force-pushed
the
travis.wu/advisor-web-targets
branch
from
September 12, 2026 12:39
475163a to
c427c02
Compare
This was referenced Sep 14, 2026
traviswu-bigstack
force-pushed
the
travis.wu/advisor-web-targets
branch
2 times, most recently
from
September 17, 2026 03:15
3cc17b6 to
f9e33de
Compare
traviswu-bigstack
marked this pull request as ready for review
September 17, 2026 03:18
5 tasks
Add advisor_targets_init/list/set/unset to sdk_advisor.sh. Nothing wrote
/etc/cube-advisor-agent/web-targets.json before; the lab cluster's copy was
hand-made. init seeds {"dashboard":"127.0.0.1:8080"} only when the file is
absent and never repairs an edited one, since an operator who removed a
target removed it on purpose. set/unset validate the name and host:port
shape the agent will dial, and every write is atomic (temp file in the same
directory, then mv). advisor_enroll now seeds the file right after enabling
the tunnel service.
Reading and updating the file goes through jq, not a hand-rolled parser: the
allowlist is operator-edited, and a regex tuned to the exact compact layout
this module writes silently reads pretty-printed, re-indented, or
CRLF-terminated JSON as empty -- turning any hand edit into set/unset
wiping the whole allowlist, including dashboard. jq's -e refuses a file
that does not parse as a JSON object instead of quietly starting from
empty; a genuinely empty file still needs an explicit check, since jq runs
its filter zero times over zero input values and calls that success.
Also: reject a leading zero on a port, stop orphaning the temp file when
the final mv fails, and give the IPv6-host refusal its own message (the
SaaS side does accept IPv6 pool addresses; a node does not, on purpose).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
Signed-off-by: Travis Wu <travis.wu@bigstack.co>
Adds `advisor targets` / `target_set` / `target_unset` on top of the sdk_advisor.sh helpers from the previous commit, plus a target count on `advisor status`. Each Main function is a thin HexSpawn wrapper: argv is passed straight through to advisor_targets_list/set/unset, which already own validation and operator-facing error text, so nothing is re-checked here. Names and addresses are ordinary argv elements -- unlike the enrolment pairing token, there is nothing here worth hiding from `ps`. `advisor status` now also runs advisor_targets_list through CliPopulateList and prints how many targets are allowed, independently of whether the agent binary is installed -- a node can hold an allowlist with no agent, and that split is worth reporting rather than folding into the existing "not installed" early return. Tests: core/modules/tests/config_advisor/test_config_advisor_targets.sh compiles the real cli_advisor.cpp against new stub hex/cli_module.h, cli_util.h, process.h and strict.h headers (same technique test_config_advisor_02.sh uses for config_advisor.cpp), with HEX_SDK pointed at a fake helper script so HexSpawn really forks/execs it. Covers: targets prints the helper's own output unchanged; target_set/target_unset refuse the wrong arity without invoking the helper, and pass both arguments through unchanged when the arity is right; status reports the target count and still returns success when the agent binary is absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
An upgrade installs the new firmware onto the other partition and boots it, so nothing a node was given at enrolment is on the slot it comes back on unless it is migrated. config_advisor.cpp registered no migrations at all, so every upgraded node kept a certificate the Advisor could no longer reach and quietly left the fleet -- with the only remedy being to enrol it again by hand. Registered for migration: the identity directory, the agent binary, the web-targets allowlist, and the console CA with its sshd drop-in. The last two are written by the agent at enrolment, which lands separately; rsync skips a path that does not exist, so registering them now is a no-op until then and saves a second change here. The binary migrates like any other file. An earlier version of this change stashed the signed release at enrolment and reinstalled from it after the upgrade, so the new partition's own key re-verified the agent before running it. That property does not survive inspection: the stash would live on the same root-writable filesystem as the binary, so anyone able to tamper with one could tamper with the other, and re-verification defends against nothing an attacker cannot already reach. Its only real effect would be that a rotated signing key disables existing agents at upgrade time -- revocation, arriving late and by accident. Revocation belongs at the Advisor, which authenticates every agent by mTLS on every connect. Nothing about the service's state migrates either, because nothing about it is this module's to carry: in cubecos hex_config owns when a service runs. So the advisor module grows the Commit hook it never had (CONFIG_MODULE(advisor, 0, 0, 0, 0, Commit)) and calls SystemdCommitService, which stops and starts the unit and never enables it, behind the guards every other module commits behind -- the dry-run barrier, a known cluster role, and CommitCheck. The unit's multi-user.target.wants symlink is deliberately not migrated: it would make systemd a second owner of the service on the new partition. The role is a guard, not a filter. IsUndef(s_eCubeRole) asks whether this node is configured at all; a module that commits before then is acting on settings nobody has supplied yet, which is why the observer wiring is here. It is not part of deciding whether the agent runs. That decision is the identity alone: no role gate, unlike every module this one is modelled on, because a node holds an identity only because someone enrolled it and any node may be enrolled. No binary check either -- an enrolled node whose binary will not run fails to start and logs it. That is also the only signal for an agent that migrated but cannot run on the new firmware, a glibc bump being the obvious case. CONFIG_REQUIRES(advisor, net_static) orders the commit after the node has its addresses, since the agent dials out to the Advisor. sshd requires the same module for the same reason. advisor_agent_service_enable becomes advisor_agent_service_start and stops enabling the unit. Enrolment still starts the agent immediately, which is what an operator expects on a booted system, but an enable symlink would make systemd a second owner of the service, starting the agent at multi-user.target on a node hex_config had decided should not run it. After this nothing in the tree enables the unit, and nothing disables it either: the symlink today's code leaves behind lives in /etc/systemd/system/multi-user.target.wants/, which is not migrated, so an upgrade gives the new partition an /etc from the image with the unit never enabled -- and a node running this helper at all is a node that upgraded. None of that was visible to "cluster check", though: sdk_health.sh had no idea the Advisor existed. Diagnosis belongs in the health framework, which can also repair, not in a CLI print that only helps someone who already thought to look -- so cli_advisor.cpp's StatusMain drops the "unit is not running, check journalctl" block (and the constants only it used) in favour of a proper component. health_advisor_check / _report / _repair land in sdk_health.sh, "advisor" joins ClusterSys in cli_cluster.cpp next to bootstrap and license (cluster-level infrastructure, not a tenant-facing service, and control-node only via the CompItem role default), and errcodes gets an "advisor" block. Not enrolled stays healthy -- most clusters never roll into the Advisor, same precedent as fc_link's "no FC HBA installed" -- while enrolled with the unit inactive is the fault this exists to catch, and enrolled with the binary itself missing is a separate code, because only re-enrolment fixes that, not a local repair. health_advisor_repair starts the unit through the existing advisor_agent_service_start (sdk_advisor.sh) via $HEX_SDK rather than a bare call, which hex_sdk's per-module autoload would otherwise turn into a silent no-op, and never enables it, for the same second-owner reason as everywhere else in this change. Code 1 also gets an auto-repair: _health_advisor_auto_repair, dispatched by _health_fail_log's own naming convention, restarts the unit through that same advisor_agent_service_start call -- restarting a service is cheap, which is exactly what the auto path is for. Code 2 is deliberately excluded: a missing binary needs re-enrolment, and the auto-repair count only resets on a passing check, so retrying a fix that cannot work would just burn the retry budget for nothing. The check was node-local, though, and that was a design error: "cluster check" only ever runs on control nodes, but cube-advisor-agent is enrolled and running on every node in the cluster, control, compute and storage alike -- confirmed on the lab, where all three nodes each hold their own identity and run their own agent. A node-local check is blind to every node except the one it happens to run on, which is most of the fleet, and a stopped agent on a compute node loses that node's own console and web target just the same as on a control node. So health_advisor_check now fans out over CUBE_NODE_LIST_HOSTNAMES, the shape health_rbd_target_check uses (rather than fc_link's per-node-helper-invoked-remotely shape, which buys more than this needs): a node with no identity is simply skipped, same as health_rbd_target_check skipping a node with no OSDs, so a cluster where nothing is enrolled anywhere is still healthy and a cluster where only some nodes are enrolled is healthy too as long as every enrolled one is fine. Code 1 and code 2 now name the affected node(s) rather than saying an agent is down with no node attached, and code 2 (missing binary somewhere) takes priority over code 1 when a run has both, since only re-enrolment fixes it. health_advisor_repair and _health_advisor_auto_repair (which now just calls the former when ERR_CODE is 1, the same delegation _health_clock_auto_repair uses) restart the affected node(s) remotely through "remote_run $node $HEX_SDK advisor_agent_service_start" rather than the house remote_systemd_restart helper, which knows only "systemctl restart" and nothing of the unit-file check or the never-enable invariant advisor_agent_service_start already encodes -- so this keeps that one place as the only place that knows how to start the unit, rather than duplicating that knowledge or bypassing it. The s_comps role in cli_cluster.cpp is unchanged: the check still runs on control nodes, which is correct: it is what it examines that had to change. Tested with the module's stub harness: test_config_advisor_03.sh compiles the real config_advisor.cpp against the stub hex and cube headers with its paths aimed at a scratch tree and a fake systemctl on PATH. It reads the registered migrate paths back from the module and asserts the set exactly -- that list is the whole of what an upgrade carries, including the binary and excluding the enable symlink -- and covers Commit: no role yet and a full dry run decide nothing, an identity starts the service and its absence stops it, and an enrolled compute, storage, network, edge or moderator node runs the agent just as a control node does. test_config_advisor_targets.sh now covers that "advisor status" says nothing about the unit's active state any more -- that moved to the health framework -- and test_sdk_advisor_health.sh covers health_advisor_check/report/repair: not-enrolled healthy with an advisory note, enrolled+active healthy, enrolled+inactive the fault code with the unit named in the message, enrolled+missing-binary a distinct code, repair calling advisor_agent_service_start through $HEX_SDK and nothing else, health_advisor_report round-tripping a code through health_errcode_lookup, auto-repair on code 1 calling advisor_agent_service_start, auto-repair on code 2 calling nothing, and the auto-repair function existing under that exact dispatched name. It now stubs a node list plus remote_run/is_remote_running and covers the cluster-wide shape: no node enrolled, two enrolled and healthy, two enrolled with one stopped (naming that node and not the healthy one), one enrolled with its binary missing (naming it), and repair/auto-repair restarting only the affected node. test_sdk_advisor_service_start.sh covers the enrolment helper: it starts the unit, and it never enables, disables or interrogates it. Signed-off-by: Travis Wu <travis.wu@bigstack.co> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
cube-advisor-agent only dials names in the node-local web-targets allowlist, and the CMP portal it needs to reach through that allowlist was never declared automatically -- an operator had to type it in by hand. Add app_ingress_address (sdk_app.sh), which prints the app framework's ingress LoadBalancer address by looking up the ingress-lb Service by name across every namespace (seen at both ingress-nginx, where app_framework_deploy creates it, and kube-system on a live cluster), or nothing if there is no app framework, no kubeconfig, or no kubectl. Add advisor_targets_discover (sdk_advisor.sh), which declares cube-cmp and app-fw-idp at <ingress>:443 -- the same address for both, since the portal and its identity provider must share one origin or the OIDC state cookie set on one never matches the callback on the other. It is a no-op with no allowlist file (nothing must be created for a cluster that never enrolled) and a no-op with no ingress address. Wired into both advisor_enroll and app_framework_install, since either order (CMP-then-enrol, enrol-then-CMP) is real. Signed-off-by: Travis Wu <travis.wu@bigstack.co> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
cli_driver.cpp and driver.cpp opened an fopen(getenv(...), "a") path to record what the stub was asked to do, which CodeQL flags as an uncontrolled path plus a 0666-mode file creation. Both now log to a fixed filename in the current directory via open(..., 0600), removing the environment-derived path entirely. test_config_advisor_03.sh, the only caller of SYSTEMD_COMMIT_LOG, now cds into its scratch directory before running the stub binary and reads the fixed filename back from there. Signed-off-by: Travis Wu <travis.wu@bigstack.co> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
…he enroller The Advisor agent runs on every node and each agent reads its own local /etc/cube-advisor-agent/web-targets.json, but advisor_targets_init and advisor_targets_discover only ever ran from advisor_enroll, on the one control node an operator typed the command on. On a 3-node lab cluster the allowlist existed on one node and was absent on the other two, so a web console session routed to either of those refused every target. app_ingress_address made it worse: it needs the app framework's kubeconfig, which also lives on one node only, so even a fan-out of the old code would have found no address elsewhere. The dashboard target is renamed cube-cos, so the three names are now cube-cos, cube-cmp and app-fw-idp. advisor_targets_discover now runs where the kubeconfig is, resolves the ingress address and writes it to every node in CUBE_NODE_LIST_HOSTNAMES through remote_run -- the same fan-out health_advisor_check uses. Only the bare address crosses a node boundary, into /etc/cube-advisor-agent/ingress; no node ever writes another node's allowlist. The advisor module's Commit then seeds the local allowlist through hex_sdk, which is what makes this self-healing: Commit runs on every node during that node's own hex_config commit, so a node that was down or not yet in the cluster at enrolment gets its allowlist on its next commit. Seeding is not reconciling. advisor_targets_init still writes only when there is no file at all -- a node with no allowlist gets one, a node that has one is left exactly as the operator left it. A commit that added back a target someone had removed with "advisor target_unset" would make that command useless, so nothing on this path adds, removes or repairs an entry in a file that exists. app_ingress_address also defaults its kubeconfig path, so a node that has none refuses quietly under set -u instead of erroring -- on most nodes that is the normal answer, not a fault. Signed-off-by: Travis Wu <travis.wu@bigstack.co> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
app_framework_install called advisor_targets_discover, which declared both cube-cmp and app-fw-idp. The real install order is app framework first, CMP second, so at that moment the ingress exists and CMP does not: the cluster declared a target for something that was not installed, and an agent that dialled it reached nothing. app-fw-idp is the app framework's own Keycloak and does belong to that install; cube-cmp belongs to whatever installs CMP. Discovery now asks what is actually there. helm list -A reports cube-portal and keycloak as independent deployed releases, which is the honest test of "is this installed" -- a namespace exists from early in an install and stays behind after a removal, and an HTTP probe answers "not yet" for everything still starting. app_helm_release_deployed in sdk_app.sh is that check; discover declares app-fw-idp only when keycloak is deployed and cube-cmp only when cube-portal is, and app_import calls discover too, so installing CMP after the framework is what declares cube-cmp. That exposed a second problem. The advisor module's Commit seeds every node's allowlist through advisor_targets_init, and most nodes have no kubeconfig, so init cannot ask what is installed -- it can only read what it was told. The file distributed to every node carried the ingress address alone, which cannot say which targets to seed. It now carries the discovered set itself, one "name host:port" per line, at /etc/cube-advisor-agent/discovered-targets (advisor_discovered_set / advisor_discovered_list, replacing advisor_ingress_set / advisor_ingress_address). init seeds cube-cos always, plus exactly the entries in that file. The fan-out stays above the "this cluster never enrolled" guard, deliberately: an install normally runs long before anyone enrols, so the set has to be on every node by then for enrolment's init to seed cube-cmp from it at all. The never-overwrite rule is unchanged -- an allowlist that exists is left exactly as the operator left it, so advisor target_unset still survives every commit. Tests: the discovery and seeding suites now cover framework-only, framework and CMP, and neither; a new test_sdk_advisor_install_order.sh walks the whole sequence on three nodes (install both with no allowlist, assert none is created but every node holds the discovered set, then enrol and assert cube-cos, cube-cmp and app-fw-idp all appear); test_sdk_app_helm_release.sh covers the release check itself. Signed-off-by: Travis Wu <travis.wu@bigstack.co> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
app_ingress_address and app_helm_release_deployed both refused unless /opt/appfw/kubeconfig was readable, and nothing creates that file -- app_framework_deploy fetches its own from rancher into a temp file and throws it away. Both helpers therefore returned "no framework" on every cluster, so advisor_targets_discover exited at its first line and no install or enrolment ever declared cube-cmp or app-fw-idp. app_kubeconfig fetches one the way the product does. The framework's name is read back from rancher rather than assumed: the installer chooses it, and a driver-installed framework is named "appfw" where app_framework_deploy's own is "app-framework". advisor_targets_discover acquires it once and exports it for the three queries, which run as separate hex_sdk processes and would otherwise authenticate to rancher three times per call. APPFW_RANCHER joins APPFW_KUBECONFIG as an injectable path. A hardcoded absolute path is one no test can reach, which is how a helper gated on an unreachable path shipped unexercised: its unit tests stubbed the very helpers that were broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
An Advisor serving its own certificate -- the normal case offline, where
there is no public CA to lean on -- cannot be enrolled against. The
release fetch dies at
curl: (60) SSL certificate problem: self-signed certificate
before any signature is checked, and the agent that would follow speaks
to the same endpoint through Go's TLS. Both read the system trust store
and neither takes a CA path.
advisor_trust_ca installs a CA the operator supplies out of band, so both
verify. Passing -k instead was never an option: the pairing token is a
bearer credential on that request, and an unverified session is exactly
where it must not be sent. A file that is not a certificate is refused at
the door rather than installed to fail every later handshake with nothing
pointing back here, and an anchor whose trust store will not rebuild is
removed rather than left claiming a trust that is not in effect.
The argument is optional: an Advisor behind a certificate this node
already trusts needs nothing.
Deliberately not migrated across an upgrade. This is enrolment-time
trust; the tunnel's own trust is the enrollment CA the agent pins from
its identity directory, which is migrated. Confirmed on hardware -- after
an upgrade that dropped this anchor, the agent had reconnected on its own
while curl to the same address still refused.
Fixes the node-side half of bigstack-oss/cube-ai-advisor#215
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj
Signed-off-by: Travis Wu <travis.wu@bigstack.co>
…rebuild Two ways an enrolled node gets stuck, both found reinstalling the Advisor on the 1cc r630. A firmware upgrade leaves a node unable to name its own cluster. advisor_cluster_id reads phone-home-agent.env, which is written at deployment and is not migrated, then cubesys.controller, which a converged single-node cluster does not set. Both absent, enrolment fails before it starts -- on a node that was enrolled, still running its agent, and holding the answer the whole time: its certificate's OU is the cluster it enrolled as. Read that back, last of the three, because the other two say what this node belongs to now while the certificate says what it enrolled as once. Also stop reading T_cubesys_controller unbound, which faults under set -u in exactly the case that matters. A rebuilt Advisor strands every node enrolled with the old one. The new install signs with a new enrollment CA, so each node holds an identity signed by a CA that no longer exists -- valid-looking and useless -- and the agent refuses to replace an existing identity, correctly. There was no supported way to ask it to: the only route was deleting the identity directory by hand. advisor_enroll takes a force argument, exposed as a positional keyword the way app_register takes skip_flavor, and never prompted, because replacing a working identity is not something to walk into by pressing return. The already-enrolled message now says what to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
A console session is piped to this node's own sshd, so sshd decides whether a certificate is good -- the Advisor only signs one. Nothing ever wrote the CA or the sshd drop-in that makes that decision possible: both paths are in this module's own migrate list, so they survive a firmware upgrade, and no code created them. Every cluster therefore had a console that authenticated nobody, which is what the 1cc r630 showed -- /etc/ssh/console-ca did not exist and sshd reported TrustedUserCAKeys none. advisor console_trust installs the CA the Advisor prints at install time and writes a drop-in scoped to the console account, so a certificate it signs cannot be presented as any other user. The drop-in is written rather than appended: an append duplicates on every re-run, and 50-redhat.conf is not ours to edit. Refusals are the point of most of this. A private key is a real key, so a naive check passes it and sshd then refuses every login with nothing pointing back here. And a drop-in sshd cannot parse would take sshd down at its next restart -- far worse than a console that does not work -- so the configuration is tested before it is kept and removed if sshd rejects it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
The cube-cos web-console target was seeded at 127.0.0.1:8080. That port is httpd, which answers 403 to everything -- nginx serves the dashboard on the management address. A target pointed there looks configured and refuses every request, with nothing in any log to say why; in the browser it presented as the proxied tab showing "403 Forbidden". advisor_dashboard_address resolves where this cluster's UI actually answers: the control VIP on an HA cluster, this node's management address otherwise. When neither is known the seed omits cube-cos entirely rather than writing an address that cannot work -- `advisor target_set` adds it once the address exists. The three sdk tests extract functions individually, so each stubs the resolver: what they check is what gets seeded, not how the address is found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
The dashboard sends the browser to Keycloak on :10443 to log in, so a node that allows cube-cos and not that endpoint allows a login that cannot complete -- the browser leaves for an address the Advisor is not permitted to dial and gets nothing. advisor_idp_address derives it from the dashboard address, and the seed writes both. It is not spelled in anything the dashboard serves: the address arrives as a bare string and script appends the port, so this target cannot be discovered by reading what the cluster says about itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
The dashboard links out to Skyline on :9999 and the Ceph dashboard on :7443 as well as Keycloak on :10443, so a node that allows cube-cos and not those allows links that cannot be followed. advisor_own_targets replaces advisor_idp_address and returns the whole set the node can name for itself, one name and address per line, which the seed renders the same way it already renders the discovered ones. One function to stub in the tests rather than one per port, and one place to add the next endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj Signed-off-by: Travis Wu <travis.wu@bigstack.co>
The action level and consent dial are files the agent reads as authoritative (ADR 0011/0017); until now only the lab wrote them by hand, so a customer who set internal in the UI changed nothing the executor honoured. Add advisor_level_set / advisor_consent_set / advisor_level_show to the sdk, writing the files the agent reads and restarting it so the change takes effect, and the hex_cli commands advisor level / level_set / consent_set beside advisor target_set. Values are validated against the agent's own vocabulary. Shell test covers valid words, refusal of an invalid one leaving the last good value, and the fail-closed defaults. Refs #1514 Signed-off-by: Travis Wu <travis.wu@bigstack.co>
traviswu-bigstack
force-pushed
the
travis.wu/advisor-web-targets
branch
from
September 21, 2026 18:11
7e68aaa to
fe33643
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What type of PR is this?
/kind bug
What this PR does / why we need it
Two gaps in the Advisor agent's node-side support, folded together because they share every file.
1. Nothing owned the web-targets allowlist.
cube-advisor-agentonly dials endpoints listed in/etc/cube-advisor-agent/web-targets.json, and no part of cubecos created or managed it — so an enrolled node could serve no web target until someone hand-wrote JSON on it, and there was no CLI to inspect or change it.config_advisor'sCommitseeds it on every node, so each node has its own:cube-cos→ this cluster's own dashboard, pluscube-cmpandapp-fw-idp→<ingress>:443when the app framework's ingress address is known. Both CMP entries share one address on purpose — the portal and its identity provider must share an origin, or the OIDC state cookie is set on one and the callback arrives at the other.advisor_targets_discoverfetches a framework kubeconfig from rancher, resolves the address, and writes it to every node as a short string; each node'sCommitreads it locally. No node needs a kubeconfig at commit time.advisor_targets_list/_set/_unsetmanage it throughjq, validating and writing atomically.advisor targets,advisor target_set,advisor target_unsetexpose it inhex_cli.2. An upgraded node silently dropped off the fleet. CubeCOS upgrades by booting the other partition, carrying across only what a module registers — and
config_advisor.cppregistered nothing, so an enrolled node came up with no agent and no identity, looking exactly like one that was never enrolled.Commithook with the standardIsUndef/CommitCheckguard callingSystemdCommitService(IsEnrolled(), …), ordered after networking viaCONFIG_REQUIRES(advisor, net_static).advisor_agent_service_enablebecameadvisor_agent_service_start: it starts the unit and never enables it, so hex_config stays the only owner.cluster checkabout the advisor — a cluster-wide check naming any enrolled node whose agent is not running, repairable manually and automatically.Which issue(s) this PR fixes
Fixes #1481
Fixes #1483
The API and UI need the component declared to display it: bigstack-oss/cube-cos-api#659, bigstack-oss/cube-cos-ui#873.
Special notes for your reviewer
Verified on hardware (1cc r630,
cube-combined). A node was enrolled on the previous image, then upgraded into this one. Without a second reboot:agent.crt/agent.key/enrollment-ca.crt/serverintact, binary sha unchangedis-enabledis-activemulti-user.targetsystemd-hexctl-sys.serviceResult=success, no boot stallCommitcluster checkClusterSys … advisor(v)agent: connected to <advisor>:8443after the framework came backTrusting the Advisor's CA at enrolment. An Advisor serving its own certificate — the normal case offline, where there is no public CA to lean on — could not be enrolled against at all: the release fetch died at
curl: (60) SSL certificate problem: self-signed certificatebefore any signature was checked, and the agent that follows speaks to the same endpoint through Go's TLS. Both read the system trust store and neither takes a CA path.advisor_trust_cainstalls a CA the operator supplies out of band, as an optional third argument toadvisor enroll.-kwas never the alternative — the pairing token is a bearer credential on that request. A file that is not a certificate is refused at the door rather than installed to fail every later handshake, and an anchor whose trust store will not rebuild is removed rather than left claiming a trust that is not in effect.It is deliberately not migrated across an upgrade: this is enrolment-time trust, while the tunnel's trust is the enrollment CA the agent pins from its identity directory, which is migrated. Confirmed on the r630 — after the upgrade that dropped this anchor, the agent had reconnected on its own while
curlto the same address still refused. Verified live:REFUSED (self-signed)→VERIFIES, with the releases endpoint then answering401instead of failing at TLS. This is the node-side half of bigstack-oss/cube-ai-advisor#215.Why discovery never ran, and what fixed it. That same hardware run exposed a defect this PR introduced:
app_ingress_addressandapp_helm_release_deployedboth refused unless/opt/appfw/kubeconfigwas readable, and nothing creates that file —app_framework_deployfetches its own from rancher into a temp file and discards it. Both helpers therefore answered "no framework" on every cluster, soadvisor_targets_discoverreturned at its first line and no install or enrolment ever declaredcube-cmporapp-fw-idp. The node under test came up with acube-cos-only allowlist.Their unit tests passed throughout, because those tests point
APPFW_KUBECONFIGat a fixture — they stubbed the exact thing that was broken.app_kubeconfignow fetches the kubeconfig the way the product does, reading the framework's name back from rancher rather than assuming it (a driver-installed framework isappfw;app_framework_deploy's own isapp-framework).APPFW_RANCHERjoinsAPPFW_KUBECONFIGas an injectable path, because a hardcoded absolute path is one no test can reach. Confirmed on the same node: discovery now writescube-cmpandapp-fw-idp→10.32.1.110:443, andadvisor statusreports 3 targets.Why there is no
systemctlcall in the migrate path. Earlier revisions had one, wrong three times:enable --nowfrom a post-migrate hook deadlocks the boot (it runs insiderc.sysinit, which isBefore=sysinit.targetwithTimeoutSec=infinity, waiting on a job that cannot run until it returns); plainenablenever starts anything (thedefault.targettransaction is computed at manager startup, so a later.wantssymlink adds no job);enable+start --no-blockworks and is still not how this codebase does it. hex_config owns service lifecycle, so the hook was deleted entirely.Why there is no release stash. An earlier revision stashed the signed release so the new partition could reinstall and re-verify offline. It sat on the same root-writable filesystem as the binary, so re-verification defended against nothing an attacker could not also reach; its only real effect — a rotated key disabling old agents at upgrade — is revocation, which belongs at the SaaS. Migrating the binary is simpler and removes a failure mode.
The allowlist read path uses
jq, not a regex. The first implementation hand-parsed it, and a file with"key": "value"— a space after the colon, what an editor orjq .produces — madelistreturn nothing, after whichsetrebuilt the file from that empty list and silently deleted every entry. Silent data loss on a security allowlist.jqalready ships in the image and 26 othersdk_shmodules use it; a file that does not parse is now refused rather than clobbered.Seeding is not reconciling.
Commitwrites the allowlist only when the file is absent — it never adds, removes or reconciles entries in one that exists, becauseadvisor target_unset cube-cmpis a deliberate act and a commit that quietly put it back would make the command meaningless. "Self-healing" means a node with no allowlist gets one, not every node reconciled to a canonical set. A test pins it: makingCommitreconcile shows the operator's removed entry reappearing.Where
cube-cospoints. The seed used to be127.0.0.1:8080. That port is httpd, which answers 403 to everything -- nginx serves the dashboard on the management address. A target pointed at loopback looks configured and refuses every request, with nothing in any log to say why; in the browser it presented as the proxied tab showing "403 Forbidden".advisor_dashboard_addressresolves where the UI actually answers -- the control VIP on an HA cluster, this node's management address otherwise -- and when neither is known the seed omitscube-cosentirely rather than writing an address that cannot work;advisor target_setadds it once the address exists. The installer half of the same bug is bigstack-oss/cube-cos-driver#116.The endpoints the dashboard links out to are targets too. CubeCOS serves Keycloak on
:10443, Skyline on:9999and the Ceph dashboard on:7443, and the dashboard links the browser at all three -- so a node that allowscube-cosand not those allows links that cannot be followed, and a login that cannot complete.advisor_own_targetsreturns the whole set the node can name for itself, one name and address per line, which the seed renders the way it already renders the discovered ones: one function to stub in the tests rather than one per port, and one place to add the next endpoint. None of them can be discovered by reading what the cluster serves -- the address arrives as a bare string in/api/v1/datacentersand script appends the port in the browser. The proxy-side support is bigstack-oss/cube-ai-advisor#227.Why per-node seeding exists. The agent runs on every node and each reads its own local allowlist, but seeding used to happen only inside
advisor_enroll, on one control node. On the lab cluster the allowlist existed on one node and was absent on the other two, so a session routed to either refused every target.Commitruns during each node's own hex_config commit, so a node that was down or added later gets one without anyone intervening.Who sets the targets, and what is reachable. Seeding covers
cube-cosand, where the app framework is present, the two CMP targets. Anything else is operator-declared; nothing else auto-discovers, deliberately. On a cluster with both an appfw-hosted portal and a separately-installed CMP, a guess would pointcube-cmpat the wrong host, surfacing as a proxied page whose OIDCredirect_uridoes not match — a broken login that reads as a proxy bug. An absent target is a better failure than a wrong one.Reachability is whatever the node can route to. The agent dials with no interface binding, so it inherits the node's full routing table, and CubeCOS nodes are multi-homed — a CMP on a provider network is reachable if the node has an interface on it. The SaaS never learns the address; it only ever sends the symbolic name. The check before filing a ticket: if you can
curlit from the node, the web console can reach it. A CMP the node has no route to cannot be reached at all — the agent is the only path in, by design.Health check semantics.
cluster checkruns only on control nodes but the agent runs on every node, so the check fans out acrossCUBE_NODE_LIST_HOSTNAMES. Codes: 0 no node enrolled — healthy, since most clusters never roll into the Advisor; 1 an enrolled node whose agent is down, named explicitly, auto-repaired by starting it; 2 enrolled with the binary missing, needing re-enrolment. Auto-repair deliberately skips code 2: the failure count only resets on a passing check, so retrying an unfixable state would burn themaxerrbudget and gate auto-repair off permanently.enabledis identity-only. Every module this idiom came from also filters onIsControl; this one does not, and a test fails if that filter is reintroduced.Coverage gaps, stated rather than hidden. The stub cannot reach
CommitCheck's non-bootstrap branch, so only theIsBootstrap()path is pinned.CONFIG_REQUIRES(advisor, net_static)is argued, not observed — resolution happens at static init of a fully linked binary, and an unknown name isHexLogFatal, so a jail build fails loudly. Several behaviours are pinned only bycore/sdk_sh/tests/*.sh, which CI never runs (#1484). Building these files needs gcc-toolset-11.Additional documentation
🤖 Generated with Claude Code
https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj