Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci-admin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}
install-mode: goinstall
working-directory: admin
args: --build-tags=dev

Expand All @@ -71,6 +72,10 @@ jobs:
working-directory: admin/ui
run: pnpm install --frozen-lockfile

- name: Check formatting
working-directory: admin/ui
run: pnpm prettier --check "src/**/*.{js,vue,css}"

- name: Lint
working-directory: admin/ui
run: pnpm lint
Expand Down Expand Up @@ -113,7 +118,7 @@ jobs:

- name: Run tests
working-directory: admin/ui
run: pnpm test --passWithNoTests
run: pnpm test

build:
name: Build
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,10 @@ test/load/results/
# Admin portal frontend
admin/ui/node_modules/
admin/ui/dist/
admin/ui/.vite/

# Playwright MCP output
.playwright-mcp/

# Admin QA test logs
.admin-qa-logs/
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ linters:
path: internal/context/
text: "var-naming: avoid package names"

- linters:
- revive
path: admin/api/
text: "var-naming: avoid meaningless package names"

# Allow pkg/crypto name even though it conflicts with stdlib.
# This package handles certificate generation, not general crypto.
# TODO: Consider renaming to pkg/certs in future refactor.
Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ LDFLAGS_DEV := -ldflags "\
all: lint test build

.PHONY: ci
ci: fmt license-check lint test-race gosec govulncheck build ## Run all CI checks locally
ci: fmt license-check lint ci-admin-ui test-race gosec govulncheck build build-onboard build-admin-dev ## Run all CI checks locally

.PHONY: ci-admin-ui
ci-admin-ui: ## Run admin UI checks (format, lint, test)
cd $(ADMIN_UI_DIR) && pnpm install --frozen-lockfile
cd $(ADMIN_UI_DIR) && pnpm prettier --check "src/**/*.{js,vue,css}"
cd $(ADMIN_UI_DIR) && pnpm lint
cd $(ADMIN_UI_DIR) && pnpm test

# ============================================================================
# Build
Expand Down Expand Up @@ -343,7 +350,7 @@ gosec: ## Run gosec security scanner (all modules)
@if [ -x "$(GOSEC)" ]; then \
$(GOSEC) -exclude=G706 -exclude-dir=sdk -exclude-dir=plugins -exclude-dir=admin ./... && \
(cd sdk && $(GOSEC) ./...) && \
(cd admin && $(GOSEC) -tags=dev ./...); \
(cd admin && $(GOSEC) -exclude=G706 -tags=dev ./...); \
else \
echo "gosec not installed. Run: make tools"; \
exit 1; \
Expand Down
233 changes: 233 additions & 0 deletions admin/api/instance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// Copyright 2026 CloudBlue LLC
// SPDX-License-Identifier: Apache-2.0

package api

import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"

"github.com/cloudblue/chaperone/admin/poller"
"github.com/cloudblue/chaperone/admin/store"
)

// InstanceHandler handles instance CRUD and test-connection endpoints.
type InstanceHandler struct {
store *store.Store
client *http.Client
}

// NewInstanceHandler creates a handler with the given store and probe timeout.
func NewInstanceHandler(st *store.Store, probeTimeout time.Duration) *InstanceHandler {
return &InstanceHandler{
store: st,
client: &http.Client{Timeout: probeTimeout},
}
}

// Register mounts instance routes on the given mux.
func (h *InstanceHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/instances", h.list)
mux.HandleFunc("POST /api/instances", h.create)
mux.HandleFunc("POST /api/instances/test", h.testConnection)
mux.HandleFunc("GET /api/instances/{id}", h.get)
mux.HandleFunc("PUT /api/instances/{id}", h.update)
mux.HandleFunc("DELETE /api/instances/{id}", h.delete)
}

func (h *InstanceHandler) list(w http.ResponseWriter, r *http.Request) {
instances, err := h.store.ListInstances(r.Context())
if err != nil {
slog.Error("listing instances", "error", err)
respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list instances")
return
}
if instances == nil {
instances = []store.Instance{}
}
respondJSON(w, http.StatusOK, instances)
}

func (h *InstanceHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}

inst, err := h.store.GetInstance(r.Context(), id)
if errors.Is(err, store.ErrInstanceNotFound) {
respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id))
return
}
if err != nil {
slog.Error("getting instance", "id", id, "error", err)
respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get instance")
return
}
respondJSON(w, http.StatusOK, inst)
}

type instanceRequest struct {
Name string `json:"name"`
Address string `json:"address"`
}

func (h *InstanceHandler) create(w http.ResponseWriter, r *http.Request) {
var req instanceRequest
if !decodeJSON(w, r, &req) {
return
}
if !validateInstanceRequest(w, &req) {
return
}

inst, err := h.store.CreateInstance(r.Context(), req.Name, req.Address)
if errors.Is(err, store.ErrDuplicateAddress) {
respondError(w, http.StatusConflict, "DUPLICATE_ADDRESS",
fmt.Sprintf("An instance with address %q is already registered", req.Address))
return
}
if err != nil {
slog.Error("creating instance", "error", err)
respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create instance")
return
}
respondJSON(w, http.StatusCreated, inst)
}

func (h *InstanceHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}

var req instanceRequest
if !decodeJSON(w, r, &req) {
return
}
if !validateInstanceRequest(w, &req) {
return
}

inst, err := h.store.UpdateInstance(r.Context(), id, req.Name, req.Address)
if errors.Is(err, store.ErrInstanceNotFound) {
respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id))
return
}
if errors.Is(err, store.ErrDuplicateAddress) {
respondError(w, http.StatusConflict, "DUPLICATE_ADDRESS",
fmt.Sprintf("An instance with address %q is already registered", req.Address))
return
}
if err != nil {
slog.Error("updating instance", "id", id, "error", err)
respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update instance")
return
}
respondJSON(w, http.StatusOK, inst)
}

func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}

err := h.store.DeleteInstance(r.Context(), id)
if errors.Is(err, store.ErrInstanceNotFound) {
respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id))
return
}
if err != nil {
slog.Error("deleting instance", "id", id, "error", err)
respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete instance")
return
}
w.WriteHeader(http.StatusNoContent)
}

func (h *InstanceHandler) testConnection(w http.ResponseWriter, r *http.Request) {
var req struct {
Address string `json:"address"`
}
if !decodeJSON(w, r, &req) {
return
}

addr := strings.TrimSpace(req.Address)
if addr == "" {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required")
return
}
if err := validHostPort(addr); err != nil {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}

result := poller.Probe(r.Context(), h.client, addr)
respondJSON(w, http.StatusOK, result)
}

// parseID extracts and validates the {id} path parameter.
func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) {
raw := r.PathValue("id")
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", fmt.Sprintf("Invalid instance ID: %q", raw))
return 0, false
}
return id, true
}

// decodeJSON reads and decodes a JSON request body (max 1 MB).
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid JSON request body")
return false
}
return true
}

func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool {
req.Name = strings.TrimSpace(req.Name)
req.Address = strings.TrimSpace(req.Address)

if req.Name == "" {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name is required")
return false
}
if req.Address == "" {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required")
return false
}
if err := validHostPort(req.Address); err != nil {
respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return false
}
return true
}

var errInvalidHostPort = errors.New("address must be a valid host:port (e.g. 192.168.1.10:9090)")

func validHostPort(addr string) error {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return errInvalidHostPort
}
if host == "" {
return errInvalidHostPort
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil || port == 0 {
return errInvalidHostPort
}
return nil
}
Loading
Loading