Skip to content

Commit db8b40e

Browse files
authored
Merge pull request #25 from smdn/cap-multigraph
Implement 'multigraph' protocol extension
2 parents 416c312 + 4b71eeb commit db8b40e

File tree

14 files changed

+770
-63
lines changed

14 files changed

+770
-63
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-FileCopyrightText: 2025 smdn <smdn@smdn.jp>
2+
// SPDX-License-Identifier: MIT
3+
4+
using System;
5+
using System.Net;
6+
using System.Threading;
7+
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
11+
using Smdn.Net.MuninNode;
12+
using Smdn.Net.MuninPlugin;
13+
14+
const string NodeHostName = "test.munin-node.localhost";
15+
const int NodePort = 14949;
16+
17+
var plugins = new IPlugin[] {
18+
new MultigraphPlugin(
19+
name: "multigraph_root",
20+
plugins: [
21+
PluginFactory.CreatePlugin(
22+
name: "subgraph_1",
23+
fieldLabel: "value",
24+
fetchFieldValue: () => Random.Shared.Next(0, 10),
25+
graphAttributes: new PluginGraphAttributesBuilder(title: "Sub-graph 1")
26+
.WithCategoryOther()
27+
.WithVerticalLabel("number")
28+
.WithGraphLimit(0, 10)
29+
.Build()
30+
),
31+
PluginFactory.CreatePlugin(
32+
name: "subgraph_2",
33+
fieldLabel: "value",
34+
fetchFieldValue: () => Random.Shared.Next(0, 10),
35+
graphAttributes: new PluginGraphAttributesBuilder(title: "Sub-graph 2")
36+
.WithCategoryOther()
37+
.WithVerticalLabel("number")
38+
.WithGraphLimit(0, 10)
39+
.Build()
40+
),
41+
PluginFactory.CreatePlugin(
42+
name: "subgraph_3",
43+
fieldLabel: "value",
44+
fetchFieldValue: () => Random.Shared.Next(0, 10),
45+
graphAttributes: new PluginGraphAttributesBuilder(title: "Sub-graph 3")
46+
.WithCategoryOther()
47+
.WithVerticalLabel("number")
48+
.WithGraphLimit(0, 10)
49+
.Build()
50+
),
51+
]
52+
)
53+
};
54+
55+
var services = new ServiceCollection();
56+
57+
services.AddLogging(
58+
builder => builder
59+
.AddSimpleConsole(static options => {
60+
options.SingleLine = true;
61+
options.IncludeScopes = true;
62+
})
63+
.AddFilter(static level => LogLevel.Trace <= level)
64+
);
65+
66+
await using var node = LocalNode.Create(
67+
plugins: plugins,
68+
port: NodePort,
69+
hostName: NodeHostName,
70+
addressListAllowFrom: [IPAddress.Loopback, IPAddress.IPv6Loopback],
71+
serviceProvider: services.BuildServiceProvider()
72+
);
73+
74+
var cts = new CancellationTokenSource();
75+
76+
Console.CancelKeyPress += (_, args) => {
77+
cts.Cancel();
78+
args.Cancel = true;
79+
};
80+
81+
try {
82+
await node.RunAsync(cts.Token);
83+
}
84+
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token) {
85+
Console.WriteLine("stopped");
86+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 smdn <smdn@smdn.jp>
3+
SPDX-License-Identifier: MIT
4+
-->
5+
<Project Sdk="Microsoft.NET.Sdk">
6+
<PropertyGroup>
7+
<OutputType>Exe</OutputType>
8+
<TargetFramework>net8.0</TargetFramework>
9+
<Nullable>enable</Nullable>
10+
</PropertyGroup>
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
13+
<PackageReference Include="Smdn.Net.MuninNode" Version="2.4.0" />
14+
</ItemGroup>
15+
</Project>

src/Smdn.Net.MuninNode/Smdn.Net.MuninNode.Protocol/MuninProtocolHandler.cs

Lines changed: 124 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ private sealed class StringListPool(int initialCapacity)
6969
private readonly string versionInformation;
7070
private readonly ArrayBufferWriterPool bufferWriterPool = new(initialCapacity: 256);
7171
private readonly StringListPool responseLineListPool = new(initialCapacity: 32);
72+
private readonly Dictionary<string, IPlugin> plugins = new(StringComparer.Ordinal);
7273

7374
/// <summary>
7475
/// Gets a value indicating whether the <c>munin master</c> supports
@@ -80,6 +81,21 @@ private sealed class StringListPool(int initialCapacity)
8081
/// </seealso>
8182
protected bool IsDirtyConfigEnabled { get; private set; }
8283

84+
/// <summary>
85+
/// Gets a value indicating whether the <c>munin master</c> supports
86+
/// <c>multigraph</c> protocol extension and enables it.
87+
/// </summary>
88+
/// <seealso cref="HandleCapCommandAsync"/>
89+
/// <seealso href="https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html">
90+
/// Protocol extension: multiple graphs from one plugin
91+
/// </seealso>
92+
/// <seealso href="https://guide.munin-monitoring.org/en/latest/plugin/multigraphing.html">
93+
/// Multigraph plugins
94+
/// </seealso>
95+
protected bool IsMultigraphEnabled { get; private set; }
96+
97+
private string joinedPluginList = string.Empty;
98+
8399
public MuninProtocolHandler(
84100
IMuninNodeProfile profile
85101
)
@@ -88,6 +104,24 @@ IMuninNodeProfile profile
88104

89105
banner = $"# munin node at {profile.HostName}";
90106
versionInformation = $"munins node on {profile.HostName} version: {profile.Version}";
107+
108+
ReinitializePluginDictionary();
109+
}
110+
111+
private void ReinitializePluginDictionary()
112+
{
113+
var flattenMultigraphPlugins = !IsMultigraphEnabled;
114+
115+
plugins.Clear();
116+
117+
foreach (var plugin in profile.PluginProvider.EnumeratePlugins(flattenMultigraphPlugins)) {
118+
plugins[plugin.Name] = plugin; // duplicate plugin names are not considered
119+
}
120+
121+
joinedPluginList = string.Join(
122+
' ',
123+
profile.PluginProvider.EnumeratePlugins(flattenMultigraphPlugins).Select(static plugin => plugin.Name)
124+
);
91125
}
92126

