97d4243cfe
All REST endpoints wired with chi router: projects, stages, instances, deploys, registries, settings, quick deploy, webhook. Full main.go wiring with graceful shutdown. Consistent JSON envelope responses. Sensitive fields stripped from API responses.
262 lines
6.4 KiB
Go
262 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/docker-watcher/internal/crypto"
|
|
"github.com/alexei/docker-watcher/internal/registry"
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
)
|
|
|
|
// registryRequest is the expected JSON body for creating/updating a registry.
|
|
type registryRequest struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Type string `json:"type"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// listRegistries handles GET /api/registries.
|
|
func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
|
|
registries, err := s.store.GetAllRegistries()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Strip tokens from response for security.
|
|
type safeRegistry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Type string `json:"type"`
|
|
HasToken bool `json:"has_token"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
safe := make([]safeRegistry, len(registries))
|
|
for i, reg := range registries {
|
|
safe[i] = safeRegistry{
|
|
ID: reg.ID,
|
|
Name: reg.Name,
|
|
URL: reg.URL,
|
|
Type: reg.Type,
|
|
HasToken: reg.Token != "",
|
|
CreatedAt: reg.CreatedAt,
|
|
UpdatedAt: reg.UpdatedAt,
|
|
}
|
|
}
|
|
respondJSON(w, http.StatusOK, safe)
|
|
}
|
|
|
|
// createRegistry handles POST /api/registries.
|
|
func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) {
|
|
var req registryRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
respondError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.URL == "" {
|
|
respondError(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
req.Type = "generic"
|
|
}
|
|
|
|
// Encrypt the token if provided.
|
|
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
|
|
return
|
|
}
|
|
|
|
reg, err := s.store.CreateRegistry(store.Registry{
|
|
Name: req.Name,
|
|
URL: req.URL,
|
|
Type: req.Type,
|
|
Token: encToken,
|
|
})
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error())
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusCreated, map[string]string{
|
|
"id": reg.ID,
|
|
"name": reg.Name,
|
|
})
|
|
}
|
|
|
|
// updateRegistry handles PUT /api/registries/{id}.
|
|
func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
existing, err := s.store.GetRegistryByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "registry")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
|
|
return
|
|
}
|
|
|
|
var req registryRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
updated := existing
|
|
if req.Name != "" {
|
|
updated.Name = req.Name
|
|
}
|
|
if req.URL != "" {
|
|
updated.URL = req.URL
|
|
}
|
|
if req.Type != "" {
|
|
updated.Type = req.Type
|
|
}
|
|
|
|
// Only re-encrypt if a new token is provided.
|
|
if req.Token != "" {
|
|
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
|
|
return
|
|
}
|
|
updated.Token = encToken
|
|
}
|
|
|
|
if err := s.store.UpdateRegistry(updated); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.Error())
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{
|
|
"id": updated.ID,
|
|
"name": updated.Name,
|
|
})
|
|
}
|
|
|
|
// deleteRegistry handles DELETE /api/registries/{id}.
|
|
func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.DeleteRegistry(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "registry")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.Error())
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
}
|
|
|
|
// testRegistryRequest is the expected JSON body for POST /api/registries/{id}/test.
|
|
type testRegistryRequest struct {
|
|
Image string `json:"image"`
|
|
}
|
|
|
|
// testRegistry handles POST /api/registries/{id}/test.
|
|
// Creates a temp registry client and attempts to list tags.
|
|
func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
reg, err := s.store.GetRegistryByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "registry")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
|
|
return
|
|
}
|
|
|
|
var req testRegistryRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Image == "" {
|
|
respondError(w, http.StatusBadRequest, "image is required for testing")
|
|
return
|
|
}
|
|
|
|
// Decrypt the token.
|
|
token := reg.Token
|
|
if token != "" {
|
|
decrypted, err := crypto.Decrypt(s.encKey, token)
|
|
if err != nil {
|
|
token = reg.Token // Fall back to raw token.
|
|
} else {
|
|
token = decrypted
|
|
}
|
|
}
|
|
|
|
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type)
|
|
return
|
|
}
|
|
|
|
tags, err := client.ListTags(r.Context(), req.Image)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadGateway, "registry test failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"success": true,
|
|
"tags": len(tags),
|
|
})
|
|
}
|
|
|
|
// listRegistryTags handles GET /api/registries/{id}/tags/{image}.
|
|
func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
image := chi.URLParam(r, "*")
|
|
|
|
reg, err := s.store.GetRegistryByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "registry")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Decrypt the token.
|
|
token := reg.Token
|
|
if token != "" {
|
|
decrypted, err := crypto.Decrypt(s.encKey, token)
|
|
if err != nil {
|
|
token = reg.Token
|
|
} else {
|
|
token = decrypted
|
|
}
|
|
}
|
|
|
|
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type)
|
|
return
|
|
}
|
|
|
|
tags, err := client.ListTags(r.Context(), image)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadGateway, "failed to list tags: "+err.Error())
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, tags)
|
|
}
|