diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 304a872..d0e3ce0 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -456,16 +456,21 @@ func runDeploymentAndSyncWithImage( }, } - if imageOverride != "" { - deployReq.BuildConfig = &deployment.BuildConfig{ - BuildType: "prebuilt", - PrebuiltImage: imageOverride, + if imageOverride != "" || service.GitRepo == "" { + // Prebuilt image deploy (rollback, redeploy, or image-sourced + // service): resolve private-registry credentials by image host. + image := imageOverride + if image == "" { + image = service.Image } - } else if service.GitRepo == "" { - // Image-sourced service: pull the image instead of building. + var ownerID string + _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) + auth := registryAuthFor(db, ownerID, image) deployReq.BuildConfig = &deployment.BuildConfig{ BuildType: "prebuilt", - PrebuiltImage: service.Image, + PrebuiltImage: image, + PullUsername: auth.Username, + PullPassword: auth.Password, } } else { // "auto" leaves BuildType empty so the manager detects it; the static diff --git a/app/backend/internal/api/registries.go b/app/backend/internal/api/registries.go new file mode 100644 index 0000000..14a0746 --- /dev/null +++ b/app/backend/internal/api/registries.go @@ -0,0 +1,193 @@ +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, + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 0c82019..40c45f9 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -336,6 +336,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/restart", handleServiceRestart) authed.POST("/services/:id/redeploy", handleServiceRedeploy) + authed.POST("/services/:id/clone", handleCloneService) + authed.POST("/services/:id/move", handleMoveService) authed.POST("/services/:id/deployments", handleCreateDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/cancel", handleCancelDeployment) @@ -356,6 +358,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // One-off exec console (docker exec, 30s ceiling) authed.POST("/services/:id/exec", handleExecInService) + // Private-image registries — owner-scoped, credentials encrypted at rest. + authed.GET("/registries", handleListRegistries) + authed.POST("/registries", handleCreateRegistry) + authed.PUT("/registries/:id", handleUpdateRegistry) + authed.DELETE("/registries/:id", handleDeleteRegistry) + // Git integration — providers carry per-user credentials. authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) authed.POST("/git/github-app/connect", handleConnectGitHubApp) diff --git a/app/backend/internal/api/service_ops.go b/app/backend/internal/api/service_ops.go new file mode 100644 index 0000000..3a720fb --- /dev/null +++ b/app/backend/internal/api/service_ops.go @@ -0,0 +1,226 @@ +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 +} diff --git a/app/backend/internal/build/manager.go b/app/backend/internal/build/manager.go index c1c3cea..03f61a7 100644 --- a/app/backend/internal/build/manager.go +++ b/app/backend/internal/build/manager.go @@ -100,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque // Pull the prebuilt image — must drain the pull stream or the daemon // aborts it and the tag below races a missing image. - auth := registry.AuthConfig{} + auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword} if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil { return nil, fmt.Errorf("failed to pull prebuilt image: %w", err) } diff --git a/app/backend/internal/cli/commands/registries.go b/app/backend/internal/cli/commands/registries.go new file mode 100644 index 0000000..ddcc75e --- /dev/null +++ b/app/backend/internal/cli/commands/registries.go @@ -0,0 +1,160 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// RegistriesCmd manages private-image registry credentials. +var RegistriesCmd = &cobra.Command{ + Use: "registries", + Aliases: []string{"registry"}, + Short: "Manage registry credentials", +} + +var registriesListCmd = &cobra.Command{ + Use: "list", + Short: "List registry credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/registries", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "registries") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, r := range items { + hasPw := "" + if b, ok := r["has_password"].(bool); ok && b { + hasPw = "yes" + } + rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw}) + } + printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows) + return nil + }, +} + +var registriesAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{"name": args[0], "host": args[1]} + if v, _ := cmd.Flags().GetString("username"); v != "" { + body["username"] = v + } + if v, _ := cmd.Flags().GetString("password"); v != "" { + body["password"] = v + } + data, err := c.Do("POST", "/registries", body) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + r, _ := unwrapObject(data, "registry") + fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host")) + return nil + }, +} + +var registriesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a registry credential (empty --password keeps the stored one)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{} + for _, f := range []string{"name", "host", "username", "password"} { + if cmd.Flags().Changed(f) { + v, _ := cmd.Flags().GetString(f) + body[f] = v + } + } + if len(body) == 0 { + return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError} + } + // The API requires name+host — backfill them from the stored row + // when the caller didn't supply both. + _, hasName := body["name"] + _, hasHost := body["host"] + if !hasName || !hasHost { + data, err := c.Do("GET", "/registries", nil) + if err != nil { + return err + } + items, _ := unwrapList(data, "registries") + for _, r := range items { + if str(r, "id") == args[0] { + if !hasName { + body["name"] = str(r, "name") + } + if !hasHost { + body["host"] = str(r, "host") + } + } + } + } + data, err := c.Do("PUT", "/registries/"+args[0], body) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Updated registry %s\n", args[0]) + return nil + }, +} + +var registriesRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a registry credential", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) { + return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError} + } + if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil { + return err + } + if JSONMode() { + return PrintJSON(map[string]string{"status": "deleted", "id": args[0]}) + } + fmt.Printf("Removed registry %s\n", args[0]) + return nil + }, +} + +func init() { + registriesAddCmd.Flags().String("username", "", "registry username") + registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)") + registriesUpdateCmd.Flags().String("name", "", "display name") + registriesUpdateCmd.Flags().String("host", "", "registry host") + registriesUpdateCmd.Flags().String("username", "", "registry username") + registriesUpdateCmd.Flags().String("password", "", "registry password/token") + RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd) +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 963229b..44c561b 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -226,6 +226,61 @@ var servicesUpdateCmd = &cobra.Command{ }, } +var servicesCloneCmd = &cobra.Command{ + Use: "clone ", + Short: "Clone a service (config, volumes, domains, variables)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{} + for _, f := range []string{"name", "project", "environment"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + key := f + if f == "project" { + key = "project_id" + } + body[key] = v + } + } + data, err := c.Do("POST", "/services/"+args[0]+"/clone", body) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + m, _ := unwrapObject(data) + fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name")) + return nil + }, +} + +var servicesMoveCmd = &cobra.Command{ + Use: "move ", + Short: "Move a service to another project", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]}) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Moved service %s to project %s\n", args[0], args[1]) + return nil + }, +} + // volumeFlagValues parses repeated "src:target[:ro]" flags into API mount // objects. Bind mounts are type=bind, named volumes type=volume. func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} { @@ -459,7 +514,11 @@ func init() { RunE: serviceAction(verb), }) } + servicesCloneCmd.Flags().String("name", "", "name for the clone (default -copy)") + servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)") + servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)") + domainsAddCmd.Flags().Bool("default", false, "set as the default domain") servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) - ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd) + ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd) } diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 186c433..8faa55a 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -74,6 +74,7 @@ func init() { rootCmd.AddCommand(commands.ExecCmd) rootCmd.AddCommand(commands.VariablesCmd) rootCmd.AddCommand(commands.VolumesCmd) + rootCmd.AddCommand(commands.RegistriesCmd) rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.TemplatesCmd) diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index e461371..d3e85b0 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -155,9 +155,12 @@ type BuildConfig struct { StartCommand string `json:"start_command"` BuildArgs map[string]string `json:"build_args,omitempty"` Environment map[string]string `json:"environment"` - Branch string `json:"branch"` - Commit string `json:"commit"` - NoCache bool `json:"no_cache"` + // Pull credentials for private prebuilt images. + PullUsername string `json:"pull_username,omitempty"` + PullPassword string `json:"pull_password,omitempty"` + Branch string `json:"branch"` + Commit string `json:"commit"` + NoCache bool `json:"no_cache"` } type TriggerConfig struct { @@ -300,6 +303,8 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme StartCommand: buildConfig.StartCommand, BuildArgs: buildConfig.BuildArgs, Environment: buildConfig.Environment, + PullUsername: buildConfig.PullUsername, + PullPassword: buildConfig.PullPassword, ProjectID: deployment.ProjectID, ServiceID: deployment.ServiceID, DeploymentID: deployment.ID, diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index e09e8e9..4693ab7 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -59,6 +59,8 @@ func init() { register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false}) register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false}) register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) + register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false}) + register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false}) @@ -70,6 +72,12 @@ func init() { register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false}) register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false}) + // Registries — private-image pull credentials, owner-scoped. + register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false}) + register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false}) + register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false}) + register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true}) + // Docker volumes (admin) register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false}) register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true}) diff --git a/app/backend/internal/types/build.go b/app/backend/internal/types/build.go index 78f86e9..66dcf52 100644 --- a/app/backend/internal/types/build.go +++ b/app/backend/internal/types/build.go @@ -29,6 +29,10 @@ type BuildRequest struct { Branch string `json:"branch"` // Git branch Commit string `json:"commit"` // Git commit SHA NoCache bool `json:"no_cache"` // Disable Docker layer cache + + // Registry credentials for pulling a private prebuilt image. + PullUsername string `json:"pull_username,omitempty"` + PullPassword string `json:"pull_password,omitempty"` } // BuildResponse represents the response from a build operation diff --git a/app/backend/migrations_goose/20261012000000_registries.sql b/app/backend/migrations_goose/20261012000000_registries.sql new file mode 100644 index 0000000..3c7e0ad --- /dev/null +++ b/app/backend/migrations_goose/20261012000000_registries.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS registries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_id, host) +); +CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS registries; +-- +goose StatementEnd diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 00211bb..60f3e19 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -69,6 +69,18 @@ CREATE TABLE services ( static_dir VARCHAR(255) NOT NULL DEFAULT '' ); +CREATE TABLE registries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_id, host) +); + CREATE TABLE service_domains ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 30f2488..acc401b 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -20,6 +20,10 @@ import { createUserToken, listUserTokens, revokeUserToken, + listRegistries, + createRegistry, + deleteRegistry, + type Registry, listDatabases, databaseAction, updateDatabaseBackupSchedule, @@ -1430,6 +1434,135 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) { ); } +function RegistriesSection() { + const queryClient = useQueryClient(); + const registriesQuery = useQuery({ + queryKey: ['registries'], + queryFn: listRegistries, + }); + const [name, setName] = useState(''); + const [host, setHost] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const createMutation = useMutation({ + mutationFn: () => + createRegistry({ + name: name.trim(), + host: host.trim(), + username: username.trim() || undefined, + password: password || undefined, + }), + onSuccess: () => { + setName(''); + setHost(''); + setUsername(''); + setPassword(''); + queryClient.invalidateQueries({ queryKey: ['registries'] }); + }, + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteRegistry(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }), + }); + + const registries = registriesQuery.data ?? []; + + return ( +
+
+
+ +
+
+

Registry credentials

+

Pull private images on deploy - matched by image host, stored encrypted

+
+
+ +
+
+ setName(e.target.value)} + placeholder="Name (e.g. ghcr)" + className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + setHost(e.target.value)} + placeholder="Host (ghcr.io, docker.io)" + className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + setUsername(e.target.value)} + placeholder="Username" + className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + setPassword(e.target.value)} + placeholder="Password / token" + className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + +
+ + {createMutation.isError ? ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'} +

+ ) : null} + + {registriesQuery.isLoading ? ( +
+ +
+ ) : registries.length === 0 ? ( +

+ No credentials. Image pulls are anonymous unless a registry matching the image host exists here. +

+ ) : ( +
+ {registries.map((r: Registry) => ( +
+
+ {r.name} + {r.host} + {r.username ? {r.username} : null} + + {r.has_password ? 'password set' : 'anonymous'} + +
+ +
+ ))} +
+ )} +
+
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1603,6 +1736,7 @@ export function SettingsPage() { {/* Personal Access Tokens */} + {/* Platform Settings - owner only */} {profileQuery.data?.isAdmin ? : null} diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 4bf23b7..7eb6dee 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -43,6 +43,9 @@ import { removeServiceDomain, setDefaultServiceDomain, checkServiceDomains, + cloneService, + moveService, + listProjects, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { useDemoMode } from '@/lib/demo-mode'; @@ -412,6 +415,36 @@ export function ServiceDetailPage() { }, }); + const projectsQuery = useQuery({ + queryKey: ['projects'], + queryFn: () => listProjects({ limit: 100 }), + enabled: !isDemoMode && activeSection === 'settings', + }); + + const cloneMutation = useMutation({ + mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['project-services'] }); + const targetProject = lifecycleForm?.cloneProject || projectId; + navigate(`/projects/${targetProject}/services/${data.service_id}`); + }, + }); + + const moveMutation = useMutation({ + mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId), + onSuccess: (_data, targetProjectId) => { + queryClient.invalidateQueries({ queryKey: ['project-services'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + navigate(`/projects/${targetProjectId}/services/${serviceId}`); + }, + }); + + const [lifecycleForm, setLifecycleForm] = useState<{ + cloneName: string; + cloneProject: string; + moveTarget: string; + } | null>(null); + const invalidateService = () => { queryClient.invalidateQueries({ queryKey: ['service', serviceId] }); queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] }); @@ -2633,6 +2666,135 @@ export function ServiceDetailPage() { )} + + {!isDemoMode && signedIn && ( +
+
+

Lifecycle

+ {lifecycleForm === null && ( + + )} +
+ + {lifecycleForm === null ? ( +

+ Duplicate this service into the same or another project, move it between projects, or delete it. +

+ ) : ( +
+
+ +
+ setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })} + placeholder={`${service.name}-copy`} + className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]" + /> + + +
+

