mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +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>
227 lines
7.9 KiB
Go
227 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type CloneServiceRequest struct {
|
|
Name string `json:"name"`
|
|
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
|
}
|
|
|
|
// handleCloneService duplicates a service's full configuration — image/git
|
|
// source, volumes, domains, access gates, builder settings, and variables —
|
|
// into the same or another project. Containers are not cloned; the copy
|
|
// starts stopped until its first deploy.
|
|
func handleCloneService(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 CloneServiceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
source, ok := loadOwnedService(c, db.(*database.DB))
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
targetProjectID := source.ProjectID
|
|
if req.ProjectID != uuid.Nil {
|
|
targetProjectID = req.ProjectID
|
|
}
|
|
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
name = source.Name + "-copy"
|
|
}
|
|
var count int
|
|
_ = db.(*database.DB).QueryRow(
|
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
|
targetProjectID, name,
|
|
).Scan(&count)
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
|
return
|
|
}
|
|
|
|
environment := source.Environment
|
|
if req.Environment != "" {
|
|
environment = req.Environment
|
|
}
|
|
if environment == "" {
|
|
environment = "production"
|
|
}
|
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
|
return
|
|
}
|
|
|
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
|
newID := uuid.New()
|
|
now := time.Now()
|
|
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
|
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
|
|
|
_, err = db.(*database.DB).Exec(
|
|
`INSERT INTO services
|
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
|
build_command, start_command, type, status, image, command, environment,
|
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
|
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
|
created_at, updated_at)
|
|
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
|
build_command, start_command, type, 'stopped', image, command, $5,
|
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
|
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
|
$9, $9
|
|
FROM services WHERE id = $10`,
|
|
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
|
maintenance, basicAuth, now, source.ID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
|
return
|
|
}
|
|
|
|
if _, err := db.(*database.DB).Exec(
|
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
|
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
|
newID, source.ID,
|
|
); err != nil {
|
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
|
return
|
|
}
|
|
// Variable values are copied as stored — ciphertext stays ciphertext.
|
|
if _, err := db.(*database.DB).Exec(
|
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
|
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
|
newID, source.ID,
|
|
); err != nil {
|
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
|
return
|
|
}
|
|
|
|
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
|
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
|
}
|
|
|
|
type MoveServiceRequest struct {
|
|
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
|
}
|
|
|
|
// handleMoveService re-parents a service to another project owned by the
|
|
// caller. Variables, domains, and volumes follow via the service id;
|
|
// running containers keep running (they join the new project's network on
|
|
// the next deploy).
|
|
func handleMoveService(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")
|
|
serviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
var req MoveServiceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
service, ok := loadOwnedService(c, db.(*database.DB))
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.ProjectID == service.ProjectID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
|
return
|
|
}
|
|
|
|
var count int
|
|
_ = db.(*database.DB).QueryRow(
|
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
|
req.ProjectID, service.Name,
|
|
).Scan(&count)
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
|
return
|
|
}
|
|
|
|
envName := service.Environment
|
|
if envName == "" {
|
|
envName = "production"
|
|
}
|
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
|
return
|
|
}
|
|
|
|
res, err := db.(*database.DB).Exec(
|
|
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
|
req.ProjectID, environmentID, time.Now(), serviceID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
|
return
|
|
}
|
|
|
|
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
|
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
|
}
|
|
|
|
// ownsProject is true when the user owns the project or is an admin.
|
|
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
|
if isAdmin {
|
|
return true
|
|
}
|
|
var owner string
|
|
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
|
return err == nil && owner == userID
|
|
}
|
|
|
|
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
|
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
|
var raw []byte
|
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
|
return []byte("[]")
|
|
}
|
|
return raw
|
|
}
|