mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
Servers Page & API
This commit is contained in:
35
internal/handlers/servers/dto.go
Normal file
35
internal/handlers/servers/dto.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package servers
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type createServerRequest struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SshKeyID string `json:"ssh_key_id"`
|
||||
Role string `json:"role" example:"master|worker|bastion"`
|
||||
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
|
||||
}
|
||||
|
||||
type updateServerRequest struct {
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
SSHUser *string `json:"ssh_user,omitempty"`
|
||||
SshKeyID *string `json:"ssh_key_id,omitempty"`
|
||||
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
|
||||
// enum: pending,provisioning,ready,failed
|
||||
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
|
||||
}
|
||||
|
||||
type serverResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SshKeyID uuid.UUID `json:"ssh_key_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
47
internal/handlers/servers/funcs.go
Normal file
47
internal/handlers/servers/funcs.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func toResponse(s models.Server) serverResponse {
|
||||
return serverResponse{
|
||||
ID: s.ID,
|
||||
OrganizationID: s.OrganizationID,
|
||||
Hostname: s.Hostname,
|
||||
IPAddress: s.IPAddress,
|
||||
SSHUser: s.SSHUser,
|
||||
SshKeyID: s.SshKeyID,
|
||||
Role: s.Role,
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func validStatus(s string) bool {
|
||||
switch strings.ToLower(s) {
|
||||
case "pending", "provisioning", "ready", "failed", "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ensureKeyBelongsToOrg(orgID uuid.UUID, keyID uuid.UUID) error {
|
||||
var k models.SshKey
|
||||
if err := db.DB.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("ssh key not found for this organization")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
296
internal/handlers/servers/servers.go
Normal file
296
internal/handlers/servers/servers.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user