Explorar o código

Merge pull request #37 from Timmoth/rpk-services-subnets

Add 'rpk services subnets' command
Tim Jones hai 2 meses
pai
achega
77d85111ec

+ 40 - 0
RackPeek.Domain/Resources/Services/Networking/Cidr.cs

@@ -0,0 +1,40 @@
+using System;
+
+namespace RackPeek.Domain.Resources.Services.Networking;
+
+
+public readonly struct Cidr
+{
+    public uint Network { get; }
+    public uint Mask { get; }
+    public int Prefix { get; }
+
+    public Cidr(uint network, uint mask, int prefix)
+    {
+        Network = network;
+        Mask = mask;
+        Prefix = prefix;
+    }
+
+    public bool Contains(uint ip) => (ip & Mask) == Network;
+
+    public override string ToString()
+    {
+        return $"{IpHelper.ToIp(Network)}/{Prefix}";
+    }
+
+    public static Cidr Parse(string cidr)
+    {
+        var parts = cidr.Split('/');
+        if (parts.Length != 2)
+            throw new ArgumentException($"CIDR must be in format a.b.c.d/nn: {cidr}");
+
+        uint ip = IpHelper.ToUInt32(parts[0]);
+        int prefix = int.Parse(parts[1]);
+
+        uint mask = IpHelper.MaskFromPrefix(prefix);
+        uint network = ip & mask;
+
+        return new Cidr(network, mask, prefix);
+    }
+}

+ 37 - 0
RackPeek.Domain/Resources/Services/Networking/IpHelper.cs

@@ -0,0 +1,37 @@
+using System;
+
+namespace RackPeek.Domain.Resources.Services.Networking;
+
+
+public static class IpHelper
+{
+    public static uint ToUInt32(string ip)
+    {
+        var parts = ip.Split('.');
+        if (parts.Length != 4)
+            throw new ArgumentException($"Invalid IPv4 address: {ip}");
+
+        return (uint)(
+            (int.Parse(parts[0]) << 24) |
+            (int.Parse(parts[1]) << 16) |
+            (int.Parse(parts[2]) << 8) |
+            int.Parse(parts[3]));
+    }
+
+    public static string ToIp(uint ip)
+    {
+        return string.Join('.',
+            (ip >> 24) & 0xFF,
+            (ip >> 16) & 0xFF,
+            (ip >> 8) & 0xFF,
+            ip & 0xFF);
+    }
+
+    public static uint MaskFromPrefix(int prefix)
+    {
+        if (prefix < 0 || prefix > 32)
+            throw new ArgumentException($"Invalid CIDR prefix: {prefix}");
+
+        return prefix == 0 ? 0 : uint.MaxValue << (32 - prefix);
+    }
+}

+ 83 - 0
RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs

@@ -0,0 +1,83 @@
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Resources.Services.UseCases;
+
+public class ServiceSubnetsUseCase
+{
+    private readonly IServiceRepository _repo;
+
+    public ServiceSubnetsUseCase(IServiceRepository repo)
+    {
+        _repo = repo;
+    }
+
+    public async Task<ServiceSubnetsResult> ExecuteAsync(string? cidr, int? prefix, CancellationToken token)
+    {
+        var services = await _repo.GetAllAsync();
+
+        // If CIDR is provided → filter mode
+        if (cidr is not null)
+        {
+            Cidr parsed;
+            try
+            {
+                parsed = Cidr.Parse(cidr);
+            }
+            catch
+            {
+                return ServiceSubnetsResult.InvalidCidr(cidr);
+            }
+
+            var matches = services
+                .Where(s => s.Network?.Ip != null)
+                .Where(s => parsed.Contains(IpHelper.ToUInt32(s.Network!.Ip!)))
+                .Select(s => new ServiceSummary(
+                    s.Name,
+                    s.Network!.Ip!,
+                    s.RunsOn))
+                .ToList();
+
+            return ServiceSubnetsResult.FromServices(matches, parsed.ToString());
+        }
+
+        // No CIDR → subnet discovery mode
+        int effectivePrefix = prefix ?? 24;
+        uint mask = IpHelper.MaskFromPrefix(effectivePrefix);
+
+        var groups = services
+            .Where(s => s.Network?.Ip != null)
+            .Select(s => IpHelper.ToUInt32(s.Network!.Ip!))
+            .GroupBy(ip => ip & mask)
+            .Select(g => new SubnetSummary(
+                $"{IpHelper.ToIp(g.Key)}/{effectivePrefix}",
+                g.Count()))
+            .OrderBy(s => s.Cidr)
+            .ToList();
+
+        return ServiceSubnetsResult.FromSubnets(groups);
+    }
+}
+
+public record SubnetSummary(string Cidr, int Count);
+public record ServiceSummary(string Name, string Ip, string? RunsOn);
+
+public class ServiceSubnetsResult
+{
+    public bool IsInvalidCidr { get; private set; }
+    public string? InvalidCidrValue { get; private set; }
+
+    public string? FilteredCidr { get; private set; }
+
+    public List<SubnetSummary> Subnets { get; private set; } = new();
+    public List<ServiceSummary> Services { get; private set; } = new();
+
+    public static ServiceSubnetsResult InvalidCidr(string cidr)
+        => new() { IsInvalidCidr = true, InvalidCidrValue = cidr };
+
+    public static ServiceSubnetsResult FromSubnets(List<SubnetSummary> subnets)
+        => new() { Subnets = subnets };
+
+    public static ServiceSubnetsResult FromServices(List<ServiceSummary> services, string cidr)
+        => new() { Services = services, FilteredCidr = cidr };
+}

