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

@@ -6,9 +6,12 @@ import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/labels"
"github.com/glueops/autoglue/internal/handlers/nodepools"
"github.com/glueops/autoglue/internal/handlers/orgs"
"github.com/glueops/autoglue/internal/handlers/servers"
"github.com/glueops/autoglue/internal/handlers/ssh"
"github.com/glueops/autoglue/internal/handlers/taints"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
"github.com/go-chi/chi/v5"
@@ -80,6 +83,42 @@ func RegisterRoutes(r chi.Router) {
s.Delete("/{id}", servers.DeleteServer)
})
v1.Route("/node-pools", func(np chi.Router) {
np.Use(authMW)
np.Get("/", nodepools.ListNodePools)
np.Post("/", nodepools.CreateNodePool)
np.Get("/{id}", nodepools.GetNodePool)
np.Patch("/{id}", nodepools.UpdateNodePool)
np.Delete("/{id}", nodepools.DeleteNodePool)
// servers
np.Get("/{id}/servers", nodepools.ListNodePoolServers)
np.Post("/{id}/servers", nodepools.AttachNodePoolServers)
np.Delete("/{id}/servers/{serverId}", nodepools.DetachNodePoolServer)
// taints
np.Get("/{id}/taints", nodepools.ListNodePoolTaints)
np.Post("/{id}/taints", nodepools.AttachNodePoolTaints)
np.Delete("/{id}/taints/{taintId}", nodepools.DetachNodePoolTaint)
})
v1.Route("/taints", func(t chi.Router) {
t.Use(authMW)
t.Get("/", taints.ListTaints)
t.Post("/", taints.CreateTaint)
t.Get("/{id}", taints.GetTaint)
t.Patch("/{id}", taints.UpdateTaint)
t.Delete("/{id}", taints.DeleteTaint)
t.Post("/{id}/node_pools", taints.AddTaintToNodePool)
t.Delete("/{id}/node_pools/{poolId}", taints.RemoveTaintFromNodePool)
})
v1.Route("/labels", func(l chi.Router) {
l.Use(authMW)
l.Get("/", labels.ListLabels)
l.Post("/", labels.CreateLabel)
l.Get("/{id}", labels.GetLabel)
})
})
})

View File

@@ -26,17 +26,22 @@ func Connect() {
}
err = DB.AutoMigrate(
&models.Annotation{},
&models.Cluster{},
&models.Credential{},
&models.EmailVerification{},
&models.Invitation{},
&models.Label{},
&models.MasterKey{},
&models.Member{},
&models.NodePool{},
&models.Organization{},
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.Server{},
&models.SshKey{},
&models.Taint{},
&models.User{},
)
if err != nil {

View File

@@ -0,0 +1,13 @@
package models
import "github.com/google/uuid"
type Annotation struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,17 @@
package models
import "github.com/google/uuid"
type Cluster struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `json:"status"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}

View File

@@ -0,0 +1,13 @@
package models
import "github.com/google/uuid"
type Label struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,16 @@
package models
import "github.com/google/uuid"
type NodePool struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
Timestamped
}

View File

@@ -13,5 +13,6 @@ type Server struct {
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1,14 @@
package models
import "github.com/google/uuid"
type Taint struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
Effect string `gorm:"not null" json:"effect"`
NodePools []NodePool `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Timestamped
}

View File

@@ -0,0 +1 @@
package annotations

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

View File

