Servers Page & API

This commit is contained in:
allanice001
2025-09-01 23:53:48 +01:00
parent 5425ed5dcc
commit 7f29580d3b
20 changed files with 2350 additions and 49 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/orgs"
"github.com/glueops/autoglue/internal/handlers/servers"
"github.com/glueops/autoglue/internal/handlers/ssh"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
@@ -69,6 +70,16 @@ func RegisterRoutes(r chi.Router) {
s.Delete("/{id}", ssh.DeleteSSHKey)
s.Get("/{id}/download", ssh.DownloadSSHKey)
})
v1.Route("/servers", func(s chi.Router) {
s.Use(authMW)
s.Get("/", servers.ListServers)
s.Post("/", servers.CreateServer)
s.Get("/{id}", servers.GetServer)
s.Patch("/{id}", servers.UpdateServer)
s.Delete("/{id}", servers.DeleteServer)
})
})
})

View File

@@ -35,6 +35,7 @@ func Connect() {
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.Server{},
&models.SshKey{},
&models.User{},
)

View File

@@ -0,0 +1,17 @@
package models
import "github.com/google/uuid"
type Server struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Hostname string `json:"hostname"`
IPAddress string `gorm:"not null"`
SSHUser string `gorm:"not null"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null"`
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
Timestamped
}

View 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"`
}

View 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
}

View 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)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{r as i}from"./vendor-Cnbx_Mrt.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
import{r as i}from"./vendor-D1z0LlOQ.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
* react-router v7.8.2
*
* Copyright (c) Remix Software Inc.

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CyXg69m3.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-Cnbx_Mrt.js">
<link rel="modulepreload" crossorigin href="/assets/radix-DN_DrUzo.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CHRYRpwL.js">
<link rel="stylesheet" crossorigin href="/assets/index-CJrhsj7s.css">
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
</head>
<body>
<div id="root"></div>