mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 08:04:53 +00:00
Compare commits
21
Commits
e995c98d05
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4734be0d9d | ||
|
|
4e8d364911 | ||
|
|
f53695be4f | ||
|
|
57f8b4f56e | ||
|
|
58424ed0ab | ||
|
|
25fa8c47e6 | ||
|
|
b3da014d26 | ||
|
|
2049087ccd | ||
|
|
01edb677e5 | ||
|
|
b22ac401c8 | ||
|
|
ea96929435 | ||
|
|
971773f80c | ||
|
|
7e99e41f8d | ||
|
|
6c5ff7be41 | ||
|
|
7b93506e2d | ||
|
|
f34fba165c | ||
|
|
6949c01464 | ||
|
|
f22ae22375 | ||
|
|
a4b6ea4bd2 | ||
|
|
3664eaed1a | ||
|
|
912f6f5142 |
No files matched your search
@@ -17,6 +17,27 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
|
||||||
|
# required checks still satisfy branch protection ("skipped" = success).
|
||||||
|
changes:
|
||||||
|
name: Changed paths
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
code: ${{ steps.filter.outputs.code }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
code:
|
||||||
|
- 'app/**'
|
||||||
|
- 'infra/**'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'install.sh'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
- 'docs/api/**'
|
||||||
|
|
||||||
infra:
|
infra:
|
||||||
name: Infra (Compose + Scripts)
|
name: Infra (Compose + Scripts)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -48,6 +69,8 @@ jobs:
|
|||||||
backend:
|
backend:
|
||||||
name: Backend (Go)
|
name: Backend (Go)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: app/backend
|
working-directory: app/backend
|
||||||
@@ -70,6 +93,8 @@ jobs:
|
|||||||
frontend:
|
frontend:
|
||||||
name: Frontend (React)
|
name: Frontend (React)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: app/frontend
|
working-directory: app/frontend
|
||||||
@@ -100,6 +125,8 @@ jobs:
|
|||||||
name: Compose boot smoke
|
name: Compose boot smoke
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -137,6 +164,7 @@ jobs:
|
|||||||
name: Build & publish images
|
name: Build & publish images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [backend, frontend, smoke]
|
needs: [backend, frontend, smoke]
|
||||||
|
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -227,7 +255,7 @@ jobs:
|
|||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"containr/internal/deployqueue"
|
"containr/internal/deployqueue"
|
||||||
|
"containr/internal/docker"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -232,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -242,7 +247,9 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -401,6 +408,13 @@ func runDeploymentAndSyncWithImage(
|
|||||||
// ephemeral ports.
|
// ephemeral ports.
|
||||||
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
|
||||||
|
|
||||||
|
publicPort := int32(service.Port)
|
||||||
|
if service.Builder == "static" && publicPort == 0 {
|
||||||
|
// The static builder's image is nginx — route to its default port
|
||||||
|
// unless the user picked another.
|
||||||
|
publicPort = 80
|
||||||
|
}
|
||||||
|
|
||||||
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
||||||
maintURL := ""
|
maintURL := ""
|
||||||
if maintenanceMode {
|
if maintenanceMode {
|
||||||
@@ -417,7 +431,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Command: command,
|
Command: command,
|
||||||
Environment: env,
|
Environment: env,
|
||||||
Replicas: replicas,
|
Replicas: replicas,
|
||||||
PublicPort: int32(service.Port),
|
PublicPort: publicPort,
|
||||||
PublishedPort: publishedPort,
|
PublishedPort: publishedPort,
|
||||||
Domain: service.Domain,
|
Domain: service.Domain,
|
||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
@@ -426,7 +440,9 @@ func runDeploymentAndSyncWithImage(
|
|||||||
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
||||||
Resources: deployment.ResourceLimits{
|
Resources: deployment.ResourceLimits{
|
||||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||||
|
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
|
||||||
CPUQuota: parseCPULimit(service.CPU),
|
CPUQuota: parseCPULimit(service.CPU),
|
||||||
|
CPUShares: parseCPUReserveShares(service.CPUReserve),
|
||||||
},
|
},
|
||||||
Maintenance: maintenanceMode,
|
Maintenance: maintenanceMode,
|
||||||
MaintenanceURL: maintURL,
|
MaintenanceURL: maintURL,
|
||||||
@@ -440,25 +456,50 @@ func runDeploymentAndSyncWithImage(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if imageOverride != "" {
|
if imageOverride != "" || service.GitRepo == "" {
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
// Prebuilt image deploy (rollback, redeploy, or image-sourced
|
||||||
BuildType: "prebuilt",
|
// service): resolve private-registry credentials by image host.
|
||||||
PrebuiltImage: imageOverride,
|
image := imageOverride
|
||||||
|
if image == "" {
|
||||||
|
image = service.Image
|
||||||
}
|
}
|
||||||
} else if service.GitRepo == "" {
|
var ownerID string
|
||||||
// Image-sourced service: pull the image instead of building.
|
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
|
||||||
|
auth := registryAuthFor(db, ownerID, image)
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
BuildType: "prebuilt",
|
BuildType: "prebuilt",
|
||||||
PrebuiltImage: service.Image,
|
PrebuiltImage: image,
|
||||||
|
PullUsername: auth.Username,
|
||||||
|
PullPassword: auth.Password,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// "auto" leaves BuildType empty so the manager detects it; the static
|
||||||
|
// builder additionally feeds its generated Dockerfile the service's
|
||||||
|
// build command and output dir.
|
||||||
|
buildType := ""
|
||||||
|
if service.Builder != "" && service.Builder != "auto" {
|
||||||
|
buildType = service.Builder
|
||||||
|
}
|
||||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||||
BuildType: "nixpacks",
|
BuildType: buildType,
|
||||||
SourcePath: sourcePath,
|
SourcePath: sourcePath,
|
||||||
Branch: req.Branch,
|
Branch: req.Branch,
|
||||||
Commit: req.CommitHash,
|
Commit: req.CommitHash,
|
||||||
NoCache: req.NoCache,
|
NoCache: req.NoCache,
|
||||||
}
|
}
|
||||||
|
if buildType == "static" {
|
||||||
|
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
||||||
|
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
|
||||||
|
failedAt := time.Now()
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
|
msg, failedAt, dbDeployment.ID,
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||||
@@ -822,6 +863,9 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s WHERE s.id = $1`, serviceID,
|
FROM services s WHERE s.id = $1`, serviceID,
|
||||||
).Scan(
|
).Scan(
|
||||||
@@ -829,7 +873,9 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
rollbackReq := CreateDeploymentRequest{
|
rollbackReq := CreateDeploymentRequest{
|
||||||
@@ -863,3 +909,35 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
"message": "Rollback initiated",
|
"message": "Rollback initiated",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capacityCheck compares the requested per-replica memory (limit, falling
|
||||||
|
// back to reservation) against node capacity. app_settings.capacity_policy
|
||||||
|
// = "block" fails the deployment; the default "warn" only logs. CPU is not
|
||||||
|
// gated — shares are a weight, not a reservation.
|
||||||
|
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
|
||||||
|
if client == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
requested := parseMemoryLimit(service.Memory)
|
||||||
|
if requested == 0 {
|
||||||
|
requested = parseMemoryLimit(service.MemoryReserve)
|
||||||
|
}
|
||||||
|
if requested == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
info, err := client.GetSystemInfo(ctx)
|
||||||
|
if err != nil || info.MemTotal <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
need := requested * int64(replicas)
|
||||||
|
if need <= info.MemTotal {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
|
||||||
|
requested, replicas, info.MemTotal)
|
||||||
|
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project-level shared variables — referenced from service env values as
|
||||||
|
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
|
||||||
|
|
||||||
|
type ProjectVariable struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id" db:"project_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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetProjectVariables(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
|
||||||
|
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
variables := []ProjectVariable{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v ProjectVariable
|
||||||
|
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
v.Value = maskedSecretValue
|
||||||
|
}
|
||||||
|
variables = append(variables, v)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"variables": variables})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateProjectVariables(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateVariablesRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storedSecrets := map[string]string{}
|
||||||
|
secretRows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for secretRows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := secretRows.Scan(&k, &v); err == nil {
|
||||||
|
storedSecrets[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secretRows.Close()
|
||||||
|
|
||||||
|
tx, err := db.(*database.DB).Begin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, v := range req.Variables {
|
||||||
|
value := v.Value
|
||||||
|
if v.IsSecret && value == maskedSecretValue {
|
||||||
|
if stored, ok := storedSecrets[v.Key]; ok {
|
||||||
|
value = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
value = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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, project_id, key, value, is_secret, created_at, updated_at
|
||||||
|
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
variables := []ProjectVariable{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v ProjectVariable
|
||||||
|
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
v.Value = maskedSecretValue
|
||||||
|
}
|
||||||
|
variables = append(variables, v)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return secrets.Decrypt(value), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry is a stored credential for pulling private images. Password is
|
||||||
|
// encrypted at rest and never returned — `has_password` signals presence.
|
||||||
|
type Registry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
|
||||||
|
Username string `json:"username"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
|
Host string `json:"host" binding:"required,min=1,max=255"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListRegistries(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
rows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT id, name, host, username, password <> '', created_at, updated_at
|
||||||
|
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||||
|
userID.(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
registries := []Registry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r Registry
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
|
||||||
|
registries = append(registries, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"registries": registries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req registryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||||
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||||
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||||
|
|
||||||
|
// Encrypt only a real credential — encrypting "" would still satisfy
|
||||||
|
// `password <> ''` and report has_password for anonymous registries.
|
||||||
|
password := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
password = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
var r Registry
|
||||||
|
err := db.(*database.DB).QueryRow(
|
||||||
|
`INSERT INTO registries (owner_id, name, host, username, password)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||||
|
userID.(string), req.Name, req.Host, req.Username, password,
|
||||||
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unique") {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"registry": r})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
registryID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req registryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
|
||||||
|
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
|
||||||
|
req.Host = strings.TrimSuffix(req.Host, "/")
|
||||||
|
|
||||||
|
// Empty password keeps the stored credential — there is no "clear"
|
||||||
|
// operation; delete + recreate to remove a password.
|
||||||
|
var passwordArg interface{}
|
||||||
|
if req.Password != "" {
|
||||||
|
passwordArg = secrets.Encrypt(req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r Registry
|
||||||
|
err = db.(*database.DB).QueryRow(
|
||||||
|
`UPDATE registries SET name = $1, host = $2, username = $3,
|
||||||
|
password = COALESCE($4, password), updated_at = now()
|
||||||
|
WHERE id = $5 AND owner_id = $6
|
||||||
|
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
|
||||||
|
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
|
||||||
|
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"registry": r})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteRegistry(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
registryID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
|
||||||
|
registryID, userID.(string),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryAuthFor resolves pull credentials for an image reference: the
|
||||||
|
// caller's registry whose host matches, else anonymous. docker.io covers
|
||||||
|
// bare image names (nginx:latest).
|
||||||
|
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
|
||||||
|
host := registryHost(imageRef)
|
||||||
|
var username, password string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
|
||||||
|
ownerID, host,
|
||||||
|
).Scan(&username, &password)
|
||||||
|
if err != nil {
|
||||||
|
return registry.AuthConfig{}
|
||||||
|
}
|
||||||
|
return registry.AuthConfig{
|
||||||
|
Username: username,
|
||||||
|
Password: secrets.Decrypt(password),
|
||||||
|
ServerAddress: host,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseCPUReserveShares(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"": 0,
|
||||||
|
"0": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"abc": 0,
|
||||||
|
"0.25": 256,
|
||||||
|
"1": 1024,
|
||||||
|
"2.5": 2560,
|
||||||
|
" 0.5 ": 512,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := parseCPUReserveShares(in); got != want {
|
||||||
|
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateResourceSpecs(t *testing.T) {
|
||||||
|
if err := validateCPUSpec(""); err != nil {
|
||||||
|
t.Error("empty cpu should pass")
|
||||||
|
}
|
||||||
|
if err := validateCPUSpec("0.5"); err != nil {
|
||||||
|
t.Error("0.5 should be valid cpu")
|
||||||
|
}
|
||||||
|
if err := validateCPUSpec("512mb"); err == nil {
|
||||||
|
t.Error("512mb should fail cpu validation")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec(""); err != nil {
|
||||||
|
t.Error("empty memory should pass")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("512Mi"); err != nil {
|
||||||
|
t.Error("512Mi should be valid memory")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("1g"); err != nil {
|
||||||
|
t.Error("1g should be valid memory")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("0.5"); err == nil {
|
||||||
|
t.Error("0.5 should fail memory validation (parses to <1 byte)")
|
||||||
|
}
|
||||||
|
if err := validateMemorySpec("lots"); err == nil {
|
||||||
|
t.Error("'lots' should fail memory validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,6 +336,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/services/:id/stop", handleServiceStop)
|
authed.POST("/services/:id/stop", handleServiceStop)
|
||||||
authed.POST("/services/:id/restart", handleServiceRestart)
|
authed.POST("/services/:id/restart", handleServiceRestart)
|
||||||
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
|
authed.POST("/services/:id/clone", handleCloneService)
|
||||||
|
authed.POST("/services/:id/move", handleMoveService)
|
||||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
@@ -344,6 +346,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.GET("/services/:id/variables", handleGetVariables)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
authed.PUT("/services/:id/variables", handleUpdateVariables)
|
||||||
|
|
||||||
|
// Project-level shared variables — ${{shared.KEY}} refs.
|
||||||
|
authed.GET("/projects/:id/variables", handleGetProjectVariables)
|
||||||
|
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
|
||||||
|
|
||||||
// Runtime logs — container stdout routinely echoes secrets,
|
// Runtime logs — container stdout routinely echoes secrets,
|
||||||
// so these stay behind authentication like build logs.
|
// so these stay behind authentication like build logs.
|
||||||
authed.GET("/services/:id/logs", handleGetLogs)
|
authed.GET("/services/:id/logs", handleGetLogs)
|
||||||
@@ -352,6 +358,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// One-off exec console (docker exec, 30s ceiling)
|
// One-off exec console (docker exec, 30s ceiling)
|
||||||
authed.POST("/services/:id/exec", handleExecInService)
|
authed.POST("/services/:id/exec", handleExecInService)
|
||||||
|
|
||||||
|
// Private-image registries — owner-scoped, credentials encrypted at rest.
|
||||||
|
authed.GET("/registries", handleListRegistries)
|
||||||
|
authed.POST("/registries", handleCreateRegistry)
|
||||||
|
authed.PUT("/registries/:id", handleUpdateRegistry)
|
||||||
|
authed.DELETE("/registries/:id", handleDeleteRegistry)
|
||||||
|
|
||||||
// Git integration — providers carry per-user credentials.
|
// Git integration — providers carry per-user credentials.
|
||||||
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||||
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
|||||||
}
|
}
|
||||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||||
|
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
|
||||||
|
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
|
||||||
|
if service.Builder == "static" && spec.Port == 0 {
|
||||||
|
// The static builder's image is nginx; expose its default port so
|
||||||
|
// Traefik has something to route to without extra config.
|
||||||
|
spec.Port = 80
|
||||||
|
}
|
||||||
spec.Volumes = loadServiceVolumes(db, service.ID)
|
spec.Volumes = loadServiceVolumes(db, service.ID)
|
||||||
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
|
||||||
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
|
||||||
@@ -112,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolve := func(serviceName, key string) (string, bool) {
|
resolve := func(serviceName, key string) (string, bool) {
|
||||||
|
if strings.EqualFold(serviceName, "shared") {
|
||||||
|
return resolveSharedVar(db, service.ProjectID, key)
|
||||||
|
}
|
||||||
id, ok := byName[strings.ToLower(serviceName)]
|
id, ok := byName[strings.ToLower(serviceName)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -160,6 +170,32 @@ func parseCPULimit(cpu string) int64 {
|
|||||||
return int64(v * 1e9)
|
return int64(v * 1e9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
|
||||||
|
// weight: 1024 shares ≈ one CPU.
|
||||||
|
func parseCPUReserveShares(cpu string) int64 {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(v * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCPUSpec and validateMemorySpec reject values that silently parse
|
||||||
|
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
|
||||||
|
func validateCPUSpec(v string) error {
|
||||||
|
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
|
||||||
|
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMemorySpec(v string) error {
|
||||||
|
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
|
||||||
|
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||||
|
|
||||||
func parseMemoryLimit(mem string) int64 {
|
func parseMemoryLimit(mem string) int64 {
|
||||||
@@ -215,6 +251,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s JOIN projects p ON s.project_id = p.id
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1`,
|
WHERE s.id = $1`,
|
||||||
@@ -224,7 +263,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
@@ -258,6 +299,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at, s.project_id
|
s.created_at, s.updated_at, s.project_id
|
||||||
FROM services s
|
FROM services s
|
||||||
WHERE s.id = $1`,
|
WHERE s.id = $1`,
|
||||||
@@ -267,7 +311,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &projectID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
@@ -549,6 +595,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at, p.owner_id
|
s.created_at, s.updated_at, p.owner_id
|
||||||
FROM services s JOIN projects p ON s.project_id = p.id
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
WHERE s.id = $1`, serviceID,
|
WHERE s.id = $1`, serviceID,
|
||||||
@@ -557,7 +606,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt, &owner,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloneServiceRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
|
||||||
|
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCloneService duplicates a service's full configuration — image/git
|
||||||
|
// source, volumes, domains, access gates, builder settings, and variables —
|
||||||
|
// into the same or another project. Containers are not cloned; the copy
|
||||||
|
// starts stopped until its first deploy.
|
||||||
|
func handleCloneService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req CloneServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectID := source.ProjectID
|
||||||
|
if req.ProjectID != uuid.Nil {
|
||||||
|
targetProjectID = req.ProjectID
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = source.Name + "-copy"
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
targetProjectID, name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
environment := source.Environment
|
||||||
|
if req.Environment != "" {
|
||||||
|
environment = req.Environment
|
||||||
|
}
|
||||||
|
if environment == "" {
|
||||||
|
environment = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
|
||||||
|
newID := uuid.New()
|
||||||
|
now := time.Now()
|
||||||
|
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
|
||||||
|
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
|
||||||
|
|
||||||
|
_, err = db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO services
|
||||||
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, status, image, command, environment,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
created_at, updated_at)
|
||||||
|
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, 'stopped', image, command, $5,
|
||||||
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
|
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
|
||||||
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
$9, $9
|
||||||
|
FROM services WHERE id = $10`,
|
||||||
|
newID, targetProjectID, name, environmentID, environment, volumesJSON,
|
||||||
|
maintenance, basicAuth, now, source.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
|
||||||
|
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Variable values are copied as stored — ciphertext stays ciphertext.
|
||||||
|
if _, err := db.(*database.DB).Exec(
|
||||||
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
|
||||||
|
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
|
||||||
|
newID, source.ID,
|
||||||
|
); err != nil {
|
||||||
|
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveServiceRequest struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMoveService re-parents a service to another project owned by the
|
||||||
|
// caller. Variables, domains, and volumes follow via the service id;
|
||||||
|
// running containers keep running (they join the new project's network on
|
||||||
|
// the next deploy).
|
||||||
|
func handleMoveService(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MoveServiceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service, ok := loadOwnedService(c, db.(*database.DB))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ProjectID == service.ProjectID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_ = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
|
||||||
|
req.ProjectID, service.Name,
|
||||||
|
).Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
envName := service.Environment
|
||||||
|
if envName == "" {
|
||||||
|
envName = "production"
|
||||||
|
}
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
|
||||||
|
req.ProjectID, environmentID, time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsProject is true when the user owns the project or is an admin.
|
||||||
|
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
|
||||||
|
return err == nil && owner == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
|
||||||
|
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
|
||||||
|
var raw []byte
|
||||||
|
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ type Service struct {
|
|||||||
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
|
||||||
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
|
||||||
|
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
|
||||||
|
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
|
||||||
|
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
|
||||||
|
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
|
||||||
|
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -117,6 +122,11 @@ type CreateServiceRequest struct {
|
|||||||
Volumes []ServiceVolume `json:"volumes"`
|
Volumes []ServiceVolume `json:"volumes"`
|
||||||
MaintenanceMode bool `json:"maintenance_mode"`
|
MaintenanceMode bool `json:"maintenance_mode"`
|
||||||
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
BasicAuth []BasicAuthCred `json:"basic_auth"`
|
||||||
|
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
||||||
|
CPUReserve string `json:"cpu_reserve"`
|
||||||
|
MemoryReserve string `json:"memory_reserve"`
|
||||||
|
StaticBuildCmd string `json:"static_build_cmd"`
|
||||||
|
StaticDir string `json:"static_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateServiceRequest represents a request to update a service
|
// UpdateServiceRequest represents a request to update a service
|
||||||
@@ -139,6 +149,11 @@ type UpdateServiceRequest struct {
|
|||||||
Volumes *[]ServiceVolume `json:"volumes"`
|
Volumes *[]ServiceVolume `json:"volumes"`
|
||||||
MaintenanceMode *bool `json:"maintenance_mode"`
|
MaintenanceMode *bool `json:"maintenance_mode"`
|
||||||
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
BasicAuth *[]BasicAuthCred `json:"basic_auth"`
|
||||||
|
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
|
||||||
|
CPUReserve *string `json:"cpu_reserve"`
|
||||||
|
MemoryReserve *string `json:"memory_reserve"`
|
||||||
|
StaticBuildCmd *string `json:"static_build_cmd"`
|
||||||
|
StaticDir *string `json:"static_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetServices retrieves all services for a project
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -183,6 +198,9 @@ func handleGetServices(c *gin.Context) {
|
|||||||
COALESCE(replicas, 1), COALESCE(port, 0),
|
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||||
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||||
COALESCE(restart_policy, 'unless-stopped'),
|
COALESCE(restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
|
||||||
|
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
|
||||||
|
COALESCE(static_dir, ''),
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM services
|
FROM services
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
@@ -203,7 +221,9 @@ func handleGetServices(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
@@ -314,12 +334,38 @@ func handleCreateService(c *gin.Context) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values if not provided
|
// Set default values if not provided; app_settings.default_cpu /
|
||||||
|
// default_memory override the hardcoded fallbacks.
|
||||||
if service.CPU == "" {
|
if service.CPU == "" {
|
||||||
service.CPU = "0.5"
|
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
|
||||||
}
|
}
|
||||||
if service.Memory == "" {
|
if service.Memory == "" {
|
||||||
service.Memory = "512Mi"
|
service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
|
||||||
|
}
|
||||||
|
if req.Builder == "" {
|
||||||
|
service.Builder = "auto"
|
||||||
|
} else {
|
||||||
|
service.Builder = req.Builder
|
||||||
|
}
|
||||||
|
service.CPUReserve = req.CPUReserve
|
||||||
|
service.MemoryReserve = req.MemoryReserve
|
||||||
|
service.StaticBuildCmd = req.StaticBuildCmd
|
||||||
|
service.StaticDir = req.StaticDir
|
||||||
|
if service.Builder == "static" && service.StaticDir == "" {
|
||||||
|
service.StaticDir = "dist"
|
||||||
|
}
|
||||||
|
for label, check := range map[string]func(string) error{
|
||||||
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
|
} {
|
||||||
|
v := map[string]string{
|
||||||
|
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
|
||||||
|
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
|
||||||
|
}[label]
|
||||||
|
if err := check(v); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if service.Replicas < 1 {
|
if service.Replicas < 1 {
|
||||||
service.Replicas = 1
|
service.Replicas = 1
|
||||||
@@ -351,14 +397,18 @@ func handleCreateService(c *gin.Context) {
|
|||||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
build_command, start_command, type, status, image, command, environment,
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||||
healthcheck_path, restart_policy, volumes, created_at, updated_at)
|
healthcheck_path, restart_policy, volumes,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`,
|
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
||||||
|
$27, $28, $29, $30, $31, $32, $33)`,
|
||||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||||
volumesJSON, service.CreatedAt, service.UpdatedAt,
|
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||||
|
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -443,6 +493,9 @@ func handleGetService(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -454,7 +507,9 @@ func handleGetService(c *gin.Context) {
|
|||||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
|
||||||
|
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
|
||||||
|
&service.CreatedAt, &service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -520,6 +575,9 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
|
||||||
|
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
|
||||||
|
COALESCE(s.static_dir, ''),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -532,6 +590,9 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||||
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||||
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||||
|
&existingService.Builder, &existingService.CPUReserve,
|
||||||
|
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
|
||||||
|
&existingService.StaticDir,
|
||||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -586,6 +647,37 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
if req.RestartPolicy != "" {
|
if req.RestartPolicy != "" {
|
||||||
existingService.RestartPolicy = req.RestartPolicy
|
existingService.RestartPolicy = req.RestartPolicy
|
||||||
}
|
}
|
||||||
|
if req.Builder != "" {
|
||||||
|
existingService.Builder = req.Builder
|
||||||
|
}
|
||||||
|
if req.CPUReserve != nil {
|
||||||
|
existingService.CPUReserve = *req.CPUReserve
|
||||||
|
}
|
||||||
|
if req.MemoryReserve != nil {
|
||||||
|
existingService.MemoryReserve = *req.MemoryReserve
|
||||||
|
}
|
||||||
|
if req.StaticBuildCmd != nil {
|
||||||
|
existingService.StaticBuildCmd = *req.StaticBuildCmd
|
||||||
|
}
|
||||||
|
if req.StaticDir != nil {
|
||||||
|
existingService.StaticDir = *req.StaticDir
|
||||||
|
}
|
||||||
|
if existingService.Builder == "static" && existingService.StaticDir == "" {
|
||||||
|
existingService.StaticDir = "dist"
|
||||||
|
}
|
||||||
|
for label, check := range map[string]func(string) error{
|
||||||
|
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
|
||||||
|
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
|
||||||
|
} {
|
||||||
|
v := map[string]string{
|
||||||
|
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
|
||||||
|
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
|
||||||
|
}[label]
|
||||||
|
if err := check(v); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
|
||||||
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
|
||||||
var volumesArg interface{}
|
var volumesArg interface{}
|
||||||
@@ -607,7 +699,9 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16
|
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||||
|
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||||
|
static_build_cmd = $22, static_dir = $23
|
||||||
WHERE id = $18`,
|
WHERE id = $18`,
|
||||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||||
@@ -615,6 +709,8 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||||
existingService.UpdatedAt, volumesArg, existingService.ID,
|
existingService.UpdatedAt, volumesArg, existingService.ID,
|
||||||
|
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||||
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -218,12 +218,14 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
||||||
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
||||||
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
||||||
COALESCE(cpu, ''), COALESCE(memory, '')
|
COALESCE(cpu, ''), COALESCE(memory, ''),
|
||||||
|
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
|
||||||
FROM services WHERE id = $1`,
|
FROM services WHERE id = $1`,
|
||||||
serviceID,
|
serviceID,
|
||||||
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
||||||
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
||||||
&svc.RestartPolicy, &svc.CPU, &svc.Memory)
|
&svc.RestartPolicy, &svc.CPU, &svc.Memory,
|
||||||
|
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
|
||||||
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
|
|||||||
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
// Build Docker image
|
// Build Docker image
|
||||||
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
|
||||||
// Create build context tar
|
// createBuildContext wants the Dockerfile contents, not its path.
|
||||||
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
|
dockerfileContent, err := os.ReadFile(optimizedDockerfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create build context: %w", err)
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type BuildManager struct {
|
|||||||
railpackBuilder *RailpackBuilder
|
railpackBuilder *RailpackBuilder
|
||||||
nixpacksBuilder *NixpacksBuilder
|
nixpacksBuilder *NixpacksBuilder
|
||||||
dockerfileBuilder *DockerfileBuilder
|
dockerfileBuilder *DockerfileBuilder
|
||||||
|
staticBuilder *StaticBuilder
|
||||||
dockerClient *docker.Client
|
dockerClient *docker.Client
|
||||||
cacheManager *CacheManager
|
cacheManager *CacheManager
|
||||||
parallelBuilder *ParallelBuilder
|
parallelBuilder *ParallelBuilder
|
||||||
@@ -28,6 +29,7 @@ const (
|
|||||||
BuildTypeRailpack BuildType = "railpack"
|
BuildTypeRailpack BuildType = "railpack"
|
||||||
BuildTypeNixpacks BuildType = "nixpacks"
|
BuildTypeNixpacks BuildType = "nixpacks"
|
||||||
BuildTypeDockerfile BuildType = "dockerfile"
|
BuildTypeDockerfile BuildType = "dockerfile"
|
||||||
|
BuildTypeStatic BuildType = "static"
|
||||||
BuildTypePrebuilt BuildType = "prebuilt"
|
BuildTypePrebuilt BuildType = "prebuilt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
|
|||||||
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
|
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
|
||||||
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
|
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
|
||||||
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
|
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
|
||||||
|
staticBuilder: NewStaticBuilder(dockerClient),
|
||||||
dockerClient: dockerClient,
|
dockerClient: dockerClient,
|
||||||
cacheManager: cacheManager,
|
cacheManager: cacheManager,
|
||||||
parallelBuilder: parallelBuilder,
|
parallelBuilder: parallelBuilder,
|
||||||
@@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
|
|||||||
return bm.nixpacksBuilder.Build(ctx, req)
|
return bm.nixpacksBuilder.Build(ctx, req)
|
||||||
case BuildTypeDockerfile:
|
case BuildTypeDockerfile:
|
||||||
return bm.dockerfileBuilder.Build(ctx, req)
|
return bm.dockerfileBuilder.Build(ctx, req)
|
||||||
|
case BuildTypeStatic:
|
||||||
|
return bm.staticBuilder.Build(ctx, req)
|
||||||
case BuildTypePrebuilt:
|
case BuildTypePrebuilt:
|
||||||
return bm.buildPrebuilt(ctx, req)
|
return bm.buildPrebuilt(ctx, req)
|
||||||
default:
|
default:
|
||||||
@@ -95,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
|||||||
|
|
||||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||||
// aborts it and the tag below races a missing image.
|
// aborts it and the tag below races a missing image.
|
||||||
auth := registry.AuthConfig{}
|
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
|
||||||
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
"containr/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaticBuilder produces an nginx-served image from a repo's built assets.
|
||||||
|
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
|
||||||
|
// output directory copied into the nginx image. Both are overridable via
|
||||||
|
// build args so repos with non-node toolchains can swap the build image too.
|
||||||
|
type StaticBuilder struct {
|
||||||
|
dockerClient *docker.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
|
||||||
|
return &StaticBuilder{dockerClient: dockerClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
|
||||||
|
FROM ${BUILD_IMAGE} AS assets
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
ARG STATIC_BUILD_CMD
|
||||||
|
RUN sh -c "${STATIC_BUILD_CMD}"
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
ARG STATIC_DIR=dist
|
||||||
|
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
`
|
||||||
|
|
||||||
|
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
|
||||||
|
buildCmd := req.BuildCommand
|
||||||
|
if buildCmd == "" {
|
||||||
|
buildCmd = "npm ci && npm run build"
|
||||||
|
}
|
||||||
|
staticDir := req.BuildArgs["STATIC_DIR"]
|
||||||
|
if staticDir == "" {
|
||||||
|
staticDir = "dist"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArgs := map[string]*string{}
|
||||||
|
for k, v := range req.BuildArgs {
|
||||||
|
v := v
|
||||||
|
buildArgs[k] = &v
|
||||||
|
}
|
||||||
|
for k, v := range req.Environment {
|
||||||
|
v := v
|
||||||
|
buildArgs[k] = &v
|
||||||
|
}
|
||||||
|
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
|
||||||
|
buildArgs["STATIC_DIR"] = &staticDir
|
||||||
|
|
||||||
|
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||||
|
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create build context: %w", err)
|
||||||
|
}
|
||||||
|
defer buildCtx.Close()
|
||||||
|
|
||||||
|
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
|
||||||
|
Dockerfile: "Dockerfile",
|
||||||
|
Tags: []string{imageName},
|
||||||
|
BuildArgs: buildArgs,
|
||||||
|
NoCache: req.NoCache,
|
||||||
|
Remove: true,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build static image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.RegistryURL != "" {
|
||||||
|
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
|
||||||
|
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||||
|
}
|
||||||
|
imageName = full
|
||||||
|
}
|
||||||
|
|
||||||
|
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
|
||||||
|
if err != nil {
|
||||||
|
imageInfo = &docker.ImageInfo{Size: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.BuildResponse{
|
||||||
|
ImageName: imageName,
|
||||||
|
ImageTag: req.ImageTag,
|
||||||
|
Size: imageInfo.Size,
|
||||||
|
Digest: imageInfo.Digest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistriesCmd manages private-image registry credentials.
|
||||||
|
var RegistriesCmd = &cobra.Command{
|
||||||
|
Use: "registries",
|
||||||
|
Aliases: []string{"registry"},
|
||||||
|
Short: "Manage registry credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List registry credentials",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", "/registries", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "registries")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, r := range items {
|
||||||
|
hasPw := ""
|
||||||
|
if b, ok := r["has_password"].(bool); ok && b {
|
||||||
|
hasPw = "yes"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesAddCmd = &cobra.Command{
|
||||||
|
Use: "add <name> <host>",
|
||||||
|
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"name": args[0], "host": args[1]}
|
||||||
|
if v, _ := cmd.Flags().GetString("username"); v != "" {
|
||||||
|
body["username"] = v
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("password"); v != "" {
|
||||||
|
body["password"] = v
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/registries", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r, _ := unwrapObject(data, "registry")
|
||||||
|
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
Short: "Update a registry credential (empty --password keeps the stored one)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "host", "username", "password"} {
|
||||||
|
if cmd.Flags().Changed(f) {
|
||||||
|
v, _ := cmd.Flags().GetString(f)
|
||||||
|
body[f] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
// The API requires name+host — backfill them from the stored row
|
||||||
|
// when the caller didn't supply both.
|
||||||
|
_, hasName := body["name"]
|
||||||
|
_, hasHost := body["host"]
|
||||||
|
if !hasName || !hasHost {
|
||||||
|
data, err := c.Do("GET", "/registries", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, _ := unwrapList(data, "registries")
|
||||||
|
for _, r := range items {
|
||||||
|
if str(r, "id") == args[0] {
|
||||||
|
if !hasName {
|
||||||
|
body["name"] = str(r, "name")
|
||||||
|
}
|
||||||
|
if !hasHost {
|
||||||
|
body["host"] = str(r, "host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/registries/"+args[0], body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Updated registry %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registriesRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Remove a registry credential",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
|
||||||
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||||
|
}
|
||||||
|
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed registry %s\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registriesAddCmd.Flags().String("username", "", "registry username")
|
||||||
|
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
|
||||||
|
registriesUpdateCmd.Flags().String("name", "", "display name")
|
||||||
|
registriesUpdateCmd.Flags().String("host", "", "registry host")
|
||||||
|
registriesUpdateCmd.Flags().String("username", "", "registry username")
|
||||||
|
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
|
||||||
|
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"name": args[1],
|
"name": args[1],
|
||||||
}
|
}
|
||||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
key := f
|
key := f
|
||||||
// JSON uses snake_case keys matching the API schema.
|
// JSON uses snake_case keys matching the API schema.
|
||||||
@@ -94,6 +94,14 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
key = "restart_policy"
|
key = "restart_policy"
|
||||||
case "healthcheck-path":
|
case "healthcheck-path":
|
||||||
key = "healthcheck_path"
|
key = "healthcheck_path"
|
||||||
|
case "cpu-reserve":
|
||||||
|
key = "cpu_reserve"
|
||||||
|
case "memory-reserve":
|
||||||
|
key = "memory_reserve"
|
||||||
|
case "static-cmd":
|
||||||
|
key = "static_build_cmd"
|
||||||
|
case "static-dir":
|
||||||
|
key = "static_dir"
|
||||||
}
|
}
|
||||||
body[key] = v
|
body[key] = v
|
||||||
}
|
}
|
||||||
@@ -151,7 +159,7 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body := map[string]interface{}{}
|
body := map[string]interface{}{}
|
||||||
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
|
||||||
if cmd.Flags().Changed(f) {
|
if cmd.Flags().Changed(f) {
|
||||||
key := f
|
key := f
|
||||||
switch f {
|
switch f {
|
||||||
@@ -159,6 +167,14 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
key = "restart_policy"
|
key = "restart_policy"
|
||||||
case "healthcheck-path":
|
case "healthcheck-path":
|
||||||
key = "healthcheck_path"
|
key = "healthcheck_path"
|
||||||
|
case "cpu-reserve":
|
||||||
|
key = "cpu_reserve"
|
||||||
|
case "memory-reserve":
|
||||||
|
key = "memory_reserve"
|
||||||
|
case "static-cmd":
|
||||||
|
key = "static_build_cmd"
|
||||||
|
case "static-dir":
|
||||||
|
key = "static_dir"
|
||||||
}
|
}
|
||||||
v, _ := cmd.Flags().GetString(f)
|
v, _ := cmd.Flags().GetString(f)
|
||||||
body[key] = v
|
body[key] = v
|
||||||
@@ -210,6 +226,61 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var servicesCloneCmd = &cobra.Command{
|
||||||
|
Use: "clone <id>",
|
||||||
|
Short: "Clone a service (config, volumes, domains, variables)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
for _, f := range []string{"name", "project", "environment"} {
|
||||||
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
|
key := f
|
||||||
|
if f == "project" {
|
||||||
|
key = "project_id"
|
||||||
|
}
|
||||||
|
body[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := unwrapObject(data)
|
||||||
|
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesMoveCmd = &cobra.Command{
|
||||||
|
Use: "move <id> <project-id>",
|
||||||
|
Short: "Move a service to another project",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
||||||
// objects. Bind mounts are type=bind, named volumes type=volume.
|
// objects. Bind mounts are type=bind, named volumes type=volume.
|
||||||
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
||||||
@@ -406,6 +477,11 @@ func init() {
|
|||||||
f.String("memory", "", "memory limit (e.g. 512m)")
|
f.String("memory", "", "memory limit (e.g. 512m)")
|
||||||
f.Int("port", 0, "container port")
|
f.Int("port", 0, "container port")
|
||||||
f.Int("replicas", 0, "replica count")
|
f.Int("replicas", 0, "replica count")
|
||||||
|
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||||
|
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
|
||||||
|
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
|
||||||
|
f.String("static-cmd", "", "build command for the static builder")
|
||||||
|
f.String("static-dir", "", "output dir for the static builder (default dist)")
|
||||||
|
|
||||||
uf := servicesUpdateCmd.Flags()
|
uf := servicesUpdateCmd.Flags()
|
||||||
uf.String("name", "", "service name")
|
uf.String("name", "", "service name")
|
||||||
@@ -423,6 +499,11 @@ func init() {
|
|||||||
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
||||||
uf.String("maintenance", "", "maintenance mode: on|off")
|
uf.String("maintenance", "", "maintenance mode: on|off")
|
||||||
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
|
||||||
|
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
|
||||||
|
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
|
||||||
|
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
|
||||||
|
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
|
||||||
|
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
|
||||||
|
|
||||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||||
verb := a
|
verb := a
|
||||||
@@ -433,7 +514,11 @@ func init() {
|
|||||||
RunE: serviceAction(verb),
|
RunE: serviceAction(verb),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
|
||||||
|
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
|
||||||
|
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
|
||||||
|
|
||||||
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
|
||||||
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
|
||||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
|
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ var varsSetCmd = &cobra.Command{
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
secret, _ := cmd.Flags().GetBool("secret")
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
for _, pair := range args[1:] {
|
for _, pair := range args[1:] {
|
||||||
k, v, found := strings.Cut(pair, "=")
|
k, v, found := strings.Cut(pair, "=")
|
||||||
if !found || k == "" {
|
if !found || k == "" {
|
||||||
@@ -73,7 +73,7 @@ var varsUnsetCmd = &cobra.Command{
|
|||||||
drop[k] = true
|
drop[k] = true
|
||||||
}
|
}
|
||||||
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
out := vars[:0]
|
out := vars[:0]
|
||||||
for _, v := range vars {
|
for _, v := range vars {
|
||||||
if !drop[str(v, "key")] {
|
if !drop[str(v, "key")] {
|
||||||
@@ -85,20 +85,92 @@ var varsUnsetCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
// Shared variables — project scope, referenced as ${{shared.KEY}}.
|
||||||
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
|
|
||||||
if err != nil {
|
var varsSharedCmd = &cobra.Command{
|
||||||
return nil, err
|
Use: "shared",
|
||||||
}
|
Short: "Manage project-level shared variables (${{shared.KEY}})",
|
||||||
return unwrapList(data, "variables")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
var sharedVarsListCmd = &cobra.Command{
|
||||||
|
Use: "list <project-id>",
|
||||||
|
Short: "List shared project variables (secrets shown masked)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := client()
|
c, err := client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
vars, err := fetchVars(c, serviceID)
|
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, err := unwrapList(data, "variables")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
||||||
|
}
|
||||||
|
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedVarsSetCmd = &cobra.Command{
|
||||||
|
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
|
||||||
|
Short: "Set shared project variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
|
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
for _, pair := range args[1:] {
|
||||||
|
k, v, found := strings.Cut(pair, "=")
|
||||||
|
if !found || k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vars = upsertVar(vars, k, v, secret)
|
||||||
|
}
|
||||||
|
return vars
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedVarsUnsetCmd = &cobra.Command{
|
||||||
|
Use: "unset <project-id> KEY [KEY...]",
|
||||||
|
Short: "Remove shared project variables",
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, k := range args[1:] {
|
||||||
|
drop[k] = true
|
||||||
|
}
|
||||||
|
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
|
out := vars[:0]
|
||||||
|
for _, v := range vars {
|
||||||
|
if !drop[str(v, "key")] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
|
||||||
|
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
|
||||||
|
|
||||||
|
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := unwrapList(data, "variables")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -115,7 +187,7 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
|||||||
if redeploy {
|
if redeploy {
|
||||||
body["redeploy"] = true
|
body["redeploy"] = true
|
||||||
}
|
}
|
||||||
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
|
data, err = c.Do("PUT", path, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -125,9 +197,9 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
|
|||||||
}
|
}
|
||||||
obj, _ := unwrapObject(data)
|
obj, _ := unwrapObject(data)
|
||||||
if obj["redeploy_queued"] == true {
|
if obj["redeploy_queued"] == true {
|
||||||
fmt.Printf("Variables updated on %s — redeploy queued\n", serviceID)
|
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,5 +219,7 @@ func init() {
|
|||||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
|
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
|
||||||
|
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.ExecCmd)
|
rootCmd.AddCommand(commands.ExecCmd)
|
||||||
rootCmd.AddCommand(commands.VariablesCmd)
|
rootCmd.AddCommand(commands.VariablesCmd)
|
||||||
rootCmd.AddCommand(commands.VolumesCmd)
|
rootCmd.AddCommand(commands.VolumesCmd)
|
||||||
|
rootCmd.AddCommand(commands.RegistriesCmd)
|
||||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||||
rootCmd.AddCommand(commands.CronCmd)
|
rootCmd.AddCommand(commands.CronCmd)
|
||||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ type VolumeMount struct {
|
|||||||
|
|
||||||
type ResourceLimits struct {
|
type ResourceLimits struct {
|
||||||
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
||||||
|
MemoryReservation int64 `json:"memory_reservation,omitempty"`
|
||||||
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
CPUQuota int64 `json:"cpu_quota,omitempty"`
|
||||||
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
CPUPeriod int64 `json:"cpu_period,omitempty"`
|
||||||
CPUShares int64 `json:"cpu_shares,omitempty"`
|
CPUShares int64 `json:"cpu_shares,omitempty"`
|
||||||
@@ -152,7 +153,11 @@ type BuildConfig struct {
|
|||||||
PrebuiltImage string `json:"prebuilt_image"`
|
PrebuiltImage string `json:"prebuilt_image"`
|
||||||
BuildCommand string `json:"build_command"`
|
BuildCommand string `json:"build_command"`
|
||||||
StartCommand string `json:"start_command"`
|
StartCommand string `json:"start_command"`
|
||||||
|
BuildArgs map[string]string `json:"build_args,omitempty"`
|
||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
|
// Pull credentials for private prebuilt images.
|
||||||
|
PullUsername string `json:"pull_username,omitempty"`
|
||||||
|
PullPassword string `json:"pull_password,omitempty"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
NoCache bool `json:"no_cache"`
|
NoCache bool `json:"no_cache"`
|
||||||
@@ -166,6 +171,12 @@ type TriggerConfig struct {
|
|||||||
Timestamp time.Time `json:"timestamp"` // When trigger occurred
|
Timestamp time.Time `json:"timestamp"` // When trigger occurred
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DockerClient exposes the engine's docker handle for read-only queries
|
||||||
|
// (capacity checks, image info).
|
||||||
|
func (de *DeploymentEngine) DockerClient() *docker.Client {
|
||||||
|
return de.dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
|
||||||
return &DeploymentEngine{
|
return &DeploymentEngine{
|
||||||
buildManager: buildManager,
|
buildManager: buildManager,
|
||||||
@@ -290,7 +301,10 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
ImageTag: deployment.ID,
|
ImageTag: deployment.ID,
|
||||||
BuildCommand: buildConfig.BuildCommand,
|
BuildCommand: buildConfig.BuildCommand,
|
||||||
StartCommand: buildConfig.StartCommand,
|
StartCommand: buildConfig.StartCommand,
|
||||||
|
BuildArgs: buildConfig.BuildArgs,
|
||||||
Environment: buildConfig.Environment,
|
Environment: buildConfig.Environment,
|
||||||
|
PullUsername: buildConfig.PullUsername,
|
||||||
|
PullPassword: buildConfig.PullPassword,
|
||||||
ProjectID: deployment.ProjectID,
|
ProjectID: deployment.ProjectID,
|
||||||
ServiceID: deployment.ServiceID,
|
ServiceID: deployment.ServiceID,
|
||||||
DeploymentID: deployment.ID,
|
DeploymentID: deployment.ID,
|
||||||
@@ -330,7 +344,9 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
HealthPath: cfg.HealthPath,
|
HealthPath: cfg.HealthPath,
|
||||||
RestartPolicy: cfg.RestartPolicy,
|
RestartPolicy: cfg.RestartPolicy,
|
||||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||||
|
MemoryReserve: cfg.Resources.MemoryReservation,
|
||||||
NanoCPUs: cfg.Resources.CPUQuota,
|
NanoCPUs: cfg.Resources.CPUQuota,
|
||||||
|
CPUShares: cfg.Resources.CPUShares,
|
||||||
Volumes: cfg.VolumeMounts,
|
Volumes: cfg.VolumeMounts,
|
||||||
Domains: cfg.Domains,
|
Domains: cfg.Domains,
|
||||||
Maintenance: cfg.Maintenance,
|
Maintenance: cfg.Maintenance,
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ type RuntimeSpec struct {
|
|||||||
HealthPath string // http path probed on Port for container healthcheck
|
HealthPath string // http path probed on Port for container healthcheck
|
||||||
RestartPolicy string
|
RestartPolicy string
|
||||||
MemoryBytes int64
|
MemoryBytes int64
|
||||||
|
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
|
||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
|
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
|
||||||
Volumes []VolumeMount // applied to every replica
|
Volumes []VolumeMount // applied to every replica
|
||||||
Domains []string // all public hostnames; Domain is the default
|
Domains []string // all public hostnames; Domain is the default
|
||||||
Maintenance bool // redirect traffic to MaintenanceURL
|
Maintenance bool // redirect traffic to MaintenanceURL
|
||||||
@@ -280,7 +282,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
|
|||||||
Labels: labels,
|
Labels: labels,
|
||||||
RestartPolicy: spec.RestartPolicy,
|
RestartPolicy: spec.RestartPolicy,
|
||||||
Memory: spec.MemoryBytes,
|
Memory: spec.MemoryBytes,
|
||||||
|
MemoryReservation: spec.MemoryReserve,
|
||||||
NanoCPUs: spec.NanoCPUs,
|
NanoCPUs: spec.NanoCPUs,
|
||||||
|
CPUShares: spec.CPUShares,
|
||||||
Networks: endpoints,
|
Networks: endpoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,12 +530,14 @@ func specHash(spec RuntimeSpec) string {
|
|||||||
Env map[string]string
|
Env map[string]string
|
||||||
Port int32
|
Port int32
|
||||||
MemoryBytes, NanoCPUs int64
|
MemoryBytes, NanoCPUs int64
|
||||||
|
MemoryReserve, CPUShares int64
|
||||||
Volumes []VolumeMount
|
Volumes []VolumeMount
|
||||||
Domains []string
|
Domains []string
|
||||||
Maintenance bool
|
Maintenance bool
|
||||||
BasicAuth string
|
BasicAuth string
|
||||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes,
|
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
|
||||||
|
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
|
||||||
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
|
spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
|
||||||
sum := sha256.Sum256(payload)
|
sum := sha256.Sum256(payload)
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
|||||||
Mounts: config.Mounts,
|
Mounts: config.Mounts,
|
||||||
Resources: container.Resources{
|
Resources: container.Resources{
|
||||||
Memory: config.Memory,
|
Memory: config.Memory,
|
||||||
|
MemoryReservation: config.MemoryReservation,
|
||||||
NanoCPUs: config.NanoCPUs,
|
NanoCPUs: config.NanoCPUs,
|
||||||
|
CPUShares: config.CPUShares,
|
||||||
},
|
},
|
||||||
NetworkMode: container.NetworkMode(config.NetworkMode),
|
NetworkMode: container.NetworkMode(config.NetworkMode),
|
||||||
}
|
}
|
||||||
@@ -291,7 +293,7 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
|
|||||||
|
|
||||||
// BuildImage builds an image from a Dockerfile
|
// BuildImage builds an image from a Dockerfile
|
||||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||||
Dockerfile: options.Dockerfile,
|
Dockerfile: options.Dockerfile,
|
||||||
Tags: options.Tags,
|
Tags: options.Tags,
|
||||||
BuildArgs: options.BuildArgs,
|
BuildArgs: options.BuildArgs,
|
||||||
@@ -299,6 +301,34 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
|
|||||||
Remove: options.Remove,
|
Remove: options.Remove,
|
||||||
NoCache: options.NoCache,
|
NoCache: options.NoCache,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
// The daemon aborts the build if the response stream isn't consumed,
|
||||||
|
// and reports failures only inside that stream.
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var buildErr string
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
for {
|
||||||
|
var line struct {
|
||||||
|
ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errorDetail"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := dec.Decode(&line); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line.ErrorDetail.Message != "" {
|
||||||
|
buildErr = line.ErrorDetail.Message
|
||||||
|
} else if line.Error != "" {
|
||||||
|
buildErr = line.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buildErr != "" {
|
||||||
|
return resp, fmt.Errorf("docker build failed: %s", buildErr)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes an image
|
// RemoveImage removes an image
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ type ContainerConfig struct {
|
|||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
Mounts []mount.Mount
|
Mounts []mount.Mount
|
||||||
Memory int64
|
Memory int64
|
||||||
|
MemoryReservation int64
|
||||||
NanoCPUs int64
|
NanoCPUs int64
|
||||||
|
CPUShares int64
|
||||||
NetworkMode string
|
NetworkMode string
|
||||||
Networks map[string]*network.EndpointSettings
|
Networks map[string]*network.EndpointSettings
|
||||||
Healthcheck *container.HealthConfig
|
Healthcheck *container.HealthConfig
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ func init() {
|
|||||||
// Services
|
// Services
|
||||||
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
|
||||||
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
|
||||||
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
|
||||||
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
|
||||||
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears.", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Domains
|
// Domains
|
||||||
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false})
|
||||||
@@ -70,6 +72,12 @@ func init() {
|
|||||||
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
|
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
|
||||||
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
|
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
|
||||||
|
|
||||||
|
// Registries — private-image pull credentials, owner-scoped.
|
||||||
|
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
|
||||||
|
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
|
||||||
|
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
|
||||||
|
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
|
||||||
|
|
||||||
// Docker volumes (admin)
|
// Docker volumes (admin)
|
||||||
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
|
||||||
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
|
||||||
@@ -77,6 +85,8 @@ func init() {
|
|||||||
// Variables
|
// Variables
|
||||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
||||||
|
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
|
||||||
|
|
||||||
// Deployments
|
// Deployments
|
||||||
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ type BuildRequest struct {
|
|||||||
Branch string `json:"branch"` // Git branch
|
Branch string `json:"branch"` // Git branch
|
||||||
Commit string `json:"commit"` // Git commit SHA
|
Commit string `json:"commit"` // Git commit SHA
|
||||||
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
||||||
|
|
||||||
|
// Registry credentials for pulling a private prebuilt image.
|
||||||
|
PullUsername string `json:"pull_username,omitempty"`
|
||||||
|
PullPassword string `json:"pull_password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildResponse represents the response from a build operation
|
// BuildResponse represents the response from a build operation
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS builder;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (owner_id, host)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS registries;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Project-level shared variables (Railway-style). Services reference them via
|
||||||
|
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
|
||||||
|
-- and masked in API responses exactly like service variables.
|
||||||
|
CREATE TABLE IF NOT EXISTS project_variables (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
key varchar(255) NOT NULL,
|
||||||
|
value text NOT NULL DEFAULT '',
|
||||||
|
is_secret boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (project_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS project_variables;
|
||||||
@@ -61,7 +61,24 @@ CREATE TABLE services (
|
|||||||
published_port INTEGER NOT NULL DEFAULT 0,
|
published_port INTEGER NOT NULL DEFAULT 0,
|
||||||
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
|
maintenance_mode BOOLEAN NOT NULL DEFAULT false,
|
||||||
basic_auth_users TEXT NOT NULL DEFAULT ''
|
basic_auth_users TEXT NOT NULL DEFAULT '',
|
||||||
|
builder VARCHAR(32) NOT NULL DEFAULT 'auto',
|
||||||
|
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
static_dir VARCHAR(255) NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE registries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (owner_id, host)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE service_domains (
|
CREATE TABLE service_domains (
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
createUserToken,
|
createUserToken,
|
||||||
listUserTokens,
|
listUserTokens,
|
||||||
revokeUserToken,
|
revokeUserToken,
|
||||||
|
listRegistries,
|
||||||
|
createRegistry,
|
||||||
|
deleteRegistry,
|
||||||
|
type Registry,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
@@ -1430,6 +1434,135 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RegistriesSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const registriesQuery = useQuery({
|
||||||
|
queryKey: ['registries'],
|
||||||
|
queryFn: listRegistries,
|
||||||
|
});
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [host, setHost] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createRegistry({
|
||||||
|
name: name.trim(),
|
||||||
|
host: host.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
password: password || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('');
|
||||||
|
setHost('');
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteRegistry(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registries = registriesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Name (e.g. ghcr)"
|
||||||
|
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="Host (ghcr.io, docker.io)"
|
||||||
|
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password / token"
|
||||||
|
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{registriesQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : registries.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{registries.map((r: Registry) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||||
|
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||||
|
{r.has_password ? 'password set' : 'anonymous'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
||||||
|
deleteMutation.mutate(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1603,6 +1736,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Personal Access Tokens */}
|
{/* Personal Access Tokens */}
|
||||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
<RegistriesSection />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import {
|
|||||||
removeServiceDomain,
|
removeServiceDomain,
|
||||||
setDefaultServiceDomain,
|
setDefaultServiceDomain,
|
||||||
checkServiceDomains,
|
checkServiceDomains,
|
||||||
|
cloneService,
|
||||||
|
moveService,
|
||||||
|
listProjects,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
@@ -412,6 +415,36 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => listProjects({ limit: 100 }),
|
||||||
|
enabled: !isDemoMode && activeSection === 'settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneMutation = useMutation({
|
||||||
|
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||||
|
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||||
|
onSuccess: (_data, targetProjectId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||||
|
cloneName: string;
|
||||||
|
cloneProject: string;
|
||||||
|
moveTarget: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const invalidateService = () => {
|
const invalidateService = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||||
@@ -448,6 +481,9 @@ export function ServiceDetailPage() {
|
|||||||
const [domainInput, setDomainInput] = useState('');
|
const [domainInput, setDomainInput] = useState('');
|
||||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||||
|
const [buildForm, setBuildForm] = useState<{
|
||||||
|
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const domainsQuery = useQuery({
|
const domainsQuery = useQuery({
|
||||||
queryKey: ['service-domains', serviceId],
|
queryKey: ['service-domains', serviceId],
|
||||||
@@ -1219,8 +1255,9 @@ export function ServiceDetailPage() {
|
|||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
|
||||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
shared variable with{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2193,7 +2230,11 @@ export function ServiceDetailPage() {
|
|||||||
disabled={updateServiceMutation.isPending}
|
disabled={updateServiceMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateServiceMutation.mutate(
|
updateServiceMutation.mutate(
|
||||||
{ volumes: volumesForm.filter((v) => v.source.trim() && v.target.startsWith('/')) },
|
{
|
||||||
|
volumes: volumesForm
|
||||||
|
.filter((v) => v.source.trim() && v.target.startsWith('/'))
|
||||||
|
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
|
||||||
|
},
|
||||||
{ onSuccess: () => setVolumesForm(null) },
|
{ onSuccess: () => setVolumesForm(null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2386,6 +2427,144 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build & Resources</h3>
|
||||||
|
{buildForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setBuildForm({
|
||||||
|
builder: service.builder ?? 'auto',
|
||||||
|
cpuReserve: service.cpuReserve ?? '',
|
||||||
|
memoryReserve: service.memoryReserve ?? '',
|
||||||
|
staticCmd: service.staticBuildCmd ?? '',
|
||||||
|
staticDir: service.staticDir ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{buildForm === null ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
|
||||||
|
.filter(Boolean).join(' · ') || 'none'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{service.builder === 'static' && (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
|
||||||
|
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
|
||||||
|
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
|
||||||
|
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
|
||||||
|
<select
|
||||||
|
value={buildForm.builder}
|
||||||
|
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
|
||||||
|
<input
|
||||||
|
value={buildForm.cpuReserve}
|
||||||
|
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
|
||||||
|
placeholder="0.25"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
|
||||||
|
<input
|
||||||
|
value={buildForm.memoryReserve}
|
||||||
|
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
|
||||||
|
placeholder="128Mi"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{buildForm.builder === 'static' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
|
||||||
|
<input
|
||||||
|
value={buildForm.staticCmd}
|
||||||
|
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
|
||||||
|
placeholder="npm ci && npm run build"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
|
||||||
|
<input
|
||||||
|
value={buildForm.staticDir}
|
||||||
|
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
|
||||||
|
placeholder="dist"
|
||||||
|
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updateServiceMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
updateServiceMutation.mutate(
|
||||||
|
{
|
||||||
|
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
|
||||||
|
cpu_reserve: buildForm.cpuReserve,
|
||||||
|
memory_reserve: buildForm.memoryReserve,
|
||||||
|
static_build_cmd: buildForm.staticCmd,
|
||||||
|
static_dir: buildForm.staticDir,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setBuildForm(null) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBuildForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
|
||||||
|
</div>
|
||||||
|
{updateServiceMutation.isError && (
|
||||||
|
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -2487,6 +2666,135 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && signedIn && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
|
||||||
|
{lifecycleForm === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lifecycleForm === null ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Duplicate this service into the same or another project, move it between projects, or delete it.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={lifecycleForm.cloneName}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
|
||||||
|
placeholder={`${service.name}-copy`}
|
||||||
|
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={lifecycleForm.cloneProject}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">same project</option>
|
||||||
|
{(projectsQuery.data ?? [])
|
||||||
|
.filter((p) => p.id !== projectId)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cloneMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
cloneMutation.mutate({
|
||||||
|
name: lifecycleForm.cloneName.trim() || undefined,
|
||||||
|
project_id: lifecycleForm.cloneProject || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
Copies config, volumes, domains, and variables. The clone starts stopped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={lifecycleForm.moveTarget}
|
||||||
|
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
|
||||||
|
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">choose a project…</option>
|
||||||
|
{(projectsQuery.data ?? [])
|
||||||
|
.filter((p) => p.id !== projectId)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Move ${service.name} to another project?`)) {
|
||||||
|
moveMutation.mutate(lifecycleForm.moveTarget);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{moveMutation.isPending ? 'Moving…' : 'Move'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
Variables, domains, and volumes move with the service. Running containers keep running.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
||||||
|
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deleteServiceMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
|
||||||
|
deleteServiceMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLifecycleForm(null)}
|
||||||
|
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
{(cloneMutation.isError || moveMutation.isError) && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
|
||||||
|
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
type Row = { key: string; value: string; isSecret: boolean };
|
||||||
|
|
||||||
|
// SharedVariablesSection edits project-level variables referenced from
|
||||||
|
// service env values as ${{shared.KEY}}.
|
||||||
|
export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [drafts, setDrafts] = useState<Row[] | null>(null);
|
||||||
|
|
||||||
|
const varsQuery = useQuery({
|
||||||
|
queryKey: ['project-variables', projectId],
|
||||||
|
queryFn: () => listProjectVariables(projectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (rows: Row[]) =>
|
||||||
|
updateProjectVariables(
|
||||||
|
projectId,
|
||||||
|
rows
|
||||||
|
.filter((r) => r.key.trim())
|
||||||
|
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
setDrafts(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
|
||||||
|
const dirty = drafts !== null;
|
||||||
|
|
||||||
|
const updateRow = (i: number, patch: Partial<Row>) => {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||||
|
setDrafts(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
|
Reference from any service as{' '}
|
||||||
|
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
|
||||||
|
deploy/redeploy — not live-updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate(rows)}
|
||||||
|
disabled={!dirty || saveMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{varsQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={row.key}
|
||||||
|
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||||
|
placeholder="KEY"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(i, { value: e.target.value })}
|
||||||
|
type={row.isSecret ? 'password' : 'text'}
|
||||||
|
placeholder="value"
|
||||||
|
autoComplete="off"
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
|
||||||
|
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
|
||||||
|
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
|
||||||
|
row.isSecret
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
|
||||||
|
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--error)]">
|
||||||
|
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
|
|||||||
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
import { ProjectCanvas } from '../canvas/ProjectCanvas';
|
||||||
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
|
||||||
import { MetricsDashboard } from '../components/MetricsDashboard';
|
import { MetricsDashboard } from '../components/MetricsDashboard';
|
||||||
|
import { SharedVariablesSection } from '../components/SharedVariablesSection';
|
||||||
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -971,6 +971,276 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/services/{id}/clone": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Clone service
|
||||||
|
* @description Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description Name for the clone (default "<name>-copy") */
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Target project (default same project; caller must own it)
|
||||||
|
*/
|
||||||
|
project_id?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
environment?: "production" | "preview" | "development";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Clone created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** Format: uuid */
|
||||||
|
service_id?: string;
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Name already exists in target project */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/services/{id}/move": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Move service
|
||||||
|
* @description Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** Format: uuid */
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Service moved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Name conflict in target project */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/registries": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List registry credentials
|
||||||
|
* @description Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Registry list */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
registries?: components["schemas"]["Registry"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Add registry credential
|
||||||
|
* @description Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RegistryRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Registry created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description A registry for this host already exists */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/registries/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Update registry credential
|
||||||
|
* @description Empty password keeps the stored credential — delete and recreate to remove a password.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RegistryRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Registry updated */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Registry not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
/** Remove registry credential */
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Registry removed */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Registry not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/services/{id}/domains": {
|
"/services/{id}/domains": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3570,6 +3840,98 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/projects/{id}/variables": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List shared project variables
|
||||||
|
* @description Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Variables retrieved */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
variables?: components["schemas"]["ServiceVariable"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Project not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Replace shared project variables
|
||||||
|
* @description Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateVariablesRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Variables updated */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
variables?: components["schemas"]["ServiceVariable"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/git/github-app/install-url": {
|
"/git/github-app/install-url": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8050,6 +8412,19 @@ export interface components {
|
|||||||
maintenance_mode?: boolean;
|
maintenance_mode?: boolean;
|
||||||
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
||||||
basic_auth?: string[];
|
basic_auth?: string[];
|
||||||
|
/**
|
||||||
|
* @description Build strategy; auto detects from repo contents
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||||
|
/** @description Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares) */
|
||||||
|
cpu_reserve?: string;
|
||||||
|
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||||
|
memory_reserve?: string;
|
||||||
|
/** @description Build command for the static builder (default "npm ci && npm run build") */
|
||||||
|
static_build_cmd?: string;
|
||||||
|
/** @description Output directory for the static builder (default dist) */
|
||||||
|
static_dir?: string;
|
||||||
/** @description Computed public URL (published port or domain) */
|
/** @description Computed public URL (published port or domain) */
|
||||||
public_url?: string;
|
public_url?: string;
|
||||||
/**
|
/**
|
||||||
@@ -8107,6 +8482,19 @@ export interface components {
|
|||||||
restart_policy?: string;
|
restart_policy?: string;
|
||||||
/** @description Volume/bind mounts applied to every replica */
|
/** @description Volume/bind mounts applied to every replica */
|
||||||
volumes?: components["schemas"]["ServiceVolume"][];
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
|
/**
|
||||||
|
* @description Build strategy (default auto)
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||||
|
/** @description Soft CPU reservation (fraction, e.g. 0.25) */
|
||||||
|
cpu_reserve?: string;
|
||||||
|
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||||
|
memory_reserve?: string;
|
||||||
|
/** @description Build command for the static builder */
|
||||||
|
static_build_cmd?: string;
|
||||||
|
/** @description Output directory for the static builder (default dist) */
|
||||||
|
static_dir?: string;
|
||||||
};
|
};
|
||||||
UpdateServiceRequest: {
|
UpdateServiceRequest: {
|
||||||
/** @description Service name */
|
/** @description Service name */
|
||||||
@@ -8151,6 +8539,19 @@ export interface components {
|
|||||||
maintenance_mode?: boolean;
|
maintenance_mode?: boolean;
|
||||||
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
||||||
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
||||||
|
/**
|
||||||
|
* @description Build strategy
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||||
|
/** @description Soft CPU reservation (empty string clears) */
|
||||||
|
cpu_reserve?: string;
|
||||||
|
/** @description Soft memory reservation (empty string clears) */
|
||||||
|
memory_reserve?: string;
|
||||||
|
/** @description Build command for the static builder (empty string clears) */
|
||||||
|
static_build_cmd?: string;
|
||||||
|
/** @description Output directory for the static builder (empty string clears) */
|
||||||
|
static_dir?: string;
|
||||||
};
|
};
|
||||||
ServiceDomain: {
|
ServiceDomain: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -8182,6 +8583,26 @@ export interface components {
|
|||||||
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
Registry: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
/** @description Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub */
|
||||||
|
host?: string;
|
||||||
|
username?: string;
|
||||||
|
has_password?: boolean;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
RegistryRequest: {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
/** @description Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. */
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
ServiceVolume: {
|
ServiceVolume: {
|
||||||
/**
|
/**
|
||||||
* @description volume = docker named volume, bind = host path
|
* @description volume = docker named volume, bind = host path
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export type ServiceEntity = {
|
|||||||
domains?: ServiceDomain[];
|
domains?: ServiceDomain[];
|
||||||
maintenanceMode?: boolean;
|
maintenanceMode?: boolean;
|
||||||
basicAuth?: string[];
|
basicAuth?: string[];
|
||||||
|
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
|
||||||
|
cpuReserve?: string;
|
||||||
|
memoryReserve?: string;
|
||||||
|
staticBuildCmd?: string;
|
||||||
|
staticDir?: string;
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -584,6 +589,11 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
domains: service.domains as ServiceDomain[] | undefined,
|
domains: service.domains as ServiceDomain[] | undefined,
|
||||||
maintenanceMode: service.maintenance_mode,
|
maintenanceMode: service.maintenance_mode,
|
||||||
basicAuth: service.basic_auth as string[] | undefined,
|
basicAuth: service.basic_auth as string[] | undefined,
|
||||||
|
builder: service.builder as ServiceEntity['builder'],
|
||||||
|
cpuReserve: service.cpu_reserve,
|
||||||
|
memoryReserve: service.memory_reserve,
|
||||||
|
staticBuildCmd: service.static_build_cmd,
|
||||||
|
staticDir: service.static_dir,
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CloneServiceInput = {
|
||||||
|
name?: string;
|
||||||
|
project_id?: string;
|
||||||
|
environment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
|
||||||
|
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveService(serviceId: string, projectId: string): Promise<void> {
|
||||||
|
await requestJson(`/services/${serviceId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ project_id: projectId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Registry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
has_password: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistryInput = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRegistries(): Promise<Registry[]> {
|
||||||
|
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
|
||||||
|
return payload.registries ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRegistry(input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>('/registries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
|
||||||
|
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRegistry(id: string): Promise<void> {
|
||||||
|
await requestJson(`/registries/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
||||||
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
||||||
// The API emits null (not []) for empty collections — coerce at the boundary.
|
// The API emits null (not []) for empty collections — coerce at the boundary.
|
||||||
@@ -1238,6 +1310,22 @@ export async function updateServiceVariables(
|
|||||||
return normalizeServiceVariables(payload.variables);
|
return normalizeServiceVariables(payload.variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
|
||||||
|
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
|
||||||
|
return normalizeServiceVariables(payload.variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectVariables(
|
||||||
|
projectId: string,
|
||||||
|
variables: UpdateServiceVariableInput[],
|
||||||
|
): Promise<ServiceVariable[]> {
|
||||||
|
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
|
||||||
|
`/projects/${projectId}/variables`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({ variables }) },
|
||||||
|
);
|
||||||
|
return normalizeServiceVariables(payload.variables);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.resource) {
|
if (input.resource) {
|
||||||
|
|||||||
@@ -667,6 +667,167 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Reconciled runtime state
|
description: Reconciled runtime state
|
||||||
|
|
||||||
|
/services/{id}/clone:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Clone service
|
||||||
|
description: Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name for the clone (default "<name>-copy")
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Target project (default same project; caller must own it)
|
||||||
|
environment:
|
||||||
|
type: string
|
||||||
|
enum: [production, preview, development]
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Clone created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
service_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
'409':
|
||||||
|
description: Name already exists in target project
|
||||||
|
|
||||||
|
/services/{id}/move:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Services
|
||||||
|
summary: Move service
|
||||||
|
description: Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [project_id]
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service moved
|
||||||
|
'409':
|
||||||
|
description: Name conflict in target project
|
||||||
|
|
||||||
|
/registries:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: List registry credentials
|
||||||
|
description: Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
registries:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Registry'
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Add registry credential
|
||||||
|
description: Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegistryRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Registry created
|
||||||
|
'409':
|
||||||
|
description: A registry for this host already exists
|
||||||
|
|
||||||
|
/registries/{id}:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Update registry credential
|
||||||
|
description: Empty password keeps the stored credential — delete and recreate to remove a password.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegistryRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry updated
|
||||||
|
'404':
|
||||||
|
description: Registry not found
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Registries
|
||||||
|
summary: Remove registry credential
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registry removed
|
||||||
|
'404':
|
||||||
|
description: Registry not found
|
||||||
|
|
||||||
/services/{id}/domains:
|
/services/{id}/domains:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -2388,6 +2549,78 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/projects/{id}/variables:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
summary: List shared project variables
|
||||||
|
description: Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Variables retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVariable'
|
||||||
|
'404':
|
||||||
|
description: Project not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
summary: Replace shared project variables
|
||||||
|
description: Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateVariablesRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Variables updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVariable'
|
||||||
|
'400':
|
||||||
|
description: Invalid input
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
# Git integration
|
# Git integration
|
||||||
/git/github-app/install-url:
|
/git/github-app/install-url:
|
||||||
get:
|
get:
|
||||||
@@ -5173,6 +5406,22 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Basic-auth usernames gating the route (hashes never returned)
|
description: Basic-auth usernames gating the route (hashes never returned)
|
||||||
|
builder:
|
||||||
|
type: string
|
||||||
|
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||||
|
description: Build strategy; auto detects from repo contents
|
||||||
|
cpu_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares)
|
||||||
|
memory_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft memory reservation (e.g. 128Mi)
|
||||||
|
static_build_cmd:
|
||||||
|
type: string
|
||||||
|
description: Build command for the static builder (default "npm ci && npm run build")
|
||||||
|
static_dir:
|
||||||
|
type: string
|
||||||
|
description: Output directory for the static builder (default dist)
|
||||||
public_url:
|
public_url:
|
||||||
type: string
|
type: string
|
||||||
description: Computed public URL (published port or domain)
|
description: Computed public URL (published port or domain)
|
||||||
@@ -5248,6 +5497,22 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/ServiceVolume'
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
description: Volume/bind mounts applied to every replica
|
description: Volume/bind mounts applied to every replica
|
||||||
|
builder:
|
||||||
|
type: string
|
||||||
|
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||||
|
description: Build strategy (default auto)
|
||||||
|
cpu_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft CPU reservation (fraction, e.g. 0.25)
|
||||||
|
memory_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft memory reservation (e.g. 128Mi)
|
||||||
|
static_build_cmd:
|
||||||
|
type: string
|
||||||
|
description: Build command for the static builder
|
||||||
|
static_dir:
|
||||||
|
type: string
|
||||||
|
description: Output directory for the static builder (default dist)
|
||||||
|
|
||||||
UpdateServiceRequest:
|
UpdateServiceRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -5312,6 +5577,22 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/BasicAuthCred'
|
$ref: '#/components/schemas/BasicAuthCred'
|
||||||
description: Replaces all basic-auth credentials; [] clears the gate
|
description: Replaces all basic-auth credentials; [] clears the gate
|
||||||
|
builder:
|
||||||
|
type: string
|
||||||
|
enum: [auto, railpack, nixpacks, dockerfile, static]
|
||||||
|
description: Build strategy
|
||||||
|
cpu_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft CPU reservation (empty string clears)
|
||||||
|
memory_reserve:
|
||||||
|
type: string
|
||||||
|
description: Soft memory reservation (empty string clears)
|
||||||
|
static_build_cmd:
|
||||||
|
type: string
|
||||||
|
description: Build command for the static builder (empty string clears)
|
||||||
|
static_dir:
|
||||||
|
type: string
|
||||||
|
description: Output directory for the static builder (empty string clears)
|
||||||
|
|
||||||
ServiceDomain:
|
ServiceDomain:
|
||||||
type: object
|
type: object
|
||||||
@@ -5365,6 +5646,44 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Plaintext on write — stored as bcrypt htpasswd; never returned
|
description: Plaintext on write — stored as bcrypt htpasswd; never returned
|
||||||
|
|
||||||
|
Registry:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
description: Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
has_password:
|
||||||
|
type: boolean
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
RegistryRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- host
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential.
|
||||||
|
|
||||||
ServiceVolume:
|
ServiceVolume:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -7684,5 +8003,7 @@ tags:
|
|||||||
description: Vulnerability scans and compliance reporting
|
description: Vulnerability scans and compliance reporting
|
||||||
- name: Cron
|
- name: Cron
|
||||||
description: Scheduled jobs
|
description: Scheduled jobs
|
||||||
|
- name: Registries
|
||||||
|
description: Private-image pull credentials
|
||||||
- name: Gateway
|
- name: Gateway
|
||||||
description: API gateway — upstream services, keys, analytics
|
description: API gateway — upstream services, keys, analytics
|
||||||
@@ -38,13 +38,16 @@ auth login|logout|status token auth and profiles
|
|||||||
tokens list|create|revoke personal access tokens
|
tokens list|create|revoke personal access tokens
|
||||||
projects list|get|create|delete
|
projects list|get|create|delete
|
||||||
services list|get|create|update|delete|start|stop|restart|redeploy
|
services list|get|create|update|delete|start|stop|restart|redeploy
|
||||||
|
services clone|move duplicate config or re-parent a service
|
||||||
services domains list|add|remove|default|check multi-domain + DNS preflight
|
services domains list|add|remove|default|check multi-domain + DNS preflight
|
||||||
|
registries list|add|update|remove private-image pull credentials
|
||||||
volumes list|delete docker volume inventory + cleanup (admin)
|
volumes list|delete docker volume inventory + cleanup (admin)
|
||||||
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
||||||
deployments list|get|logs|rollback|cancel
|
deployments list|get|logs|rollback|cancel
|
||||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||||
exec <service-id> -- <cmd> one-off container command
|
exec <service-id> -- <cmd> one-off container command
|
||||||
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
||||||
|
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
|
||||||
databases list|get|create|delete|action|backup|restore|download-backup
|
databases list|get|create|delete|action|backup|restore|download-backup
|
||||||
cron list|get|create|delete|trigger|executions
|
cron list|get|create|delete|trigger|executions
|
||||||
templates list|get|create|delete|deploy
|
templates list|get|create|delete|deploy
|
||||||
@@ -115,6 +118,58 @@ Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
|
|||||||
taken offline with empty responses. Basic-auth credentials are stored
|
taken offline with empty responses. Basic-auth credentials are stored
|
||||||
bcrypt-hashed (htpasswd) and gate every request.
|
bcrypt-hashed (htpasswd) and gate every request.
|
||||||
|
|
||||||
|
## Build strategy and resources
|
||||||
|
|
||||||
|
Source-built services pick a builder per service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --builder railpack # auto|railpack|nixpacks|dockerfile|static
|
||||||
|
containr services update <id> --builder static --static-cmd "npm run build" --static-dir dist
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto` detects from repo contents (Dockerfile → railpack → nixpacks).
|
||||||
|
`static` builds assets in a node stage and serves them from nginx —
|
||||||
|
`--static-cmd` runs inside the build stage (default
|
||||||
|
`npm ci && npm run build`), `--static-dir` is copied into the nginx image
|
||||||
|
(default `dist`, exposed on port 80).
|
||||||
|
|
||||||
|
Resource reservations complement the hard `cpu`/`memory` limits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --cpu-reserve 0.25 --memory-reserve 128Mi
|
||||||
|
```
|
||||||
|
|
||||||
|
`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/
|
||||||
|
`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty
|
||||||
|
string clears. `app_settings.default_cpu`/`default_memory` change the
|
||||||
|
instance-level defaults used at service creation;
|
||||||
|
`app_settings.capacity_policy=block` fails deployments whose requested
|
||||||
|
memory exceeds node capacity (default `warn` logs only).
|
||||||
|
|
||||||
|
## Clone, move, registries
|
||||||
|
|
||||||
|
`services clone` duplicates config, volumes, domains, access gates,
|
||||||
|
builder settings, and variables — ciphertext secret values are copied
|
||||||
|
verbatim (no decrypt/re-encrypt). The clone starts stopped:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
|
||||||
|
containr services move <id> <project-id> # name conflicts → 409
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: cloned domains are verbatim copies — the same hostname attached to
|
||||||
|
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
|
||||||
|
|
||||||
|
`registries` stores private-image pull credentials, owner-scoped and
|
||||||
|
encrypted at rest. Matched by image host on every image-sourced deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr registries add ghcr ghcr.io --username <user> --password <pat>
|
||||||
|
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
|
||||||
|
containr registries update <id> --username <new-user> # empty --password keeps stored
|
||||||
|
containr registries remove <id>
|
||||||
|
```
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|||||||
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
||||||
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
||||||
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
||||||
|
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
|
||||||
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
|
||||||
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
|
||||||
|
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
|
||||||
|
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
|
||||||
|
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
|
||||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
## Core objects
|
## Core objects
|
||||||
|
|
||||||
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -21,18 +21,25 @@ for platform in $PLATFORMS; do
|
|||||||
GOARCH=${platform#*/}
|
GOARCH=${platform#*/}
|
||||||
|
|
||||||
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
|
||||||
|
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
|
||||||
if [ "$GOOS" = "windows" ]; then
|
if [ "$GOOS" = "windows" ]; then
|
||||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||||
|
MCP_NAME="${MCP_NAME}.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Building for ${platform}..."
|
echo "📦 Building CLI for ${platform}..."
|
||||||
|
|
||||||
GOOS=$GOOS GOARCH=$GOARCH go build \
|
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||||
-ldflags "$LDFLAGS" \
|
-ldflags "$LDFLAGS" \
|
||||||
-o "bin/${OUTPUT_NAME}" \
|
-o "bin/${OUTPUT_NAME}" \
|
||||||
./cmd/cli
|
./cmd/cli
|
||||||
|
|
||||||
echo "✅ Built bin/${OUTPUT_NAME}"
|
echo "📦 Building MCP server for ${platform}..."
|
||||||
|
GOOS=$GOOS GOARCH=$GOARCH go build \
|
||||||
|
-ldflags "$LDFLAGS" \
|
||||||
|
-o "bin/${MCP_NAME}" \
|
||||||
|
./cmd/mcp
|
||||||
|
|
||||||
|
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🎉 CLI build complete!"
|
echo "🎉 CLI build complete!"
|
||||||
|
|||||||
Reference in new issue
Block a user