Skip to content

Commit 3fbefc4

Browse files
committed
feat!: provide default config and allow modifying configs via CLI options
BREAKING CHANGE: Before this commit, the CLI will fail if no config file exists in the configured paths. After this commit, the CLI will create a default `sncloud.ini` file in the current active directory. It also changes the release workflow that only `snctl-cpp` will be installed.
1 parent a823dd9 commit 3fbefc4

File tree

9 files changed

+312
-116
lines changed

9 files changed

+312
-116
lines changed

.github/workflows/release-linux.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,4 @@ jobs:
6060
uses: actions/upload-artifact@master
6161
with:
6262
name: snctl-cpp-alpine-${{ matrix.platform }}
63-
path: |
64-
snctl-cpp
65-
sncloud.ini
66-
install.sh
63+
path: snctl-cpp

.github/workflows/release.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,4 @@ jobs:
4949
uses: actions/upload-artifact@master
5050
with:
5151
name: snctl-cpp-macos-14-arm64
52-
path: |
53-
snctl-cpp
54-
sncloud.ini
55-
install.sh
52+
path: snctl-cpp

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ compile_commands.json
33
.cache
44
a.out
55
dependencies/
6+
sncloud.ini

README.md

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,10 @@ Take v0.1.0 for example:
1616
export VERSION=0.1.0
1717
curl -O -L https://github.com/BewareMyPower/snctl-cpp/releases/download/v$VERSION/snctl-cpp-macos-14-arm64.zip
1818
unzip -q snctl-cpp-macos-14-arm64.zip
19-
./install.sh
20-
```
21-
22-
The binary and configuration file will be installed under `~/.snctl-cpp`, so you have to add it to your `PATH`:
23-
24-
```bash
25-
export PATH=$HOME/.snctl-cpp:$PATH
2619
```
2720

2821
Now, you can run `snctl-cpp -h` to see the help message.
2922

30-
> **NOTE**:
31-
>
32-
> Actually running `./install.sh` is not necessary. You can run `./snctl-cpp` directly in the uncompressed directory because `sncloud.ini` is in the same directory.
33-
3423
### Build from source
3524

3625
You must have a C++ compiler that supports C++17.
@@ -42,24 +31,38 @@ cmake --build build
4231
cp build/snctl-cpp .
4332
```
4433

45-
You can run `./install.sh` to override the existing installation in `~/.snctl-cpp` directory, but you can also just run `./snctl-cpp` directly without installing it. The `sncloud.ini` file in the current working directory has higher priority than the one in `~/.snctl-cpp` directory.
46-
4734
## Configuration
4835

49-
**Please make sure the `sncloud.ini` file is in the current working directory or `~/.snctl-cpp` directory if you don't specify the `--config` option.**
36+
By default, `snctl-cpp` will look for configurations from the following paths in order, which can be configured by the `--config` option:
37+
1. The `sncloud.ini` in the current active directory.
38+
2. `~/.snctl-cpp/sncloud.ini`.
5039

51-
You only need to fill the following fields in `sncloud.ini`:
52-
- `bootstrap.servers`: the URL of the Kafka service, e.g. `pc-xxx:9093` on StreamNative cloud.
53-
- (optional) `token`: the token generated by the API key. It's allowed to be empty for quickly testing against a local Kafka cluster without any authentication.
40+
If no file exists in the paths above, `snctl-cpp` will create a new file in the current active directory with the default configs:
5441