@@ -0,0 +1,43 @@
package nodepools
import (
"github.com/google/uuid"
)
type nodePoolResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Servers []serverBrief `json:"servers,omitempty"`
}
type serverBrief struct {
ID uuid.UUID `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Role string `json:"role"`
Status string `json:"status"`
}
type createNodePoolRequest struct {
Name string `json:"name"`
ServerIDs []string `json:"server_ids,omitempty"` // optional initial servers
}
type updateNodePoolRequest struct {
Name *string `json:"name,omitempty"`
}
type attachServersRequest struct {
ServerIDs []string `json:"server_ids"`
}
type taintBrief struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type attachTaintsRequest struct {
TaintIDs []string `json:"taint_ids"`
}

View File

@@ -0,0 +1,72 @@
package nodepools
import (
"errors"
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(ng models.NodePool, includeServers bool) nodePoolResponse {
resp := nodePoolResponse{
ID: ng.ID,
Name: ng.Name,
}
if includeServers {
resp.Servers = make([]serverBrief, 0, len(ng.Servers))
for _, s := range ng.Servers {
resp.Servers = append(resp.Servers, serverBrief{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IPAddress,
Role: s.Role,
Status: s.Status,
})
}
}
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 ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
var count int64
if err := db.DB.Model(&models.Server{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return fmt.Errorf("some servers do not belong to this organization")
}
return nil
}
func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
if len(ids) == 0 {
return nil
}
var count int64
if err := db.DB.Model(&models.Taint{}).
Where("organization_id = ? AND id IN ?", orgID, ids).
Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some taints not in organization")
}
return nil
}

View File

@@ -0,0 +1,626 @@
package nodepools
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"
)
// ListNodePools godoc
// @Summary List node pools (org scoped)
// @Description Returns node pools for the organization in X-Org-ID. Add `include=servers` to include attached servers. Filter by `q` (name contains).
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Param include query string false "Optional: servers"
// @Security BearerAuth
// @Success 200 {array} nodePoolResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node groups"
// @Router /api/v1/node-pools [get]
func ListNodePools(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+"%")
}
includeServers := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "servers")
if includeServers {
q = q.Preload("Servers")
}
var rows []models.NodePool
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list node groups", http.StatusInternalServerError)
return
}
out := make([]nodePoolResponse, 0, len(rows))
for _, ng := range rows {
out = append(out, toResp(ng, includeServers))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetNodePool godoc
// @Summary Get node group by ID (org scoped)
// @Description Returns one node group. Add `include=servers` to include servers.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Param include query string false "Optional: servers"
// @Security BearerAuth
// @Success 200 {object} nodePoolResponse
// @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/node-pools/{id} [get]
func GetNodePool(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 id", http.StatusBadRequest)
return
}
includeServers := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "servers")
var ng models.NodePool
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if includeServers {
q = q.Preload("Servers")
}
if err := q.First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(ng, includeServers))
}
// CreateNodePool godoc
// @Summary Create node group (org scoped)
// @Description Creates a node group. Optionally attach initial servers.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createNodePoolRequest true "NodeGroup payload"
// @Security BearerAuth
// @Success 201 {object} nodePoolResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/node-pools [post]
func CreateNodePool(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 createNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
http.Error(w, "invalid json or missing name", http.StatusBadRequest)
return
}
ng := models.NodePool{
OrganizationID: ac.OrganizationID,
Name: strings.TrimSpace(req.Name),
}
if err := db.DB.Create(&ng).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
// attach servers if provided
if len(req.ServerIDs) > 0 {
ids, err := parseUUIDs(req.ServerIDs)
if err != nil {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
if err := ensureServersBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid server_ids for this organization", http.StatusBadRequest)
return
}
var servers []models.Server
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&servers).Error; err != nil {
http.Error(w, "attach servers failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Servers").Append(&servers); err != nil {
http.Error(w, "attach servers failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusCreated, toResp(ng, false))
}
// UpdateNodePool godoc
// @Summary Update node pool (org scoped)
// @Description Partially update node pool fields.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body updateNodePoolRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} nodePoolResponse
// @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 "update failed"
// @Router /api/v1/node-pools/{id} [patch]
func UpdateNodePool(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 id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Name != nil {
ng.Name = strings.TrimSpace(*req.Name)
}
if err := db.DB.Save(&ng).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(ng, false))
}
// DeleteNodePool godoc
// @Summary Delete node pool (org scoped)
// @Description Permanently deletes the node pool.
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/node-pools/{id} [delete]
func DeleteNodePool(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 id", http.StatusBadRequest)
return
}
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Delete(&models.NodePool{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListNodePoolServers godoc
// @Summary List servers attached to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Security BearerAuth
// @Success 200 {array} serverBrief
// @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/node-pools/{id}/servers [get]
func ListNodePoolServers(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 id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Preload("Servers").First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
out := make([]serverBrief, 0, len(ng.Servers))
for _, s := range ng.Servers {
out = append(out, serverBrief{
ID: s.ID,
Hostname: s.Hostname,
IP: s.IPAddress,
Role: s.Role,
Status: s.Status,
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// AttachNodePoolServers godoc
// @Summary Attach servers to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Param body body attachServersRequest true "Server IDs to attach"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/node-pools/{id}/servers [post]
func AttachNodePoolServers(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
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var body struct {
ServerIDs []string `json:"server_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.ServerIDs) == 0 {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
ids, err := parseUUIDs(body.ServerIDs)
if err != nil {
http.Error(w, "invalid server_ids", http.StatusBadRequest)
return
}
if err := ensureServersBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid server_ids for this organization", http.StatusBadRequest)
return
}
var servers []models.Server
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&servers).Error; err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Servers").Append(&servers); err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DetachNodePoolServer godoc
// @Summary Detach one server from a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param serverId path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @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 "detach failed"
// @Router /api/v1/node-pools/{id}/servers/{serverId} [delete]
func DetachNodePoolServer(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
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
sid, err := uuid.Parse(chi.URLParam(r, "serverId"))
if err != nil {
http.Error(w, "invalid serverId", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
Preload("Servers").First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", sid, ac.OrganizationID).First(&s).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := db.DB.Model(&ng).Association("Servers").Delete(&s); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// ListNodePoolTaints godoc
// @Summary List taints attached to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Security BearerAuth
// @Success 200 {array} taintBrief
// @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/node-pools/{id}/taints [get]
func ListNodePoolTaints(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
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
Preload("Taints").
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
out := make([]taintBrief, 0, len(ng.Taints))
for _, t := range ng.Taints {
out = append(out, taintBrief{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// AttachNodePoolTaints godoc
// @Summary Attach taints to a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body attachTaintsRequest true "Taint IDs to attach"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid taint_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/node-pools/{id}/taints [post]
func AttachNodePoolTaints(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
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var body struct {
TaintIDs []string `json:"taint_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.TaintIDs) == 0 {
http.Error(w, "invalid taint_ids", http.StatusBadRequest)
return
}
ids, err := parseUUIDs(body.TaintIDs)
if err != nil {
http.Error(w, "invalid taint_ids", http.StatusBadRequest)
return
}
if err := ensureTaintsBelongToOrg(ac.OrganizationID, ids); err != nil {
http.Error(w, "invalid taint_ids for this organization", http.StatusBadRequest)
return
}
var taints []models.Taint
if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&taints).Error; err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Taints").Append(&taints); err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DetachNodePoolTaint godoc
// @Summary Detach one taint from a node pool (org scoped)
// @Tags node-pools
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param taintId path string true "Taint ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @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 "detach failed"
// @Router /api/v1/node-pools/{id}/taints/{taintId} [delete]
func DetachNodePoolTaint(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
}
ngID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
tid, err := uuid.Parse(chi.URLParam(r, "taintId"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var ng models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID).
First(&ng).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var t models.Taint
if err := db.DB.Where("id = ? AND organization_id = ?", tid, ac.OrganizationID).
First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&ng).Association("Taints").Delete(&t); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -0,0 +1,33 @@
package taints
import "github.com/google/uuid"
type taintResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
NodeGroups []nodePoolBrief `json:"node_groups,omitempty"`
}
type nodePoolBrief struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
type createTaintRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
NodePoolIDs []string `json:"node_pool_ids,omitempty"`
}
type updateTaintRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
Effect *string `json:"effect,omitempty"`
}
type addTaintToPoolRequest struct {
NodePoolIDs []string `json:"node_pool_ids"`
}

View File

@@ -0,0 +1,51 @@
package taints
import (
"fmt"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
)
func toResp(t models.Taint, include bool) taintResponse {
resp := taintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
}
if include {
resp.NodeGroups = make([]nodePoolBrief, 0, len(t.NodePools))
for _, np := range t.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,425 @@
package taints
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"
)
// ListTaints godoc
// @Summary List node taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_groups` to include linked node groups.
// @Tags taints
// @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} taintResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /api/v1/taints [get]
func ListTaints(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 = q.Preload("NodePools")
}
var rows []models.Taint
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list taints", http.StatusInternalServerError)
return
}
out := make([]taintResponse, 0, len(rows))
for _, np := range rows {
out = append(out, toResp(np, includePools))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetTaint godoc
// @Summary Get node taint by ID (org scoped)
// @Description Returns one taint. Add `include=node_groups` to include node groups.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @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/taints/{id} [get]
func GetTaint(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 taint id", http.StatusBadRequest)
return
}
include := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
var t models.Taint
q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
if include {
q = q.Preload("NodePools")
}
if err := q.First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "taint not found", http.StatusNotFound)
return
}
http.Error(w, "failed to find taint", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(t, include))
}
// CreateTaint godoc
// @Summary Create node taint (org scoped)
// @Description Creates a taint. Optionally link to node pools.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createTaintRequest true "Taint payload"
// @Security BearerAuth
// @Success 201 {object} taintResponse
// @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/taints [post]
func CreateTaint(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 createTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" || req.Value == "" || req.Effect == "" {
http.Error(w, "invalid json or missing key/value/effect", http.StatusBadRequest)
return
}
t := models.Taint{
OrganizationID: ac.OrganizationID,
Key: req.Key,
Value: req.Value,
Effect: req.Effect,
}
if err := db.DB.Create(&t).Error; err != nil {
http.Error(w, "failed to create taint", 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))
}
// UpdateTaint godoc
// @Summary Update node taint (org scoped)
// @Description Partially update taint fields.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param body body updateTaintRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @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 "update failed"
// @Router /api/v1/taints/{id} [patch]
func UpdateTaint(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 id", http.StatusBadRequest)
return
}
var t models.Taint
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Key != nil {
t.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
t.Value = strings.TrimSpace(*req.Value)
}
if req.Effect != nil {
t.Effect = strings.TrimSpace(*req.Effect)
}
if err := db.DB.Save(&t).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResp(t, false))
}
// DeleteTaint godoc
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/taints/{id} [delete]
func DeleteTaint(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 id", http.StatusBadRequest)
return
}
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).Delete(&models.Taint{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// AddTaintToNodePool godoc
// @Summary Attach taint to node pools (org scoped)
// @Description Links the taint to one or more node pools in the same organization.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Taint ID (UUID)"
// @Param body body addTaintToPoolRequest true "IDs to attach"
// @Param include query string false "Optional: node_pools"
// @Security BearerAuth
// @Success 200 {object} taintResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /api/v1/taints/{id}/node_pools [post]
func AddTaintToNodePool(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
}
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t models.Taint
if err := db.DB.
Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID).
First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var in struct {
NodePoolIDs []string `json:"node_pool_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.NodePoolIDs) == 0 {
http.Error(w, "invalid json or empty node_pool_ids", http.StatusBadRequest)
return
}
ids, err := parseUUIDs(in.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 pools []models.NodePool
if err := db.DB.
Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).
Find(&pools).Error; err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&t).Association("NodePools").Append(&pools); err != nil {
http.Error(w, "attach failed", http.StatusInternalServerError)
return
}
includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools")
if includePools {
if err := db.DB.Preload("NodePools").
First(&t, "id = ? AND organization_id = ?", taintID, ac.OrganizationID).Error; err != nil {
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
}
_ = response.JSON(w, http.StatusOK, toResp(t, includePools))
}
// RemoveTaintFromNodePool godoc
// @Summary Detach taint from a node pool (org scoped)
// @Description Unlinks the taint from the specified node pool.
// @Tags taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Taint ID (UUID)"
// @Param poolId path string true "Node Pool ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @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 "detach failed"
// @Router /api/v1/taints/{id}/node_pools/{poolId} [delete]
func RemoveTaintFromNodePool(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
}
taintID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
poolID, err := uuid.Parse(chi.URLParam(r, "poolId"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var t models.Taint
if err := db.DB.Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID).
First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var p models.NodePool
if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID).
First(&p).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if err := db.DB.Model(&t).Association("NodePools").Delete(&p); err != nil {
http.Error(w, "detach failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{r as i}from"./vendor-D1z0LlOQ.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
import{r as i}from"./vendor-DBKlM1wc.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
* react-router v7.8.2
*
* Copyright (c) Remix Software Inc.

File diff suppressed because one or more lines are too long

View File

@@ -5,12 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
<script type="module" crossorigin src="/assets/index-eleTxiqf.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CQ4G2GmP.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-DBKlM1wc.js">
<link rel="modulepreload" crossorigin href="/assets/radix-BnAuhYuH.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CNkJtX2d.js">
<link rel="stylesheet" crossorigin href="/assets/index-D2Vr0ZQJ.css">
</head>
<body>
<div id="root"></div>