448 lines
14 KiB
Go
448 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`)
|
|
|
|
func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) {
|
|
tplName, err := runtimeTemplateName(templateRaw, templatePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instances, err := runtimeProfileInstances(profileRaw, tplName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
renderedTemplates := map[string]any{}
|
|
renderedInstances := make([]any, 0, len(instances))
|
|
for _, instance := range instances {
|
|
boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
renderedTemplates[boundName] = boundTemplate
|
|
renderedInstances = append(renderedInstances, renderedInstance)
|
|
}
|
|
|
|
root := map[string]any{
|
|
"templates": renderedTemplates,
|
|
"instances": renderedInstances,
|
|
}
|
|
for _, key := range []string{"global", "queue"} {
|
|
if value, ok := profileRaw[key]; ok {
|
|
root[key] = deepCopyAny(value)
|
|
}
|
|
}
|
|
|
|
for _, overlay := range overlays {
|
|
var err error
|
|
root, err = applyRuntimeOverlay(root, overlay.Raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if metadata != nil {
|
|
root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata)
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
type runtimeOverlayInput struct {
|
|
Name string
|
|
Path string
|
|
Raw map[string]any
|
|
}
|
|
|
|
func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) {
|
|
name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath))))
|
|
if name == "" {
|
|
return "", fmt.Errorf("%s: template name is empty", templatePath)
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) {
|
|
body := templateRaw
|
|
if nested, ok := templateRaw["template"].(map[string]any); ok {
|
|
body = nested
|
|
}
|
|
nodes, hasNodes := body["nodes"].([]any)
|
|
edges, hasEdges := body["edges"].([]any)
|
|
if !hasNodes || !hasEdges {
|
|
return nil, fmt.Errorf("template body must contain nodes[] and edges[]")
|
|
}
|
|
out := map[string]any{
|
|
"nodes": deepCopyAny(nodes),
|
|
"edges": deepCopyAny(edges),
|
|
}
|
|
if executor, ok := body["executor"]; ok {
|
|
out["executor"] = deepCopyAny(executor)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func runtimeTemplateParams(templateRaw map[string]any) map[string]any {
|
|
params, _ := templateRaw["params"].(map[string]any)
|
|
if params == nil {
|
|
return map[string]any{}
|
|
}
|
|
return cloneMap(params)
|
|
}
|
|
|
|
func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) {
|
|
if items, ok := profileRaw["instances"].([]any); ok {
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
instance, _ := item.(map[string]any)
|
|
if instance == nil {
|
|
return nil, fmt.Errorf("profile.instances entries must be objects")
|
|
}
|
|
cloned := deepCopyMap(instance)
|
|
if strings.TrimSpace(stringValue(cloned["template"])) == "" {
|
|
cloned["template"] = tplName
|
|
}
|
|
out = append(out, cloned)
|
|
}
|
|
return out, nil
|
|
}
|
|
name := strings.TrimSpace(stringValue(profileRaw["name"]))
|
|
if name == "" {
|
|
return nil, fmt.Errorf("profile must contain name or instances[]")
|
|
}
|
|
instance := map[string]any{
|
|
"name": name,
|
|
"template": tplName,
|
|
}
|
|
if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 {
|
|
instance["params"] = deepCopyMap(params)
|
|
}
|
|
if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 {
|
|
instance["override"] = deepCopyMap(override)
|
|
}
|
|
return []map[string]any{instance}, nil
|
|
}
|
|
|
|
func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) {
|
|
if len(sharedParams) == 0 {
|
|
return instances, nil
|
|
}
|
|
out := make([]map[string]any, 0, len(instances))
|
|
for _, item := range instances {
|
|
inst := deepCopyMap(item)
|
|
params, _ := inst["params"].(map[string]any)
|
|
if params == nil {
|
|
params = map[string]any{}
|
|
}
|
|
inst["params"] = deepMergeMap(sharedParams, params)
|
|
out = append(out, inst)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) {
|
|
instanceName := strings.TrimSpace(stringValue(instance["name"]))
|
|
if instanceName == "" {
|
|
return "", nil, nil, fmt.Errorf("scene instance name is required")
|
|
}
|
|
tplName, err := runtimeTemplateName(templateRaw, templatePath)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
boundName := tplName + "__" + instanceName
|
|
context, err := buildRuntimeBindingContext(instance)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
templateBody, err := runtimeTemplateBody(templateRaw)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
slotRequirements, err := runtimeSlotRequirements(templateRaw)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context, slotRequirements)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
renderedTemplate, _ := renderedTemplateAny.(map[string]any)
|
|
renderedInstance := map[string]any{
|
|
"name": instanceName,
|
|
"template": boundName,
|
|
}
|
|
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
|
renderedInstance["scene_meta"] = deepCopyMap(sceneMeta)
|
|
}
|
|
if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 {
|
|
renderedInstance["params"] = deepCopyMap(params)
|
|
}
|
|
return boundName, renderedTemplate, renderedInstance, nil
|
|
}
|
|
|
|
func runtimeSlotRequirements(templateRaw map[string]any) (map[string]bool, error) {
|
|
group, err := parseTemplateSlots(templateRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]bool{}
|
|
for _, slot := range group.Inputs {
|
|
out[slot.Name] = slot.Required
|
|
}
|
|
for _, slot := range group.Services {
|
|
out[slot.Name] = slot.Required
|
|
}
|
|
for _, slot := range group.Outputs {
|
|
out[slot.Name] = slot.Required
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) {
|
|
context := map[string]any{}
|
|
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
|
|
context["scene"] = deepCopyMap(sceneMeta)
|
|
}
|
|
for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} {
|
|
group, _ := instance[groupName].(map[string]any)
|
|
for slotName, raw := range group {
|
|
entry, _ := raw.(map[string]any)
|
|
if entry == nil {
|
|
return nil, fmt.Errorf("binding entry must be an object")
|
|
}
|
|
context[slotName] = resolvedRuntimeBindingValue(entry)
|
|
}
|
|
}
|
|
return context, nil
|
|
}
|
|
|
|
func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
|
|
if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil {
|
|
return deepCopyMap(resolved)
|
|
}
|
|
return deepCopyMap(entry)
|
|
}
|
|
|
|
func expandRuntimeSlotTokens(value any, context map[string]any, slotRequirements map[string]bool) (any, error) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(typed))
|
|
for key, item := range typed {
|
|
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[key] = expanded
|
|
}
|
|
return out, nil
|
|
case []any:
|
|
out := make([]any, 0, len(typed))
|
|
for _, item := range typed {
|
|
expanded, err := expandRuntimeSlotTokens(item, context, slotRequirements)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, expanded)
|
|
}
|
|
return out, nil
|
|
case string:
|
|
match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed))
|
|
if len(match) != 3 {
|
|
return typed, nil
|
|
}
|
|
required, known := slotRequirements[match[1]]
|
|
if !known {
|
|
required = true
|
|
}
|
|
slotValues, _ := context[match[1]].(map[string]any)
|
|
if slotValues == nil {
|
|
if !required {
|
|
return "", nil
|
|
}
|
|
return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
|
|
}
|
|
fieldValue, ok := slotValues[match[2]]
|
|
if !ok {
|
|
if !required {
|
|
return "", nil
|
|
}
|
|
return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
|
|
}
|
|
return deepCopyAny(fieldValue), nil
|
|
default:
|
|
return deepCopyAny(value), nil
|
|
}
|
|
}
|
|
|
|
func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) {
|
|
out := deepCopyMap(root)
|
|
for _, key := range []string{"global", "queue", "templates"} {
|
|
if value, ok := overlay[key]; ok {
|
|
out[key] = deepMergeAny(out[key], value)
|
|
}
|
|
}
|
|
if rawPatches, ok := overlay["instance_overrides"]; ok {
|
|
patches, _ := rawPatches.(map[string]any)
|
|
if patches == nil {
|
|
return nil, fmt.Errorf("overlay.instance_overrides must be an object")
|
|
}
|
|
instances, _ := out["instances"].([]any)
|
|
mergedInstances := make([]any, 0, len(instances))
|
|
for _, item := range instances {
|
|
instance, _ := item.(map[string]any)
|
|
merged := deepCopyMap(instance)
|
|
if patch, ok := patches["*"].(map[string]any); ok {
|
|
merged = mergeRuntimeInstancePatch(merged, patch)
|
|
}
|
|
name := stringValue(merged["name"])
|
|
if patch, ok := patches[name].(map[string]any); ok {
|
|
merged = mergeRuntimeInstancePatch(merged, patch)
|
|
}
|
|
mergedInstances = append(mergedInstances, merged)
|
|
}
|
|
out["instances"] = mergedInstances
|
|
}
|
|
if rawInstances, ok := overlay["instances"]; ok {
|
|
patchList, _ := rawInstances.([]any)
|
|
if patchList == nil {
|
|
return nil, fmt.Errorf("overlay.instances must be an array")
|
|
}
|
|
instances, _ := out["instances"].([]any)
|
|
byName := map[string]int{}
|
|
for i, item := range instances {
|
|
instance, _ := item.(map[string]any)
|
|
byName[stringValue(instance["name"])] = i
|
|
}
|
|
for _, item := range patchList {
|
|
patch, _ := item.(map[string]any)
|
|
name := stringValue(patch["name"])
|
|
if patch == nil || name == "" {
|
|
return nil, fmt.Errorf("overlay.instances entries must be objects with name")
|
|
}
|
|
idx, ok := byName[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("overlay instance not found in profile: %s", name)
|
|
}
|
|
instance, _ := instances[idx].(map[string]any)
|
|
instances[idx] = mergeRuntimeInstancePatch(instance, patch)
|
|
}
|
|
out["instances"] = instances
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any {
|
|
merged := deepCopyMap(instance)
|
|
if params, ok := patch["params"].(map[string]any); ok {
|
|
existing, _ := merged["params"].(map[string]any)
|
|
merged["params"] = deepMergeMap(existing, params)
|
|
}
|
|
if override, ok := patch["override"].(map[string]any); ok {
|
|
existing, _ := merged["override"].(map[string]any)
|
|
merged["override"] = deepMergeMap(existing, override)
|
|
}
|
|
for key, value := range patch {
|
|
if key == "name" || key == "template" || key == "params" || key == "override" {
|
|
continue
|
|
}
|
|
merged[key] = deepMergeAny(merged[key], value)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any {
|
|
instanceNames := make([]string, 0)
|
|
instanceDisplayNames := make([]string, 0)
|
|
instances, _ := root["instances"].([]any)
|
|
for _, item := range instances {
|
|
instance, _ := item.(map[string]any)
|
|
if name := strings.TrimSpace(stringValue(instance["name"])); name != "" {
|
|
instanceNames = append(instanceNames, name)
|
|
}
|
|
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok {
|
|
if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" {
|
|
instanceDisplayNames = append(instanceDisplayNames, displayName)
|
|
}
|
|
}
|
|
}
|
|
overlayNames := make([]any, 0, len(overlays))
|
|
overlayPaths := make([]any, 0, len(overlays))
|
|
for _, overlay := range overlays {
|
|
overlayNames = append(overlayNames, overlay.Name)
|
|
overlayPaths = append(overlayPaths, overlay.Path)
|
|
}
|
|
out := map[string]any{
|
|
"template": templateName,
|
|
"template_path": templatePath,
|
|
"profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))),
|
|
"business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])),
|
|
"profile_path": profilePath,
|
|
"instance_names": instanceNames,
|
|
"instance_display_names": instanceDisplayNames,
|
|
"overlays": overlayNames,
|
|
"overlay_paths": overlayPaths,
|
|
}
|
|
for key, value := range metadata {
|
|
out[key] = deepCopyAny(value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func deepMergeMap(base map[string]any, override map[string]any) map[string]any {
|
|
if base == nil {
|
|
base = map[string]any{}
|
|
}
|
|
return deepMergeAny(base, override).(map[string]any)
|
|
}
|
|
|
|
func deepMergeAny(base any, override any) any {
|
|
baseMap, baseIsMap := base.(map[string]any)
|
|
overrideMap, overrideIsMap := override.(map[string]any)
|
|
if baseIsMap && overrideIsMap {
|
|
merged := deepCopyMap(baseMap)
|
|
for key, value := range overrideMap {
|
|
if existing, ok := merged[key]; ok {
|
|
merged[key] = deepMergeAny(existing, value)
|
|
} else {
|
|
merged[key] = deepCopyAny(value)
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
return deepCopyAny(override)
|
|
}
|
|
|
|
func deepCopyMap(in map[string]any) map[string]any {
|
|
if in == nil {
|
|
return map[string]any{}
|
|
}
|
|
out, _ := deepCopyAny(in).(map[string]any)
|
|
return out
|
|
}
|
|
|
|
func deepCopyAny(value any) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return value
|
|
}
|
|
return out
|
|
}
|