mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
refactor(db): port operations, settings, audit, and misc metadata to sqlc (#84)
* 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]>
This commit is contained in:
1 parent
66301bb68a
commit
0dc5857b5f
45 files changed
+3272
-689
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -180,13 +181,13 @@ func handleGetProjectActivity(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Access check: approved / owner / member / admin.
|
||||
var visible bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid)
|
||||
FROM projects p WHERE p.id = $1`,
|
||||
projectID, userID, contextIsAdmin(c),
|
||||
).Scan(&visible); err != nil || !visible {
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
visible, err := sqlcdb.New(db.DB).CheckSecurityProjectAccess(context.Background(), sqlcdb.CheckSecurityProjectAccessParams{
|
||||
ID: uuid.MustParse(projectID),
|
||||
OwnerID: userUUID,
|
||||
Column3: contextIsAdmin(c),
|
||||
})
|
||||
if err != nil || !visible {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found")
|
||||
return
|
||||
}
|
||||
@@ -237,12 +238,12 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
|
||||
}
|
||||
|
||||
prune := func() {
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days)
|
||||
n, err := sqlcdb.New(db.DB).PruneAuditLogs(context.Background(), int32(days))
|
||||
if err != nil {
|
||||
log.Printf("audit retention prune failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
if n > 0 {
|
||||
log.Printf("audit retention: pruned %d rows older than %d days", n, days)
|
||||
}
|
||||
}
|
||||
@@ -265,34 +266,24 @@ func StartAuditRetention(ctx context.Context, db *database.DB) {
|
||||
// backfillAuditMetadata classifies rows written before severity/category/label
|
||||
// existed so filters and the feed see uniform metadata.
|
||||
func backfillAuditMetadata(db *database.DB) {
|
||||
cursor := uuid.Nil.String()
|
||||
q := sqlcdb.New(db.DB)
|
||||
cursor := uuid.Nil
|
||||
for {
|
||||
rows, err := db.Query(
|
||||
`SELECT id::text, resource, action FROM audit_logs
|
||||
WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor)
|
||||
rows, err := q.ListAuditBackfillBatch(context.Background(), cursor)
|
||||
if err != nil {
|
||||
log.Printf("audit backfill scan failed: %v", err)
|
||||
return
|
||||
}
|
||||
type row struct{ id, resource, action string }
|
||||
var batch []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if rows.Scan(&r.id, &r.resource, &r.action) == nil {
|
||||
batch = append(batch, r)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(batch) == 0 {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
for _, r := range batch {
|
||||
cursor = r.id
|
||||
sev, cat, label := classifyAuditEvent(r.resource, r.action)
|
||||
if _, err := db.Exec(
|
||||
`UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`,
|
||||
sev, cat, label, r.id,
|
||||
); err != nil {
|
||||
for _, r := range rows {
|
||||
id, _ := uuid.Parse(r.ID)
|
||||
cursor = id
|
||||
sev, cat, label := classifyAuditEvent(r.Resource, r.Action)
|
||||
if err := q.SetAuditLogMetadata(context.Background(), sqlcdb.SetAuditLogMetadataParams{
|
||||
Severity: sev, Category: cat, Label: label, Column4: id,
|
||||
}); err != nil {
|
||||
log.Printf("audit backfill update failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,15 +52,11 @@ func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = db.QueryRowContext(ctx, `
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET is_admin = true, updated_at = NOW()
|
||||
RETURNING id
|
||||
`, email, string(hashed), name).Scan(&userID)
|
||||
if err != nil {
|
||||
if _, err := sqlcdb.New(db.DB).UpsertAdminUser(ctx, sqlcdb.UpsertAdminUserParams{
|
||||
Email: email,
|
||||
PasswordHash: string(hashed),
|
||||
Name: name,
|
||||
}); err != nil {
|
||||
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
|
||||
return
|
||||
}
|
||||
@@ -185,24 +181,18 @@ func handleAdminOverview(c *gin.Context) {
|
||||
func handleAdminListUsers(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := db.QueryContext(c.Request.Context(), `
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
`)
|
||||
rows, err := sqlcdb.New(db.DB).ListAdminUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]adminUserRow, 0)
|
||||
for rows.Next() {
|
||||
var u adminUserRow
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, u)
|
||||
users := make([]adminUserRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
users = append(users, adminUserRow{
|
||||
ID: r.ID.String(), Email: r.Email, Name: r.Name,
|
||||
AvatarURL: r.AvatarUrl, IsAdmin: r.IsAdmin, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
@@ -229,15 +219,16 @@ func handleAdminSetUserAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := db.ExecContext(c.Request.Context(),
|
||||
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
|
||||
req.IsAdmin, targetID,
|
||||
)
|
||||
targetUUID, _ := uuid.Parse(targetID)
|
||||
n, err := sqlcdb.New(db.DB).SetUserAdmin(c.Request.Context(), sqlcdb.SetUserAdminParams{
|
||||
IsAdmin: req.IsAdmin,
|
||||
ID: targetUUID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n == 0 {
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
@@ -258,15 +249,13 @@ func handleAdminImpersonateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var email, name string
|
||||
var isAdmin bool
|
||||
err := db.QueryRowContext(c.Request.Context(),
|
||||
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
|
||||
Scan(&email, &name, &isAdmin)
|
||||
userUUID, _ := uuid.Parse(targetID)
|
||||
info, err := sqlcdb.New(db.DB).GetAdminUserInfo(c.Request.Context(), userUUID)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
|
||||
return
|
||||
}
|
||||
email, name, isAdmin := info.Email, info.Name, info.IsAdmin
|
||||
if isAdmin {
|
||||
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
|
||||
return
|
||||
@@ -286,9 +275,9 @@ func handleAdminImpersonateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
|
||||
"actor_id": actorID,
|
||||
"actor_id": actorID,
|
||||
"target_email": email,
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -160,21 +162,22 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var upstreamURL, healthPath string
|
||||
var authHeader, authValue sql.NullString
|
||||
var timeoutMs sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
||||
FROM api_services WHERE id = $1
|
||||
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
||||
if err != nil {
|
||||
svcUUID, parseErr := uuid.Parse(serviceID)
|
||||
var svc sqlcdb.GetAPServiceForValidateRow
|
||||
var err error
|
||||
if parseErr == nil {
|
||||
svc, err = sqlcdb.New(db.DB).GetAPServiceForValidate(ctx, svcUUID)
|
||||
}
|
||||
if parseErr != nil || err != nil {
|
||||
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
||||
return
|
||||
}
|
||||
upstreamURL, healthPath := svc.UpstreamUrl, svc.HealthPath
|
||||
authHeader, authValue := svc.UpstreamAuthHeader, svc.UpstreamAuthValue
|
||||
|
||||
timeout := 8000
|
||||
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||
timeout = int(timeoutMs.Int64)
|
||||
if svc.RequestTimeoutMs.Valid && svc.RequestTimeoutMs.Int32 > 0 {
|
||||
timeout = int(svc.RequestTimeoutMs.Int32)
|
||||
}
|
||||
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||
|
||||
@@ -203,17 +206,19 @@ func handleAPwhyServiceValidate(c *gin.Context) {
|
||||
validationStatus = "healthy"
|
||||
}
|
||||
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
UPDATE api_services
|
||||
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, validationStatus, message, serviceID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
_ = q.SetAPServiceValidation(ctx, sqlcdb.SetAPServiceValidationParams{
|
||||
LastValidationStatus: sql.NullString{String: validationStatus, Valid: true},
|
||||
LastValidationMessage: sql.NullString{String: message, Valid: true},
|
||||
ID: svcUUID,
|
||||
})
|
||||
|
||||
if !ok {
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
||||
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
||||
`, serviceID, message, status)
|
||||
_ = q.InsertServiceIncident(ctx, sqlcdb.InsertServiceIncidentParams{
|
||||
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: true},
|
||||
Message: message,
|
||||
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||
@@ -231,36 +236,24 @@ func handleAPwhyServicesList(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Query services from database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
||||
FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := sqlcdb.New(db.DB).ListAPServices(ctx)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||
"Failed to query services", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
services := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||
var enabled int
|
||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue // Skip malformed rows
|
||||
}
|
||||
|
||||
services := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
services = append(services, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": upstreamURL,
|
||||
"routePrefix": routePrefix,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"slug": r.Slug,
|
||||
"upstreamUrl": r.UpstreamUrl,
|
||||
"routePrefix": r.RoutePrefix,
|
||||
"enabled": r.Enabled != 0,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,14 +292,14 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO api_services (
|
||||
name, slug, upstream_url, route_prefix, health_path,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
||||
svcID, err := sqlcdb.New(db.DB).InsertAPService(ctx, sqlcdb.InsertAPServiceParams{
|
||||
Name: req.Name,
|
||||
Slug: slug,
|
||||
UpstreamUrl: req.UpstreamURL,
|
||||
RoutePrefix: req.RoutePrefix,
|
||||
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
|
||||
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -320,7 +313,7 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
serviceData := map[string]interface{}{
|
||||
"id": id,
|
||||
"id": svcID.String(),
|
||||
"name": req.Name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": req.UpstreamURL,
|
||||
@@ -356,13 +349,15 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
enabled := int32(0)
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, serviceID)
|
||||
svcUUID, _ := uuid.Parse(serviceID)
|
||||
err := sqlcdb.New(db.DB).SetAPServiceEnabled(context.Background(), sqlcdb.SetAPServiceEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: svcUUID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -383,11 +378,7 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
func handleAPwhyKeysList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
FROM api_keys
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := sqlcdb.New(db.DB).ListAPKeys(context.Background())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
@@ -395,34 +386,24 @@ func handleAPwhyKeysList(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||
var enabled int
|
||||
var rpmLimit, monthlyQuota sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
keys := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
key := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"keyPrefix": keyPrefix,
|
||||
"plan": plan,
|
||||
"enabled": enabled != 0,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"keyPrefix": r.KeyPrefix,
|
||||
"plan": r.Plan,
|
||||
"enabled": r.Enabled != 0,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
}
|
||||
|
||||
if rpmLimit.Valid {
|
||||
key["rpmLimit"] = rpmLimit.Int64
|
||||
if r.RpmLimit.Valid {
|
||||
key["rpmLimit"] = int64(r.RpmLimit.Int32)
|
||||
}
|
||||
if monthlyQuota.Valid {
|
||||
key["monthlyQuota"] = monthlyQuota.Int64
|
||||
if r.MonthlyQuota.Valid {
|
||||
key["monthlyQuota"] = int64(r.MonthlyQuota.Int32)
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
@@ -481,14 +462,14 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||
var id string
|
||||
err = db.QueryRowContext(context.Background(), `
|
||||
INSERT INTO api_keys (
|
||||
name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
||||
keyID, err := sqlcdb.New(db.DB).InsertAPKey(context.Background(), sqlcdb.InsertAPKeyParams{
|
||||
Name: input.Name,
|
||||
KeyHash: keyHash,
|
||||
KeyPrefix: keyPrefix,
|
||||
Plan: plan,
|
||||
RpmLimit: sql.NullInt32{Int32: int32(rpmLimit), Valid: true},
|
||||
MonthlyQuota: sql.NullInt32{Int32: int32(monthlyQuota), Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -501,7 +482,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"id": id,
|
||||
"id": keyID.String(),
|
||||
"name": input.Name,
|
||||
"plan": plan,
|
||||
"key": apiKey, // Only return the actual key once
|
||||
@@ -532,13 +513,15 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
enabled := 0
|
||||
enabled := int32(0)
|
||||
if *input.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
enabled, keyID)
|
||||
keyUUID, _ := uuid.Parse(keyID)
|
||||
err := sqlcdb.New(db.DB).SetAPKeyEnabled(context.Background(), sqlcdb.SetAPKeyEnabledParams{
|
||||
Enabled: enabled,
|
||||
ID: keyUUID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -560,23 +543,27 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
// Get counts from database
|
||||
var totalServices, totalKeys, totalUsers int
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices); err != nil {
|
||||
totalServices, err := q.CountAPServices(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count services: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys); err != nil {
|
||||
totalKeys, err := q.CountAPKeys(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count api keys: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers); err != nil {
|
||||
totalUsers, err := q.CountUsers(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to count users: " + err.Error(),
|
||||
@@ -584,8 +571,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var totalRequests int
|
||||
if err := db.QueryRowContext(context.Background(), "SELECT COALESCE(SUM(request_count), 0) FROM usage_counters").Scan(&totalRequests); err != nil {
|
||||
totalRequests, err := q.SumUsageCounters(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate request counters: " + err.Error(),
|
||||
@@ -593,12 +580,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var requestsToday int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(value), 0)::int
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('day', NOW())
|
||||
`).Scan(&requestsToday); err != nil {
|
||||
requestsToday, err := q.SumRequestMetricSince(ctx, "day")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate today's requests: " + err.Error(),
|
||||
@@ -606,12 +589,8 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var requestsThisMonth int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(value), 0)::int
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= DATE_TRUNC('month', NOW())
|
||||
`).Scan(&requestsThisMonth); err != nil {
|
||||
requestsThisMonth, err := q.SumRequestMetricSince(ctx, "month")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to aggregate monthly requests: " + err.Error(),
|
||||
@@ -636,94 +615,47 @@ func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
topServices := make([]map[string]interface{}, 0)
|
||||
serviceRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT s.id, s.name, COALESCE(SUM(u.request_count), 0) AS total_requests
|
||||
FROM api_services s
|
||||
LEFT JOIN usage_counters u ON u.service_id = s.id
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY total_requests DESC, s.name ASC
|
||||
LIMIT 10
|
||||
`)
|
||||
if err == nil {
|
||||
defer serviceRows.Close()
|
||||
for serviceRows.Next() {
|
||||
var serviceID, serviceName string
|
||||
var totalRequests int
|
||||
if scanErr := serviceRows.Scan(&serviceID, &serviceName, &totalRequests); scanErr == nil {
|
||||
topServices = append(topServices, map[string]interface{}{
|
||||
"service_id": serviceID,
|
||||
"name": serviceName,
|
||||
"requests": totalRequests,
|
||||
})
|
||||
}
|
||||
if serviceRows, err := q.ListTopAPServices(ctx); err == nil {
|
||||
for _, r := range serviceRows {
|
||||
topServices = append(topServices, map[string]interface{}{
|
||||
"service_id": r.ID.String(),
|
||||
"name": r.Name,
|
||||
"requests": r.TotalRequests,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestsByDay := make([]map[string]interface{}, 0)
|
||||
trafficRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT TO_CHAR(DATE_TRUNC('day', occurred_at), 'YYYY-MM-DD') AS day_bucket, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'request_total' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY DATE_TRUNC('day', occurred_at)
|
||||
ORDER BY DATE_TRUNC('day', occurred_at) ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer trafficRows.Close()
|
||||
for trafficRows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if scanErr := trafficRows.Scan(&day, &count); scanErr == nil {
|
||||
requestsByDay = append(requestsByDay, map[string]interface{}{
|
||||
"day": day,
|
||||
"requests": count,
|
||||
})
|
||||
}
|
||||
if trafficRows, err := q.ListRequestsByDay(ctx); err == nil {
|
||||
for _, r := range trafficRows {
|
||||
requestsByDay = append(requestsByDay, map[string]interface{}{
|
||||
"day": r.DayBucket,
|
||||
"requests": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
statusCodes := make([]map[string]interface{}, 0)
|
||||
statusRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE(http_status, 0) AS status_code, COALESCE(SUM(count), 0) AS total
|
||||
FROM incident_events
|
||||
WHERE occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE(http_status, 0)
|
||||
ORDER BY total DESC, status_code ASC
|
||||
`)
|
||||
if err == nil {
|
||||
defer statusRows.Close()
|
||||
for statusRows.Next() {
|
||||
var code int
|
||||
var total int
|
||||
if scanErr := statusRows.Scan(&code, &total); scanErr == nil {
|
||||
statusCodes = append(statusCodes, map[string]interface{}{
|
||||
"status_code": code,
|
||||
"count": total,
|
||||
})
|
||||
}
|
||||
if statusRows, err := q.ListIncidentStatusCodes(ctx); err == nil {
|
||||
for _, r := range statusRows {
|
||||
statusCodes = append(statusCodes, map[string]interface{}{
|
||||
"status_code": r.StatusCode,
|
||||
"count": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clientEvents := make([]map[string]interface{}, 0)
|
||||
eventRows, err := db.QueryContext(context.Background(), `
|
||||
SELECT COALESCE((labels_json::jsonb ->> 'path'), 'unknown') AS path, COUNT(*) AS total
|
||||
FROM metrics_timeseries
|
||||
WHERE metric = 'client_event' AND occurred_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY COALESCE((labels_json::jsonb ->> 'path'), 'unknown')
|
||||
ORDER BY total DESC, path ASC
|
||||
LIMIT 20
|
||||
`)
|
||||
if err == nil {
|
||||
defer eventRows.Close()
|
||||
for eventRows.Next() {
|
||||
var path string
|
||||
var total int
|
||||
if scanErr := eventRows.Scan(&path, &total); scanErr == nil {
|
||||
clientEvents = append(clientEvents, map[string]interface{}{
|
||||
"path": path,
|
||||
"count": total,
|
||||
})
|
||||
}
|
||||
if eventRows, err := q.ListClientEventPaths(ctx); err == nil {
|
||||
for _, r := range eventRows {
|
||||
clientEvents = append(clientEvents, map[string]interface{}{
|
||||
"path": r.Path,
|
||||
"count": r.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -98,16 +102,20 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
|
||||
}
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
resourceUUID := parseUUIDOrNil(resourceID)
|
||||
userUUID := parseUUIDOrNil(userID)
|
||||
severity, category, label := classifyAuditEvent(resource, action)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
|
||||
)
|
||||
err := sqlcdb.New(db.DB).InsertAuditLog(context.Background(), sqlcdb.InsertAuditLogParams{
|
||||
ID: uuid.New(),
|
||||
UserID: nullUUIDOrNil(userID),
|
||||
Resource: resource,
|
||||
ResourceID: nullUUIDOrNil(resourceID),
|
||||
Action: action,
|
||||
Details: pqtype.NullRawMessage{RawMessage: detailsJSON, Valid: true},
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
Label: label,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
@@ -131,16 +139,24 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
|
||||
if uid, ok := userID.(string); ok {
|
||||
userIDStr = uid
|
||||
}
|
||||
userUUID := parseUUIDOrNil(userIDStr)
|
||||
resourceUUID := parseUUIDOrNil(resourceID)
|
||||
severity, category, label := classifyAuditEvent(resource, action)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`,
|
||||
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
|
||||
)
|
||||
var inet pqtype.Inet
|
||||
_ = inet.Scan(c.ClientIP())
|
||||
err := sqlcdb.New(db.DB).InsertAuditLogWithRequest(context.Background(), sqlcdb.InsertAuditLogWithRequestParams{
|
||||
ID: uuid.New(),
|
||||
UserID: nullUUIDOrNil(userIDStr),
|
||||
Resource: resource,
|
||||
ResourceID: nullUUIDOrNil(resourceID),
|
||||
Action: action,
|
||||
Column6: json.RawMessage(detailsJSON),
|
||||
Column7: inet,
|
||||
UserAgent: sql.NullString{String: c.GetHeader("User-Agent"), Valid: true},
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
Label: label,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
@@ -255,30 +271,29 @@ func handleGetResourceAuditLogs(c *gin.Context) {
|
||||
resource := c.Param("resource")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, COALESCE(user_id::text, ''), resource, COALESCE(resource_id::text, ''), action, COALESCE(details::text, '{}'),
|
||||
COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), created_at
|
||||
FROM audit_logs
|
||||
WHERE user_id::text = $1 AND resource = $2 AND resource_id::text = $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
userID, resource, resourceID,
|
||||
)
|
||||
|
||||
rows, err := sqlcdb.New(db.DB).ListResourceAuditLogs(context.Background(), sqlcdb.ListResourceAuditLogsParams{
|
||||
UserID: nullUUIDOrNil(userID),
|
||||
Resource: resource,
|
||||
ResourceID: nullUUIDOrNil(resourceID),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, log)
|
||||
for _, row := range rows {
|
||||
logs = append(logs, AuditLog{
|
||||
ID: row.ID.String(),
|
||||
UserID: row.UserID,
|
||||
Resource: row.Resource,
|
||||
ResourceID: row.ResourceID,
|
||||
Action: row.Action,
|
||||
Details: row.Details,
|
||||
IPAddress: row.IpAddress,
|
||||
UserAgent: row.UserAgent,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
@@ -302,3 +317,8 @@ func parseUUIDOrNil(raw string) interface{} {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func nullUUIDOrNil(raw string) uuid.NullUUID {
|
||||
parsed, err := uuid.Parse(strings.TrimSpace(raw))
|
||||
return uuid.NullUUID{UUID: parsed, Valid: err == nil}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
@@ -669,39 +670,22 @@ func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := h.db.Exec(
|
||||
`INSERT INTO builds
|
||||
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
service_id = EXCLUDED.service_id,
|
||||
status = EXCLUDED.status,
|
||||
progress = EXCLUDED.progress,
|
||||
started_at = EXCLUDED.started_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
image_name = EXCLUDED.image_name,
|
||||
image_tag = EXCLUDED.image_tag,
|
||||
size = EXCLUDED.size,
|
||||
error = EXCLUDED.error,
|
||||
log = EXCLUDED.log,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
status.ID,
|
||||
nullIfEmptyString(status.ProjectID),
|
||||
nullIfEmptyString(status.ServiceID),
|
||||
status.Status,
|
||||
status.Progress,
|
||||
status.StartedAt,
|
||||
status.CompletedAt,
|
||||
status.ImageName,
|
||||
status.ImageTag,
|
||||
status.Size,
|
||||
nullIfEmptyString(status.Error),
|
||||
status.Log,
|
||||
jsonOrEmptyObject(metadataRaw),
|
||||
now,
|
||||
)
|
||||
err := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
|
||||
ID: status.ID,
|
||||
ProjectID: nsNullIfEmpty(status.ProjectID),
|
||||
ServiceID: nsNullIfEmpty(status.ServiceID),
|
||||
Status: status.Status,
|
||||
Progress: int32(status.Progress),
|
||||
StartedAt: status.StartedAt,
|
||||
CompletedAt: ntPtr(status.CompletedAt),
|
||||
ImageName: status.ImageName,
|
||||
ImageTag: status.ImageTag,
|
||||
Size: status.Size,
|
||||
Error: nsNullIfEmpty(status.Error),
|
||||
Log: status.Log,
|
||||
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return nil
|
||||
}
|
||||
@@ -713,18 +697,37 @@ func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
|
||||
row := h.db.QueryRow(
|
||||
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds
|
||||
WHERE id = $1`,
|
||||
buildID,
|
||||
)
|
||||
|
||||
build, found, err := scanBuildRow(row.Scan)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
row, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
return BuildStatusResponse{}, false, err
|
||||
}
|
||||
return build, found, err
|
||||
build := BuildStatusResponse{
|
||||
ID: row.ID,
|
||||
ProjectID: row.ProjectID.String,
|
||||
ServiceID: row.ServiceID.String,
|
||||
Status: row.Status,
|
||||
Progress: int(row.Progress),
|
||||
StartedAt: row.StartedAt.UTC(),
|
||||
ImageName: row.ImageName,
|
||||
ImageTag: row.ImageTag,
|
||||
Size: row.Size,
|
||||
Error: row.Error.String,
|
||||
Log: row.Log,
|
||||
}
|
||||
if t := timePtrNT(row.CompletedAt); t != nil {
|
||||
u := t.UTC()
|
||||
build.CompletedAt = &u
|
||||
}
|
||||
if len(row.Metadata) > 0 {
|
||||
metadata := map[string]string{}
|
||||
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
|
||||
build.Metadata = metadata
|
||||
}
|
||||
}
|
||||
return build, true, nil
|
||||
}
|
||||
|
||||
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
|
||||
@@ -808,19 +811,11 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result, err := h.db.Exec(
|
||||
`UPDATE builds
|
||||
SET status = 'cancelled',
|
||||
progress = 100,
|
||||
completed_at = $1,
|
||||
log = COALESCE(log, '') || $2,
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND status NOT IN ('success', 'failed', 'cancelled')`,
|
||||
now,
|
||||
h.formatLogLine("Cancellation requested")+"\n",
|
||||
buildID,
|
||||
)
|
||||
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
|
||||
CompletedAt: sql.NullTime{Time: now, Valid: true},
|
||||
Log: h.formatLogLine("Cancellation requested") + "\n",
|
||||
ID: buildID,
|
||||
})
|
||||
if err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return false
|
||||
@@ -828,9 +823,7 @@ func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
log.Printf("failed to cancel build %s in database: %v", buildID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected == 0 {
|
||||
if affected == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -909,12 +902,9 @@ func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bo
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func nullIfEmptyString(value string) interface{} {
|
||||
func nsNullIfEmpty(value string) sql.NullString {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
return sql.NullString{String: trimmed, Valid: trimmed != ""}
|
||||
}
|
||||
|
||||
func jsonOrEmptyObject(raw []byte) string {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -57,15 +59,12 @@ func contextIsAdmin(c *gin.Context) bool {
|
||||
// public; unapproved ones are visible to the owner, project members, and
|
||||
// platform admins only.
|
||||
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
|
||||
var isApproved bool
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&isApproved, &ownerID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
row, err := q.GetProjectReadAccess(context.Background(), projectID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
isApproved, ownerID := row.IsApproved, row.OwnerID
|
||||
if isApproved || contextIsAdmin(c) {
|
||||
return true, true
|
||||
}
|
||||
@@ -76,11 +75,11 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
var member bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
|
||||
projectID, userID,
|
||||
).Scan(&member); err == nil && member {
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userUUID,
|
||||
}); err == nil && member {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
@@ -91,11 +90,8 @@ func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bo
|
||||
// project members, and platform admins may reach secret-adjacent data
|
||||
// such as variables and runtime logs.
|
||||
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
|
||||
var ownerID string
|
||||
err := db.QueryRow(
|
||||
`SELECT owner_id::text FROM projects WHERE id = $1`,
|
||||
projectID,
|
||||
).Scan(&ownerID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
ownerID, err := q.GetProjectOwnerText(context.Background(), projectID)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
@@ -109,11 +105,11 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
|
||||
if ownerID == userID {
|
||||
return true, true
|
||||
}
|
||||
var member bool
|
||||
if err := db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
|
||||
projectID, userID,
|
||||
).Scan(&member); err == nil && member {
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
if member, err := q.ProjectMemberExists(context.Background(), sqlcdb.ProjectMemberExistsParams{
|
||||
ProjectID: projectID,
|
||||
UserID: userUUID,
|
||||
}); err == nil && member {
|
||||
return true, true
|
||||
}
|
||||
return true, false
|
||||
@@ -121,8 +117,7 @@ func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (
|
||||
|
||||
// serviceProjectID resolves the project that owns a service.
|
||||
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
|
||||
var projectID uuid.UUID
|
||||
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
|
||||
projectID, err := sqlcdb.New(db.DB).GetServiceProjectID(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func handleDeleteEnvironment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var projectID uuid.UUID
|
||||
if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil {
|
||||
if projectID, err = sqlcdb.New(db.DB).GetEnvironmentProjectID(context.Background(), envID); err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/gateway"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -107,23 +109,28 @@ func extractGatewayKey(c *gin.Context) string {
|
||||
|
||||
// lookupGatewayService resolves the slug to an enabled service row.
|
||||
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
|
||||
var s gatewayService
|
||||
var enabled int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, upstream_url, route_prefix, upstream_auth_header,
|
||||
upstream_auth_value, enabled, rpm_limit, monthly_quota,
|
||||
request_timeout_ms
|
||||
FROM api_services WHERE slug = $1
|
||||
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
|
||||
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
|
||||
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
|
||||
r, err := sqlcdb.New(db.DB).GetAPServiceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Enabled = enabled != 0
|
||||
s := gatewayService{
|
||||
ID: r.ID.String(),
|
||||
UpstreamURL: r.UpstreamUrl,
|
||||
RoutePrefix: r.RoutePrefix,
|
||||
UpstreamAuthHeader: r.UpstreamAuthHeader,
|
||||
UpstreamAuthValue: r.UpstreamAuthValue,
|
||||
Enabled: r.Enabled != 0,
|
||||
RPMLimit: nullInt32To64(r.RpmLimit),
|
||||
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
|
||||
RequestTimeoutMS: int(r.RequestTimeoutMs.Int32),
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func nullInt32To64(v sql.NullInt32) sql.NullInt64 {
|
||||
return sql.NullInt64{Int64: int64(v.Int32), Valid: v.Valid}
|
||||
}
|
||||
|
||||
// authenticateGatewayKey resolves a presented key by its indexed prefix and
|
||||
// bcrypt-verifies candidates. Returns nil when nothing matches.
|
||||
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
|
||||
@@ -131,26 +138,24 @@ func authenticateGatewayKey(ctx context.Context, db *database.DB, presented stri
|
||||
if len(prefix) > 8 {
|
||||
prefix = prefix[:8]
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
|
||||
FROM api_keys WHERE key_prefix = $1
|
||||
`, prefix)
|
||||
rows, err := sqlcdb.New(db.DB).ListAPKeysByPrefix(ctx, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k gatewayKey
|
||||
var enabled int
|
||||
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
|
||||
return nil, err
|
||||
for _, r := range rows {
|
||||
k := gatewayKey{
|
||||
ID: r.ID.String(),
|
||||
Hash: r.KeyHash,
|
||||
Enabled: r.Enabled != 0,
|
||||
RPMLimit: nullInt32To64(r.RpmLimit),
|
||||
MonthlyQuota: nullInt32To64(r.MonthlyQuota),
|
||||
AllowedServiceIDs: r.AllowedServiceIds,
|
||||
}
|
||||
k.Enabled = enabled != 0
|
||||
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
|
||||
return &k, nil
|
||||
}
|
||||
}
|
||||
return nil, rows.Err()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
|
||||
@@ -168,48 +173,56 @@ func keyAllowsService(k *gatewayKey, serviceID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// monthlyUsage sums a counter dimension for the current calendar month.
|
||||
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
|
||||
var total int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
|
||||
WHERE `+column+` = $1 AND period_month = $2
|
||||
`, id, period).Scan(&total)
|
||||
return total, err
|
||||
// monthlyKeyUsage / monthlyServiceUsage sum a counter dimension for the
|
||||
// current calendar month.
|
||||
func monthlyKeyUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
|
||||
keyUUID, _ := uuid.Parse(id)
|
||||
return sqlcdb.New(db.DB).SumUsageByKey(ctx, sqlcdb.SumUsageByKeyParams{
|
||||
ApiKeyID: keyUUID, PeriodMonth: period,
|
||||
})
|
||||
}
|
||||
|
||||
func monthlyServiceUsage(ctx context.Context, db *database.DB, id, period string) (int64, error) {
|
||||
svcUUID, _ := uuid.Parse(id)
|
||||
return sqlcdb.New(db.DB).SumUsageByService(ctx, sqlcdb.SumUsageByServiceParams{
|
||||
ServiceID: svcUUID, PeriodMonth: period,
|
||||
})
|
||||
}
|
||||
|
||||
// recordGatewayRequest persists usage, metrics, and failure incidents for a
|
||||
// proxied call. Runs in the request path but is three small writes.
|
||||
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
|
||||
VALUES ($1, $2, $3, 1, NOW())
|
||||
ON CONFLICT (api_key_id, service_id, period_month)
|
||||
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
|
||||
`, k.ID, svc.ID, period)
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
|
||||
`, k.ID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
keyUUID, _ := uuid.Parse(k.ID)
|
||||
svcUUID, _ := uuid.Parse(svc.ID)
|
||||
_ = q.UpsertUsageCounter(ctx, sqlcdb.UpsertUsageCounterParams{
|
||||
ApiKeyID: keyUUID, ServiceID: svcUUID, PeriodMonth: period,
|
||||
})
|
||||
_ = q.TouchAPKeyLastUsed(ctx, keyUUID)
|
||||
labels, _ := json.Marshal(map[string]interface{}{
|
||||
"service_id": svc.ID,
|
||||
"key_id": k.ID,
|
||||
"status": status,
|
||||
})
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
|
||||
VALUES ('gateway_request', 1, $1, NOW())
|
||||
`, string(labels))
|
||||
_ = q.InsertMetricPoint(ctx, sql.NullString{String: string(labels), Valid: true})
|
||||
}
|
||||
|
||||
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
|
||||
var kid interface{}
|
||||
var kid uuid.NullUUID
|
||||
if keyID != nil {
|
||||
kid = *keyID
|
||||
if u, err := uuid.Parse(*keyID); err == nil {
|
||||
kid = uuid.NullUUID{UUID: u, Valid: true}
|
||||
}
|
||||
}
|
||||
_, _ = db.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
`, svcID, kid, code, msg, severity, status)
|
||||
svcUUID, _ := uuid.Parse(svcID)
|
||||
_ = sqlcdb.New(db.DB).InsertGatewayIncident(ctx, sqlcdb.InsertGatewayIncidentParams{
|
||||
ServiceID: uuid.NullUUID{UUID: svcUUID, Valid: svcUUID != uuid.Nil},
|
||||
ApiKeyID: kid,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Severity: severity,
|
||||
HttpStatus: sql.NullInt32{Int32: int32(status), Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
|
||||
@@ -267,13 +280,13 @@ func handleGatewayProxy(c *gin.Context) {
|
||||
|
||||
period := now.Format("2006-01")
|
||||
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
|
||||
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
|
||||
if used, err := monthlyKeyUsage(ctx, db, key.ID, period); err == nil && used >= key.MonthlyQuota.Int64 {
|
||||
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
|
||||
return
|
||||
}
|
||||
}
|
||||
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
|
||||
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
|
||||
if used, err := monthlyServiceUsage(ctx, db, svc.ID, period); err == nil && used >= svc.MonthlyQuota.Int64 {
|
||||
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/source"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// resolveGitCloneSpec turns a service's git_repo (clone URL, ssh remote, or
|
||||
@@ -22,26 +26,31 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
|
||||
}
|
||||
|
||||
spec := source.Spec{Branch: branch, Commit: commit, BuildPath: buildPath}
|
||||
q := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
var providerID string
|
||||
ownerUUID, _ := uuid.Parse(ownerID)
|
||||
var providerID uuid.UUID
|
||||
if !strings.Contains(cloneURL, "://") {
|
||||
// Bare owner/repo — the connected repository row knows the host.
|
||||
err = db.QueryRow(
|
||||
`SELECT clone_url, provider_id FROM git_repositories
|
||||
WHERE full_name = $1 AND user_id = $2 LIMIT 1`,
|
||||
cloneURL, ownerID,
|
||||
).Scan(&cloneURL, &providerID)
|
||||
row, err := q.GetGitRepoCloneByFullName(ctx, sqlcdb.GetGitRepoCloneByFullNameParams{
|
||||
FullName: cloneURL,
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return source.Spec{}, fmt.Errorf("repository %s is not connected — connect it under Git settings or store a clone URL", raw)
|
||||
}
|
||||
cloneURL = row.CloneUrl
|
||||
providerID = row.ProviderID
|
||||
} else {
|
||||
// URL form — match a connected repo for its provider when possible;
|
||||
// an unmatched public URL still clones, just without credentials.
|
||||
_ = db.QueryRow(
|
||||
`SELECT provider_id FROM git_repositories
|
||||
WHERE clone_url = $1 AND user_id = $2 LIMIT 1`,
|
||||
cloneURL, ownerID,
|
||||
).Scan(&providerID)
|
||||
if pid, err := q.GetGitRepoProviderByCloneURL(ctx, sqlcdb.GetGitRepoProviderByCloneURLParams{
|
||||
CloneUrl: cloneURL,
|
||||
UserID: ownerUUID,
|
||||
}); err == nil {
|
||||
providerID = pid
|
||||
}
|
||||
}
|
||||
|
||||
if err := source.ValidateRemoteURL(cloneURL); err != nil {
|
||||
@@ -49,17 +58,17 @@ func resolveGitCloneSpec(db *database.DB, ownerID, gitRepo, branch, commit, buil
|
||||
}
|
||||
spec.CloneURL = cloneURL
|
||||
|
||||
if providerID == "" {
|
||||
if providerID == uuid.Nil {
|
||||
return spec, nil
|
||||
}
|
||||
var name, apiURL, token string
|
||||
if err := db.QueryRow(
|
||||
`SELECT name, api_url, access_token FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
providerID, ownerID,
|
||||
).Scan(&name, &apiURL, &token); err != nil {
|
||||
prov, err := q.GetGitProviderForFetch(ctx, sqlcdb.GetGitProviderForFetchParams{
|
||||
ID: providerID,
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
return spec, nil // provider row gone — anonymous clone still works for public repos
|
||||
}
|
||||
name, apiURL, token := prov.Name, prov.ApiUrl, prov.AccessToken
|
||||
|
||||
switch strings.ToLower(name) {
|
||||
case "github_app":
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -88,8 +89,8 @@ func handleAdminGitHubAppManifest(c *gin.Context) {
|
||||
|
||||
callbackURL := base + "/git/github-app/callback"
|
||||
manifest, err := json.Marshal(map[string]interface{}{
|
||||
"name": name,
|
||||
"url": base,
|
||||
"name": name,
|
||||
"url": base,
|
||||
"hook_attributes": map[string]interface{}{
|
||||
"url": base + "/api/git/github-app/webhook",
|
||||
"active": true,
|
||||
@@ -206,9 +207,8 @@ func handleAdminGitHubAppConvert(c *gin.Context) {
|
||||
// GET /admin/git/github-app — provisioning status for the settings UI.
|
||||
func handleAdminGetGitHubApp(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
var stored string
|
||||
source := "none"
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
|
||||
if v, err := sqlcdb.New(db.DB).GetAppSetting(c.Request.Context(), settingGitHubAppID); err == nil && strings.TrimSpace(v) != "" {
|
||||
source = "settings"
|
||||
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
|
||||
source = "env"
|
||||
|
||||
@@ -193,8 +193,8 @@ func handleAcceptInvite(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
|
||||
existing, err := q.CountUsersByEmail(c.Request.Context(), req.Email)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
|
||||
return
|
||||
}
|
||||
@@ -214,17 +214,23 @@ func handleAcceptInvite(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
row, err := q.InsertUser(c.Request.Context(), sqlcdb.InsertUserParams{
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
|
||||
return
|
||||
}
|
||||
user := User{
|
||||
ID: row.ID.String(),
|
||||
Email: row.Email,
|
||||
Name: row.Name,
|
||||
AvatarURL: row.AvatarUrl,
|
||||
IsAdmin: row.IsAdmin,
|
||||
CreatedAt: row.CreatedAt.Time.String(),
|
||||
}
|
||||
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
@@ -60,17 +61,12 @@ func handleGetServiceMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var projectID uuid.UUID
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT s.project_id, COALESCE(s.status, '')
|
||||
FROM services s WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&projectID, &status)
|
||||
svc, err := sqlcdb.New(db.DB).GetServiceProjectAndStatus(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
projectID := svc.ProjectID
|
||||
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
|
||||
@@ -315,10 +315,9 @@ func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID st
|
||||
// pullCredentials resolves the project owner's registry auth for the image
|
||||
// host so private images pull on remote nodes. Empty map → anonymous pull.
|
||||
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
|
||||
var ownerID string
|
||||
if err := r.db.QueryRow(
|
||||
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
|
||||
).Scan(&ownerID); err != nil {
|
||||
pid, _ := uuid.Parse(projectID)
|
||||
ownerID, err := sqlcdb.New(r.db.DB).GetProjectOwnerText(context.Background(), pid)
|
||||
if err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
auth := registryAuthFor(r.db, ownerID, imageRef)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -29,15 +30,13 @@ func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID s
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
rows, err := db.Query(`SELECT id FROM users WHERE is_admin`)
|
||||
adminIDs, err := sqlcdb.New(db.DB).ListAdminUserIDs(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("containr: notify admins lookup failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
for _, id := range adminIDs {
|
||||
{
|
||||
insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID)
|
||||
}
|
||||
}
|
||||
@@ -73,34 +72,32 @@ type agentHealthRow struct {
|
||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-staleAfter)
|
||||
var agents []agentHealthRow
|
||||
rows, err := db.Query(
|
||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
||||
COALESCE(last_heartbeat < $1, TRUE), auto_prune
|
||||
FROM node_agents`, cutoff)
|
||||
q := sqlcdb.New(db.DB)
|
||||
agentRows, err := q.ListAgentHealthRows(context.Background(), sql.NullTime{Time: cutoff, Valid: true})
|
||||
if err != nil {
|
||||
log.Printf("containr: agent health sweep: %v", err)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var a agentHealthRow
|
||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
||||
agents = append(agents, a)
|
||||
}
|
||||
for _, r := range agentRows {
|
||||
agents = append(agents, agentHealthRow{
|
||||
id: r.ID, name: r.Name, status: r.Status, stale: r.Stale, autoPrune: r.AutoPrune,
|
||||
})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, a := range agents {
|
||||
switch {
|
||||
case a.stale && a.status != "offline":
|
||||
if _, err := db.Exec(
|
||||
`UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
||||
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
|
||||
Status: sql.NullString{String: "offline", Valid: true}, ID: a.id,
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
notifyAdmins(db, "agent", "Node agent offline",
|
||||
fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id)
|
||||
case !a.stale && a.status == "offline":
|
||||
if _, err := db.Exec(
|
||||
`UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil {
|
||||
if err := q.SetAgentStatus(context.Background(), sqlcdb.SetAgentStatusParams{
|
||||
Status: sql.NullString{String: "online", Valid: true}, ID: a.id,
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
notifyAdmins(db, "agent", "Node agent back online",
|
||||
@@ -186,17 +183,17 @@ func checkForUpgrade(db *database.DB, currentVersion string) {
|
||||
return
|
||||
}
|
||||
|
||||
var lastNotified string
|
||||
_ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified)
|
||||
lastNotified, _ := sqlcdb.New(db.DB).GetAppSetting(context.Background(), "upgrade_notified_tag")
|
||||
if lastNotified == release.TagName {
|
||||
return
|
||||
}
|
||||
notifyAdmins(db, "upgrade", "Containr update available",
|
||||
fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL),
|
||||
"release", release.TagName)
|
||||
_, _ = db.Exec(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName)
|
||||
_ = sqlcdb.New(db.DB).UpsertAppSettingValue(context.Background(), sqlcdb.UpsertAppSettingValueParams{
|
||||
Key: "upgrade_notified_tag",
|
||||
Value: release.TagName,
|
||||
})
|
||||
}
|
||||
|
||||
// semverNewer reports whether candidate is a higher semver than current.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployqueue"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GET /operations — a single cross-cutting view of platform work: active
|
||||
@@ -55,91 +58,59 @@ func handleGetOperations(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
// Same visibility rule as the deployment list endpoint.
|
||||
visibility := `p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
q := sqlcdb.New(db.DB)
|
||||
visibility := sqlcdb.ListActiveOperationDeploymentsParams{OwnerID: userUUID, Column2: isAdmin}
|
||||
|
||||
active := []operationDeployment{}
|
||||
rows, err := db.Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('queued','pending','building','deploying','rolling_back')
|
||||
AND (`+visibility+`)
|
||||
ORDER BY d.created_at ASC`, userID, isAdmin)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d operationDeployment
|
||||
var sid string
|
||||
_ = rows.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||
d.ServiceID = sid
|
||||
active = append(active, d)
|
||||
if rows, err := q.ListActiveOperationDeployments(context.Background(), visibility); err == nil {
|
||||
for _, r := range rows {
|
||||
active = append(active, operationDeployment{
|
||||
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
|
||||
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
|
||||
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
|
||||
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recentFailed := []operationDeployment{}
|
||||
rows2, err2 := db.Query(
|
||||
`SELECT d.id, d.service_id, s.name, p.name, d.status,
|
||||
COALESCE(d.image_name, ''), d.error, d.started_at, d.completed_at, d.created_at
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE d.status IN ('failed','cancelled','rolled_back')
|
||||
AND d.updated_at > NOW() - INTERVAL '24 hours'
|
||||
AND (`+visibility+`)
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT 20`, userID, isAdmin)
|
||||
if err2 == nil {
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var d operationDeployment
|
||||
var sid string
|
||||
_ = rows2.Scan(&d.ID, &sid, &d.ServiceName, &d.ProjectName, &d.Status, &d.Image, &d.Error, &d.StartedAt, &d.CompletedAt, &d.CreatedAt)
|
||||
d.ServiceID = sid
|
||||
recentFailed = append(recentFailed, d)
|
||||
if rows, err := q.ListRecentFailedDeployments(context.Background(), sqlcdb.ListRecentFailedDeploymentsParams{
|
||||
OwnerID: userUUID, Column2: isAdmin,
|
||||
}); err == nil {
|
||||
for _, r := range rows {
|
||||
recentFailed = append(recentFailed, operationDeployment{
|
||||
ID: r.ID.String(), ServiceID: r.ServiceID.String(), ServiceName: r.ServiceName,
|
||||
ProjectName: r.ProjectName, Status: r.Status.String, Image: r.Image,
|
||||
Error: strPtrNS(r.Error), StartedAt: timePtrNT(r.StartedAt),
|
||||
CompletedAt: timePtrNT(r.CompletedAt), CreatedAt: r.CreatedAt.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cronRuns := []operationCronRun{}
|
||||
rows3, err3 := db.Query(
|
||||
`SELECT e.id, j.name, j.schedule, e.status, e.started_at, e.finished_at, e.error
|
||||
FROM cron_executions e
|
||||
JOIN cron_jobs j ON j.id = e.cron_job_id
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
WHERE e.started_at > NOW() - INTERVAL '24 hours'
|
||||
AND (`+visibility+`)
|
||||
ORDER BY e.started_at DESC
|
||||
LIMIT 30`, userID, isAdmin)
|
||||
if err3 == nil {
|
||||
defer rows3.Close()
|
||||
for rows3.Next() {
|
||||
var r operationCronRun
|
||||
_ = rows3.Scan(&r.ID, &r.JobName, &r.Schedule, &r.Status, &r.StartedAt, &r.FinishedAt, &r.Error)
|
||||
cronRuns = append(cronRuns, r)
|
||||
if rows, err := q.ListRecentCronRuns(context.Background(), sqlcdb.ListRecentCronRunsParams{
|
||||
OwnerID: userUUID, Column2: isAdmin,
|
||||
}); err == nil {
|
||||
for _, r := range rows {
|
||||
cronRuns = append(cronRuns, operationCronRun{
|
||||
ID: r.ID.String(), JobName: r.JobName, Schedule: r.Schedule, Status: r.Status.String,
|
||||
StartedAt: r.StartedAt, FinishedAt: timePtrNT(r.FinishedAt), Error: strPtrNS(r.Error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
backups := []operationBackup{}
|
||||
rows4, err4 := db.Query(
|
||||
`SELECT b.id, b.database_id, ds.name, b.status, b.size, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
JOIN database_services ds ON ds.id = b.database_id
|
||||
WHERE b.created_at > NOW() - INTERVAL '24 hours'
|
||||
AND (ds.user_id = $1 OR $2::bool)
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT 20`, userID, isAdmin)
|
||||
if err4 == nil {
|
||||
defer rows4.Close()
|
||||
for rows4.Next() {
|
||||
var b operationBackup
|
||||
_ = rows4.Scan(&b.ID, &b.DatabaseID, &b.DBName, &b.Status, &b.Size, &b.CreatedAt, &b.Completed)
|
||||
backups = append(backups, b)
|
||||
if rows, err := q.ListRecentOperationBackups(context.Background(), sqlcdb.ListRecentOperationBackupsParams{
|
||||
UserID: userID, Column2: isAdmin,
|
||||
}); err == nil {
|
||||
for _, r := range rows {
|
||||
backups = append(backups, operationBackup{
|
||||
ID: r.ID, DatabaseID: r.DatabaseID, DBName: r.DbName, Status: r.Status,
|
||||
Size: r.Size, CreatedAt: r.CreatedAt.Time, Completed: timePtrNT(r.CompletedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var queue []deployqueue.ServiceQueueState
|
||||
if queueValue, exists := c.Get("deploy_queue"); exists && queueValue != nil {
|
||||
queue = queueValue.(*deployqueue.Queue).Snapshot()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -112,7 +114,8 @@ func handleUpdateProjectVariables(c *gin.Context) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
||||
txq := sqlcdb.New(tx)
|
||||
if err = txq.DeleteProjectVariables(c.Request.Context(), projectID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||
return
|
||||
}
|
||||
@@ -127,11 +130,15 @@ func handleUpdateProjectVariables(c *gin.Context) {
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
if _, err = tx.Exec(
|
||||
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
|
||||
); err != nil {
|
||||
if err = txq.InsertProjectVariable(c.Request.Context(), sqlcdb.InsertProjectVariableParams{
|
||||
ID: uuid.New(),
|
||||
ProjectID: projectID,
|
||||
Key: v.Key,
|
||||
Value: value,
|
||||
IsSecret: v.IsSecret,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||
return
|
||||
}
|
||||
@@ -168,11 +175,10 @@ func handleUpdateProjectVariables(c *gin.Context) {
|
||||
|
||||
// resolveSharedVar returns the decrypted value of a project variable.
|
||||
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
|
||||
projectID, key,
|
||||
).Scan(&value)
|
||||
value, err := sqlcdb.New(db.DB).GetProjectVariableValue(context.Background(), sqlcdb.GetProjectVariableValueParams{
|
||||
ProjectID: projectID,
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
@@ -177,17 +179,17 @@ func handleDeleteRegistry(c *gin.Context) {
|
||||
// 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)
|
||||
ownerUUID, _ := uuid.Parse(ownerID)
|
||||
row, err := sqlcdb.New(db.DB).GetRegistryAuth(context.Background(), sqlcdb.GetRegistryAuthParams{
|
||||
OwnerID: ownerUUID,
|
||||
Host: host,
|
||||
})
|
||||
if err != nil {
|
||||
return registry.AuthConfig{}
|
||||
}
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: secrets.Decrypt(password),
|
||||
Username: row.Username,
|
||||
Password: secrets.Decrypt(row.Password),
|
||||
ServerAddress: host,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"containr/internal/build"
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/docker"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
@@ -83,19 +85,18 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// 127.0.0.1 fallback stays for node-bound checks.
|
||||
docker := dockerClient
|
||||
haManager.WithServiceResolver(func(ctx context.Context, serviceID string) (string, int, error) {
|
||||
var name, projectID string
|
||||
var port int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT name, project_id::text, COALESCE(port, 0) FROM services WHERE id = $1`,
|
||||
serviceID,
|
||||
).Scan(&name, &projectID, &port)
|
||||
sid, err := uuid.Parse(serviceID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(projectID)); err != nil {
|
||||
svc, err := sqlcdb.New(db.DB).GetServiceNameProjectPort(ctx, sid)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return name, port, nil
|
||||
if err := docker.ConnectSelfToNetwork(ctx, deployment.ProjectNetworkName(svc.ProjectID)); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return svc.Name, int(svc.Port), nil
|
||||
})
|
||||
}
|
||||
haAPIManager := NewHAManager(haManager)
|
||||
@@ -137,31 +138,25 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Boot sweep: in-memory engine state dies on restart, so anything that
|
||||
// looked in-flight is actually orphaned — mark it failed rather than
|
||||
// leaving phantom 'building'/'queued' rows.
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
|
||||
)
|
||||
bootQ := sqlcdb.New(db.DB)
|
||||
_ = bootQ.FailInterruptedDeployments(context.Background())
|
||||
|
||||
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||
// encryption-at-rest landed. Transparent and idempotent.
|
||||
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||
type encRow struct {
|
||||
id string
|
||||
value string
|
||||
}
|
||||
var toEncrypt []encRow
|
||||
for encRows.Next() {
|
||||
var r encRow
|
||||
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||
toEncrypt = append(toEncrypt, r)
|
||||
if encRows, err := bootQ.ListSecretEnvVars(context.Background()); err == nil {
|
||||
toEncrypt := 0
|
||||
for _, r := range encRows {
|
||||
if secrets.IsEncrypted(r.Value) {
|
||||
continue
|
||||
}
|
||||
if err := bootQ.UpdateEnvVarValue(context.Background(), sqlcdb.UpdateEnvVarValueParams{
|
||||
Value: secrets.Encrypt(r.Value), ID: r.ID,
|
||||
}); err == nil {
|
||||
toEncrypt++
|
||||
}
|
||||
}
|
||||
encRows.Close()
|
||||
for _, r := range toEncrypt {
|
||||
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
|
||||
}
|
||||
if len(toEncrypt) > 0 {
|
||||
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
|
||||
if toEncrypt > 0 {
|
||||
log.Printf("secrets: encrypted %d plaintext secret variable(s)", toEncrypt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
@@ -70,20 +71,18 @@ type Service struct {
|
||||
// loadServiceSleep fills the sleep-mode columns, which live outside the
|
||||
// wide service SELECTs. Missing columns (pre-migration) degrade to off.
|
||||
func loadServiceSleep(db *database.DB, s *Service) {
|
||||
var idle int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COALESCE(sleep_enabled, false), COALESCE(sleep_idle_minutes, 15) FROM services WHERE id = $1`,
|
||||
s.ID,
|
||||
).Scan(&s.SleepEnabled, &idle); err == nil {
|
||||
s.SleepIdleMinutes = idle
|
||||
row, err := sqlcdb.New(db.DB).GetServiceSleep(context.Background(), s.ID)
|
||||
if err == nil {
|
||||
s.SleepEnabled = row.SleepEnabled
|
||||
s.SleepIdleMinutes = int(row.SleepIdleMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceNodeID reads the pin column directly — kept out of the wide service
|
||||
// SELECTs so pre-migration schemas keep working.
|
||||
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
||||
var id sql.NullString
|
||||
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
|
||||
id, err := sqlcdb.New(db.DB).GetServiceNodeID(context.Background(), serviceID)
|
||||
if err == nil {
|
||||
return id.String
|
||||
}
|
||||
return ""
|
||||
@@ -91,17 +90,14 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
||||
|
||||
// serviceSpread reads the spread column with the same lazy pattern.
|
||||
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
|
||||
var spread bool
|
||||
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
|
||||
return spread
|
||||
}
|
||||
return false
|
||||
spread, err := sqlcdb.New(db.DB).GetServiceSpread(context.Background(), serviceID)
|
||||
return err == nil && spread
|
||||
}
|
||||
|
||||
// servicePlacementTags reads the placement_tags column with the lazy pattern.
|
||||
func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
raw, err := sqlcdb.New(db.DB).GetServicePlacementTags(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
@@ -179,8 +175,8 @@ func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) {
|
||||
|
||||
// serviceTraefikLabels lazy-reads the traefik_labels column.
|
||||
func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
raw, err := sqlcdb.New(db.DB).GetServiceTraefikLabels(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var labels map[string]string
|
||||
@@ -211,7 +207,6 @@ func tagsJSON(tags []string) []byte {
|
||||
// loadServiceNode fills the pin + spread + placement tags + the agent's
|
||||
// display name for responses.
|
||||
func loadServiceNode(db *database.DB, s *Service) {
|
||||
var name sql.NullString
|
||||
s.NodeID = serviceNodeID(db, s.ID)
|
||||
s.Spread = serviceSpread(db, s.ID)
|
||||
s.PlacementTags = servicePlacementTags(db, s.ID)
|
||||
@@ -219,8 +214,8 @@ func loadServiceNode(db *database.DB, s *Service) {
|
||||
if s.NodeID == "" {
|
||||
return
|
||||
}
|
||||
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
|
||||
s.NodeName = name.String
|
||||
if name, err := sqlcdb.New(db.DB).GetNodeAgentName(context.Background(), s.NodeID); err == nil {
|
||||
s.NodeName = name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +230,8 @@ type ServiceVolume struct {
|
||||
// loadServiceVolumes reads the JSONB column; absent/invalid data degrades
|
||||
// to no mounts rather than failing the request.
|
||||
func loadServiceVolumes(db *database.DB, serviceID uuid.UUID) []deployment.VolumeMount {
|
||||
var raw []byte
|
||||
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil {
|
||||
raw, err := sqlcdb.New(db.DB).GetServiceVolumes(context.Background(), serviceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var stored []ServiceVolume
|
||||
@@ -703,13 +698,11 @@ func handleCreateService(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
var environmentID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||
projectID,
|
||||
environment,
|
||||
).Scan(&environmentID)
|
||||
return environmentID, err
|
||||
row, err := sqlcdb.New(db.DB).GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{
|
||||
ProjectID: projectID,
|
||||
Name: environment,
|
||||
})
|
||||
return row.ID, err
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
|
||||
@@ -10,11 +10,13 @@ import (
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Platform setting keys. Values stored in app_settings take precedence over
|
||||
@@ -70,8 +72,7 @@ func handleGetBranding(c *gin.Context) {
|
||||
// then the caller's default.
|
||||
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||
if db != nil && db.DB != nil {
|
||||
var value string
|
||||
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
@@ -88,18 +89,15 @@ func settingValue(db *database.DB, key, envKey, fallback string) string {
|
||||
}
|
||||
|
||||
func setSetting(db *database.DB, key, value string, secret bool) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||
`, key, value, secret)
|
||||
return err
|
||||
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
|
||||
Key: key,
|
||||
Value: value,
|
||||
IsSecret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
func deleteSetting(db *database.DB, key string) error {
|
||||
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||
return err
|
||||
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
|
||||
}
|
||||
|
||||
// signupEnabled controls whether public registration stays open after the
|
||||
@@ -110,11 +108,12 @@ func signupEnabled(db *database.DB) bool {
|
||||
}
|
||||
|
||||
func isAdminUser(db *database.DB, userID string) bool {
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
parsed, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
|
||||
return err == nil && isAdmin
|
||||
}
|
||||
|
||||
func requireAdmin(c *gin.Context) (*database.DB, bool) {
|
||||
@@ -242,8 +241,7 @@ func handleUpdateSettings(c *gin.Context) {
|
||||
// resolvedTunnelToken prefers the in-app value and falls back to the env.
|
||||
func resolvedTunnelToken(db *database.DB) (string, string) {
|
||||
if db != nil && db.DB != nil {
|
||||
var value string
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" {
|
||||
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value), "app"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ package api
|
||||
// lives in app_settings so it survives restarts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -25,8 +27,7 @@ func setupComplete(db *database.DB, hasUsers bool) bool {
|
||||
if !hasUsers {
|
||||
return false
|
||||
}
|
||||
var projects int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 {
|
||||
if projects, err := sqlcdb.New(db.DB).CountProjects(context.Background()); err == nil && projects > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -640,19 +640,20 @@ func deployTemplateGraph(
|
||||
createdServiceIDs := []uuid.UUID{}
|
||||
createdDatabaseIDs := []string{}
|
||||
cleanup := func() {
|
||||
q := sqlcdb.New(db.DB)
|
||||
for _, sid := range createdServiceIDs {
|
||||
if engine != nil {
|
||||
removeServiceRuntime(context.Background(), db, engine, sid)
|
||||
}
|
||||
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
|
||||
_ = q.DeleteEnvVarsForService(context.Background(), sid)
|
||||
_ = q.DeleteServiceByID(context.Background(), sid)
|
||||
}
|
||||
for _, did := range createdDatabaseIDs {
|
||||
if dbHandler != nil && dbHandler.dockerClient != nil {
|
||||
_ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true)
|
||||
_ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true)
|
||||
}
|
||||
_, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did)
|
||||
_ = q.DeleteDatabaseServiceByID(context.Background(), did)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,14 +773,19 @@ func deployTemplateGraph(
|
||||
if builder == "" {
|
||||
builder = "auto"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
`UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb),
|
||||
git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6
|
||||
WHERE id = $7`,
|
||||
m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON,
|
||||
strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder,
|
||||
serviceID,
|
||||
); err != nil {
|
||||
var volumesRaw json.RawMessage
|
||||
if s, ok := volumesJSON.(string); ok {
|
||||
volumesRaw = json.RawMessage(s)
|
||||
}
|
||||
if err = sqlcdb.New(tx).UpdateServiceDeployMeta(ctx, sqlcdb.UpdateServiceDeployMetaParams{
|
||||
Port: int32(m.spec.Port),
|
||||
HealthcheckPath: strings.TrimSpace(m.spec.HealthCheck),
|
||||
Column3: volumesRaw,
|
||||
Column4: strings.TrimSpace(m.spec.Repo),
|
||||
Column5: strings.TrimSpace(m.spec.Branch),
|
||||
Builder: builder,
|
||||
ID: serviceID,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
cleanup()
|
||||
return nil, &gin.H{"error": "Failed to configure service"}
|
||||
@@ -861,15 +867,24 @@ func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`,
|
||||
dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt,
|
||||
); err != nil {
|
||||
q := sqlcdb.New(db.DB)
|
||||
if err := q.InsertDeployment(context.Background(), sqlcdb.InsertDeploymentParams{
|
||||
ID: dep.ID,
|
||||
ServiceID: dep.ServiceID,
|
||||
Version: fmt.Sprintf("v%d", now.Unix()),
|
||||
Status: sql.NullString{String: dep.Status, Valid: true},
|
||||
ImageName: sql.NullString{String: "", Valid: true},
|
||||
ImageTag: sql.NullString{String: "", Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: dep.CreatedAt, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: dep.UpdatedAt, Valid: true},
|
||||
}); err != nil {
|
||||
return uuid.Nil
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
_ = q.SetServiceStatus(context.Background(), sqlcdb.SetServiceStatusParams{
|
||||
Status: sql.NullString{String: "queued", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: service.ID,
|
||||
})
|
||||
|
||||
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
||||
DeploymentID: dep.ID,
|
||||
|
||||
@@ -411,12 +411,11 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// sqlc's schema predates the runtime columns; port and healthcheck_path are
|
||||
// written via raw SQL like every other service mutation path.
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
`UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`,
|
||||
config.Port, strings.TrimSpace(config.HealthCheck), serviceID,
|
||||
); err != nil {
|
||||
if err = sqlcdb.New(tx).UpdateServiceRuntimeMeta(ctx, sqlcdb.UpdateServiceRuntimeMetaParams{
|
||||
Port: int32(config.Port),
|
||||
HealthcheckPath: strings.TrimSpace(config.HealthCheck),
|
||||
ID: serviceID,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/secrets"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -147,7 +150,8 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
|
||||
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
|
||||
@@ -164,12 +168,15 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
varID := uuid.New()
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
varID, serviceID, v.Key, value, v.IsSecret, now, now,
|
||||
)
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user