93127
/// <inheritdoc cref="IMuninProtocolHandler.HandleTransactionStartAsync"/>
@@ -397,29 +431,52 @@ CancellationToken cancellationToken
397431
/// even when <c>dirtyconfig</c> is enabled.
398432
/// </remarks>
399433
/// <seealso cref="IsDirtyConfigEnabled"/>
434+
/// <seealso cref="IsMultigraphEnabled"/>
400435
/// <seealso href="https://guide.munin-monitoring.org/en/latest/master/network-protocol.html">
401436
/// Data exchange between master and node - `cap` command
402437
/// </seealso>
403438
/// <seealso href="https://guide.munin-monitoring.org/en/latest/plugin/protocol-dirtyconfig.html">
404439
/// Protocol extension: dirtyconfig
405440
/// </seealso>
441+
/// <seealso href="https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html">
442+
/// Protocol extension: multiple graphs from one plugin
443+
/// </seealso>
406444
protected virtual ValueTask HandleCapCommandAsync(
407445
IMuninNodeClient client,
408446
ReadOnlySequence<byte> arguments,
409447
CancellationToken cancellationToken
410448
)
411449
{
450+
var wasMultigraphEnabled = IsMultigraphEnabled;
451+
412452
// 'Protocol extension: dirtyconfig' (https://guide.munin-monitoring.org/en/latest/plugin/protocol-dirtyconfig.html)
413453
IsDirtyConfigEnabled = SequenceContains(arguments, "dirtyconfig"u8);
414454

415-
// TODO: multigraph (https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html)
416-
var responseLine = IsDirtyConfigEnabled ? "cap dirtyconfig" : "cap";
455+
// 'Protocol extension: multiple graphs from one plugin' (https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html)
456+
IsMultigraphEnabled = SequenceContains(arguments, "multigraph"u8);
457+
458+
if (IsMultigraphEnabled != wasMultigraphEnabled)
459+
ReinitializePluginDictionary();
417460

418461
return SendResponseAsync(
419462
client: client ?? throw new ArgumentNullException(nameof(client)),
420-
responseLine: responseLine,
463+
responseLine: GetCapResponseLine(IsDirtyConfigEnabled, IsMultigraphEnabled),
421464
cancellationToken: cancellationToken
422465
);
466+
467+
static string GetCapResponseLine(bool dirtyconfig, bool multigraph)
468+
{
469+
if (dirtyconfig && multigraph)
470+
return "cap dirtyconfig multigraph";
471+
472+
if (dirtyconfig)
473+
return "cap dirtyconfig";
474+
475+
if (multigraph)
476+
return "cap multigraph";
477+
478+
return "cap";
479+
}
423480
}
424481

