Files
CydandClaude Fable 5 4b9c46251e SiteConfigMerge: frame checks as plan tripwires; exit 3 on warnings
Per operator: under the SiteLink IP plan there should never be
GUID/MAC/IP conflicts. Reframed the merge checks accordingly - they
are tripwires for plan drift (un-renumbered site) or stale inventory
(same pod recorded in two site files), not expected events. The merge
now exits 3 when any warning fired (master still written) so event-day
scripts can gate on a clean merge; 0 = clean, 1 = usage, 2 = hard
error. Verified: same-file-twice merge exits 3, clean merge exits 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:40:09 -05:00

176 lines
7.2 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace SiteConfigMerge
{
internal static class Program
{
private static int Main(string[] args)
{
try
{
if (args.Length == 0)
return Usage();
switch (args[0].ToLowerInvariant())
{
case "dump":
return Dump(args.Skip(1).ToArray());
case "merge":
return Merge(args.Skip(1).ToArray());
default:
return Usage();
}
}
catch (Exception ex)
{
Console.Error.WriteLine("ERROR: " + ex.Message);
return 2;
}
}
private static int Usage()
{
Console.WriteLine(
@"SiteConfigMerge — decode and merge TeslaConsole .siteconfig files (SiteLink)
Usage:
SiteConfigMerge dump <file.siteconfig> [...]
Decode and print squads/pods of each file.
SiteConfigMerge merge -o <master.siteconfig> <siteName>.siteconfig [...]
Merge sites into one config. The site name is taken from each input
file name; every squad is renamed ""<siteName>-<original squad name>"".
Pod records are copied byte-for-byte from the inputs.");
return 1;
}
private static int Dump(string[] files)
{
if (files.Length == 0)
return Usage();
foreach (var file in files)
{
var binder = new TeslaBinder();
var squads = SiteConfigFile.Read(file, binder);
Console.WriteLine($"=== {file}");
Console.WriteLine($" serialized by: {binder.CapturedAssembly ?? "(no TeslaConsole records)"}");
Console.WriteLine($" squads: {squads.Count}");
foreach (var entry in squads)
{
Console.WriteLine($" Squad \"{entry.Squad.Name}\" guid={entry.Squad.Guid} online={entry.Squad.Online} pods={entry.Pods.Count}");
foreach (var p in entry.Pods.Select(e => e.Pod))
{
Console.WriteLine($" Pod \"{p.Name}\" host={p.HostName} type={p.HostType}");
Console.WriteLine($" ip={p.IPAddress} subnet={p.Subnet} gw={p.Gateway} dns={p.Dns}");
Console.WriteLine($" mac={FormatMac(p.MacAddress)} key={(p.Key == null || p.Key.Length == 0 ? "none" : p.Key.Length + " bytes")} " +
$"art={p.PodArtPath ?? "(null)"} id={p.Id}");
}
}
}
return 0;
}
private static int Merge(string[] args)
{
string output = null;
var inputs = new List<string>();
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-o" || args[i] == "--output")
{
if (i + 1 >= args.Length)
throw new ArgumentException("-o requires a file name");
output = args[++i];
}
else
{
inputs.Add(args[i]);
}
}
if (output == null || inputs.Count == 0)
return Usage();
var binder = new TeslaBinder(); // shared: output uses the first input's assembly identity
var merged = new List<SquadEntry>();
var squadNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var podIds = new Dictionary<Guid, string>();
var podMacs = new Dictionary<string, string>();
var podIps = new Dictionary<string, string>();
foreach (var file in inputs)
{
string site = Path.GetFileNameWithoutExtension(file);
var squads = SiteConfigFile.Read(file, binder);
Console.WriteLine($"{file}: site \"{site}\", {squads.Count} squad(s), {squads.Sum(s => s.Pods.Count)} pod(s)");
int unnamed = 0;
foreach (var entry in squads)
{
string original = entry.Squad.Name;
entry.Squad.Name = string.IsNullOrEmpty(original)
? $"{site}-squad{++unnamed}"
: $"{site}-{original}";
if (!squadNames.Add(entry.Squad.Name))
throw new InvalidOperationException(
$"duplicate squad name \"{entry.Squad.Name}\" — same site file given twice?");
foreach (var p in entry.Pods.Select(e => e.Pod))
{
string where = $"{entry.Squad.Name}/\"{p.Name}\"";
if (podIds.TryGetValue(p.Id, out var prev))
Warn($"pod GUID {p.Id} appears in both {prev} and {where} — same pod imported twice?");
else
podIds[p.Id] = where;
string mac = FormatMac(p.MacAddress);
if (mac != "none")
{
if (podMacs.TryGetValue(mac, out prev))
Warn($"MAC {mac} appears in both {prev} and {where}");
else
podMacs[mac] = where;
}
string ip = p.IPAddress?.ToString();
if (!string.IsNullOrEmpty(ip) && ip != "0.0.0.0")
{
if (podIps.TryGetValue(ip, out prev))
Warn($"IP {ip} used by both {prev} and {where} — sites need renumbering before linking");
else
podIps[ip] = where;
}
}
merged.Add(entry);
}
}
SiteConfigFile.Write(output, merged, binder);
Console.WriteLine($"wrote {output}: {merged.Count} squad(s), {merged.Sum(s => s.Pods.Count)} pod(s) " +
$"(identity: {binder.CapturedAssembly ?? TeslaBinder.DefaultAssembly})");
if (mWarnings > 0)
{
// Under the SiteLink IP plan none of the consistency checks should ever
// fire; a warning means plan drift (un-renumbered site) or stale
// inventory (same pod in two site files). Exit 3 so event scripts can
// gate on a clean merge.
Console.Error.WriteLine($"{mWarnings} warning(s) — master written, but resolve these before the event.");
return 3;
}
return 0;
}
private static int mWarnings;
private static void Warn(string message)
{
mWarnings++;
Console.Error.WriteLine("WARNING: " + message);
}
private static string FormatMac(byte[] mac) =>
mac == null || mac.Length == 0 ? "none" : BitConverter.ToString(mac).Replace('-', ':');
}
}