Add third-party service storage support

This commit is contained in:
tian 2026-04-29 10:42:24 +08:00
parent 951b528f97
commit 5620aad10b
6 changed files with 319 additions and 0 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 '',

View File

@ -17,6 +17,7 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
"templates",
"profiles",
"overlays",
"integration_services",
"devices",
"device_config_state",
"tasks",