+ Copies config, volumes, domains, and variables. The clone starts stopped. +

+
+ +
+ +
+ + +
+

+ Variables, domains, and volumes move with the service. Running containers keep running. +

+
+ +
+ + +
+ +
+ + {(cloneMutation.isError || moveMutation.isError) && ( +

+ {((cloneMutation.error ?? moveMutation.error) as Error)?.message} +

+ )} +
+
+ )} +
+ )} )} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 7b958bd..b9a1bdf 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -971,6 +971,276 @@ export interface paths { patch?: never; trace?: never; }; + "/services/{id}/clone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Clone service + * @description Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Name for the clone (default "-copy") */ + name?: string; + /** + * Format: uuid + * @description Target project (default same project; caller must own it) + */ + project_id?: string; + /** @enum {string} */ + environment?: "production" | "preview" | "development"; + }; + }; + }; + responses: { + /** @description Clone created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + service_id?: string; + name?: string; + message?: string; + }; + }; + }; + /** @description Name already exists in target project */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/services/{id}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Move service + * @description Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + project_id: string; + }; + }; + }; + responses: { + /** @description Service moved */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Name conflict in target project */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/registries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List registry credentials + * @description Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registry list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + registries?: components["schemas"]["Registry"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Add registry credential + * @description Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegistryRequest"]; + }; + }; + responses: { + /** @description Registry created */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A registry for this host already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/registries/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update registry credential + * @description Empty password keeps the stored credential — delete and recreate to remove a password. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegistryRequest"]; + }; + }; + responses: { + /** @description Registry updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Registry not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + /** Remove registry credential */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Registry removed */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Registry not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/services/{id}/domains": { parameters: { query?: never; @@ -8313,6 +8583,26 @@ export interface components { /** @description Plaintext on write — stored as bcrypt htpasswd; never returned */ password: string; }; + Registry: { + /** Format: uuid */ + id?: string; + name?: string; + /** @description Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub */ + host?: string; + username?: string; + has_password?: boolean; + /** Format: date-time */ + created_at?: string; + /** Format: date-time */ + updated_at?: string; + }; + RegistryRequest: { + name: string; + host: string; + username?: string; + /** @description Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. */ + password?: string; + }; ServiceVolume: { /** * @description volume = docker named volume, bind = host path diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index f8190bd..80bd54a 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1185,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise { }); } +export type CloneServiceInput = { + name?: string; + project_id?: string; + environment?: string; +}; + +export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> { + return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function moveService(serviceId: string, projectId: string): Promise { + await requestJson(`/services/${serviceId}/move`, { + method: 'POST', + body: JSON.stringify({ project_id: projectId }), + }); +} + +export type Registry = { + id: string; + name: string; + host: string; + username?: string; + has_password: boolean; + created_at?: string; + updated_at?: string; +}; + +export type RegistryInput = { + name: string; + host: string; + username?: string; + password?: string; +}; + +export async function listRegistries(): Promise { + const payload = await requestJson<{ registries?: Registry[] }>('/registries'); + return payload.registries ?? []; +} + +export async function createRegistry(input: RegistryInput): Promise { + const payload = await requestJson<{ registry: Registry }>('/registries', { + method: 'POST', + body: JSON.stringify(input), + }); + return payload.registry; +} + +export async function updateRegistry(id: string, input: RegistryInput): Promise { + const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }); + return payload.registry; +} + +export async function deleteRegistry(id: string): Promise { + await requestJson(`/registries/${id}`, { method: 'DELETE' }); +} + function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime { const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] }; // The API emits null (not []) for empty collections — coerce at the boundary. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d707d0f..e69970b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -667,6 +667,167 @@ paths: '200': description: Reconciled runtime state + /services/{id}/clone: + post: + tags: + - Services + summary: Clone service + description: Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name for the clone (default "-copy") + project_id: + type: string + format: uuid + description: Target project (default same project; caller must own it) + environment: + type: string + enum: [production, preview, development] + responses: + '201': + description: Clone created + content: + application/json: + schema: + type: object + properties: + service_id: + type: string + format: uuid + name: + type: string + message: + type: string + '409': + description: Name already exists in target project + + /services/{id}/move: + post: + tags: + - Services + summary: Move service + description: Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [project_id] + properties: + project_id: + type: string + format: uuid + responses: + '200': + description: Service moved + '409': + description: Name conflict in target project + + /registries: + get: + tags: + - Registries + summary: List registry credentials + description: Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence. + security: + - bearerAuth: [] + responses: + '200': + description: Registry list + content: + application/json: + schema: + type: object + properties: + registries: + type: array + items: + $ref: '#/components/schemas/Registry' + post: + tags: + - Registries + summary: Add registry credential + description: Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegistryRequest' + responses: + '201': + description: Registry created + '409': + description: A registry for this host already exists + + /registries/{id}: + put: + tags: + - Registries + summary: Update registry credential + description: Empty password keeps the stored credential — delete and recreate to remove a password. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegistryRequest' + responses: + '200': + description: Registry updated + '404': + description: Registry not found + delete: + tags: + - Registries + summary: Remove registry credential + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Registry removed + '404': + description: Registry not found + /services/{id}/domains: get: tags: @@ -5485,6 +5646,44 @@ components: type: string description: Plaintext on write — stored as bcrypt htpasswd; never returned + Registry: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + host: + type: string + description: Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub + username: + type: string + has_password: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + RegistryRequest: + type: object + required: + - name + - host + properties: + name: + type: string + host: + type: string + username: + type: string + password: + type: string + description: Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. + ServiceVolume: type: object required: @@ -7804,5 +8003,7 @@ tags: description: Vulnerability scans and compliance reporting - name: Cron description: Scheduled jobs + - name: Registries + description: Private-image pull credentials - name: Gateway description: API gateway — upstream services, keys, analytics diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 89bcb4b..7b031fc 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -38,7 +38,9 @@ auth login|logout|status token auth and profiles tokens list|create|revoke personal access tokens projects list|get|create|delete services list|get|create|update|delete|start|stop|restart|redeploy +services clone|move duplicate config or re-parent a service services domains list|add|remove|default|check multi-domain + DNS preflight +registries list|add|update|remove private-image pull credentials volumes list|delete docker volume inventory + cleanup (admin) deploy trigger a deployment (--no-cache, --commit-hash, --branch) deployments list|get|logs|rollback|cancel @@ -144,6 +146,30 @@ instance-level defaults used at service creation; `app_settings.capacity_policy=block` fails deployments whose requested memory exceeds node capacity (default `warn` logs only). +## Clone, move, registries + +`services clone` duplicates config, volumes, domains, access gates, +builder settings, and variables — ciphertext secret values are copied +verbatim (no decrypt/re-encrypt). The clone starts stopped: + +```bash +containr services clone --name staging-api --project --environment preview +containr services move # name conflicts → 409 +``` + +Note: cloned domains are verbatim copies — the same hostname attached to +two services makes Traefik routing ambiguous. Re-domain or undeploy one. + +`registries` stores private-image pull credentials, owner-scoped and +encrypted at rest. Matched by image host on every image-sourced deploy: + +```bash +containr registries add ghcr ghcr.io --username --password +containr registries add hub docker.io --username --password # Docker Hub +containr registries update --username # empty --password keeps stored +containr registries remove +``` + ## MCP server `containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools diff --git a/llms.txt b/llms.txt index 153beb3..6928664 100644 --- a/llms.txt +++ b/llms.txt @@ -20,11 +20,13 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`. - **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin). - **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only. - **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`). +- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous. +- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. ## Core objects -`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`. +`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`. ## Docs