Skip to content

fix(advisor): own the web-targets allowlist, and carry enrolment across an upgrade - #1482

Open
traviswu-bigstack wants to merge 15 commits into
developfrom
travis.wu/advisor-web-targets
Open

traviswu-bigstack wants to merge 15 commits into
developfrom
travis.wu/advisor-web-targets

Conversation

@traviswu-bigstack

@traviswu-bigstack traviswu-bigstack commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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-agent only 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's Commit seeds it on every node, so each node has its own: cube-cos → this cluster's own dashboard, plus cube-cmp and app-fw-idp<ingress>:443 when 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.
  • The ingress address is discovered once and distributed. advisor_targets_discover fetches a framework kubeconfig from rancher, resolves the address, and writes it to every node as a short string; each node's Commit reads it locally. No node needs a kubeconfig at commit time.
  • advisor_targets_list/_set/_unset manage it through jq, validating and writing atomically.
  • advisor targets, advisor target_set, advisor target_unset expose it in hex_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.cpp registered nothing, so an enrolled node came up with no agent and no identity, looking exactly like one that was never enrolled.

  • Migrate what an enrolled node needs: the agent binary, its identity, the allowlist, the console CA and its sshd drop-in.
  • Put the service under hex_config, where every other cubecos service lives — a Commit hook with the standard IsUndef/CommitCheck guard calling SystemdCommitService(IsEnrolled(), …), ordered after networking via CONFIG_REQUIRES(advisor, net_static). advisor_agent_service_enable became advisor_agent_service_start: it starts the unit and never enables it, so hex_config stays the only owner.
  • Teach cluster check about 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:

Check Result
Identity, binary migrated agent.crt/agent.key/enrollment-ca.crt/server intact, binary sha unchanged
is-enabled disabled — enabled is the failure signal
is-active active
Who started it hex_config's commit pass, not multi-user.target
systemd-hexctl-sys.service Result=success, no boot stall
Allowlist seeded by Commit created on first boot of the new slot
cluster check ClusterSys … advisor(v)
Agent rejoined unattended agent: connected to <advisor>:8443 after the framework came back

Trusting 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 certificate before 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_ca installs a CA the operator supplies out of band, as an optional third argument to advisor enroll.

-k was 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 curl to the same address still refused. Verified live: REFUSED (self-signed)VERIFIES, with the releases endpoint then answering 401 instead 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_address and app_helm_release_deployed both refused unless /opt/appfw/kubeconfig was readable, and nothing creates that fileapp_framework_deploy fetches its own from rancher into a temp file and discards it. Both helpers therefore answered "no framework" on every cluster, so advisor_targets_discover returned at its first line and no install or enrolment ever declared cube-cmp or app-fw-idp. The node under test came up with a cube-cos-only allowlist.

Their unit tests passed throughout, because those tests point APPFW_KUBECONFIG at a fixture — they stubbed the exact thing that was broken. app_kubeconfig now fetches the kubeconfig the way the product does, reading the framework's name back from rancher rather than assuming it (a driver-installed framework is appfw; app_framework_deploy's own is app-framework). APPFW_RANCHER joins APPFW_KUBECONFIG as an injectable path, because a hardcoded absolute path is one no test can reach. Confirmed on the same node: discovery now writes cube-cmp and app-fw-idp10.32.1.110:443, and advisor status reports 3 targets.

Why there is no systemctl call in the migrate path. Earlier revisions had one, wrong three times: enable --now from a post-migrate hook deadlocks the boot (it runs inside rc.sysinit, which is Before=sysinit.target with TimeoutSec=infinity, waiting on a job that cannot run until it returns); plain enable never starts anything (the default.target transaction is computed at manager startup, so a later .wants symlink adds no job); enable + start --no-block works 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 or jq . produces — made list return nothing, after which set rebuilt the file from that empty list and silently deleted every entry. Silent data loss on a security allowlist. jq already ships in the image and 26 other sdk_sh modules use it; a file that does not parse is now refused rather than clobbered.

Seeding is not reconciling. Commit writes the allowlist only when the file is absent — it never adds, removes or reconciles entries in one that exists, because advisor target_unset cube-cmp is 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: making Commit reconcile shows the operator's removed entry reappearing.

Where cube-cos points. The seed used to be 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 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_address resolves 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 omits cube-cos entirely rather than writing an address that cannot work; advisor target_set adds 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 :9999 and the Ceph dashboard on :7443, and the dashboard links the browser at all three -- so a node that allows cube-cos and not those allows links that cannot be followed, and a login that cannot complete. advisor_own_targets returns 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/datacenters and 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. Commit runs 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-cos and, 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 point cube-cmp at the wrong host, surfacing as a proxied page whose OIDC redirect_uri does 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 curl it 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 check runs only on control nodes but the agent runs on every node, so the check fans out across CUBE_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 the maxerr budget and gate auto-repair off permanently.

enabled is identity-only. Every module this idiom came from also filters on IsControl; 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 the IsBootstrap() 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 is HexLogFatal, so a jail build fails loudly. Several behaviours are pinned only by core/sdk_sh/tests/*.sh, which CI never runs (#1484). Building these files needs gcc-toolset-11.

Additional documentation

advisor enroll [<url> [<version> [<ca-file>]]]
                                         — ca-file is optional; needed when the
                                           Advisor serves its own certificate
advisor targets                          — list what this node allows
advisor target_set <name> <host:port>    — allow an endpoint; address is whatever the node can route to
advisor target_unset <name>              — stop allowing it; nothing puts it back
cluster check                            — reports the advisor module under Cluster System
cluster check_repair                     — starts a stopped agent on any enrolled node

🤖 Generated with Claude Code

https://claude.ai/code/session_01PZ5umjjCedZwWtbAbiMjfj

@traviswu-bigstack
traviswu-bigstack force-pushed the travis.wu/advisor-web-targets branch from 475163a to c427c02 Compare September 12, 2026 12:39
@traviswu-bigstack traviswu-bigstack changed the title fix(advisor): give the node-side web-targets allowlist an owner fix(advisor): own the web-targets allowlist, and carry enrolment across an upgrade Sep 14, 2026
Comment thread core/modules/tests/config_advisor/stub/cli_driver.cpp Fixed
Comment thread core/modules/tests/config_advisor/stub/cli_driver.cpp Fixed
Comment thread core/modules/tests/config_advisor/stub/driver.cpp Fixed
traviswu-bigstack and others added 15 commits September 22, 2026 02:11
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
traviswu-bigstack force-pushed the travis.wu/advisor-web-targets branch from 7e68aaa to fe33643 Compare September 21, 2026 18:11
@traviswu-bigstack traviswu-bigstack added done Merge the pull request and removed done Merge the pull request labels Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

done Merge the pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] An upgraded node silently drops off the Advisor fleet [Bug] Nothing creates the Advisor agent's web-targets allowlist

2 participants