mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
most of the working app
This commit is contained in:
@@ -24,19 +24,19 @@ type nodePoolBrief struct {
|
||||
|
||||
type annotationResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
NodePools []nodePoolBrief `json:"node_pools,omitempty"`
|
||||
}
|
||||
|
||||
type createAnnotationRequest struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
NodePoolIDs []string `json:"node_pool_ids"`
|
||||
}
|
||||
|
||||
type updateAnnotationRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Key *string `json:"key,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ type addAnnotationToNodePool struct {
|
||||
func toResp(a models.Annotation, includePools bool) annotationResponse {
|
||||
out := annotationResponse{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Key: a.Key,
|
||||
Value: a.Value,
|
||||
}
|
||||
if includePools {
|
||||
@@ -216,14 +216,14 @@ func CreateAnnotation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req createAnnotationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Value) == "" {
|
||||
http.Error(w, "invalid json or missing name/value", http.StatusBadRequest)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Key) == "" || strings.TrimSpace(req.Value) == "" {
|
||||
http.Error(w, "invalid json or missing key/value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
a := models.Annotation{
|
||||
OrganizationID: ac.OrganizationID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Key: strings.TrimSpace(req.Key),
|
||||
Value: strings.TrimSpace(req.Value),
|
||||
}
|
||||
|
||||
@@ -301,8 +301,8 @@ func UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
a.Name = strings.TrimSpace(*req.Name)
|
||||
if req.Key != nil {
|
||||
a.Key = strings.TrimSpace(*req.Key)
|
||||
}
|
||||
if req.Value != nil {
|
||||
a.Value = strings.TrimSpace(*req.Value)
|
||||
|
||||
680
internal/handlers/clusters/clusters.go
Normal file
680
internal/handlers/clusters/clusters.go
Normal file
@@ -0,0 +1,680 @@
|
||||
package clusters
|
||||
|
||||
import (
|
||||
"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/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListClusters godoc
|
||||
// @Summary List clusters (org scoped)
|
||||
// @Description Returns clusters for the organization in X-Org-ID. Add `include=node_pools,bastion` to expand. Filter by `q` (name contains).
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @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: node_pools,bastion"
|
||||
// @Success 200 {array} clusterResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list clusters"
|
||||
// @Router /api/v1/clusters [get]
|
||||
func ListClusters(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
|
||||
}
|
||||
include := strings.Split(strings.ToLower(r.URL.Query().Get("include")), ",")
|
||||
withPools := contains(include, "node_pools")
|
||||
withBastion := contains(include, "bastion")
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
var rows []models.Cluster
|
||||
tx := db.DB.Where("organization_id = ?", ac.OrganizationID)
|
||||
if q != "" {
|
||||
tx = tx.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(q)+"%")
|
||||
}
|
||||
if withPools {
|
||||
tx = tx.
|
||||
Preload("NodePools").
|
||||
Preload("NodePools.Labels").
|
||||
Preload("NodePools.Annotations").
|
||||
Preload("NodePools.Taints").
|
||||
Preload("NodePools.Servers")
|
||||
}
|
||||
if withBastion {
|
||||
tx = tx.Preload("BastionServer")
|
||||
}
|
||||
if err := tx.Find(&rows).Error; err != nil {
|
||||
http.Error(w, "failed to list clusters", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]clusterResponse, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
out = append(out, toResp(c, withPools, withBastion))
|
||||
}
|
||||
_ = response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// CreateCluster godoc
|
||||
// @Summary Create cluster (org scoped)
|
||||
// @Description Creates a cluster and optionally links node pools and a bastion server. If `kubeconfig` is provided, it will be encrypted per-organization and stored securely (never returned).
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param body body clusters.createClusterRequest true "payload"
|
||||
// @Success 201 {object} clusters.clusterResponse
|
||||
// @Failure 400 {string} string "invalid json / invalid node_pool_ids / invalid bastion_server_id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "create failed"
|
||||
// @Router /api/v1/clusters [post]
|
||||
func CreateCluster(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 in createClusterRequest
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
var poolIDs []uuid.UUID
|
||||
var err error
|
||||
if len(in.NodePoolIDs) > 0 {
|
||||
poolIDs, err = parseUUIDs(in.NodePoolIDs)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid node_pool_ids", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, poolIDs); err != nil {
|
||||
http.Error(w, "invalid node_pool_ids", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var bastionID *uuid.UUID
|
||||
if in.BastionServerID != nil && *in.BastionServerID != "" {
|
||||
bid, err := uuid.Parse(*in.BastionServerID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid bastion_server_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, bid, "bastion"); err != nil {
|
||||
http.Error(w, "invalid bastion_server_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bastionID = &bid
|
||||
}
|
||||
|
||||
c := models.Cluster{
|
||||
OrganizationID: ac.OrganizationID,
|
||||
Name: in.Name,
|
||||
Provider: in.Provider,
|
||||
Region: in.Region,
|
||||
Status: "pending",
|
||||
BastionServerID: bastionID,
|
||||
}
|
||||
|
||||
if in.ClusterLoadBalancer != nil {
|
||||
c.ClusterLoadBalancer = *in.ClusterLoadBalancer
|
||||
}
|
||||
|
||||
if in.ControlLoadBalancer != nil {
|
||||
c.ControlLoadBalancer = *in.ControlLoadBalancer
|
||||
}
|
||||
|
||||
if in.Kubeconfig != nil {
|
||||
kc := strings.TrimSpace(*in.Kubeconfig)
|
||||
if kc != "" {
|
||||
ct, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(kc))
|
||||
if err != nil {
|
||||
http.Error(w, "kubeconfig encrypt failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.EncryptedKubeconfig = ct
|
||||
c.KubeIV = iv
|
||||
c.KubeTag = tag
|
||||
}
|
||||
}
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(poolIDs) > 0 {
|
||||
var pools []models.NodePool
|
||||
if err := tx.Where("id IN ?", poolIDs).Find(&pools).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&c).Association("NodePools").Replace(&pools); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "create failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.DB.Preload("NodePools").Preload("BastionServer")
|
||||
if err := tx.First(&c, "id = ?", c.ID).Error; err != nil {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = response.JSON(w, http.StatusCreated, toResp(c, true, true))
|
||||
}
|
||||
|
||||
// GetCluster godoc
|
||||
// @Summary Get cluster by ID (org scoped)
|
||||
// @Description Returns one cluster. Add `include=node_pools,bastion` to expand.
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param include query string false "Optional: node_pools,bastion"
|
||||
// @Success 200 {object} clusters.clusterResponse
|
||||
// @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/clusters/{id} [get]
|
||||
func GetCluster(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
|
||||
}
|
||||
|
||||
include := strings.Split(strings.ToLower(r.URL.Query().Get("include")), ",")
|
||||
withPools := contains(include, "node_pools")
|
||||
withBastion := contains(include, "bastion")
|
||||
|
||||
tx := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID)
|
||||
if withPools {
|
||||
tx = tx.Preload("NodePools").
|
||||
Preload("NodePools.Taints").
|
||||
Preload("NodePools.Annotations").
|
||||
Preload("NodePools.Labels").
|
||||
Preload("NodePools.Servers")
|
||||
}
|
||||
if withBastion {
|
||||
tx = tx.Preload("BastionServer")
|
||||
}
|
||||
|
||||
var c models.Cluster
|
||||
if err := tx.First(&c).Error; err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = response.JSON(w, http.StatusOK, toResp(c, withPools, withBastion))
|
||||
}
|
||||
|
||||
// UpdateCluster godoc
|
||||
// @Summary Update cluster (org scoped). If `kubeconfig` is provided and non-empty, it will be encrypted per-organization and stored (never returned). Sending an empty string for `kubeconfig` is ignored (no change).
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param body body clusters.updateClusterRequest true "payload"
|
||||
// @Success 200 {object} clusters.clusterResponse
|
||||
// @Failure 400 {string} string "invalid id / invalid json / invalid bastion_server_id"
|
||||
// @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/clusters/{id} [patch]
|
||||
func UpdateCluster(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 c models.Cluster
|
||||
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&c).Error; err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var in updateClusterRequest
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
if in.Name != nil {
|
||||
c.Name = *in.Name
|
||||
}
|
||||
if in.Provider != nil {
|
||||
c.Provider = *in.Provider
|
||||
}
|
||||
if in.Region != nil {
|
||||
c.Region = *in.Region
|
||||
}
|
||||
if in.Status != nil {
|
||||
c.Status = *in.Status
|
||||
}
|
||||
if in.ClusterLoadBalancer != nil {
|
||||
c.ClusterLoadBalancer = *in.ClusterLoadBalancer
|
||||
}
|
||||
if in.ControlLoadBalancer != nil {
|
||||
c.ControlLoadBalancer = *in.ControlLoadBalancer
|
||||
}
|
||||
if in.Kubeconfig != nil {
|
||||
kc := strings.TrimSpace(*in.Kubeconfig)
|
||||
if kc != "" {
|
||||
ct, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(kc))
|
||||
if err != nil {
|
||||
http.Error(w, "kubeconfig encrypt failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.EncryptedKubeconfig = ct
|
||||
c.KubeIV = iv
|
||||
c.KubeTag = tag
|
||||
}
|
||||
}
|
||||
if in.BastionServerID != nil {
|
||||
if *in.BastionServerID == "" {
|
||||
c.BastionServerID = nil
|
||||
} else {
|
||||
bid, err := uuid.Parse(*in.BastionServerID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid bastion_server_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, bid, "bastion"); err != nil {
|
||||
http.Error(w, "invalid bastion_server_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.BastionServerID = &bid
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DB.Save(&c).Error; err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
db.DB.Preload("NodePools").Preload("BastionServer").First(&c, "id = ?", c.ID)
|
||||
_ = response.JSON(w, http.StatusOK, toResp(c, true, true))
|
||||
}
|
||||
|
||||
// DeleteCluster godoc
|
||||
// @Summary Delete cluster (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster 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 500 {string} string "delete failed"
|
||||
// @Router /api/v1/clusters/{id} [delete]
|
||||
func DeleteCluster(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.Cluster{}).Error; err != nil {
|
||||
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ListClusterNodePools godoc
|
||||
// @Summary List node pools attached to a cluster (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param q query string false "Name contains (case-insensitive)"
|
||||
// @Success 200 {array} clusters.nodePoolBrief
|
||||
// @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/clusters/{id}/node_pools [get]
|
||||
func ListClusterNodePools(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
// ensure cluster exists and belongs to org
|
||||
var exists int64
|
||||
if err := db.DB.Model(&models.Cluster{}).
|
||||
Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).
|
||||
Count(&exists).Error; err != nil {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists == 0 {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var pools []models.NodePool
|
||||
tx := db.DB.
|
||||
Model(&models.NodePool{}).
|
||||
Joins("JOIN cluster_node_pools cnp ON cnp.node_pool_id = node_pools.id").
|
||||
Where("cnp.cluster_id = ? AND node_pools.organization_id = ?", cid, ac.OrganizationID)
|
||||
if q != "" {
|
||||
tx = tx.Where("LOWER(node_pools.name) LIKE ?", "%"+strings.ToLower(q)+"%")
|
||||
}
|
||||
if err := tx.Find(&pools).Error; err != nil {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]nodePoolBrief, 0, len(pools))
|
||||
for _, p := range pools {
|
||||
out = append(out, nodePoolBrief{ID: p.ID, Name: p.Name})
|
||||
}
|
||||
_ = response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Attach/Detach NodePools
|
||||
|
||||
// AttachNodePools godoc
|
||||
// @Summary Attach node pools to cluster (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param body body clusters.attachNodePoolsRequest true "node_pool_ids"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / 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/clusters/{id}/node_pools [post]
|
||||
func AttachNodePools(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var c models.Cluster
|
||||
if err := db.DB.Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).First(&c).Error; err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var in attachNodePoolsRequest
|
||||
if !readJSON(w, r, &in) {
|
||||
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", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var pools []models.NodePool
|
||||
if err := db.DB.Where("id IN ?", ids).Find(&pools).Error; err != nil {
|
||||
http.Error(w, "attach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&c).Association("NodePools").Append(&pools); err != nil {
|
||||
http.Error(w, "attach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// DetachNodePool godoc
|
||||
// @Summary Detach one node pool from a cluster (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param poolId path string true "Node Pool 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 /api/v1/clusters/{id}/node_pools/{poolId} [delete]
|
||||
func DetachNodePool(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(chi.URLParam(r, "poolId"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid poolId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var c models.Cluster
|
||||
if err := db.DB.Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).First(&c).Error; err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
var p models.NodePool
|
||||
if err := db.DB.Where("id = ? AND organization_id = ?", pid, ac.OrganizationID).First(&p).Error; err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&c).Association("NodePools").Delete(&p); err != nil {
|
||||
http.Error(w, "detach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Bastion subresource
|
||||
|
||||
// GetBastion godoc
|
||||
// @Summary Get cluster bastion (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Success 200 {object} clusters.serverBrief
|
||||
// @Success 204 {string} string "No Content (no bastion set)"
|
||||
// @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/clusters/{id}/bastion [get]
|
||||
func GetBastion(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var c models.Cluster
|
||||
if err := db.DB.Preload("BastionServer").
|
||||
Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).
|
||||
First(&c).Error; err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "fetch failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if c.BastionServer == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
_ = response.JSON(w, http.StatusOK, serverBrief{
|
||||
ID: c.BastionServer.ID, Hostname: c.BastionServer.Hostname,
|
||||
IP: c.BastionServer.IPAddress, Role: c.BastionServer.Role, Status: c.BastionServer.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// PutBastion godoc
|
||||
// @Summary Set/replace cluster bastion (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster ID (UUID)"
|
||||
// @Param body body clusters.setBastionRequest true "server_id with role=bastion"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_id / server not bastion"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "cluster or server not found"
|
||||
// @Failure 500 {string} string "update failed"
|
||||
// @Router /api/v1/clusters/{id}/bastion [post]
|
||||
func PutBastion(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var in setBastionRequest
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(in.ServerID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid server_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, sid, "bastion"); err != nil {
|
||||
http.Error(w, "server must exist in org and have role=bastion", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&models.Cluster{}).
|
||||
Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).
|
||||
Updates(map[string]any{"bastion_server_id": sid}).Error; err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// DeleteBastion godoc
|
||||
// @Summary Clear cluster bastion (org scoped)
|
||||
// @Tags clusters
|
||||
// @Security BearerAuth
|
||||
// @Param X-Org-ID header string true "Organization UUID"
|
||||
// @Param id path string true "Cluster 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 "update failed"
|
||||
// @Router /api/v1/clusters/{id}/bastion [delete]
|
||||
func DeleteBastion(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
|
||||
}
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&models.Cluster{}).
|
||||
Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).
|
||||
Updates(map[string]any{"bastion_server_id": nil}).Error; err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
87
internal/handlers/clusters/dto.go
Normal file
87
internal/handlers/clusters/dto.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package clusters
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// clusterResponse describes a cluster with optional expansions.
|
||||
// swagger:model clusters.clusterResponse
|
||||
type clusterResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
ClusterLoadBalancer string `json:"cluster_load_balancer"`
|
||||
ControlLoadBalancer string `json:"control_load_balancer"`
|
||||
NodePools []nodePoolBrief `json:"node_pools,omitempty"`
|
||||
BastionServer *serverBrief `json:"bastion_server,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 nodePoolBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Labels []labelBrief `json:"labels,omitempty"`
|
||||
Annotations []annotationBrief `json:"annotations,omitempty"`
|
||||
Taints []taintBrief `json:"taints,omitempty"`
|
||||
Servers []serverBrief `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
type labelBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type annotationBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type taintBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Effect string `json:"effect"`
|
||||
}
|
||||
|
||||
// swagger:model clusters.updateClusterRequest
|
||||
type updateClusterRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Region *string `json:"region"`
|
||||
Status *string `json:"status"`
|
||||
BastionServerID *string `json:"bastion_server_id"`
|
||||
ClusterLoadBalancer *string `json:"cluster_load_balancer"`
|
||||
ControlLoadBalancer *string `json:"control_load_balancer"`
|
||||
Kubeconfig *string `json:"kubeconfig"`
|
||||
}
|
||||
|
||||
// swagger:model clusters.attachNodePoolsRequest
|
||||
type attachNodePoolsRequest struct {
|
||||
NodePoolIDs []string `json:"node_pool_ids"`
|
||||
}
|
||||
|
||||
// swagger:model clusters.setBastionRequest
|
||||
type setBastionRequest struct {
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
// swagger:model clusters.createClusterRequest
|
||||
type createClusterRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
NodePoolIDs []string `json:"node_pool_ids"`
|
||||
BastionServerID *string `json:"bastion_server_id"`
|
||||
ClusterLoadBalancer *string `json:"cluster_load_balancer"`
|
||||
ControlLoadBalancer *string `json:"control_load_balancer"`
|
||||
Kubeconfig *string `json:"kubeconfig"`
|
||||
}
|
||||
191
internal/handlers/clusters/funcs.go
Normal file
191
internal/handlers/clusters/funcs.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package clusters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error {
|
||||
if len(ids) == 0 {
|
||||
return errors.New("empty ids")
|
||||
}
|
||||
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 errors.New("some node pools do not belong to org")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureServerBelongsToOrgWithRole(orgID uuid.UUID, id uuid.UUID, role string) error {
|
||||
var count int64
|
||||
if err := db.DB.Model(&models.Server{}).
|
||||
Where("organization_id = ? AND id = ? AND role = ?", orgID, id, role).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New("server not found in org or role mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toResp(c models.Cluster, includePools, includeBastion bool) clusterResponse {
|
||||
out := clusterResponse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Provider: c.Provider,
|
||||
Region: c.Region,
|
||||
Status: c.Status,
|
||||
ClusterLoadBalancer: c.ClusterLoadBalancer,
|
||||
ControlLoadBalancer: c.ControlLoadBalancer,
|
||||
}
|
||||
if includePools {
|
||||
out.NodePools = make([]nodePoolBrief, 0, len(c.NodePools))
|
||||
for _, p := range c.NodePools {
|
||||
nb := nodePoolBrief{ID: p.ID, Name: p.Name}
|
||||
fmt.Printf("node pool %s\n", p.Name)
|
||||
fmt.Printf("node pool labels %d\n", len(p.Labels))
|
||||
if len(p.Labels) > 0 {
|
||||
nb.Labels = make([]labelBrief, 0, len(p.Labels))
|
||||
for _, l := range p.Labels {
|
||||
nb.Labels = append(nb.Labels, labelBrief{ID: l.ID, Key: l.Key, Value: l.Value})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("node pool annotations %d\n", len(p.Annotations))
|
||||
|
||||
if len(p.Annotations) > 0 {
|
||||
nb.Annotations = make([]annotationBrief, 0, len(p.Annotations))
|
||||
for _, a := range p.Annotations {
|
||||
nb.Annotations = append(nb.Annotations, annotationBrief{ID: a.ID, Key: a.Key, Value: a.Value})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("node pool taints %d\n", len(p.Taints))
|
||||
|
||||
if len(p.Taints) > 0 {
|
||||
nb.Taints = make([]taintBrief, 0, len(p.Taints))
|
||||
for _, t := range p.Taints {
|
||||
nb.Taints = append(nb.Taints, taintBrief{ID: t.ID, Key: t.Key, Value: t.Value, Effect: t.Effect})
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.Servers) > 0 {
|
||||
nb.Servers = make([]serverBrief, 0, len(p.Servers))
|
||||
for _, s := range p.Servers {
|
||||
nb.Servers = append(nb.Servers, serverBrief{ID: s.ID, Hostname: s.Hostname, Role: s.Role, Status: s.Status, IP: s.IPAddress})
|
||||
}
|
||||
}
|
||||
|
||||
out.NodePools = append(out.NodePools, nb)
|
||||
}
|
||||
}
|
||||
if includeBastion && c.BastionServer != nil {
|
||||
out.BastionServer = &serverBrief{
|
||||
ID: c.BastionServer.ID,
|
||||
Hostname: c.BastionServer.Hostname,
|
||||
IP: c.BastionServer.IPAddress,
|
||||
Role: c.BastionServer.Role,
|
||||
Status: c.BastionServer.Status,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if strings.TrimSpace(x) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func errorsIsNotFound(err error) bool { return err == gorm.ErrRecordNotFound }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const maxJSONBytes int64 = 1 << 20
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" {
|
||||
mt, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mt != "application/json" {
|
||||
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
||||
defer r.Body.Close()
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
var maxErr *http.MaxBytesError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &maxErr):
|
||||
http.Error(w, fmt.Sprintf("request body too large (max %d bytes)", maxJSONBytes), http.StatusRequestEntityTooLarge)
|
||||
case errors.Is(err, io.EOF):
|
||||
http.Error(w, "request body must not be empty", http.StatusBadRequest)
|
||||
case errors.As(err, &syntaxErr):
|
||||
http.Error(w, fmt.Sprintf("malformed JSON at character %d", syntaxErr.Offset), http.StatusBadRequest)
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
http.Error(w, "malformed JSON", http.StatusBadRequest)
|
||||
case errors.As(err, &typeErr):
|
||||
// Example: expected string but got number for field "name"
|
||||
field := typeErr.Field
|
||||
if field == "" && len(typeErr.Struct) > 0 {
|
||||
field = typeErr.Struct
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("invalid value for %q (expected %s)", field, typeErr.Type.String()), http.StatusBadRequest)
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
// Extract the field name from the error message.
|
||||
field := strings.Trim(strings.TrimPrefix(err.Error(), "json: unknown field "), "\"")
|
||||
http.Error(w, fmt.Sprintf("unknown field %q", field), http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if dec.More() {
|
||||
// Try to read one more token/value; if not EOF, there was extra content.
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
http.Error(w, "body must contain only a single JSON object", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -54,7 +54,7 @@ type taintBrief struct {
|
||||
|
||||
type annotationBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
|
||||
@@ -851,7 +851,7 @@ func ListNodePoolAnnotations(w http.ResponseWriter, r *http.Request) {
|
||||
for _, a := range ng.Annotations {
|
||||
out = append(out, annotationBrief{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Key: a.Key,
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user