Parcourir la source

Added System IP address (#207)

Tim Jones il y a 1 mois
Parent
commit
2a39c98a83

+ 1 - 0
RackPeek.Domain/Persistence/IResourceCollection.cs

@@ -30,6 +30,7 @@ public interface IResourceCollection
     Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name);
     public Task<Dictionary<string, int>> GetLabelsAsync();
     
+    Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync();
 
     Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>();
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);

+ 95 - 0
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -131,6 +131,101 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         }
     }
 
+      public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync()
+    {        lock (_lock)
+        {
+        var result = new List<(Resource, string)>();
+
+        var allResources = _resources;
+
+        var systemsByName = allResources
+            .OfType<SystemResource>()
+            .ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
+
+        // Cache resolved system IPs (prevents repeated recursion)
+        var resolvedSystemIps = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var resource in allResources)
+        {
+            switch (resource)
+            {
+                case SystemResource system:
+                {
+                    var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
+                    if (!string.IsNullOrWhiteSpace(ip))
+                        result.Add((system, ip));
+                    break;
+                }
+
+                case Service service:
+                {
+                    var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
+                    if (!string.IsNullOrWhiteSpace(ip))
+                        result.Add((service, ip));
+                    break;
+                }
+            }
+        }
+        return Task.FromResult((IReadOnlyList<(Resource, string)>)result);
+        }
+    }
+    private string? ResolveSystemIp(
+        SystemResource system,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache)
+    {
+        // Return cached result if already resolved
+        if (cache.TryGetValue(system.Name, out var cached))
+            return cached;
+
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(system.Ip))
+        {
+            cache[system.Name] = system.Ip;
+            return system.Ip;
+        }
+
+        // Must have exactly one parent
+        if (system.RunsOn?.Count != 1)
+        {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var parentName = system.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out var parent))
+        {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var resolved = ResolveSystemIp(parent, systemsByName, cache);
+        cache[system.Name] = resolved;
+
+        return resolved;
+    }
+    private string? ResolveServiceIp(
+        Service service,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache)
+    {
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
+            return service.Network!.Ip;
+
+        // Must have exactly one parent
+        if (service.RunsOn?.Count != 1)
+            return null;
+
+        var parentName = service.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out var parent))
+            return null;
+
+        return ResolveSystemIp(parent, systemsByName, cache);
+    }
+    
     public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
     {
         lock (_lock)

+ 94 - 0
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -65,6 +65,100 @@ public sealed class YamlResourceCollection(
 
         return Task.FromResult(result);
     }
+    public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync()
+    {
+        var result = new List<(Resource, string)>();
+
+        var allResources = resourceCollection.Resources;
+
+        // Build fast lookup for systems
+        var systemsByName = allResources
+            .OfType<SystemResource>()
+            .ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
+
+        // Cache resolved system IPs (prevents repeated recursion)
+        var resolvedSystemIps = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (var resource in allResources)
+        {
+            switch (resource)
+            {
+                case SystemResource system:
+                {
+                    var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps);
+                    if (!string.IsNullOrWhiteSpace(ip))
+                        result.Add((system, ip));
+                    break;
+                }
+
+                case Service service:
+                {
+                    var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps);
+                    if (!string.IsNullOrWhiteSpace(ip))
+                        result.Add((service, ip));
+                    break;
+                }
+            }
+        }
+
+        return Task.FromResult((IReadOnlyList<(Resource, string)>)result);
+    }
+    private string? ResolveSystemIp(
+        SystemResource system,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache)
+    {
+        // Return cached result if already resolved
+        if (cache.TryGetValue(system.Name, out var cached))
+            return cached;
+
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(system.Ip))
+        {
+            cache[system.Name] = system.Ip;
+            return system.Ip;
+        }
+
+        // Must have exactly one parent
+        if (system.RunsOn?.Count != 1)
+        {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var parentName = system.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out var parent))
+        {
+            cache[system.Name] = null;
+            return null;
+        }
+
+        var resolved = ResolveSystemIp(parent, systemsByName, cache);
+        cache[system.Name] = resolved;
+
+        return resolved;
+    }
+    private string? ResolveServiceIp(
+        Service service,
+        Dictionary<string, SystemResource> systemsByName,
+        Dictionary<string, string?> cache)
+    {
+        // Direct IP wins
+        if (!string.IsNullOrWhiteSpace(service.Network?.Ip))
+            return service.Network!.Ip;
+
+        // Must have exactly one parent
+        if (service.RunsOn?.Count != 1)
+            return null;
+
+        var parentName = service.RunsOn.First();
+
+        if (!systemsByName.TryGetValue(parentName, out var parent))
+            return null;
+
+        return ResolveSystemIp(parent, systemsByName, cache);
+    }
     public Task<Dictionary<string, int>> GetTagsAsync()
     {
         var result = resourceCollection.Resources

+ 1 - 1
RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs

@@ -24,7 +24,7 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
         if (service is null)
             throw new NotFoundException($"Service '{name}' not found.");
 
-        if (!string.IsNullOrWhiteSpace(ip))
+        if (ip != null)
         {
             service.Network ??= new Network();
             service.Network.Ip = ip;

+ 2 - 0
RackPeek.Domain/Resources/SystemResources/SystemResource.cs

@@ -23,4 +23,6 @@ public class SystemResource : Resource, IDriveResource
     public int? Cores { get; set; }
     public double? Ram { get; set; }
     public List<Drive>? Drives { get; set; }
+    
+    public string? Ip { get; set; }
 }

+ 6 - 0
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs

@@ -11,6 +11,7 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
         string? os = null,
         int? cores = null,
         double? ram = null,
+        string? ip = null,
         List<string>? runsOn = null,
         string? notes = null
     )
