Files
autoglue/internal/handlers/cluster_metadata_test.go
Irfan Paraniya 385affb1d7 feat: Implement cluster metadata key-value store (Closes #302) (#834)
* feat: add cluster metadata key-value store

- Add ClusterMetadata model with ClusterID FK, key, value fields
- Add Metadata []ClusterMetadata relation to Cluster model
- Add CRUD handlers: List, Get, Create, Update, Delete cluster metadata
  - Keys are forced to lowercase on create/update
  - Values preserve case sensitivity
- Add metadata routes under /clusters/{clusterID}/metadata
- Include metadata in ClusterResponse DTO and clusterToDTO mapping
- Add Preload(Metadata) to all cluster queries
- Register ClusterMetadata in AutoMigrate

Closes: internal-GlueOps/issues#302

* feat: include cluster metadata in prepare payload

- Preload cluster Metadata in ClusterPrepareWorker
- Map cluster metadata into mapper.ClusterToDTO response payload

This ensures metadata key-value pairs are injected into the platform JSON payload used by prepare/bootstrap flows.

* feat: add cluster metadata UI section to configure dialog

* feat: simplify cluster metadata to map[string]string in response

* fix: address cluster metadata PR review feedback

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

* chore: finalize review feedback updates

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

* chore: revert unintended go.sum change

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>
2026-04-17 11:05:49 +05:30

161 lines
5.4 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/testutil/pgtest"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestCreateClusterMetadata_OrgScoping(t *testing.T) {
db := pgtest.DB(t)
migrateClusterMetadata(t, db)
orgA := createTestOrg(t, db, "metadata-org-a")
orgB := createTestOrg(t, db, "metadata-org-b")
clusterInOrgB := createTestCluster(t, db, orgB.ID, "cluster-b")
req := httptest.NewRequest(http.MethodPost, "/clusters/"+clusterInOrgB.ID.String()+"/metadata", strings.NewReader(`{"key":"network.service_cidr","value":"10.96.0.0/12"}`))
req.Header.Set("Content-Type", "application/json")
req = withOrgAndClusterID(req, orgA.ID, clusterInOrgB.ID)
rr := httptest.NewRecorder()
CreateClusterMetadata(db).ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for cross-org cluster access, got %d body=%s", rr.Code, rr.Body.String())
}
}
func TestCreateClusterMetadata_NormalizesKeyAndTrimsValue(t *testing.T) {
db := pgtest.DB(t)
migrateClusterMetadata(t, db)
org := createTestOrg(t, db, "metadata-normalize-org")
cluster := createTestCluster(t, db, org.ID, "cluster-normalize")
req := httptest.NewRequest(http.MethodPost, "/clusters/"+cluster.ID.String()+"/metadata", strings.NewReader(`{"key":" Network.Service_CIDR ","value":" My Value "}`))
req.Header.Set("Content-Type", "application/json")
req = withOrgAndClusterID(req, org.ID, cluster.ID)
rr := httptest.NewRecorder()
CreateClusterMetadata(db).ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := out["key"]; got != "network.service_cidr" {
t.Fatalf("expected normalized lowercase key, got %v", got)
}
if got := out["value"]; got != "My Value" {
t.Fatalf("expected trimmed value preserving case, got %v", got)
}
}
func TestCreateClusterMetadata_RequiredFields(t *testing.T) {
db := pgtest.DB(t)
migrateClusterMetadata(t, db)
org := createTestOrg(t, db, "metadata-required-org")
cluster := createTestCluster(t, db, org.ID, "cluster-required")
cases := []struct {
name string
body string
}{
{name: "missing key", body: `{"key":" ","value":"ok"}`},
{name: "missing value", body: `{"key":"network.calico_cidr","value":" "}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/clusters/"+cluster.ID.String()+"/metadata", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
req = withOrgAndClusterID(req, org.ID, cluster.ID)
rr := httptest.NewRecorder()
CreateClusterMetadata(db).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rr.Code, rr.Body.String())
}
})
}
}
func TestCreateClusterMetadata_DuplicateKeyConflict(t *testing.T) {
db := pgtest.DB(t)
migrateClusterMetadata(t, db)
org := createTestOrg(t, db, "metadata-duplicate-org")
cluster := createTestCluster(t, db, org.ID, "cluster-duplicate")
first := httptest.NewRequest(http.MethodPost, "/clusters/"+cluster.ID.String()+"/metadata", strings.NewReader(`{"key":"network.service_cidr","value":"10.96.0.0/12"}`))
first.Header.Set("Content-Type", "application/json")
first = withOrgAndClusterID(first, org.ID, cluster.ID)
firstRR := httptest.NewRecorder()
CreateClusterMetadata(db).ServeHTTP(firstRR, first)
if firstRR.Code != http.StatusCreated {
t.Fatalf("expected first create to succeed, got %d body=%s", firstRR.Code, firstRR.Body.String())
}
second := httptest.NewRequest(http.MethodPost, "/clusters/"+cluster.ID.String()+"/metadata", strings.NewReader(`{"key":"NETWORK.SERVICE_CIDR","value":"10.97.0.0/12"}`))
second.Header.Set("Content-Type", "application/json")
second = withOrgAndClusterID(second, org.ID, cluster.ID)
secondRR := httptest.NewRecorder()
CreateClusterMetadata(db).ServeHTTP(secondRR, second)
if secondRR.Code != http.StatusConflict {
t.Fatalf("expected 409 on duplicate key, got %d body=%s", secondRR.Code, secondRR.Body.String())
}
}
func migrateClusterMetadata(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.AutoMigrate(&models.ClusterMetadata{}); err != nil {
t.Fatalf("migrate cluster metadata: %v", err)
}
}
func createTestOrg(t *testing.T, db *gorm.DB, namePrefix string) models.Organization {
t.Helper()
org := models.Organization{Name: namePrefix + "-" + uuid.NewString()}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
return org
}
func createTestCluster(t *testing.T, db *gorm.DB, orgID uuid.UUID, namePrefix string) models.Cluster {
t.Helper()
cluster := models.Cluster{
OrganizationID: orgID,
Name: namePrefix + "-" + uuid.NewString(),
}
if err := db.Create(&cluster).Error; err != nil {
t.Fatalf("create cluster: %v", err)
}
return cluster
}
func withOrgAndClusterID(r *http.Request, orgID, clusterID uuid.UUID) *http.Request {
ctx := httpmiddleware.WithOrg(r.Context(), &models.Organization{ID: orgID})
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("clusterID", clusterID.String())
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
return r.WithContext(ctx)
}