mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
Servers Page & API
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ func Connect() {
|
||||
&models.OrganizationKey{},
|
||||
&models.PasswordReset{},
|
||||
&models.RefreshToken{},
|
||||
&models.Server{},
|
||||
&models.SshKey{},
|
||||
&models.User{},
|
||||
)
|
||||
|
||||
17
internal/db/models/server.go
Normal file
17
internal/db/models/server.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
Normal file
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
Normal file
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
14
internal/ui/dist/index.html
vendored
14
internal/ui/dist/index.html
vendored
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user