Files
autoglue/internal/handlers/clusters.go
2025-12-11 13:08:39 +00:00

1720 lines
56 KiB
Go

package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"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"
)
// ListClusters godoc
//
// @ID ListClusters
// @Summary List clusters (org scoped)
// @Description Returns clusters for the organization in X-Org-ID. Filter by `q` (name contains).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Success 200 {array} dto.ClusterResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /clusters [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusters(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 ILIKE ?`, "%"+needle+"%")
}
var rows []models.Cluster
if err := q.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterResponse, 0, len(rows))
for _, row := range rows {
cr := clusterToDTO(row)
if row.EncryptedKubeconfig != "" && row.KubeIV != "" && row.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, row.EncryptedKubeconfig, row.KubeIV, row.KubeTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
return
}
cr.Kubeconfig = &kubeconfig
}
out = append(out, cr)
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetCluster godoc
//
// @ID GetCluster
// @Summary Get a single cluster by ID (org scoped)
// @Description Returns a cluster with all related resources (domain, record set, load balancers, bastion, node pools).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
resp := clusterToDTO(cluster)
if cluster.EncryptedKubeconfig != "" && cluster.KubeIV != "" && cluster.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, cluster.EncryptedKubeconfig, cluster.KubeIV, cluster.KubeTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
return
}
resp.Kubeconfig = &kubeconfig
}
utils.WriteJSON(w, http.StatusOK, resp)
}
}
// CreateCluster godoc
//
// @ID CreateCluster
// @Summary Create cluster (org scoped)
// @Description Creates a cluster. Status is managed by the system and starts as `pre_pending` for validation.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateClusterRequest true "payload"
// @Success 201 {object} dto.ClusterResponse
// @Failure 400 {string} string "invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /clusters [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCluster(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 in dto.CreateClusterRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
certificateKey, err := GenerateSecureHex(32)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate certificate key")
return
}
randomToken, err := GenerateFormattedToken()
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate random token")
return
}
c := models.Cluster{
OrganizationID: orgID,
Name: in.Name,
Provider: in.ClusterProvider,
Region: in.Region,
Status: models.ClusterStatusPrePending,
LastError: "",
CertificateKey: certificateKey,
RandomToken: randomToken,
DockerImage: in.DockerImage,
DockerTag: in.DockerTag,
}
if err := db.Create(&c).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusCreated, clusterToDTO(c))
}
}
// UpdateCluster godoc
//
// @ID UpdateCluster
// @Summary Update basic cluster details (org scoped)
// @Description Updates the cluster name, provider, and/or region. Status is managed by the system.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.UpdateClusterRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.UpdateClusterRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Apply only provided fields
if in.Name != nil {
cluster.Name = *in.Name
}
if in.ClusterProvider != nil {
cluster.Provider = *in.ClusterProvider
}
if in.Region != nil {
cluster.Region = *in.Region
}
if in.DockerImage != nil {
cluster.DockerImage = *in.DockerImage
}
if in.DockerTag != nil {
cluster.DockerTag = *in.DockerTag
}
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Any change to the cluster config may require re-validation.
_ = markClusterNeedsValidation(db, cluster.ID)
// Preload for a rich response
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DeleteCluster godoc
//
// @ID DeleteCluster
// @Summary Delete a cluster (org scoped)
// @Description Deletes the cluster. Related resources are cleaned up via DB constraints (e.g. CASCADE).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 204 {string} string "deleted"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
tx := db.Where("id = ? AND organization_id = ?", clusterID, orgID).Delete(&models.Cluster{})
if tx.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// AttachCaptainDomain godoc
//
// @ID AttachCaptainDomain
// @Summary Attach a captain domain to a cluster
// @Description Sets captain_domain_id on the cluster. Validation of shape happens asynchronously.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachCaptainDomainRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or domain not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/captain-domain [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachCaptainDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Ensure domain exists and belongs to the org
var domain models.Domain
if err := db.Where("id = ? AND organization_id = ?", in.DomainID, orgID).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "domain_not_found", "domain not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.CaptainDomainID = &domain.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := markClusterNeedsValidation(db, cluster.ID); err != nil {
// Don't fail the request, just log if you have logging.
}
// Preload domain for response
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachCaptainDomain godoc
//
// @ID DetachCaptainDomain
// @Summary Detach the captain domain from a cluster
// @Description Clears captain_domain_id on the cluster. This will likely cause the cluster to become incomplete.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/captain-domain [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.CaptainDomainID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachControlPlaneRecordSet godoc
//
// @ID AttachControlPlaneRecordSet
// @Summary Attach a control plane record set to a cluster
// @Description Sets control_plane_record_set_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachRecordSetRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or record set not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/control-plane-record-set [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// record sets are indirectly org-scoped via their domain
var rs models.RecordSet
if err := db.
Joins("JOIN domains d ON d.id = record_sets.domain_id").
Where("record_sets.id = ? AND d.organization_id = ?", in.RecordSetID, orgID).
First(&rs).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "recordset_not_found", "record set not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.ControlPlaneRecordSetID = &rs.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachControlPlaneRecordSet godoc
//
// @ID DetachControlPlaneRecordSet
// @Summary Detach the control plane record set from a cluster
// @Description Clears control_plane_record_set_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/control-plane-record-set [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.ControlPlaneRecordSetID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachAppsLoadBalancer godoc
//
// @ID AttachAppsLoadBalancer
// @Summary Attach an apps load balancer to a cluster
// @Description Sets apps_load_balancer_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachLoadBalancerRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or load balancer not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/apps-load-balancer [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var lb models.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.AppsLoadBalancerID = &lb.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachAppsLoadBalancer godoc
//
// @ID DetachAppsLoadBalancer
// @Summary Detach the apps load balancer from a cluster
// @Description Clears apps_load_balancer_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/apps-load-balancer [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.AppsLoadBalancerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachGlueOpsLoadBalancer godoc
//
// @ID AttachGlueOpsLoadBalancer
// @Summary Attach a GlueOps/control-plane load balancer to a cluster
// @Description Sets glueops_load_balancer_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachLoadBalancerRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or load balancer not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/glueops-load-balancer [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var lb models.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.GlueOpsLoadBalancerID = &lb.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachGlueOpsLoadBalancer godoc
//
// @ID DetachGlueOpsLoadBalancer
// @Summary Detach the GlueOps/control-plane load balancer from a cluster
// @Description Clears glueops_load_balancer_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/glueops-load-balancer [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.GlueOpsLoadBalancerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachBastionServer godoc
//
// @ID AttachBastionServer
// @Summary Attach a bastion server to a cluster
// @Description Sets bastion_server_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachBastionRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or server not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/bastion [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachBastionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", in.ServerID, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.BastionServerID = &server.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachBastionServer godoc
//
// @ID DetachBastionServer
// @Summary Detach the bastion server from a cluster
// @Description Clears bastion_server_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/bastion [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.BastionServerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// SetClusterKubeconfig godoc
//
// @ID SetClusterKubeconfig
// @Summary Set (or replace) the kubeconfig for a cluster
// @Description Stores the kubeconfig encrypted per organization. The kubeconfig is never returned in responses.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.SetKubeconfigRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/kubeconfig [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.SetKubeconfigRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
ct, iv, tag, err := utils.EncryptForOrg(orgID, []byte(in.Kubeconfig), db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "encryption_error", "failed to encrypt kubeconfig")
return
}
cluster.EncryptedKubeconfig = ct
cluster.KubeIV = iv
cluster.KubeTag = tag
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// ClearClusterKubeconfig godoc
//
// @ID ClearClusterKubeconfig
// @Summary Clear the kubeconfig for a cluster
// @Description Removes the encrypted kubeconfig, IV, and tag from the cluster record.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/kubeconfig [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.EncryptedKubeconfig = ""
cluster.KubeIV = ""
cluster.KubeTag = ""
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachNodePool godoc
//
// @ID AttachNodePool
// @Summary Attach a node pool to a cluster
// @Description Adds an entry in the cluster_node_pools join table.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachNodePoolRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Load cluster (org scoped)
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Load node pool (org scoped)
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", in.NodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Create association in join table
if err := db.Model(&cluster).Association("NodePools").Append(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to attach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
// Reload for rich response
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachNodePool godoc
//
// @ID DetachNodePool
// @Summary Detach a node pool from a cluster
// @Description Removes an entry from the cluster_node_pools join table.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param nodePoolID path string true "Node Pool ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools/{nodePoolID} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
nodePoolID, err := uuid.Parse(chi.URLParam(r, "nodePoolID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_nodepool_id", "invalid node pool id")
return
}
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", nodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&cluster).Association("NodePools").Delete(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to detach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// -- Helpers
func clusterToDTO(c models.Cluster) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := serverToDTO(*c.BastionServer)
bastion = &b
}
var captainDomain *dto.DomainResponse
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
dr := domainToDTO(c.CaptainDomain)
captainDomain = &dr
}
var controlPlane *dto.RecordSetResponse
if c.ControlPlaneRecordSet != nil {
rr := recordSetToDTO(*c.ControlPlaneRecordSet)
controlPlane = &rr
}
var cfqdn *string
if captainDomain != nil && controlPlane != nil {
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
cfqdn = &fq
}
var appsLB *dto.LoadBalancerResponse
if c.AppsLoadBalancer != nil {
lr := loadBalancerToDTO(*c.AppsLoadBalancer)
appsLB = &lr
}
var glueOpsLB *dto.LoadBalancerResponse
if c.GlueOpsLoadBalancer != nil {
lr := loadBalancerToDTO(*c.GlueOpsLoadBalancer)
glueOpsLB = &lr
}
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
for _, np := range c.NodePools {
nps = append(nps, nodePoolToDTO(np))
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
CaptainDomain: captainDomain,
ControlPlaneRecordSet: controlPlane,
ControlPlaneFQDN: cfqdn,
AppsLoadBalancer: appsLB,
GlueOpsLoadBalancer: glueOpsLB,
BastionServer: bastion,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
LastError: c.LastError,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
NodePools: nps,
DockerImage: c.DockerImage,
DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func nodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
labels := make([]dto.LabelResponse, 0, len(np.Labels))
for _, l := range np.Labels {
labels = append(labels, dto.LabelResponse{
Key: l.Key,
Value: l.Value,
})
}
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, a := range np.Annotations {
annotations = append(annotations, dto.AnnotationResponse{
Key: a.Key,
Value: a.Value,
})
}
taints := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
taints = append(taints, dto.TaintResponse{
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
servers := make([]dto.ServerResponse, 0, len(np.Servers))
for _, s := range np.Servers {
servers = append(servers, serverToDTO(s))
}
return dto.NodePoolResponse{
AuditFields: common.AuditFields{
ID: np.ID,
OrganizationID: np.OrganizationID,
CreatedAt: np.CreatedAt,
UpdatedAt: np.UpdatedAt,
},
Name: np.Name,
Role: dto.NodeRole(np.Role),
Labels: labels,
Annotations: annotations,
Taints: taints,
Servers: servers,
}
}
func serverToDTO(s models.Server) dto.ServerResponse {
return dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PrivateIPAddress: s.PrivateIPAddress,
PublicIPAddress: s.PublicIPAddress,
Role: s.Role,
Status: s.Status,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func domainToDTO(d models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: d.ID.String(),
OrganizationID: d.OrganizationID.String(),
DomainName: d.DomainName,
ZoneID: d.ZoneID,
Status: d.Status,
LastError: d.LastError,
CredentialID: d.CredentialID.String(),
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func recordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
return dto.RecordSetResponse{
ID: rs.ID.String(),
DomainID: rs.DomainID.String(),
Name: rs.Name,
Type: rs.Type,
TTL: rs.TTL,
Values: []byte(rs.Values),
Fingerprint: rs.Fingerprint,
Status: rs.Status,
Owner: rs.Owner,
LastError: rs.LastError,
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func loadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: lb.ID,
OrganizationID: lb.OrganizationID,
Name: lb.Name,
Kind: lb.Kind,
PublicIPAddress: lb.PublicIPAddress,
PrivateIPAddress: lb.PrivateIPAddress,
CreatedAt: lb.CreatedAt,
UpdatedAt: lb.UpdatedAt,
}
}
func GenerateSecureHex(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
func GenerateFormattedToken() (string, error) {
part1, err := GenerateSecureHex(3)
if err != nil {
return "", fmt.Errorf("failed to generate token part 1: %w", err)
}
part2, err := GenerateSecureHex(8)
if err != nil {
return "", fmt.Errorf("failed to generate token part 2: %w", err)
}
return fmt.Sprintf("%s.%s", part1, part2), nil
}
func markClusterNeedsValidation(db *gorm.DB, clusterID uuid.UUID) error {
return db.Model(&models.Cluster{}).Where("id = ?", clusterID).Updates(map[string]any{
"status": models.ClusterStatusPrePending,
"last_error": "",
}).Error
}