diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index d7045eb..2fcaa34 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -68,6 +68,15 @@ type ConfigOverlayAsset struct { Raw map[string]any `json:"raw"` } +type ConfigIntegrationServiceAsset struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Raw map[string]any `json:"raw"` +} + func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { items := make([]ConfigTemplateAsset, 0) seen := map[string]bool{} @@ -320,6 +329,42 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset }, nil } +func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListIntegrationServices() + if err != nil { + return nil, err + } + items := make([]ConfigIntegrationServiceAsset, 0, len(records)) + for _, record := range records { + item, err := integrationServiceAssetFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + return items, nil +} + +func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return nil, err + } + record, err := s.assets.GetIntegrationService(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return integrationServiceAssetFromRecord(*record) +} + func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { if s != nil && s.assets != nil { raw, path, ok, err := s.readRepoAssetJSON(kind, name) @@ -468,6 +513,24 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly } } +func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + return &ConfigIntegrationServiceAsset{ + Name: firstString(raw["name"], record.Name), + Path: repoAssetPath("integration_services", record.Name), + Type: firstString(record.ServiceType, stringValue(raw["type"])), + Description: firstString(raw["description"], record.Description), + Enabled: boolValue(raw["enabled"], record.Enabled), + Raw: raw, + }, nil +} + func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { root := s.mediaRepoRoot() if root == "" { @@ -519,6 +582,24 @@ func valueString(v any) string { } } +func boolValue(v any, fallback bool) bool { + switch value := v.(type) { + case bool: + return value + case float64: + return value != 0 + case int: + return value != 0 + case int64: + return value != 0 + case string: + value = strings.TrimSpace(strings.ToLower(value)) + return value == "1" || value == "true" || value == "yes" || value == "on" + default: + return fallback + } +} + func firstString(v any, fallback string) string { if got := stringValue(v); got != "" { return got diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index 1a828ea..c183942 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -336,6 +337,111 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) { } } +func TestListIntegrationServices(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","endpoint":"http://10.0.0.49:9000","enabled":false}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + items, err := svc.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 integration service, got %#v", items) + } + if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled { + t.Fatalf("unexpected integration service summary: %#v", items[0]) + } + + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item == nil { + t.Fatal("expected integration service") + } + if item.Description != "primary object store" { + t.Fatalf("unexpected integration service description: %#v", item) + } + if item.Type != "object_storage" || item.Enabled { + t.Fatalf("unexpected integration service status: %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "object_storage" { + t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw) + } + if got := stringValue(item.Raw["provider"]); got != "minio" { + t.Fatalf("unexpected integration service provider: %#v", item.Raw) + } + if got := stringValue(item.Raw["endpoint"]); got != "http://10.0.0.49:9000" { + t.Fatalf("unexpected integration service endpoint: %#v", item.Raw) + } + if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled { + t.Fatalf("expected raw enabled=false, got %#v", item.Raw) + } +} + +func TestGetIntegrationServiceNotFound(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB())) + item, err := svc.GetIntegrationService("missing_service") + if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) { + t.Fatalf("expected not found error, got item=%#v err=%v", item, err) + } + if item != nil { + t.Fatalf("expected nil item for missing integration service, got %#v", item) + } +} + +func TestGetIntegrationServicePrefersRecordTypeOverRawType(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + true, + `{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{}, repo) + item, err := svc.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if item.Type != "object_storage" { + t.Fatalf("expected canonical type from record, got %#v", item) + } + if got := stringValue(item.Raw["type"]); got != "minio" { + t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw) + } +} + func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 677a5e7..cdfd607 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -18,6 +18,16 @@ type AssetRecord struct { UpdatedAt string } +type IntegrationServiceRecord struct { + Name string + ServiceType string + Description string + Enabled bool + BodyJSON string + CreatedAt string + UpdatedAt string +} + type AssetsRepo struct { db *sql.DB } @@ -52,6 +62,24 @@ func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON strin }) } +func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error { + if r == nil || r.db == nil { + return nil + } + now := time.Now().Format(time.RFC3339) + _, err := r.db.Exec(` +INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?) +ON CONFLICT(name) DO UPDATE SET + type=excluded.type, + description=excluded.description, + enabled=excluded.enabled, + body_json=excluded.body_json, + updated_at=excluded.updated_at +`, name, serviceType, description, enabled, bodyJSON, name, now, now) + return err +} + func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) { return r.listAssets("templates") } @@ -64,6 +92,31 @@ func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) { return r.listAssets("overlays") } +func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + rows, err := r.db.Query(` +SELECT name, type, description, enabled, body_json, created_at, updated_at +FROM integration_services +ORDER BY updated_at DESC, name ASC +`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []IntegrationServiceRecord + for rows.Next() { + var item IntegrationServiceRecord + if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) { return r.getAsset("templates", name) } @@ -76,10 +129,37 @@ func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) { return r.getAsset("overlays", name) } +func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) { + if r == nil || r.db == nil { + return nil, nil + } + var item IntegrationServiceRecord + err := r.db.QueryRow(` +SELECT name, type, description, enabled, body_json, created_at, updated_at +FROM integration_services +WHERE name = ? +`, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil +} + func (r *AssetsRepo) DeleteTemplate(name string) error { return r.deleteAsset("templates", name) } +func (r *AssetsRepo) DeleteIntegrationService(name string) error { + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name) + return err +} + func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { if r == nil || r.db == nil { return nil diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 1eb4db8..0c9d679 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -102,3 +102,44 @@ func TestAssetsRepoDeleteTemplateRemovesRecord(t *testing.T) { t.Fatalf("expected template deleted, got %#v", record) } } + +func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) { + store := openTestStore(t) + defer store.Close() + + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveIntegrationService( + "minio_primary", + "object_storage", + "primary object store", + false, + `{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false}`, + ); err != nil { + t.Fatalf("SaveIntegrationService: %v", err) + } + + records, err := repo.ListIntegrationServices() + if err != nil { + t.Fatalf("ListIntegrationServices: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one integration service, got %#v", records) + } + if records[0].ServiceType != "object_storage" || records[0].Enabled { + t.Fatalf("unexpected integration service record: %#v", records[0]) + } + + record, err := repo.GetIntegrationService("minio_primary") + if err != nil { + t.Fatalf("GetIntegrationService: %v", err) + } + if record == nil { + t.Fatal("expected integration service record") + } + if record.ServiceType != "object_storage" || record.Enabled { + t.Fatalf("unexpected integration service fetch: %#v", record) + } + if !strings.Contains(record.BodyJSON, `"provider":"minio"`) { + t.Fatalf("expected body_json payload preserved, got %#v", record) + } +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index b42d66b..0b56b6a 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -29,6 +29,16 @@ CREATE TABLE IF NOT EXISTS overlays ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS integration_services ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + body_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS devices ( device_id TEXT PRIMARY KEY, hostname TEXT NOT NULL DEFAULT '', diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 126486b..8efea62 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -17,6 +17,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) { "templates", "profiles", "overlays", + "integration_services", "devices", "device_config_state", "tasks",