Tim Jones пре 2 недеља
родитељ
комит
c770fc295a

+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -9,5 +9,6 @@
   "cli-commands-index.md",
   "cli-commands-index.md",
   "ssh-config-export.md",
   "ssh-config-export.md",
   "hosts-file-export.md",
   "hosts-file-export.md",
+  "inventory-api.md",
   "versioning.md"
   "versioning.md"
 ]
 ]

+ 223 - 0
Shared.Rcl/wwwroot/raw_docs/inventory-api.md

@@ -0,0 +1,223 @@
+# Inventory API
+
+RackPeek exposes a small HTTP API for upserting inventory from scripts and
+external systems — useful for automatic ingestion from Ansible, Terraform,
+discovery scanners, or any other tool that can produce a YAML or JSON
+description of your infrastructure.
+
+The API is served by the same process as the Web UI, on the same port.
+
+## What you need
+
+1. **The server URL** — wherever RackPeek is running, e.g.
+   `http://localhost:8080`.
+2. **An API key** — a shared secret you choose, set via the `RPK_API_KEY`
+   environment variable on the server. Requests without a matching key
+   are rejected.
+
+## Configuring the API key
+
+Set `RPK_API_KEY` on the RackPeek container or process. Until you set it,
+the inventory endpoint returns `503 Service Unavailable` for every request.
+
+| Variable | Required | Description |
+|---|---|---|
+| `RPK_API_KEY` | Yes (to use the API) | Shared secret. Clients must send this in the `X-Api-Key` header. |
+
+Example with Docker Compose:
+
+```yaml
+version: "3.9"
+
+services:
+  rackpeek:
+    image: aptacode/rackpeek:latest
+    container_name: rackpeek
+    ports:
+      - "8080:8080"
+    volumes:
+      - rackpeek-config:/app/config
+    environment:
+      - RPK_API_KEY=change-me-to-a-long-random-string
+    restart: unless-stopped
+
+volumes:
+  rackpeek-config:
+```
+
+Pick a long random value (e.g. `openssl rand -hex 32`). The server compares
+keys in constant time, so timing-based guessing is not viable, but a weak
+key still loses to brute force — treat it like a password.
+
+## Endpoints
+
+### `GET /health`
+
+Liveness check. No authentication. Returns `200 OK` with body `rackpeek` as
+`text/plain`. Used by the Docker healthcheck.
+
+```bash
+curl http://localhost:8080/health
+# rackpeek
+```
+
+### `POST /api/inventory`
+
+Upsert resources and connections. Requires the `X-Api-Key` header.
+
+#### Request body
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `yaml` | string | one of `yaml` or `json` | Raw YAML payload, same schema as `config.yaml`. |
+| `json` | object | one of `yaml` or `json` | Structured JSON payload (see shape below). |
+| `mode` | `"Merge"` \| `"Replace"` | No (default `Merge`) | How incoming resources combine with existing ones. |
+| `dryRun` | bool | No (default `false`) | Compute the diff and return it without writing to disk. |
+
+Exactly one of `yaml` or `json` must be supplied — sending both is rejected.
+
+#### Merge semantics
+
+For resources whose `name` doesn't already exist, the resource is added.
+For resources whose `name` already exists:
+
+- **`Merge`** — fields present in the incoming payload overwrite the existing
+  values; fields that are omitted, `null`, empty lists, or empty maps in the
+  incoming payload are left untouched. Use this for partial updates.
+- **`Replace`** — the incoming resource fully replaces the existing one,
+  even for omitted fields. Use this when the incoming payload is the
+  authoritative full picture.
+
+If an incoming resource has a different `kind` than the existing one with
+the same name (e.g. server → switch), the existing resource is fully
+replaced regardless of mode.
+
+#### JSON payload shape
+
+The `json` field accepts the same shape as `config.yaml` — a root object
+with `version`, `resources`, and optional `connections`. The canonical
+schemas live in [`schemas/`](https://github.com/Timmoth/RackPeek/tree/main/schemas)
+in the repository.
+
+```json
+{
+  "version": 3,
+  "resources": [
+    {
+      "kind": "server",
+      "name": "web-01",
+      "tags": ["homelab", "prod"],
+      "labels": { "env": "production" },
+      "ipmi": true,
+      "ram": { "size": 64 }
+    }
+  ],
+  "connections": []
+}
+```
+
+#### Response
+
+`200 OK` with a diff summary:
+
+| Field | Description |
+|---|---|
+| `added` | Names of resources that were newly created. |
+| `updated` | Names of existing resources whose fields changed under `Merge` mode. |
+| `replaced` | Names of resources fully overwritten (either `Replace` mode, or a kind mismatch). |
+| `oldYaml` | YAML snapshot of each updated/replaced resource **before** the change, keyed by name. |
+| `newYaml` | YAML snapshot of each incoming resource **after** the change, keyed by name. |
+
+`oldYaml` is omitted for newly-added resources (nothing existed to snapshot).
+
+#### Error responses
+
+| Status | When |
+|---|---|
+| `400 Bad Request` | Validation failure — neither `yaml` nor `json` supplied, both supplied, malformed payload, duplicate resource names, etc. Body: `{ "error": "..." }`. |
+| `401 Unauthorized` | `X-Api-Key` header missing or doesn't match `RPK_API_KEY`. |
+| `503 Service Unavailable` | `RPK_API_KEY` is not configured on the server. |
+
+## Examples
+
+### Upsert from a YAML file with curl
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d "$(jq -Rs --arg mode Merge '{yaml: ., mode: $mode}' < inventory.yaml)"
+```
+
+### Upsert a single resource as JSON
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "Merge",
+    "json": {
+      "version": 3,
+      "resources": [
+        {
+          "kind": "server",
+          "name": "web-01",
+          "tags": ["homelab"],
+          "ram": { "size": 64 }
+        }
+      ]
+    }
+  }'
+```
+
+### Preview a change with `dryRun`
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "dryRun": true,
+    "mode": "Replace",
+    "json": {
+      "version": 3,
+      "resources": [
+        { "kind": "server", "name": "web-01", "ram": { "size": 128 } }
+      ]
+    }
+  }'
+```
+
+The response shows what would change without modifying `config.yaml`.
+
+### Bash helper
+
+```bash
+rpk_ingest() {
+  local file="$1"
+  curl -fsS -X POST "${RPK_URL:-http://localhost:8080}/api/inventory" \
+    -H "X-Api-Key: $RPK_API_KEY" \
+    -H "Content-Type: application/json" \
+    -d "$(jq -Rs '{yaml: ., mode: "Merge"}' < "$file")"
+}
+
+rpk_ingest inventory.yaml
+```
+
+## Tips
+
+- Run with `dryRun: true` first when wiring up a new pipeline — the response
+  tells you exactly what would change.
+- Prefer `Merge` when your script only knows about a subset of fields (e.g.
+  a discovery scanner that only sees CPU/RAM but doesn't know about
+  labels/tags). Use `Replace` when the script owns the full resource.
+- The `tags` array (a list) is replaced wholesale by any non-empty incoming
+  value, even in `Merge` mode — if you want to add a single tag without
+  overwriting the others, fetch the resource first or use the CLI
+  (`rpk <kind> tag add ...`).
+- The `labels` map (a dictionary) is merged key-wise in `Merge` mode —
+  existing keys absent from the incoming payload are preserved; keys
+  present in the incoming payload overwrite the existing value.
+- The same YAML you commit to Git via the Git integration is what the API
+  reads and writes — there is no separate API store.

+ 129 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -666,4 +666,133 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
 
 
         Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
         Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
     }
     }
+
+    [Fact]
+    public async Task Health_Endpoint_Is_Anonymous_And_Returns_rackpeek() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.GetAsync("/health");
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
+        Assert.Equal("rackpeek", await response.Content.ReadAsStringAsync());
+    }
+
+    [Fact]
+    public async Task Wrong_Api_Key_Returns_401() {
+        HttpClient client = Factory.CreateClient();
+        client.DefaultRequestHeaders.Add("X-Api-Key", "definitely-wrong");
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Bad_Request_Body_Contains_Error_Field() {
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+        Dictionary<string, string>? body =
+            await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
+
+        Assert.NotNull(body);
+        Assert.True(body!.ContainsKey("error"));
+        Assert.False(string.IsNullOrWhiteSpace(body["error"]));
+    }
+
+    [Fact]
+    public async Task Added_Resource_Has_No_OldYaml_Entry() {
+        HttpClient client = CreateClient(true);
+
+        var yaml = """
+                   resources:
+                     - kind: Server
+                       name: fresh-server
+                   """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("fresh-server", result!.Added);
+        Assert.DoesNotContain("fresh-server", result.OldYaml.Keys);
+        Assert.Contains("fresh-server", result.NewYaml.Keys);
+    }
+
+    [Fact]
+    public async Task Merge_Replaces_Tags_Wholesale() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: tag-merge
+                          tags:
+                            - alpha
+                            - beta
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: tag-merge
+                         tags:
+                           - gamma
+                     """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("tag-merge", result!.Updated);
+
+        var newYaml = result.NewYaml["tag-merge"];
+        Assert.Contains("gamma", newYaml);
+        Assert.DoesNotContain("alpha", newYaml);
+        Assert.DoesNotContain("beta", newYaml);
+    }
+
+    [Fact]
+    public async Task Merge_Preserves_Labels_Not_In_Incoming() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: label-merge
+                          labels:
+                            env: production
+                            team: backend
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: label-merge
+                         labels:
+                           env: staging
+                     """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("label-merge", result!.Updated);
+
+        var newYaml = result.NewYaml["label-merge"];
+        Assert.Contains("env: staging", newYaml);
+        Assert.Contains("team: backend", newYaml);
+        Assert.DoesNotContain("env: production", newYaml);
+    }
 }
 }

+ 30 - 0
Tests/Api/InventoryEndpointUnconfiguredTests.cs

@@ -0,0 +1,30 @@
+using System.Net;
+using System.Net.Http.Json;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+public class InventoryEndpointUnconfiguredTests(ITestOutputHelper output) : ApiTestBase(output) {
+    protected override void ConfigureTestConfiguration(IDictionary<string, string?> config) =>
+        config.Remove("RPK_API_KEY");
+
+    [Fact]
+    public async Task Missing_Server_Api_Key_Returns_503() {
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Missing_Server_Api_Key_Returns_503_Even_Without_Client_Header() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+    }
+}