Merge branch 'feat/shared-vars' into feat/mobile-app

This commit is contained in:
Tomas Dvorak committed 2026-10-09 01:17:57 +02:00
commit 1054679ca2
14 files changed
+610 -16

No files matched your search

+180
View File
@@ -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
}
+4
View File
@@ -344,6 +344,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)
+3
View File
@@ -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
+88 -14
View File
@@ -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()
if err != nil {
return err
}
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() c, err := client()
if err != nil { if err != nil {
return err return err
} }
vars, err := fetchVars(c, serviceID) 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)
} }
+2
View File
@@ -77,6 +77,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;
@@ -1222,8 +1222,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
+92
View File
@@ -3570,6 +3570,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;
+16
View File
@@ -1248,6 +1248,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) {
+72
View File
@@ -2388,6 +2388,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:
+1
View File
@@ -45,6 +45,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|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
+1
View File
@@ -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`).