package config import ( "fmt" "net" "os" "path/filepath" "strconv" "gopkg.in/yaml.v3" ) // Document is config.yaml as an editable YAML tree. Persisting through the tree // rather than re-marshalling a Config keeps two things the typed model cannot // represent: keys this program does not know about, and the user's comments. // Loading lives next to it in this package so one place owns the file format. type Document struct { path string node yaml.Node } func OpenDocument(path string) (*Document, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } document := &Document{path: path} if err = yaml.Unmarshal(raw, &document.node); err != nil { return nil, err } return document, nil } // Set replaces a top-level section, merging into whatever is already there. // The value is normalized through a generic map so a typed struct and a // hand-built map produce the same tree. func (d *Document) Set(key string, value any) error { normalized, err := normalizeYAML(value) if err != nil { return err } return setYAMLMapping(&d.node, key, normalized) } // Delete removes the nested key path if present. func (d *Document) Delete(keys ...string) { deleteYAMLMapping(&d.node, keys...) } // DeleteIntegrationConfig removes settings owned by the database-backed // integration store rather than by config.yaml. func (d *Document) DeleteIntegrationConfig() { d.Delete("admin", "storage") d.Delete("admin", "email") } // Has reports whether the nested key path exists. func (d *Document) Has(keys ...string) bool { return yamlMappingValue(&d.node, keys...) != nil } // SetServerHTTPPort rewrites only the port of server.http.addr, keeping the // host the user configured. A non-positive port is ignored. func (d *Document) SetServerHTTPPort(port int) error { if port <= 0 { return nil } host := "0.0.0.0" if addr := yamlMappingValue(&d.node, "server", "http", "addr"); addr != nil { if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" { host = currentHost } } return d.Set("server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(port))}}) } // Save writes the document through a temporary file so a crash mid-write can // never leave a truncated config.yaml behind. func (d *Document) Save() error { output, err := yaml.Marshal(&d.node) if err != nil { return err } if err = os.MkdirAll(filepath.Dir(d.path), 0o755); err != nil { return err } temporary, err := os.CreateTemp(filepath.Dir(d.path), ".kra-config-*.yaml") if err != nil { return err } tempName := temporary.Name() defer os.Remove(tempName) if _, err = temporary.Write(output); err != nil { _ = temporary.Close() return err } if err = temporary.Chmod(0o600); err != nil { _ = temporary.Close() return err } if err = temporary.Close(); err != nil { return err } return os.Rename(tempName, d.path) } func normalizeYAML(input any) (map[string]any, error) { raw, err := yaml.Marshal(input) if err != nil { return nil, err } var output map[string]any if err = yaml.Unmarshal(raw, &output); err != nil { return nil, err } return output, nil } func setYAMLMapping(node *yaml.Node, key string, value any) error { if node.Kind == yaml.DocumentNode { node = node.Content[0] } if node.Kind != yaml.MappingNode { return fmt.Errorf("configuration root is not a mapping") } raw, err := yaml.Marshal(value) if err != nil { return err } var replacement yaml.Node if err = yaml.Unmarshal(raw, &replacement); err != nil { return err } for i := 0; i < len(node.Content); i += 2 { if node.Content[i].Value == key { mergeYAMLNode(node.Content[i+1], replacement.Content[0]) return nil } } node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0]) return nil } // mergeYAMLNode updates values produced from the runtime configuration while // retaining keys that are not represented by the typed configuration and // comments that were already present in the user's config file. func mergeYAMLNode(dst, src *yaml.Node) { if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode { for i := 0; i+1 < len(src.Content); i += 2 { key := src.Content[i].Value found := false for j := 0; j+1 < len(dst.Content); j += 2 { if dst.Content[j].Value == key { mergeYAMLNode(dst.Content[j+1], src.Content[i+1]) found = true break } } if !found { dst.Content = append(dst.Content, src.Content[i], src.Content[i+1]) } } return } // Keep comments attached to a scalar/sequence node when its value changes. head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment *dst = *src dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot } func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node { if node == nil { return nil } if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { node = node.Content[0] } for _, key := range keys { if node.Kind != yaml.MappingNode { return nil } var next *yaml.Node for i := 0; i+1 < len(node.Content); i += 2 { if node.Content[i].Value == key { next = node.Content[i+1] break } } if next == nil { return nil } node = next } return node } func deleteYAMLMapping(node *yaml.Node, keys ...string) { if len(keys) == 0 || node == nil { return } if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { node = node.Content[0] } if node.Kind != yaml.MappingNode { return } key := keys[0] for i := 0; i+1 < len(node.Content); i += 2 { if node.Content[i].Value != key { continue } if len(keys) == 1 { node.Content = append(node.Content[:i], node.Content[i+2:]...) return } deleteYAMLMapping(node.Content[i+1], keys[1:]...) return } }