kra-new/internal/config/document.go

217 lines
5.7 KiB
Go

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
}
}