@@ -42,6 +43,11 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
         if (ram.HasValue)
             system.Ram = ram.Value;
 
+        if (ip != null)
+        {
+            system.Ip = ip;
+        }
+        
         if (notes != null) system.Notes = notes;
 
         if (runsOn?.Count > 0)

+ 0 - 1
RackPeek.Domain/UseCases/AddResourceUseCase.cs

@@ -23,7 +23,6 @@ public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCa
 
         if (runsOn != null)
         {
-
             foreach (var parent in runsOn) {
                 var normalizedParent = Normalize.HardwareName(parent);
                 ThrowIfInvalid.ResourceName(normalizedParent);

+ 8 - 0
RackPeek.Web.Viewer/Pages/Home.razor

@@ -53,6 +53,14 @@
 
         <ul class="space-y-2 text-sm">
 
+            <li>
+                <NavLink href="subnets"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-subnets">
+                    → Subnet Browser
+                </NavLink>
+            </li>
+            
             <li>
                 <NavLink href="cli"
                          class="block hover:text-emerald-300"

+ 1 - 0
RackPeek.Web.Viewer/wwwroot/schemas/v2/schema.v2.json

@@ -323,6 +323,7 @@
                 "container", "embedded", "cloud", "other"
               ]
             },
+            "ip": { "type": "string" },
             "os": { "type": "string" },
             "cores": { "type": "integer", "minimum": 1 },
             "ram": { "type": "number", "minimum": 0 },

+ 9 - 0
RackPeek.Web/Components/Pages/Home.razor

@@ -54,6 +54,15 @@
 
         <ul class="space-y-2 text-sm">
 
+            <li>
+                <NavLink href="subnets"
+                         class="block hover:text-emerald-300"
+                         data-testid="home-tool-subnets">
+                    → Subnet Browser
+                </NavLink>
+            </li>
+
+            
             <li>
                 <NavLink href="cli"
                          class="block hover:text-emerald-300"

+ 1 - 0
RackPeek.Web/wwwroot/schemas/v2/schema.v2.json

@@ -323,6 +323,7 @@
                 "container", "embedded", "cloud", "other"
               ]
             },
+            "ip": { "type": "string" },
             "os": { "type": "string" },
             "cores": { "type": "integer", "minimum": 1 },
             "ram": { "type": "number", "minimum": 0 },

+ 5 - 0
Shared.Rcl/Commands/Systems/SystemSetCommand.cs

@@ -20,6 +20,10 @@ public class SystemSetSettings : ServerNameSettings
     [CommandOption("--runs-on <RUNSON>")]
     [Description("The physical machine(s) the service is running on.")]
     public string[]? RunsOn { get; set; }
