Files
autoglue/internal/handlers/servers/servers.go
2025-09-01 23:53:48 +01:00

297 lines
9.3 KiB
Go

package servers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListServers godoc
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
// @Param role query string false "Filter by role"
// @Security BearerAuth
// @Success 200 {array} serverResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list servers"
// @Router /api/v1/servers [get]
func ListServers(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
if !validStatus(s) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
q = q.Where("status = ?", strings.ToLower(s))
}
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
q = q.Where("role = ?", role)
}
var rows []models.Server
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list servers", http.StatusInternalServerError)
return
}
out := make([]serverResponse, 0, len(rows))
for _, s := range rows {
out = append(out, toResponse(s))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetServer godoc
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 200 {object} 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 /api/v1/servers/{id} [get]
func GetServer(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 s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResponse(s))
}
// CreateServer godoc
// @Summary Create server (org scoped)
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createServerRequest true "Server payload"
// @Security BearerAuth
// @Success 201 {object} serverResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/servers [post]
func CreateServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.IPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
http.Error(w, "ip_address, ssh_user, ssh_key_id and role are required", http.StatusBadRequest)
return
}
if req.Status != "" && !validStatus(req.Status) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
keyID, err := uuid.Parse(req.SshKeyID)
if err != nil {
http.Error(w, "invalid ssh_key_id", http.StatusBadRequest)
return
}
if err := ensureKeyBelongsToOrg(ac.OrganizationID, keyID); err != nil {
http.Error(w, "invalid or unauthorized ssh_key_id", http.StatusBadRequest)
return
}
s := models.Server{
OrganizationID: ac.OrganizationID,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
SSHUser: req.SSHUser,
SshKeyID: keyID,
Role: req.Role,
Status: "pending",
}
if req.Status != "" {
s.Status = strings.ToLower(req.Status)
}
if err := db.DB.Create(&s).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, toResponse(s))
}
// UpdateServer godoc
// @Summary Update server (org scoped)
// @Description Partially update fields; changing ssh_key_id validates ownership.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Param body body updateServerRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} serverResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_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/servers/{id} [patch]
func UpdateServer(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 s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Hostname != nil {
s.Hostname = *req.Hostname
}
if req.IPAddress != nil {
s.IPAddress = *req.IPAddress
}
if req.SSHUser != nil {
s.SSHUser = *req.SSHUser
}
if req.Role != nil {
s.Role = *req.Role
}
if req.Status != nil {
if !validStatus(*req.Status) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
s.Status = strings.ToLower(*req.Status)
}
if req.SshKeyID != nil {
keyID, err := uuid.Parse(*req.SshKeyID)
if err != nil {
http.Error(w, "invalid ssh_key_id", http.StatusBadRequest)
return
}
if err := ensureKeyBelongsToOrg(ac.OrganizationID, keyID); err != nil {
http.Error(w, "invalid or unauthorized ssh_key_id", http.StatusBadRequest)
return
}
s.SshKeyID = keyID
}
if err := db.DB.Save(&s).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResponse(s))
}
// DeleteServer godoc
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/servers/{id} [delete]
func DeleteServer(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.Server{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}