From 385affb1d70747321a5393e70732e0e85af7e915 Mon Sep 17 00:00:00 2001 From: Irfan Paraniya Date: Fri, 17 Apr 2026 11:05:49 +0530 Subject: [PATCH] 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> --- internal/api/mount_cluster_routes.go | 6 + internal/app/runtime.go | 2 +- internal/bg/prepare_cluster.go | 1 + internal/handlers/cluster_metadata.go | 399 +++++++++++++++++++++ internal/handlers/cluster_metadata_test.go | 160 +++++++++ internal/handlers/clusters.go | 23 +- internal/handlers/dto/cluster_metadata.go | 20 ++ internal/handlers/dto/clusters.go | 5 +- internal/mapper/cluster.go | 6 + internal/models/cluster.go | 5 +- internal/models/cluster_metadata.go | 14 + ui/src/api/clusters.ts | 13 +- ui/src/pages/cluster-page.tsx | 94 ++++- ui/src/sdkClient.ts | 7 +- 14 files changed, 746 insertions(+), 9 deletions(-) create mode 100644 internal/handlers/cluster_metadata.go create mode 100644 internal/handlers/cluster_metadata_test.go create mode 100644 internal/handlers/dto/cluster_metadata.go create mode 100644 internal/models/cluster_metadata.go diff --git a/internal/api/mount_cluster_routes.go b/internal/api/mount_cluster_routes.go index 1275196..e59d0d9 100644 --- a/internal/api/mount_cluster_routes.go +++ b/internal/api/mount_cluster_routes.go @@ -40,6 +40,12 @@ func mountClusterRoutes(r chi.Router, db *gorm.DB, cfg config.Config, jobs *bg.J c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db, cfg)) c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db, cfg)) + c.Get("/{clusterID}/metadata", handlers.ListClusterMetadata(db)) + c.Post("/{clusterID}/metadata", handlers.CreateClusterMetadata(db)) + c.Get("/{clusterID}/metadata/{metadataID}", handlers.GetClusterMetadata(db)) + c.Patch("/{clusterID}/metadata/{metadataID}", handlers.UpdateClusterMetadata(db)) + c.Delete("/{clusterID}/metadata/{metadataID}", handlers.DeleteClusterMetadata(db)) + c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db)) c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db)) c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs)) diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 0f228bf..c0a3059 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -45,8 +45,8 @@ func NewRuntime() *Runtime { &models.LoadBalancer{}, &models.Cluster{}, &models.Action{}, - &models.Cluster{}, &models.ClusterRun{}, + &models.ClusterMetadata{}, ) if err != nil { diff --git a/internal/bg/prepare_cluster.go b/internal/bg/prepare_cluster.go index dea1175..aa0149c 100644 --- a/internal/bg/prepare_cluster.go +++ b/internal/bg/prepare_cluster.go @@ -77,6 +77,7 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers.SshKey"). + Preload("Metadata"). Where("status = ?", clusterStatusPrePending). Find(&clusters).Error; err != nil { log.Error().Err(err).Msg("[cluster_prepare] query clusters failed") diff --git a/internal/handlers/cluster_metadata.go b/internal/handlers/cluster_metadata.go new file mode 100644 index 0000000..0b37076 --- /dev/null +++ b/internal/handlers/cluster_metadata.go @@ -0,0 +1,399 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/glueops/autoglue/internal/api/httpmiddleware" + "github.com/glueops/autoglue/internal/handlers/dto" + "github.com/glueops/autoglue/internal/models" + "github.com/glueops/autoglue/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ListClusterMetadata godoc +// +// @ID ListClusterMetadata +// @Summary List metadata for a cluster (org scoped) +// @Description Returns all metadata key-value pairs attached to the cluster. +// @Tags Cluster Metadata +// @Produce json +// @Param X-Org-ID header string false "Organization UUID" +// @Param clusterID path string true "Cluster ID" +// @Success 200 {array} dto.ClusterMetadataResponse +// @Failure 400 {string} string "invalid cluster id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "db error" +// @Router /clusters/{clusterID}/metadata [get] +// @Security BearerAuth +// @Security OrgKeyAuth +// @Security OrgSecretAuth +func ListClusterMetadata(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) + if !ok { + utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID") + return + } + + clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid cluster id") + return + } + + // Ensure cluster belongs to org + var cluster models.Cluster + if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + var rows []models.ClusterMetadata + if err := db.Where("cluster_id = ?", clusterID).Order("created_at ASC").Find(&rows).Error; err != nil { + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + out := make([]dto.ClusterMetadataResponse, 0, len(rows)) + for _, m := range rows { + out = append(out, clusterMetadataToDTO(m)) + } + utils.WriteJSON(w, http.StatusOK, out) + } +} + +// GetClusterMetadata godoc +// +// @ID GetClusterMetadata +// @Summary Get a single cluster metadata entry (org scoped) +// @Description Returns one metadata key-value pair by ID. +// @Tags Cluster Metadata +// @Produce json +// @Param X-Org-ID header string false "Organization UUID" +// @Param clusterID path string true "Cluster ID" +// @Param metadataID path string true "Metadata ID (UUID)" +// @Success 200 {object} dto.ClusterMetadataResponse +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "db error" +// @Router /clusters/{clusterID}/metadata/{metadataID} [get] +// @Security BearerAuth +// @Security OrgKeyAuth +// @Security OrgSecretAuth +func GetClusterMetadata(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) + if !ok { + utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID") + return + } + + clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid cluster id") + return + } + + metadataID, err := uuid.Parse(chi.URLParam(r, "metadataID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid metadata id") + return + } + + // Ensure cluster belongs to org + var cluster models.Cluster + if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + var m models.ClusterMetadata + if err := db.Where("id = ? AND cluster_id = ?", metadataID, clusterID).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + utils.WriteJSON(w, http.StatusOK, clusterMetadataToDTO(m)) + } +} + +// CreateClusterMetadata godoc +// +// @ID CreateClusterMetadata +// @Summary Create cluster metadata (org scoped) +// @Description Adds a new key-value metadata entry to a cluster. Keys are forced to lowercase; values preserve case. +// @Tags Cluster Metadata +// @Accept json +// @Produce json +// @Param X-Org-ID header string false "Organization UUID" +// @Param clusterID path string true "Cluster ID" +// @Param body body dto.CreateClusterMetadataRequest true "Key-value pair" +// @Success 201 {object} dto.ClusterMetadataResponse +// @Failure 400 {string} string "invalid id / invalid json / missing key" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "cluster not found" +// @Failure 500 {string} string "db error" +// @Router /clusters/{clusterID}/metadata [post] +// @Security BearerAuth +// @Security OrgKeyAuth +// @Security OrgSecretAuth +func CreateClusterMetadata(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) + if !ok { + utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID") + return + } + + clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid cluster id") + return + } + + // Ensure cluster belongs to org + var cluster models.Cluster + if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + var req dto.CreateClusterMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid json") + return + } + + key := strings.ToLower(strings.TrimSpace(req.Key)) + if key == "" { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "key is required") + return + } + value := strings.TrimSpace(req.Value) + if value == "" { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "value is required") + return + } + + m := models.ClusterMetadata{ + ClusterID: clusterID, + Key: key, + Value: value, // value case preserved + } + m.OrganizationID = orgID + + if err := db.Create(&m).Error; err != nil { + if isUniqueConstraintViolation(err) { + utils.WriteError(w, http.StatusConflict, "conflict", "metadata key already exists for this cluster") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + utils.WriteJSON(w, http.StatusCreated, clusterMetadataToDTO(m)) + } +} + +// UpdateClusterMetadata godoc +// +// @ID UpdateClusterMetadata +// @Summary Update cluster metadata (org scoped) +// @Description Partially updates a metadata entry. Key is forced to lowercase if provided; value case is preserved. +// @Tags Cluster Metadata +// @Accept json +// @Produce json +// @Param X-Org-ID header string false "Organization UUID" +// @Param clusterID path string true "Cluster ID" +// @Param metadataID path string true "Metadata ID (UUID)" +// @Param body body dto.UpdateClusterMetadataRequest true "Fields to update" +// @Success 200 {object} dto.ClusterMetadataResponse +// @Failure 400 {string} string "invalid id / invalid json" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "db error" +// @Router /clusters/{clusterID}/metadata/{metadataID} [patch] +// @Security BearerAuth +// @Security OrgKeyAuth +// @Security OrgSecretAuth +func UpdateClusterMetadata(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) + if !ok { + utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID") + return + } + + clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid cluster id") + return + } + + metadataID, err := uuid.Parse(chi.URLParam(r, "metadataID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid metadata id") + return + } + + // Ensure cluster belongs to org + var cluster models.Cluster + if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + var m models.ClusterMetadata + if err := db.Where("id = ? AND cluster_id = ?", metadataID, clusterID).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + var req dto.UpdateClusterMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid json") + return + } + + if req.Key != nil { + normalizedKey := strings.TrimSpace(*req.Key) + if normalizedKey == "" { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "key cannot be empty") + return + } + m.Key = strings.ToLower(normalizedKey) + } + if req.Value != nil { + value := strings.TrimSpace(*req.Value) + if value == "" { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "value cannot be empty") + return + } + m.Value = value // value case preserved + } + + if err := db.Save(&m).Error; err != nil { + if isUniqueConstraintViolation(err) { + utils.WriteError(w, http.StatusConflict, "conflict", "metadata key already exists for this cluster") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + utils.WriteJSON(w, http.StatusOK, clusterMetadataToDTO(m)) + } +} + +// DeleteClusterMetadata godoc +// +// @ID DeleteClusterMetadata +// @Summary Delete cluster metadata (org scoped) +// @Description Permanently deletes a metadata entry from a cluster. +// @Tags Cluster Metadata +// @Param X-Org-ID header string false "Organization UUID" +// @Param clusterID path string true "Cluster ID" +// @Param metadataID path string true "Metadata ID (UUID)" +// @Success 204 "No Content" +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "cluster not found" +// @Failure 500 {string} string "db error" +// @Router /clusters/{clusterID}/metadata/{metadataID} [delete] +// @Security BearerAuth +// @Security OrgKeyAuth +// @Security OrgSecretAuth +func DeleteClusterMetadata(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) + if !ok { + utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID") + return + } + + clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid cluster id") + return + } + + metadataID, err := uuid.Parse(chi.URLParam(r, "metadataID")) + if err != nil { + utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid metadata id") + return + } + + // Ensure cluster belongs to org + var cluster models.Cluster + if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found") + return + } + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + if err := db.Where("id = ? AND cluster_id = ?", metadataID, clusterID).Delete(&models.ClusterMetadata{}).Error; err != nil { + utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +func clusterMetadataToDTO(m models.ClusterMetadata) dto.ClusterMetadataResponse { + return dto.ClusterMetadataResponse{ + AuditFields: m.AuditFields, + ClusterID: m.ClusterID.String(), + Key: m.Key, + Value: m.Value, + } +} + +func isUniqueConstraintViolation(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "duplicate key value") || strings.Contains(msg, "unique constraint") +} diff --git a/internal/handlers/cluster_metadata_test.go b/internal/handlers/cluster_metadata_test.go new file mode 100644 index 0000000..6ed52d8 --- /dev/null +++ b/internal/handlers/cluster_metadata_test.go @@ -0,0 +1,160 @@ +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) +} diff --git a/internal/handlers/clusters.go b/internal/handlers/clusters.go index 72b6fef..30d6cde 100644 --- a/internal/handlers/clusters.go +++ b/internal/handlers/clusters.go @@ -63,6 +63,7 @@ func ListClusters(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). Find(&rows).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -132,6 +133,7 @@ func GetCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -313,6 +315,7 @@ func UpdateCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -452,6 +455,7 @@ func AttachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -521,6 +525,7 @@ func DetachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -613,6 +618,7 @@ func AttachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFun Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -683,6 +689,7 @@ func DetachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFun Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -771,6 +778,7 @@ func AttachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -841,6 +849,7 @@ func DetachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -929,6 +938,7 @@ func AttachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -999,6 +1009,7 @@ func DetachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -1087,6 +1098,7 @@ func AttachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -1157,6 +1169,7 @@ func DetachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -1244,6 +1257,7 @@ func SetClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -1317,6 +1331,7 @@ func ClearClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return @@ -1415,6 +1430,7 @@ func AttachNodePool(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") @@ -1509,6 +1525,7 @@ func DetachNodePool(db *gorm.DB, cfg config.Config) http.HandlerFunc { Preload("NodePools.Annotations"). Preload("NodePools.Taints"). Preload("NodePools.Servers"). + Preload("Metadata"). First(&cluster, "id = ?", cluster.ID).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") @@ -1562,7 +1579,10 @@ func clusterToDTO(c models.Cluster, cfg config.Config) dto.ClusterResponse { for _, np := range c.NodePools { nps = append(nps, nodePoolToDTO(np)) } - fmt.Println(cfg.BaseURL) + metadata := make(map[string]string, len(c.Metadata)) + for _, m := range c.Metadata { + metadata[m.Key] = m.Value + } return dto.ClusterResponse{ ID: c.ID, Name: c.Name, @@ -1580,6 +1600,7 @@ func clusterToDTO(c models.Cluster, cfg config.Config) dto.ClusterResponse { RandomToken: c.RandomToken, CertificateKey: c.CertificateKey, NodePools: nps, + Metadata: metadata, DockerImage: c.DockerImage, DockerTag: c.DockerTag, CreatedAt: c.CreatedAt, diff --git a/internal/handlers/dto/cluster_metadata.go b/internal/handlers/dto/cluster_metadata.go new file mode 100644 index 0000000..9a3683d --- /dev/null +++ b/internal/handlers/dto/cluster_metadata.go @@ -0,0 +1,20 @@ +package dto + +import "github.com/glueops/autoglue/internal/common" + +type ClusterMetadataResponse struct { + common.AuditFields + ClusterID string `json:"cluster_id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type CreateClusterMetadataRequest struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type UpdateClusterMetadataRequest struct { + Key *string `json:"key,omitempty"` + Value *string `json:"value,omitempty"` +} diff --git a/internal/handlers/dto/clusters.go b/internal/handlers/dto/clusters.go index b3b2f60..6a447e8 100644 --- a/internal/handlers/dto/clusters.go +++ b/internal/handlers/dto/clusters.go @@ -22,8 +22,9 @@ type ClusterResponse struct { LastError string `json:"last_error"` RandomToken string `json:"random_token"` CertificateKey string `json:"certificate_key"` - NodePools []NodePoolResponse `json:"node_pools,omitempty"` - DockerImage string `json:"docker_image"` + NodePools []NodePoolResponse `json:"node_pools,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + DockerImage string `json:"docker_image"` DockerTag string `json:"docker_tag"` Kubeconfig *string `json:"kubeconfig,omitempty"` OrgKey *string `json:"org_key,omitempty"` diff --git a/internal/mapper/cluster.go b/internal/mapper/cluster.go index 105fc4e..2821d92 100644 --- a/internal/mapper/cluster.go +++ b/internal/mapper/cluster.go @@ -52,6 +52,11 @@ func ClusterToDTO(c models.Cluster) dto.ClusterResponse { nps = append(nps, NodePoolToDTO(np)) } + metadata := make(map[string]string, len(c.Metadata)) + for _, m := range c.Metadata { + metadata[m.Key] = m.Value + } + return dto.ClusterResponse{ ID: c.ID, Name: c.Name, @@ -68,6 +73,7 @@ func ClusterToDTO(c models.Cluster) dto.ClusterResponse { RandomToken: c.RandomToken, CertificateKey: c.CertificateKey, NodePools: nps, + Metadata: metadata, DockerImage: c.DockerImage, DockerTag: c.DockerTag, CreatedAt: c.CreatedAt, diff --git a/internal/models/cluster.go b/internal/models/cluster.go index 81fd2f4..bbb657a 100644 --- a/internal/models/cluster.go +++ b/internal/models/cluster.go @@ -35,8 +35,9 @@ type Cluster struct { GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"` BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"` BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"` - NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` - RandomToken string `json:"random_token"` + NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` + Metadata []ClusterMetadata `gorm:"foreignKey:ClusterID;constraint:OnDelete:CASCADE" json:"metadata,omitempty"` + RandomToken string `json:"random_token"` CertificateKey string `json:"certificate_key"` EncryptedKubeconfig string `gorm:"type:text" json:"-"` KubeIV string `json:"-"` diff --git a/internal/models/cluster_metadata.go b/internal/models/cluster_metadata.go new file mode 100644 index 0000000..8414a32 --- /dev/null +++ b/internal/models/cluster_metadata.go @@ -0,0 +1,14 @@ +package models + +import ( + "github.com/glueops/autoglue/internal/common" + "github.com/google/uuid" +) + +type ClusterMetadata struct { + common.AuditFields `gorm:"embedded"` + ClusterID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_cluster_metadata_cluster_key" json:"cluster_id"` + Cluster Cluster `gorm:"foreignKey:ClusterID;constraint:OnDelete:CASCADE" json:"-"` + Key string `gorm:"not null;uniqueIndex:idx_cluster_metadata_cluster_key" json:"key"` + Value string `gorm:"not null" json:"value"` +} diff --git a/ui/src/api/clusters.ts b/ui/src/api/clusters.ts index 8bab5d8..5d4cc8e 100644 --- a/ui/src/api/clusters.ts +++ b/ui/src/api/clusters.ts @@ -1,6 +1,6 @@ import { withRefresh } from "@/api/with-refresh"; import type { DtoAttachBastionRequest, DtoAttachCaptainDomainRequest, DtoAttachLoadBalancerRequest, DtoAttachRecordSetRequest, DtoCreateClusterRequest, DtoSetKubeconfigRequest, DtoUpdateClusterRequest } from "@/sdk"; -import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"; +import { makeClusterApi, makeClusterMetadataApi, makeClusterRunsApi } from "@/sdkClient"; @@ -8,6 +8,7 @@ import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"; const clusters = makeClusterApi() const clusterRuns = makeClusterRunsApi() +const clusterMetadata = makeClusterMetadataApi() export const clustersApi = { // --- basic CRUD --- @@ -145,6 +146,16 @@ export const clustersApi = { return await clusters.detachNodePool({ clusterID, nodePoolID }) }), + // --- metadata --- + + createClusterMetadata: (clusterID: string, key: string, value: string) => + withRefresh(async () => { + return await clusterMetadata.createClusterMetadata({ + clusterID, + createClusterMetadataRequest: { key, value }, + }) + }), + // --- cluster runs / actions --- listClusterRuns: (clusterID: string) => withRefresh(async () => { diff --git a/ui/src/pages/cluster-page.tsx b/ui/src/pages/cluster-page.tsx index 9602741..7e6ff87 100644 --- a/ui/src/pages/cluster-page.tsx +++ b/ui/src/pages/cluster-page.tsx @@ -8,7 +8,7 @@ import { serversApi } from "@/api/servers"; import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react"; +import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Key, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -220,6 +220,8 @@ export const ClustersPage = () => { const [glueopsLbId, setGlueopsLbId] = useState("") const [bastionId, setBastionId] = useState("") const [nodePoolId, setNodePoolId] = useState("") + const [metadataKey, setMetadataKey] = useState("") + const [metadataValue, setMetadataValue] = useState("") const [kubeconfigText, setKubeconfigText] = useState("") const [busyKey, setBusyKey] = useState(null) @@ -408,6 +410,9 @@ export const ClustersPage = () => { // --- Config dialog helpers --- useEffect(() => { + setMetadataKey("") + setMetadataValue("") + if (!configCluster) { setCaptainDomainId("") setRecordSetId("") @@ -650,6 +655,26 @@ export const ClustersPage = () => { } } + async function handleCreateMetadata() { + if (!configCluster?.id) return + if (!metadataKey.trim()) return toast.error("Key is required") + if (!metadataValue.trim()) return toast.error("Value is required") + setBusyKey("metadata") + try { + await clustersApi.createClusterMetadata(configCluster.id, metadataKey.trim(), metadataValue.trim()) + toast.success("Metadata created.") + setMetadataKey("") + setMetadataValue("") + await refreshConfigCluster() + } catch (err: any) { + toast.error(err?.message ?? "Failed to create metadata.") + } finally { + setBusyKey(null) + } + } + + + if (clustersQ.isLoading) return
Loading clusters…
if (clustersQ.error) return
Error loading clusters.
@@ -1484,6 +1509,73 @@ export const ClustersPage = () => { + {/* Metadata */} +
+
+
+ +

Cluster Metadata

+
+

+ Store custom key-value metadata for cluster configuration. +

+
+ +
+
+ + setMetadataKey(e.target.value)} + className="text-xs" + /> +
+
+ + setMetadataValue(e.target.value)} + className="text-xs" + /> +
+ +
+ +
+ + {configCluster.metadata && Object.keys(configCluster.metadata).length > 0 ? ( +
+ {Object.entries(configCluster.metadata).map(([key, value]) => ( +
+
+ {key} + + {value} + +
+
+ ))} +
+ ) : ( +

+ No metadata set for this cluster yet. +

+ )} +
+
+