Skip to content

Conversation

moisesPompilio
Copy link
Contributor

Added support for configuring the node via CLI arguments and environment variables, using clap (as already done in ldk-server-cli).

Configuration is now loaded from three sources:

  1. Config file (most complete set of options)
  2. Environment variables (essential options only)
  3. CLI arguments (essential options only)

Environment variables and CLI arguments override values defined in the config file when provided.

In addition, the bitcoind_rpc_addr field was split into two variables for better clarity:

  • bitcoind_rpc_host
  • bitcoind_rpc_port

Tests were added for the new configuration loading logic, and the README was updated with usage instructions.

close #42 and #66

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Aug 21, 2025

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-reviews-bot ldk-reviews-bot requested a review from jkczyz August 21, 2025 13:10
@Anyitechs
Copy link

Thank you for your work on this!

I'm still going through the changes, but I have an early high-level question. Is there a reason or trade-off why the clap crate is preferred over the config crate for this?

My initial thought was that a crate like config might be a better fit here, since it's specifically designed for layered configuration and helps separate config loading from CLI argument parsing.

Just want to make sure I understand the design decision.

@moisesPompilio
Copy link
Contributor Author

I chose to use clap because it was already a dependency in ldk-server-cli and it provides built-in support for both CLI arguments and environment variables. This made it convenient to extend ldk-server without introducing a new dependency, so clap seemed like a good fit for this use case.

I hadn’t considered using a separate configuration crate like config for this, so I didn’t research that option.

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Copy link
Contributor

@jkczyz jkczyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling these issues! Looks like CI is failing, though.

None
};

macro_rules! pick {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, the call sites may be more readable if everything was explicitly written out. There isn't much happening here, so I don't think it would be too verbose.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I removed the picks macro and wrote the calls explicitly.

Comment on lines -140 to +61
rpc_address: String,
rpc_user: String,
rpc_password: String,
rpc_host: Option<String>,
rpc_port: Option<u16>,
rpc_user: Option<String>,
rpc_password: Option<String>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you make a separate commit for splitting the RPC address?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry, I forgot to split it into two commits.


macro_rules! pick {
($cli:expr, $toml:expr, $err_msg:expr) => {
$cli.or($toml).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, $err_msg))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or_else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

};
}

fn missing_field_msg(field: &str) -> String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this return the io::Error instead? Then the unrolled macro would be less verbose.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I removed the macro and made the code explicit. The function now directly returns io::Error

@moisesPompilio moisesPompilio force-pushed the issue-42 branch 2 times, most recently from d05dec0 to 96a5676 Compare September 1, 2025 19:20
@moisesPompilio
Copy link
Contributor Author

Looks like CI is failing, though.

CI was failing because tests used the same file names. I fixed it by giving each test a unique file name.

Copy link

@Anyitechs Anyitechs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for your work on this! The changes look good overall, but I left a few comments for consideration.

format!("Failed to read config file '{}': {}", config_path.as_ref().display(), e),
)
#[derive(Parser, Debug)]
#[command(version, about = "LDK Node Configuration", long_about = None)]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would it be appropriate to use 'LDK Server Configuration' here instead of ‘LDK Node Configuration’?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

format!("Config file contains invalid TOML format: {}", e),
)
})?)
} else {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need the else {} block here? Since we're primarily interested in the Some() case, and considering the events-rabbitmq and experimental-lsps2-support features are already being handled in load_config_feature(), this block might be redundant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else {} is needed to handle the None case and also manage features when they're passed. I replaced if with match to make the options clearer.

storage_dir_path: Option<String>,
}

pub fn load_config(args_config: &ArgsConfig) -> io::Result<Config> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The load_config() function seems to be doing quite a lot at the moment. It might be beneficial to implement TryFrom<TomlConfig> for Config and move all the logic after the if let Some() block into the try_from() method, similar to how it was structured before.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point.TryFrom<TomlConfig>made sense when the goal was a direct conversion from TOML to Config. Now that load_config() merges values from both TomlConfig and ArgsConfig, the logic is more about combining sources than just converting one type. In this case, keeping it in load_config() seems more consistent.

Copy link
Contributor

@jkczyz jkczyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about the delay. Some travel got the best of my time.

Comment on lines 82 to 67
let bitcoind_rpc_addr = config_file.bitcoind_rpc_addr;

builder.set_chain_source_bitcoind_rpc(
bitcoind_rpc_addr.ip().to_string(),
bitcoind_rpc_addr.port(),
config_file.bitcoind_rpc_addr.ip().to_string(),
config_file.bitcoind_rpc_addr.port(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this belong in the second commit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, done!

Comment on lines -113 to +33
node: NodeConfig,
storage: StorageConfig,
bitcoind: BitcoindConfig,
node: Option<NodeConfig>,
storage: Option<StorageConfig>,
bitcoind: Option<BitcoindConfig>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a good enough grasp of this change to understand why these need to be Options now. Same elsewhere. Could you explain?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously these configs were only provided via file. I made them Option so they’re not required in the file anymore, since they can now also be passed via CLI or env. This way, the user can decide to provide some settings only through CLI/env or file, while they’re still required at node startup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. So long Config isn't using Options then this should be ok. (cc: @tnull in case he has any opinons on the matter).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might make sense to rename these structs, since they’re only used for the TOML file. Adding a suffix or prefix to make that explicit could make the code clearer.

Comment on lines 182 to 185
let network = match args_config.node_network.or(node.and_then(|n| n.network)) {
Some(n) => n,
None => return Err(missing_field_err("network")),
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of matching like this, let's use ok_or_else()? and map when needed. Likewise elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Comment on lines 186 to 196
let listening_addr_str = match args_config
.node_listening_address
.as_deref()
.or(node.and_then(|n| n.listening_address.as_deref()))
{
Some(addr) => addr,
None => return Err(missing_field_err("node_listening_address")),
};
let listening_addr = SocketAddress::from_str(listening_addr_str).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid listening address: {}", e))
})?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can combine these using and_then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Comment on lines -113 to +33
node: NodeConfig,
storage: StorageConfig,
bitcoind: BitcoindConfig,
node: Option<NodeConfig>,
storage: Option<StorageConfig>,
bitcoind: Option<BitcoindConfig>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. So long Config isn't using Options then this should be ok. (cc: @tnull in case he has any opinons on the matter).

toml_config = remove_config_line(&toml_config, &format!("{} =", $field));
fs::write(storage_path.join(config_file_name), &toml_config).unwrap();
let result = load_config(&args_config);
println!("Rsult: {:?}", result);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/Rsult/Result

But why print in tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the print isn't needed. I had added it temporarily for debugging while checking values, but I've removed it now.

- Added `clap` dependency to `ldk-server` to
  support passing essential node config via CLI arguments and environment
  variables.
- Implemented layered config loading: config file (full set of options) +
  environment variables + CLI arguments. Env vars and CLI args override
  values from the config file when present.
- Added tests for the new config loading logic.
- Updated README with usage instructions and explanation of config precedence.

Close lightningdevkit#42
…pc_port`

This change replaces the single RPC address field with separate host and port fields, allowing hostname support and improving compatibility with containerized environments.

Closes lightningdevkit#66

juntar com o segundo commit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

config via environment variables
4 participants