425482
/// <summary>
@@ -437,7 +494,7 @@ CancellationToken cancellationToken
437494
// XXX: ignore [node] arguments
438495
return SendResponseAsync(
439496
client: client ?? throw new ArgumentNullException(nameof(client)),
440-
responseLine: string.Join(" ", profile.PluginProvider.Plugins.Select(static plugin => plugin.Name)),
497+
responseLine: joinedPluginList,
441498
cancellationToken: cancellationToken
442499
);
443500
}
@@ -458,11 +515,8 @@ CancellationToken cancellationToken
458515
throw new ArgumentNullException(nameof(client));
459516

460517
var queryItem = profile.Encoding.GetString(arguments);
461-
var plugin = profile.PluginProvider.Plugins.FirstOrDefault(
462-
plugin => string.Equals(queryItem, plugin.Name, StringComparison.Ordinal)
463-
);
464518

465-
if (plugin is null) {
519+
if (!plugins.TryGetValue(queryItem, out var plugin)) {
466520
await SendResponseAsync(
467521
client,
468522
ResponseLinesUnknownService,
@@ -475,11 +529,25 @@ await SendResponseAsync(
475529
var responseLines = responseLineListPool.Take();
476530

477531
try {
478-
await WriteFetchResponseAsync(
479-
plugin.DataSource,
480-
responseLines,
481-
cancellationToken
482-
).ConfigureAwait(false);
532+
if (plugin is IMultigraphPlugin multigraphPlugin) {
533+
// 'Protocol extension: multiple graphs from one plugin' (https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html)
534+
foreach (var subPlugin in multigraphPlugin.Plugins) {
535+
responseLines.Add($"multigraph {subPlugin.Name}");
536+
537+
await WriteFetchResponseAsync(
538+
subPlugin.DataSource,
539+
responseLines,
540+
cancellationToken
541+
).ConfigureAwait(false);
542+
}
543+
}
544+
else {
545+
await WriteFetchResponseAsync(
546+
plugin.DataSource,
547+
responseLines,
548+
cancellationToken
549+
).ConfigureAwait(false);
550+
}
483551

484552
responseLines.Add(".");
485553

@@ -525,11 +593,8 @@ CancellationToken cancellationToken
525593
throw new ArgumentNullException(nameof(client));
526594

527595
var queryItem = profile.Encoding.GetString(arguments);
528-
var plugin = profile.PluginProvider.Plugins.FirstOrDefault(
529-
plugin => string.Equals(queryItem, plugin.Name, StringComparison.Ordinal)
530-
);
531596

532-
if (plugin is null) {
597+
if (!plugins.TryGetValue(queryItem, out var plugin)) {
533598
return SendResponseAsync(
534599
client,
535600
ResponseLinesUnknownService,
@@ -544,16 +609,25 @@ async ValueTask HandleConfigCommandAsyncCore()
544609
var responseLines = responseLineListPool.Take();
545610

546611
try {
547-
WriteConfigResponse(
548-
plugin,
549-
responseLines
550-
);
551-
552-
if (IsDirtyConfigEnabled) {
553-
await WriteFetchResponseAsync(
554-
dataSource: plugin.DataSource,
555-
responseLines: responseLines,
556-
cancellationToken: cancellationToken
612+
if (plugin is IMultigraphPlugin multigraphPlugin) {
613+
// 'Protocol extension: multiple graphs from one plugin' (https://guide.munin-monitoring.org/en/latest/plugin/protocol-multigraph.html)
614+
foreach (var subPlugin in multigraphPlugin.Plugins) {
615+
responseLines.Add($"multigraph {subPlugin.Name}");
616+
617+
await WriteConfigResponseAsync(
618+
subPlugin,
619+
includeFetchResponse: IsDirtyConfigEnabled,
620+
responseLines,
621+
cancellationToken
622+
).ConfigureAwait(false);
623+
}
624+
}
625+
else {
626+
await WriteConfigResponseAsync(
627+
plugin,
628+
includeFetchResponse: IsDirtyConfigEnabled,
629+
responseLines,
630+
cancellationToken
557631
).ConfigureAwait(false);
558632
}
559633

@@ -569,6 +643,29 @@ await SendResponseAsync(
569643
responseLineListPool.Return(responseLines);
570644
}
571645
}
646+
647+
static ValueTask WriteConfigResponseAsync(
648+
IPlugin plugin,
649+
bool includeFetchResponse,
650+
List<string> responseLines,
651+
CancellationToken cancellationToken
652+
)
653+
{
654+
WriteConfigResponse(
655+
plugin,
656+
responseLines
657+
);
658+
659+
if (includeFetchResponse) {
660+
return WriteFetchResponseAsync(
661+
dataSource: plugin.DataSource,
662+
responseLines: responseLines,
663+
cancellationToken: cancellationToken
664+
);
665+
}
666+
667+
return default;
668+
}
572669
}
573670

574671
private static void WriteConfigResponse(

src/Smdn.Net.MuninNode/Smdn.Net.MuninNode/NodeBase.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: MIT
33
using System;
44
using System.Buffers;
5+
using System.Collections.Generic;
56
using System.IO.Pipelines;
67
using System.Net;
78
using System.Net.Sockets;
@@ -487,9 +488,8 @@ CancellationToken cancellationToken
487488
if (PluginProvider.SessionCallback is INodeSessionCallback pluginProviderSessionCallback)
488489
await pluginProviderSessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
489490

490-
foreach (var plugin in PluginProvider.Plugins) {
491-
if (plugin.SessionCallback is INodeSessionCallback pluginSessionCallback)
492-
await pluginSessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
491+
foreach (var pluginSessionCallback in EnumerateSessionCallbackForPlugins(PluginProvider)) {
492+
await pluginSessionCallback.ReportSessionStartedAsync(sessionId, cancellationToken).ConfigureAwait(false);
493493
}
494494

495495
// https://docs.microsoft.com/ja-jp/dotnet/standard/io/pipelines
@@ -504,16 +504,23 @@ await Task.WhenAll(
504504
LogSessionClosed(Logger, null);
505505
}
506506
finally {
507-
foreach (var plugin in PluginProvider.Plugins) {
508-
if (plugin.SessionCallback is INodeSessionCallback pluginSessionCallback)
509-
await pluginSessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
507+
foreach (var pluginSessionCallback in EnumerateSessionCallbackForPlugins(PluginProvider)) {
508+
await pluginSessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
510509
}
511510

512511
if (PluginProvider.SessionCallback is INodeSessionCallback pluginProviderSessionCallback)
513512
await pluginProviderSessionCallback.ReportSessionClosedAsync(sessionId, cancellationToken).ConfigureAwait(false);
514513

515514
await protocolHandler.HandleTransactionEndAsync(client, cancellationToken).ConfigureAwait(false);
516515
}
516+
517+
static IEnumerable<INodeSessionCallback> EnumerateSessionCallbackForPlugins(IPluginProvider pluginProvider)
518+
{
519+
foreach (var plugin in pluginProvider.EnumeratePlugins(flattenMultigraphPlugins: true)) {
520+
if (plugin.SessionCallback is INodeSessionCallback pluginSessionCallback)
521+
yield return pluginSessionCallback;
522+
}
523+
}
517524
}
518525

519526
private static string GenerateSessionId(EndPoint? localEndPoint, EndPoint? remoteEndPoint)

0 commit comments

Comments
 (0)