mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Service lifecycle: POST /services/:id/clone duplicates config, volumes, domains, access gates, builder settings, and variables (secret ciphertext copied verbatim); POST /services/:id/move re-parents to another owned project. Both enforce ownership, reject name conflicts, and audit. Registries: CRUD at /registries — host-matched pull credentials for prebuilt-image deploys, encrypted at rest via secrets pkg, never returned (has_password flag). Empty password on update keeps the stored credential; host normalized (scheme/case/trailing slash). Credentials thread through BuildConfig → BuildRequest → pull auth config, used only for the pull. Surfaces: CLI services clone|move + registries group, 7 MCP tools, OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and Settings registries section, llms.txt + CLI.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
194 lines
6.3 KiB
Go
194 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/secrets"
|
|
|
|
"github.com/docker/docker/api/types/registry"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Registry is a stored credential for pulling private images. Password is
|
|
// encrypted at rest and never returned — `has_password` signals presence.
|
|
type Registry struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
|
|
Username string `json:"username"`
|
|
HasPassword bool `json:"has_password"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type registryRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
|
Host string `json:"host" binding:"required,min=1,max=255"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func handleListRegistries(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
userID, _ := c.Get("user_id")
|
|
rows, err := db.(*database.DB).Query(
|
|
`SELECT id, name, host, username, password <> '', created_at, updated_at
|
|
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
|
|
userID.(string),
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
registries := []Registry{}
|
|
for rows.Next() {
|
|
var r Registry
|
|
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
|
|
registries = append(registries, r)
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"registries": registries})
|
|
}
|
|
|
|
func handleCreateRegistry(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
userID, _ := c.Get("user_id")
|
|
|
|
var req registryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
|
|
|
// Encrypt only a real credential — encrypting "" would still satisfy
|
|
// `password <> ''` and report has_password for anonymous registries.
|
|
password := ""
|
|
if req.Password != "" {
|
|
password = secrets.Encrypt(req.Password)
|
|
}
|
|
var r Registry
|
|
err := db.(*database.DB).QueryRow(
|
|
`INSERT INTO registries (owner_id, name, host, username, password)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
|
userID.(string), req.Name, req.Host, req.Username, password,
|
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "unique") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"registry": r})
|
|
}
|
|
|
|
func handleUpdateRegistry(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
userID, _ := c.Get("user_id")
|
|
registryID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
var req registryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
|
|
|
// Empty password keeps the stored credential — there is no "clear"
|
|
// operation; delete + recreate to remove a password.
|
|
var passwordArg interface{}
|
|
if req.Password != "" {
|
|
passwordArg = secrets.Encrypt(req.Password)
|
|
}
|
|
|
|
var r Registry
|
|
err = db.(*database.DB).QueryRow(
|
|
`UPDATE registries SET name = $1, host = $2, username = $3,
|
|
password = COALESCE($4, password), updated_at = now()
|
|
WHERE id = $5 AND owner_id = $6
|
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
|
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
|
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"registry": r})
|
|
}
|
|
|
|
func handleDeleteRegistry(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
userID, _ := c.Get("user_id")
|
|
registryID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
res, err := db.(*database.DB).Exec(
|
|
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
|
|
registryID, userID.(string),
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
|
|
}
|
|
|
|
// registryAuthFor resolves pull credentials for an image reference: the
|
|
// caller's registry whose host matches, else anonymous. docker.io covers
|
|
// bare image names (nginx:latest).
|
|
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
|
host := registryHost(imageRef)
|
|
var username, password string
|
|
err := db.QueryRow(
|
|
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
|
ownerID, host,
|
|
).Scan(&username, &password)
|
|
if err != nil {
|
|
return registry.AuthConfig{}
|
|
}
|
|
return registry.AuthConfig{
|
|
Username: username,
|
|
Password: secrets.Decrypt(password),
|
|
ServerAddress: host,
|
|
}
|
|
}
|