3588AdminBackend/internal/service/config_preview.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"])
}