533 lines
16 KiB
Go
533 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"3588AdminBackend/internal/config"
|
|
"3588AdminBackend/internal/storage"
|
|
)
|
|
|
|
var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
|
|
|
type ConfigPreviewService struct {
|
|
cfg *config.Config
|
|
assets *storage.AssetsRepo
|
|
}
|
|
|
|
type ConfigSource struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type ConfigPreviewSources struct {
|
|
Root string `json:"root"`
|
|
Templates []ConfigSource `json:"templates"`
|
|
Profiles []ConfigSource `json:"profiles"`
|
|
Overlays []ConfigSource `json:"overlays"`
|
|
}
|
|
|
|
type ConfigPreviewRequest struct {
|
|
Template string
|
|
Profile string
|
|
Overlays []string
|
|
ConfigID string
|
|
ConfigVersion string
|
|
}
|
|
|
|
type ConfigPreviewResult struct {
|
|
Request ConfigPreviewRequest `json:"request"`
|
|
Root string `json:"root"`
|
|
Sha256 string `json:"sha256"`
|
|
Size int `json:"size"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
JSON string `json:"json"`
|
|
}
|
|
|
|
type ConfigAssetImportResult struct {
|
|
Root string `json:"root"`
|
|
Templates int `json:"templates"`
|
|
Profiles int `json:"profiles"`
|
|
Overlays int `json:"overlays"`
|
|
}
|
|
|
|
func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *ConfigPreviewService {
|
|
var assets *storage.AssetsRepo
|
|
if len(repo) > 0 {
|
|
assets = repo[0]
|
|
}
|
|
return &ConfigPreviewService{cfg: cfg, assets: assets}
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
|
|
out := ConfigPreviewSources{}
|
|
if s == nil || s.assets == nil {
|
|
return out, nil
|
|
}
|
|
templates, err := s.assets.ListTemplates()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for _, item := range templates {
|
|
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
|
|
}
|
|
profiles, err := s.assets.ListProfiles()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for _, item := range profiles {
|
|
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
|
|
}
|
|
overlays, err := s.assets.ListOverlays()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for _, item := range overlays {
|
|
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
|
|
}
|
|
sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
|
|
sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
|
|
sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
|
|
if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 {
|
|
out.Root = "SQLite"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
|
|
if err := validateConfigName(req.Template); err != nil {
|
|
return nil, fmt.Errorf("invalid template: %w", err)
|
|
}
|
|
if strings.TrimSpace(req.Profile) != "" {
|
|
if err := validateConfigName(req.Profile); err != nil {
|
|
return nil, fmt.Errorf("invalid profile: %w", err)
|
|
}
|
|
}
|
|
for _, overlay := range req.Overlays {
|
|
if err := validateConfigName(overlay); err != nil {
|
|
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
|
}
|
|
}
|
|
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profileRaw, profilePath, err := s.readAssetJSON("profiles", req.Profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
|
for _, overlay := range req.Overlays {
|
|
raw, path, err := s.readAssetJSON("overlays", overlay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
|
}
|
|
return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
|
|
if err := validateConfigName(req.Template); err != nil {
|
|
return nil, fmt.Errorf("invalid template: %w", err)
|
|
}
|
|
for _, overlay := range req.Overlays {
|
|
if err := validateConfigName(overlay); err != nil {
|
|
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
|
}
|
|
}
|
|
doc, err := s.BuildProfileDocument(editor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(req.Profile) == "" {
|
|
req.Profile = strings.TrimSpace(editor.Name)
|
|
}
|
|
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overlays := make([]runtimeOverlayInput, 0, len(req.Overlays))
|
|
for _, overlay := range req.Overlays {
|
|
raw, path, err := s.readAssetJSON("overlays", overlay)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
|
|
}
|
|
return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
|
|
if err := validateConfigName(req.Template); err != nil {
|
|
return nil, fmt.Errorf("invalid template: %w", err)
|
|
}
|
|
if strings.TrimSpace(req.Profile) != "" {
|
|
if err := validateConfigName(req.Profile); err != nil {
|
|
return nil, fmt.Errorf("invalid profile: %w", err)
|
|
}
|
|
}
|
|
for _, overlay := range req.Overlays {
|
|
if err := validateConfigName(overlay); err != nil {
|
|
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
|
}
|
|
}
|
|
if req.ConfigID == "" {
|
|
req.ConfigID = "preview_" + time.Now().Format("20060102.150405")
|
|
}
|
|
if req.ConfigVersion == "" {
|
|
req.ConfigVersion = time.Now().Format("20060102.150405")
|
|
}
|
|
resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, map[string]any{
|
|
"config_id": req.ConfigID,
|
|
"config_version": req.ConfigVersion,
|
|
"rendered_at": time.Now().Format(time.RFC3339),
|
|
"rendered_by": "managerd",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := marshalConfigJSON(doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metadata, _ := doc["metadata"].(map[string]any)
|
|
sum := sha256.Sum256(body)
|
|
return &ConfigPreviewResult{
|
|
Request: req,
|
|
Root: previewRenderRoot(templatePath, profilePath),
|
|
Sha256: hex.EncodeToString(sum[:]),
|
|
Size: len(body),
|
|
Metadata: metadata,
|
|
JSON: string(body),
|
|
}, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
|
|
if raw == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
body, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var clone map[string]any
|
|
if err := json.Unmarshal(body, &clone); err != nil {
|
|
return nil, err
|
|
}
|
|
instances, _ := clone["instances"].([]any)
|
|
for _, item := range instances {
|
|
instanceMap, _ := item.(map[string]any)
|
|
paramsMap, _ := instanceMap["params"].(map[string]any)
|
|
if paramsMap == nil {
|
|
paramsMap = map[string]any{}
|
|
instanceMap["params"] = paramsMap
|
|
}
|
|
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
|
|
if inputBindings == nil {
|
|
inputBindings = map[string]any{}
|
|
instanceMap["input_bindings"] = inputBindings
|
|
}
|
|
videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref")
|
|
if videoSourceRef != "" {
|
|
asset, err := s.GetVideoSource(videoSourceRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err)
|
|
}
|
|
entry, _ := inputBindings["video_input_main"].(map[string]any)
|
|
if entry == nil {
|
|
entry = map[string]any{}
|
|
}
|
|
entry["video_source_ref"] = videoSourceRef
|
|
entry["resolved"] = map[string]any{
|
|
"url": asset.Config.URL,
|
|
"resolution": asset.Config.Resolution,
|
|
"frame_size": asset.Config.FrameSize,
|
|
"fps": asset.Config.FPS,
|
|
"video_format": asset.Config.VideoFormat,
|
|
}
|
|
inputBindings["video_input_main"] = entry
|
|
}
|
|
|
|
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
|
|
if serviceBindings == nil {
|
|
serviceBindings = map[string]any{}
|
|
instanceMap["service_bindings"] = serviceBindings
|
|
}
|
|
for _, binding := range []struct {
|
|
slot string
|
|
expected string
|
|
}{
|
|
{slot: "object_storage_main", expected: "object_storage"},
|
|
{slot: "token_service_main", expected: "token_service"},
|
|
{slot: "alarm_service_main", expected: "alarm_service"},
|
|
} {
|
|
serviceRef := bindingField(serviceBindings, binding.slot, "service_ref")
|
|
if serviceRef == "" {
|
|
continue
|
|
}
|
|
asset, err := s.GetIntegrationService(serviceRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err)
|
|
}
|
|
if strings.TrimSpace(asset.Type) != binding.expected {
|
|
return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected)
|
|
}
|
|
entry, _ := serviceBindings[binding.slot].(map[string]any)
|
|
if entry == nil {
|
|
entry = map[string]any{}
|
|
}
|
|
entry["service_ref"] = serviceRef
|
|
entry["resolved"] = resolvedServiceBinding(asset)
|
|
serviceBindings[binding.slot] = entry
|
|
}
|
|
}
|
|
return clone, nil
|
|
}
|
|
|
|
func bindingField(bindings map[string]any, slot string, field string) string {
|
|
entry, _ := bindings[slot].(map[string]any)
|
|
return stringValue(entry[field])
|
|
}
|
|
|
|
func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any {
|
|
if asset == nil {
|
|
return nil
|
|
}
|
|
switch asset.Type {
|
|
case "object_storage":
|
|
if asset.ObjectStorage == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"endpoint": asset.ObjectStorage.Endpoint,
|
|
"bucket": asset.ObjectStorage.Bucket,
|
|
"access_key": asset.ObjectStorage.AccessKey,
|
|
"secret_key": asset.ObjectStorage.SecretKey,
|
|
}
|
|
case "token_service":
|
|
if asset.TokenService == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"get_token_url": asset.TokenService.GetTokenURL,
|
|
"username": asset.TokenService.Username,
|
|
"password": asset.TokenService.Password,
|
|
"tenant_code": asset.TokenService.TenantCode,
|
|
}
|
|
case "alarm_service":
|
|
if asset.AlarmService == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"put_message_url": asset.AlarmService.PutMessageURL,
|
|
"username": asset.AlarmService.Username,
|
|
"password": asset.AlarmService.Password,
|
|
"tenant_code": asset.AlarmService.TenantCode,
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func previewRenderRoot(templatePath string, profilePath string) string {
|
|
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
|
|
return "SQLite"
|
|
}
|
|
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
|
|
return dir
|
|
}
|
|
return "managerd"
|
|
}
|
|
|
|
func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {
|
|
body, err := marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tempFile, err := os.CreateTemp("", pattern)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := tempFile.Name()
|
|
if _, err := tempFile.Write(body); err != nil {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(path)
|
|
return "", err
|
|
}
|
|
_ = tempFile.Close()
|
|
return path, nil
|
|
}
|
|
|
|
func setAnyString(m map[string]any, key string, value string) {
|
|
if strings.TrimSpace(value) != "" {
|
|
m[key] = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
|
|
func (s *ConfigPreviewService) mediaRepoRoot() string {
|
|
if s.cfg == nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(s.cfg.MediaRepoPath) == "" {
|
|
return ""
|
|
}
|
|
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
|
|
}
|
|
|
|
func listConfigSources(dir string) ([]ConfigSource, error) {
|
|
files, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []ConfigSource{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
out := make([]ConfigSource, 0)
|
|
for _, file := range files {
|
|
if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))
|
|
out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
func validateConfigName(name string) error {
|
|
if !safeConfigName.MatchString(strings.TrimSpace(name)) {
|
|
return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash")
|
|
}
|
|
if strings.Contains(name, "..") {
|
|
return fmt.Errorf("must not contain '..'")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func repoAssetPath(kind string, name string) string {
|
|
return "sqlite:" + kind + "/" + strings.TrimSpace(name)
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) {
|
|
if s == nil || s.assets == nil {
|
|
return nil, fmt.Errorf("asset repository is not configured")
|
|
}
|
|
root := s.mediaRepoRoot()
|
|
if root == "" {
|
|
return nil, fmt.Errorf("legacy import source path is not configured")
|
|
}
|
|
result := &ConfigAssetImportResult{Root: root}
|
|
for _, item := range []struct {
|
|
kind string
|
|
inc *int
|
|
}{
|
|
{kind: "templates", inc: &result.Templates},
|
|
{kind: "profiles", inc: &result.Profiles},
|
|
{kind: "overlays", inc: &result.Overlays},
|
|
} {
|
|
sources, err := listConfigSources(filepath.Join(root, "configs", item.kind))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, source := range sources {
|
|
body, err := os.ReadFile(source.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
name := firstString(raw["name"], source.Name)
|
|
description := stringValue(raw["description"])
|
|
switch item.kind {
|
|
case "templates":
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" {
|
|
raw["source"] = "standard"
|
|
body, err = marshalConfigJSON(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.assets.SaveTemplate(name, description, string(body)); err != nil {
|
|
return nil, err
|
|
}
|
|
case "profiles":
|
|
if err := s.assets.SaveProfile(name, profileRawTemplateName(raw), stringValue(raw["business_name"]), description, string(body)); err != nil {
|
|
return nil, err
|
|
}
|
|
case "overlays":
|
|
if err := s.assets.SaveOverlay(name, description, string(body)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
*item.inc = *item.inc + 1
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte, string, error) {
|
|
if err := validateConfigName(name); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if s == nil || s.assets == nil {
|
|
return nil, "", fmt.Errorf("asset repository is not configured")
|
|
}
|
|
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
|
|
return body, name + ".json", err
|
|
}
|
|
return nil, "", os.ErrNotExist
|
|
}
|
|
|
|
func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) {
|
|
var (
|
|
record *storage.AssetRecord
|
|
err error
|
|
)
|
|
switch kind {
|
|
case "templates":
|
|
record, err = s.assets.GetTemplate(name)
|
|
case "profiles":
|
|
record, err = s.assets.GetProfile(name)
|
|
case "overlays":
|
|
record, err = s.assets.GetOverlay(name)
|
|
default:
|
|
return nil, true, fmt.Errorf("unsupported asset kind: %s", kind)
|
|
}
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
if record == nil {
|
|
return nil, false, nil
|
|
}
|
|
return []byte(record.BodyJSON), true, nil
|
|
}
|
|
|
|
func profileRawTemplateName(raw map[string]any) string {
|
|
instances, _ := raw["instances"].([]any)
|
|
for _, item := range instances {
|
|
instanceMap, _ := item.(map[string]any)
|
|
if v := stringValue(instanceMap["template"]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return stringValue(raw["primary_template_name"])
|
|
}
|