diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 54c7680..2ade4f0 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -45,6 +45,13 @@ type Config struct { // JWKSUri is the OIDC JWKS endpoint for JWT signature verification. JWKSUri string + // ── Crate registry ──────────────────────────────────────────────────────── + + // CrateRegistryURL is the base URL of the crate registry. + // Default: https://raw.githubusercontent.com/kleffio/crate-registry/main + // For local dev, use: file:///absolute/path/to/crate-registry + CrateRegistryURL string + // ── Plugin system ───────────────────────────────────────────────────────── // RuntimeProvider selects the container runtime for plugin management. @@ -86,6 +93,8 @@ func LoadConfig() (*Config, error) { IntrospectClientSecret: config.String("INTROSPECT_CLIENT_SECRET", ""), JWKSUri: config.String("JWKS_URI", ""), + CrateRegistryURL: config.String("CRATE_REGISTRY_URL", ""), + RuntimeProvider: config.String("RUNTIME_PROVIDER", "docker"), PluginRegistryURL: config.String("PLUGIN_REGISTRY_URL", ""), PluginNetwork: config.String("PLUGIN_NETWORK", "kleff"), diff --git a/internal/bootstrap/container.go b/internal/bootstrap/container.go index 521aa13..86c379b 100644 --- a/internal/bootstrap/container.go +++ b/internal/bootstrap/container.go @@ -16,6 +16,9 @@ import ( adminhttp "github.com/kleffio/platform/internal/core/admin/adapters/http" audithttp "github.com/kleffio/platform/internal/core/audit/adapters/http" billinghttp "github.com/kleffio/platform/internal/core/billing/adapters/http" + cataloghttp "github.com/kleffio/platform/internal/core/catalog/adapters/http" + catalogpersistence "github.com/kleffio/platform/internal/core/catalog/adapters/persistence" + catalogregistry "github.com/kleffio/platform/internal/core/catalog/adapters/registry" deploymentshttp "github.com/kleffio/platform/internal/core/deployments/adapters/http" nodeshttp "github.com/kleffio/platform/internal/core/nodes/adapters/http" organizationshttp "github.com/kleffio/platform/internal/core/organizations/adapters/http" @@ -43,6 +46,7 @@ type Container struct { // HTTP handler groups per domain module AuthHandler *pluginhttp.AuthHandler SetupHandler *pluginhttp.SetupHandler + CatalogHandler *cataloghttp.Handler OrganizationsHandler *organizationshttp.Handler DeploymentsHandler *deploymentshttp.Handler NodesHandler *nodeshttp.Handler @@ -84,6 +88,17 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { // Non-fatal: server continues even if some plugins fail to start. } + catalogStore := catalogpersistence.NewPostgresCatalogStore(db) + + // Sync crates, blueprints, and constructs from the remote crate registry. + // Non-fatal: if the registry is unreachable on startup, existing DB data is used. + crateRegistry := catalogregistry.New(cfg.CrateRegistryURL) + if err := crateRegistry.Sync(context.Background(), catalogStore); err != nil { + logger.Warn("crate registry sync warning", "error", err) + } else { + logger.Info("crate registry synced") + } + return &Container{ Config: cfg, Logger: logger, @@ -93,6 +108,7 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { AuthHandler: pluginhttp.NewAuthHandler(pluginMgr, logger), SetupHandler: pluginhttp.NewSetupHandler(pluginMgr, catalogRegistry, logger), + CatalogHandler: cataloghttp.NewHandler(catalogStore, logger), OrganizationsHandler: organizationshttp.NewHandler(logger), DeploymentsHandler: deploymentshttp.NewHandler(logger), NodesHandler: nodeshttp.NewHandler(logger), diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go index 4682478..b24cc01 100644 --- a/internal/bootstrap/http.go +++ b/internal/bootstrap/http.go @@ -34,6 +34,9 @@ func buildRouter(c *Container) http.Handler { // Public setup routes — only active before the first IDP is installed. c.SetupHandler.RegisterPublicRoutes(r) + // Catalog (crates + blueprints) is public — no login needed to browse. + c.CatalogHandler.RegisterRoutes(r) + // Authenticated routes r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth(c.TokenVerifier)) diff --git a/internal/core/catalog/adapters/http/handler.go b/internal/core/catalog/adapters/http/handler.go new file mode 100644 index 0000000..7a006c6 --- /dev/null +++ b/internal/core/catalog/adapters/http/handler.go @@ -0,0 +1,119 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kleffio/platform/internal/core/catalog/ports" +) + +// Handler exposes the catalog (crates, blueprints, constructs) over HTTP. +type Handler struct { + repo ports.CatalogRepository + logger *slog.Logger +} + +func NewHandler(repo ports.CatalogRepository, logger *slog.Logger) *Handler { + return &Handler{repo: repo, logger: logger} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/api/v1/crates", h.listCrates) + r.Get("/api/v1/crates/{id}", h.getCrate) + r.Get("/api/v1/blueprints", h.listBlueprints) + r.Get("/api/v1/blueprints/{id}", h.getBlueprint) + r.Get("/api/v1/constructs", h.listConstructs) + r.Get("/api/v1/constructs/{id}", h.getConstruct) +} + +func (h *Handler) listCrates(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") + + crates, err := h.repo.ListCrates(r.Context(), category) + if err != nil { + h.internalError(w, "list crates", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"crates": crates}) +} + +func (h *Handler) getCrate(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + crate, err := h.repo.GetCrate(r.Context(), id) + if err != nil { + h.notFound(w, err) + return + } + + writeJSON(w, http.StatusOK, crate) +} + +func (h *Handler) listBlueprints(w http.ResponseWriter, r *http.Request) { + crateID := r.URL.Query().Get("crate") + + blueprints, err := h.repo.ListBlueprints(r.Context(), crateID) + if err != nil { + h.internalError(w, "list blueprints", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"blueprints": blueprints}) +} + +func (h *Handler) getBlueprint(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + blueprint, err := h.repo.GetBlueprint(r.Context(), id) + if err != nil { + h.notFound(w, err) + return + } + + writeJSON(w, http.StatusOK, blueprint) +} + +func (h *Handler) listConstructs(w http.ResponseWriter, r *http.Request) { + crateID := r.URL.Query().Get("crate") + blueprintID := r.URL.Query().Get("blueprint") + + constructs, err := h.repo.ListConstructs(r.Context(), crateID, blueprintID) + if err != nil { + h.internalError(w, "list constructs", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"constructs": constructs}) +} + +func (h *Handler) getConstruct(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + construct, err := h.repo.GetConstruct(r.Context(), id) + if err != nil { + h.notFound(w, err) + return + } + + writeJSON(w, http.StatusOK, construct) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (h *Handler) internalError(w http.ResponseWriter, op string, err error) { + h.logger.Error(op, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) +} + +func (h *Handler) notFound(w http.ResponseWriter, err error) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) +} diff --git a/internal/core/catalog/adapters/persistence/store.go b/internal/core/catalog/adapters/persistence/store.go new file mode 100644 index 0000000..172da7d --- /dev/null +++ b/internal/core/catalog/adapters/persistence/store.go @@ -0,0 +1,369 @@ +package persistence + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/kleffio/platform/internal/core/catalog/domain" + "github.com/kleffio/platform/internal/core/catalog/ports" +) + +// PostgresCatalogStore implements ports.CatalogRepository against PostgreSQL. +type PostgresCatalogStore struct { + db *sql.DB +} + +func NewPostgresCatalogStore(db *sql.DB) ports.CatalogRepository { + return &PostgresCatalogStore{db: db} +} + +// ── Crates ──────────────────────────────────────────────────────────────────── + +func (s *PostgresCatalogStore) ListCrates(ctx context.Context, category string) ([]*domain.Crate, error) { + query := ` + SELECT id, name, category, description, logo, tags, official, created_at, updated_at + FROM crates` + args := []any{} + + if category != "" { + query += " WHERE category = $1" + args = append(args, category) + } + query += " ORDER BY name" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list crates: %w", err) + } + defer rows.Close() + + var crates []*domain.Crate + for rows.Next() { + c, err := scanCrate(rows) + if err != nil { + return nil, err + } + crates = append(crates, c) + } + return crates, rows.Err() +} + +func (s *PostgresCatalogStore) GetCrate(ctx context.Context, id string) (*domain.Crate, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, category, description, logo, tags, official, created_at, updated_at + FROM crates WHERE id = $1`, id) + + c, err := scanCrate(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("crate %q not found", id) + } + if err != nil { + return nil, fmt.Errorf("get crate: %w", err) + } + + blueprints, err := s.ListBlueprints(ctx, id) + if err != nil { + return nil, err + } + c.Blueprints = blueprints + return c, nil +} + +func (s *PostgresCatalogStore) UpsertCrate(ctx context.Context, c *domain.Crate) error { + tagsJSON, err := json.Marshal(c.Tags) + if err != nil { + return fmt.Errorf("upsert crate: marshal tags: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO crates (id, name, category, description, logo, tags, official, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category, + description = EXCLUDED.description, + logo = EXCLUDED.logo, + tags = EXCLUDED.tags, + official = EXCLUDED.official, + updated_at = NOW()`, + c.ID, c.Name, c.Category, c.Description, c.Logo, tagsJSON, c.Official, + ) + return err +} + +// ── Blueprints ──────────────────────────────────────────────────────────────── + +func (s *PostgresCatalogStore) ListBlueprints(ctx context.Context, crateID string) ([]*domain.Blueprint, error) { + query := ` + SELECT id, crate_id, construct_id, name, description, logo, version, + official, config, resources, extensions, created_at, updated_at + FROM blueprints WHERE 1=1` + args := []any{} + i := 1 + + if crateID != "" { + query += fmt.Sprintf(" AND crate_id = $%d", i) + args = append(args, crateID) + } + query += " ORDER BY name" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list blueprints: %w", err) + } + defer rows.Close() + + var blueprints []*domain.Blueprint + for rows.Next() { + b, err := scanBlueprint(rows) + if err != nil { + return nil, err + } + blueprints = append(blueprints, b) + } + return blueprints, rows.Err() +} + +func (s *PostgresCatalogStore) GetBlueprint(ctx context.Context, id string) (*domain.Blueprint, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, crate_id, construct_id, name, description, logo, version, + official, config, resources, extensions, created_at, updated_at + FROM blueprints WHERE id = $1`, id) + + b, err := scanBlueprint(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("blueprint %q not found", id) + } + if err != nil { + return nil, fmt.Errorf("get blueprint: %w", err) + } + return b, nil +} + +func (s *PostgresCatalogStore) UpsertBlueprint(ctx context.Context, b *domain.Blueprint) error { + configJSON, err := json.Marshal(b.Config) + if err != nil { + return fmt.Errorf("upsert blueprint: marshal config: %w", err) + } + resourcesJSON, err := json.Marshal(b.Resources) + if err != nil { + return fmt.Errorf("upsert blueprint: marshal resources: %w", err) + } + extJSON, err := json.Marshal(b.Extensions) + if err != nil { + return fmt.Errorf("upsert blueprint: marshal extensions: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO blueprints (id, crate_id, construct_id, name, description, logo, version, official, config, resources, extensions, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) + ON CONFLICT (id) DO UPDATE SET + crate_id = EXCLUDED.crate_id, + construct_id = EXCLUDED.construct_id, + name = EXCLUDED.name, + description = EXCLUDED.description, + logo = EXCLUDED.logo, + version = EXCLUDED.version, + official = EXCLUDED.official, + config = EXCLUDED.config, + resources = EXCLUDED.resources, + extensions = EXCLUDED.extensions, + updated_at = NOW()`, + b.ID, b.CrateID, b.ConstructID, b.Name, b.Description, b.Logo, b.Version, b.Official, + configJSON, resourcesJSON, extJSON, + ) + return err +} + +// ── Constructs ──────────────────────────────────────────────────────────────── + +func (s *PostgresCatalogStore) ListConstructs(ctx context.Context, crateID, blueprintID string) ([]*domain.Construct, error) { + query := ` + SELECT id, crate_id, blueprint_id, image, version, env, ports, + runtime_hints, extensions, outputs, created_at, updated_at + FROM constructs WHERE 1=1` + args := []any{} + i := 1 + + if crateID != "" { + query += fmt.Sprintf(" AND crate_id = $%d", i) + args = append(args, crateID) + i++ + } + if blueprintID != "" { + query += fmt.Sprintf(" AND blueprint_id = $%d", i) + args = append(args, blueprintID) + } + query += " ORDER BY id" + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list constructs: %w", err) + } + defer rows.Close() + + var constructs []*domain.Construct + for rows.Next() { + c, err := scanConstruct(rows) + if err != nil { + return nil, err + } + constructs = append(constructs, c) + } + return constructs, rows.Err() +} + +func (s *PostgresCatalogStore) GetConstruct(ctx context.Context, id string) (*domain.Construct, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, crate_id, blueprint_id, image, version, env, ports, + runtime_hints, extensions, outputs, created_at, updated_at + FROM constructs WHERE id = $1`, id) + + c, err := scanConstruct(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("construct %q not found", id) + } + if err != nil { + return nil, fmt.Errorf("get construct: %w", err) + } + return c, nil +} + +func (s *PostgresCatalogStore) UpsertConstruct(ctx context.Context, c *domain.Construct) error { + envJSON, err := json.Marshal(c.Env) + if err != nil { + return fmt.Errorf("upsert construct: marshal env: %w", err) + } + portsJSON, err := json.Marshal(c.Ports) + if err != nil { + return fmt.Errorf("upsert construct: marshal ports: %w", err) + } + hintsJSON, err := json.Marshal(c.RuntimeHints) + if err != nil { + return fmt.Errorf("upsert construct: marshal runtime_hints: %w", err) + } + extJSON, err := json.Marshal(c.Extensions) + if err != nil { + return fmt.Errorf("upsert construct: marshal extensions: %w", err) + } + outputsJSON, err := json.Marshal(c.Outputs) + if err != nil { + return fmt.Errorf("upsert construct: marshal outputs: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO constructs (id, crate_id, blueprint_id, image, version, env, ports, runtime_hints, extensions, outputs, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + ON CONFLICT (id) DO UPDATE SET + crate_id = EXCLUDED.crate_id, + blueprint_id = EXCLUDED.blueprint_id, + image = EXCLUDED.image, + version = EXCLUDED.version, + env = EXCLUDED.env, + ports = EXCLUDED.ports, + runtime_hints = EXCLUDED.runtime_hints, + extensions = EXCLUDED.extensions, + outputs = EXCLUDED.outputs, + updated_at = NOW()`, + c.ID, c.CrateID, c.BlueprintID, c.Image, c.Version, + envJSON, portsJSON, hintsJSON, extJSON, outputsJSON, + ) + return err +} + +// ── Scanners ────────────────────────────────────────────────────────────────── + +type scanner interface { + Scan(dest ...any) error +} + +func scanCrate(s scanner) (*domain.Crate, error) { + var ( + c domain.Crate + tagsJSON []byte + ) + err := s.Scan( + &c.ID, &c.Name, &c.Category, &c.Description, &c.Logo, + &tagsJSON, &c.Official, &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + if err := json.Unmarshal(tagsJSON, &c.Tags); err != nil { + return nil, fmt.Errorf("unmarshal crate tags: %w", err) + } + return &c, nil +} + +func scanBlueprint(s scanner) (*domain.Blueprint, error) { + var ( + b domain.Blueprint + configJSON, resourcesJSON, extensionsJSON []byte + createdAt, updatedAt time.Time + ) + + err := s.Scan( + &b.ID, &b.CrateID, &b.ConstructID, &b.Name, &b.Description, &b.Logo, + &b.Version, &b.Official, + &configJSON, &resourcesJSON, &extensionsJSON, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + + b.CreatedAt = createdAt + b.UpdatedAt = updatedAt + + if err := json.Unmarshal(configJSON, &b.Config); err != nil { + return nil, fmt.Errorf("unmarshal blueprint config: %w", err) + } + if err := json.Unmarshal(resourcesJSON, &b.Resources); err != nil { + return nil, fmt.Errorf("unmarshal blueprint resources: %w", err) + } + if err := json.Unmarshal(extensionsJSON, &b.Extensions); err != nil { + return nil, fmt.Errorf("unmarshal blueprint extensions: %w", err) + } + + return &b, nil +} + +func scanConstruct(s scanner) (*domain.Construct, error) { + var ( + c domain.Construct + envJSON, portsJSON, hintsJSON, extJSON, outputsJSON []byte + createdAt, updatedAt time.Time + ) + + err := s.Scan( + &c.ID, &c.CrateID, &c.BlueprintID, &c.Image, &c.Version, + &envJSON, &portsJSON, &hintsJSON, &extJSON, &outputsJSON, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + + c.CreatedAt = createdAt + c.UpdatedAt = updatedAt + + if err := json.Unmarshal(envJSON, &c.Env); err != nil { + return nil, fmt.Errorf("unmarshal construct env: %w", err) + } + if err := json.Unmarshal(portsJSON, &c.Ports); err != nil { + return nil, fmt.Errorf("unmarshal construct ports: %w", err) + } + if err := json.Unmarshal(hintsJSON, &c.RuntimeHints); err != nil { + return nil, fmt.Errorf("unmarshal construct runtime_hints: %w", err) + } + if err := json.Unmarshal(extJSON, &c.Extensions); err != nil { + return nil, fmt.Errorf("unmarshal construct extensions: %w", err) + } + if err := json.Unmarshal(outputsJSON, &c.Outputs); err != nil { + return nil, fmt.Errorf("unmarshal construct outputs: %w", err) + } + + return &c, nil +} diff --git a/internal/core/catalog/adapters/registry/github.go b/internal/core/catalog/adapters/registry/github.go new file mode 100644 index 0000000..9b49303 --- /dev/null +++ b/internal/core/catalog/adapters/registry/github.go @@ -0,0 +1,252 @@ +// Package registry fetches and syncs the Kleff crate catalog from the remote +// crate registry (by default, github.com/kleffio/crate-registry). +package registry + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/kleffio/platform/internal/core/catalog/domain" + "github.com/kleffio/platform/internal/core/catalog/ports" +) + +const defaultRegistryBaseURL = "https://raw.githubusercontent.com/kleffio/crate-registry/main" + +// CrateRegistry fetches crate/blueprint/construct definitions from a remote +// registry and upserts them into the database via CatalogRepository. +type CrateRegistry struct { + baseURL string + client *http.Client +} + +// New creates a CrateRegistry. baseURL defaults to the official registry if empty. +// For local development, pass a file:// URL pointing to your crate-registry checkout, +// e.g. "file:///home/user/crate-registry". +func New(baseURL string) *CrateRegistry { + if baseURL == "" { + baseURL = defaultRegistryBaseURL + } + // Trim trailing slash so path joining is consistent. + baseURL = strings.TrimRight(baseURL, "/") + return &CrateRegistry{ + baseURL: baseURL, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Sync fetches the full registry (index → crates → blueprints → constructs) and +// upserts everything into the provided store. Errors on individual files are +// logged but do not abort the full sync — partial data is better than nothing. +func (r *CrateRegistry) Sync(ctx context.Context, store ports.CatalogRepository) error { + // 1. Fetch index.json + indexData, err := r.fetch(ctx, "index.json") + if err != nil { + return fmt.Errorf("crate registry: fetch index: %w", err) + } + + var index crateIndex + if err := json.Unmarshal(indexData, &index); err != nil { + return fmt.Errorf("crate registry: parse index: %w", err) + } + + var syncErrors []string + + for _, ref := range index.Crates { + // 2. Fetch and upsert crate metadata + crateData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/crate.json", ref.ID)) + if err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("crate %s: %v", ref.ID, err)) + continue + } + + var wc wireCrate + if err := json.Unmarshal(crateData, &wc); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("crate %s parse: %v", ref.ID, err)) + continue + } + + if err := store.UpsertCrate(ctx, wc.toDomain()); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("crate %s upsert: %v", ref.ID, err)) + continue + } + + // 3. Fetch blueprint.json and construct.json from each version folder + for _, version := range ref.Versions { + bpData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/%s/blueprint.json", ref.ID, version)) + if err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s: %v", ref.ID, version, err)) + continue + } + + var wb wireBlueprint + if err := json.Unmarshal(bpData, &wb); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s parse: %v", ref.ID, version, err)) + continue + } + + if err := store.UpsertBlueprint(ctx, wb.toDomain()); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s upsert: %v", ref.ID, version, err)) + } + + cData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/%s/construct.json", ref.ID, version)) + if err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s: %v", ref.ID, version, err)) + continue + } + + var wc wireConstruct + if err := json.Unmarshal(cData, &wc); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s parse: %v", ref.ID, version, err)) + continue + } + + if err := store.UpsertConstruct(ctx, wc.toDomain()); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s upsert: %v", ref.ID, version, err)) + } + } + } + + if len(syncErrors) > 0 { + return fmt.Errorf("crate registry sync completed with %d error(s): %s", + len(syncErrors), strings.Join(syncErrors, "; ")) + } + return nil +} + +// fetch retrieves a single file from the registry (HTTP or file://). +func (r *CrateRegistry) fetch(ctx context.Context, path string) ([]byte, error) { + url := r.baseURL + "/" + path + + if strings.HasPrefix(url, "file://") { + filePath := strings.TrimPrefix(url, "file://") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file %s: %w", filePath, err) + } + return data, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + resp, err := r.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch %s: unexpected status %d", url, resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, fmt.Errorf("read body %s: %w", url, err) + } + return data, nil +} + +// ── Wire types (registry JSON format) ──────────────────────────────────────── + +// crateIndex is the top-level index.json structure. +type crateIndex struct { + Crates []crateRef `json:"crates"` +} + +// crateRef is an entry in index.json listing a crate's version folder names. +type crateRef struct { + ID string `json:"id"` + Versions []string `json:"versions"` +} + +// wireCrate maps crate.json from the registry. +type wireCrate struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Official bool `json:"official"` +} + +func (w wireCrate) toDomain() *domain.Crate { + return &domain.Crate{ + ID: w.ID, + Name: w.Name, + Category: w.Category, + Description: w.Description, + Logo: w.Logo, + Tags: w.Tags, + Official: w.Official, + } +} + +// wireBlueprint maps blueprints/*.json from the registry. +// Note: "crate" and "construct" are the registry field names, not crate_id/construct_id. +type wireBlueprint struct { + ID string `json:"id"` + Crate string `json:"crate"` + Construct string `json:"construct"` + Name string `json:"name"` + Description string `json:"description"` + Logo string `json:"logo"` + Version string `json:"version"` + Official bool `json:"official"` + Config []domain.ConfigField `json:"config"` + Resources domain.Resources `json:"resources"` + Extensions map[string]domain.BlueprintExtension `json:"extensions"` +} + +func (w wireBlueprint) toDomain() *domain.Blueprint { + return &domain.Blueprint{ + ID: w.ID, + CrateID: w.Crate, + ConstructID: w.Construct, + Name: w.Name, + Description: w.Description, + Logo: w.Logo, + Version: w.Version, + Official: w.Official, + Config: w.Config, + Resources: w.Resources, + Extensions: w.Extensions, + } +} + +// wireConstruct maps constructs/*.json from the registry. +type wireConstruct struct { + ID string `json:"id"` + Crate string `json:"crate"` + Blueprint string `json:"blueprint"` + Image string `json:"image"` + Version string `json:"version"` + Env map[string]string `json:"env"` + Ports []domain.Port `json:"ports"` + RuntimeHints domain.RuntimeHints `json:"runtime_hints"` + Extensions map[string]domain.ConstructExtension `json:"extensions"` + Outputs []domain.Output `json:"outputs"` +} + +func (w wireConstruct) toDomain() *domain.Construct { + return &domain.Construct{ + ID: w.ID, + CrateID: w.Crate, + BlueprintID: w.Blueprint, + Image: w.Image, + Version: w.Version, + Env: w.Env, + Ports: w.Ports, + RuntimeHints: w.RuntimeHints, + Extensions: w.Extensions, + Outputs: w.Outputs, + } +} diff --git a/internal/core/catalog/domain/blueprint.go b/internal/core/catalog/domain/blueprint.go new file mode 100644 index 0000000..5a11831 --- /dev/null +++ b/internal/core/catalog/domain/blueprint.go @@ -0,0 +1,132 @@ +package domain + +import "time" + +// Crate is a software category that groups related blueprints. +// Examples: minecraft, redis, postgresql. +type Crate struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Official bool `json:"official"` + Blueprints []*Blueprint `json:"blueprints,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Blueprint is the user-facing definition of a runnable service. +// It contains what the user configures: version, players, memory, etc. +// It does NOT contain Docker images, ports, or runtime details — those live in Construct. +type Blueprint struct { + ID string `json:"id"` + CrateID string `json:"crate_id"` + ConstructID string `json:"construct_id"` + Name string `json:"name"` + Description string `json:"description"` + Logo string `json:"logo"` + Version string `json:"version"` + Official bool `json:"official"` + Config []ConfigField `json:"config"` + Resources Resources `json:"resources"` + Extensions map[string]BlueprintExtension `json:"extensions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// BlueprintExtension declares that this blueprint supports an extension type +// (e.g. plugin, mod) and which sources users can install from. +type BlueprintExtension struct { + Enabled bool `json:"enabled"` + Sources []string `json:"sources"` +} + +// Construct is the technical recipe for running a blueprint. +// It contains the Docker image, fixed env vars, ports, runtime hints, and +// extension install details. It is never shown directly to the user. +type Construct struct { + ID string `json:"id"` + CrateID string `json:"crate_id"` + BlueprintID string `json:"blueprint_id"` + Image string `json:"image"` + Version string `json:"version"` + Env map[string]string `json:"env"` + Ports []Port `json:"ports"` + RuntimeHints RuntimeHints `json:"runtime_hints"` + Extensions map[string]ConstructExtension `json:"extensions"` + Outputs []Output `json:"outputs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ConstructExtension holds the technical details for installing an extension +// (e.g. which path to drop JARs, whether to restart after install). +type ConstructExtension struct { + InstallMethod string `json:"install_method"` // "jar-drop", "folder-drop", "file-drop", etc. + InstallPath string `json:"install_path"` + FileExtension string `json:"file_extension,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + RequiresRestart bool `json:"requires_restart"` +} + +// RuntimeHints control how the daemon deploys the container. +type RuntimeHints struct { + // KubernetesStrategy is one of "", "agones", or "statefulset". + KubernetesStrategy string `json:"kubernetes_strategy"` + + // ExposeUDP indicates whether UDP ports need host-level exposure. + ExposeUDP bool `json:"expose_udp"` + + // PersistentStorage indicates whether a PVC should be created. + PersistentStorage bool `json:"persistent_storage"` + + // StoragePath is the mount path inside the container for the PVC. + StoragePath string `json:"storage_path,omitempty"` + + // StorageGB is the requested PVC size in gigabytes. + StorageGB int `json:"storage_gb,omitempty"` + + // HealthCheckPath and HealthCheckPort are used for HTTP health probes. + HealthCheckPath string `json:"health_check_path"` + HealthCheckPort int `json:"health_check_port"` +} + +// Resources defines the default resource allocation for a service. +type Resources struct { + MemoryMB int `json:"memory_mb"` + CPUMillicores int `json:"cpu_millicores"` + DiskGB int `json:"disk_gb"` +} + +// Port defines a network port the container exposes. +type Port struct { + Name string `json:"name"` + Container int `json:"container"` + Protocol string `json:"protocol"` // "tcp" or "udp" + Expose bool `json:"expose"` + Label string `json:"label"` +} + +// ConfigField defines one field in the deploy form shown to the user. +// The key becomes an environment variable inside the container. +type ConfigField struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Type string `json:"type"` // "string", "number", "boolean", "select", "secret" + Options []string `json:"options,omitempty"` + Default any `json:"default,omitempty"` + Required bool `json:"required"` + AutoGenerate bool `json:"auto_generate,omitempty"` + AutoGenerateLength int `json:"auto_generate_length,omitempty"` +} + +// Output is a value the service exposes after starting. +// Other services in a template can reference these. +type Output struct { + Key string `json:"key"` + Description string `json:"description"` + ValueTemplate string `json:"value_template"` +} diff --git a/internal/core/catalog/ports/repository.go b/internal/core/catalog/ports/repository.go new file mode 100644 index 0000000..fd5468c --- /dev/null +++ b/internal/core/catalog/ports/repository.go @@ -0,0 +1,42 @@ +package ports + +import ( + "context" + + "github.com/kleffio/platform/internal/core/catalog/domain" +) + +// CatalogRepository is the storage interface for crates, blueprints, and constructs. +type CatalogRepository interface { + // ── Read ────────────────────────────────────────────────────────────────── + + // ListCrates returns all crates, optionally filtered by category. + // Blueprints are not populated on list results. + ListCrates(ctx context.Context, category string) ([]*domain.Crate, error) + + // GetCrate returns a single crate with its blueprints populated. + GetCrate(ctx context.Context, id string) (*domain.Crate, error) + + // ListBlueprints returns all blueprints, optionally filtered by crate. + ListBlueprints(ctx context.Context, crateID string) ([]*domain.Blueprint, error) + + // GetBlueprint returns a single blueprint by ID. + GetBlueprint(ctx context.Context, id string) (*domain.Blueprint, error) + + // ListConstructs returns all constructs, optionally filtered by crate or blueprint. + ListConstructs(ctx context.Context, crateID, blueprintID string) ([]*domain.Construct, error) + + // GetConstruct returns a single construct by ID. + GetConstruct(ctx context.Context, id string) (*domain.Construct, error) + + // ── Write (used by the registry sync) ───────────────────────────────────── + + // UpsertCrate inserts or updates a crate. + UpsertCrate(ctx context.Context, c *domain.Crate) error + + // UpsertBlueprint inserts or updates a blueprint. + UpsertBlueprint(ctx context.Context, b *domain.Blueprint) error + + // UpsertConstruct inserts or updates a construct. + UpsertConstruct(ctx context.Context, c *domain.Construct) error +} diff --git a/internal/database/migrations/002_blueprints.sql b/internal/database/migrations/002_blueprints.sql new file mode 100644 index 0000000..7a37f88 --- /dev/null +++ b/internal/database/migrations/002_blueprints.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS crates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + description TEXT NOT NULL, + logo TEXT NOT NULL DEFAULT '', + tags JSONB NOT NULL DEFAULT '[]', + official BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS blueprints ( + id TEXT PRIMARY KEY, + crate_id TEXT NOT NULL REFERENCES crates(id) ON DELETE CASCADE, + construct_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + description TEXT NOT NULL, + logo TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL, + official BOOLEAN NOT NULL DEFAULT false, + resources JSONB NOT NULL DEFAULT '{}', + config JSONB NOT NULL DEFAULT '[]', + extensions JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS constructs ( + id TEXT PRIMARY KEY, + crate_id TEXT NOT NULL REFERENCES crates(id) ON DELETE CASCADE, + blueprint_id TEXT NOT NULL REFERENCES blueprints(id) ON DELETE CASCADE, + image TEXT NOT NULL, + version TEXT NOT NULL, + env JSONB NOT NULL DEFAULT '{}', + ports JSONB NOT NULL DEFAULT '[]', + runtime_hints JSONB NOT NULL DEFAULT '{}', + extensions JSONB NOT NULL DEFAULT '{}', + outputs JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);