mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts: # docs/guides/CLI.md
This commit is contained in:
commit
91f40a652d
16 files changed
+649
-20
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -357,6 +357,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)
|
||||||
|
|||||||
@@ -119,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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,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})
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1277,8 +1277,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4613,6 +4613,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;
|
||||||
|
|||||||
@@ -1359,6 +1359,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) {
|
||||||
|
|||||||
@@ -3051,6 +3051,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:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ 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|update|delete|action|backup|restore|download-backup|import-backup
|
databases list|get|create|update|delete|action|backup|restore|download-backup|import-backup
|
||||||
import-backup <id> <file.tar.gz|-> uploads an archive as a restore
|
import-backup <id> <file.tar.gz|-> uploads an archive as a restore
|
||||||
point; ships offsite too when a backup target is assigned
|
point; ships offsite too when a backup target is assigned
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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`).
|
- **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`).
|
||||||
|
|||||||
+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