mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-04-17 11:39:23 +02:00
* 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>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -45,8 +45,8 @@ func NewRuntime() *Runtime {
|
||||
&models.LoadBalancer{},
|
||||
&models.Cluster{},
|
||||
&models.Action{},
|
||||
&models.Cluster{},
|
||||
&models.ClusterRun{},
|
||||
&models.ClusterMetadata{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -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")
|
||||
|
||||
399
internal/handlers/cluster_metadata.go
Normal file
399
internal/handlers/cluster_metadata.go
Normal file
@@ -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")
|
||||
}
|
||||
160
internal/handlers/cluster_metadata_test.go
Normal file
160
internal/handlers/cluster_metadata_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
20
internal/handlers/dto/cluster_metadata.go
Normal file
20
internal/handlers/dto/cluster_metadata.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
14
internal/models/cluster_metadata.go
Normal file
14
internal/models/cluster_metadata.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string | null>(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 <div className="p-6">Loading clusters…</div>
|
||||
if (clustersQ.error) return <div className="p-6 text-red-500">Error loading clusters.</div>
|
||||
|
||||
@@ -1484,6 +1509,73 @@ export const ClustersPage = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Metadata */}
|
||||
<section className="space-y-2 rounded-xl border p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">Cluster Metadata</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Store custom key-value metadata for cluster configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Key</Label>
|
||||
<Input
|
||||
placeholder="e.g., network.service_cidr"
|
||||
value={metadataKey}
|
||||
onChange={(e) => setMetadataKey(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
placeholder="e.g., 10.96.0.0/12"
|
||||
value={metadataValue}
|
||||
onChange={(e) => setMetadataValue(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateMetadata}
|
||||
disabled={isBusy("metadata") || !metadataKey.trim() || !metadataValue.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
{isBusy("metadata") ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<Label className="text-xs">Stored Metadata</Label>
|
||||
{configCluster.metadata && Object.keys(configCluster.metadata).length > 0 ? (
|
||||
<div className="divide-border mt-1 rounded-md border">
|
||||
{Object.entries(configCluster.metadata).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-xs"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<code className="font-mono font-medium">{key}</code>
|
||||
<code className="text-muted-foreground font-mono text-xs truncate">
|
||||
{value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
No metadata set for this cluster yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="outline" onClick={() => setConfigCluster(null)}>
|
||||
Close
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArcherAdminApi,
|
||||
AuthApi,
|
||||
ClusterRunsApi,
|
||||
ClusterMetadataApi,
|
||||
ClustersApi,
|
||||
Configuration,
|
||||
CredentialsApi,
|
||||
@@ -142,4 +143,8 @@ export function makeActionsApi() {
|
||||
|
||||
export function makeClusterRunsApi() {
|
||||
return makeApiClient(ClusterRunsApi)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeClusterMetadataApi() {
|
||||
return makeApiClient(ClusterMetadataApi)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user