From 1ebfeca996ccb14be512cbffdc195069827b6ae1 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Tue, 7 Apr 2026 20:23:40 -0400 Subject: [PATCH 1/5] first implementation --- internal/bootstrap/config.go | 9 + internal/bootstrap/container.go | 16 + internal/bootstrap/http.go | 3 + .../core/catalog/adapters/http/handler.go | 119 ++++++ .../catalog/adapters/persistence/store.go | 370 ++++++++++++++++++ .../core/catalog/adapters/registry/github.go | 256 ++++++++++++ internal/core/catalog/domain/blueprint.go | 132 +++++++ internal/core/catalog/ports/repository.go | 42 ++ .../database/migrations/002_blueprints.sql | 331 ++++++++++++++++ .../database/migrations/003_constructs.sql | 39 ++ 10 files changed, 1317 insertions(+) create mode 100644 internal/core/catalog/adapters/http/handler.go create mode 100644 internal/core/catalog/adapters/persistence/store.go create mode 100644 internal/core/catalog/adapters/registry/github.go create mode 100644 internal/core/catalog/domain/blueprint.go create mode 100644 internal/core/catalog/ports/repository.go create mode 100644 internal/database/migrations/002_blueprints.sql create mode 100644 internal/database/migrations/003_constructs.sql 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..6e631ae --- /dev/null +++ b/internal/core/catalog/adapters/persistence/store.go @@ -0,0 +1,370 @@ +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) + i++ + } + 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..a4f20b7 --- /dev/null +++ b/internal/core/catalog/adapters/registry/github.go @@ -0,0 +1,256 @@ +// 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 and upsert blueprints + for _, bpID := range ref.Blueprints { + bpData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/blueprints/%s.json", ref.ID, bpID)) + if err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s: %v", ref.ID, bpID, 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, bpID, err)) + continue + } + + if err := store.UpsertBlueprint(ctx, wb.toDomain()); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s upsert: %v", ref.ID, bpID, err)) + } + } + + // 4. Fetch and upsert constructs + for _, cID := range ref.Constructs { + cData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/constructs/%s.json", ref.ID, cID)) + if err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s: %v", ref.ID, cID, 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, cID, err)) + continue + } + + if err := store.UpsertConstruct(ctx, wc.toDomain()); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s upsert: %v", ref.ID, cID, 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 blueprint and construct IDs. +type crateRef struct { + ID string `json:"id"` + Blueprints []string `json:"blueprints"` + Constructs []string `json:"constructs"` +} + +// 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..5abe146 --- /dev/null +++ b/internal/database/migrations/002_blueprints.sql @@ -0,0 +1,331 @@ +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, + name TEXT NOT NULL, + description TEXT NOT NULL, + long_description TEXT NOT NULL DEFAULT '', + logo TEXT NOT NULL DEFAULT '', + image TEXT NOT NULL, + version TEXT NOT NULL, + official BOOLEAN NOT NULL DEFAULT false, + category TEXT NOT NULL, + runtime_hints JSONB NOT NULL DEFAULT '{}', + resources JSONB NOT NULL DEFAULT '{}', + ports JSONB NOT NULL DEFAULT '[]', + config JSONB NOT NULL DEFAULT '[]', + outputs JSONB NOT NULL DEFAULT '[]', + extensions JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Seed: Crates ────────────────────────────────────────────────────────────── + +INSERT INTO crates (id, name, category, description, logo, tags, official) VALUES +('minecraft', 'Minecraft', 'game-server', 'The world''s most popular game. Java and Bedrock editions.', '', '["sandbox","survival","multiplayer"]', true), +('fivem', 'FiveM', 'game-server', 'GTA V multiplayer modification platform.', '', '["gta","roleplay","multiplayer"]', true), +('gmod', 'Garry''s Mod','game-server', 'A physics sandbox game with extensive workshop support.', '', '["sandbox","workshop"]', true), +('cs2', 'CS2', 'game-server', 'Counter-Strike 2 dedicated server.', '', '["fps","competitive"]', true), +('rust-game', 'Rust', 'game-server', 'Survival multiplayer game with Oxide plugin support.', '', '["survival","oxide"]', true), +('valheim', 'Valheim', 'game-server', 'Viking survival and exploration game.', '', '["survival","viking"]', true), +('ark', 'ARK', 'game-server', 'ARK: Survival Evolved and Survival Ascended.', '', '["survival","dinosaurs"]', true), +('redis', 'Redis', 'cache', 'In-memory data store, cache, and message broker.', '', '["cache","in-memory"]', true), +('postgresql', 'PostgreSQL', 'database', 'The world''s most advanced open source relational database.','', '["sql","relational"]', true), +('mysql', 'MySQL', 'database', 'Popular open source relational database.', '', '["sql","relational"]', true), +('mongodb', 'MongoDB', 'database', 'General purpose, document-based distributed database.', '', '["nosql","document"]', true), +('rabbitmq', 'RabbitMQ', 'messaging', 'Open source message broker with management UI.', '', '["amqp","messaging"]', true), +('nginx', 'Nginx', 'web', 'High-performance HTTP server and reverse proxy.', '', '["proxy","web","http"]', true), +('caddy', 'Caddy', 'web', 'Modern web server with automatic HTTPS.', '', '["proxy","https","web"]', true) +ON CONFLICT (id) DO NOTHING; + +-- ── Seed: Blueprints ────────────────────────────────────────────────────────── + +INSERT INTO blueprints (id, crate_id, name, description, image, version, official, category, runtime_hints, resources, ports, config, outputs, extensions) VALUES + +-- Minecraft +('minecraft-papermc', 'minecraft', 'PaperMC', 'High-performance Minecraft server fork with plugin support.', + 'itzg/minecraft-server', '1.3.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":2048,"cpu_millicores":2000,"disk_gb":10}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"},{"name":"query","container":25565,"protocol":"udp","expose":true,"label":"Query"}]', + '[{"key":"EULA","label":"Accept EULA","description":"You must accept the Minecraft EULA to run a server.","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["PAPER"],"default":"PAPER","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.21.3","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MOTD","label":"MOTD","description":"Message shown in the server list.","type":"string","default":"A Minecraft Server","required":false},{"key":"RCON_PASSWORD","label":"RCON Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16},{"key":"MEMORY","label":"JVM Memory","description":"Java heap size, e.g. 2G or 4G.","type":"string","default":"2G","required":false}]', + '[{"key":"ADDRESS","description":"Internal host:port for other services to connect to.","value_template":"{{ .ContainerName }}:25565"},{"key":"RCON_ADDRESS","description":"RCON host:port.","value_template":"{{ .ContainerName }}:25575"}]', + '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]}}' +), + +('minecraft-spigot', 'minecraft', 'Spigot', 'Widely used Minecraft server with plugin support.', + 'itzg/minecraft-server', '1.2.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":2048,"cpu_millicores":2000,"disk_gb":10}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["SPIGOT"],"default":"SPIGOT","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"2G","required":false}]', + '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', + '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]}}' +), + +('minecraft-forge', 'minecraft', 'Forge', 'Modded Minecraft with Forge mod loader.', + 'itzg/minecraft-server', '1.2.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["FORGE"],"default":"FORGE","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"FORGEVERSION","label":"Forge Version","type":"string","default":"RECOMMENDED","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"4G","required":false}]', + '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', + '{"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' +), + +('minecraft-fabric', 'minecraft', 'Fabric', 'Lightweight modded Minecraft with Fabric mod loader.', + 'itzg/minecraft-server', '1.1.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":3072,"cpu_millicores":2000,"disk_gb":15}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["FABRIC"],"default":"FABRIC","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"3G","required":false}]', + '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', + '{"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' +), + +('minecraft-arclight', 'minecraft', 'Arclight', 'Hybrid server supporting both plugins and mods simultaneously.', + 'itzg/minecraft-server', '1.1.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["ARCLIGHT"],"default":"ARCLIGHT","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["1.21.4","1.20.4","1.20.1"],"default":"1.21.4","required":true},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"4G","required":false}]', + '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', + '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]},"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' +), + +('minecraft-velocity', 'minecraft', 'Velocity', 'High-performance Minecraft proxy server.', + 'itzg/mc-proxy', '1.2.0', true, 'proxy', + '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":5}', + '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft Proxy"}]', + '[{"key":"TYPE","label":"Proxy Type","type":"select","options":["VELOCITY"],"default":"VELOCITY","required":true},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"1G","required":false}]', + '[{"key":"ADDRESS","description":"Proxy address for players to connect to.","value_template":"{{ .ContainerName }}:25565"}]', + '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/server/plugins/","file_extension":".jar","config_path":"/server/plugins/","requires_restart":true,"sources":["modrinth","hangar","github-release","upload"]}}' +), + +('minecraft-bungeecord', 'minecraft', 'BungeeCord', 'Classic Minecraft proxy server.', + 'itzg/mc-proxy', '1.1.0', true, 'proxy', + '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":5}', + '[{"name":"minecraft","container":25577,"protocol":"tcp","expose":true,"label":"Minecraft Proxy"}]', + '[{"key":"TYPE","label":"Proxy Type","type":"select","options":["BUNGEECORD"],"default":"BUNGEECORD","required":true},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"1G","required":false}]', + '[{"key":"ADDRESS","description":"Proxy address for players to connect to.","value_template":"{{ .ContainerName }}:25577"}]', + '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/server/plugins/","file_extension":".jar","config_path":"/server/plugins/","requires_restart":true,"sources":["github-release","upload"]}}' +), + +('minecraft-bedrock', 'minecraft', 'Bedrock', 'Minecraft Bedrock Edition dedicated server.', + 'itzg/minecraft-bedrock-server', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":10}', + '[{"name":"bedrock","container":19132,"protocol":"udp","expose":true,"label":"Minecraft (Bedrock)"}]', + '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"VERSION","label":"Bedrock Version","type":"string","default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":10,"required":false}]', + '[{"key":"ADDRESS","description":"Bedrock server address.","value_template":"{{ .ContainerName }}:19132"}]', + '{}' +), + +-- FiveM +('fivem-server', 'fivem', 'FiveM Server', 'Standard FiveM GTA V multiplayer server.', + 'ghcr.io/fersuazo/fivem-server-docker:latest', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', + '[{"name":"game","container":30120,"protocol":"tcp","expose":true,"label":"FiveM"},{"name":"game-udp","container":30120,"protocol":"udp","expose":true,"label":"FiveM (UDP)"}]', + '[{"key":"LICENSE_KEY","label":"FiveM License Key","description":"Your Cfx.re license key.","type":"secret","required":true},{"key":"SERVER_NAME","label":"Server Name","type":"string","default":"FiveM Server","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":32,"required":false},{"key":"STEAM_WEB_API_KEY","label":"Steam Web API Key","type":"secret","required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:30120"}]', + '{"resource":{"enabled":true,"install_method":"folder-drop","install_path":"/txData/resources/","requires_restart":false,"live_commands":{"start":"start {name}","stop":"stop {name}","restart":"restart {name}"},"sources":["github-release","cfx-resource","upload"]}}' +), + +-- GMod +('gmod-server', 'gmod', 'Garry''s Mod', 'Garry''s Mod dedicated server with addon and workshop support.', + 'gameservermanagers/gameserver:gmodserver', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":30}', + '[{"name":"game","container":27015,"protocol":"udp","expose":true,"label":"Game"},{"name":"game-tcp","container":27015,"protocol":"tcp","expose":true,"label":"Game (TCP)"}]', + '[{"key":"GAMEMODE","label":"Gamemode","type":"string","default":"sandbox","required":false},{"key":"MAP","label":"Default Map","type":"string","default":"gm_flatgrass","required":false},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":16,"required":false},{"key":"SERVERNAME","label":"Server Name","type":"string","default":"Garry''s Mod Server","required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:27015"}]', + '{"addon":{"enabled":true,"install_method":"folder-drop","install_path":"/serverdata/garrysmod/addons/","requires_restart":true,"sources":["github-release","upload"]},"workshop":{"enabled":true,"install_method":"workshop-id","config_file":"/serverdata/garrysmod/cfg/workshop.cfg","config_entry_template":"resource.AddWorkshop(\"{id}\")","requires_restart":true,"sources":["steam-workshop"]}}' +), + +-- CS2 +('cs2-server', 'cs2', 'CS2 Server', 'Counter-Strike 2 dedicated server with MetaMod/SourceMod support.', + 'joedwards32/cs2', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":30}', + '[{"name":"game","container":27015,"protocol":"udp","expose":true,"label":"Game"},{"name":"game-tcp","container":27015,"protocol":"tcp","expose":true,"label":"Game (TCP)"}]', + '[{"key":"CS2_SERVERNAME","label":"Server Name","type":"string","default":"CS2 Server","required":false},{"key":"CS2_MAXPLAYERS","label":"Max Players","type":"number","default":10,"required":false},{"key":"CS2_GAMETYPE","label":"Game Type","type":"number","default":0,"required":false},{"key":"CS2_GAMEMODE","label":"Game Mode","type":"number","default":1,"required":false},{"key":"CS2_STARTMAP","label":"Start Map","type":"string","default":"de_dust2","required":false},{"key":"CS2_RCON_PORT","label":"RCON Port","type":"number","default":27050,"required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:27015"}]', + '{"sourcemod-plugin":{"enabled":true,"install_method":"file-drop","install_path":"/home/steam/cs2-dedicated/game/csgo/addons/sourcemod/plugins/","file_extension":".smx","requires_restart":false,"live_commands":{"load":"sm plugins load {filename}","unload":"sm plugins unload {filename}","reload":"sm plugins reload {filename}"},"sources":["alliedmodders","github-release","upload"]}}' +), + +-- Rust +('rust-server', 'rust-game', 'Rust Server', 'Rust dedicated server with Oxide/uMod plugin support.', + 'didstopia/rust-server', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":8192,"cpu_millicores":4000,"disk_gb":30}', + '[{"name":"game","container":28015,"protocol":"udp","expose":true,"label":"Game"},{"name":"rcon","container":28016,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"RUST_SERVER_NAME","label":"Server Name","type":"string","default":"Rust Server","required":false},{"key":"RUST_SERVER_MAXPLAYERS","label":"Max Players","type":"number","default":100,"required":false},{"key":"RUST_SERVER_WORLDSIZE","label":"World Size","type":"number","default":3500,"required":false},{"key":"RUST_SERVER_SEED","label":"World Seed","type":"number","default":12345,"required":false},{"key":"RUST_RCON_PASSWORD","label":"RCON Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:28015"}]', + '{"oxide-plugin":{"enabled":true,"install_method":"file-drop","install_path":"/serverdata/oxide/plugins/","file_extension":".cs","requires_restart":false,"live_commands":{"reload":"oxide.reload {name}"},"sources":["umod","github-release","upload"]}}' +), + +-- Valheim +('valheim-server', 'valheim', 'Valheim Server', 'Valheim dedicated server.', + 'lloesche/valheim-server', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":10}', + '[{"name":"game","container":2456,"protocol":"udp","expose":true,"label":"Game"},{"name":"game2","container":2457,"protocol":"udp","expose":true,"label":"Game 2"}]', + '[{"key":"SERVER_NAME","label":"Server Name","type":"string","default":"Valheim Server","required":true},{"key":"WORLD_NAME","label":"World Name","type":"string","default":"Dedicated","required":true},{"key":"SERVER_PASS","label":"Server Password","description":"Minimum 5 characters.","type":"secret","required":true},{"key":"SERVER_PUBLIC","label":"Public Server","type":"boolean","default":true,"required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:2456"}]', + '{}' +), + +-- ARK +('ark-ase', 'ark', 'ARK: Survival Evolved', 'ARK: Survival Evolved dedicated server.', + 'hermsi1337/ark-survival-evolved', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":8192,"cpu_millicores":4000,"disk_gb":50}', + '[{"name":"game","container":7777,"protocol":"udp","expose":true,"label":"Game"},{"name":"query","container":27015,"protocol":"udp","expose":true,"label":"Query"},{"name":"rcon","container":27020,"protocol":"tcp","expose":false,"label":"RCON"}]', + '[{"key":"SESSIONNAME","label":"Session Name","type":"string","default":"ARK Server","required":true},{"key":"SERVERPASSWORD","label":"Server Password","type":"secret","required":false},{"key":"ADMINPASSWORD","label":"Admin Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":70,"required":false},{"key":"MAP","label":"Map","type":"select","options":["TheIsland","TheCenter","ScorchedEarth_P","Ragnarok","Aberration_P","Extinction","Valguero_P","Genesis","CrystalIsles","Gen2"],"default":"TheIsland","required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:7777"}]', + '{}' +), + +('ark-sa', 'ark', 'ARK: Survival Ascended', 'ARK: Survival Ascended dedicated server.', + 'acekorneya/asa-server', '1.0.0', true, 'game-server', + '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', + '{"memory_mb":16384,"cpu_millicores":4000,"disk_gb":60}', + '[{"name":"game","container":7777,"protocol":"udp","expose":true,"label":"Game"},{"name":"query","container":27015,"protocol":"udp","expose":true,"label":"Query"}]', + '[{"key":"SESSIONNAME","label":"Session Name","type":"string","default":"ASA Server","required":true},{"key":"SERVERPASSWORD","label":"Server Password","type":"secret","required":false},{"key":"ADMINPASSWORD","label":"Admin Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":70,"required":false}]', + '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:7777"}]', + '{}' +), + +-- Redis +('redis-standalone', 'redis', 'Redis Standalone', 'Single Redis instance for caching and session storage.', + 'redis:7-alpine', '1.0.0', true, 'cache', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":512,"cpu_millicores":500,"disk_gb":5}', + '[{"name":"redis","container":6379,"protocol":"tcp","expose":false,"label":"Redis"}]', + '[{"key":"REDIS_PASSWORD","label":"Password","type":"secret","required":false,"auto_generate":false},{"key":"REDIS_MAXMEMORY","label":"Max Memory","description":"e.g. 256mb or 1gb","type":"string","default":"256mb","required":false},{"key":"REDIS_MAXMEMORY_POLICY","label":"Eviction Policy","type":"select","options":["noeviction","allkeys-lru","volatile-lru","allkeys-random","volatile-random","volatile-ttl"],"default":"allkeys-lru","required":false}]', + '[{"key":"ADDRESS","description":"Redis host:port for other services.","value_template":"{{ .ContainerName }}:6379"}]', + '{}' +), + +('redis-cluster', 'redis', 'Redis Cluster', 'Redis Cluster with 3+ nodes for high availability.', + 'redis:7-alpine', '1.0.0', true, 'cache', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":10}', + '[{"name":"redis","container":6379,"protocol":"tcp","expose":false,"label":"Redis"},{"name":"cluster-bus","container":16379,"protocol":"tcp","expose":false,"label":"Cluster Bus"}]', + '[{"key":"REDIS_PASSWORD","label":"Password","type":"secret","required":false,"auto_generate":false},{"key":"REDIS_MAXMEMORY","label":"Max Memory Per Node","type":"string","default":"256mb","required":false}]', + '[{"key":"ADDRESS","description":"Redis cluster seed node address.","value_template":"{{ .ContainerName }}:6379"}]', + '{}' +), + +-- PostgreSQL +('postgresql-16', 'postgresql', 'PostgreSQL 16', 'PostgreSQL 16 relational database.', + 'postgres:16-alpine', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', + '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', + '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"},{"key":"DSN","description":"Full connection string.","value_template":"postgres://{{ .Env.POSTGRES_USER }}:{{ .Env.POSTGRES_PASSWORD }}@{{ .ContainerName }}:5432/{{ .Env.POSTGRES_DB }}"}]', + '{}' +), + +('postgresql-15', 'postgresql', 'PostgreSQL 15', 'PostgreSQL 15 relational database.', + 'postgres:15-alpine', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', + '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', + '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"}]', + '{}' +), + +('postgresql-14', 'postgresql', 'PostgreSQL 14', 'PostgreSQL 14 relational database.', + 'postgres:14-alpine', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', + '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', + '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"}]', + '{}' +), + +-- MySQL / MariaDB +('mysql-8', 'mysql', 'MySQL 8', 'MySQL 8 relational database.', + 'mysql:8', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"mysql","container":3306,"protocol":"tcp","expose":false,"label":"MySQL"}]', + '[{"key":"MYSQL_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MYSQL_DATABASE","label":"Default Database","type":"string","default":"app","required":false},{"key":"MYSQL_USER","label":"Username","type":"string","default":"kleff","required":false},{"key":"MYSQL_PASSWORD","label":"User Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16}]', + '[{"key":"ADDRESS","description":"MySQL host:port.","value_template":"{{ .ContainerName }}:3306"}]', + '{}' +), + +('mariadb-11', 'mysql', 'MariaDB 11', 'MariaDB 11 relational database.', + 'mariadb:11', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"mariadb","container":3306,"protocol":"tcp","expose":false,"label":"MariaDB"}]', + '[{"key":"MARIADB_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MARIADB_DATABASE","label":"Default Database","type":"string","default":"app","required":false},{"key":"MARIADB_USER","label":"Username","type":"string","default":"kleff","required":false},{"key":"MARIADB_PASSWORD","label":"User Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16}]', + '[{"key":"ADDRESS","description":"MariaDB host:port.","value_template":"{{ .ContainerName }}:3306"}]', + '{}' +), + +-- MongoDB +('mongodb-7', 'mongodb', 'MongoDB 7', 'MongoDB 7 document database.', + 'mongo:7', '1.0.0', true, 'database', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', + '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', + '[{"name":"mongodb","container":27017,"protocol":"tcp","expose":false,"label":"MongoDB"}]', + '[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Root Username","type":"string","default":"admin","required":true},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MONGO_INITDB_DATABASE","label":"Default Database","type":"string","default":"app","required":false}]', + '[{"key":"ADDRESS","description":"MongoDB host:port.","value_template":"{{ .ContainerName }}:27017"}]', + '{}' +), + +-- RabbitMQ +('rabbitmq', 'rabbitmq', 'RabbitMQ', 'RabbitMQ message broker with management UI.', + 'rabbitmq:3-management-alpine', '1.0.0', true, 'messaging', + '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"/api/healthchecks/node","health_check_port":15672}', + '{"memory_mb":512,"cpu_millicores":500,"disk_gb":5}', + '[{"name":"amqp","container":5672,"protocol":"tcp","expose":false,"label":"AMQP"},{"name":"management","container":15672,"protocol":"tcp","expose":true,"label":"Management UI"}]', + '[{"key":"RABBITMQ_DEFAULT_USER","label":"Username","type":"string","default":"kleff","required":true},{"key":"RABBITMQ_DEFAULT_PASS","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"RABBITMQ_DEFAULT_VHOST","label":"Default VHost","type":"string","default":"/","required":false}]', + '[{"key":"ADDRESS","description":"AMQP host:port.","value_template":"{{ .ContainerName }}:5672"},{"key":"MANAGEMENT_URL","description":"Management UI URL.","value_template":"http://{{ .ContainerName }}:15672"}]', + '{}' +), + +-- Nginx +('nginx', 'nginx', 'Nginx', 'Nginx HTTP server and reverse proxy.', + 'nginx:alpine', '1.0.0', true, 'web', + '{"kubernetes_strategy":"","expose_udp":false,"health_check_path":"/","health_check_port":80}', + '{"memory_mb":128,"cpu_millicores":250,"disk_gb":5}', + '[{"name":"http","container":80,"protocol":"tcp","expose":true,"label":"HTTP"},{"name":"https","container":443,"protocol":"tcp","expose":true,"label":"HTTPS"}]', + '[{"key":"NGINX_HOST","label":"Server Name","type":"string","default":"localhost","required":false},{"key":"NGINX_PORT","label":"HTTP Port","type":"number","default":80,"required":false}]', + '[{"key":"ADDRESS","description":"HTTP address.","value_template":"http://{{ .ContainerName }}:80"}]', + '{}' +), + +-- Caddy +('caddy', 'caddy', 'Caddy', 'Caddy web server with automatic HTTPS.', + 'caddy:alpine', '1.0.0', true, 'web', + '{"kubernetes_strategy":"","expose_udp":false,"health_check_path":"/","health_check_port":80}', + '{"memory_mb":128,"cpu_millicores":250,"disk_gb":5}', + '[{"name":"http","container":80,"protocol":"tcp","expose":true,"label":"HTTP"},{"name":"https","container":443,"protocol":"tcp","expose":true,"label":"HTTPS"},{"name":"admin","container":2019,"protocol":"tcp","expose":false,"label":"Admin API"}]', + '[{"key":"CADDY_DOMAIN","label":"Domain","description":"Domain for automatic HTTPS. Leave blank for local use.","type":"string","required":false}]', + '[{"key":"ADDRESS","description":"HTTP address.","value_template":"http://{{ .ContainerName }}:80"}]', + '{}' +) + +ON CONFLICT (id) DO NOTHING; diff --git a/internal/database/migrations/003_constructs.sql b/internal/database/migrations/003_constructs.sql new file mode 100644 index 0000000..59ee0b4 --- /dev/null +++ b/internal/database/migrations/003_constructs.sql @@ -0,0 +1,39 @@ +-- Migration 003: refactor catalog to Blueprint/Construct split. +-- +-- Blueprints are now user-facing only (config, resources, extensions sources). +-- Constructs hold the technical recipe (image, env, ports, runtime_hints, outputs). +-- All old hardcoded seed data is removed; the crate registry adapter repopulates +-- crates, blueprints, and constructs from the remote registry on startup. + +-- Remove seeded data so we can restructure cleanly. +DELETE FROM blueprints; +DELETE FROM crates; + +-- Drop columns that moved to the constructs table. +ALTER TABLE blueprints + DROP COLUMN IF EXISTS image, + DROP COLUMN IF EXISTS category, + DROP COLUMN IF EXISTS long_description, + DROP COLUMN IF EXISTS runtime_hints, + DROP COLUMN IF EXISTS ports, + DROP COLUMN IF EXISTS outputs; + +-- Add the link from blueprint → construct. +ALTER TABLE blueprints + ADD COLUMN IF NOT EXISTS construct_id TEXT NOT NULL DEFAULT ''; + +-- constructs table — the technical recipe for a blueprint. +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() +); From 7926a41d755fe059454b8d8b7df4f2eb2b01ed35 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Wed, 8 Apr 2026 16:32:16 -0400 Subject: [PATCH 2/5] crates sync --- .../core/catalog/adapters/registry/github.go | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/internal/core/catalog/adapters/registry/github.go b/internal/core/catalog/adapters/registry/github.go index a4f20b7..9b49303 100644 --- a/internal/core/catalog/adapters/registry/github.go +++ b/internal/core/catalog/adapters/registry/github.go @@ -76,41 +76,38 @@ func (r *CrateRegistry) Sync(ctx context.Context, store ports.CatalogRepository) continue } - // 3. Fetch and upsert blueprints - for _, bpID := range ref.Blueprints { - bpData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/blueprints/%s.json", ref.ID, bpID)) + // 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, bpID, err)) + 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, bpID, err)) + 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, bpID, err)) + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s upsert: %v", ref.ID, version, err)) } - } - // 4. Fetch and upsert constructs - for _, cID := range ref.Constructs { - cData, err := r.fetch(ctx, fmt.Sprintf("crates/%s/constructs/%s.json", ref.ID, cID)) + 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, cID, err)) + 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, cID, err)) + 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, cID, err)) + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s upsert: %v", ref.ID, version, err)) } } } @@ -164,11 +161,10 @@ type crateIndex struct { Crates []crateRef `json:"crates"` } -// crateRef is an entry in index.json listing a crate's blueprint and construct IDs. +// crateRef is an entry in index.json listing a crate's version folder names. type crateRef struct { - ID string `json:"id"` - Blueprints []string `json:"blueprints"` - Constructs []string `json:"constructs"` + ID string `json:"id"` + Versions []string `json:"versions"` } // wireCrate maps crate.json from the registry. From 909d9e769de1a466213c710eddfe1fbbb8a23384 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Wed, 8 Apr 2026 16:56:39 -0400 Subject: [PATCH 3/5] fix --- .../database/migrations/002_blueprints.sql | 299 ------------------ 1 file changed, 299 deletions(-) diff --git a/internal/database/migrations/002_blueprints.sql b/internal/database/migrations/002_blueprints.sql index 5abe146..f69b863 100644 --- a/internal/database/migrations/002_blueprints.sql +++ b/internal/database/migrations/002_blueprints.sql @@ -30,302 +30,3 @@ CREATE TABLE IF NOT EXISTS blueprints ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - --- ── Seed: Crates ────────────────────────────────────────────────────────────── - -INSERT INTO crates (id, name, category, description, logo, tags, official) VALUES -('minecraft', 'Minecraft', 'game-server', 'The world''s most popular game. Java and Bedrock editions.', '', '["sandbox","survival","multiplayer"]', true), -('fivem', 'FiveM', 'game-server', 'GTA V multiplayer modification platform.', '', '["gta","roleplay","multiplayer"]', true), -('gmod', 'Garry''s Mod','game-server', 'A physics sandbox game with extensive workshop support.', '', '["sandbox","workshop"]', true), -('cs2', 'CS2', 'game-server', 'Counter-Strike 2 dedicated server.', '', '["fps","competitive"]', true), -('rust-game', 'Rust', 'game-server', 'Survival multiplayer game with Oxide plugin support.', '', '["survival","oxide"]', true), -('valheim', 'Valheim', 'game-server', 'Viking survival and exploration game.', '', '["survival","viking"]', true), -('ark', 'ARK', 'game-server', 'ARK: Survival Evolved and Survival Ascended.', '', '["survival","dinosaurs"]', true), -('redis', 'Redis', 'cache', 'In-memory data store, cache, and message broker.', '', '["cache","in-memory"]', true), -('postgresql', 'PostgreSQL', 'database', 'The world''s most advanced open source relational database.','', '["sql","relational"]', true), -('mysql', 'MySQL', 'database', 'Popular open source relational database.', '', '["sql","relational"]', true), -('mongodb', 'MongoDB', 'database', 'General purpose, document-based distributed database.', '', '["nosql","document"]', true), -('rabbitmq', 'RabbitMQ', 'messaging', 'Open source message broker with management UI.', '', '["amqp","messaging"]', true), -('nginx', 'Nginx', 'web', 'High-performance HTTP server and reverse proxy.', '', '["proxy","web","http"]', true), -('caddy', 'Caddy', 'web', 'Modern web server with automatic HTTPS.', '', '["proxy","https","web"]', true) -ON CONFLICT (id) DO NOTHING; - --- ── Seed: Blueprints ────────────────────────────────────────────────────────── - -INSERT INTO blueprints (id, crate_id, name, description, image, version, official, category, runtime_hints, resources, ports, config, outputs, extensions) VALUES - --- Minecraft -('minecraft-papermc', 'minecraft', 'PaperMC', 'High-performance Minecraft server fork with plugin support.', - 'itzg/minecraft-server', '1.3.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":2048,"cpu_millicores":2000,"disk_gb":10}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"},{"name":"query","container":25565,"protocol":"udp","expose":true,"label":"Query"}]', - '[{"key":"EULA","label":"Accept EULA","description":"You must accept the Minecraft EULA to run a server.","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["PAPER"],"default":"PAPER","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.21.3","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MOTD","label":"MOTD","description":"Message shown in the server list.","type":"string","default":"A Minecraft Server","required":false},{"key":"RCON_PASSWORD","label":"RCON Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16},{"key":"MEMORY","label":"JVM Memory","description":"Java heap size, e.g. 2G or 4G.","type":"string","default":"2G","required":false}]', - '[{"key":"ADDRESS","description":"Internal host:port for other services to connect to.","value_template":"{{ .ContainerName }}:25565"},{"key":"RCON_ADDRESS","description":"RCON host:port.","value_template":"{{ .ContainerName }}:25575"}]', - '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]}}' -), - -('minecraft-spigot', 'minecraft', 'Spigot', 'Widely used Minecraft server with plugin support.', - 'itzg/minecraft-server', '1.2.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":2048,"cpu_millicores":2000,"disk_gb":10}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["SPIGOT"],"default":"SPIGOT","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"2G","required":false}]', - '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', - '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]}}' -), - -('minecraft-forge', 'minecraft', 'Forge', 'Modded Minecraft with Forge mod loader.', - 'itzg/minecraft-server', '1.2.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["FORGE"],"default":"FORGE","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"FORGEVERSION","label":"Forge Version","type":"string","default":"RECOMMENDED","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"4G","required":false}]', - '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', - '{"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' -), - -('minecraft-fabric', 'minecraft', 'Fabric', 'Lightweight modded Minecraft with Fabric mod loader.', - 'itzg/minecraft-server', '1.1.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":3072,"cpu_millicores":2000,"disk_gb":15}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["FABRIC"],"default":"FABRIC","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["LATEST","1.21.4","1.20.4","1.20.1"],"default":"LATEST","required":true},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"3G","required":false}]', - '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', - '{"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' -), - -('minecraft-arclight', 'minecraft', 'Arclight', 'Hybrid server supporting both plugins and mods simultaneously.', - 'itzg/minecraft-server', '1.1.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft (Java)"},{"name":"rcon","container":25575,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"TYPE","label":"Server Type","type":"select","options":["ARCLIGHT"],"default":"ARCLIGHT","required":true},{"key":"VERSION","label":"Minecraft Version","type":"select","options":["1.21.4","1.20.4","1.20.1"],"default":"1.21.4","required":true},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":20,"required":false},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"4G","required":false}]', - '[{"key":"ADDRESS","description":"Internal host:port.","value_template":"{{ .ContainerName }}:25565"}]', - '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/data/plugins/","file_extension":".jar","config_path":"/data/plugins/","requires_restart":true,"sources":["modrinth","hangar","spigotmc","github-release","upload"]},"mod":{"enabled":true,"install_method":"jar-drop","install_path":"/data/mods/","file_extension":".jar","requires_restart":true,"sources":["modrinth","curseforge","github-release","upload"]}}' -), - -('minecraft-velocity', 'minecraft', 'Velocity', 'High-performance Minecraft proxy server.', - 'itzg/mc-proxy', '1.2.0', true, 'proxy', - '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":5}', - '[{"name":"minecraft","container":25565,"protocol":"tcp","expose":true,"label":"Minecraft Proxy"}]', - '[{"key":"TYPE","label":"Proxy Type","type":"select","options":["VELOCITY"],"default":"VELOCITY","required":true},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"1G","required":false}]', - '[{"key":"ADDRESS","description":"Proxy address for players to connect to.","value_template":"{{ .ContainerName }}:25565"}]', - '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/server/plugins/","file_extension":".jar","config_path":"/server/plugins/","requires_restart":true,"sources":["modrinth","hangar","github-release","upload"]}}' -), - -('minecraft-bungeecord', 'minecraft', 'BungeeCord', 'Classic Minecraft proxy server.', - 'itzg/mc-proxy', '1.1.0', true, 'proxy', - '{"kubernetes_strategy":"agones","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":5}', - '[{"name":"minecraft","container":25577,"protocol":"tcp","expose":true,"label":"Minecraft Proxy"}]', - '[{"key":"TYPE","label":"Proxy Type","type":"select","options":["BUNGEECORD"],"default":"BUNGEECORD","required":true},{"key":"MEMORY","label":"JVM Memory","type":"string","default":"1G","required":false}]', - '[{"key":"ADDRESS","description":"Proxy address for players to connect to.","value_template":"{{ .ContainerName }}:25577"}]', - '{"plugin":{"enabled":true,"install_method":"jar-drop","install_path":"/server/plugins/","file_extension":".jar","config_path":"/server/plugins/","requires_restart":true,"sources":["github-release","upload"]}}' -), - -('minecraft-bedrock', 'minecraft', 'Bedrock', 'Minecraft Bedrock Edition dedicated server.', - 'itzg/minecraft-bedrock-server', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":10}', - '[{"name":"bedrock","container":19132,"protocol":"udp","expose":true,"label":"Minecraft (Bedrock)"}]', - '[{"key":"EULA","label":"Accept EULA","type":"boolean","required":true,"default":true},{"key":"VERSION","label":"Bedrock Version","type":"string","default":"LATEST","required":true},{"key":"DIFFICULTY","label":"Difficulty","type":"select","options":["peaceful","easy","normal","hard"],"default":"normal","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":10,"required":false}]', - '[{"key":"ADDRESS","description":"Bedrock server address.","value_template":"{{ .ContainerName }}:19132"}]', - '{}' -), - --- FiveM -('fivem-server', 'fivem', 'FiveM Server', 'Standard FiveM GTA V multiplayer server.', - 'ghcr.io/fersuazo/fivem-server-docker:latest', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":20}', - '[{"name":"game","container":30120,"protocol":"tcp","expose":true,"label":"FiveM"},{"name":"game-udp","container":30120,"protocol":"udp","expose":true,"label":"FiveM (UDP)"}]', - '[{"key":"LICENSE_KEY","label":"FiveM License Key","description":"Your Cfx.re license key.","type":"secret","required":true},{"key":"SERVER_NAME","label":"Server Name","type":"string","default":"FiveM Server","required":false},{"key":"MAX_PLAYERS","label":"Max Players","type":"number","default":32,"required":false},{"key":"STEAM_WEB_API_KEY","label":"Steam Web API Key","type":"secret","required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:30120"}]', - '{"resource":{"enabled":true,"install_method":"folder-drop","install_path":"/txData/resources/","requires_restart":false,"live_commands":{"start":"start {name}","stop":"stop {name}","restart":"restart {name}"},"sources":["github-release","cfx-resource","upload"]}}' -), - --- GMod -('gmod-server', 'gmod', 'Garry''s Mod', 'Garry''s Mod dedicated server with addon and workshop support.', - 'gameservermanagers/gameserver:gmodserver', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":30}', - '[{"name":"game","container":27015,"protocol":"udp","expose":true,"label":"Game"},{"name":"game-tcp","container":27015,"protocol":"tcp","expose":true,"label":"Game (TCP)"}]', - '[{"key":"GAMEMODE","label":"Gamemode","type":"string","default":"sandbox","required":false},{"key":"MAP","label":"Default Map","type":"string","default":"gm_flatgrass","required":false},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":16,"required":false},{"key":"SERVERNAME","label":"Server Name","type":"string","default":"Garry''s Mod Server","required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:27015"}]', - '{"addon":{"enabled":true,"install_method":"folder-drop","install_path":"/serverdata/garrysmod/addons/","requires_restart":true,"sources":["github-release","upload"]},"workshop":{"enabled":true,"install_method":"workshop-id","config_file":"/serverdata/garrysmod/cfg/workshop.cfg","config_entry_template":"resource.AddWorkshop(\"{id}\")","requires_restart":true,"sources":["steam-workshop"]}}' -), - --- CS2 -('cs2-server', 'cs2', 'CS2 Server', 'Counter-Strike 2 dedicated server with MetaMod/SourceMod support.', - 'joedwards32/cs2', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":30}', - '[{"name":"game","container":27015,"protocol":"udp","expose":true,"label":"Game"},{"name":"game-tcp","container":27015,"protocol":"tcp","expose":true,"label":"Game (TCP)"}]', - '[{"key":"CS2_SERVERNAME","label":"Server Name","type":"string","default":"CS2 Server","required":false},{"key":"CS2_MAXPLAYERS","label":"Max Players","type":"number","default":10,"required":false},{"key":"CS2_GAMETYPE","label":"Game Type","type":"number","default":0,"required":false},{"key":"CS2_GAMEMODE","label":"Game Mode","type":"number","default":1,"required":false},{"key":"CS2_STARTMAP","label":"Start Map","type":"string","default":"de_dust2","required":false},{"key":"CS2_RCON_PORT","label":"RCON Port","type":"number","default":27050,"required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:27015"}]', - '{"sourcemod-plugin":{"enabled":true,"install_method":"file-drop","install_path":"/home/steam/cs2-dedicated/game/csgo/addons/sourcemod/plugins/","file_extension":".smx","requires_restart":false,"live_commands":{"load":"sm plugins load {filename}","unload":"sm plugins unload {filename}","reload":"sm plugins reload {filename}"},"sources":["alliedmodders","github-release","upload"]}}' -), - --- Rust -('rust-server', 'rust-game', 'Rust Server', 'Rust dedicated server with Oxide/uMod plugin support.', - 'didstopia/rust-server', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":8192,"cpu_millicores":4000,"disk_gb":30}', - '[{"name":"game","container":28015,"protocol":"udp","expose":true,"label":"Game"},{"name":"rcon","container":28016,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"RUST_SERVER_NAME","label":"Server Name","type":"string","default":"Rust Server","required":false},{"key":"RUST_SERVER_MAXPLAYERS","label":"Max Players","type":"number","default":100,"required":false},{"key":"RUST_SERVER_WORLDSIZE","label":"World Size","type":"number","default":3500,"required":false},{"key":"RUST_SERVER_SEED","label":"World Seed","type":"number","default":12345,"required":false},{"key":"RUST_RCON_PASSWORD","label":"RCON Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:28015"}]', - '{"oxide-plugin":{"enabled":true,"install_method":"file-drop","install_path":"/serverdata/oxide/plugins/","file_extension":".cs","requires_restart":false,"live_commands":{"reload":"oxide.reload {name}"},"sources":["umod","github-release","upload"]}}' -), - --- Valheim -('valheim-server', 'valheim', 'Valheim Server', 'Valheim dedicated server.', - 'lloesche/valheim-server', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":4096,"cpu_millicores":2000,"disk_gb":10}', - '[{"name":"game","container":2456,"protocol":"udp","expose":true,"label":"Game"},{"name":"game2","container":2457,"protocol":"udp","expose":true,"label":"Game 2"}]', - '[{"key":"SERVER_NAME","label":"Server Name","type":"string","default":"Valheim Server","required":true},{"key":"WORLD_NAME","label":"World Name","type":"string","default":"Dedicated","required":true},{"key":"SERVER_PASS","label":"Server Password","description":"Minimum 5 characters.","type":"secret","required":true},{"key":"SERVER_PUBLIC","label":"Public Server","type":"boolean","default":true,"required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:2456"}]', - '{}' -), - --- ARK -('ark-ase', 'ark', 'ARK: Survival Evolved', 'ARK: Survival Evolved dedicated server.', - 'hermsi1337/ark-survival-evolved', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":8192,"cpu_millicores":4000,"disk_gb":50}', - '[{"name":"game","container":7777,"protocol":"udp","expose":true,"label":"Game"},{"name":"query","container":27015,"protocol":"udp","expose":true,"label":"Query"},{"name":"rcon","container":27020,"protocol":"tcp","expose":false,"label":"RCON"}]', - '[{"key":"SESSIONNAME","label":"Session Name","type":"string","default":"ARK Server","required":true},{"key":"SERVERPASSWORD","label":"Server Password","type":"secret","required":false},{"key":"ADMINPASSWORD","label":"Admin Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":70,"required":false},{"key":"MAP","label":"Map","type":"select","options":["TheIsland","TheCenter","ScorchedEarth_P","Ragnarok","Aberration_P","Extinction","Valguero_P","Genesis","CrystalIsles","Gen2"],"default":"TheIsland","required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:7777"}]', - '{}' -), - -('ark-sa', 'ark', 'ARK: Survival Ascended', 'ARK: Survival Ascended dedicated server.', - 'acekorneya/asa-server', '1.0.0', true, 'game-server', - '{"kubernetes_strategy":"agones","expose_udp":true,"health_check_path":"","health_check_port":0}', - '{"memory_mb":16384,"cpu_millicores":4000,"disk_gb":60}', - '[{"name":"game","container":7777,"protocol":"udp","expose":true,"label":"Game"},{"name":"query","container":27015,"protocol":"udp","expose":true,"label":"Query"}]', - '[{"key":"SESSIONNAME","label":"Session Name","type":"string","default":"ASA Server","required":true},{"key":"SERVERPASSWORD","label":"Server Password","type":"secret","required":false},{"key":"ADMINPASSWORD","label":"Admin Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"MAXPLAYERS","label":"Max Players","type":"number","default":70,"required":false}]', - '[{"key":"ADDRESS","description":"Server address.","value_template":"{{ .ContainerName }}:7777"}]', - '{}' -), - --- Redis -('redis-standalone', 'redis', 'Redis Standalone', 'Single Redis instance for caching and session storage.', - 'redis:7-alpine', '1.0.0', true, 'cache', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":512,"cpu_millicores":500,"disk_gb":5}', - '[{"name":"redis","container":6379,"protocol":"tcp","expose":false,"label":"Redis"}]', - '[{"key":"REDIS_PASSWORD","label":"Password","type":"secret","required":false,"auto_generate":false},{"key":"REDIS_MAXMEMORY","label":"Max Memory","description":"e.g. 256mb or 1gb","type":"string","default":"256mb","required":false},{"key":"REDIS_MAXMEMORY_POLICY","label":"Eviction Policy","type":"select","options":["noeviction","allkeys-lru","volatile-lru","allkeys-random","volatile-random","volatile-ttl"],"default":"allkeys-lru","required":false}]', - '[{"key":"ADDRESS","description":"Redis host:port for other services.","value_template":"{{ .ContainerName }}:6379"}]', - '{}' -), - -('redis-cluster', 'redis', 'Redis Cluster', 'Redis Cluster with 3+ nodes for high availability.', - 'redis:7-alpine', '1.0.0', true, 'cache', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":10}', - '[{"name":"redis","container":6379,"protocol":"tcp","expose":false,"label":"Redis"},{"name":"cluster-bus","container":16379,"protocol":"tcp","expose":false,"label":"Cluster Bus"}]', - '[{"key":"REDIS_PASSWORD","label":"Password","type":"secret","required":false,"auto_generate":false},{"key":"REDIS_MAXMEMORY","label":"Max Memory Per Node","type":"string","default":"256mb","required":false}]', - '[{"key":"ADDRESS","description":"Redis cluster seed node address.","value_template":"{{ .ContainerName }}:6379"}]', - '{}' -), - --- PostgreSQL -('postgresql-16', 'postgresql', 'PostgreSQL 16', 'PostgreSQL 16 relational database.', - 'postgres:16-alpine', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', - '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', - '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"},{"key":"DSN","description":"Full connection string.","value_template":"postgres://{{ .Env.POSTGRES_USER }}:{{ .Env.POSTGRES_PASSWORD }}@{{ .ContainerName }}:5432/{{ .Env.POSTGRES_DB }}"}]', - '{}' -), - -('postgresql-15', 'postgresql', 'PostgreSQL 15', 'PostgreSQL 15 relational database.', - 'postgres:15-alpine', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', - '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', - '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"}]', - '{}' -), - -('postgresql-14', 'postgresql', 'PostgreSQL 14', 'PostgreSQL 14 relational database.', - 'postgres:14-alpine', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"postgres","container":5432,"protocol":"tcp","expose":false,"label":"PostgreSQL"}]', - '[{"key":"POSTGRES_USER","label":"Username","type":"string","default":"postgres","required":true},{"key":"POSTGRES_PASSWORD","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"POSTGRES_DB","label":"Default Database","type":"string","default":"app","required":true}]', - '[{"key":"ADDRESS","description":"PostgreSQL host:port.","value_template":"{{ .ContainerName }}:5432"}]', - '{}' -), - --- MySQL / MariaDB -('mysql-8', 'mysql', 'MySQL 8', 'MySQL 8 relational database.', - 'mysql:8', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"mysql","container":3306,"protocol":"tcp","expose":false,"label":"MySQL"}]', - '[{"key":"MYSQL_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MYSQL_DATABASE","label":"Default Database","type":"string","default":"app","required":false},{"key":"MYSQL_USER","label":"Username","type":"string","default":"kleff","required":false},{"key":"MYSQL_PASSWORD","label":"User Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16}]', - '[{"key":"ADDRESS","description":"MySQL host:port.","value_template":"{{ .ContainerName }}:3306"}]', - '{}' -), - -('mariadb-11', 'mysql', 'MariaDB 11', 'MariaDB 11 relational database.', - 'mariadb:11', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"mariadb","container":3306,"protocol":"tcp","expose":false,"label":"MariaDB"}]', - '[{"key":"MARIADB_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MARIADB_DATABASE","label":"Default Database","type":"string","default":"app","required":false},{"key":"MARIADB_USER","label":"Username","type":"string","default":"kleff","required":false},{"key":"MARIADB_PASSWORD","label":"User Password","type":"secret","required":false,"auto_generate":true,"auto_generate_length":16}]', - '[{"key":"ADDRESS","description":"MariaDB host:port.","value_template":"{{ .ContainerName }}:3306"}]', - '{}' -), - --- MongoDB -('mongodb-7', 'mongodb', 'MongoDB 7', 'MongoDB 7 document database.', - 'mongo:7', '1.0.0', true, 'database', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"","health_check_port":0}', - '{"memory_mb":1024,"cpu_millicores":1000,"disk_gb":20}', - '[{"name":"mongodb","container":27017,"protocol":"tcp","expose":false,"label":"MongoDB"}]', - '[{"key":"MONGO_INITDB_ROOT_USERNAME","label":"Root Username","type":"string","default":"admin","required":true},{"key":"MONGO_INITDB_ROOT_PASSWORD","label":"Root Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":24},{"key":"MONGO_INITDB_DATABASE","label":"Default Database","type":"string","default":"app","required":false}]', - '[{"key":"ADDRESS","description":"MongoDB host:port.","value_template":"{{ .ContainerName }}:27017"}]', - '{}' -), - --- RabbitMQ -('rabbitmq', 'rabbitmq', 'RabbitMQ', 'RabbitMQ message broker with management UI.', - 'rabbitmq:3-management-alpine', '1.0.0', true, 'messaging', - '{"kubernetes_strategy":"statefulset","expose_udp":false,"health_check_path":"/api/healthchecks/node","health_check_port":15672}', - '{"memory_mb":512,"cpu_millicores":500,"disk_gb":5}', - '[{"name":"amqp","container":5672,"protocol":"tcp","expose":false,"label":"AMQP"},{"name":"management","container":15672,"protocol":"tcp","expose":true,"label":"Management UI"}]', - '[{"key":"RABBITMQ_DEFAULT_USER","label":"Username","type":"string","default":"kleff","required":true},{"key":"RABBITMQ_DEFAULT_PASS","label":"Password","type":"secret","required":true,"auto_generate":true,"auto_generate_length":16},{"key":"RABBITMQ_DEFAULT_VHOST","label":"Default VHost","type":"string","default":"/","required":false}]', - '[{"key":"ADDRESS","description":"AMQP host:port.","value_template":"{{ .ContainerName }}:5672"},{"key":"MANAGEMENT_URL","description":"Management UI URL.","value_template":"http://{{ .ContainerName }}:15672"}]', - '{}' -), - --- Nginx -('nginx', 'nginx', 'Nginx', 'Nginx HTTP server and reverse proxy.', - 'nginx:alpine', '1.0.0', true, 'web', - '{"kubernetes_strategy":"","expose_udp":false,"health_check_path":"/","health_check_port":80}', - '{"memory_mb":128,"cpu_millicores":250,"disk_gb":5}', - '[{"name":"http","container":80,"protocol":"tcp","expose":true,"label":"HTTP"},{"name":"https","container":443,"protocol":"tcp","expose":true,"label":"HTTPS"}]', - '[{"key":"NGINX_HOST","label":"Server Name","type":"string","default":"localhost","required":false},{"key":"NGINX_PORT","label":"HTTP Port","type":"number","default":80,"required":false}]', - '[{"key":"ADDRESS","description":"HTTP address.","value_template":"http://{{ .ContainerName }}:80"}]', - '{}' -), - --- Caddy -('caddy', 'caddy', 'Caddy', 'Caddy web server with automatic HTTPS.', - 'caddy:alpine', '1.0.0', true, 'web', - '{"kubernetes_strategy":"","expose_udp":false,"health_check_path":"/","health_check_port":80}', - '{"memory_mb":128,"cpu_millicores":250,"disk_gb":5}', - '[{"name":"http","container":80,"protocol":"tcp","expose":true,"label":"HTTP"},{"name":"https","container":443,"protocol":"tcp","expose":true,"label":"HTTPS"},{"name":"admin","container":2019,"protocol":"tcp","expose":false,"label":"Admin API"}]', - '[{"key":"CADDY_DOMAIN","label":"Domain","description":"Domain for automatic HTTPS. Leave blank for local use.","type":"string","required":false}]', - '[{"key":"ADDRESS","description":"HTTP address.","value_template":"http://{{ .ContainerName }}:80"}]', - '{}' -) - -ON CONFLICT (id) DO NOTHING; From e98c83dd3aea536f6ceeae49a927197ce9c5cc3c Mon Sep 17 00:00:00 2001 From: Jeefos Date: Wed, 8 Apr 2026 20:14:02 -0400 Subject: [PATCH 4/5] removed dead code --- .../database/migrations/002_blueprints.sql | 46 +++++++++++-------- .../database/migrations/003_constructs.sql | 39 ---------------- 2 files changed, 28 insertions(+), 57 deletions(-) delete mode 100644 internal/database/migrations/003_constructs.sql diff --git a/internal/database/migrations/002_blueprints.sql b/internal/database/migrations/002_blueprints.sql index f69b863..7a37f88 100644 --- a/internal/database/migrations/002_blueprints.sql +++ b/internal/database/migrations/002_blueprints.sql @@ -11,22 +11,32 @@ CREATE TABLE IF NOT EXISTS crates ( ); CREATE TABLE IF NOT EXISTS blueprints ( - id TEXT PRIMARY KEY, - crate_id TEXT NOT NULL REFERENCES crates(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT NOT NULL, - long_description TEXT NOT NULL DEFAULT '', - logo TEXT NOT NULL DEFAULT '', - image TEXT NOT NULL, - version TEXT NOT NULL, - official BOOLEAN NOT NULL DEFAULT false, - category TEXT NOT NULL, - runtime_hints JSONB NOT NULL DEFAULT '{}', - resources JSONB NOT NULL DEFAULT '{}', - ports JSONB NOT NULL DEFAULT '[]', - config JSONB NOT NULL DEFAULT '[]', - outputs JSONB NOT NULL DEFAULT '[]', - extensions JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + 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() ); diff --git a/internal/database/migrations/003_constructs.sql b/internal/database/migrations/003_constructs.sql deleted file mode 100644 index 59ee0b4..0000000 --- a/internal/database/migrations/003_constructs.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Migration 003: refactor catalog to Blueprint/Construct split. --- --- Blueprints are now user-facing only (config, resources, extensions sources). --- Constructs hold the technical recipe (image, env, ports, runtime_hints, outputs). --- All old hardcoded seed data is removed; the crate registry adapter repopulates --- crates, blueprints, and constructs from the remote registry on startup. - --- Remove seeded data so we can restructure cleanly. -DELETE FROM blueprints; -DELETE FROM crates; - --- Drop columns that moved to the constructs table. -ALTER TABLE blueprints - DROP COLUMN IF EXISTS image, - DROP COLUMN IF EXISTS category, - DROP COLUMN IF EXISTS long_description, - DROP COLUMN IF EXISTS runtime_hints, - DROP COLUMN IF EXISTS ports, - DROP COLUMN IF EXISTS outputs; - --- Add the link from blueprint → construct. -ALTER TABLE blueprints - ADD COLUMN IF NOT EXISTS construct_id TEXT NOT NULL DEFAULT ''; - --- constructs table — the technical recipe for a blueprint. -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() -); From 552b1880769672a8de845f06d9af6b0069689b55 Mon Sep 17 00:00:00 2001 From: Jeefos Date: Wed, 8 Apr 2026 20:20:41 -0400 Subject: [PATCH 5/5] removed dead code --- internal/core/catalog/adapters/persistence/store.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/core/catalog/adapters/persistence/store.go b/internal/core/catalog/adapters/persistence/store.go index 6e631ae..172da7d 100644 --- a/internal/core/catalog/adapters/persistence/store.go +++ b/internal/core/catalog/adapters/persistence/store.go @@ -106,7 +106,6 @@ func (s *PostgresCatalogStore) ListBlueprints(ctx context.Context, crateID strin if crateID != "" { query += fmt.Sprintf(" AND crate_id = $%d", i) args = append(args, crateID) - i++ } query += " ORDER BY name"