55-
Options:
56-
- Add the `--config <config-file>` option to specify a different path of the INI config file.
57-
- Add a `--client-id` option to specify the client id of the underlying Kafka client. In Ursa, the client id carries the zone information, see [here](https://docs.streamnative.io/docs/config-kafka-client#eliminate-cross-az-networking-traffic).
42+
```ini
43+
[kafka]
44+
bootstrap.servers = localhost:9092
45+
token =
46+
```
47+
48+
To modify the config, you can edit the config file directly, whose path can be retrieved by the `./snctl-cpp --get-config` command.
49+
50+
The alternative way is to use the `configs` subcommand so that you can modify the config without opening the file. Here is an example that `snctl-cpp` is put into the `/private/tmp/` directory and there is no config file in the default paths:
5851

59-
The built-in `sncloud.ini` file specifies `localhost:9092` as the default bootstrap server. You can also test `snctl-cpp` against a local Kafka cluster.
52+
```bash
53+
$ ./snctl-cpp configs update --kafka-url localhost:9093
54+
No config file found. Creating /private/tmp/sncloud.ini with the default configs
55+
Updated bootstrap.servers to localhost:9093
56+
Updated config file /private/tmp/sncloud.ini
57+
$ ./snctl-cpp configs update --kafka-token my-token
58+
Updated token
59+
Updated config file /private/tmp/sncloud.ini
60+
```
6061

6162
## Commands
6263

64+
**NOTE**: The commands below assumes `snctl-cpp` is in the `PATH`.
65+
6366
### Create a topic
6467

6568
```bash

include/snctl-cpp/configs.h

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Copyright 2025 Yunze Xu
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
#pragma once
17+
18+
#include "snctl-cpp/subcommand.h"
19+
#include <SimpleIni.h>
20+
#include <argparse/argparse.hpp>
21+
#include <filesystem>
22+
#include <iostream>
23+
#include <optional>
24+
#include <stdexcept>
25+
#include <string>
26+
27+
struct KafkaConfigs {
28+
std::string bootstrap_servers = "localhost:9092";
29+
std::string token = "";
30+
};
31+
32+
struct LogConfigs {
33+
// Whether to enable logging for rdkafka
34+
bool enabled = true;
35+
// The file to store logs from rdkafka. If it's empty, the logs will be
36+
// written to the standard output.
37+
std::string path = "/tmp/rdkafka.log";
38+
};
39+
40+
class Configs : public SubCommand {
41+
public:
42+
Configs(argparse::ArgumentParser &parent) : SubCommand("configs") {
43+
update_command_.add_description("Update key-value from the INI section");
44+
update_command_.add_argument("--kafka-url")
45+
.help("The Kafka bootstrap.servers");
46+
update_command_.add_argument("--kafka-token").help("The Kafka token");
47+
48+
add_child(update_command_);
49+
attach_parent(parent);
50+
}
51+
52+
// This method must be called after parent.parse_args() is called
53+
void init(argparse::ArgumentParser &parent) {
54+
for (auto &&file : parent.get<std::vector<std::string>>("config")) {
55+
if (!std::filesystem::exists(file)) {
56+
continue;
57+
}
58+
if (loadFile(file)) {
59+
break;
60+
}
61+
}
62+
if (config_file_.empty()) {
63+
config_file_ = std::filesystem::current_path() / "sncloud.ini";
64+
std::cout << "No config file found. Creating " << config_file_
65+
<< " with the default configs" << std::endl;
66+
saveFile();
67+
}
68+
}
69+
70+
void run() {
71+
if (is_subcommand_used(update_command_)) {
72+
bool updated = false;
73+
if (update_command_.present("--kafka-url")) {
74+
if (auto value = update_command_.get("--kafka-url");
75+
value != kafka_configs_.bootstrap_servers) {
76+
77+
kafka_configs_.bootstrap_servers = value;
78+
std::cout << "Updated bootstrap.servers to " << value << std::endl;
79+
updated = true;
80+
} else {
81+
std::cout << "The provided bootstrap.servers is the same with the "
82+
"config in "
83+
<< config_file_ << std::endl;
84+
}
85+
}
86+
if (update_command_.present("--kafka-token")) {
87+
auto value = update_command_.get("--kafka-token");
88+
if (value.empty()) {
89+
throw std::invalid_argument("The token cannot be empty");
90+
}
91+
if (value != kafka_configs_.token) {
92+
kafka_configs_.token = value;
93+
updated = true;
94+
std::cout << "Updated token" << std::endl;
95+
} else {
96+
std::cout << "The provided token is the same with the config in "
97+
<< config_file_ << std::endl;
98+
}
99+
}
100+
if (updated) {
101+
saveFile();
102+
std::cout << "Updated config file " << config_file_ << std::endl;
103+
} else {
104+
std::cout << "No config updated" << std::endl;
105+
}
106+
} else {
107+
fail();
108+
}
109+
}
110+
111+
const auto &config_file() const noexcept { return config_file_; }
112+
113+
const auto &kafka_configs() const noexcept { return kafka_configs_; }
114+
115+
const auto &log_configs() const noexcept { return log_configs_; }
116+
117+
private:
118+
argparse::ArgumentParser update_command_{"update"};
119+
120+
CSimpleIni ini_;
121+
std::string config_file_;
122+
KafkaConfigs kafka_configs_;
123+
LogConfigs log_configs_;
124+
125+
std::optional<std::string> getValue(const std::string &section,
126+
const std::string &key) {
127+
auto value = ini_.GetValue(section.c_str(), key.c_str());
128+
if (value == nullptr) {
129+
return std::nullopt;
130+
}
131+
return std::optional(value);
132+
}
133+
134+
bool loadFile(const std::string &file) {
135+
if (auto rc = ini_.LoadFile(file.c_str()); rc != SI_OK) {
136+
std::cerr << "Failed to load existing file " << file << ": " << rc
137+
<< std::endl;
138+
return false;
139+
}
140+
141+
// reset configs
142+
kafka_configs_ = {};
143+
log_configs_ = {};
144+
145+
// load configs from the INI file
146+
if (auto value = getValue("kafka", "bootstrap.servers"); value) {
147+
kafka_configs_.bootstrap_servers = *value;
148+
} else {
149+
std::cerr << "No bootstrap.servers found in the kafka section. Use the "
150+
"default value: "
151+
<< kafka_configs_.bootstrap_servers << std::endl;
152+
}
153+
if (auto value = getValue("kafka", "token"); value) {
154+
kafka_configs_.token = *value;
155+
}
156+
if (auto value = getValue("log", "enabled"); value) {
157+
log_configs_.enabled = std::string(*value) != "false";
158+
if (log_configs_.enabled) {
159+
if (auto value = getValue("log", "path"); value) {
160+
log_configs_.path = *value;
161+
}
162+
}
163+
}
164+
165+
config_file_ = file;
166+
return true;
167+
}
168+
169+
void saveFile() {
170+
ini_.SetValue("kafka", "bootstrap.servers",
171+
kafka_configs_.bootstrap_servers.c_str());
172+
ini_.SetValue("kafka", "token", kafka_configs_.token.c_str());
173+
ini_.SetBoolValue("log", "enabled", log_configs_.enabled);
174+
if (log_configs_.enabled) {
175+
ini_.SetValue("log", "path", log_configs_.path.c_str());
176+
}
177+
ini_.SaveFile(config_file_.c_str());
178+
}
179+
};

include/snctl-cpp/subcommand.h

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Copyright 2025 Yunze Xu
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
#pragma once
17+
18+
#include <argparse/argparse.hpp>
19+
#include <sstream>
20+
#include <stdexcept>
21+
#include <string>
22+
23+
class SubCommand {
24+
public:
25+
SubCommand(const std::string &name) : name_(name), handle_(name) {}
26+
27+
bool used_by_parent(argparse::ArgumentParser &parent) const noexcept {
28+
return parent.is_subcommand_used(handle_);
29+
}
30+
31+
protected:
32+
void attach_parent(argparse::ArgumentParser &parent) {
33+
parent.add_subparser(handle_);
34+
}
35+
36+
void add_child(argparse::ArgumentParser &child) {
37+
handle_.add_subparser(child);
38+
}
39+
40+
bool is_subcommand_used(const argparse::ArgumentParser &subcommand) const {
41+
return handle_.is_subcommand_used(subcommand);
42+
}
43+
44+
void fail() {
45+
std::ostringstream oss;
46+
oss << "Invalid subcommand for " << name_ << "\n" << handle_;
47+
throw std::runtime_error(oss.str());
48+
}
49+
50+
private:
51+
const std::string name_;
52+
argparse::ArgumentParser handle_;
53+
};

0 commit comments

Comments
 (0)