From e05445715696bf8739f8a864e88eabf53935766c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:46:35 +0200 Subject: [PATCH] feat(templates): remote template registry via CONTAINR_TEMPLATE_REGISTRY_URL (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /templates merges a remote Containr catalog as source:"registry" (5s timeout, 60s cache, offline-safe) - GET /templates/:id falls through to the registry - Deploy of a registry template materializes a deterministic private copy for the deployer — repeat deploys reuse it - Templates page badges registry entries; openapi + env documented Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 6 + ROADMAP2.md | 9 +- app/backend/internal/api/templateregistry.go | 186 ++++++++++++++++++ app/backend/internal/api/templates.go | 58 ++++-- .../templates/pages/TemplatesPage.tsx | 5 + app/frontend/src/generated/api-types.ts | 2 + app/frontend/src/lib/api-client.ts | 2 + docs/api/openapi.yaml | 3 + 8 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 app/backend/internal/api/templateregistry.go diff --git a/.env.example b/.env.example index 5d30343..441f421 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,12 @@ SECRETS_KEY= CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret # Optional rotation list (comma-separated). When set, this takes precedence. # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret +# Bundled agent binaries dir served at /api/agents/download/:platform. +# CONTAINR_AGENT_BIN_DIR=/agents +# Optional remote template catalog (another Containr instance). Its +# templates merge into GET /templates as source:"registry" and +# materialize locally on deploy. The bundled catalog works without it. +# CONTAINR_TEMPLATE_REGISTRY_URL=https://templates.example.com CORS_ORIGINS=http://localhost:3000,http://localhost:8080 # Optional build-time API URL overrides. Leave empty for the default # same-origin setup - the frontend nginx proxies /api to the backend, which diff --git a/ROADMAP2.md b/ROADMAP2.md index 3181eec..6081d64 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -403,9 +403,12 @@ and wired refs, one click or one CLI call. deploys a compose file straight into a project; CLI `import compose`, MCP tool, workspace import modal; project delete cascades to graph-provisioned managed databases (`database_services.project_id`). -- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL` - fetch/publish; bundled catalog works offline (dflow's self-contained - principle — nothing phones home). +- [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a + remote Containr catalog into `GET /templates` as `source:"registry"` + (60s cache, dead registry never blocks local). `GET /templates/:id` + falls through to the registry; deploy materializes a deterministic + `reg-` private copy for the deployer so re-deploys don't dup. + `api/templateregistry.go`, `.env.example`. ## 9b. Phase I — Companion surfaces diff --git a/app/backend/internal/api/templateregistry.go b/app/backend/internal/api/templateregistry.go new file mode 100644 index 0000000..a248d7b --- /dev/null +++ b/app/backend/internal/api/templateregistry.go @@ -0,0 +1,186 @@ +package api + +// Template registry — CONTAINR_TEMPLATE_REGISTRY_URL points at another +// Containr instance (or any server answering the same template shape): +// GET {base}/api/v1/templates → {"templates": [ServiceTemplate…]} +// GET {base}/api/v1/templates/:id → {"template": …, "config": …, "variables": …} +// Registry entries merge into the catalog as source:"registry"; deploying +// one materializes it as a user template for the deployer first. The +// bundled catalog always works offline — a dead registry never blocks it. + +import ( + "containr/internal/database/sqlcdb" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" +) + +const registryCacheTTL = 60 * time.Second + +var registryCache struct { + mu sync.Mutex + fetchedAt time.Time + templates []ServiceTemplate +} + +func registryBaseURL() string { + base := strings.TrimRight(strings.TrimSpace(os.Getenv("CONTAINR_TEMPLATE_REGISTRY_URL")), "/") + base = strings.TrimSuffix(base, "/api/v1") + return base +} + +// fetchRegistryTemplates lists the remote catalog, cached for +// registryCacheTTL. Failures degrade to an empty list — never fatal. +func fetchRegistryTemplates(ctx context.Context) []ServiceTemplate { + if registryBaseURL() == "" { + return nil + } + registryCache.mu.Lock() + fresh := time.Since(registryCache.fetchedAt) < registryCacheTTL && registryCache.templates != nil + cached := registryCache.templates + registryCache.mu.Unlock() + if fresh { + return cached + } + + var templates []ServiceTemplate + var payload struct { + Templates []ServiceTemplate `json:"templates"` + } + if err := registryGet(ctx, "/api/v1/templates", &payload); err == nil { + templates = payload.Templates + } + for i := range templates { + templates[i].Source = "registry" + } + + registryCache.mu.Lock() + registryCache.fetchedAt = time.Now() + registryCache.templates = templates + registryCache.mu.Unlock() + return templates +} + +// fetchRegistryTemplate fetches one remote template's full document +// (template + config + variables). nil when the registry lacks it. +func fetchRegistryTemplate(ctx context.Context, id string) *ServiceTemplate { + base := registryBaseURL() + if base == "" { + return nil + } + var payload struct { + Template ServiceTemplate `json:"template"` + Config json.RawMessage `json:"config"` + Variables []TemplateVariable `json:"variables"` + } + if err := registryGet(ctx, "/api/v1/templates/"+id, &payload); err != nil { + return nil + } + t := payload.Template + if t.ID == "" { + t.ID = id + } + t.Source = "registry" + // The detail endpoint returns config/variables decoded; store them back + // as raw JSON so they can persist unmodified into a local copy. + if len(payload.Config) > 0 { + t.Config = string(payload.Config) + } + if payload.Variables != nil { + if raw, err := json.Marshal(payload.Variables); err == nil { + t.Variables = string(raw) + } + } + return &t +} + +func registryGet(ctx context.Context, path string, out interface{}) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryBaseURL()+path, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errRegistry{resp.StatusCode} + } + return json.NewDecoder(resp.Body).Decode(out) +} + +type errRegistry struct{ status int } + +func (e errRegistry) Error() string { return "template registry status " + http.StatusText(e.status) } + +// respondRegistryTemplate answers GET /templates/:id for a registry-only +// template with the same envelope shape as local templates. +func respondRegistryTemplate(c *gin.Context, t *ServiceTemplate) { + config := json.RawMessage(t.Config) + if len(config) == 0 { + config = json.RawMessage(`{}`) + } + variables := json.RawMessage(t.Variables) + if len(variables) == 0 { + variables = json.RawMessage(`[]`) + } + c.JSON(http.StatusOK, gin.H{ + "template": t, + "config": config, + "variables": variables, + }) +} + +// materializeRegistryTemplate copies a registry template into the local +// catalog as a private template owned by the deployer, so the normal +// deploy path (and future re-deploys) work without the registry. The +// local id is deterministic per (registry, template, user) so repeat +// deploys reuse the same copy instead of accumulating duplicates. +func materializeRegistryTemplate(c *gin.Context, q *sqlcdb.Queries, userID, templateID string) (sqlcdb.ServiceTemplate, error) { + ownerUUID, err := uuid.Parse(userID) + if err != nil { + return sqlcdb.ServiceTemplate{}, err + } + sum := sha256.Sum256([]byte(registryBaseURL() + "|" + templateID + "|" + userID)) + localID := "reg-" + hex.EncodeToString(sum[:8]) + if existing, err := q.GetServiceTemplateByID(c.Request.Context(), localID); err == nil { + return existing, nil + } + + rt := fetchRegistryTemplate(c.Request.Context(), templateID) + if rt == nil { + return sqlcdb.ServiceTemplate{}, sql.ErrNoRows + } + variables := pqtype.NullRawMessage{} + if rt.Variables != "" { + variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(rt.Variables), Valid: true} + } + if err := q.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{ + ID: localID, + Name: rt.Name, + Description: nullableText(rt.Description), + Category: rt.Category, + Logo: nullableText(rt.Logo), + Config: json.RawMessage(rt.Config), + Variables: variables, + OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true}, + IsPublic: false, + }); err != nil { + return sqlcdb.ServiceTemplate{}, err + } + return q.GetServiceTemplateByID(c.Request.Context(), localID) +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index 46d8fd1..596cd56 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -21,18 +21,20 @@ import ( ) type ServiceTemplate struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Category string `json:"category" db:"category"` - Logo string `json:"logo" db:"logo"` - Config string `json:"config" db:"config"` - Variables string `json:"variables" db:"variables"` - IsOfficial bool `json:"is_official" db:"is_official"` - OwnerID string `json:"owner_id,omitempty" db:"owner_id"` - IsPublic bool `json:"is_public" db:"is_public"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Category string `json:"category" db:"category"` + Logo string `json:"logo" db:"logo"` + Config string `json:"config" db:"config"` + Variables string `json:"variables" db:"variables"` + IsOfficial bool `json:"is_official" db:"is_official"` + OwnerID string `json:"owner_id,omitempty" db:"owner_id"` + IsPublic bool `json:"is_public" db:"is_public"` + // Source marks catalog origin: "" (local/official) or "registry". + Source string `json:"source,omitempty" db:"-"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } type TemplateConfig struct { @@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) { } templates := make([]ServiceTemplate, 0, len(templateRows)) + localIDs := make(map[string]bool, len(templateRows)) for _, row := range templateRows { + localIDs[row.ID] = true templates = append(templates, mapSQLCTemplate(row)) } + // Merge the configured remote registry (CONTAINR_TEMPLATE_REGISTRY_URL). + // Registry ids never shadow local templates. + for _, t := range fetchRegistryTemplates(ctx) { + if localIDs[t.ID] { + continue + } + if category != "" && t.Category != category { + continue + } + templates = append(templates, t) + } + c.JSON(http.StatusOK, gin.H{"templates": templates}) } @@ -112,6 +128,11 @@ func handleGetTemplate(c *gin.Context) { row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID) if err != nil { if errors.Is(err, sql.ErrNoRows) { + // Registry fallback — remote templates materialize read-only. + if rt := fetchRegistryTemplate(c.Request.Context(), templateID); rt != nil { + respondRegistryTemplate(c, rt) + return + } c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) return } @@ -190,11 +211,18 @@ func handleCreateFromTemplate(c *gin.Context) { templateRow, err := queries.GetServiceTemplateByID(ctx, templateID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) + // Registry template — materialize a local copy for the + // deployer, then continue down the normal deploy path. + row, mErr := materializeRegistryTemplate(c, queries, userID, templateID) + if mErr != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) + return + } + templateRow = row + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"}) - return } template := mapSQLCTemplate(templateRow) diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index 0d9903e..f0a4d5c 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -644,6 +644,11 @@ export function TemplatesPage() { Public ) : null} + {template.source === 'registry' ? ( + + Registry + + ) : null}