From e8fd5645d9e7933819604ca7ca8662c8863247af Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:57:54 +0300 Subject: [PATCH 1/7] feat(service,commands): settings mutations via REST API - Unified POST /v1/elections/settings for stake-policy, tick-interval, max-factor - Unified POST /v1/ton-http-api with append flag for set/add - POST /v1/log for log settings - Added save_to_file to elections exclude/include handlers - Removed config stake-policy alias, moved vault_secret_missing to utils - CI: moved ton-http-api set from phase 2 to phase 8 --- .../src/commands/nodectl/config_cmd.rs | 132 +------- .../commands/nodectl/config_elections_cmd.rs | 188 +++++----- .../src/commands/nodectl/config_log_cmd.rs | 98 +++--- .../src/commands/nodectl/config_node_cmd.rs | 16 +- .../nodectl/config_ton_http_api_cmd.rs | 109 +++--- .../src/commands/nodectl/config_wallet_cmd.rs | 11 +- .../src/commands/nodectl/service_api_cmd.rs | 24 +- .../commands/src/commands/nodectl/utils.rs | 10 + .../service/src/http/auth_tests.rs | 14 +- .../service/src/http/config_handlers.rs | 320 +++++++++++++++++- .../service/src/http/http_server_task.rs | 121 ++----- .../test_run_net_py/run_singlehost_nodectl.py | 7 +- 12 files changed, 595 insertions(+), 455 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_cmd.rs index f34a20b8..45545512 100644 --- a/src/node-control/commands/src/commands/nodectl/config_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_cmd.rs @@ -7,25 +7,17 @@ * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ use super::{ - config_bind_cmd::BindCmd, - config_elections_cmd::ElectionsCfgCmd, - config_log_cmd::LogCmd, - config_node_cmd::NodeCmd, - config_pool_cmd::PoolCmd, - config_ton_http_api_cmd::TonHttpApiCmd, - config_wallet_cmd::WalletCmd, - master_wallet_cmd::MasterWalletCmd, - utils::{require_config, save_config}, + config_bind_cmd::BindCmd, config_elections_cmd::ElectionsCfgCmd, config_log_cmd::LogCmd, + config_node_cmd::NodeCmd, config_pool_cmd::PoolCmd, config_ton_http_api_cmd::TonHttpApiCmd, + config_wallet_cmd::WalletCmd, master_wallet_cmd::MasterWalletCmd, utils::save_config, }; -use anyhow::Context; use common::{ TonWalletVersion, app_config::{ - AppConfig, ElectionsConfig, HttpConfig, KeyConfig, LogConfig, StakePolicy, - TonHttpApiConfig, WalletConfig, + AppConfig, ElectionsConfig, HttpConfig, KeyConfig, LogConfig, TonHttpApiConfig, + WalletConfig, }, task_cancellation::CancellationCtx, - ton_utils::tons_f64_to_nanotons, }; use std::{collections::HashMap, path::Path}; @@ -86,8 +78,6 @@ pub enum ConfigAction { Elections(ElectionsCfgCmd), /// Manage log configuration Log(LogCmd), - /// Set the stake policy (shortcut for `elections stake-policy`) - StakePolicy(StakePolicyCmd), } #[derive(clap::Args, Clone)] @@ -101,26 +91,6 @@ pub struct GenerateCmd { force: bool, } -#[derive(clap::Args, Clone)] -pub struct StakePolicyCmd { - #[arg(long = "fixed", conflicts_with_all = ["split50", "minimum", "adaptive_split50"], help = "Fixed stake amount in TON")] - fixed: Option, - #[arg(long = "split50", conflicts_with_all = ["fixed", "minimum", "adaptive_split50"], help = "Use 50% of available balance")] - split50: bool, - #[arg(long = "minimum", conflicts_with_all = ["fixed", "split50", "adaptive_split50"], help = "Use minimum required stake")] - minimum: bool, - #[arg(long = "adaptive-split50", conflicts_with_all = ["fixed", "split50", "minimum"], help = "Adaptive split: splits when half exceeds effective minimum, otherwise stakes all")] - adaptive_split50: bool, - #[arg( - short = 'n', - long = "node", - help = "Apply policy only to this node (override). Omit to set the default policy for all nodes." - )] - node: Option, - #[arg(long = "reset", help = "Remove a per-node policy override (requires --node)")] - reset: bool, -} - impl ConfigCmd { pub async fn run(&self, cancellation_ctx: CancellationCtx) -> anyhow::Result<()> { let url = self.url.as_deref(); @@ -133,11 +103,10 @@ impl ConfigCmd { ConfigAction::Wallet(cmd) => cmd.run(config_path, cancellation_ctx, url, token).await, ConfigAction::Pool(cmd) => cmd.run(config_path, url, token).await, ConfigAction::Bind(cmd) => cmd.run(config_path, url, token).await, - ConfigAction::TonHttpApi(cmd) => cmd.run(require_config(config_path)?).await, + ConfigAction::TonHttpApi(cmd) => cmd.run(url, token, config_path).await, ConfigAction::MasterWallet(cmd) => cmd.run(url, token, config_path).await, ConfigAction::Elections(cmd) => cmd.run(config_path, url, token).await, ConfigAction::Log(cmd) => cmd.run(config_path, url, token).await, - ConfigAction::StakePolicy(cmd) => cmd.run(require_config(config_path)?).await, } } } @@ -175,92 +144,3 @@ impl GenerateCmd { Ok(()) } } - -impl StakePolicyCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - - // Handle clearing a per-node override - if self.reset { - let node_id = self - .node - .as_ref() - .ok_or_else(|| anyhow::anyhow!("--reset requires --node "))?; - config - .elections - .as_mut() - .ok_or_else(|| anyhow::anyhow!("Elections are not configured"))? - .policy_overrides - .remove(node_id); - save_config(&config, path).context("failed to write config file")?; - let result = StakePolicyClearResult { - ok: true, - node: node_id.clone(), - policy: config.elections.as_ref().map(|e| e.policy.clone()).unwrap_or_default(), - }; - println!("{}", serde_json::to_string_pretty(&result)?); - return Ok(()); - } - - let policy = if let Some(tons) = self.fixed { - StakePolicy::Fixed(tons_f64_to_nanotons(tons)) - } else if self.split50 { - StakePolicy::Split50 - } else if self.minimum { - StakePolicy::Minimum - } else if self.adaptive_split50 { - StakePolicy::AdaptiveSplit50 - } else { - anyhow::bail!( - "No policy specified. Use --fixed, --split50, --minimum, or --adaptive-split50" - ); - }; - - // Update elections config - if let Some(elections) = &mut config.elections { - if let Some(node_id) = &self.node { - elections.policy_overrides.insert(node_id.clone(), policy.clone()); - } else { - // Default policy for all nodes - elections.policy = policy.clone(); - } - } else { - if self.node.is_some() { - anyhow::bail!( - "Elections are not configured. Set a default policy first before adding per-node overrides." - ); - } - config.elections = - Some(ElectionsConfig { policy: policy.clone(), ..Default::default() }); - } - - save_config(&config, path)?; - - let result = StakePolicyResult { - ok: true, - config: path.display().to_string(), - node: self.node.clone(), - policy, - }; - - println!("{}", serde_json::to_string_pretty(&result)?); - Ok(()) - } -} - -#[derive(serde::Serialize)] -struct StakePolicyResult { - ok: bool, - config: String, - /// If set, the policy was applied as a per-node override. - #[serde(skip_serializing_if = "Option::is_none")] - node: Option, - policy: StakePolicy, -} - -#[derive(serde::Serialize)] -struct StakePolicyClearResult { - ok: bool, - node: String, - policy: StakePolicy, -} diff --git a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs index d428b8dc..74c96dbe 100644 --- a/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_elections_cmd.rs @@ -8,17 +8,14 @@ */ use crate::commands::nodectl::{ output_format::OutputFormat, - utils::{ - api_get, fetch_network_max_factor, require_config, resolve_service_url, save_config, - try_create_rpc_client, - }, + utils::{api_get, api_post, resolve_service_url}, }; use colored::Colorize; use common::{ - app_config::{AppConfig, BindingStatus, ElectionsConfig, StakePolicy}, + app_config::{BindingStatus, StakePolicy}, ton_utils::tons_f64_to_nanotons, }; -use std::{collections::HashMap, path::Path}; +use std::collections::HashMap; #[derive(clap::Args, Clone)] #[command(about = "Manage elections configuration")] @@ -104,11 +101,11 @@ impl ElectionsCfgCmd { ) -> anyhow::Result<()> { match &self.action { ElectionsAction::Show(cmd) => cmd.run(url, token, config_path).await, - ElectionsAction::StakePolicy(cmd) => cmd.run(require_config(config_path)?).await, - ElectionsAction::TickInterval(cmd) => cmd.run(require_config(config_path)?).await, - ElectionsAction::MaxFactor(cmd) => cmd.run(require_config(config_path)?).await, - ElectionsAction::Enable(cmd) => cmd.run(require_config(config_path)?).await, - ElectionsAction::Disable(cmd) => cmd.run(require_config(config_path)?).await, + ElectionsAction::StakePolicy(cmd) => cmd.run(url, token, config_path).await, + ElectionsAction::TickInterval(cmd) => cmd.run(url, token, config_path).await, + ElectionsAction::MaxFactor(cmd) => cmd.run(url, token, config_path).await, + ElectionsAction::Enable(cmd) => cmd.run(url, token, config_path).await, + ElectionsAction::Disable(cmd) => cmd.run(url, token, config_path).await, } } } @@ -189,27 +186,43 @@ fn print_elections_settings_table(view: &ElectionsSettingsView) { println!(); } +/// Shared body for `POST /v1/elections/settings` — all fields optional. +#[derive(Default, serde::Serialize)] +struct ElectionsSettingsBody { + #[serde(skip_serializing_if = "Option::is_none")] + policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + node: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + reset: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tick_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_factor: Option, +} + +const ELECTIONS_SETTINGS_PATH: &str = "/v1/elections/settings"; + impl StakePolicySetCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; if self.reset { - let node_id = self - .node - .as_ref() - .ok_or_else(|| anyhow::anyhow!("--reset requires --node "))?; - config - .elections - .as_mut() - .ok_or_else(|| anyhow::anyhow!("Elections are not configured"))? - .policy_overrides - .remove(node_id); - save_config(&config, path)?; + let body = ElectionsSettingsBody { + node: self.node.clone(), + reset: true, + ..Default::default() + }; + api_post(&base_url, ELECTIONS_SETTINGS_PATH, token, &body).await?; println!( - "{} Per-node override for '{}' removed. Default policy: {}", + "{} Per-node override for '{}' removed", "OK".green().bold(), - node_id, - config.elections.as_ref().map(|e| e.policy.to_string()).unwrap_or_default() + self.node.as_deref().unwrap_or("?"), ); return Ok(()); } @@ -228,20 +241,12 @@ impl StakePolicySetCmd { ); }; - if let Some(elections) = &mut config.elections { - if let Some(node_id) = &self.node { - elections.policy_overrides.insert(node_id.clone(), policy.clone()); - } else { - elections.policy = policy.clone(); - } - } else if self.node.is_some() { - anyhow::bail!("Elections are not configured. Set a default policy first."); - } else { - config.elections = - Some(ElectionsConfig { policy: policy.clone(), ..Default::default() }); - } - - save_config(&config, path)?; + let body = ElectionsSettingsBody { + policy: Some(policy.clone()), + node: self.node.clone(), + ..Default::default() + }; + api_post(&base_url, ELECTIONS_SETTINGS_PATH, token, &body).await?; let scope = match &self.node { Some(n) => format!("node '{}'", n), @@ -253,67 +258,80 @@ impl StakePolicySetCmd { } impl TickIntervalCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - config - .elections - .as_mut() - .ok_or_else(|| anyhow::anyhow!("Elections are not configured"))? - .tick_interval = self.seconds; - save_config(&config, path)?; + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + api_post( + &base_url, + ELECTIONS_SETTINGS_PATH, + token, + &ElectionsSettingsBody { tick_interval: Some(self.seconds), ..Default::default() }, + ) + .await?; println!("{} Tick interval set to {} seconds", "OK".green().bold(), self.seconds); Ok(()) } } impl MaxFactorCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - config.elections.as_ref().ok_or_else(|| anyhow::anyhow!("Elections are not configured"))?; - - let rpc_client = try_create_rpc_client(&config).await?; - let network_max_factor = fetch_network_max_factor(&rpc_client).await?; - let elections = config - .elections - .as_mut() - .ok_or_else(|| anyhow::anyhow!("Elections are not configured"))?; - elections.max_factor = self.value; - elections.validate(Some(network_max_factor))?; - save_config(&config, path)?; - println!("{} Max factor set to {}", "OK".green().bold(), self.value); + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + let resp = api_post( + &base_url, + ELECTIONS_SETTINGS_PATH, + token, + &ElectionsSettingsBody { max_factor: Some(self.value), ..Default::default() }, + ) + .await?; + let parsed: serde_json::Value = serde_json::from_str(&resp)?; + let max_factor = parsed["result"]["max_factor"].as_f64(); + match max_factor { + Some(v) => println!("{} Max factor set to {v}", "OK".green().bold()), + None => println!("{} Max factor set to {}", "OK".green().bold(), self.value), + } Ok(()) } } -impl EnableCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; +#[derive(serde::Serialize)] +struct NodeListBody<'a> { + nodes: &'a [String], +} - for node_id in &self.nodes { - let binding = config - .bindings - .get_mut(node_id) - .ok_or_else(|| anyhow::anyhow!("Binding for node '{}' not found", node_id))?; - binding.enable = true; - } - save_config(&config, path)?; +impl EnableCmd { + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + api_post(&base_url, "/v1/elections/include", token, &NodeListBody { nodes: &self.nodes }) + .await?; println!("{} Elections enabled for: {}", "OK".green().bold(), self.nodes.join(", ")); Ok(()) } } impl DisableCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - - for node_id in &self.nodes { - let binding = config - .bindings - .get_mut(node_id) - .ok_or_else(|| anyhow::anyhow!("Binding for node '{}' not found", node_id))?; - binding.enable = false; - } - save_config(&config, path)?; + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + api_post(&base_url, "/v1/elections/exclude", token, &NodeListBody { nodes: &self.nodes }) + .await?; println!("{} Elections disabled for: {}", "OK".green().bold(), self.nodes.join(", ")); Ok(()) } diff --git a/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs index bc713b54..3b3f84ec 100644 --- a/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_log_cmd.rs @@ -8,11 +8,11 @@ */ use crate::commands::nodectl::{ output_format::OutputFormat, - utils::{api_get, require_config, resolve_service_url, save_config}, + utils::{api_get, api_post, resolve_service_url}, }; use colored::Colorize; -use common::app_config::{AppConfig, LogConfig, LogOutput, LogRotation}; -use std::path::{Path, PathBuf}; +use common::app_config::{LogConfig, LogOutput, LogRotation}; +use std::path::PathBuf; /// Manage log configuration #[derive(clap::Args, Clone)] @@ -127,7 +127,7 @@ impl LogCmd { ) -> anyhow::Result<()> { match &self.action { LogAction::Ls(cmd) => cmd.run(url, token, config_path).await, - LogAction::Set(cmd) => cmd.run(require_config(config_path)?).await, + LogAction::Set(cmd) => cmd.run(url, token, config_path).await, } } } @@ -158,60 +158,46 @@ impl LogLsCmd { } } -impl LogSetCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - if self.level.is_none() - && self.path.is_none() - && self.rotation.is_none() - && self.output.is_none() - && self.max_size_mb.is_none() - && self.max_files.is_none() - { - anyhow::bail!( - "No settings specified. Use --level, --path, --rotation, --output, --max-size-mb, or --max-files" - ); - } - - let mut config = AppConfig::load(path)?; - let log = config.log.get_or_insert_with(LogConfig::default); - - let mut changes = Vec::new(); - - if let Some(level) = &self.level { - log.level = level.to_tracing_level(); - changes.push(format!("level = {}", log.level)); - } - if let Some(p) = &self.path { - log.path = Some(p.clone()); - changes.push(format!("path = {}", p.display())); - } - if let Some(rotation) = &self.rotation { - log.rotation = rotation.clone().into(); - changes.push(format!("rotation = {:?}", log.rotation).to_lowercase()); - } - if let Some(output) = &self.output { - log.output = output.clone().into(); - changes.push(format!("output = {:?}", log.output).to_lowercase()); - } - if let Some(max_size) = self.max_size_mb { - log.max_size_mb = max_size; - changes.push(format!("max_size_mb = {}", max_size)); - } - if let Some(max_files) = self.max_files { - log.max_files = max_files; - changes.push(format!("max_files = {}", max_files)); - } +#[derive(serde::Serialize)] +struct LogSetBody { + #[serde(skip_serializing_if = "Option::is_none")] + level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rotation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_size_mb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_files: Option, +} - // Validate: file/all output requires a log path - if matches!(log.output, LogOutput::File | LogOutput::All) && log.path.is_none() { - anyhow::bail!( - "Output mode '{}' requires a log file path. Use --path to set one.", - output_display(&log.output) - ); - } +impl LogSetCmd { + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; - save_config(&config, path)?; - println!("{} Log settings updated: {}", "OK".green().bold(), changes.join(", ")); + let body = LogSetBody { + level: self.level.as_ref().map(|l| format!("{}", l.to_tracing_level())), + path: self.path.as_ref().map(|p| p.display().to_string()), + rotation: self.rotation.as_ref().map(|r| r.clone().into()), + output: self.output.as_ref().map(|o| o.clone().into()), + max_size_mb: self.max_size_mb, + max_files: self.max_files, + }; + + let resp = api_post(&base_url, "/v1/log", token, &body).await?; + let parsed: serde_json::Value = serde_json::from_str(&resp)?; + let result = &parsed["result"]; + let log: LogConfig = serde_json::from_value(result.clone()) + .map_err(|e| anyhow::anyhow!("failed to parse log config: {e}"))?; + print_log_table(&log); Ok(()) } } diff --git a/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs index 3cabbb38..79afd820 100644 --- a/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_node_cmd.rs @@ -8,10 +8,12 @@ */ use crate::commands::nodectl::{ output_format::OutputFormat, - utils::{api_delete, api_get, api_post, resolve_service_url, warn_missing_secret}, + utils::{ + api_delete, api_get, api_post, resolve_service_url, vault_secret_missing, + warn_missing_secret, + }, }; use colored::Colorize; -use secrets_vault::vault_builder::SecretVaultBuilder; #[derive(clap::Args, Clone)] #[command(about = "Manage nodes in the configuration")] @@ -117,16 +119,6 @@ impl NodeAddCmd { } } -/// Best-effort check that returns `true` only if we could reach the local vault -/// AND the secret is definitely absent. Any other outcome (no vault, lookup -/// error) is treated as "unknown" and produces no warning. -async fn vault_secret_missing(secret_name: &str) -> bool { - match SecretVaultBuilder::from_env().await { - Ok(vault) => vault.exists(&secret_name.into()).await.ok() == Some(false), - Err(_) => false, - } -} - #[derive(serde::Serialize, serde::Deserialize)] struct NodeView { name: String, diff --git a/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs index acaea9d9..3a8cf6c1 100644 --- a/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs @@ -6,10 +6,8 @@ * * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -use crate::commands::nodectl::utils::save_config; +use crate::commands::nodectl::utils::{api_post, resolve_service_url}; use colored::Colorize; -use common::app_config::{AppConfig, EndpointEntry}; -use std::path::Path; #[derive(clap::Args, Clone)] #[command(about = "Manage ton-http-api configuration")] @@ -47,66 +45,71 @@ pub struct TonHttpApiAddCmd { } impl TonHttpApiCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { match &self.action { - TonHttpApiAction::Set(cmd) => cmd.run(path).await, - TonHttpApiAction::Add(cmd) => cmd.run(path).await, + TonHttpApiAction::Set(cmd) => cmd.run(url, token, config_path).await, + TonHttpApiAction::Add(cmd) => cmd.run(url, token, config_path).await, } } } -impl TonHttpApiSetCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - let url = self.url.trim().to_string(); - if url.is_empty() { - anyhow::bail!("--url value must not be empty"); - } +/// Shared body for `POST /v1/ton-http-api`. +#[derive(serde::Serialize)] +struct TonHttpApiBody<'a> { + urls: Vec<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + api_key: Option<&'a str>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + append: bool, +} - config.ton_http_api.urls = vec![EndpointEntry::Url(url)]; - config.ton_http_api.api_key = self.api_key.clone(); - save_config(&config, path)?; +fn print_endpoints(resp: &str) -> anyhow::Result<()> { + let parsed: serde_json::Value = serde_json::from_str(resp)?; + let endpoints = parsed["result"]["endpoints"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect::>().join(", ")) + .unwrap_or_default(); + println!("\n{} ton-http-api endpoints: [{}]\n", "OK".green().bold(), endpoints); + Ok(()) +} - let api_key_info = - self.api_key.as_deref().map(|_| ", api_key=***").unwrap_or(", api_key=none"); - println!( - "\n{} ton-http-api set: url='{}'{}\n", - "OK".green().bold(), - config.ton_http_api.urls[0].url(), - api_key_info - ); - Ok(()) +impl TonHttpApiSetCmd { + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + let body = TonHttpApiBody { + urls: vec![self.url.as_str()], + api_key: self.api_key.as_deref(), + append: false, + }; + let resp = api_post(&base_url, "/v1/ton-http-api", token, &body).await?; + print_endpoints(&resp) } } impl TonHttpApiAddCmd { - pub async fn run(&self, path: &Path) -> anyhow::Result<()> { - let mut config = AppConfig::load(path)?; - let new_urls: Vec = - self.urls.iter().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect(); - - if new_urls.is_empty() { - anyhow::bail!("At least one non-empty --url value is required"); - } - - let mut existing = config.ton_http_api.endpoints(); - for url in &new_urls { - if !existing.iter().any(|e| e == url) { - let entry = match &self.api_key { - Some(key) => EndpointEntry::WithKey { url: url.clone(), api_key: key.clone() }, - None => EndpointEntry::Url(url.clone()), - }; - config.ton_http_api.urls.push(entry); - existing.push(url.clone()); - } - } - save_config(&config, path)?; - - println!( - "\n{} ton-http-api endpoints: [{}]\n", - "OK".green().bold(), - config.ton_http_api.endpoints().join(", "), - ); - Ok(()) + pub async fn run( + &self, + url: Option<&str>, + token: Option<&str>, + config_path: Option<&str>, + ) -> anyhow::Result<()> { + let base_url = resolve_service_url(url, config_path)?; + let body = TonHttpApiBody { + urls: self.urls.iter().map(|s| s.as_str()).collect(), + api_key: self.api_key.as_deref(), + append: true, + }; + let resp = api_post(&base_url, "/v1/ton-http-api", token, &body).await?; + print_endpoints(&resp) } } diff --git a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs index 4afa91ef..f9e334ee 100644 --- a/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_wallet_cmd.rs @@ -11,7 +11,7 @@ use crate::commands::nodectl::{ utils::{ SEND_TIMEOUT, api_delete, api_get, api_post, fetch_network_max_factor, get_wallet_config, load_config_vault_rpc_client, make_wallet, require_config, resolve_service_url, - wait_for_seqno_change, wallet_info, warn_missing_secret, + vault_secret_missing, wait_for_seqno_change, wallet_info, warn_missing_secret, }, }; use anyhow::Context; @@ -28,7 +28,6 @@ use contracts::{ nominator, }; use elections::providers::{DefaultElectionsProvider, ElectionsProvider}; -use secrets_vault::vault_builder::SecretVaultBuilder; use std::{io::Write, path::Path}; use ton_block::{Cell, MsgAddressInt, write_boc}; use ton_http_api_client::v2::data_models::AccountState; @@ -185,14 +184,6 @@ impl WalletAddCmd { } } -/// Best-effort vault check; returns true only if vault is reachable and the secret is absent. -async fn vault_secret_missing(secret_name: &str) -> bool { - match SecretVaultBuilder::from_env().await { - Ok(vault) => vault.exists(&secret_name.into()).await.ok() == Some(false), - Err(_) => false, - } -} - #[derive(serde::Serialize, serde::Deserialize)] struct WalletView { name: String, diff --git a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs index b583ce42..756e2641 100644 --- a/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/service_api_cmd.rs @@ -254,11 +254,15 @@ impl ApiCmd { send_post(&client, &url, &payload, token).await?; } ServiceAction::StakePolicy(cmd) => { - let url = join_url(&base_url, "/v1/stake_strategy"); + let url = join_url(&base_url, "/v1/elections/settings"); let Some(policy) = cmd.to_policy() else { anyhow::bail!("no policy specified"); }; - let request = StakePolicyRequest { policy, node: cmd.node.clone() }; + let request = ElectionsSettingsRequest { + policy: Some(policy), + node: cmd.node.clone(), + ..Default::default() + }; send_post(&client, &url, &request, token).await?; } ServiceAction::Login(cmd) => { @@ -350,12 +354,20 @@ fn filter_response_by_nodes( Ok(serde_json::to_string(&value).context("failed to re-serialize filtered response to JSON")?) } -#[derive(Clone, serde::Serialize)] -struct StakePolicyRequest { - policy: StakePolicy, - /// If set, the policy is applied as a per-node override. +/// Client-side mirror of `ElectionsSettingsUpdateRequest` in `service::http::config_handlers`. +/// Must stay in sync with the server-side definition. +#[derive(Clone, Default, serde::Serialize)] +struct ElectionsSettingsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + policy: Option, #[serde(skip_serializing_if = "Option::is_none")] node: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reset: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tick_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_factor: Option, } impl StakePolicyCmd { diff --git a/src/node-control/commands/src/commands/nodectl/utils.rs b/src/node-control/commands/src/commands/nodectl/utils.rs index e1247824..ae1e5b2d 100644 --- a/src/node-control/commands/src/commands/nodectl/utils.rs +++ b/src/node-control/commands/src/commands/nodectl/utils.rs @@ -330,6 +330,16 @@ where Ok(body) } +/// Best-effort check that returns `true` only if we could reach the local vault +/// AND the secret is definitely absent. Any other outcome (no vault, lookup +/// error) is treated as "unknown" and produces no warning. +pub async fn vault_secret_missing(secret_name: &str) -> bool { + match SecretVaultBuilder::from_env().await { + Ok(vault) => vault.exists(&secret_name.into()).await.ok() == Some(false), + Err(_) => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/node-control/service/src/http/auth_tests.rs b/src/node-control/service/src/http/auth_tests.rs index 922a5ad1..3ee1409e 100644 --- a/src/node-control/service/src/http/auth_tests.rs +++ b/src/node-control/service/src/http/auth_tests.rs @@ -16,7 +16,7 @@ use argon2::PasswordHasher; use axum::body::Body; use base64::Engine; use common::{ - app_config::{AuthConfig, StakePolicy, UserEntry}, + app_config::{AuthConfig, UserEntry}, snapshot::SnapshotStore, task_cancellation::CancellationCtx, }; @@ -339,8 +339,8 @@ async fn protected_route_valid_nominator_token_200() { async fn nominator_forbidden_on_operator_route() { let st = state_with_auth().await; let tok = st.jwt_auth.generate("nom", Role::Nominator, 3600).unwrap().0; - let body = StakePolicyRequest { policy: StakePolicy::Minimum, node: None }; - let resp = app(st).oneshot(post_bearer("/v1/stake_strategy", &body, &tok)).await.unwrap(); + let body = serde_json::json!({ "policy": "minimum" }); + let resp = app(st).oneshot(post_bearer("/v1/elections/settings", &body, &tok)).await.unwrap(); assert_eq!(resp.status(), 403); } @@ -348,8 +348,8 @@ async fn nominator_forbidden_on_operator_route() { async fn operator_allowed_on_operator_route() { let st = state_with_auth().await; let tok = st.jwt_auth.generate("op", Role::Operator, 3600).unwrap().0; - let body = StakePolicyRequest { policy: StakePolicy::Fixed(100), node: None }; - let resp = app(st).oneshot(post_bearer("/v1/stake_strategy", &body, &tok)).await.unwrap(); + let body = serde_json::json!({ "policy": { "fixed": 100 } }); + let resp = app(st).oneshot(post_bearer("/v1/elections/settings", &body, &tok)).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -395,8 +395,8 @@ async fn auth_disabled_all_routes_open() { #[tokio::test] async fn auth_disabled_operator_routes_open() { let st = state_no_auth().await; - let body = StakePolicyRequest { policy: StakePolicy::Fixed(100), node: None }; - let resp = app(st).oneshot(post_json("/v1/stake_strategy", &body)).await.unwrap(); + let body = serde_json::json!({ "policy": { "fixed": 100 } }); + let resp = app(st).oneshot(post_json("/v1/elections/settings", &body)).await.unwrap(); assert_eq!(resp.status(), 200); } diff --git a/src/node-control/service/src/http/config_handlers.rs b/src/node-control/service/src/http/config_handlers.rs index 5b2e5fad..37b4b7e9 100644 --- a/src/node-control/service/src/http/config_handlers.rs +++ b/src/node-control/service/src/http/config_handlers.rs @@ -12,13 +12,13 @@ use adnl::common::Timeouts; use common::{ TonWalletVersion, app_config::{ - AdnlConfig, BindingStatus, ElectionsConfig, KeyConfig, LogConfig, LogOutput, LogRotation, - NodeBinding, PoolConfig, StakePolicy, TimeoutVariant, WalletConfig, + AdnlConfig, BindingStatus, ElectionsConfig, EndpointEntry, KeyConfig, LogConfig, LogOutput, + LogRotation, NodeBinding, PoolConfig, StakePolicy, TimeoutVariant, WalletConfig, }, - ton_utils::normalize_ton_address, + ton_utils::{extract_max_factor, normalize_ton_address}, }; use control_client::client_adnl::ControlClientAdnl; -use std::{collections::HashMap, str::FromStr}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; use ton_block::MsgAddressInt; /// `type_id` for ADNL public keys (Ed25519). @@ -121,6 +121,7 @@ pub struct ElectionsSettingsDto { pub policy_overrides: HashMap, pub max_factor: f32, pub tick_interval: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bindings: Vec, } @@ -218,6 +219,65 @@ pub struct EntityRefResponse { pub result: EntityRefDto, } +// --- Generic OK response (no payload) --- + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct OkResponse { + pub ok: bool, +} + +// --- Elections settings mutation --- + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct ElectionsSettingsUpdateRequest { + /// Stake policy to set. Ignored when `reset` is true. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Target node for per-node policy override. Omit to set the default policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub node: Option, + /// Remove the per-node override (requires `node`). Other fields are still applied. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reset: bool, + /// Elections tick interval in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub tick_interval: Option, + /// Max stake factor (validated against network param 17). + #[serde(skip_serializing_if = "Option::is_none")] + pub max_factor: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct TonHttpApiRequest { + pub urls: Vec, + pub api_key: Option, + /// When true, appends to existing endpoints (skipping duplicates). + /// When false (default), replaces all endpoints. + #[serde(default)] + pub append: bool, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct TonHttpApiResult { + pub endpoints: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct TonHttpApiResponse { + pub ok: bool, + pub result: TonHttpApiResult, +} + +#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +pub struct LogSetRequest { + pub level: Option, + pub path: Option, + pub rotation: Option, + pub output: Option, + pub max_size_mb: Option, + pub max_files: Option, +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -1022,3 +1082,255 @@ pub async fn v1_bindings_rm_handler( Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: node } })) } + +// --------------------------------------------------------------------------- +// Settings mutation handlers +// --------------------------------------------------------------------------- + +#[utoipa::path( + post, + path = "/v1/elections/settings", + request_body = ElectionsSettingsUpdateRequest, + responses( + (status = 200, description = "Elections settings updated", body = ElectionsSettingsResponse), + (status = 400, description = "Invalid request or elections not configured", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), + (status = 500, description = "Internal error", body = ApiErrorResponse) + ), + security(("bearerAuth" = [])) +)] +pub async fn v1_elections_settings_update_handler( + state: axum::extract::State, + req: axum::Json, +) -> Result, AppError> { + let req = req.0; + + if req.policy.is_none() && !req.reset && req.tick_interval.is_none() && req.max_factor.is_none() + { + return Err(AppError::bad_request("at least one setting is required")); + } + + let cfg = state.runtime_cfg.get(); + let elections = cfg + .elections + .as_ref() + .ok_or_else(|| AppError::bad_request("elections are not configured"))?; + + // --- Validate max_factor against network param 17 (best-effort) --- + if let Some(value) = req.max_factor { + let network_limit = state + .runtime_cfg + .rpc_client() + .get_config_param(17) + .await + .ok() + .and_then(|p| extract_max_factor(p).ok()); + + let probe = ElectionsConfig { max_factor: value, ..elections.clone() }; + probe.validate(network_limit).map_err(|e| AppError::bad_request(e.to_string()))?; + } + + // --- Validate policy --- + if let Some(ref policy) = req.policy { + if !req.reset && matches!(policy, StakePolicy::Fixed(0)) { + return Err(AppError::bad_request("fixed stake must be > 0")); + } + } + if req.reset && req.node.is_none() { + return Err(AppError::bad_request("reset requires 'node' to be set")); + } + drop(cfg); + + // --- Apply all changes in a single update --- + let policy = req.policy; + let node = req.node; + let reset = req.reset; + let tick_interval = req.tick_interval; + let max_factor = req.max_factor; + + state + .runtime_cfg + .update_with(|cfg| { + if let Some(elections) = &mut cfg.elections { + // Stake policy + if reset { + if let Some(ref node_id) = node { + elections.policy_overrides.remove(node_id); + } + } else if let Some(policy) = policy { + if let Some(ref node_id) = node { + elections.policy_overrides.insert(node_id.clone(), policy); + } else { + elections.policy = policy; + } + } + + if let Some(seconds) = tick_interval { + elections.tick_interval = seconds; + } + if let Some(value) = max_factor { + elections.max_factor = value; + } + } + }) + .map_err(|e| AppError::internal(e.to_string()))?; + state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; + + // Restart elections task so changes take effect immediately. + let task = state.elections_task.clone(); + tokio::spawn(async move { + let _ = task.restart().await; + }); + + // Return updated settings (without bindings — use GET for the full view). + let config = state.runtime_cfg.get(); + let elections = config.elections.as_ref().expect("validated above"); + let dto = ElectionsSettingsDto { + stake_policy: elections.policy.clone(), + policy_overrides: elections.policy_overrides.clone(), + max_factor: elections.max_factor, + tick_interval: elections.tick_interval, + bindings: vec![], + }; + + Ok(axum::Json(ElectionsSettingsResponse { ok: true, result: dto })) +} + +#[utoipa::path( + post, + path = "/v1/ton-http-api", + request_body = TonHttpApiRequest, + responses( + (status = 200, description = "TON HTTP API config updated", body = TonHttpApiResponse), + (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), + (status = 500, description = "Internal error", body = ApiErrorResponse) + ), + security(("bearerAuth" = [])) +)] +pub async fn v1_ton_http_api_handler( + state: axum::extract::State, + req: axum::Json, +) -> Result, AppError> { + let req = req.0; + let urls: Vec = + req.urls.into_iter().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect(); + if urls.is_empty() { + return Err(AppError::bad_request("at least one non-empty url is required")); + } + + let api_key = req.api_key; + let append = req.append; + state + .runtime_cfg + .update_with(|cfg| { + if append { + let existing = cfg.ton_http_api.endpoints(); + for url in &urls { + if !existing.iter().any(|e| e == url) { + let entry = match &api_key { + Some(key) => { + EndpointEntry::WithKey { url: url.clone(), api_key: key.clone() } + } + None => EndpointEntry::Url(url.clone()), + }; + cfg.ton_http_api.urls.push(entry); + } + } + } else { + cfg.ton_http_api.urls = urls.into_iter().map(|u| EndpointEntry::Url(u)).collect(); + cfg.ton_http_api.api_key = api_key; + } + }) + .map_err(|e| AppError::internal(e.to_string()))?; + state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; + + let endpoints = state.runtime_cfg.get().ton_http_api.endpoints(); + Ok(axum::Json(TonHttpApiResponse { ok: true, result: TonHttpApiResult { endpoints } })) +} + +#[utoipa::path( + post, + path = "/v1/log", + request_body = LogSetRequest, + responses( + (status = 200, description = "Log settings updated", body = LogResponse), + (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 401, description = "Not authenticated", body = ApiErrorResponse), + (status = 500, description = "Internal error", body = ApiErrorResponse) + ), + security(("bearerAuth" = [])) +)] +pub async fn v1_log_set_handler( + state: axum::extract::State, + req: axum::Json, +) -> Result, AppError> { + let req = req.0; + if req.level.is_none() + && req.path.is_none() + && req.rotation.is_none() + && req.output.is_none() + && req.max_size_mb.is_none() + && req.max_files.is_none() + { + return Err(AppError::bad_request("at least one setting is required")); + } + + let level = req + .level + .as_deref() + .map(|l| { + tracing::Level::from_str(l) + .map_err(|_| AppError::bad_request(format!("invalid log level: '{l}'"))) + }) + .transpose()?; + + // Pre-validate: file/all output requires a path (check against current + incoming) + if let Some(ref output) = req.output { + if matches!(output, LogOutput::File | LogOutput::All) { + let has_path = req.path.is_some() + || state.runtime_cfg.get().log.as_ref().and_then(|l| l.path.as_ref()).is_some(); + if !has_path { + let mode = match output { + LogOutput::File => "file", + LogOutput::All => "all", + _ => unreachable!(), + }; + return Err(AppError::bad_request(format!( + "output mode '{mode}' requires a log file path" + ))); + } + } + } + + state + .runtime_cfg + .update_with(|cfg| { + let log = cfg.log.get_or_insert_with(LogConfig::default); + if let Some(l) = level { + log.level = l; + } + if let Some(p) = &req.path { + log.path = Some(PathBuf::from(p)); + } + if let Some(r) = req.rotation { + log.rotation = r; + } + if let Some(o) = req.output { + log.output = o; + } + if let Some(s) = req.max_size_mb { + log.max_size_mb = s; + } + if let Some(f) = req.max_files { + log.max_files = f; + } + }) + .map_err(|e| AppError::internal(e.to_string()))?; + state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; + + let config = state.runtime_cfg.get(); + let log = config.log.as_ref().cloned().unwrap_or_default(); + let dto = log_config_to_dto(&log); + Ok(axum::Json(LogResponse { ok: true, result: dto })) +} diff --git a/src/node-control/service/src/http/http_server_task.rs b/src/node-control/service/src/http/http_server_task.rs index d8dc08b7..4c73cdb2 100644 --- a/src/node-control/service/src/http/http_server_task.rs +++ b/src/node-control/service/src/http/http_server_task.rs @@ -27,7 +27,6 @@ use crate::{ task::task_manager::{TaskController, TaskStatus}, }; use common::{ - app_config::StakePolicy, snapshot::{ ElectionsSnapshot, ElectionsStatus, OurElectionParticipant, SnapshotStore, TimeRange, ValidatorsSnapshot, @@ -161,7 +160,10 @@ pub(crate) fn routes(enable_swagger: bool, state: AppState) -> axum::Router { let operator_only = axum::Router::new() .route("/v1/elections/exclude", axum::routing::post(v1_elections_exclude_handler)) .route("/v1/elections/include", axum::routing::post(v1_elections_include_handler)) - .route("/v1/stake_strategy", axum::routing::post(v1_stake_strategy_handler)) + .route( + "/v1/elections/settings", + axum::routing::post(super::config_handlers::v1_elections_settings_update_handler), + ) .route("/v1/task/elections", axum::routing::post(v1_task_elections_handler)) .route("/v1/nodes", axum::routing::post(super::config_handlers::v1_nodes_add_handler)) .route( @@ -183,6 +185,11 @@ pub(crate) fn routes(enable_swagger: bool, state: AppState) -> axum::Router { "/v1/bindings/{node}", axum::routing::delete(super::config_handlers::v1_bindings_rm_handler), ) + .route( + "/v1/ton-http-api", + axum::routing::post(super::config_handlers::v1_ton_http_api_handler), + ) + .route("/v1/log", axum::routing::post(super::config_handlers::v1_log_set_handler)) .route("/auth/users", axum::routing::get(list_users_handler)) .route_layer(axum::middleware::from_fn_with_state( state.clone(), @@ -290,30 +297,6 @@ pub struct ValidatorsResponse { pub result: ValidatorsSnapshot, } -#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)] -pub struct StakePolicyRequest { - pub policy: StakePolicy, - /// If set, the policy is applied as a per-node override. - /// If omitted, it sets the default policy for all nodes. - #[serde(skip_serializing_if = "Option::is_none")] - pub node: Option, -} - -#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] -pub struct StakePolicyApplied { - pub policy: StakePolicy, - /// If set, the policy was applied to this specific node only. - #[serde(skip_serializing_if = "Option::is_none")] - pub node: Option, - pub applied_at: u64, -} - -#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] -pub struct StakePolicyResponse { - pub ok: bool, - pub result: StakePolicyApplied, -} - #[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum ElectionsTaskAction { @@ -482,6 +465,7 @@ pub async fn v1_elections_exclude_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; + state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; let task = state.elections_task.clone(); tokio::spawn(async move { @@ -533,6 +517,7 @@ pub async fn v1_elections_include_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; + state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; let task = state.elections_task.clone(); tokio::spawn(async move { @@ -570,57 +555,6 @@ pub async fn v1_validators_handler( axum::Json(ValidatorsResponse { ok: true, result: snapshot.validators }) } -#[utoipa::path( - post, - path = "/v1/stake_strategy", - request_body = StakePolicyRequest, - responses( - (status = 200, description = "Applied stake policy", body = StakePolicyResponse), - (status = 400, description = "Invalid request", body = ApiErrorResponse), - (status = 401, description = "Not authenticated", body = ApiErrorResponse), - (status = 500, description = "Internal error", body = ApiErrorResponse) - ), - security(("bearerAuth" = [])) -)] -pub async fn v1_stake_strategy_handler( - state: axum::extract::State, - req: axum::Json, -) -> Result, AppError> { - if matches!(req.policy, StakePolicy::Fixed(0)) { - return Err(AppError::bad_request("fixed stake must be > 0")); - } - if state.runtime_cfg.get().elections.is_none() { - return Err(AppError::bad_request("elections are not configured")); - } - - let policy = req.policy.clone(); - let node_id = req.node.clone(); - state - .runtime_cfg - .update_with(|cfg| { - if let Some(elections) = &mut cfg.elections { - if let Some(node_id) = node_id { - elections.policy_overrides.insert(node_id, policy); - } else { - elections.policy = policy; - } - } - }) - .map_err(|e| AppError::internal(e.to_string()))?; - - let task = state.elections_task.clone(); - tokio::spawn(async move { - let _ = task.restart().await; - }); - - let applied = StakePolicyApplied { - policy: req.policy.clone(), - node: req.node.clone(), - applied_at: state.runtime_cfg.updated_at(), - }; - Ok(axum::Json(StakePolicyResponse { ok: true, result: applied })) -} - #[utoipa::path( post, path = "/v1/task/elections", @@ -885,8 +819,8 @@ impl utoipa::Modify for BearerAuthAddon { v1_elections_exclude_handler, v1_elections_include_handler, v1_validators_handler, - v1_stake_strategy_handler, v1_task_elections_handler, + super::config_handlers::v1_elections_settings_update_handler, // It won't compile without full names super::config_handlers::v1_nodes_handler, super::config_handlers::v1_nodes_add_handler, @@ -900,6 +834,8 @@ impl utoipa::Modify for BearerAuthAddon { super::config_handlers::v1_bindings_handler, super::config_handlers::v1_bindings_add_handler, super::config_handlers::v1_bindings_rm_handler, + super::config_handlers::v1_ton_http_api_handler, + super::config_handlers::v1_log_set_handler, super::config_handlers::v1_elections_settings_handler, super::config_handlers::v1_log_handler, super::config_handlers::v1_master_wallet_handler, @@ -918,9 +854,7 @@ impl utoipa::Modify for BearerAuthAddon { common::app_config::BindingStatus, common::app_config::LogRotation, common::app_config::LogOutput, - StakePolicyRequest, - StakePolicyApplied, - StakePolicyResponse, + super::config_handlers::ElectionsSettingsUpdateRequest, ElectionsTaskAction, ElectionsTaskControlRequest, TaskStatusDto, @@ -942,6 +876,11 @@ impl utoipa::Modify for BearerAuthAddon { super::config_handlers::BindingAddRequest, super::config_handlers::EntityRefDto, super::config_handlers::EntityRefResponse, + super::config_handlers::OkResponse, + super::config_handlers::TonHttpApiRequest, + super::config_handlers::TonHttpApiResult, + super::config_handlers::TonHttpApiResponse, + super::config_handlers::LogSetRequest, BindingElectionStatusDto, ElectionsSettingsDto, ElectionsSettingsResponse, @@ -1125,8 +1064,8 @@ mod tests { let resp = app .oneshot(post_json( - "/v1/stake_strategy", - &StakePolicyRequest { policy: StakePolicy::Fixed(0), node: None }, + "/v1/elections/settings", + &serde_json::json!({ "policy": { "fixed": 0 } }), )) .await .unwrap(); @@ -1148,8 +1087,8 @@ mod tests { let resp = app .oneshot(post_json( - "/v1/stake_strategy", - &StakePolicyRequest { policy: StakePolicy::Fixed(123), node: None }, + "/v1/elections/settings", + &serde_json::json!({ "policy": { "fixed": 123 } }), )) .await .unwrap(); @@ -1157,8 +1096,7 @@ mod tests { assert_eq!(resp.status(), 200); let v = body_json(resp).await; assert_eq!(v["ok"], true); - assert_eq!(v["result"]["policy"]["fixed"], 123); - assert!(v["result"]["applied_at"].as_u64().unwrap_or(0) > 0); + assert_eq!(v["result"]["stake_policy"]["fixed"], 123); } #[tokio::test] @@ -1173,11 +1111,8 @@ mod tests { let resp = app .oneshot(post_json( - "/v1/stake_strategy", - &StakePolicyRequest { - policy: StakePolicy::Fixed(500), - node: Some("node1".to_string()), - }, + "/v1/elections/settings", + &serde_json::json!({ "policy": { "fixed": 500 }, "node": "node1" }), )) .await .unwrap(); @@ -1185,8 +1120,6 @@ mod tests { assert_eq!(resp.status(), 200); let v = body_json(resp).await; assert_eq!(v["ok"], true); - assert_eq!(v["result"]["policy"]["fixed"], 500); - assert_eq!(v["result"]["node"], "node1"); let cfg = runtime_cfg.get(); let elections = cfg.elections.as_ref().unwrap(); diff --git a/src/node/tests/test_run_net_py/run_singlehost_nodectl.py b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py index b3a3e0f8..87fe625f 100644 --- a/src/node/tests/test_run_net_py/run_singlehost_nodectl.py +++ b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py @@ -372,8 +372,6 @@ def phase2_generate_config(self) -> str: self.log.info(" config generate...") self._nctl("config", "generate", "--output", str(self.paths.nodectl_config), "--force") - self.log.info(" config ton-http-api set...") - self._nctl("config", "ton-http-api", "set", "--url", self.cfg.http_api_url) # Create the key used by nodes 3+ (nodes 1-2 get per-node keys in phase 5) self.log.info(" key add control-client-secret...") @@ -469,6 +467,11 @@ def phase8_complete_config(self) -> str: self._setup_auth() + # Set ton-http-api URL via REST API (moved from phase 2; service starts + # with the default URL which matches the CI default). + self.log.info(" config ton-http-api set...") + self._nctl("config", "ton-http-api", "set", "--url", self.cfg.http_api_url) + # Patch global tick_interval — no CLI command exists for this field cfg_json = json.loads(self.paths.nodectl_config.read_text()) cfg_json["tick_interval"] = 20 From 5a47a5c36bcebeeb186c43fbf4dabfc4fe84e8d9 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:56:57 +0300 Subject: [PATCH 2/7] feat(service): notify service loop to rebuild caches after REST mutations Entity CRUD and ton-http-api handlers signal config_changed after save_to_file so the service loop calls force_reload to rebuild wallets, pools, RPC client and restart tasks immediately. --- src/node-control/service/src/http/auth_tests.rs | 2 ++ .../service/src/http/config_handlers.rs | 9 +++++++++ .../service/src/http/http_server_task.rs | 16 ++++++++++++++-- src/node-control/service/src/runtime_config.rs | 9 +++++++++ .../service/src/service_main_task.rs | 14 +++++++++++++- 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/node-control/service/src/http/auth_tests.rs b/src/node-control/service/src/http/auth_tests.rs index 3ee1409e..dc59ab98 100644 --- a/src/node-control/service/src/http/auth_tests.rs +++ b/src/node-control/service/src/http/auth_tests.rs @@ -123,6 +123,7 @@ async fn state_with_auth() -> AppState { jwt_auth: test_jwt_auth().await, user_store: Arc::new(UserStore::new(rt as Arc)), login_rate_limiter: Arc::new(tokio::sync::Mutex::new(Default::default())), + config_changed: Arc::new(tokio::sync::Notify::new()), } } @@ -135,6 +136,7 @@ async fn state_no_auth() -> AppState { jwt_auth: test_jwt_auth().await, user_store: Arc::new(UserStore::new(rt.clone() as Arc)), login_rate_limiter: Arc::new(tokio::sync::Mutex::new(Default::default())), + config_changed: Arc::new(tokio::sync::Notify::new()), } } diff --git a/src/node-control/service/src/http/config_handlers.rs b/src/node-control/service/src/http/config_handlers.rs index 37b4b7e9..2c237724 100644 --- a/src/node-control/service/src/http/config_handlers.rs +++ b/src/node-control/service/src/http/config_handlers.rs @@ -745,6 +745,7 @@ pub async fn v1_nodes_add_handler( cfg.nodes.insert(name, adnl_config); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: req.name } })) } @@ -784,6 +785,7 @@ pub async fn v1_nodes_rm_handler( cfg.nodes.remove(&target); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name } })) } @@ -833,6 +835,7 @@ pub async fn v1_wallets_add_handler( cfg.wallets.insert(name, wallet_config); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: req.name } })) } @@ -877,6 +880,7 @@ pub async fn v1_wallets_rm_handler( cfg.wallets.remove(&target); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name } })) } @@ -929,6 +933,7 @@ pub async fn v1_pools_add_handler( cfg.pools.insert(name, pool_config); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: req.name } })) } @@ -971,6 +976,7 @@ pub async fn v1_pools_rm_handler( cfg.pools.remove(&target); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name } })) } @@ -1036,6 +1042,7 @@ pub async fn v1_bindings_add_handler( cfg.bindings.insert(node, binding); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: req.node } })) } @@ -1079,6 +1086,7 @@ pub async fn v1_bindings_rm_handler( cfg.bindings.remove(&target); }) .map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); Ok(axum::Json(EntityRefResponse { ok: true, result: EntityRefDto { name: node } })) } @@ -1244,6 +1252,7 @@ pub async fn v1_ton_http_api_handler( }) .map_err(|e| AppError::internal(e.to_string()))?; state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; + state.config_changed.notify_one(); let endpoints = state.runtime_cfg.get().ton_http_api.endpoints(); Ok(axum::Json(TonHttpApiResponse { ok: true, result: TonHttpApiResult { endpoints } })) diff --git a/src/node-control/service/src/http/http_server_task.rs b/src/node-control/service/src/http/http_server_task.rs index 4c73cdb2..6a88bf80 100644 --- a/src/node-control/service/src/http/http_server_task.rs +++ b/src/node-control/service/src/http/http_server_task.rs @@ -44,6 +44,9 @@ pub struct AppState { pub jwt_auth: Arc, pub user_store: Arc, pub(crate) login_rate_limiter: Arc>, + /// Signalled by mutation handlers after structural config changes + /// (entity CRUD, ton-http-api) so the service loop can rebuild caches. + pub config_changed: Arc, } pub async fn run( @@ -51,6 +54,7 @@ pub async fn run( store: Arc, runtime_cfg: Arc, tasks: HashMap<&'static str, Arc>, + config_changed: Arc, ) { tracing::info!("http-server task started"); @@ -100,8 +104,15 @@ pub async fn run( let elections_task = tasks.get("elections").cloned().expect("elections task is not registered"); let login_rate_limiter = Arc::new(tokio::sync::Mutex::new(LoginRateLimiter::default())); - let state = - AppState { store, runtime_cfg, elections_task, jwt_auth, user_store, login_rate_limiter }; + let state = AppState { + store, + runtime_cfg, + elections_task, + jwt_auth, + user_store, + login_rate_limiter, + config_changed, + }; let app = routes(enable_swagger, state); let listener = match tokio::net::TcpListener::bind(bind_addr).await { @@ -972,6 +983,7 @@ mod tests { jwt_auth: test_jwt_auth().await, user_store, login_rate_limiter: Arc::new(tokio::sync::Mutex::new(LoginRateLimiter::default())), + config_changed: Arc::new(tokio::sync::Notify::new()), } } diff --git a/src/node-control/service/src/runtime_config.rs b/src/node-control/service/src/runtime_config.rs index 3ece2d49..7a0aa1a6 100644 --- a/src/node-control/service/src/runtime_config.rs +++ b/src/node-control/service/src/runtime_config.rs @@ -330,6 +330,15 @@ impl RuntimeConfigStore { Ok(()) } + /// Rebuild all cached runtime objects (vault, RPC client, wallets, pools) + /// from the current in-memory config. Does not read from disk. + /// + /// Use after REST mutations that change structural config (entities, endpoints). + pub async fn force_reload(&self) -> anyhow::Result<()> { + let config = (*self.get()).clone(); + self.reload(config).await + } + /// Reload config from the file if it has changed externally. pub async fn reload_from_file(&self) -> bool { let current_hash = Self::hash_file(&Path::new(&self.config_path)); diff --git a/src/node-control/service/src/service_main_task.rs b/src/node-control/service/src/service_main_task.rs index 2f085985..13a94487 100644 --- a/src/node-control/service/src/service_main_task.rs +++ b/src/node-control/service/src/service_main_task.rs @@ -105,11 +105,14 @@ pub async fn run_with_config( let _ = tasks.get("voting").expect("voting task").enable().await; } + let config_changed = Arc::new(tokio::sync::Notify::new()); + let http_task_handle = tokio::spawn(http_server_task::run( cancellation_ctx.clone(), store.clone(), runtime_cfg.clone(), tasks.clone(), + config_changed.clone(), )); let max_wait = std::time::Duration::from_secs(10); @@ -121,13 +124,22 @@ pub async fn run_with_config( tokio::select! { _ = &mut timeout => { - // Reload config from file if changed + // Reload config from file if changed externally if runtime_cfg.reload_from_file().await { for task in tasks.values() { let _ = task.restart().await; } } } + _ = config_changed.notified() => { + // REST mutation changed structural config — rebuild caches + if let Err(e) = runtime_cfg.force_reload().await { + tracing::error!("cache rebuild after config mutation failed: {e:#}"); + } + for task in tasks.values() { + let _ = task.restart().await; + } + } _ = cancel.changed() => { break; } From 3935478a8ce6802be7e99807e8b231b5d243f603 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:44:07 +0300 Subject: [PATCH 3/7] fix(service): migrate remaining save_to_file callers to update_and_save --- src/node-control/service/src/http/config_handlers.rs | 9 +++------ src/node-control/service/src/http/http_server_task.rs | 6 ++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/node-control/service/src/http/config_handlers.rs b/src/node-control/service/src/http/config_handlers.rs index 2c237724..d14040e7 100644 --- a/src/node-control/service/src/http/config_handlers.rs +++ b/src/node-control/service/src/http/config_handlers.rs @@ -1158,7 +1158,7 @@ pub async fn v1_elections_settings_update_handler( state .runtime_cfg - .update_with(|cfg| { + .update_and_save(|cfg| { if let Some(elections) = &mut cfg.elections { // Stake policy if reset { @@ -1182,7 +1182,6 @@ pub async fn v1_elections_settings_update_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; - state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; // Restart elections task so changes take effect immediately. let task = state.elections_task.clone(); @@ -1231,7 +1230,7 @@ pub async fn v1_ton_http_api_handler( let append = req.append; state .runtime_cfg - .update_with(|cfg| { + .update_and_save(|cfg| { if append { let existing = cfg.ton_http_api.endpoints(); for url in &urls { @@ -1251,7 +1250,6 @@ pub async fn v1_ton_http_api_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; - state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; state.config_changed.notify_one(); let endpoints = state.runtime_cfg.get().ton_http_api.endpoints(); @@ -1314,7 +1312,7 @@ pub async fn v1_log_set_handler( state .runtime_cfg - .update_with(|cfg| { + .update_and_save(|cfg| { let log = cfg.log.get_or_insert_with(LogConfig::default); if let Some(l) = level { log.level = l; @@ -1336,7 +1334,6 @@ pub async fn v1_log_set_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; - state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; let config = state.runtime_cfg.get(); let log = config.log.as_ref().cloned().unwrap_or_default(); diff --git a/src/node-control/service/src/http/http_server_task.rs b/src/node-control/service/src/http/http_server_task.rs index 6a88bf80..2e18c631 100644 --- a/src/node-control/service/src/http/http_server_task.rs +++ b/src/node-control/service/src/http/http_server_task.rs @@ -468,7 +468,7 @@ pub async fn v1_elections_exclude_handler( let to_exclude = req.nodes.clone(); state .runtime_cfg - .update_with(|cfg| { + .update_and_save(|cfg| { for node_id in &to_exclude { if let Some(binding) = cfg.bindings.get_mut(node_id) { binding.enable = false; @@ -476,7 +476,6 @@ pub async fn v1_elections_exclude_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; - state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; let task = state.elections_task.clone(); tokio::spawn(async move { @@ -520,7 +519,7 @@ pub async fn v1_elections_include_handler( let to_include = req.nodes.clone(); state .runtime_cfg - .update_with(|cfg| { + .update_and_save(|cfg| { for node_id in &to_include { if let Some(binding) = cfg.bindings.get_mut(node_id) { binding.enable = true; @@ -528,7 +527,6 @@ pub async fn v1_elections_include_handler( } }) .map_err(|e| AppError::internal(e.to_string()))?; - state.runtime_cfg.save_to_file().map_err(|e| AppError::internal(e.to_string()))?; let task = state.elections_task.clone(); tokio::spawn(async move { From 47ac2a12e4a984df1332e7469d21b584e14da7d2 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:08:35 +0300 Subject: [PATCH 4/7] fix(service): address copilot review: gate restart on reload success, dedupe ton-http-api, trim log inputs --- .../service/src/http/config_handlers.rs | 21 +++++++++++-------- .../service/src/service_main_task.rs | 20 ++++++++++++------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/node-control/service/src/http/config_handlers.rs b/src/node-control/service/src/http/config_handlers.rs index d14040e7..b2e9c77b 100644 --- a/src/node-control/service/src/http/config_handlers.rs +++ b/src/node-control/service/src/http/config_handlers.rs @@ -1232,9 +1232,10 @@ pub async fn v1_ton_http_api_handler( .runtime_cfg .update_and_save(|cfg| { if append { - let existing = cfg.ton_http_api.endpoints(); + let mut seen: std::collections::HashSet = + cfg.ton_http_api.endpoints().into_iter().collect(); for url in &urls { - if !existing.iter().any(|e| e == url) { + if seen.insert(url.clone()) { let entry = match &api_key { Some(key) => { EndpointEntry::WithKey { url: url.clone(), api_key: key.clone() } @@ -1273,8 +1274,12 @@ pub async fn v1_log_set_handler( req: axum::Json, ) -> Result, AppError> { let req = req.0; - if req.level.is_none() - && req.path.is_none() + // Normalize inputs: treat whitespace-only strings as unset. + let level_str = req.level.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let path_str = req.path.as_deref().map(str::trim).filter(|s| !s.is_empty()); + + if level_str.is_none() + && path_str.is_none() && req.rotation.is_none() && req.output.is_none() && req.max_size_mb.is_none() @@ -1283,9 +1288,7 @@ pub async fn v1_log_set_handler( return Err(AppError::bad_request("at least one setting is required")); } - let level = req - .level - .as_deref() + let level = level_str .map(|l| { tracing::Level::from_str(l) .map_err(|_| AppError::bad_request(format!("invalid log level: '{l}'"))) @@ -1295,7 +1298,7 @@ pub async fn v1_log_set_handler( // Pre-validate: file/all output requires a path (check against current + incoming) if let Some(ref output) = req.output { if matches!(output, LogOutput::File | LogOutput::All) { - let has_path = req.path.is_some() + let has_path = path_str.is_some() || state.runtime_cfg.get().log.as_ref().and_then(|l| l.path.as_ref()).is_some(); if !has_path { let mode = match output { @@ -1317,7 +1320,7 @@ pub async fn v1_log_set_handler( if let Some(l) = level { log.level = l; } - if let Some(p) = &req.path { + if let Some(p) = path_str { log.path = Some(PathBuf::from(p)); } if let Some(r) = req.rotation { diff --git a/src/node-control/service/src/service_main_task.rs b/src/node-control/service/src/service_main_task.rs index 13a94487..6b563887 100644 --- a/src/node-control/service/src/service_main_task.rs +++ b/src/node-control/service/src/service_main_task.rs @@ -132,12 +132,20 @@ pub async fn run_with_config( } } _ = config_changed.notified() => { - // REST mutation changed structural config — rebuild caches - if let Err(e) = runtime_cfg.force_reload().await { - tracing::error!("cache rebuild after config mutation failed: {e:#}"); - } - for task in tasks.values() { - let _ = task.restart().await; + // REST mutation changed structural config — rebuild caches. + // Only restart tasks if caches are consistent; otherwise tasks + // keep running against the previous caches. + match runtime_cfg.force_reload().await { + Ok(()) => { + for task in tasks.values() { + let _ = task.restart().await; + } + } + Err(e) => { + tracing::error!( + "cache rebuild after config mutation failed; skipping task restart: {e:#}" + ); + } } } _ = cancel.changed() => { From bec3d5531b4b66c3ebaad80a4b9bb02fbb5e71ef Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:40:46 +0300 Subject: [PATCH 5/7] feat(commands)!: rename option from `--url` to `--endpoint` for `config ton-http-api` cmd - breaking change! to use a different word from `--url` option in the config root cmd --- .../commands/nodectl/config_ton_http_api_cmd.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs index 3a8cf6c1..74334f89 100644 --- a/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_ton_http_api_cmd.rs @@ -25,10 +25,10 @@ pub enum TonHttpApiAction { } #[derive(clap::Args, Clone)] -#[command(about = "Set ton-http-api url and optional api key")] +#[command(about = "Set ton-http-api endpoint and optional api key")] pub struct TonHttpApiSetCmd { - #[arg(short = 'u', long = "url")] - url: String, + #[arg(short = 'e', long = "endpoint", help = "TON HTTP API endpoint")] + endpoint: String, #[arg(short = 'k', long = "api-key")] api_key: Option, } @@ -36,8 +36,8 @@ pub struct TonHttpApiSetCmd { #[derive(clap::Args, Clone)] #[command(about = "Add one or more failover endpoint URLs for ton-http-api")] pub struct TonHttpApiAddCmd { - #[arg(short = 'u', long = "url", required = true)] - urls: Vec, + #[arg(short = 'e', long = "endpoint", required = true, help = "TON HTTP API endpoint")] + endpoints: Vec, /// Per-endpoint API key applied to all URLs in this invocation. /// When omitted, the endpoints inherit the global api_key. #[arg(short = 'k', long = "api-key")] @@ -87,7 +87,7 @@ impl TonHttpApiSetCmd { ) -> anyhow::Result<()> { let base_url = resolve_service_url(url, config_path)?; let body = TonHttpApiBody { - urls: vec![self.url.as_str()], + urls: vec![self.endpoint.as_str()], api_key: self.api_key.as_deref(), append: false, }; @@ -105,7 +105,7 @@ impl TonHttpApiAddCmd { ) -> anyhow::Result<()> { let base_url = resolve_service_url(url, config_path)?; let body = TonHttpApiBody { - urls: self.urls.iter().map(|s| s.as_str()).collect(), + urls: self.endpoints.iter().map(|s| s.as_str()).collect(), api_key: self.api_key.as_deref(), append: true, }; From 4b8cae6c70b4ac6f4803932a5fa4f4e59071af19 Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:41:53 +0300 Subject: [PATCH 6/7] feat(commands): update config command URL help text and add NODECTL_URL env --- .../commands/src/commands/nodectl/config_cmd.rs | 5 +++-- src/node-control/commands/src/commands/nodectl/utils.rs | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/node-control/commands/src/commands/nodectl/config_cmd.rs b/src/node-control/commands/src/commands/nodectl/config_cmd.rs index 45545512..bf92f383 100644 --- a/src/node-control/commands/src/commands/nodectl/config_cmd.rs +++ b/src/node-control/commands/src/commands/nodectl/config_cmd.rs @@ -39,8 +39,9 @@ pub struct ConfigCmd { short = 'u', long = "url", value_hint = clap::ValueHint::Url, - help = "URL to the node control service API (takes precedence over --config; defaults to http://127.0.0.1:8080 if not --url and --config are provided)", - global = true + help = "URL to the node control service API (takes precedence over --config; defaults to http://127.0.0.1:8080 if not --url, --config, or NODECTL_URL environment variable are provided)", + env = "NODECTL_URL", + global = true, )] url: Option, diff --git a/src/node-control/commands/src/commands/nodectl/utils.rs b/src/node-control/commands/src/commands/nodectl/utils.rs index ae1e5b2d..b57f4b8a 100644 --- a/src/node-control/commands/src/commands/nodectl/utils.rs +++ b/src/node-control/commands/src/commands/nodectl/utils.rs @@ -269,7 +269,7 @@ pub(crate) fn normalize_base_url(url: &str) -> String { if !base.starts_with("http://") && !base.starts_with("https://") { base = format!("http://{}", base); } - base + base.trim_end_matches('/').to_string() } /// Sends a GET request to the service API and returns the response body. @@ -397,4 +397,10 @@ mod tests { ); assert_eq!(normalize_base_url("example.com/path"), "http://example.com/path"); } + + #[test] + fn test_normalize_base_url_trims_trailing_slash() { + assert_eq!(normalize_base_url("http://example.com:8080/"), "http://example.com:8080"); + assert_eq!(normalize_base_url("127.0.0.1/"), "http://127.0.0.1"); + } } From 275628ecb187d4c539c7e71464d4f881d162da2c Mon Sep 17 00:00:00 2001 From: NikitaM <21960067+Keshoid@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:49:59 +0300 Subject: [PATCH 7/7] test: fix adding endpoints in ci script --- src/node/tests/test_run_net_py/run_singlehost_nodectl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/tests/test_run_net_py/run_singlehost_nodectl.py b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py index 87fe625f..f9b950d0 100644 --- a/src/node/tests/test_run_net_py/run_singlehost_nodectl.py +++ b/src/node/tests/test_run_net_py/run_singlehost_nodectl.py @@ -470,7 +470,7 @@ def phase8_complete_config(self) -> str: # Set ton-http-api URL via REST API (moved from phase 2; service starts # with the default URL which matches the CI default). self.log.info(" config ton-http-api set...") - self._nctl("config", "ton-http-api", "set", "--url", self.cfg.http_api_url) + self._nctl("config", "ton-http-api", "set", "-e", self.cfg.http_api_url) # Patch global tick_interval — no CLI command exists for this field cfg_json = json.loads(self.paths.nodectl_config.read_text())