Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions apps/registry.go
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion services/host-agent/cmd/host-agent/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions services/host-agent/cmd/host-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down
53 changes: 7 additions & 46 deletions services/host-agent/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,6 @@ tags:
// Create server with fakes
server := &Server{
cfg: ServerConfig{
AppsDir: tmpDir,
ConfigDir: filepath.Join(tmpDir, "nix"),
DataDir: tmpDir,
Port: 8080,
Expand Down Expand Up @@ -385,7 +384,6 @@ integrations:
// Create server
server := &Server{
cfg: ServerConfig{
AppsDir: tmpDir,
ConfigDir: filepath.Join(tmpDir, "nix"),
DataDir: tmpDir,
Port: 8080,
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions services/host-agent/internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions services/host-agent/internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 19 additions & 15 deletions services/host-agent/internal/catalog/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,35 @@ 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
// Each app has its own subdirectory with a metadata.yaml file
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)
}
Expand All @@ -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
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}

Expand All @@ -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)
}
Expand Down
2 changes: 0 additions & 2 deletions services/host-agent/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading