mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc Covers the cross-cutting surface: operations board reads, app settings, notification producers, activity/audit inserts, builds upsert/cancel, git clone credential resolution, invites, environments, registry auth, project/service variables, HA resolver, boot sweeps, and admin user ops. Dynamic filtered lists and Better Auth table probes stay raw by design. Authored By: TDvorak <[email protected]> * refactor(db): port apwhy gateway handlers and proxy data plane to sqlc Final tranche — every static call site now uses generated queries. Raw SQL remains only where the statement is assembled at runtime (filtered lists, sparse updates, admin count closure) or probes Better Auth's auth_users table, which lives outside the local schema. Authored By: TDvorak <[email protected]>
240 lines
6.9 KiB
Go
240 lines
6.9 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/secrets"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// maskedSecretValue is the placeholder returned for secret values; on update
|
|
// it means "keep the stored value" rather than overwriting with asterisks.
|
|
const maskedSecretValue = "********"
|
|
|
|
type EnvironmentVariable struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
|
Key string `json:"key" db:"key"`
|
|
Value string `json:"value" db:"value"`
|
|
IsSecret bool `json:"is_secret" db:"is_secret"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
type UpdateVariablesRequest struct {
|
|
Variables []VariableInput `json:"variables" binding:"required"`
|
|
Redeploy bool `json:"redeploy"`
|
|
}
|
|
|
|
type VariableInput struct {
|
|
Key string `json:"key" binding:"required"`
|
|
Value string `json:"value"`
|
|
IsSecret bool `json:"is_secret"`
|
|
}
|
|
|
|
func handleGetVariables(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
rows, err := db.(*database.DB).Query(
|
|
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
|
|
FROM environment_variables
|
|
WHERE service_id = $1
|
|
ORDER BY key ASC`,
|
|
serviceID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var variables []EnvironmentVariable
|
|
for rows.Next() {
|
|
var v EnvironmentVariable
|
|
err := rows.Scan(
|
|
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
|
return
|
|
}
|
|
if v.IsSecret {
|
|
v.Value = maskedSecretValue
|
|
}
|
|
variables = append(variables, v)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
|
}
|
|
|
|
func handleUpdateVariables(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
var req UpdateVariablesRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Snapshot stored secrets so a client echoing back the masked placeholder
|
|
// does not destroy the real value.
|
|
storedSecrets := map[string]string{}
|
|
secretRows, err := db.(*database.DB).Query(
|
|
`SELECT key, value FROM environment_variables WHERE service_id = $1 AND is_secret = TRUE`,
|
|
serviceID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
|
return
|
|
}
|
|
for secretRows.Next() {
|
|
var k, v string
|
|
if err := secretRows.Scan(&k, &v); err == nil {
|
|
storedSecrets[k] = v
|
|
}
|
|
}
|
|
secretRows.Close()
|
|
|
|
tx, err := db.(*database.DB).Begin()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
txq := sqlcdb.New(tx)
|
|
err = txq.DeleteEnvVarsForService(c.Request.Context(), serviceID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, v := range req.Variables {
|
|
value := v.Value
|
|
if v.IsSecret && value == maskedSecretValue {
|
|
if stored, ok := storedSecrets[v.Key]; ok {
|
|
value = stored
|
|
}
|
|
}
|
|
if v.IsSecret {
|
|
value = secrets.Encrypt(value)
|
|
}
|
|
err = txq.InsertEnvVar(c.Request.Context(), sqlcdb.InsertEnvVarParams{
|
|
ID: uuid.New(),
|
|
ServiceID: serviceID,
|
|
Key: v.Key,
|
|
Value: value,
|
|
IsSecret: sql.NullBool{Bool: v.IsSecret, Valid: true},
|
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
|
return
|
|
}
|
|
|
|
rows, err := db.(*database.DB).Query(
|
|
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
|
|
FROM environment_variables
|
|
WHERE service_id = $1
|
|
ORDER BY key ASC`,
|
|
serviceID,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var variables []EnvironmentVariable
|
|
for rows.Next() {
|
|
var v EnvironmentVariable
|
|
err := rows.Scan(
|
|
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if v.IsSecret {
|
|
v.Value = maskedSecretValue
|
|
}
|
|
variables = append(variables, v)
|
|
}
|
|
|
|
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
|
|
if req.Redeploy {
|
|
// Load the service spec inputs and queue a reconcile behind any
|
|
// in-flight deployment — new env lands in the recreated containers.
|
|
var svc Service
|
|
svcErr := db.(*database.DB).QueryRow(
|
|
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
|
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
|
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
|
COALESCE(cpu, ''), COALESCE(memory, ''),
|
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
|
|
FROM services WHERE id = $1`,
|
|
serviceID,
|
|
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
|
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
|
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
|
|
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
|
|
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|