+ 6 - 0
RackPeek/CliBootstrap.cs

@@ -263,6 +263,7 @@ public static class CliBootstrap
         services.AddScoped<GetServiceUseCase>();
         services.AddScoped<UpdateServiceUseCase>();
         services.AddScoped<ServiceReportUseCase>();
+        services.AddScoped<ServiceSubnetsUseCase>();
 
         // Service commands
         services.AddScoped<ServiceSetCommand>();
@@ -272,6 +273,8 @@ public static class CliBootstrap
         services.AddScoped<ServiceDeleteCommand>();
         services.AddScoped<ServiceAddCommand>();
         services.AddScoped<ServiceReportCommand>();
+        services.AddScoped<ServiceSubnetsCommand>();
+        
         // Spectre bootstrap
         app.Configure(config =>
         {
@@ -535,6 +538,9 @@ public static class CliBootstrap
 
                     service.AddCommand<ServiceDeleteCommand>("del")
                         .WithDescription("Delete a service");
+                    
+                    service.AddCommand<ServiceSubnetsCommand>("subnets")
+                        .WithDescription("List service subnets or filter by CIDR");
 
                 });
 

+ 142 - 0
RackPeek/Commands/Services/ServiceSubnetsCommand.cs

@@ -0,0 +1,142 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using RackPeek.Domain.Resources.Services.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace RackPeek.Commands.Services;
+
+public class ServiceSubnetsCommand(
+    ILogger<ServiceSubnetsCommand> logger,
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceSubnetsCommand.Settings>
+{
+    public class Settings : CommandSettings
+    {
+        [CommandOption("--cidr <CIDR>")]
+        public string? Cidr { get; set; }
+
+        [CommandOption("--prefix <PREFIX>")]
+        public int? Prefix { get; set; }
+    }
+    
+    private static string BuildUtilizationBar(double fullness, int width = 30)
+    {
+        fullness = Math.Clamp(fullness, 0, 100);
+        int filled = (int)(width * (fullness / 100.0));
+        int empty = width - filled;
+
+        var color = fullness switch
+        {
+            < 50 => Color.Green,
+            < 80 => Color.Yellow,
+            _ => Color.Red
+        };
+
+        string filledBar = new string('█', filled);
+        string emptyBar = new string('░', empty);
+
+        return $"[{color.ToString().ToLower()}]{filledBar}[/]{emptyBar} {fullness:0}%";
+    }
+    
+    private static uint IpToUInt32(string ip)
+    {
+        var parts = ip.Split('.');
+        return (uint)(
+            (int.Parse(parts[0]) << 24) |
+            (int.Parse(parts[1]) << 16) |
+            (int.Parse(parts[2]) << 8) |
+            int.Parse(parts[3]));
+    }
+
+
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        Settings settings,
+        CancellationToken cancellationToken)
+    {
+        using var scope = serviceProvider.CreateScope();
+        var useCase = scope.ServiceProvider.GetRequiredService<ServiceSubnetsUseCase>();
+
+        var result = await useCase.ExecuteAsync(settings.Cidr, settings.Prefix, cancellationToken);
+
+        // Handle invalid CIDR
+        if (result.IsInvalidCidr)
+        {
+            AnsiConsole.MarkupLine($"[red]Invalid CIDR:[/] {result.InvalidCidrValue}");
+            return 1;
+        }
+
+       
+        if (settings.Cidr is not null)
+        {
+            var services = result.Services
+                .OrderBy(s => IpToUInt32(s.Ip))
+                .ToList();
+
+
+            if (services.Count == 0)
+            {
+                AnsiConsole.MarkupLine($"[yellow]No services found in {settings.Cidr}[/]");
+                return 0;
+            }
+
+            var table = new Table()
+                .Border(TableBorder.Rounded)
+                .AddColumn("Name")
+                .AddColumn("IP")
+                .AddColumn("Runs On");
+
+            foreach (var s in services)
+                table.AddRow(s.Name, s.Ip, s.RunsOn ?? "Unknown");
+
+            AnsiConsole.MarkupLine($"[green]Services in {result.FilteredCidr}[/]");
+            AnsiConsole.Write(table);
+            return 0;
+        }
+
+   
+        var subnets = result.Subnets;
+        
+        subnets = subnets
+            .OrderByDescending(s =>
+            {
+                var parts = s.Cidr.Split('/');
+                int prefix = int.Parse(parts[1]);
+                double alloc = Math.Pow(2, 32 - prefix) - 2;
+                return alloc <= 0 ? 0 : (s.Count / alloc);
+            })
+            .ToList();
+        
+        if (subnets.Count == 0)
+        {
+            AnsiConsole.MarkupLine("[yellow]No subnets found.[/]");
+            return 0;
+        }
+
+        var subnetTable = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Subnet")
+            .AddColumn("Services")
+            .AddColumn("Utilization");
+
+        foreach (var subnet in subnets)
+        {
+            var parts = subnet.Cidr.Split('/');
+            int prefix = int.Parse(parts[1]);
+
+            // allocatable addresses
+            double alloc = Math.Pow(2, 32 - prefix) - 2;
+            double used = subnet.Count;
+            double fullness = alloc <= 0 ? 0 : (used / alloc) * 100;
+
+            string bar = BuildUtilizationBar(fullness);
+
+            subnetTable.AddRow(subnet.Cidr, subnet.Count.ToString(), bar);
+        }
+
+        AnsiConsole.Write(subnetTable);
+
+        return 0;
+    }
+}

