Initial Labels Page & API

This commit is contained in:
allanice001
2025-09-03 09:27:57 +01:00
parent 7f29580d3b
commit 26aef56d1d
46 changed files with 7286 additions and 95 deletions

View File

@@ -0,0 +1,21 @@
package labels
import "github.com/google/uuid"
type labelResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
NodeGroups []nodePoolBrief `json:"node_groups,omitempty"`
}
type nodePoolBrief struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type createLabelRequest struct {
Key string `json:"key"`
Value string `json:"value"`
NodePoolIDs []string `json:"node_pool_ids,omitempty"`
}

View File

@@ -0,0 +1,50 @@
package labels
import (
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(l models.Label, include bool) labelResponse {
resp := labelResponse{
ID: l.ID,
Key: l.Key,
Value: l.Value,
}
if include {
resp.NodeGroups = make([]nodePoolBrief, 0, len(l.NodePools))
for _, np := range l.NodePools {
resp.NodeGroups = append(resp.NodeGroups, nodePoolBrief{ID: np.ID, Name: np.Name})
}
}
return resp
}
func parseUUIDs(ids []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(ids))
for _, s := range ids {
u, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
var count int64
if err := db.DB.Model(&models.NodePool{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return fmt.Errorf("some node groups do not belong to this organization")
}
return nil
}

View File

@@ -0,0 +1,180 @@
package labels
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListLabels godoc
// @Summary List node labels (org scoped)
// @Description Returns node labels for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node groups.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param name query string false "Exact name"
// @Param value query string false "Exact value"
// @Param q query string false "Name contains (case-insensitive)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {array} labelResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /api/v1/labels [get]
func ListLabels(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where("name ILIKE ?", "%"+needle+"%")
}
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
if includePools {
q.Preload("NodePools")
}
var rows []models.Label
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list taints", http.StatusInternalServerError)
return
}
out := make([]labelResponse, 0, len(rows))
for _, np := range rows {
out = append(out, toResp(np, includePools))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetLabel godoc
// @Summary Get label by ID (org scoped)
// @Description Returns one label. Add `include=node_pools` to include node groups.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} labelResponse
// @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 "fetch failed"
// @Router /api/v1/labels/{id} [get]
func GetLabel(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid label id", http.StatusBadRequest)
return
}
include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
var l models.Label
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if include {
q = q.Preload("NodePools")
}
if err := q.First(&l).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "label not found", http.StatusNotFound)
return
}
http.Error(w, "failed to find label", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(l, include))
}
// CreateLabel godoc
// @Summary Create label (org scoped)
// @Description Creates a label. Optionally link to node pools.
// @Tags labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createLabelRequest true "Label payload"
// @Security BearerAuth
// @Success 201 {object} labelResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/labels [post]
func CreateLabel(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createLabelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" || req.Value == "" {
http.Error(w, "invalid json or missing key/value", http.StatusBadRequest)
return
}
t := models.Label{
OrganizationID: ac.OrganizationID,
Key: req.Key,
Value: req.Value,
}
if err := db.DB.Create(&t).Error; err != nil {
http.Error(w, "failed to create label", http.StatusInternalServerError)
return
}
if len(req.NodePoolIDs) > 0 {
ids, err := parseUUIDs(req.NodePoolIDs)
if err != nil {
http.Error(w, "invalid node pool IDs", http.StatusBadRequest)
return
}
if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid node pool IDs for this organization", http.StatusBadRequest)
return
}
var nps []models.NodePool
if err := db.DB.Where("id in ? AND organization_id = ?", ids, ac.OrganizationID).Find(&nps).Error; err != nil {
http.Error(w, "node pools not found for this organization", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&t).Association("NodePools").Append(&nps); err != nil {
http.Error(w, "attach node pools failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusCreated, toResp(t, false))
}