mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2f2ace47 |
No files matched your search
@@ -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
|
||||
|
||||
+6
-3
@@ -390,9 +390,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-<hash>` private copy for the deployer so re-deploys don't dup.
|
||||
`api/templateregistry.go`, `.env.example`.
|
||||
|
||||
## 9b. Phase I — Companion surfaces
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ type ServiceTemplate struct {
|
||||
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"`
|
||||
}
|
||||
@@ -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,12 +211,19 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
template := mapSQLCTemplate(templateRow)
|
||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||
|
||||
@@ -644,6 +644,11 @@ export function TemplatesPage() {
|
||||
Public
|
||||
</span>
|
||||
) : null}
|
||||
{template.source === 'registry' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
|
||||
Registry
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
|
||||
@@ -10943,6 +10943,8 @@ export interface components {
|
||||
owner_id?: string | null;
|
||||
/** @description Whether a user-created template is published to the public catalog */
|
||||
is_public?: boolean;
|
||||
/** @description Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first. */
|
||||
source?: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Creation timestamp
|
||||
|
||||
@@ -181,6 +181,7 @@ export type TemplateEntity = {
|
||||
isOfficial: boolean;
|
||||
ownerId: string | null;
|
||||
isPublic: boolean;
|
||||
source?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
@@ -749,6 +750,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
|
||||
isOfficial: Boolean(template.is_official),
|
||||
ownerId: template.owner_id ?? null,
|
||||
isPublic: Boolean(template.is_public),
|
||||
source: template.source,
|
||||
createdAt: template.created_at,
|
||||
updatedAt: template.updated_at,
|
||||
};
|
||||
|
||||
@@ -7393,6 +7393,9 @@ components:
|
||||
is_public:
|
||||
type: boolean
|
||||
description: Whether a user-created template is published to the public catalog
|
||||
source:
|
||||
type: string
|
||||
description: 'Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first.'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Reference in new issue
Block a user