+ 2 - 1
RackPeek/Program.cs

@@ -30,7 +30,8 @@ public static class Program
             "ups.yaml",
             "firewalls.yaml",
             "laptops.yaml",
-            "routers.yaml"
+            "routers.yaml",
+            "Services.yaml"
         ]);
 
         services.AddLogging(configure =>

+ 585 - 0
RackPeek/Services.yaml

@@ -0,0 +1,585 @@
+resources:
+  - kind: Service
+    name: immich
+    network:
+      ip: 192.168.0.4
+      port: 8080
+      protocol: TCP
+      url: http://immich.lan:8080
+    runsOn: proxmox-host
+
+  - kind: Service
+    name: jellyfin
+    network:
+      ip: 192.168.0.10
+      port: 8096
+      protocol: TCP
+      url: http://jellyfin.lan:8096
+    runsOn: docker-host
+
+  - kind: Service
+    name: plex
+    network:
+      ip: 192.168.0.11
+      port: 32400
+      protocol: TCP
+      url: http://plex.lan:32400
+    runsOn: proxmox-host
+
+  - kind: Service
+    name: home-assistant
+    network:
+      ip: 192.168.1.20
+      port: 8123
+      protocol: TCP
+      url: http://ha.lan:8123
+    runsOn: k8s-node-1
+
+  - kind: Service
+    name: pihole
+    network:
+      ip: 192.168.1.2
+      port: 53
+      protocol: UDP
+      url: http://pihole.lan/admin
+    runsOn: baremetal-rpi4
+
+  - kind: Service
+    name: unifi-controller
+    network:
+      ip: 192.168.1.5
+      port: 8443
+      protocol: TCP
+      url: https://unifi.lan:8443
+    runsOn: vm-cluster-1
+
+  - kind: Service
+    name: syncthing
+    network:
+      ip: 10.0.0.15
+      port: 8384
+      protocol: TCP
+      url: http://sync.internal:8384
+    runsOn: docker-host
+
+  - kind: Service
+    name: grafana
+    network:
+      ip: 10.0.0.20
+      port: 3000
+      protocol: TCP
+      url: http://grafana.internal:3000
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: prometheus
+    network:
+      ip: 10.0.0.21
+      port: 9090
+      protocol: TCP
+      url: http://prometheus.internal:9090
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: loki
+    network:
+      ip: 10.0.0.22
+      port: 3100
+      protocol: TCP
+      url: http://loki.internal:3100
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: minio
+    network:
+      ip: 172.16.0.10
+      port: 9000
+      protocol: TCP
+      url: http://minio.storage:9000
+    runsOn: storage-node-1
+
+  - kind: Service
+    name: nextcloud
+    network:
+      ip: 172.16.0.11
+      port: 443
+      protocol: TCP
+      url: https://nextcloud.storage
+    runsOn: storage-node-2
+
+  - kind: Service
+    name: vaultwarden
+    network:
+      ip: 192.168.0.30
+      port: 8081
+      protocol: TCP
+      url: http://vault.lan:8081
+    runsOn: docker-host
+
+  - kind: Service
+    name: traefik
+    network:
+      ip: 192.168.0.2
+      port: 80
+      protocol: TCP
+      url: http://traefik.lan
+    runsOn: k8s-node-1
+
+  - kind: Service
+    name: nginx-reverse-proxy
+    network:
+      ip: 192.168.0.3
+      port: 443
+      protocol: TCP
+      url: https://proxy.lan
+    runsOn: docker-host
+
+  - kind: Service
+    name: qbittorrent
+    network:
+      ip: 192.168.0.40
+      port: 8080
+      protocol: TCP
+      url: http://torrent.lan:8080
+    runsOn: proxmox-host
+
+  - kind: Service
+    name: radarr
+    network:
+      ip: 192.168.0.41
+      port: 7878
+      protocol: TCP
+      url: http://radarr.lan:7878
+    runsOn: docker-host
+
+  - kind: Service
+    name: sonarr
+    network:
+      ip: 192.168.0.42
+      port: 8989
+      protocol: TCP
+      url: http://sonarr.lan:8989
+    runsOn: docker-host
+
+  - kind: Service
+    name: prowlarr
+    network:
+      ip: 192.168.0.43
+      port: 9696
+      protocol: TCP
+      url: http://prowlarr.lan:9696
+    runsOn: docker-host
+
+  - kind: Service
+    name: sabnzbd
+    network:
+      ip: 192.168.0.44
+      port: 8085
+      protocol: TCP
+      url: http://sabnzbd.lan:8085
+    runsOn: docker-host
+
+  - kind: Service
+    name: frigate
+    network:
+      ip: 192.168.1.30
+      port: 5000
+      protocol: TCP
+      url: http://frigate.lan:5000
+    runsOn: k8s-node-2
+
+  - kind: Service
+    name: mosquitto-mqtt
+    network:
+      ip: 192.168.1.31
+      port: 1883
+      protocol: TCP
+      url: mqtt://mqtt.lan:1883
+    runsOn: docker-host
+
+  - kind: Service
+    name: zigbee2mqtt
+    network:
+      ip: 192.168.1.32
+      port: 8080
+      protocol: TCP
+      url: http://z2m.lan:8080
+    runsOn: docker-host
+
+  - kind: Service
+    name: postgres-main
+    network:
+      ip: 10.0.1.10
+      port: 5432
+      protocol: TCP
+      url: postgres://db.internal:5432
+    runsOn: db-node-1
+
+  - kind: Service
+    name: mariadb
+    network:
+      ip: 10.0.1.11
+      port: 3306
+      protocol: TCP
+      url: mysql://mariadb.internal:3306
+    runsOn: db-node-2
+
+  - kind: Service
+    name: redis-cache
+    network:
+      ip: 10.0.1.12
+      port: 6379
+      protocol: TCP
+      url: redis://redis.internal:6379
+    runsOn: cache-node
+
+  - kind: Service
+    name: elasticsearch
+    network:
+      ip: 10.0.2.10
+      port: 9200
+      protocol: TCP
+      url: http://es.internal:9200
+    runsOn: search-node
+
+  - kind: Service
+    name: kibana
+    network:
+      ip: 10.0.2.11
+      port: 5601
+      protocol: TCP
+      url: http://kibana.internal:5601
+    runsOn: search-node
+
+  - kind: Service
+    name: uptime-kuma
+    network:
+      ip: 192.168.0.50
+      port: 3001
+      protocol: TCP
+      url: http://uptime.lan:3001
+    runsOn: docker-host
+
+  - kind: Service
+    name: wireguard-vpn
+    network:
+      ip: 192.168.1.100
+      port: 51820
+      protocol: UDP
+      url: wg://vpn.lan
+    runsOn: baremetal-rpi4
+
+  - kind: Service
+    name: openvpn
+    network:
+      ip: 192.168.1.101
+      port: 1194
+      protocol: UDP
+      url: ovpn://openvpn.lan
+    runsOn: vm-cluster-2
+
+  - kind: Service
+    name: adguard-home
+    network:
+      ip: 192.168.1.3
+      port: 3000
+      protocol: TCP
+      url: http://adguard.lan:3000
+    runsOn: docker-host
+
+  - kind: Service
+    name: gitlab
+    network:
+      ip: 10.0.3.10
+      port: 443
+      protocol: TCP
+      url: https://gitlab.internal
+    runsOn: dev-node-1
+
+  - kind: Service
+    name: gitea
+    network:
+      ip: 10.0.3.11
+      port: 3000
+      protocol: TCP
+      url: http://gitea.internal:3000
+    runsOn: dev-node-2
+
+  - kind: Service
+    name: drone-ci
+    network:
+      ip: 10.0.3.12
+      port: 8080
+      protocol: TCP
+      url: http://drone.internal:8080
+    runsOn: dev-node-2
+
+  - kind: Service
+    name: harbor-registry
+    network:
+      ip: 10.0.3.13
+      port: 5000
+      protocol: TCP
+      url: http://harbor.internal:5000
+    runsOn: dev-node-3
+
+  - kind: Service
+    name: kubernetes-api
+    network:
+      ip: 10.0.4.1
+      port: 6443
+      protocol: TCP
+      url: https://k8s-api.internal:6443
+    runsOn: k8s-control-plane
+
+  - kind: Service
+    name: longhorn-ui
+    network:
+      ip: 10.0.4.20
+      port: 9500
+      protocol: TCP
+      url: http://longhorn.internal:9500
+    runsOn: k8s-node-3
+
+  - kind: Service
+    name: rook-ceph-dashboard
+    network:
+      ip: 10.0.4.21
+      port: 8443
+      protocol: TCP
+      url: https://ceph.internal:8443
+    runsOn: k8s-node-3
+
+  - kind: Service
+    name: samba-fileserver
+    network:
+      ip: 192.168.0.60
+      port: 445
+      protocol: TCP
+      url: smb://fileserver.lan
+    runsOn: storage-node-1
+
+  - kind: Service
+    name: nfs-server
+    network:
+      ip: 192.168.0.61
+      port: 2049
+      protocol: TCP
+      url: nfs://nfs.lan
+    runsOn: storage-node-2
+
+  - kind: Service
+    name: iscsi-target
+    network:
+      ip: 172.16.1.10
+      port: 3260
+      protocol: TCP
+      url: iscsi://iscsi.storage
+    runsOn: storage-node-3
+
+  - kind: Service
+    name: calibre-web
+    network:
+      ip: 192.168.0.70
+      port: 8083
+      protocol: TCP
+      url: http://books.lan:8083
+    runsOn: docker-host
+
+  - kind: Service
+    name: paperless-ngx
+    network:
+      ip: 192.168.0.71
+      port: 8000
+      protocol: TCP
+      url: http://docs.lan:8000
+    runsOn: docker-host
+
+  - kind: Service
+    name: openldap
+    network:
+      ip: 10.0.5.10
+      port: 389
+      protocol: TCP
+      url: ldap://ldap.internal:389
+    runsOn: auth-node
+
+  - kind: Service
+    name: keycloak
+    network:
+      ip: 10.0.5.11
+      port: 8080
+      protocol: TCP
+      url: http://keycloak.internal:8080
+    runsOn: auth-node
+
+  - kind: Service
+    name: ntp-server
+    network:
+      ip: 192.168.1.50
+      port: 123
+      protocol: UDP
+      url: ntp://ntp.lan
+    runsOn: baremetal-rpi3
+
+  - kind: Service
+    name: syslog-server
+    network:
+      ip: 10.0.6.10
+      port: 514
+      protocol: UDP
+      url: syslog://syslog.internal
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: dhcp-server
+    network:
+      ip: 192.168.1.1
+      port: 67
+      protocol: UDP
+      url: dhcp://dhcp.lan
+    runsOn: router-appliance
+
+  - kind: Service
+    name: bind-dns
+    network:
+      ip: 10.0.7.10
+      port: 53
+      protocol: UDP
+      url: dns://dns.internal
+    runsOn: infra-node
+
+  - kind: Service
+    name: vault
+    network:
+      ip: 10.0.7.11
+      port: 8200
+      protocol: TCP
+      url: http://vault.internal:8200
+    runsOn: infra-node
+
+  - kind: Service
+    name: consul
+    network:
+      ip: 10.0.7.12
+      port: 8500
+      protocol: TCP
+      url: http://consul.internal:8500
+    runsOn: infra-node
+
+  - kind: Service
+    name: nomad
+    network:
+      ip: 10.0.7.13
+      port: 4646
+      protocol: TCP
+      url: http://nomad.internal:4646
+    runsOn: infra-node
+
+  - kind: Service
+    name: openhab
+    network:
+      ip: 192.168.1.40
+      port: 8080
+      protocol: TCP
+      url: http://openhab.lan:8080
+    runsOn: k8s-node-2
+
+  - kind: Service
+    name: mqtt-explorer
+    network:
+      ip: 192.168.1.41
+      port: 4000
+      protocol: TCP
+      url: http://mqtt-explorer.lan:4000
+    runsOn: docker-host
+
+  - kind: Service
+    name: influxdb
+    network:
+      ip: 10.0.8.10
+      port: 8086
+      protocol: TCP
+      url: http://influx.internal:8086
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: telegraf
+    network:
+      ip: 10.0.8.11
+      port: 8125
+      protocol: UDP
+      url: statsd://telegraf.internal
+    runsOn: monitoring-node
+
+  - kind: Service
+    name: speedtest-tracker
+    network:
+      ip: 192.168.0.80
+      port: 8080
+      protocol: TCP
+      url: http://speedtest.lan:8080
+    runsOn: docker-host
+
+  - kind: Service
+    name: navidrome
+    network:
+      ip: 192.168.0.81
+      port: 4533
+      protocol: TCP
+      url: http://music.lan:4533
+    runsOn: docker-host
+
+  - kind: Service
+    name: photoprism
+    network:
+      ip: 192.168.0.82
+      port: 2342
+      protocol: TCP
+      url: http://photos.lan:2342
+    runsOn: docker-host
+
+  - kind: Service
+    name: dnsdist
+    network:
+      ip: 10.0.9.10
+      port: 53
+      protocol: UDP
+      url: dns://dnsdist.internal
+    runsOn: infra-node
+
+  - kind: Service
+    name: powerdns
+    network:
+      ip: 10.0.9.11
+      port: 8081
+      protocol: TCP
+      url: http://pdns.internal:8081
+    runsOn: infra-node
+
+  - kind: Service
+    name: openproject
+    network:
+      ip: 10.0.10.10
+      port: 8080
+      protocol: TCP
+      url: http://openproject.internal:8080
+    runsOn: dev-node-3
+
+  - kind: Service
+    name: mattermost
+    network:
+      ip: 10.0.10.11
+      port: 8065
+      protocol: TCP
+      url: http://chat.internal:8065
+    runsOn: dev-node-3
+
+  - kind: Service
+    name: rocket-chat
+    network:
+      ip: 10.0.10.12
+      port: 3000
+      protocol: TCP
+      url: http://rocket.internal:3000
+    runsOn: dev-node-3