feat(nodectl): entity CRUD via REST API#88
Conversation
…config-management-through-rest-api
There was a problem hiding this comment.
Pull request overview
This PR continues the “centralized config management” effort (SMA-69) by moving entity CRUD (nodes, wallets, pools, bindings) from direct config-file mutation in nodectl into daemon-backed REST API endpoints, and refactors the CLI + integration script accordingly.
Changes:
- Add new REST handlers for listing and CRUD mutations of nodes/wallets/pools/bindings, plus “elections settings”, “log”, and “master wallet” read endpoints.
- Refactor multiple
nodectl config ...subcommands to become thin REST clients (URL/token resolution, JSON DTO parsing, etc.). - Update the singlehost network bootstrap script to generate vault secrets earlier, set up auth/JWT, and adjust phase flow/validation.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/node/tests/test_run_net_py/run_singlehost_nodectl.py | Updates CI/bootstrap flow for auth + REST-based config and stake validation. |
| src/node-control/service/src/runtime_config.rs | Adds concurrent ADNL config resolution helper; exposes open_wallet for internal use. |
| src/node-control/service/src/http/mod.rs | Exposes the new config_handlers module. |
| src/node-control/service/src/http/http_server_task.rs | Wires new REST routes + OpenAPI types; widens AppError constructors for reuse. |
| src/node-control/service/src/http/config_handlers.rs | Introduces config REST endpoints (list + CRUD mutations) and related DTOs. |
| src/node-control/common/src/ton_utils.rs | Moves/introduces normalize_ton_address and adds unit tests. |
| src/node-control/common/src/app_config.rs | Adds OpenAPI schema derives for log-related enums behind openapi feature. |
| src/node-control/commands/src/commands/nodectl/utils.rs | Adds shared service-API helpers (URL resolution, GET/POST/DELETE wrappers). |
| src/node-control/commands/src/commands/nodectl/service_api_cmd.rs | Improves base URL normalization and adds tests. |
| src/node-control/commands/src/commands/nodectl/master_wallet_cmd.rs | Switches master-wallet info command to call service API. |
| src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs | Switches wallet add/ls/rm to service API (send/stake still require local config). |
| src/node-control/commands/src/commands/nodectl/config_pool_cmd.rs | Switches pool add/ls/rm to service API and simplifies table rendering. |
| src/node-control/commands/src/commands/nodectl/config_node_cmd.rs | Switches node add/ls/rm to service API; keeps best-effort vault warning. |
| src/node-control/commands/src/commands/nodectl/config_log_cmd.rs | Switches log ls to service API (log set remains local-config based). |
| src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs | Switches elections “show” to service API and updates table formatting. |
| src/node-control/commands/src/commands/nodectl/config_cmd.rs | Makes --config optional and adds global --url/--token passthrough for REST-backed subcommands. |
| src/node-control/commands/src/commands/nodectl/config_bind_cmd.rs | Switches binding add/ls/rm to service API. |
Comments suppressed due to low confidence (1)
src/node/tests/test_run_net_py/run_singlehost_nodectl.py:677
electionscan still beNoneafter the polling loop (e.g., if_fetch_nodectl_elections()keeps failing). In that caseself._compare_stakes(elections, elector_map)will raise because_compare_stakescallselections.get(...). Add an explicitif elections is None: ...handling (fail or return) after the loop before calling_compare_stakes.
elector_map = self._fetch_elector_stake_map()
if elector_map is None:
return
self._compare_stakes(elections, elector_map)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ) | ||
| os.environ["NODECTL_API_TOKEN"] = json.loads(result.stdout)["token"] | ||
| self.log.info(" Logged in and exported NODECTL_API_TOKEN") | ||
| self.log.info(" NODECTL_API_TOKEN=" + os.environ["NODECTL_API_TOKEN"]) |
There was a problem hiding this comment.
The test logs the full NODECTL_API_TOKEN value (self.log.info("NODECTL_API_TOKEN=" + ...)). This exposes a bearer token in CI logs and makes it easy to accidentally leak credentials when rerunning locally. Prefer not logging the token at all (or log only a short prefix/suffix).
| self.log.info(" NODECTL_API_TOKEN=" + os.environ["NODECTL_API_TOKEN"]) |
| let mut base = url.to_string(); | ||
| if base.starts_with("0.0.0.0") { | ||
| base = base.replacen("0.0.0.0", "127.0.0.1", 1); | ||
| } | ||
| if !base.starts_with("http://") && !base.starts_with("https://") { | ||
| base = format!("http://{}", base); | ||
| } | ||
| base |
There was a problem hiding this comment.
normalize_base_url only replaces 0.0.0.0 when the string literally starts with 0.0.0.0. If the user passes --url http://0.0.0.0:8080/https://..., this won’t be normalized and the client will try to connect to 0.0.0.0. Consider trimming the scheme before the starts_with("0.0.0.0") check (same approach as in service_api_cmd.rs) or centralizing URL normalization in one helper.
| let mut base = url.to_string(); | |
| if base.starts_with("0.0.0.0") { | |
| base = base.replacen("0.0.0.0", "127.0.0.1", 1); | |
| } | |
| if !base.starts_with("http://") && !base.starts_with("https://") { | |
| base = format!("http://{}", base); | |
| } | |
| base | |
| let (scheme, rest) = if let Some(stripped) = url.strip_prefix("http://") { | |
| ("http://", stripped) | |
| } else if let Some(stripped) = url.strip_prefix("https://") { | |
| ("https://", stripped) | |
| } else { | |
| ("http://", url) | |
| }; | |
| let normalized_rest = if rest.starts_with("0.0.0.0") { | |
| rest.replacen("0.0.0.0", "127.0.0.1", 1) | |
| } else { | |
| rest.to_string() | |
| }; | |
| format!("{}{}", scheme, normalized_rest) |
| println!("{}", serde_json::to_string_pretty(result)?); | ||
| } | ||
| OutputFormat::Table => { | ||
| let log = serde_json::from_value::<LogConfig>(result.clone()).unwrap_or_default(); |
There was a problem hiding this comment.
In table output mode, LogLsCmd silently falls back to LogConfig::default() when the API response can’t be parsed. That can hide server-side/schema bugs and print misleading configuration. Prefer returning an error (or at least surfacing the parse error) instead of defaulting.
| let log = serde_json::from_value::<LogConfig>(result.clone()).unwrap_or_default(); | |
| let log = serde_json::from_value::<LogConfig>(result.clone()).map_err(|err| { | |
| anyhow::anyhow!( | |
| "failed to parse /v1/log response result as LogConfig: {err}; payload: {}", | |
| result | |
| ) | |
| })?; |
| cfg.nodes.insert(name, adnl_config); | ||
| }) | ||
| .map_err(|e| AppError::internal(e.to_string()))?; | ||
| state.runtime_cfg.save_to_file(); |
There was a problem hiding this comment.
All mutation handlers call runtime_cfg.save_to_file(), but that method only logs write/serialization errors and doesn’t report failure back to the caller. This means the API can return success even when persistence failed (changes would be lost on restart). Consider making persistence fallible (return Result) and mapping failures to AppError::internal.
| state.runtime_cfg.save_to_file(); | |
| state | |
| .runtime_cfg | |
| .save_to_file() | |
| .map_err(|e| AppError::internal(e.to_string()))?; |
| .runtime_cfg | ||
| .update_with(|cfg| { | ||
| cfg.nodes.insert(name, adnl_config); | ||
| }) | ||
| .map_err(|e| AppError::internal(e.to_string()))?; |
There was a problem hiding this comment.
After update_with mutations (adding/removing nodes/wallets/pools/bindings), the service’s derived runtime state (opened wallets/pools, etc.) is not rebuilt. RuntimeConfigStore::update_with swaps only the AppConfig snapshot while keeping cached wallets/pools in RuntimeState, and there is no task restart here (unlike elections include/exclude). This can leave the daemon running with stale caches and tasks that never observe the new entities; consider triggering a reload/rebuild or restarting affected tasks after a successful mutation.
There was a problem hiding this comment.
I will fix hot reload in the next PR.
| if !state.runtime_cfg.get().nodes.contains_key(&name) { | ||
| return Err(AppError::not_found(format!("node '{name}' not found"))); | ||
| } | ||
|
|
There was a problem hiding this comment.
v1_nodes_rm_handler removes a node without checking whether a binding exists for that node name. Since v1_bindings_add_handler enforces that a binding’s node must exist, allowing node deletion can create an inconsistent config state (binding refers to a non-existent node). Consider rejecting deletion when a binding exists or removing the binding atomically as part of node deletion.
| // Each handler validates input against the live config, applies the change via | ||
| // `RuntimeConfigStore::update_with` (atomic Arc swap), then persists with | ||
| // `save_to_file`. Validation errors map to 400, missing entities to 404, | ||
| // I/O failures to 500. All routes are mounted behind `require_operator`. | ||
| // --------------------------------------------------------------------------- |
There was a problem hiding this comment.
New CRUD mutation endpoints are introduced here (POST/DELETE for nodes/wallets/pools/bindings), but there don’t appear to be accompanying handler-level tests similar to the existing HTTP handler tests in http_server_task.rs. Adding coverage for success + key validation failures (400/404) + require_operator enforcement would help prevent regressions.
There was a problem hiding this comment.
Created a separate task for tests.
…e errors, check bindings on node rm
…ture/sma-69-centralize-config-management-entity-crud-via-rest-api
| control_server_endpoint: &'a str, | ||
| control_server_pubkey: &'a str, | ||
| control_client_secret: &'a str, | ||
| } |
There was a problem hiding this comment.
are you sure lifetimes are really needed here? i think there are almost no alloc here and it could be done more simply with String
There was a problem hiding this comment.
I use refs to avoid string clones.
| @@ -465,7 +460,7 @@ impl RuntimeConfig for RuntimeConfigStore { | |||
| self.update_with(f) | |||
There was a problem hiding this comment.
check if any old wallet, pools, rpc_client data remains after change config
There was a problem hiding this comment.
Created atomic function to 1)save config to disk and 2) swap runtime state.
| let name = req.name.clone(); | ||
| state | ||
| .runtime_cfg | ||
| .update_with(|cfg| { |
There was a problem hiding this comment.
config is mutated in memory before persistence. If save_to_file() fails, we return error 500, but leave the live runtime state changed and out of sync. I think worth thinking about it's a little
There was a problem hiding this comment.
Fixed. Joint save_to_file and update_with into atomic operation which returns error on failure.
|
|
||
| /// Sends a POST request with a JSON body and returns the response body. | ||
| pub async fn api_post<B: serde::Serialize>( | ||
| base_url: &str, |
There was a problem hiding this comment.
we usually write generics via where T
…_and_save for atomic config updates
Summary
Part 2 of centralizing config management. Moves the 8 entity mutation commands (
config {node,wallet,pool,bind} {add,rm}) from local config-file writes to REST endpoints on the daemon.POST /v1/{nodes,wallets,pools,bindings}andDELETE /v1/{nodes,wallets,pools,bindings}/{name}, all behindrequire_operator.RuntimeConfigStore::update_withand persists withsave_to_file.add/rmbecome thin REST clients;--url/--token/NODECTL_API_TOKENwork the same as in SMA-19. Best-effort vault check kept in CLI fornode add/wallet adduntil the key REST API lands.normalize_ton_addressmoved fromcommandstocommon::ton_utils.Notes
Closes SMA-69