+    
+    [CommandOption("--ip")]
+    [Description("The ip address of the system.")]
+    public string? Ip { get; set; }
 }
 
 public class SystemSetCommand(
@@ -40,6 +44,7 @@ public class SystemSetCommand(
             settings.Os,
             settings.Cores,
             settings.Ram,
+            settings.Ip,
             settings.RunsOn?.ToList()
         );
 

+ 49 - 4
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -1,8 +1,10 @@
+@using RackPeek.Domain.Resources.SystemResources
 @inject UpdateServiceUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Service> GetByNameUseCase
 @inject IDeleteResourceUseCase<Service> DeleteUseCase
 @inject ICloneResourceUseCase<Service> CloneUseCase
 @inject IRenameResourceUseCase<Service> RenameUseCase
+@inject IGetResourceByNameUseCase<SystemResource> GetSystemByNameUseCase
 @inject NavigationManager Nav
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
@@ -82,11 +84,16 @@
                    border border-zinc-600"
                     @bind="_edit.Ip"/>
             }
-            else if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
+            else if (!string.IsNullOrWhiteSpace(EffectiveIp))
             {
                 <div class="text-zinc-300"
                      data-testid="service-ip-value">
-                    @Service.Network!.Ip
+                    @EffectiveIp
+
+                    @if (string.IsNullOrWhiteSpace(Service.Network?.Ip))
+                    {
+                        <span class="text-zinc-500 text-xs ml-2">(inherited)</span>
+                    }
                 </div>
             }
         </div>
@@ -333,8 +340,10 @@
             _edit.RunsOn,
             _edit.Notes
         );
-
+        
         await OnSave.InvokeAsync(Service.Name);
+        await ResolveEffectiveIp();
+
     }
 
     void Cancel()
@@ -444,7 +453,7 @@
 
     private string? GetBrowsableHref()
     {
-        var ip = Service.Network?.Ip;
+        var ip = Service.Network?.Ip ?? EffectiveIp;
         var port = Service.Network?.Port;
 
         if (string.IsNullOrWhiteSpace(ip) || port is null)
@@ -464,3 +473,39 @@
         return ub.Uri.ToString();
     }
 }
+
+@code
+{
+    private string? EffectiveIp;
+
+    protected override async Task OnParametersSetAsync()
+    {
+        await ResolveEffectiveIp();
+    }
+
+    private async Task ResolveEffectiveIp()
+    {
+        // If service has its own IP, use it
+        if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
+        {
+            EffectiveIp = Service.Network!.Ip;
+            return;
+        }
+
+        // Must have exactly one parent
+        if (Service.RunsOn?.Count != 1)
+        {
+            EffectiveIp = null;
+            return;
+        }
+
+        var parentName = Service.RunsOn.First();
+
+        // Load parent system
+        var parent = await GetSystemByNameUseCase.ExecuteAsync(parentName);
+
+        EffectiveIp = string.IsNullOrWhiteSpace(parent?.Ip)
+            ? null
+            : parent.Ip;
+    }
+}

+ 159 - 0
Shared.Rcl/SubnetBrowser.razor

