3588AdminBackend/internal/service/config_preview.go

447 lines
12 KiB
Go

package service
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"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) {
if out, ok, err := s.listRepoSources(); ok || err != nil {
return out, err
}
root := s.mediaRepoRoot()
if root == "" {
return defaultConfigPreviewSources(""), nil
}
out := ConfigPreviewSources{Root: root}
var err error
out.Templates, err = listConfigSources(filepath.Join(root, "configs", "templates"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
out.Profiles, err = listConfigSources(filepath.Join(root, "configs", "profiles"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
out.Overlays, err = listConfigSources(filepath.Join(root, "configs", "overlays"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
return out, nil
}
func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) {
if s == nil || s.assets == nil {
return ConfigPreviewSources{}, false, nil
}
templates, err := s.assets.ListTemplates()
if err != nil {
return ConfigPreviewSources{}, true, err
}
profiles, err := s.assets.ListProfiles()
if err != nil {
return ConfigPreviewSources{}, true, err
}
overlays, err := s.assets.ListOverlays()
if err != nil {
return ConfigPreviewSources{}, true, err
}
if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 {
return ConfigPreviewSources{}, false, nil
}
out := ConfigPreviewSources{Root: "SQLite"}
for _, item := range templates {
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
}
for _, item := range profiles {
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
}
for _, item := range overlays {
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
}
return out, true, nil
}
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json")
return s.renderFromPaths(root, req, templatePath, profilePath)
}
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
}
doc, err := s.BuildProfileDocument(editor)
if err != nil {
return nil, err
}
body, err := marshalConfigJSON(doc)
if err != nil {
return nil, err
}
tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json")
if err != nil {
return nil, err
}
tempProfilePath := tempProfile.Name()
if _, err := tempProfile.Write(body); err != nil {
_ = tempProfile.Close()
_ = os.Remove(tempProfilePath)
return nil, err
}
_ = tempProfile.Close()
defer os.Remove(tempProfilePath)
if strings.TrimSpace(req.Profile) == "" {
req.Profile = strings.TrimSpace(editor.Name)
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
return s.renderFromPaths(root, req, templatePath, tempProfilePath)
}
func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*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")
}
if _, err := os.Stat(templatePath); err != nil {
return nil, err
}
if _, err := os.Stat(profilePath); err != nil {
return nil, fmt.Errorf("invalid profile: %w", err)
}
out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
if err != nil {
return nil, err
}
outPath := out.Name()
_ = out.Close()
defer os.Remove(outPath)
args := []string{
filepath.Join(root, "tools", "render_config.py"),
"--template", templatePath,
"--profile", profilePath,
"--out", outPath,
"--config-id", req.ConfigID,
"--config-version", req.ConfigVersion,
"--rendered-at", time.Now().Format(time.RFC3339),
}
for _, overlay := range req.Overlays {
args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json"))
}
cmd := exec.Command("python", args...)
cmd.Dir = root
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("render config preview: %s", msg)
}
body, err := os.ReadFile(outPath)
if err != nil {
return nil, err
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
return nil, err
}
metadata, _ := doc["metadata"].(map[string]any)
sum := sha256.Sum256(body)
return &ConfigPreviewResult{
Request: req,
Root: root,
Sha256: hex.EncodeToString(sum[:]),
Size: len(body),
Metadata: metadata,
JSON: string(body),
}, nil
}
func (s *ConfigPreviewService) mediaRepoRoot() string {
if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" {
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
}
if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" {
return filepath.Clean(env)
}
wd, err := os.Getwd()
if err != nil {
return ""
}
candidates := []string{
filepath.Join(wd, "..", "OrangePi3588Media"),
filepath.Join(wd, "..", "..", "OrangePi3588Media"),
filepath.Join(filepath.Dir(wd), "OrangePi3588Media"),
}
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil {
return filepath.Clean(candidate)
}
}
return ""
}
func (s *ConfigPreviewService) hasExplicitRoot() bool {
return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != ""
}
func listConfigSources(dir string) ([]ConfigSource, error) {
files, err := os.ReadDir(dir)
if err != 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 defaultConfigPreviewSources(root string) ConfigPreviewSources {
return ConfigPreviewSources{
Root: root,
Templates: []ConfigSource{
{Name: "workshop_face_shoe_alarm"},
},
Profiles: []ConfigSource{
{Name: "local_3588_test"},
},
Overlays: []ConfigSource{
{Name: "face_debug"},
{Name: "face_test_sensitive"},
{Name: "production_quiet"},
{Name: "shoe_debug"},
{Name: "shoe_test_sensitive"},
},
}
}
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("assets repository is not configured")
}
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo 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 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 {
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
return body, name + ".json", err
}
}
root := s.mediaRepoRoot()
if root == "" {
return nil, "", fmt.Errorf("media repo path is not configured")
}
path := filepath.Join(root, "configs", kind, name+".json")
body, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
return body, name + ".json", nil
}
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["template_name"])
}