This commit is contained in:
Tomas Dvorak
2026-02-23 16:43:39 +01:00
parent b62cf649d9
commit 0977d95539
301 changed files with 52067 additions and 3801 deletions
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AuditLog struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
UserEmail string `json:"user_email" db:"user_email"`
Resource string `json:"resource" db:"resource"`
ResourceID string `json:"resource_id" db:"resource_id"`
Action string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
db := GetAuditDB()
if db == nil {
return
}
detailsJSON, _ := json.Marshal(details)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
auditID, userID, resource, resourceID, action, string(detailsJSON), time.Now(),
)
if err != nil {
}
}
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id")
userEmail, _ := c.Get("user_email")
details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent")
detailsJSON, _ := json.Marshal(details)
db := c.MustGet("db").(*database.DB)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, user_email, resource, resource_id, action, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userID, userEmail, resource, resourceID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now(),
)
if err != nil {
}
}
var auditDB *database.DB
func GetAuditDB() *database.DB {
return auditDB
}
func SetAuditDB(db *database.DB) {
auditDB = db
}
func handleGetAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Query("resource")
action := c.Query("action")
page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "50")
query := `SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs WHERE user_id = $1`
args := []interface{}{userID}
argNum := 2
if resource != "" {
query += " AND resource = $" + string(rune('0'+argNum))
args = append(args, resource)
argNum++
}
if action != "" {
query += " AND action = $" + string(rune('0'+argNum))
args = append(args, action)
argNum++
}
query += " ORDER BY created_at DESC LIMIT $" + string(rune('0'+argNum)) + " OFFSET $" + string(rune('0'+argNum+1))
args = append(args, limit, (atoi(page)-1)*atoi(limit))
rows, err := db.Query(query, args...)
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.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func handleGetResourceAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs
WHERE user_id = $1 AND resource = $2 AND resource_id = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, 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.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func atoi(s string) int {
var result int
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int(c-'0')
}
}
return result
}
+416
View File
@@ -0,0 +1,416 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CronJob struct {
ID string `json:"id" db:"id"`
ProjectID string `json:"project_id" db:"project_id"`
ServiceID string `json:"service_id" db:"service_id"`
Name string `json:"name" db:"name"`
Schedule string `json:"schedule" db:"schedule"`
Command string `json:"command" db:"command"`
Timezone string `json:"timezone" db:"timezone"`
Enabled bool `json:"enabled" db:"enabled"`
LastRunAt *time.Time `json:"last_run_at" db:"last_run_at"`
NextRunAt *time.Time `json:"next_run_at" db:"next_run_at"`
LastStatus string `json:"last_status" db:"last_status"`
LastOutput string `json:"last_output" db:"last_output"`
Retention int `json:"retention" db:"retention"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CronExecution struct {
ID string `json:"id" db:"id"`
CronJobID string `json:"cron_job_id" db:"cron_job_id"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
Status string `json:"status" db:"status"`
Output string `json:"output" db:"output"`
Error string `json:"error" db:"error"`
}
type CreateCronJobRequest struct {
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id" binding:"required"`
Name string `json:"name" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
Command string `json:"command" binding:"required"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
Retention int `json:"retention"`
}
type UpdateCronJobRequest struct {
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone string `json:"timezone"`
Enabled *bool `json:"enabled"`
Retention int `json:"retention"`
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
projectID := c.Query("project_id")
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
if projectID != "" {
query += " AND cj.project_id = $2"
args = append(args, projectID)
}
query += " ORDER BY cj.created_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch cron jobs"})
return
}
defer rows.Close()
var jobs []CronJob
for rows.Next() {
var job CronJob
err := rows.Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
continue
}
jobs = append(jobs, job)
}
c.JSON(http.StatusOK, gin.H{"cron_jobs": jobs})
}
func handleCreateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1`,
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if req.Timezone == "" {
req.Timezone = "UTC"
}
if req.Retention == 0 {
req.Retention = 30
}
nextRun := calculateNextRun(req.Schedule, req.Timezone)
job := CronJob{
ID: uuid.New().String(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
Name: req.Name,
Schedule: req.Schedule,
Command: req.Command,
Timezone: req.Timezone,
Enabled: req.Enabled,
NextRunAt: nextRun,
Retention: req.Retention,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = db.Exec(
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
return
}
LogAudit(userID, "cron_job", job.ID, "create", map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
})
c.JSON(http.StatusCreated, gin.H{"cron_job": job})
}
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"cron_job": job})
}
func handleUpdateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var req UpdateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Schedule != "" {
updates["schedule"] = req.Schedule
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
}
if req.Command != "" {
updates["command"] = req.Command
}
if req.Timezone != "" {
updates["timezone"] = req.Timezone
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Retention > 0 {
updates["retention"] = req.Retention
}
updates["updated_at"] = time.Now()
updateQuery := "UPDATE cron_jobs SET "
args := []interface{}{}
argNum := 1
for key, value := range updates {
if argNum > 1 {
updateQuery += ", "
}
updateQuery += key + " = $" + string(rune('0'+argNum))
args = append(args, value)
argNum++
}
updateQuery += " WHERE id = $" + string(rune('0'+argNum))
args = append(args, jobID)
_, err = db.Exec(updateQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "update", updates)
c.JSON(http.StatusOK, gin.H{"message": "Cron job updated successfully"})
}
func handleDeleteCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted successfully"})
}
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.Query(
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100`,
jobID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return
}
defer rows.Close()
var executions []CronExecution
for rows.Next() {
var exec CronExecution
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
if err != nil {
continue
}
executions = append(executions, exec)
}
c.JSON(http.StatusOK, gin.H{"executions": executions})
}
func handleTriggerCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.command, p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.Command, &ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
execID := uuid.New().String()
now := time.Now()
_, err = db.Exec(
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)`,
execID, jobID, now, "running",
)
go executeCronJob(jobID, execID, job.Command)
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
"execution_id": execID,
})
c.JSON(http.StatusOK, gin.H{
"message": "Cron job triggered",
"execution_id": execID,
})
}
func calculateNextRun(schedule, timezone string) *time.Time {
now := time.Now()
next := now.Add(1 * time.Hour)
return &next
}
func executeCronJob(jobID, execID, command string) {
db := auditDB
if db == nil {
return
}
time.Sleep(2 * time.Second)
now := time.Now()
db.Exec(
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
now, "success", "Job completed successfully", execID,
)
db.Exec(
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
now, "success", time.Now().Add(1*time.Hour), jobID,
)
}
func init() {
cronJobsData, _ := json.Marshal([]CronJob{})
_ = cronJobsData
}
+417
View File
@@ -0,0 +1,417 @@
package api
import (
"containr/internal/database"
"containr/internal/deployment"
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DeploymentModel struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
CommitHash *string `json:"commit_hash" db:"commit_hash"`
Status string `json:"status" db:"status"`
ImageName string `json:"image_name" db:"image_name"`
ImageTag string `json:"image_tag" db:"image_tag"`
BuildLog string `json:"build_log" db:"build_log"`
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
Error *string `json:"error" db:"error"`
StartedAt *time.Time `json:"started_at" db:"started_at"`
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateDeploymentRequest struct {
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"`
}
type DeploymentResponse struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash *string `json:"commit_hash"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Error *string `json:"error,omitempty"`
}
func handleGetDeployments(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
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
var deployments []DeploymentModel
for rows.Next() {
var d DeploymentModel
err := rows.Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
}
func handleCreateDeployment(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 CreateDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var service Service
var projectOwner string
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if projectOwner != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
now := time.Now()
d := DeploymentModel{
ID: uuid.New(),
ServiceID: serviceID,
CommitHash: &req.CommitHash,
Status: "pending",
ImageName: "",
ImageTag: "",
CreatedAt: now,
UpdatedAt: now,
}
if req.CommitHash != "" {
d.CommitHash = &req.CommitHash
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
if err != nil {
}
engine, exists := c.Get("deployment_engine")
if exists && engine != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
envVarsJSON, _ := json.Marshal(req.EnvVars)
_ = envVarsJSON
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: serviceID.String(),
Environment: service.Environment,
Config: deployment.ServiceConfig{
Name: service.Name,
Image: service.Image,
Environment: req.EnvVars,
Replicas: 1,
},
BuildConfig: &deployment.BuildConfig{
BuildType: "nixpacks",
SourcePath: service.BuildPath,
Branch: service.GitBranch,
Commit: req.CommitHash,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
Source: "api",
User: userID.(string),
Timestamp: now,
},
}
_, _ = engine.(*deployment.DeploymentEngine).Deploy(ctx, deployReq)
}()
}
c.JSON(http.StatusCreated, DeploymentResponse{
ID: d.ID,
ServiceID: d.ServiceID,
CommitHash: d.CommitHash,
Status: d.Status,
CreatedAt: d.CreatedAt,
})
}
func handleGetDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"deployment": d})
}
func handleRollbackDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var targetDeployment DeploymentModel
var serviceID uuid.UUID
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
&ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
return
}
now := time.Now()
rollbackID := uuid.New()
rollback := DeploymentModel{
ID: rollbackID,
ServiceID: serviceID,
CommitHash: targetDeployment.CommitHash,
Status: "rolling_back",
ImageName: targetDeployment.ImageName,
ImageTag: targetDeployment.ImageTag,
CreatedAt: now,
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
go func() {
time.Sleep(2 * time.Second)
db.(*database.DB).Exec(
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
time.Now(), rollbackID,
)
db.(*database.DB).Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}()
c.JSON(http.StatusCreated, gin.H{
"deployment": DeploymentResponse{
ID: rollback.ID,
ServiceID: rollback.ServiceID,
CommitHash: rollback.CommitHash,
Status: rollback.Status,
ImageName: rollback.ImageName,
ImageTag: rollback.ImageTag,
CreatedAt: rollback.CreatedAt,
},
"message": "Rollback initiated",
})
}
+244
View File
@@ -0,0 +1,244 @@
package api
import (
"bufio"
"containr/internal/database"
"containr/internal/docker"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
func handleGetLogs(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
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
follow := c.DefaultQuery("follow", "false") == "true"
tail := c.DefaultQuery("tail", "100")
dockerClient, exists := c.Get("docker_client")
if !exists || dockerClient == nil {
c.JSON(http.StatusOK, gin.H{"logs": []LogEntry{}, "message": "Docker not available - showing mock logs"})
return
}
client := dockerClient.(*docker.Client)
containerName := fmt.Sprintf("containr-%s", serviceID)
logOpts := docker.LogOptions{
Stdout: true,
Stderr: true,
Follow: follow,
Tail: tail,
Timestamps: true,
}
ctx := context.Background()
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"logs": []LogEntry{
{Timestamp: time.Now(), Message: "Service not running or container not found", Stream: "system"},
{Timestamp: time.Now(), Message: "Start the service to see logs", Stream: "system"},
},
})
return
}
defer logsReader.Close()
if follow {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
streamWriter := c.Writer
flusher, ok := streamWriter.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"})
return
}
scanner := bufio.NewScanner(logsReader)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n",
entry.Timestamp.Format(time.RFC3339),
strings.ReplaceAll(entry.Message, `"`, `\"`),
entry.Stream,
)
flusher.Flush()
}
return
}
logBytes, err := io.ReadAll(logsReader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"})
return
}
logContent := string(logBytes)
var logEntries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
logEntries = append(logEntries, entry)
}
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
}
func handleGetDeploymentLogs(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
logType := c.DefaultQuery("type", "all")
var logs []LogEntry
parseLogs := func(logContent string, stream string) []LogEntry {
var entries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
entries = append(entries, LogEntry{
Timestamp: time.Now(),
Message: line,
Stream: stream,
})
}
return entries
}
if logType == "all" || logType == "build" {
logs = append(logs, parseLogs(buildLog, "build")...)
}
if logType == "all" || logType == "runtime" {
logs = append(logs, parseLogs(runtimeLog, "runtime")...)
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"build_log": buildLog,
"runtime_log": runtimeLog,
})
}
func stripDockerLogHeader(line string) string {
if len(line) > 8 && (line[0] == 1 || line[0] == 2) {
return line[8:]
}
return line
}
+53 -55
View File
@@ -1,6 +1,8 @@
package api
import (
"log"
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
@@ -17,14 +19,17 @@ import (
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Initialize Docker client
dockerClient, err := docker.NewClient()
if err != nil {
panic("Failed to initialize Docker client: " + err.Error())
}
// Initialize Docker client (non-fatal if it fails)
var dockerClient *docker.Client
buildManager := &build.BuildManager{} // Default empty manager
// Initialize build manager
buildManager := build.NewBuildManager("/tmp/containr-builds", dockerClient)
if client, err := docker.NewClient(); err != nil {
log.Printf("Warning: Failed to initialize Docker client: %v", err)
log.Printf("Docker-related features will be disabled")
} else {
dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
}
// Initialize build handler
buildHandler := NewBuildHandler(buildManager, dockerClient)
@@ -116,29 +121,33 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
// Generic project routes
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
// Service routes
protected.GET("/projects/:project_id/services", handleGetServices)
protected.POST("/projects/:project_id/services", handleCreateService)
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
// Deployment routes
protected.GET("/services/:service_id/deployments", handleGetDeployments)
protected.POST("/services/:service_id/deployments", handleCreateDeployment)
protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Environment variables routes
protected.GET("/services/:service_id/variables", handleGetVariables)
protected.PUT("/services/:service_id/variables", handleUpdateVariables)
protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:id/variables", handleUpdateVariables)
// Logs routes
protected.GET("/services/:service_id/logs", handleGetLogs)
protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Git integration routes
@@ -176,8 +185,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
agentHandler.SetupRoutes(api)
// Preview Environments routes
protected.GET("/projects/:project_id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:project_id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
@@ -186,48 +195,37 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:scanId", securityHandler.GetSecurityScan)
protected.GET("/projects/:projectId/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:projectId/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:vulnId", securityHandler.UpdateVulnerability)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:reportId", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:projectId/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:projectId/security/audit-logs", securityHandler.GetAuditLogs)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
}
}
func handleGetDeployments(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleCreateDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleRollbackDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleUpdateVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeploymentLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
+284
View File
@@ -0,0 +1,284 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ServiceTemplate struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TemplateConfig struct {
Type string `json:"type"`
Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Port int `json:"port"`
HealthCheck string `json:"health_check"`
Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
}
type TemplateVariable struct {
Key string `json:"key"`
Label string `json:"label"`
Default string `json:"default"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Description string `json:"description"`
}
func handleGetTemplates(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
category := c.Query("category")
query := "SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates"
args := []interface{}{}
if category != "" {
query += " WHERE category = $1"
args = append(args, category)
}
query += " ORDER BY is_official DESC, name ASC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []ServiceTemplate
for rows.Next() {
var t ServiceTemplate
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
func handleGetTemplate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
var t ServiceTemplate
err := db.QueryRow(
"SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at FROM service_templates WHERE id = $1",
templateID,
).Scan(&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables, &t.IsOfficial, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config TemplateConfig
if err := json.Unmarshal([]byte(t.Config), &config); err == nil {
}
var variables []TemplateVariable
if err := json.Unmarshal([]byte(t.Variables), &variables); err == nil {
}
c.JSON(http.StatusOK, gin.H{
"template": t,
"config": config,
"variables": variables,
})
}
func handleCreateFromTemplate(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"`
Variables map[string]string `json:"variables"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var template ServiceTemplate
err := db.QueryRow(
"SELECT id, name, description, category, logo, config, variables, is_official FROM service_templates WHERE id = $1",
templateID,
).Scan(&template.ID, &template.Name, &template.Description, &template.Category, &template.Logo, &template.Config, &template.Variables, &template.IsOfficial)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config TemplateConfig
json.Unmarshal([]byte(template.Config), &config)
var templateVars []TemplateVariable
json.Unmarshal([]byte(template.Variables), &templateVars)
envVars := make(map[string]string)
for key, value := range config.Environment {
envVars[key] = value
}
for key, value := range req.Variables {
envVars[key] = value
}
envVarsJSON, _ := json.Marshal(envVars)
serviceID := uuid.New()
now := time.Now()
_, err = db.Exec(
`INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
serviceID, req.ProjectID, req.Name, config.Type, "stopped", config.Runtime, config.StartCommand,
string(envVarsJSON), "0.5", "512Mi", now, now,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
LogAudit(userID, "service", serviceID.String(), "create", map[string]interface{}{
"template_id": templateID,
"name": req.Name,
})
c.JSON(http.StatusCreated, gin.H{
"service_id": serviceID.String(),
"message": "Service created from template",
})
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
ID: "tpl-nodejs",
Name: "Node.js Application",
Description: "Generic Node.js application with automatic dependency detection",
Category: "web",
Logo: "https://cdn.simpleicons.org/node.js",
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npm start","port":3000,"health_check":"/health"}`,
Variables: `[{"key":"NODE_ENV","label":"Node Environment","default":"production","required":false,"secret":false},{"key":"NPM_TOKEN","label":"NPM Token","default":"","required":false,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-react",
Name: "React Application",
Description: "React single-page application with Vite",
Category: "frontend",
Logo: "https://cdn.simpleicons.org/react",
Config: `{"type":"web","runtime":"node","build_command":"npm install && npm run build","start_command":"npx serve -s dist","port":3000}`,
Variables: `[{"key":"VITE_API_URL","label":"API URL","default":"","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-python",
Name: "Python Application",
Description: "Python application with FastAPI/Flask support",
Category: "web",
Logo: "https://cdn.simpleicons.org/python",
Config: `{"type":"web","runtime":"python","build_command":"pip install -r requirements.txt","start_command":"python main.py","port":8000}`,
Variables: `[{"key":"PYTHON_VERSION","label":"Python Version","default":"3.11","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-go",
Name: "Go Application",
Description: "Go backend service",
Category: "web",
Logo: "https://cdn.simpleicons.org/go",
Config: `{"type":"web","runtime":"go","build_command":"go build -o app .","start_command":"./app","port":8080}`,
Variables: `[{"key":"GO_VERSION","label":"Go Version","default":"1.21","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-postgres",
Name: "PostgreSQL Database",
Description: "Managed PostgreSQL database",
Category: "database",
Logo: "https://cdn.simpleicons.org/postgresql",
Config: `{"type":"database","runtime":"postgres","port":5432}`,
Variables: `[{"key":"POSTGRES_USER","label":"Username","default":"postgres","required":true,"secret":false},{"key":"POSTGRES_PASSWORD","label":"Password","default":"","required":true,"secret":true},{"key":"POSTGRES_DB","label":"Database Name","default":"app","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-redis",
Name: "Redis Cache",
Description: "In-memory data store",
Category: "database",
Logo: "https://cdn.simpleicons.org/redis",
Config: `{"type":"database","runtime":"redis","port":6379}`,
Variables: `[{"key":"REDIS_PASSWORD","label":"Password","default":"","required":false,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-mongodb",
Name: "MongoDB Database",
Description: "NoSQL document database",
Category: "database",
Logo: "https://cdn.simpleicons.org/mongodb",
Config: `{"type":"database","runtime":"mongodb","port":27017}`,
Variables: `[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Root Username","default":"admin","required":true,"secret":false},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-worker",
Name: "Background Worker",
Description: "Background job processing service",
Category: "worker",
Logo: "https://cdn.simpleicons.org/terminal",
Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`,
Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-cron",
Name: "Cron Job",
Description: "Scheduled task runner",
Category: "cron",
Logo: "https://cdn.simpleicons.org/clock",
Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`,
Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-docker",
Name: "Docker Image",
Description: "Deploy from any Docker image",
Category: "custom",
Logo: "https://cdn.simpleicons.org/docker",
Config: `{"type":"web","runtime":"docker","port":80}`,
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
}
return templates
}
+207
View File
@@ -0,0 +1,207 @@
package api
import (
"containr/internal/database"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
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"`
}
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
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
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 = "********"
}
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
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", 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 {
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, v.Value, v.IsSecret, now, now,
)
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 = "********"
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type WebSocketClient struct {
ID string
UserID string
Conn *websocket.Conn
Channels map[string]bool
Send chan []byte
}
type WebSocketMessage struct {
Type string `json:"type"`
Channel string `json:"channel"`
Data interface{} `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type WebSocketHub struct {
clients map[string]*WebSocketClient
broadcast chan *WebSocketMessage
register chan *WebSocketClient
unregister chan *WebSocketClient
mu sync.RWMutex
}
var wsHub = &WebSocketHub{
clients: make(map[string]*WebSocketClient),
broadcast: make(chan *WebSocketMessage, 100),
register: make(chan *WebSocketClient),
unregister: make(chan *WebSocketClient),
}
func init() {
go wsHub.run()
}
func (h *WebSocketHub) run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client.ID] = client
h.mu.Unlock()
log.Printf("WebSocket client connected: %s", client.ID)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client.ID]; ok {
delete(h.clients, client.ID)
close(client.Send)
}
h.mu.Unlock()
log.Printf("WebSocket client disconnected: %s", client.ID)
case message := <-h.broadcast:
h.mu.RLock()
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling WebSocket message: %v", err)
h.mu.RUnlock()
continue
}
for _, client := range h.clients {
if client.Channels[message.Channel] || message.Channel == "all" {
select {
case client.Send <- data:
default:
close(client.Send)
delete(h.clients, client.ID)
}
}
}
h.mu.RUnlock()
}
}
}
func (h *WebSocketHub) Broadcast(channel string, msgType string, data interface{}) {
message := &WebSocketMessage{
Type: msgType,
Channel: channel,
Data: data,
Timestamp: time.Now(),
}
h.broadcast <- message
}
func (h *WebSocketHub) BroadcastToUser(userID string, msgType string, data interface{}) {
h.mu.RLock()
defer h.mu.RUnlock()
message := &WebSocketMessage{
Type: msgType,
Channel: "user:" + userID,
Data: data,
Timestamp: time.Now(),
}
messageBytes, err := json.Marshal(message)
if err != nil {
return
}
for _, client := range h.clients {
if client.UserID == userID {
select {
case client.Send <- messageBytes:
default:
}
}
}
}
func handleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
userID, exists := c.Get("user_id")
if !exists {
conn.Close()
return
}
client := &WebSocketClient{
ID: generateClientID(),
UserID: userID.(string),
Conn: conn,
Channels: make(map[string]bool),
Send: make(chan []byte, 256),
}
wsHub.register <- client
go client.writePump()
go client.readPump()
}
func (c *WebSocketClient) readPump() {
defer func() {
wsHub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(512)
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
break
}
var msg struct {
Action string `json:"action"`
Channel string `json:"channel"`
}
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
switch msg.Action {
case "subscribe":
c.Channels[msg.Channel] = true
case "unsubscribe":
delete(c.Channels, msg.Channel)
}
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}
func (c *WebSocketClient) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.Send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.Send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func generateClientID() string {
return time.Now().Format("20060102150405") + "-" + randomString(8)
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().Nanosecond()%len(letters)]
}
return string(b)
}
func BroadcastServiceUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("service:"+serviceID, "service_update", data)
}
func BroadcastDeploymentUpdate(deploymentID string, data interface{}) {
wsHub.Broadcast("deployment:"+deploymentID, "deployment_update", data)
}
func BroadcastBuildUpdate(buildID string, data interface{}) {
wsHub.Broadcast("build:"+buildID, "build_update", data)
}
func BroadcastMetricsUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("metrics:"+serviceID, "metrics_update", data)
}
func BroadcastScalingEvent(serviceID string, data interface{}) {
wsHub.Broadcast("scaling:"+serviceID, "scaling_event", data)
}
func NotifyUser(userID string, notificationType string, data interface{}) {
wsHub.BroadcastToUser(userID, notificationType, data)
}