447 lines
12 KiB
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"])
|
|
}
|