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>
This commit is contained in:
Irfan Paraniya
2026-04-17 11:05:49 +05:30
committed by GitHub
parent 300f419c53
commit 385affb1d7
14 changed files with 746 additions and 9 deletions

View File

@@ -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.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db, cfg))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(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", handlers.ListClusterRuns(db))
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db)) c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs)) c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))

View File

@@ -45,8 +45,8 @@ func NewRuntime() *Runtime {
&models.LoadBalancer{}, &models.LoadBalancer{},
&models.Cluster{}, &models.Cluster{},
&models.Action{}, &models.Action{},
&models.Cluster{},
&models.ClusterRun{}, &models.ClusterRun{},
&models.ClusterMetadata{},
) )
if err != nil { if err != nil {

View File

@@ -77,6 +77,7 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers.SshKey"). Preload("NodePools.Servers.SshKey").
Preload("Metadata").
Where("status = ?", clusterStatusPrePending). Where("status = ?", clusterStatusPrePending).
Find(&clusters).Error; err != nil { Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_prepare] query clusters failed") log.Error().Err(err).Msg("[cluster_prepare] query clusters failed")

View 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")
}

View 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)
}

View File

@@ -63,6 +63,7 @@ func ListClusters(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -132,6 +133,7 @@ func GetCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster).Error; err != nil { First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { 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.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -452,6 +455,7 @@ func AttachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -521,6 +525,7 @@ func DetachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -613,6 +618,7 @@ func AttachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFun
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -683,6 +689,7 @@ func DetachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFun
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -771,6 +778,7 @@ func AttachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -841,6 +849,7 @@ func DetachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -929,6 +938,7 @@ func AttachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -999,6 +1009,7 @@ func DetachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -1087,6 +1098,7 @@ func AttachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -1157,6 +1169,7 @@ func DetachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -1244,6 +1257,7 @@ func SetClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -1317,6 +1331,7 @@ func ClearClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return return
@@ -1415,6 +1430,7 @@ func AttachNodePool(db *gorm.DB, cfg config.Config) http.HandlerFunc {
Preload("NodePools.Annotations"). Preload("NodePools.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") 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.Annotations").
Preload("NodePools.Taints"). Preload("NodePools.Taints").
Preload("NodePools.Servers"). Preload("NodePools.Servers").
Preload("Metadata").
First(&cluster, "id = ?", cluster.ID).Error; err != nil { First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") 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 { for _, np := range c.NodePools {
nps = append(nps, nodePoolToDTO(np)) 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{ return dto.ClusterResponse{
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
@@ -1580,6 +1600,7 @@ func clusterToDTO(c models.Cluster, cfg config.Config) dto.ClusterResponse {
RandomToken: c.RandomToken, RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey, CertificateKey: c.CertificateKey,
NodePools: nps, NodePools: nps,
Metadata: metadata,
DockerImage: c.DockerImage, DockerImage: c.DockerImage,
DockerTag: c.DockerTag, DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,

View 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"`
}

View File

@@ -22,8 +22,9 @@ type ClusterResponse struct {
LastError string `json:"last_error"` LastError string `json:"last_error"`
RandomToken string `json:"random_token"` RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"` CertificateKey string `json:"certificate_key"`
NodePools []NodePoolResponse `json:"node_pools,omitempty"` NodePools []NodePoolResponse `json:"node_pools,omitempty"`
DockerImage string `json:"docker_image"` Metadata map[string]string `json:"metadata,omitempty"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"` DockerTag string `json:"docker_tag"`
Kubeconfig *string `json:"kubeconfig,omitempty"` Kubeconfig *string `json:"kubeconfig,omitempty"`
OrgKey *string `json:"org_key,omitempty"` OrgKey *string `json:"org_key,omitempty"`

View File

@@ -52,6 +52,11 @@ func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
nps = append(nps, NodePoolToDTO(np)) 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{ return dto.ClusterResponse{
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
@@ -68,6 +73,7 @@ func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
RandomToken: c.RandomToken, RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey, CertificateKey: c.CertificateKey,
NodePools: nps, NodePools: nps,
Metadata: metadata,
DockerImage: c.DockerImage, DockerImage: c.DockerImage,
DockerTag: c.DockerTag, DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,

View File

@@ -35,8 +35,9 @@ type Cluster struct {
GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"` GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"` BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"` BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
RandomToken string `json:"random_token"` Metadata []ClusterMetadata `gorm:"foreignKey:ClusterID;constraint:OnDelete:CASCADE" json:"metadata,omitempty"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"` CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"` EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"` KubeIV string `json:"-"`

View 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"`
}

View File

@@ -1,6 +1,6 @@
import { withRefresh } from "@/api/with-refresh"; import { withRefresh } from "@/api/with-refresh";
import type { DtoAttachBastionRequest, DtoAttachCaptainDomainRequest, DtoAttachLoadBalancerRequest, DtoAttachRecordSetRequest, DtoCreateClusterRequest, DtoSetKubeconfigRequest, DtoUpdateClusterRequest } from "@/sdk"; 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 clusters = makeClusterApi()
const clusterRuns = makeClusterRunsApi() const clusterRuns = makeClusterRunsApi()
const clusterMetadata = makeClusterMetadataApi()
export const clustersApi = { export const clustersApi = {
// --- basic CRUD --- // --- basic CRUD ---
@@ -145,6 +146,16 @@ export const clustersApi = {
return await clusters.detachNodePool({ clusterID, nodePoolID }) 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 --- // --- cluster runs / actions ---
listClusterRuns: (clusterID: string) => listClusterRuns: (clusterID: string) =>
withRefresh(async () => { withRefresh(async () => {

View File

@@ -8,7 +8,7 @@ import { serversApi } from "@/api/servers";
import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk"; import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -220,6 +220,8 @@ export const ClustersPage = () => {
const [glueopsLbId, setGlueopsLbId] = useState("") const [glueopsLbId, setGlueopsLbId] = useState("")
const [bastionId, setBastionId] = useState("") const [bastionId, setBastionId] = useState("")
const [nodePoolId, setNodePoolId] = useState("") const [nodePoolId, setNodePoolId] = useState("")
const [metadataKey, setMetadataKey] = useState("")
const [metadataValue, setMetadataValue] = useState("")
const [kubeconfigText, setKubeconfigText] = useState("") const [kubeconfigText, setKubeconfigText] = useState("")
const [busyKey, setBusyKey] = useState<string | null>(null) const [busyKey, setBusyKey] = useState<string | null>(null)
@@ -408,6 +410,9 @@ export const ClustersPage = () => {
// --- Config dialog helpers --- // --- Config dialog helpers ---
useEffect(() => { useEffect(() => {
setMetadataKey("")
setMetadataValue("")
if (!configCluster) { if (!configCluster) {
setCaptainDomainId("") setCaptainDomainId("")
setRecordSetId("") 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.isLoading) return <div className="p-6">Loading clusters</div>
if (clustersQ.error) return <div className="p-6 text-red-500">Error 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> </div>
</section> </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"> <DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setConfigCluster(null)}> <Button variant="outline" onClick={() => setConfigCluster(null)}>
Close Close

View File

@@ -6,6 +6,7 @@ import {
ArcherAdminApi, ArcherAdminApi,
AuthApi, AuthApi,
ClusterRunsApi, ClusterRunsApi,
ClusterMetadataApi,
ClustersApi, ClustersApi,
Configuration, Configuration,
CredentialsApi, CredentialsApi,
@@ -142,4 +143,8 @@ export function makeActionsApi() {
export function makeClusterRunsApi() { export function makeClusterRunsApi() {
return makeApiClient(ClusterRunsApi) return makeApiClient(ClusterRunsApi)
} }
export function makeClusterMetadataApi() {
return makeApiClient(ClusterMetadataApi)
}