diff --git a/app/backend/internal/api/domains.go b/app/backend/internal/api/domains.go index 46ba044..aa16397 100644 --- a/app/backend/internal/api/domains.go +++ b/app/backend/internal/api/domains.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "net" "net/http" "regexp" @@ -210,7 +211,7 @@ func basicAuthUsernames(encoded string) []string { // serviceWriteAccess mirrors the service update handler's access check. func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool { userID, _ := c.Get("user_id") - uid, _ := userID.(uuid.UUID) + uid, _ := uuid.Parse(fmt.Sprint(userID)) ok, err := sqlcdb.New(db.DB).ServiceWriteAccess(context.Background(), sqlcdb.ServiceWriteAccessParams{ ID: serviceID, OwnerID: uid, diff --git a/app/backend/internal/api/domains_test.go b/app/backend/internal/api/domains_test.go index 305e10e..d191342 100644 --- a/app/backend/internal/api/domains_test.go +++ b/app/backend/internal/api/domains_test.go @@ -143,3 +143,83 @@ func TestServiceDomainsLiveInvariants(t *testing.T) { t.Fatalf("legacy after delete = %q, want %q", legacy, d1.Domain) } } + +// Live clone test: cloneServiceRow must copy config, domains, and variables +// verbatim, start stopped, and land in the target project/environment. +func TestCloneServiceRowLive(t *testing.T) { + dsn := testDSNString(t) + db, err := database.NewConnection(dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer db.Close() + ctx := context.Background() + q := sqlcdb.New(db.DB) + + userID := uuid.New() + projectID := uuid.New() + envID := uuid.New() + serviceID := uuid.New() + suffix := strings.ReplaceAll(serviceID.String()[:8], "-", "") + must := func(err error) { + t.Helper() + if err != nil { + t.Fatalf("setup: %v", err) + } + } + _, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`, + userID, "clone-"+suffix+"@test.local") + must(err) + _, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`, + projectID, "clone-"+suffix, userID) + must(err) + _, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`, + envID, projectID) + must(err) + _, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, + image_name, port, volumes, traefik_labels, maintenance_mode) + VALUES ($1, $2, $3, $4, 'web', 'image', 'traefik/whoami:latest', 80, + '[{"mount":"/data"}]'::jsonb, + '{"middlewares.rl.ratelimit.average":"100"}'::jsonb, true)`, + serviceID, "clonesrc-"+suffix, projectID, envID) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }() + + if _, err := q.CreateServiceDomain(ctx, sqlcdb.CreateServiceDomainParams{ + ServiceID: serviceID, Domain: "src-" + suffix + ".example.com", IsDefault: true}); err != nil { + t.Fatalf("seed domain: %v", err) + } + if _, err := db.Exec(`INSERT INTO environment_variables (id, service_id, key, value, is_secret) + VALUES ($1, $2, 'FOO', 'bar', false)`, uuid.New(), serviceID); err != nil { + t.Fatalf("seed var: %v", err) + } + + cloneID, err := cloneServiceRow(db, serviceID, projectID, "clone-"+suffix, "") + if err != nil { + t.Fatalf("clone: %v", err) + } + + var status, imageName, env string + var maintenance bool + var volumes, labels []byte + if err := db.QueryRow(`SELECT status, image_name, COALESCE(environment,''), maintenance_mode, + volumes, traefik_labels FROM services WHERE id = $1`, cloneID). + Scan(&status, &imageName, &env, &maintenance, &volumes, &labels); err != nil { + t.Fatalf("read clone: %v", err) + } + if status != "stopped" || imageName != "traefik/whoami:latest" || env != "production" { + t.Fatalf("clone mismatch: %v %v %v", status, imageName, env) + } + if !maintenance || string(volumes) == "[]" || string(labels) == "{}" { + t.Fatalf("clone dropped config: maint=%v vol=%s labels=%s", maintenance, volumes, labels) + } + doms := loadServiceDomains(db, cloneID) + if len(doms) != 1 || doms[0].Domain != "src-"+suffix+".example.com" || !doms[0].IsDefault { + t.Fatalf("domains not cloned: %+v", doms) + } + var varVal string + if err := db.QueryRow(`SELECT value FROM environment_variables WHERE service_id = $1 AND key = 'FOO'`, + cloneID).Scan(&varVal); err != nil || varVal != "bar" { + t.Fatalf("vars not cloned: %v %q", err, varVal) + } +} diff --git a/app/backend/internal/api/service_ops.go b/app/backend/internal/api/service_ops.go index 4819e0f..5547ed5 100644 --- a/app/backend/internal/api/service_ops.go +++ b/app/backend/internal/api/service_ops.go @@ -1,11 +1,14 @@ package api import ( + "context" + "database/sql" "net/http" "strings" "time" "containr/internal/database" + "containr/internal/database/sqlcdb" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -53,11 +56,8 @@ func handleCloneService(c *gin.Context) { 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) + count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName( + c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: targetProjectID, Name: name}) if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"}) return @@ -84,8 +84,11 @@ func handleCloneService(c *gin.Context) { // inherits the source's. Used by the clone endpoint and preview // environments, which additionally override git_branch and domains. func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) { - var sourceEnv string - if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil { + ctx := context.Background() + q := sqlcdb.New(db.DB) + + sourceEnv, err := q.GetServiceEnvironment(ctx, sourceID) + if err != nil { return uuid.Nil, err } if environment == "" { @@ -102,46 +105,31 @@ func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.U maintenance, basicAuth := serviceAccess(db, sourceID) newID := uuid.New() now := time.Now() - // string() so pq sends text — []byte would go as bytea and fail ::jsonb. - volumesJSON := string(loadServiceVolumesJSON(db, sourceID)) - _, err = 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, sourceID, - ) + err = q.CloneServiceRow(ctx, sqlcdb.CloneServiceRowParams{ + ID: newID, + ProjectID: targetProjectID, + Name: name, + EnvironmentID: environmentID, + Environment: sql.NullString{String: environment, Valid: true}, + MaintenanceMode: maintenance, + BasicAuthUsers: basicAuth, + CreatedAt: sql.NullTime{Time: now, Valid: true}, + ID_2: sourceID, + }) if err != nil { return uuid.Nil, err } - if _, err := 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, sourceID, - ); err != nil { - _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID) + if err := q.CloneServiceDomains(ctx, sqlcdb.CloneServiceDomainsParams{ + ServiceID: newID, ServiceID_2: sourceID}); err != nil { + _ = q.DeleteServiceByID(ctx, newID) return uuid.Nil, err } // Variable values are copied as stored — ciphertext stays ciphertext. - if _, err := 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, sourceID, - ); err != nil { - _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID) + if err := q.CloneServiceVariables(ctx, sqlcdb.CloneServiceVariablesParams{ + ServiceID: newID, ServiceID_2: sourceID}); err != nil { + _ = q.DeleteServiceByID(ctx, newID) return uuid.Nil, err } return newID, nil @@ -187,11 +175,8 @@ func handleMoveService(c *gin.Context) { 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) + count, _ := sqlcdb.New(db.(*database.DB).DB).CountServicesByName( + c.Request.Context(), sqlcdb.CountServicesByNameParams{ProjectID: req.ProjectID, Name: service.Name}) if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"}) return @@ -207,15 +192,18 @@ func handleMoveService(c *gin.Context) { 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, - ) + n, err := sqlcdb.New(db.(*database.DB).DB).MoveServiceProject( + c.Request.Context(), sqlcdb.MoveServiceProjectParams{ + ProjectID: req.ProjectID, + EnvironmentID: environmentID, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: serviceID, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"}) return } - if n, _ := res.RowsAffected(); n == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"}) return } @@ -229,16 +217,10 @@ func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bo 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("[]") + uid, err := uuid.Parse(userID) + if err != nil { + return false } - return raw + owner, err := sqlcdb.New(db.DB).GetProjectOwner(context.Background(), projectID) + return err == nil && owner == uid } diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 0700f84..ed3b878 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -18,12 +18,16 @@ type Querier interface { AggregateNodeMetrics(ctx context.Context, arg AggregateNodeMetricsParams) ([]AggregateNodeMetricsRow, error) ClearServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) error ClearServiceNodePins(ctx context.Context, nodeID sql.NullString) (int64, error) + CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error + CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error + CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) + CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) @@ -54,6 +58,7 @@ type Querier interface { DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error + DeleteServiceByID(ctx context.Context, id uuid.UUID) error DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) DeleteUserInvite(ctx context.Context, id uuid.UUID) error @@ -76,12 +81,14 @@ type Querier interface { GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) + GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetServiceAccess(ctx context.Context, id uuid.UUID) (GetServiceAccessRow, error) GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) + GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) @@ -135,6 +142,7 @@ type Querier interface { MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error + MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error) // Resource-aware: lowest memory utilisation first, then cpu, then container // count. Agents without telemetry sort last (NULL → worst score). PickLeastLoadedAgent(ctx context.Context) (string, error) diff --git a/app/backend/internal/database/sqlcdb/service_ops.sql.go b/app/backend/internal/database/sqlcdb/service_ops.sql.go new file mode 100644 index 0000000..82d4bda --- /dev/null +++ b/app/backend/internal/database/sqlcdb/service_ops.sql.go @@ -0,0 +1,158 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: service_ops.sql + +package sqlcdb + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const cloneServiceDomains = `-- name: CloneServiceDomains :exec +INSERT INTO service_domains (service_id, domain, is_default, cert_type) +SELECT $1, domain, is_default, cert_type FROM service_domains sd WHERE sd.service_id = $2 +` + +type CloneServiceDomainsParams struct { + ServiceID uuid.UUID `json:"service_id"` + ServiceID_2 uuid.UUID `json:"service_id_2"` +} + +func (q *Queries) CloneServiceDomains(ctx context.Context, arg CloneServiceDomainsParams) error { + _, err := q.db.ExecContext(ctx, cloneServiceDomains, arg.ServiceID, arg.ServiceID_2) + return err +} + +const cloneServiceRow = `-- name: CloneServiceRow :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, + traefik_labels, 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, volumes, $6, $7, + builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, + traefik_labels, $8, $8 +FROM services s WHERE s.id = $9 +` + +type CloneServiceRowParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + EnvironmentID uuid.UUID `json:"environment_id"` + Environment sql.NullString `json:"environment"` + MaintenanceMode bool `json:"maintenance_mode"` + BasicAuthUsers string `json:"basic_auth_users"` + CreatedAt sql.NullTime `json:"created_at"` + ID_2 uuid.UUID `json:"id_2"` +} + +func (q *Queries) CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error { + _, err := q.db.ExecContext(ctx, cloneServiceRow, + arg.ID, + arg.ProjectID, + arg.Name, + arg.EnvironmentID, + arg.Environment, + arg.MaintenanceMode, + arg.BasicAuthUsers, + arg.CreatedAt, + arg.ID_2, + ) + return err +} + +const cloneServiceVariables = `-- name: CloneServiceVariables :exec +INSERT INTO environment_variables (id, service_id, key, value, is_secret) +SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables ev WHERE ev.service_id = $2 +` + +type CloneServiceVariablesParams struct { + ServiceID uuid.UUID `json:"service_id"` + ServiceID_2 uuid.UUID `json:"service_id_2"` +} + +func (q *Queries) CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error { + _, err := q.db.ExecContext(ctx, cloneServiceVariables, arg.ServiceID, arg.ServiceID_2) + return err +} + +const countServicesByName = `-- name: CountServicesByName :one +SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2 +` + +type CountServicesByNameParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) CountServicesByName(ctx context.Context, arg CountServicesByNameParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countServicesByName, arg.ProjectID, arg.Name) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteServiceByID = `-- name: DeleteServiceByID :exec +DELETE FROM services WHERE id = $1 +` + +func (q *Queries) DeleteServiceByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteServiceByID, id) + return err +} + +const getProjectOwner = `-- name: GetProjectOwner :one +SELECT owner_id FROM projects WHERE id = $1 +` + +func (q *Queries) GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getProjectOwner, id) + var owner_id uuid.UUID + err := row.Scan(&owner_id) + return owner_id, err +} + +const getServiceEnvironment = `-- name: GetServiceEnvironment :one +SELECT COALESCE(environment, '') FROM services WHERE id = $1 +` + +func (q *Queries) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) { + row := q.db.QueryRowContext(ctx, getServiceEnvironment, id) + var environment string + err := row.Scan(&environment) + return environment, err +} + +const moveServiceProject = `-- name: MoveServiceProject :execrows +UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4 +` + +type MoveServiceProjectParams struct { + ProjectID uuid.UUID `json:"project_id"` + EnvironmentID uuid.UUID `json:"environment_id"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error) { + result, err := q.db.ExecContext(ctx, moveServiceProject, + arg.ProjectID, + arg.EnvironmentID, + arg.UpdatedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/app/backend/sqlc/queries/service_ops.sql b/app/backend/sqlc/queries/service_ops.sql new file mode 100644 index 0000000..6b48ea3 --- /dev/null +++ b/app/backend/sqlc/queries/service_ops.sql @@ -0,0 +1,38 @@ +-- name: CountServicesByName :one +SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2; + +-- name: GetServiceEnvironment :one +SELECT COALESCE(environment, '') FROM services WHERE id = $1; + +-- name: CloneServiceRow :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, + traefik_labels, 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, volumes, $6, $7, + builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, + traefik_labels, $8, $8 +FROM services s WHERE s.id = $9; + +-- name: CloneServiceDomains :exec +INSERT INTO service_domains (service_id, domain, is_default, cert_type) +SELECT $1, domain, is_default, cert_type FROM service_domains sd WHERE sd.service_id = $2; + +-- name: CloneServiceVariables :exec +INSERT INTO environment_variables (id, service_id, key, value, is_secret) +SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables ev WHERE ev.service_id = $2; + +-- name: DeleteServiceByID :exec +DELETE FROM services WHERE id = $1; + +-- name: MoveServiceProject :execrows +UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4; + +-- name: GetProjectOwner :one +SELECT owner_id FROM projects WHERE id = $1;