@@ -0,0 +1,159 @@
+@page "/subnets"
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources
+@inject IResourceCollection ResourceCollection
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
+     data-testid="subnet-browser-root">
+
+    <h1 class="text-lg text-zinc-100"
+        data-testid="subnet-browser-title">
+        Subnet Browser
+    </h1>
+
+    <!-- Filter -->
+    <div>
+        <input
+            data-testid="subnet-browser-filter"
+            placeholder="Filter by IP (e.g. 10.0.99)"
+            class="w-full px-3 py-2 rounded-md
+           bg-zinc-800 text-zinc-100
+           border border-zinc-700
+           focus:outline-none focus:ring-2 focus:ring-emerald-500"
+            @bind="Filter"
+            @bind:event="oninput" />
+    </div>
+
+    @if (_grouped is null)
+    {
+        <div class="text-zinc-500"
+             data-testid="subnet-browser-loading">
+            loading…
+        </div>
+    }
+    else if (!_grouped.Any())
+    {
+        <div class="text-zinc-500"
+             data-testid="subnet-browser-empty">
+            no matching IPs found
+        </div>
+    }
+    else
+    {
+        <div class="space-y-6"
+             data-testid="subnet-browser-list">
+
+            @foreach (var subnetGroup in _grouped.OrderBy(x => x.Key))
+            {
+                <div data-testid=@($"subnet-group-{subnetGroup.Key.Replace('.', '-')}")>
+
+                    <!-- Subnet Header -->
+                    <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+                        @subnetGroup.Key
+                    </div>
+
+                    <ul class="ml-2 border-l border-zinc-800 pl-4 space-y-3">
+                        @foreach (var ipGroup in subnetGroup.Value.OrderBy(x => x.Key))
+                        {
+                            <li>
+
+                                <!-- IP -->
+                                <div class="text-zinc-100">
+                                    ├─ @ipGroup.Key
+                                </div>
+
+                                <!-- Resources on this IP -->
+                                <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
+                                    @foreach (var (resource, _) in ipGroup.Value)
+                                    {
+                                        var url = GetResourceUrl(resource);
+
+                                        <li class="text-zinc-500 hover:text-emerald-300">
+                                            <NavLink href="@url" class="block">
+                                                └─ @resource.Name (@resource.GetType().Name.Replace("Resource",""))
+                                            </NavLink>
+                                        </li>
+                                    }
+                                </ul>
+                            </li>
+                        }
+                    </ul>
+
+                </div>
+            }
+
+        </div>
+    }
+</div>
+@code {
+
+    private string _filter = string.Empty;
+
+    private IReadOnlyList<(Resource resource, string ip)> _all = [];
+
+    private Dictionary<string, Dictionary<string, List<(Resource, string)>>>? _grouped;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _all = await ResourceCollection.GetResourceIpsAsync();
+        ApplyFilter();
+    }
+
+    private string Filter
+    {
+        get => _filter;
+        set
+        {
+            if (_filter == value)
+                return;
+
+            _filter = value;
+            ApplyFilter();
+        }
+    }
+
+    private void ApplyFilter()
+    {
+        var filtered = string.IsNullOrWhiteSpace(_filter)
+            ? _all
+            : _all.Where(x =>
+                    x.ip.Contains(_filter, StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+        _grouped = filtered
+            .Where(x => !string.IsNullOrWhiteSpace(x.ip))
+            .GroupBy(x => GetSubnet(x.ip))
+            .ToDictionary(
+                g => g.Key,
+                g => g.GroupBy(x => x.ip)
+                    .ToDictionary(
+                        ip => ip.Key,
+                        ip => ip.ToList()
+                    )
+            );
+
+        StateHasChanged();
+    }
+
+    private string GetSubnet(string ip)
+    {
+        var parts = ip.Split('.');
+        return parts.Length == 4
+            ? $"{parts[0]}.{parts[1]}.{parts[2]}.x"
+            : "unknown";
+    }
+
+    private string GetResourceUrl(Resource resource)
+    {
+        return resource switch
+        {
+            RackPeek.Domain.Resources.SystemResources.SystemResource
+                => $"resources/systems/{Uri.EscapeDataString(resource.Name)}",
+
+            RackPeek.Domain.Resources.Services.Service
+                => $"resources/services/{Uri.EscapeDataString(resource.Name)}",
+
+            _ => "#"
+        };
+    }
+}

+ 65 - 0
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -168,6 +168,27 @@
             }
         </div>
 
+        <!-- IP -->
+        <div>
+            <div class="text-zinc-400 mb-1">IP Address</div>
+            @if (_isEditing)
+            {
+                <input class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
+                       data-testid="system-ip-input"
+                       @bind="_edit.Ip" />
+            }
+            else if (!string.IsNullOrWhiteSpace(EffectiveIp))
+            {
+                <div class="text-zinc-300">
+                    @EffectiveIp
+                    @if (string.IsNullOrWhiteSpace(System.Ip))
+                    {
+                        <span class="text-zinc-500 text-xs ml-2">(inherited)</span>
+                    }
+                </div>
+            }
+        </div>
+        
         <!-- Runs On -->
         <div class="text-sm">
             <div class="text-zinc-400 mb-1">
@@ -341,6 +362,7 @@
     protected override async Task OnParametersSetAsync()
     {
         await RefreshParentKinds();
+        await ResolveEffectiveIp();
     }
     
     private async Task RefreshParentKinds()
@@ -375,6 +397,7 @@
     var os    = _isEditing ? _edit.Os    : System.Os;
     var cores = _isEditing ? _edit.Cores : System.Cores;
     var ram   = _isEditing ? _edit.Ram   : System.Ram;
+    var ip    = _isEditing ? _edit.Ip    : System.Ip;
     var notes = _isEditing ? _edit.Notes : System.Notes;
 
     await UpdateUseCase.ExecuteAsync(
@@ -383,6 +406,7 @@
         os,
         cores,
         ram,
+        ip,
         runsOn,
         notes);
 
