diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b04f1cd..ce05c7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,27 @@ permissions: contents: read 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: name: Infra (Compose + Scripts) runs-on: ubuntu-latest @@ -48,6 +69,8 @@ jobs: backend: name: Backend (Go) runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' defaults: run: working-directory: app/backend @@ -70,6 +93,8 @@ jobs: frontend: name: Frontend (React) runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' defaults: run: working-directory: app/frontend @@ -100,6 +125,8 @@ jobs: name: Compose boot smoke runs-on: ubuntu-latest timeout-minutes: 20 + needs: changes + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout uses: actions/checkout@v4 @@ -137,6 +164,7 @@ jobs: name: Build & publish images runs-on: ubuntu-latest needs: [backend, frontend, smoke] + if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success' permissions: contents: read packages: write @@ -227,7 +255,7 @@ jobs: name: Release runs-on: ubuntu-latest needs: [docker] - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && needs.docker.result == 'success' permissions: contents: write packages: write diff --git a/app/backend/internal/api/projectvars.go b/app/backend/internal/api/projectvars.go new file mode 100644 index 0000000..123ad31 --- /dev/null +++ b/app/backend/internal/api/projectvars.go @@ -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 +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 6e445c1..1aab499 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -356,6 +356,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.GET("/services/:id/variables", handleGetVariables) 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, // so these stay behind authentication like build logs. authed.GET("/services/:id/logs", handleGetLogs) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index 20ac604..0e692b1 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -119,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err } resolve := func(serviceName, key string) (string, bool) { + if strings.EqualFold(serviceName, "shared") { + return resolveSharedVar(db, service.ProjectID, key) + } id, ok := byName[strings.ToLower(serviceName)] if !ok { return "", false diff --git a/app/backend/internal/cli/commands/variables.go b/app/backend/internal/cli/commands/variables.go index 2e64895..f6d16c0 100644 --- a/app/backend/internal/cli/commands/variables.go +++ b/app/backend/internal/cli/commands/variables.go @@ -50,7 +50,7 @@ var varsSetCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { secret, _ := cmd.Flags().GetBool("secret") 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:] { k, v, found := strings.Cut(pair, "=") if !found || k == "" { @@ -73,7 +73,7 @@ var varsUnsetCmd = &cobra.Command{ drop[k] = true } 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] for _, v := range vars { if !drop[str(v, "key")] { @@ -85,20 +85,92 @@ var varsUnsetCmd = &cobra.Command{ }, } -func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) { - data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil) - if err != nil { - return nil, err - } - return unwrapList(data, "variables") +// Shared variables — project scope, referenced as ${{shared.KEY}}. + +var varsSharedCmd = &cobra.Command{ + Use: "shared", + Short: "Manage project-level shared variables (${{shared.KEY}})", } -func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error { +var sharedVarsListCmd = &cobra.Command{ + Use: "list ", + 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 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 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 } - 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 { return err } @@ -115,7 +187,7 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf if redeploy { body["redeploy"] = true } - data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body) + data, err = c.Do("PUT", path, body) if err != nil { return err } @@ -125,9 +197,9 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf } obj, _ := unwrapObject(data) 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 { - 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 } @@ -147,5 +219,7 @@ func init() { varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret") varsSetCmd.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) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 633ed4d..1ebb385 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -87,6 +87,8 @@ func init() { // Variables 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_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 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}) diff --git a/app/backend/migrations_goose/20261101000000_project_variables.sql b/app/backend/migrations_goose/20261101000000_project_variables.sql new file mode 100644 index 0000000..a3dacc8 --- /dev/null +++ b/app/backend/migrations_goose/20261101000000_project_variables.sql @@ -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; diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index a779c8a..76ec01a 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -1270,8 +1270,9 @@ export function ServiceDetailPage() {

Environment Variables

Runtime values injected at deploy. Reference another service's variable with{' '} - {'${{service.KEY}}'} — e.g.{' '} - {'${{postgres.POSTGRES_PASSWORD}}'} + {'${{service.KEY}}'} or a project + shared variable with{' '} + {'${{shared.KEY}}'}

diff --git a/app/frontend/src/features/workspace/components/SharedVariablesSection.tsx b/app/frontend/src/features/workspace/components/SharedVariablesSection.tsx new file mode 100644 index 0000000..cffb65d --- /dev/null +++ b/app/frontend/src/features/workspace/components/SharedVariablesSection.tsx @@ -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(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) => { + const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)); + setDrafts(next); + }; + + return ( +
+
+
+

Shared Variables

+

+ Reference from any service as{' '} + {'${{shared.KEY}}'}. Applied on next + deploy/redeploy — not live-updated. +

+
+
+ + +
+
+ + {varsQuery.isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( +

No shared variables.

+ ) : ( +
+ {rows.map((row, i) => ( +
+ 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" + /> + 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" + /> + + +
+ ))} +
+ )} + {saveMutation.isError && ( +

+ {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'} +

+ )} +
+ ); +} diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 979bb0d..8c0ba72 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections'; import { ProjectCanvas } from '../canvas/ProjectCanvas'; import { canvasStorageKey, clearCanvasMetadata } from '../storage'; import { MetricsDashboard } from '../components/MetricsDashboard'; +import { SharedVariablesSection } from '../components/SharedVariablesSection'; import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components'; import { ArrowLeft, @@ -935,6 +936,8 @@ export function ProjectWorkspacePage() { + {!isDemoMode && signedIn && } +

Local Data