package handlers import ( "encoding/json" "errors" "net/http" "strings" "time" "github.com/glueops/autoglue/internal/api/httpmiddleware" "github.com/glueops/autoglue/internal/common" "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" ) // -- Node Pools Core // ListNodePools godoc // // @ID ListNodePools // @Summary List node pools (org scoped) // @Description Returns node pools for the organization in X-Org-ID. // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param q query string false "Name contains (case-insensitive)" // @Success 200 {array} dto.NodePoolResponse // @Failure 401 {string} string "Unauthorized" // @Failure 403 {string} string "organization required" // @Failure 500 {string} string "failed to list node pools" // @Router /node-pools [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func ListNodePools(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 } q := db.Where("organization_id = ?", orgID) if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { q = q.Where("name LIKE ?", "%"+needle+"%") } var pools []models.NodePool if err := q. Preload("Servers"). Preload("Labels"). Preload("Taints"). Preload("Annotations"). Order("created_at DESC"). Find(&pools).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := make([]dto.NodePoolResponse, 0, len(pools)) for _, p := range pools { npr := dto.NodePoolResponse{ AuditFields: p.AuditFields, Name: p.Name, Role: dto.NodeRole(p.Role), Servers: make([]dto.ServerResponse, 0, len(p.Servers)), Labels: make([]dto.LabelResponse, 0, len(p.Labels)), Taints: make([]dto.TaintResponse, 0, len(p.Taints)), Annotations: make([]dto.AnnotationResponse, 0, len(p.Annotations)), } //Servers for _, s := range p.Servers { outSrv := dto.ServerResponse{ ID: s.ID, Hostname: s.Hostname, PublicIPAddress: s.PublicIPAddress, PrivateIPAddress: s.PrivateIPAddress, OrganizationID: s.OrganizationID, SshKeyID: s.SshKeyID, SSHUser: s.SSHUser, Role: s.Role, Status: s.Status, CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339), // add more fields as needed } npr.Servers = append(npr.Servers, outSrv) } //Labels for _, l := range p.Labels { outL := dto.LabelResponse{ AuditFields: common.AuditFields{ ID: l.ID, OrganizationID: l.OrganizationID, CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, }, Key: l.Key, Value: l.Value, } npr.Labels = append(npr.Labels, outL) } // Taints for _, t := range p.Taints { outT := dto.TaintResponse{ ID: t.ID, OrganizationID: t.OrganizationID, CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339), Key: t.Key, Value: t.Value, Effect: t.Effect, } npr.Taints = append(npr.Taints, outT) } // Annotations for _, a := range p.Annotations { outA := dto.AnnotationResponse{ AuditFields: common.AuditFields{ ID: a.ID, OrganizationID: a.OrganizationID, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, }, Key: a.Key, Value: a.Value, } npr.Annotations = append(npr.Annotations, outA) } out = append(out, npr) } utils.WriteJSON(w, http.StatusOK, out) } } // GetNodePool godoc // // @ID GetNodePool // @Summary Get node pool by ID (org scoped) // @Description Returns one node pool. Add `include=servers` to include servers. // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Success 200 {object} dto.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 /node-pools/{id} [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func GetNodePool(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "id required") return } var out dto.NodePoolResponse if err := db.Model(&models.NodePool{}).Preload("Servers").Where("id = ? AND organization_id = ?", id, orgID).First(&out, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } utils.WriteJSON(w, http.StatusOK, out) } } // CreateNodePool godoc // // @ID CreateNodePool // @Summary Create node pool (org scoped) // @Description Creates a node pool. Optionally attach initial servers. // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param body body dto.CreateNodePoolRequest true "NodePool payload" // @Success 201 {object} dto.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 /node-pools [post] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func CreateNodePool(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 } var req dto.CreateNodePoolRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } req.Name = strings.TrimSpace(req.Name) req.Role = dto.NodeRole(strings.TrimSpace(string(req.Role))) if req.Name == "" || req.Role == "" { utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing name/role") return } n := models.NodePool{ AuditFields: common.AuditFields{ OrganizationID: orgID, }, Name: req.Name, Role: string(req.Role), } if err := db.Create(&n).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := dto.NodePoolResponse{ AuditFields: n.AuditFields, Name: n.Name, Role: dto.NodeRole(n.Role), } utils.WriteJSON(w, http.StatusCreated, out) } } // UpdateNodePool godoc // // @ID UpdateNodePool // @Summary Update node pool (org scoped) // @Description Partially update node pool fields. // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param body body dto.UpdateNodePoolRequest true "Fields to update" // @Success 200 {object} dto.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 /node-pools/{id} [patch] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func UpdateNodePool(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "id required") return } var n models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&n).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var req dto.UpdateNodePoolRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } if req.Name != nil { n.Name = strings.TrimSpace(*req.Name) } if req.Role != nil { v := dto.NodeRole(strings.TrimSpace(string(*req.Role))) n.Role = string(v) } if err := db.Save(&n).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := dto.NodePoolResponse{ AuditFields: n.AuditFields, Name: n.Name, Role: dto.NodeRole(n.Role), } utils.WriteJSON(w, http.StatusOK, out) } } // DeleteNodePool godoc // // @ID DeleteNodePool // @Summary Delete node pool (org scoped) // @Description Permanently deletes the node pool. // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Success 204 "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 /node-pools/{id} [delete] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func DeleteNodePool(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "id required") return } if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.NodePool{}).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } w.WriteHeader(http.StatusNoContent) } } // -- Node Pools Servers // ListNodePoolServers godoc // // @ID ListNodePoolServers // @Summary List servers attached to a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Success 200 {array} dto.ServerResponse // @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 /node-pools/{id}/servers [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func ListNodePoolServers(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Servers").First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := make([]dto.ServerResponse, 0, len(np.Servers)) for _, server := range np.Servers { out = append(out, dto.ServerResponse{ ID: server.ID, OrganizationID: server.OrganizationID, Hostname: server.Hostname, PrivateIPAddress: server.PrivateIPAddress, PublicIPAddress: server.PublicIPAddress, Role: server.Role, SshKeyID: server.SshKeyID, SSHUser: server.SSHUser, Status: server.Status, CreatedAt: server.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: server.UpdatedAt.UTC().Format(time.RFC3339), }) } utils.WriteJSON(w, http.StatusOK, out) } } // AttachNodePoolServers godoc // // @ID AttachNodePoolServers // @Summary Attach servers to a node pool (org scoped) // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param body body dto.AttachServersRequest true "Server IDs to attach" // @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 /node-pools/{id}/servers [post] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func AttachNodePoolServers(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var req dto.AttachServersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } ids, err := parseUUIDs(req.ServerIDs) if err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids") return } if len(ids) == 0 { // nothing to attach utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach") return } // validate IDs belong to org if err := ensureServersBelongToOrg(orgID, ids, db); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids for this organization") return } // fetch only the requested servers var servers []models.Server if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&servers).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error") return } if len(servers) == 0 { w.WriteHeader(http.StatusNoContent) return } if err := db.Model(&np).Association("Servers").Append(&servers); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed") return } w.WriteHeader(http.StatusNoContent) } } // DetachNodePoolServer godoc // // @ID DetachNodePoolServer // @Summary Detach one server from a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param serverId path string true "Server ID (UUID)" // @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 /node-pools/{id}/servers/{serverId} [delete] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func DetachNodePoolServer(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } serverId, err := uuid.Parse(chi.URLParam(r, "serverId")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "server id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var s models.Server if err := db.Where("id = ? AND organization_id = ?", serverId, orgID).First(&s).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } if err := db.Model(&np).Association("Servers").Delete(&s); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error") return } w.WriteHeader(http.StatusNoContent) } } // -- Node Pools Taints // ListNodePoolTaints godoc // // @ID ListNodePoolTaints // @Summary List taints attached to a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Success 200 {array} dto.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 /node-pools/{id}/taints [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func ListNodePoolTaints(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Taints").First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := make([]dto.TaintResponse, 0, len(np.Taints)) for _, t := range np.Taints { out = append(out, dto.TaintResponse{ ID: t.ID, Key: t.Key, Value: t.Value, Effect: t.Effect, }) } utils.WriteJSON(w, http.StatusOK, out) } } // AttachNodePoolTaints godoc // // @ID AttachNodePoolTaints // @Summary Attach taints to a node pool (org scoped) // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param body body dto.AttachTaintsRequest true "Taint IDs to attach" // @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 /node-pools/{id}/taints [post] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func AttachNodePoolTaints(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var req dto.AttachTaintsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } ids, err := parseUUIDs(req.TaintIDs) if err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids") return } if len(ids) == 0 { utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach") return } // validate IDs belong to org if err := ensureTaintsBelongToOrg(orgID, ids, db); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids for this organization") return } var taints []models.Taint if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&taints).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error") return } if len(taints) == 0 { w.WriteHeader(http.StatusNoContent) return } if err := db.Model(&np).Association("Taints").Append(&taints); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error") return } w.WriteHeader(http.StatusNoContent) } } // DetachNodePoolTaint godoc // // @ID DetachNodePoolTaint // @Summary Detach one taint from a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param taintId path string true "Taint ID (UUID)" // @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 /node-pools/{id}/taints/{taintId} [delete] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func DetachNodePoolTaint(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } taintId, err := uuid.Parse(chi.URLParam(r, "taintId")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "taintId_required", "taint id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var t models.Taint if err := db.Where("id = ? AND organization_id = ?", taintId, orgID).First(&t).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "taint_not_found", "taint not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } if err := db.Model(&np).Association("Taints").Delete(&t); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } w.WriteHeader(http.StatusNoContent) } } // -- Node Pools Labels // ListNodePoolLabels godoc // // @ID ListNodePoolLabels // @Summary List labels attached to a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Label Pool ID (UUID)" // @Success 200 {array} dto.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 /node-pools/{id}/labels [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func ListNodePoolLabels(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := make([]dto.LabelResponse, 0, len(np.Taints)) for _, taint := range np.Taints { out = append(out, dto.LabelResponse{ AuditFields: common.AuditFields{ ID: taint.ID, OrganizationID: taint.OrganizationID, CreatedAt: taint.CreatedAt, UpdatedAt: taint.UpdatedAt, }, Key: taint.Key, Value: taint.Value, }) } utils.WriteJSON(w, http.StatusOK, out) } } // AttachNodePoolLabels godoc // // @ID AttachNodePoolLabels // @Summary Attach labels to a node pool (org scoped) // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param body body dto.AttachLabelsRequest true "Label IDs to attach" // @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 /node-pools/{id}/labels [post] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func AttachNodePoolLabels(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var req dto.AttachLabelsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } ids, err := parseUUIDs(req.LabelIDs) if err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids") return } if len(ids) == 0 { utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach") return } if err := ensureLabelsBelongToOrg(orgID, ids, db); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids for this organization") } var labels []models.Label if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&labels).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error") return } if len(labels) == 0 { w.WriteHeader(http.StatusNoContent) return } if err := db.Model(&np).Association("Labels").Append(&labels); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed") return } w.WriteHeader(http.StatusNoContent) } } // DetachNodePoolLabel godoc // // @ID DetachNodePoolLabel // @Summary Detach one label from a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param labelId path string true "Label ID (UUID)" // @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 /node-pools/{id}/labels/{labelId} [delete] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func DetachNodePoolLabel(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } labelId, err := uuid.Parse(chi.URLParam(r, "labelId")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "labelId required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var l models.Label if err := db.Where("id = ? AND organization_id = ?", labelId, orgID).First(&l).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } if err := db.Model(&np).Association("Labels").Delete(&l); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error") return } w.WriteHeader(http.StatusNoContent) } } // -- Node Pools Annotations // ListNodePoolAnnotations godoc // // @ID ListNodePoolAnnotations // @Summary List annotations attached to a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Success 200 {array} dto.AnnotationResponse // @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 /node-pools/{id}/annotations [get] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func ListNodePoolAnnotations(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } out := make([]dto.AnnotationResponse, 0, len(np.Annotations)) for _, ann := range np.Annotations { out = append(out, dto.AnnotationResponse{ AuditFields: common.AuditFields{ ID: ann.ID, OrganizationID: ann.OrganizationID, CreatedAt: ann.CreatedAt, UpdatedAt: ann.UpdatedAt, }, Key: ann.Key, Value: ann.Value, }) } utils.WriteJSON(w, http.StatusOK, out) } } // AttachNodePoolAnnotations godoc // // @ID AttachNodePoolAnnotations // @Summary Attach annotation to a node pool (org scoped) // @Tags NodePools // @Accept json // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Group ID (UUID)" // @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach" // @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 /node-pools/{id}/annotations [post] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func AttachNodePoolAnnotations(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var req dto.AttachAnnotationsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request") return } ids, err := parseUUIDs(req.AnnotationIDs) if err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids") return } if len(ids) == 0 { utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach") return } if err := ensureAnnotaionsBelongToOrg(orgID, ids, db); err != nil { utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids for this organization") return } var ann []models.Annotation if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&ann).Error; err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } if len(ann) == 0 { w.WriteHeader(http.StatusNoContent) return } if err := db.Model(&np).Association("Annotations").Append(&ann); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed") return } w.WriteHeader(http.StatusNoContent) } } // DetachNodePoolAnnotation godoc // // @ID DetachNodePoolAnnotation // @Summary Detach one annotation from a node pool (org scoped) // @Tags NodePools // @Produce json // @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Node Pool ID (UUID)" // @Param annotationId path string true "Annotation ID (UUID)" // @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 /node-pools/{id}/annotations/{annotationId} [delete] // @Security BearerAuth // @Security OrgKeyAuth // @Security OrgSecretAuth func DetachNodePoolAnnotation(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 } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required") return } annotationId, err := uuid.Parse(chi.URLParam(r, "annotationId")) if err != nil { utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool annotation id required") return } var np models.NodePool if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } var ann []models.Annotation if err := db.Where("id = ? AND organization_id = ?", annotationId, orgID).First(&ann).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { utils.WriteError(w, http.StatusNotFound, "annotation_not_found", "annotation not found") return } utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } if err := db.Model(&np).Association("Annotations").Delete(&ann); err != nil { utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error") return } w.WriteHeader(http.StatusNoContent) } } // -- Helpers func parseUUIDs(ids []string) ([]uuid.UUID, error) { out := make([]uuid.UUID, 0, len(ids)) for _, id := range ids { u, err := uuid.Parse(id) if err != nil { return nil, err } out = append(out, u) } return out, nil } func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error { var count int64 if err := 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 errors.New("some servers do not belong to this org") } return nil } func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error { var count int64 if err := 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 do not belong to this org") } return nil } func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error { var count int64 if err := db.Model(&models.Label{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil { return err } if count != int64(len(ids)) { return errors.New("some labels do not belong to this org") } return nil } func ensureAnnotaionsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error { var count int64 if err := db.Model(&models.Annotation{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil { return err } if count != int64(len(ids)) { return errors.New("some annotations do not belong to this org") } return nil }