@@ -411,6 +435,7 @@ async Task HandleParentDeleted(string? name)
     var os    = _isEditing ? _edit.Os    : System.Os;
     var cores = _isEditing ? _edit.Cores : System.Cores;
     var ram   = _isEditing ? _edit.Ram   : System.Ram;
+    var ip    = _isEditing ? _edit.Ip    : System.Ip;
     var notes = _isEditing ? _edit.Notes : System.Notes;
 
     await UpdateUseCase.ExecuteAsync(
@@ -419,6 +444,7 @@ async Task HandleParentDeleted(string? name)
         os,
         cores,
         ram,
+        ip,
         runsOn,
         notes);
 
@@ -551,3 +577,42 @@ async Task HandleParentDeleted(string? name)
     }
 
 }
+
+@code{
+
+    private string? EffectiveIp;
+
+    private async Task ResolveEffectiveIp()
+    {
+        // If system has its own IP, use it
+        if (!string.IsNullOrWhiteSpace(System.Ip))
+        {
+            EffectiveIp = System.Ip;
+            return;
+        }
+
+        // Must have exactly one parent
+        if (System.RunsOn?.Count != 1)
+        {
+            EffectiveIp = null;
+            return;
+        }
+
+        var parentName = System.RunsOn.First();
+
+        // Must be a system
+        if (!_parentKinds.TryGetValue(parentName, out var kind) ||
+            kind?.ToLower() != "system")
+        {
+            EffectiveIp = null;
+            return;
+        }
+
+        // Load parent system
+        var parent = await GetByNameUseCase.ExecuteAsync(parentName);
+
+        EffectiveIp = string.IsNullOrWhiteSpace(parent?.Ip)
+            ? null
+            : parent.Ip;
+    }
+}

+ 2 - 0
Shared.Rcl/Systems/SystemEditModel.cs

@@ -14,6 +14,7 @@ public sealed class SystemEditModel
             : value.Trim().ToLowerInvariant();
     }
     
+    public string? Ip { get; set; }
     public string? Os { get; set; }
     public int? Cores { get; set; }
     public double? Ram { get; set; }
@@ -29,6 +30,7 @@ public sealed class SystemEditModel
             Os = system.Os,
             Cores = system.Cores,
             Ram = system.Ram,
+            Ip = system.Ip,
             RunsOn = system.RunsOn,
             Notes = system.Notes
         };

+ 1 - 0
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -86,6 +86,7 @@
             edit.Os,
             edit.Cores,
             edit.Ram,
+            edit.Ip,
             edit.RunsOn,
             edit.Notes
         );

+ 1 - 0
Shared.Rcl/Systems/SystemsListPage.razor

@@ -100,6 +100,7 @@
             edit.Os,
             edit.Cores,
             edit.Ram,
+            edit.Ip,
             edit.RunsOn,
             edit.Notes
         );

+ 2 - 0
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -133,6 +133,7 @@ public async Task systems_cli_workflow_runs_on_hardware_and_systems_test()
         "--os", "debian-12",
         "--cores", "2",
         "--ram", "4",
+        "--ip", "10.0.20.10",
         "--runs-on", "proxmox-node01"
     );
     Assert.Equal("System 'sys01' updated.\n", output);
@@ -168,6 +169,7 @@ public async Task systems_cli_workflow_runs_on_hardware_and_systems_test()
                    os: debian-12
                    cores: 2
                    ram: 4
+                   ip: 10.0.20.10
                    name: sys01
                    runsOn:
                    - proxmox-node01

+ 1 - 0
Tests/TestConfigs/v2/10-system.yaml

@@ -10,6 +10,7 @@ resources:
     os: ubuntu-22.04
     cores: 4
     ram: 8
+    ip: 10.0.20.10
     drives:
       - size: 128
       - size: 256

+ 1 - 0
Tests/TestConfigs/v2/11-demo-config.yaml

@@ -317,6 +317,7 @@ resources:
     os: proxmox
     cores: 16
     ram: 128
+    ip: 10.0.20.10
     drives:
       - size: 1024
       - size: 1024

+ 1 - 0
Tests/schemas/schema.v2.json

@@ -323,6 +323,7 @@
                 "container", "embedded", "cloud", "other"
               ]
             },
+            "ip": { "type": "string" },
             "os": { "type": "string" },
             "cores": { "type": "integer", "minimum": 1 },
             "ram": { "type": "number", "minimum": 0 },