From 80379b41ea52523a519dfb8164147b2cd15bc02a Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Wed, 1 Apr 2026 23:02:43 -0700 Subject: [PATCH] feat: embed app registry at build time App metadata and icons are now embedded directly into the host-agent binary via go:embed, eliminating the runtime dependency on the apps/ directory. - Add apps/registry.go with //go:embed */metadata.yaml and */icon.png - Switch catalog.Loader to use io/fs.FS internally; add NewLoaderFromFS - Server and configure subcommand use embedded registry instead of appsDir - Icon serving reads from embedded FS instead of filesystem - Remove AppsDir from Config and ServerConfig (no longer needed) - Update tests to reflect embedded catalog behavior - Remove manual appconfig/register.go step from adding-app docs --- README.md | 1 - apps/registry.go | 9 ++++ .../host-agent/cmd/host-agent/configure.go | 3 +- services/host-agent/cmd/host-agent/main.go | 2 - services/host-agent/internal/api/api_test.go | 53 +++---------------- services/host-agent/internal/api/routes.go | 14 +++-- services/host-agent/internal/api/server.go | 12 ++--- .../host-agent/internal/catalog/loader.go | 34 ++++++------ services/host-agent/internal/config/config.go | 2 - 9 files changed, 52 insertions(+), 78 deletions(-) create mode 100644 apps/registry.go diff --git a/README.md b/README.md index efe1e4a..bd8cf46 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,6 @@ curl http://localhost/api/apps/miniflux/plan-install 1. Create `apps/myapp/metadata.yaml` — integrations, SSO strategy, port 2. Create `apps/myapp/module.nix` — container definition using `mkBloudApp` 3. Create `apps/myapp/configurator.go` — PreStart, HealthCheck, PostStart -4. Register in `services/host-agent/internal/appconfig/register.go` See [apps/adding-apps.md](apps/adding-apps.md) for details. diff --git a/apps/registry.go b/apps/registry.go new file mode 100644 index 0000000..3e756ad --- /dev/null +++ b/apps/registry.go @@ -0,0 +1,9 @@ +package apps + +import "embed" + +// FS contains all app metadata and icons embedded at build time. +// +//go:embed */metadata.yaml +//go:embed */icon.png +var FS embed.FS diff --git a/services/host-agent/cmd/host-agent/configure.go b/services/host-agent/cmd/host-agent/configure.go index 5910c38..76126e0 100644 --- a/services/host-agent/cmd/host-agent/configure.go +++ b/services/host-agent/cmd/host-agent/configure.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + appsregistry "codeberg.org/d-buckner/bloud-v3/apps" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/appconfig" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/catalog" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/config" @@ -266,7 +267,7 @@ func writeSSOEnvVars(appName string, cfg *config.Config, logger *slog.Logger) { } // Load catalog to check if this app has SSO - loader := catalog.NewLoader(cfg.AppsDir) + loader := catalog.NewLoaderFromFS(appsregistry.FS) allApps, err := loader.LoadAll() if err != nil { logger.Warn("failed to load catalog for SSO env vars", "error", err) diff --git a/services/host-agent/cmd/host-agent/main.go b/services/host-agent/cmd/host-agent/main.go index e36a45d..3e81c18 100644 --- a/services/host-agent/cmd/host-agent/main.go +++ b/services/host-agent/cmd/host-agent/main.go @@ -52,7 +52,6 @@ func runServer() { logger.Info("loaded configuration", "port", cfg.Port, "data_dir", cfg.DataDir, - "apps_dir", cfg.AppsDir, "flake_path", cfg.FlakePath, "nixos_path", cfg.NixosPath, ) @@ -72,7 +71,6 @@ func runServer() { // Create HTTP server server := api.NewServer(database, api.ServerConfig{ - AppsDir: cfg.AppsDir, ConfigDir: cfg.NixConfigDir, DataDir: cfg.DataDir, TraefikDynamicDir: cfg.TraefikDynamicDir, diff --git a/services/host-agent/internal/api/api_test.go b/services/host-agent/internal/api/api_test.go index 70c1b76..bc78e55 100644 --- a/services/host-agent/internal/api/api_test.go +++ b/services/host-agent/internal/api/api_test.go @@ -298,7 +298,6 @@ tags: // Create server with fakes server := &Server{ cfg: ServerConfig{ - AppsDir: tmpDir, ConfigDir: filepath.Join(tmpDir, "nix"), DataDir: tmpDir, Port: 8080, @@ -385,7 +384,6 @@ integrations: // Create server server := &Server{ cfg: ServerConfig{ - AppsDir: tmpDir, ConfigDir: filepath.Join(tmpDir, "nix"), DataDir: tmpDir, Port: 8080, @@ -515,40 +513,7 @@ func TestAPI_Storage(t *testing.T) { } func TestAPI_RefreshCatalog(t *testing.T) { - server, appsDir := setupTestServer(t) - - // Add another app to catalog - newAppDir := filepath.Join(appsDir, "new-app") - require.NoError(t, os.MkdirAll(newAppDir, 0755)) - - newAppYAML := `name: new-app -displayName: New App -description: A newly added app -category: testing -version: 2.0.0 -dependencies: [] -resources: - minRam: 256 - minDisk: 2 - gpu: false -sso: - enabled: false - protocol: "" - blueprint: "" -defaultConfig: {} -healthCheck: - path: / - interval: 30 - timeout: 5 -docs: - homepage: https://example.com - source: https://github.com/example/new-app -tags: - - new -` - newAppFile := filepath.Join(newAppDir, "metadata.yaml") - err := os.WriteFile(newAppFile, []byte(newAppYAML), 0644) - require.NoError(t, err, "should be able to write new app file") + server, _ := setupTestServer(t) // Refresh catalog req := httptest.NewRequest("POST", "/api/apps/refresh-catalog", nil) @@ -558,19 +523,19 @@ tags: assert.Equal(t, http.StatusOK, w.Code) - // Verify new app is in catalog + // Catalog is now embedded at build time - verify it loads real apps after refresh req = httptest.NewRequest("GET", "/api/apps", nil) w = httptest.NewRecorder() server.router.ServeHTTP(w, req) var response map[string]interface{} - err = json.NewDecoder(w.Body).Decode(&response) + err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) apps, ok := response["apps"].([]interface{}) require.True(t, ok, "response should contain apps array") - assert.Len(t, apps, 2, "should have 2 apps after refresh") + assert.NotEmpty(t, apps, "catalog should contain embedded apps after refresh") } func TestAPI_PlanInstall(t *testing.T) { @@ -750,14 +715,10 @@ func TestAPI_AppMetadata_NotFound(t *testing.T) { } func TestAPI_AppIcon(t *testing.T) { - server, appsDir := setupTestServer(t) - - // Create an icon file - iconPath := filepath.Join(appsDir, "test-app", "icon.png") - iconData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes - require.NoError(t, os.WriteFile(iconPath, iconData, 0644)) + server, _ := setupTestServer(t) - req := httptest.NewRequest("GET", "/api/apps/test-app/icon", nil) + // Icons are served from the embedded registry - use a real app + req := httptest.NewRequest("GET", "/api/apps/miniflux/icon", nil) w := httptest.NewRecorder() server.router.ServeHTTP(w, req) diff --git a/services/host-agent/internal/api/routes.go b/services/host-agent/internal/api/routes.go index 42d1fec..81dce5b 100644 --- a/services/host-agent/internal/api/routes.go +++ b/services/host-agent/internal/api/routes.go @@ -3,11 +3,13 @@ package api import ( "encoding/json" "fmt" + "io/fs" "net/http" "os" "path/filepath" "strings" + appsregistry "codeberg.org/d-buckner/bloud-v3/apps" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/orchestrator" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/store" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/system" @@ -173,7 +175,7 @@ func (s *Server) handleListApps(w http.ResponseWriter, r *http.Request) { // handleRefreshCatalog reloads the app catalog from YAML files func (s *Server) handleRefreshCatalog(w http.ResponseWriter, r *http.Request) { - s.refreshCatalog(s.cfg.AppsDir) + s.refreshCatalog() respondJSON(w, http.StatusOK, map[string]string{ "status": "catalog refreshed", @@ -527,18 +529,20 @@ func (s *Server) dropAppDatabase(appName string) error { return nil } -// handleAppIcon serves the icon.png for an app +// handleAppIcon serves the icon.png for an app from the embedded registry func (s *Server) handleAppIcon(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") - iconPath := filepath.Join(s.cfg.AppsDir, name, "icon.png") + iconPath := name + "/icon.png" - if _, err := os.Stat(iconPath); os.IsNotExist(err) { + data, err := fs.ReadFile(appsregistry.FS, iconPath) + if err != nil { http.NotFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=86400") - http.ServeFile(w, r, iconPath) + w.Header().Set("Content-Type", "image/png") + w.Write(data) } // Helper functions for JSON responses diff --git a/services/host-agent/internal/api/server.go b/services/host-agent/internal/api/server.go index 5927ae5..7cc1d6b 100644 --- a/services/host-agent/internal/api/server.go +++ b/services/host-agent/internal/api/server.go @@ -11,6 +11,7 @@ import ( "sync" "time" + appsregistry "codeberg.org/d-buckner/bloud-v3/apps" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/catalog" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/netutil" "codeberg.org/d-buckner/bloud-v3/services/host-agent/internal/orchestrator" @@ -45,7 +46,6 @@ type Server struct { // ServerConfig holds paths for server initialization type ServerConfig struct { - AppsDir string ConfigDir string DataDir string // Path to bloud data directory TraefikDynamicDir string // Path to Traefik dynamic config directory (contains apps-routes.yml) @@ -127,7 +127,7 @@ func NewServer(db *sql.DB, cfg ServerConfig, logger *slog.Logger) *Server { } // Initialize catalog and graph on startup - s.refreshCatalog(s.cfg.AppsDir) + s.refreshCatalog() // Initialize orchestrator (Podman client may not be available in tests) s.initOrchestrator(appStore) @@ -208,11 +208,11 @@ func (s *Server) initOrchestrator(appStore *store.AppStore) { // rather than background watchdogs. See podman-service.nix for hook setup. } -// refreshCatalog loads apps from YAML files and updates the cache and graph -func (s *Server) refreshCatalog(appsDir string) { - s.logger.Info("refreshing app catalog", "apps_dir", appsDir) +// refreshCatalog loads apps from the embedded registry and updates the cache and graph +func (s *Server) refreshCatalog() { + s.logger.Info("refreshing app catalog") - loader := catalog.NewLoader(appsDir) + loader := catalog.NewLoaderFromFS(appsregistry.FS) // Refresh the legacy catalog cache if err := s.catalog.Refresh(loader); err != nil { diff --git a/services/host-agent/internal/catalog/loader.go b/services/host-agent/internal/catalog/loader.go index 7ca4307..83f49af 100644 --- a/services/host-agent/internal/catalog/loader.go +++ b/services/host-agent/internal/catalog/loader.go @@ -2,23 +2,27 @@ package catalog import ( "fmt" + "io/fs" "os" - "path/filepath" + "path" "gopkg.in/yaml.v3" ) // Loader handles loading app definitions from YAML files type Loader struct { - appsDir string + fsys fs.FS } -// NewLoader creates a new catalog loader -// appsDir should be the path to the apps/ directory containing app subdirectories +// NewLoader creates a catalog loader that reads from the given directory path. func NewLoader(appsDir string) *Loader { - return &Loader{ - appsDir: appsDir, - } + return &Loader{fsys: os.DirFS(appsDir)} +} + +// NewLoaderFromFS creates a catalog loader that reads from an fs.FS. +// Use this with the embedded app registry for self-contained binaries. +func NewLoaderFromFS(fsys fs.FS) *Loader { + return &Loader{fsys: fsys} } // LoadAll loads all app definitions from the apps directory @@ -26,7 +30,7 @@ func NewLoader(appsDir string) *Loader { func (l *Loader) LoadAll() (map[string]*App, error) { apps := make(map[string]*App) - entries, err := os.ReadDir(l.appsDir) + entries, err := fs.ReadDir(l.fsys, ".") if err != nil { return nil, fmt.Errorf("failed to read apps directory: %w", err) } @@ -36,8 +40,8 @@ func (l *Loader) LoadAll() (map[string]*App, error) { continue } - metadataPath := filepath.Join(l.appsDir, entry.Name(), "metadata.yaml") - if _, err := os.Stat(metadataPath); os.IsNotExist(err) { + metadataPath := path.Join(entry.Name(), "metadata.yaml") + if _, err := fs.Stat(l.fsys, metadataPath); err != nil { continue } @@ -54,7 +58,7 @@ func (l *Loader) LoadAll() (map[string]*App, error) { // loadAppFromFile loads a single app definition from a YAML file func (l *Loader) loadAppFromFile(filePath string) (*App, error) { - data, err := os.ReadFile(filePath) + data, err := fs.ReadFile(l.fsys, filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } @@ -90,7 +94,7 @@ func (l *Loader) validateApp(app *App) error { // LoadGraph loads app definitions and builds an AppGraph func (l *Loader) LoadGraph() (*AppGraph, error) { - entries, err := os.ReadDir(l.appsDir) + entries, err := fs.ReadDir(l.fsys, ".") if err != nil { return nil, fmt.Errorf("failed to read apps directory: %w", err) } @@ -102,8 +106,8 @@ func (l *Loader) LoadGraph() (*AppGraph, error) { continue } - metadataPath := filepath.Join(l.appsDir, entry.Name(), "metadata.yaml") - if _, err := os.Stat(metadataPath); os.IsNotExist(err) { + metadataPath := path.Join(entry.Name(), "metadata.yaml") + if _, err := fs.Stat(l.fsys, metadataPath); err != nil { continue } @@ -120,7 +124,7 @@ func (l *Loader) LoadGraph() (*AppGraph, error) { // loadAppDefinition loads a single AppDefinition from a YAML file func (l *Loader) loadAppDefinition(filePath string) (*AppDefinition, error) { - data, err := os.ReadFile(filePath) + data, err := fs.ReadFile(l.fsys, filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } diff --git a/services/host-agent/internal/config/config.go b/services/host-agent/internal/config/config.go index f099b8b..3e3620f 100644 --- a/services/host-agent/internal/config/config.go +++ b/services/host-agent/internal/config/config.go @@ -13,7 +13,6 @@ import ( type Config struct { Port int DataDir string - AppsDir string // Path to apps/ directory containing app definitions NixConfigDir string TraefikDynamicDir string // Path to Traefik dynamic config directory (contains apps-routes.yml) FlakePath string // Path to flake.nix for nixos-rebuild @@ -81,7 +80,6 @@ func LoadWithLogger(logger *slog.Logger) *Config { cfg := &Config{ Port: getEnvAsInt("BLOUD_PORT", 3000), DataDir: dataDir, - AppsDir: appsDir, NixConfigDir: getEnv("BLOUD_NIX_CONFIG_DIR", filepath.Join(dataDir, "nix")), TraefikDynamicDir: getEnv("BLOUD_TRAEFIK_DYNAMIC_DIR", filepath.Join(dataDir, "traefik", "dynamic")), FlakePath: getEnv("BLOUD_FLAKE_PATH", defaultFlakePath),