From a72afb35a0bb30e17c00c96be86d8452f93a9df8 Mon Sep 17 00:00:00 2001 From: alyssa Date: Mon, 10 Mar 2025 15:12:56 +0000 Subject: [PATCH] feat: add remote config over http/redis --- PluralKit.Bot/BotConfig.cs | 2 + PluralKit.Bot/Init.cs | 7 ++ PluralKit.Bot/Modules.cs | 2 + PluralKit.Bot/PluralKit.Bot.csproj | 1 + PluralKit.Bot/Services/HttpListenerService.cs | 56 +++++++++++++++ .../Services/RuntimeConfigService.cs | 58 +++++++++++++++ PluralKit.Bot/packages.lock.json | 47 ++++++++++++ PluralKit.Tests/packages.lock.json | 49 ++++++++++++- crates/gateway/src/cache_api.rs | 23 +++++- crates/gateway/src/main.rs | 12 +++- crates/libpk/src/lib.rs | 1 + crates/libpk/src/runtime_config.rs | 72 +++++++++++++++++++ 12 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 PluralKit.Bot/Services/HttpListenerService.cs create mode 100644 PluralKit.Bot/Services/RuntimeConfigService.cs create mode 100644 crates/libpk/src/runtime_config.rs diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index fdc54ad2..a9db7233 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -24,6 +24,8 @@ public class BotConfig public string? HttpCacheUrl { get; set; } public bool HttpUseInnerCache { get; set; } = false; + public string? HttpListenerAddr { get; set; } + public string? DiscordBaseUrl { get; set; } public string? AvatarServiceUrl { get; set; } diff --git a/PluralKit.Bot/Init.cs b/PluralKit.Bot/Init.cs index c4b9ed65..f19576de 100644 --- a/PluralKit.Bot/Init.cs +++ b/PluralKit.Bot/Init.cs @@ -76,6 +76,13 @@ public class Init // Init the bot instance itself, register handlers and such to the client before beginning to connect bot.Init(); + // load runtime config from redis + await services.Resolve().LoadConfig(); + + // Start HTTP server + if (config.HttpListenerAddr != null) + services.Resolve().Start(config.HttpListenerAddr); + // Start the Discord shards themselves (handlers already set up) logger.Information("Connecting to Discord"); await StartCluster(services); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 6ceea629..ae6f85ec 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -153,6 +153,8 @@ public class BotModule: Module builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 01d89e1d..753d1f30 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -24,5 +24,6 @@ + diff --git a/PluralKit.Bot/Services/HttpListenerService.cs b/PluralKit.Bot/Services/HttpListenerService.cs new file mode 100644 index 00000000..73a1f907 --- /dev/null +++ b/PluralKit.Bot/Services/HttpListenerService.cs @@ -0,0 +1,56 @@ +using Serilog; + +using Newtonsoft.Json; + +using WatsonWebserver.Lite; +using WatsonWebserver.Core; + +namespace PluralKit.Bot; + +public class HttpListenerService +{ + private readonly ILogger _logger; + private readonly RuntimeConfigService _runtimeConfig; + + public HttpListenerService(ILogger logger, RuntimeConfigService runtimeConfig) + { + _logger = logger.ForContext(); + _runtimeConfig = runtimeConfig; + } + + public void Start(string host) + { + var server = new WebserverLite(new WebserverSettings(host, 5002), DefaultRoute); + + server.Routes.PreAuthentication.Static.Add(WatsonWebserver.Core.HttpMethod.GET, "/runtime_config", RuntimeConfigGet); + server.Routes.PreAuthentication.Parameter.Add(WatsonWebserver.Core.HttpMethod.POST, "/runtime_config/{key}", RuntimeConfigSet); + server.Routes.PreAuthentication.Parameter.Add(WatsonWebserver.Core.HttpMethod.DELETE, "/runtime_config/{key}", RuntimeConfigDelete); + + server.Start(); + } + + private async Task DefaultRoute(HttpContextBase ctx) + => await ctx.Response.Send("hellorld"); + + private async Task RuntimeConfigGet(HttpContextBase ctx) + { + var config = _runtimeConfig.GetAll(); + ctx.Response.Headers.Add("content-type", "application/json"); + await ctx.Response.Send(JsonConvert.SerializeObject(config)); + } + + private async Task RuntimeConfigSet(HttpContextBase ctx) + { + var key = ctx.Request.Url.Parameters["key"]; + var value = ctx.Request.DataAsString; + await _runtimeConfig.Set(key, value); + await RuntimeConfigGet(ctx); + } + + private async Task RuntimeConfigDelete(HttpContextBase ctx) + { + var key = ctx.Request.Url.Parameters["key"]; + await _runtimeConfig.Delete(key); + await RuntimeConfigGet(ctx); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Services/RuntimeConfigService.cs b/PluralKit.Bot/Services/RuntimeConfigService.cs new file mode 100644 index 00000000..2d71a2a2 --- /dev/null +++ b/PluralKit.Bot/Services/RuntimeConfigService.cs @@ -0,0 +1,58 @@ +using Newtonsoft.Json; + +using Serilog; + +using StackExchange.Redis; + +using PluralKit.Core; + +namespace PluralKit.Bot; + +public class RuntimeConfigService +{ + private readonly RedisService _redis; + private readonly ILogger _logger; + + private Dictionary settings = new(); + + private string RedisKey; + + public RuntimeConfigService(ILogger logger, RedisService redis, BotConfig config) + { + _logger = logger.ForContext(); + _redis = redis; + + var clusterId = config.Cluster?.NodeIndex ?? 0; + RedisKey = $"remote_config:dotnet_bot:{clusterId}"; + } + + public async Task LoadConfig() + { + var redisConfig = await _redis.Connection.GetDatabase().HashGetAllAsync(RedisKey); + foreach (var entry in redisConfig) + settings.Add(entry.Name, entry.Value); + + var configStr = JsonConvert.SerializeObject(settings); + _logger.Information($"starting with runtime config: {configStr}"); + } + + public async Task Set(string key, string value) + { + await _redis.Connection.GetDatabase().HashSetAsync(RedisKey, new[] { new HashEntry(key, new RedisValue(value)) }); + settings.Add(key, value); + _logger.Information($"updated runtime config: {key}={value}"); + } + + public async Task Delete(string key) + { + await _redis.Connection.GetDatabase().HashDeleteAsync(RedisKey, key); + settings.Remove(key); + _logger.Information($"updated runtime config: {key} removed"); + } + + public object? Get(string key) => settings.GetValueOrDefault(key); + + public bool Exists(string key) => settings.ContainsKey(key); + + public Dictionary GetAll() => settings; +} \ No newline at end of file diff --git a/PluralKit.Bot/packages.lock.json b/PluralKit.Bot/packages.lock.json index e2eda930..fca78f6e 100644 --- a/PluralKit.Bot/packages.lock.json +++ b/PluralKit.Bot/packages.lock.json @@ -14,6 +14,16 @@ "resolved": "4.13.0", "contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg==" }, + "Watson.Lite": { + "type": "Direct", + "requested": "[6.3.5, )", + "resolved": "6.3.5", + "contentHash": "YF8+se3IVenn8YlyNeb4wSJK6QMnVD0QHIOEiZ22wS4K2wkwoSDzWS+ZAjk1MaPeB+XO5gRoENUN//pOc+wI2g==", + "dependencies": { + "CavemanTcp": "2.0.5", + "Watson.Core": "6.3.5" + } + }, "App.Metrics": { "type": "Transitive", "resolved": "4.3.0", @@ -107,6 +117,11 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1" } }, + "CavemanTcp": { + "type": "Transitive", + "resolved": "2.0.5", + "contentHash": "90wywmGpjrj26HMAkufYZwuZI8sVYB1mRwEdqugSR3kgDnPX+3l0jO86gwtFKsPvsEpsS4Dn/1EbhguzUxMU8Q==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.35", @@ -130,6 +145,11 @@ "System.Diagnostics.DiagnosticSource": "5.0.0" } }, + "IpMatcher": { + "type": "Transitive", + "resolved": "1.0.5", + "contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw==" + }, "IPNetwork2": { "type": "Transitive", "resolved": "3.0.667", @@ -391,6 +411,11 @@ "resolved": "8.5.0", "contentHash": "VYYMZNitZ85UEhwOKkTQI63WEMvzUqwQc74I2mm8h/DBVAMcBBxqYPni4DmuRtbCwngmuONuK2yBJfWNRKzI+A==" }, + "RegexMatcher": { + "type": "Transitive", + "resolved": "1.0.9", + "contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q==" + }, "Serilog": { "type": "Transitive", "resolved": "4.2.0", @@ -714,6 +739,28 @@ "System.Runtime": "4.3.0" } }, + "Timestamps": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ==" + }, + "UrlMatcher": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA==" + }, + "Watson.Core": { + "type": "Transitive", + "resolved": "6.3.5", + "contentHash": "Y5YxKOCSLe2KDmfwvI/J0qApgmmZR77LwyoufRVfKH7GLdHiE7fY0IfoNxWTG7nNv8knBfgwyOxdehRm+4HaCg==", + "dependencies": { + "IpMatcher": "1.0.5", + "RegexMatcher": "1.0.9", + "System.Text.Json": "8.0.5", + "Timestamps": "1.0.11", + "UrlMatcher": "3.0.1" + } + }, "myriad": { "type": "Project", "dependencies": { diff --git a/PluralKit.Tests/packages.lock.json b/PluralKit.Tests/packages.lock.json index 2ba02316..c11cedb5 100644 --- a/PluralKit.Tests/packages.lock.json +++ b/PluralKit.Tests/packages.lock.json @@ -128,6 +128,11 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1" } }, + "CavemanTcp": { + "type": "Transitive", + "resolved": "2.0.5", + "contentHash": "90wywmGpjrj26HMAkufYZwuZI8sVYB1mRwEdqugSR3kgDnPX+3l0jO86gwtFKsPvsEpsS4Dn/1EbhguzUxMU8Q==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.35", @@ -156,6 +161,11 @@ "resolved": "2.14.1", "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, + "IpMatcher": { + "type": "Transitive", + "resolved": "1.0.5", + "contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw==" + }, "IPNetwork2": { "type": "Transitive", "resolved": "3.0.667", @@ -510,6 +520,11 @@ "resolved": "8.5.0", "contentHash": "VYYMZNitZ85UEhwOKkTQI63WEMvzUqwQc74I2mm8h/DBVAMcBBxqYPni4DmuRtbCwngmuONuK2yBJfWNRKzI+A==" }, + "RegexMatcher": { + "type": "Transitive", + "resolved": "1.0.9", + "contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q==" + }, "Sentry": { "type": "Transitive", "resolved": "4.13.0", @@ -887,6 +902,37 @@ "System.Runtime": "4.3.0" } }, + "Timestamps": { + "type": "Transitive", + "resolved": "1.0.11", + "contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ==" + }, + "UrlMatcher": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA==" + }, + "Watson.Core": { + "type": "Transitive", + "resolved": "6.3.5", + "contentHash": "Y5YxKOCSLe2KDmfwvI/J0qApgmmZR77LwyoufRVfKH7GLdHiE7fY0IfoNxWTG7nNv8knBfgwyOxdehRm+4HaCg==", + "dependencies": { + "IpMatcher": "1.0.5", + "RegexMatcher": "1.0.9", + "System.Text.Json": "8.0.5", + "Timestamps": "1.0.11", + "UrlMatcher": "3.0.1" + } + }, + "Watson.Lite": { + "type": "Transitive", + "resolved": "6.3.5", + "contentHash": "YF8+se3IVenn8YlyNeb4wSJK6QMnVD0QHIOEiZ22wS4K2wkwoSDzWS+ZAjk1MaPeB+XO5gRoENUN//pOc+wI2g==", + "dependencies": { + "CavemanTcp": "2.0.5", + "Watson.Core": "6.3.5" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -954,7 +1000,8 @@ "Humanizer.Core": "[2.14.1, )", "Myriad": "[1.0.0, )", "PluralKit.Core": "[1.0.0, )", - "Sentry": "[4.13.0, )" + "Sentry": "[4.13.0, )", + "Watson.Lite": "[6.3.5, )" } }, "pluralkit.core": { diff --git a/crates/gateway/src/cache_api.rs b/crates/gateway/src/cache_api.rs index c73424f9..e3de4696 100644 --- a/crates/gateway/src/cache_api.rs +++ b/crates/gateway/src/cache_api.rs @@ -2,9 +2,10 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::get, + routing::{delete, get, post}, Router, }; +use libpk::runtime_config::RuntimeConfig; use serde_json::{json, to_string}; use tracing::{error, info}; use twilight_model::id::Id; @@ -21,7 +22,11 @@ fn status_code(code: StatusCode, body: String) -> Response { // this function is manually formatted for easier legibility of route_services #[rustfmt::skip] -pub async fn run_server(cache: Arc) -> anyhow::Result<()> { +pub async fn run_server(cache: Arc, runtime_config: Arc) -> anyhow::Result<()> { + // hacky fix for `move` + let runtime_config_for_post = runtime_config.clone(); + let runtime_config_for_delete = runtime_config.clone(); + let app = Router::new() .route( "/guilds/:guild_id", @@ -171,6 +176,20 @@ pub async fn run_server(cache: Arc) -> anyhow::Result<()> { status_code(StatusCode::FOUND, to_string(&stats).unwrap()) })) + .route("/runtime_config", get(|| async move { + status_code(StatusCode::FOUND, to_string(&runtime_config.get_all().await).unwrap()) + })) + .route("/runtime_config/:key", post(|Path(key): Path, body: String| async move { + let runtime_config = runtime_config_for_post; + runtime_config.set(key, body).await.expect("failed to update runtime config"); + status_code(StatusCode::FOUND, to_string(&runtime_config.get_all().await).unwrap()) + })) + .route("/runtime_config/:key", delete(|Path(key): Path| async move { + let runtime_config = runtime_config_for_delete; + runtime_config.delete(key).await.expect("failed to update runtime config"); + status_code(StatusCode::FOUND, to_string(&runtime_config.get_all().await).unwrap()) + })) + .layer(axum::middleware::from_fn(crate::logger::logger)) .with_state(cache); diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 6bc33e13..254ce504 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -2,7 +2,9 @@ #![feature(if_let_guard)] use chrono::Timelike; +use discord::gateway::cluster_config; use fred::{clients::RedisPool, interfaces::*}; +use libpk::runtime_config::RuntimeConfig; use signal_hook::{ consts::{SIGINT, SIGTERM}, iterator::Signals, @@ -28,6 +30,14 @@ async fn real_main() -> anyhow::Result<()> { let redis = libpk::db::init_redis().await?; + let runtime_config = Arc::new( + RuntimeConfig::new( + redis.clone(), + format!("gateway:{}", cluster_config().node_id), + ) + .await?, + ); + let shard_state = discord::shard_state::new(redis.clone()); let cache = Arc::new(discord::cache::new()); @@ -57,7 +67,7 @@ async fn real_main() -> anyhow::Result<()> { // todo: probably don't do it this way let api_shutdown_tx = shutdown_tx.clone(); set.spawn(tokio::spawn(async move { - match cache_api::run_server(cache).await { + match cache_api::run_server(cache, runtime_config).await { Err(error) => { tracing::error!(?error, "failed to serve cache api"); let _ = api_shutdown_tx.send(()); diff --git a/crates/libpk/src/lib.rs b/crates/libpk/src/lib.rs index af967728..1864e332 100644 --- a/crates/libpk/src/lib.rs +++ b/crates/libpk/src/lib.rs @@ -8,6 +8,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte use sentry_tracing::event_from_event; pub mod db; +pub mod runtime_config; pub mod state; pub mod _config; diff --git a/crates/libpk/src/runtime_config.rs b/crates/libpk/src/runtime_config.rs new file mode 100644 index 00000000..739818e4 --- /dev/null +++ b/crates/libpk/src/runtime_config.rs @@ -0,0 +1,72 @@ +use fred::{clients::RedisPool, interfaces::HashesInterface}; +use std::collections::HashMap; +use tokio::sync::RwLock; +use tracing::info; + +pub struct RuntimeConfig { + redis: RedisPool, + settings: RwLock>, + redis_key: String, +} + +impl RuntimeConfig { + pub async fn new(redis: RedisPool, component_key: String) -> anyhow::Result { + let redis_key = format!("remote_config:{component_key}"); + + let mut c = RuntimeConfig { + redis, + settings: RwLock::new(HashMap::new()), + redis_key, + }; + + c.load().await?; + + Ok(c) + } + + pub async fn load(&mut self) -> anyhow::Result<()> { + let redis_config: HashMap = self.redis.hgetall(&self.redis_key).await?; + + let mut settings = self.settings.write().await; + + for (key, value) in redis_config { + settings.insert(key, value); + } + + info!("starting with runtime config: {:?}", self.settings); + Ok(()) + } + + pub async fn set(&self, key: String, value: String) -> anyhow::Result<()> { + self.redis + .hset::<(), &str, (String, String)>(&self.redis_key, (key.clone(), value.clone())) + .await?; + self.settings + .write() + .await + .insert(key.clone(), value.clone()); + info!("updated runtime config: {key}={value}"); + Ok(()) + } + + pub async fn delete(&self, key: String) -> anyhow::Result<()> { + self.redis + .hdel::<(), &str, String>(&self.redis_key, key.clone()) + .await?; + self.settings.write().await.remove(&key.clone()); + info!("updated runtime config: {key} removed"); + Ok(()) + } + + pub async fn get(&self, key: String) -> Option { + self.settings.read().await.get(&key).cloned() + } + + pub async fn exists(&self, key: &str) -> bool { + self.settings.read().await.contains_key(key) + } + + pub async fn get_all(&self) -> HashMap { + self.settings.read().await.clone() + } +}