|
|
@@ -0,0 +1,344 @@
|
|
|
+#include "settings_registry.hpp"
|
|
|
+
|
|
|
+#include "../runners/load_balancer.hpp"
|
|
|
+
|
|
|
+namespace smartbotic::webserver {
|
|
|
+
|
|
|
+std::string settingClassToString(SettingClass klass) {
|
|
|
+ switch (klass) {
|
|
|
+ case SettingClass::Live: return "live";
|
|
|
+ case SettingClass::RestartRequired: return "restart-required";
|
|
|
+ case SettingClass::Secret: return "secret";
|
|
|
+ }
|
|
|
+ return "unknown";
|
|
|
+}
|
|
|
+
|
|
|
+std::string settingComponentToString(SettingComponent component) {
|
|
|
+ switch (component) {
|
|
|
+ case SettingComponent::WebServer: return "webserver";
|
|
|
+ case SettingComponent::Runner: return "runner";
|
|
|
+ }
|
|
|
+ return "unknown";
|
|
|
+}
|
|
|
+
|
|
|
+namespace {
|
|
|
+
|
|
|
+// A webserver-side override of a runner-owned key is invisible to a running
|
|
|
+// runner: the runner reads its own runner.json at its own startup, and
|
|
|
+// nothing today ships settings from the webserver's database over to it.
|
|
|
+// Repeating that sentence in each runner-owned entry's reason would be pure
|
|
|
+// noise, so it lives here once.
|
|
|
+constexpr const char* kRunnerNotPropagated =
|
|
|
+ " A webserver-side override would not reach a running runner - nothing "
|
|
|
+ "propagates settings from the webserver's database to the runner "
|
|
|
+ "process - so this stays restart-required until that propagation "
|
|
|
+ "exists.";
|
|
|
+
|
|
|
+std::optional<std::string> validatePositiveIntRange(const nlohmann::json& value, int64_t min_value,
|
|
|
+ int64_t max_value, const char* unit) {
|
|
|
+ if (!value.is_number_integer()) {
|
|
|
+ return std::string("must be an integer number of ") + unit;
|
|
|
+ }
|
|
|
+ int64_t v = value.get<int64_t>();
|
|
|
+ if (v < min_value || v > max_value) {
|
|
|
+ return "must be between " + std::to_string(min_value) + " and " +
|
|
|
+ std::to_string(max_value) + " " + unit + " (got " + std::to_string(v) + ")";
|
|
|
+ }
|
|
|
+ return std::nullopt;
|
|
|
+}
|
|
|
+
|
|
|
+std::optional<std::string> validateLoadBalancingStrategy(const nlohmann::json& value) {
|
|
|
+ if (!value.is_string()) {
|
|
|
+ return std::string("must be a string, one of: round-robin, least-connections, "
|
|
|
+ "weighted, random");
|
|
|
+ }
|
|
|
+ const std::string s = value.get<std::string>();
|
|
|
+ if (s != "round-robin" && s != "least-connections" && s != "weighted" && s != "random") {
|
|
|
+ return "must be one of: round-robin, least-connections, weighted, random (got '" + s + "')";
|
|
|
+ }
|
|
|
+ return std::nullopt;
|
|
|
+}
|
|
|
+
|
|
|
+std::vector<SettingDef> buildSettingDefs() {
|
|
|
+ std::vector<SettingDef> defs;
|
|
|
+
|
|
|
+ // ---- WebServer-owned, structural (RestartRequired) ----
|
|
|
+
|
|
|
+ defs.push_back({
|
|
|
+ "http_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Bound once when HttpServer is constructed; changing it would mean rebinding the "
|
|
|
+ "listening socket.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.http_port; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "node_sync_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Bound once when the NodeSync gRPC server is constructed.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.node_sync_port; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "credential_service_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Bound once when the Credential gRPC server is constructed.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.credential_service_port; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "static_files_path", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Read once to mount the static file directory when HttpServer is constructed.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.static_files_path; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "database_address", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Used once to open the StorageClient connection at construction - the settings "
|
|
|
+ "store itself lives behind that connection, so this key cannot safely gate its "
|
|
|
+ "own storage.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.database_address; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "database_project", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Used once to open the StorageClient connection at construction, same as "
|
|
|
+ "database_address.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.database_project; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "server.max_upload_mb", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Applied when the httplib server is constructed - the body-size cap is baked into "
|
|
|
+ "HttpServerConfig at that point.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.max_upload_mb; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "server.form_dispatch_threads", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "WebhookController's dispatch thread pool is built once, when routes are set up.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.form_dispatch_threads; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "server.form_dispatch_queue_capacity", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Bounds the same dispatch pool as server.form_dispatch_threads, built once.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.form_dispatch_queue_capacity; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "credentials.pbkdf2_iterations", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
|
|
|
+ "Baked into the AesGcm key-derivation instance at CredentialStore construction; "
|
|
|
+ "changing it without re-deriving already-encrypted credentials would make them "
|
|
|
+ "silently undecryptable rather than fail loudly.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.credentials_config.pbkdf2_iterations; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---- WebServer-owned, secrets ----
|
|
|
+
|
|
|
+ defs.push_back({
|
|
|
+ "auth.jwt_secret", "string", SettingClass::Secret, SettingComponent::WebServer,
|
|
|
+ "Signs every issued token. Rotating it at runtime would invalidate every "
|
|
|
+ "outstanding session without warning, and its value must never be exposed over "
|
|
|
+ "the API.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return nlohmann::json(); },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "credentials.master_key", "string", SettingClass::Secret, SettingComponent::WebServer,
|
|
|
+ "Encrypts and decrypts every stored credential. Rotating it at runtime without "
|
|
|
+ "re-encrypting existing records would make them permanently undecryptable, and "
|
|
|
+ "its value must never be exposed over the API.",
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return nlohmann::json(); },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---- WebServer-owned, live ----
|
|
|
+
|
|
|
+ defs.push_back({
|
|
|
+ "runners.heartbeat_timeout_sec", "int", SettingClass::Live, SettingComponent::WebServer,
|
|
|
+ "Consulted on every RunnerRegistry cleanup tick, not captured once at startup.",
|
|
|
+ "1 to 3600 seconds",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.runner_config.heartbeat_timeout_sec; },
|
|
|
+ [](const nlohmann::json& v) { return validatePositiveIntRange(v, 1, 3600, "seconds"); },
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "runners.offline_removal_sec", "int", SettingClass::Live, SettingComponent::WebServer,
|
|
|
+ "Consulted on every RunnerRegistry cleanup tick, not captured once at startup.",
|
|
|
+ "1 to 86400 seconds",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.runner_config.offline_removal_sec; },
|
|
|
+ [](const nlohmann::json& v) { return validatePositiveIntRange(v, 1, 86400, "seconds"); },
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "runners.load_balancing", "string", SettingClass::Live, SettingComponent::WebServer,
|
|
|
+ "Consulted on every LoadBalancer::selectRunner() call.",
|
|
|
+ "one of: round-robin, least-connections, weighted, random",
|
|
|
+ [](const WebServerServiceConfig& c) {
|
|
|
+ return runners::loadBalancingStrategyToString(c.load_balancer_config.strategy);
|
|
|
+ },
|
|
|
+ validateLoadBalancingStrategy,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "auth.access_token_lifetime_sec", "int64", SettingClass::Live, SettingComponent::WebServer,
|
|
|
+ "Read on every JwtUtils::generateAccessToken() call.",
|
|
|
+ "30 to 86400 seconds",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.jwt_config.access_token_lifetime_sec; },
|
|
|
+ [](const nlohmann::json& v) { return validatePositiveIntRange(v, 30, 86400, "seconds"); },
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "auth.refresh_token_lifetime_sec", "int64", SettingClass::Live, SettingComponent::WebServer,
|
|
|
+ "Read on every JwtUtils::generateRefreshToken() call. This key was present in "
|
|
|
+ "config/webserver.json but, before this change, was never read via cfg.getOr - "
|
|
|
+ "the file value was silently ignored and the struct default (86400s) always won. "
|
|
|
+ "Fixed alongside adding the live accessor.",
|
|
|
+ "60 to 2592000 seconds",
|
|
|
+ [](const WebServerServiceConfig& c) { return c.jwt_config.refresh_token_lifetime_sec; },
|
|
|
+ [](const nlohmann::json& v) { return validatePositiveIntRange(v, 60, 2592000, "seconds"); },
|
|
|
+ });
|
|
|
+
|
|
|
+ // ---- Runner-owned: mechanically per-operation inside the runner, but
|
|
|
+ // restart-required from the webserver's point of view because nothing
|
|
|
+ // propagates an override across the process boundary. ----
|
|
|
+
|
|
|
+ defs.push_back({
|
|
|
+ "grpc_port", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Bound once when the runner's own gRPC server starts.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 9003; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "runner_id", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Identifies this runner instance at registration; changing it mid-run "
|
|
|
+ "would fork its identity from its own history.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "runner-1"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "webserver_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Used once to open the channel back to the webserver at construction.") +
|
|
|
+ kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "localhost:8080"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "node_sync_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Used once to open the NodeSync channel.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "localhost:9002"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "credential_service_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Used once to open the credential-service channel.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "localhost:9003"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "runner.database_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Used once to open the runner's own StorageClient connection. Listed "
|
|
|
+ "under a runner.-prefixed name here because the webserver has its own "
|
|
|
+ "database_address key above; the two are read from different config "
|
|
|
+ "files by different processes and happen to share a bare name in "
|
|
|
+ "runner.json.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "localhost:9004"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "runner.database_project", "string", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Used once to open the runner's own StorageClient connection, same "
|
|
|
+ "naming note as runner.database_address.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return "smartbotic-automation"; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "max_message_size_mb", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Sizes the runner's own gRPC channels and server limits at "
|
|
|
+ "construction.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 64; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "node_sync.enabled", "bool", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Decided once at startup, to choose whether the node-sync client "
|
|
|
+ "thread is started at all.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return true; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "node_sync.reconnect_interval_ms", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Consumed per reconnect attempt inside the runner process, which "
|
|
|
+ "makes it mechanically per-operation there - but it is the runner, "
|
|
|
+ "not the webserver, that reads it.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 5000; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "registration.heartbeat_interval_sec", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Governs the interval of the runner's own heartbeat loop; read once "
|
|
|
+ "into that loop's timer at startup.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 10; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "registration.max_concurrent_executions", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Reported to the webserver at registration and consulted by the "
|
|
|
+ "runner's own admission check.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 10; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "execution.default_timeout_ms", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Read by the workflow engine inside the runner process.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 60000; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+ defs.push_back({
|
|
|
+ "execution.max_memory_per_script_mb", "int", SettingClass::RestartRequired, SettingComponent::Runner,
|
|
|
+ std::string("Read by the script engine inside the runner process.") + kRunnerNotPropagated,
|
|
|
+ "",
|
|
|
+ [](const WebServerServiceConfig&) { return 64; },
|
|
|
+ nullptr,
|
|
|
+ });
|
|
|
+
|
|
|
+ return defs;
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
+const std::vector<SettingDef>& allSettingDefs() {
|
|
|
+ static const std::vector<SettingDef> defs = buildSettingDefs();
|
|
|
+ return defs;
|
|
|
+}
|
|
|
+
|
|
|
+const SettingDef* findSettingDef(const std::string& key) {
|
|
|
+ for (const auto& def : allSettingDefs()) {
|
|
|
+ if (def.key == key) {
|
|
|
+ return &def;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nullptr;
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace smartbotic::webserver
|