mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
feat: config command to edit config files (#1140)
Changing a setting after `generate-config` meant hand-editing YAML, which is awkward in provisioning scripts. `config` now edits an existing file in place: ```bash ./gitea-runner -c config.yaml config set runner.capacity 4 ./gitea-runner -c config.yaml config set runner.envs.MY_VAR value ./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22' ./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22' ./gitea-runner -c config.yaml config get runner.labels ``` Edits go through the YAML node tree, so comments, key order and the blank lines between top-level sections survive — a test asserts that appending a label to `config.example.yaml` changes nothing but the added line. Keys are resolved by reflecting over the `Config` struct's yaml tags, so an unknown key or a value of the wrong type is rejected before the file is touched. The write is atomic and keeps the file's symlink, owner, mode and line endings. --------- Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1140 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./gitea-runner generate-config > config.yaml` to generate a config file.
|
||||
# just run `./gitea-runner config generate > config.yaml` to generate a config file.
|
||||
|
||||
# Logging for the runner process itself (messages printed to stderr).
|
||||
# This does not control how workflow step output is streamed to the Gitea UI;
|
||||
@@ -176,7 +176,7 @@ container:
|
||||
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
|
||||
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
|
||||
network_create_options:
|
||||
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
|
||||
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
|
||||
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
|
||||
530
internal/pkg/config/edit.go
Normal file
530
internal/pkg/config/edit.go
Normal file
@@ -0,0 +1,530 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type fieldKind int
|
||||
|
||||
const (
|
||||
kindScalar fieldKind = iota
|
||||
kindSequence
|
||||
kindSection
|
||||
)
|
||||
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
// GetValue renders a flat list or mapping one entry per line, and anything nested as YAML.
|
||||
func GetValue(file, path string) (string, error) {
|
||||
session, err := loadForEdit(file, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
node, err := lookupNode(session.root, session.segments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return renderNode(node)
|
||||
}
|
||||
|
||||
func SetValue(file, path string, values ...string) error {
|
||||
session, err := loadForEdit(file, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var replacement *yaml.Node
|
||||
switch session.field.kind {
|
||||
case kindSequence:
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("%q needs at least one value", path)
|
||||
}
|
||||
items, err := session.scalars(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replacement = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items}
|
||||
case kindScalar:
|
||||
if len(values) != 1 {
|
||||
return fmt.Errorf("%q takes exactly one value", path)
|
||||
}
|
||||
items, err := session.scalars(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replacement = items[0]
|
||||
default:
|
||||
return fmt.Errorf("%q is a section, set one of its keys instead", path)
|
||||
}
|
||||
|
||||
node, err := ensureNode(session.root, session.segments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replaceNode(node, replacement)
|
||||
|
||||
return session.write()
|
||||
}
|
||||
|
||||
func AddValue(file, path string, values ...string) error {
|
||||
session, err := loadSequenceEdit(file, path, values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := session.scalars(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node, err := ensureNode(session.root, session.segments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if node.Kind != yaml.SequenceNode {
|
||||
replaceNode(node, &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"})
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if indexOfValue(node, item.Value) >= 0 {
|
||||
return fmt.Errorf("%s already contains %q", path, item.Value)
|
||||
}
|
||||
node.Content = append(node.Content, item)
|
||||
}
|
||||
|
||||
return session.write()
|
||||
}
|
||||
|
||||
func RemoveValue(file, path string, values ...string) error {
|
||||
session, err := loadSequenceEdit(file, path, values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := session.scalars(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node, err := lookupNode(session.root, session.segments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if node.Kind != yaml.SequenceNode {
|
||||
return fmt.Errorf("%s is not a list in %q", path, file)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
index := indexOfValue(node, item.Value)
|
||||
if index < 0 {
|
||||
return fmt.Errorf("%s does not contain %q", path, item.Value)
|
||||
}
|
||||
node.Content = slices.Delete(node.Content, index, index+1)
|
||||
}
|
||||
|
||||
return session.write()
|
||||
}
|
||||
|
||||
func indexOfValue(seq *yaml.Node, value string) int {
|
||||
for i, item := range seq.Content {
|
||||
if item.Kind == yaml.ScalarNode && item.Value == value {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// replaceNode assigns field by field, as *node = *with would drop the comments attached to node.
|
||||
func replaceNode(node, with *yaml.Node) {
|
||||
node.Kind, node.Tag, node.Style, node.Value, node.Content = with.Kind, with.Tag, with.Style, with.Value, with.Content
|
||||
}
|
||||
|
||||
type editSession struct {
|
||||
file string
|
||||
path string
|
||||
original []byte
|
||||
root *yaml.Node
|
||||
field *fieldInfo
|
||||
segments []string
|
||||
}
|
||||
|
||||
// loadForEdit validates the path and parses the file, so every caller fails before anything is written.
|
||||
func loadForEdit(file, path string) (*editSession, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("no config key given")
|
||||
}
|
||||
segments := strings.Split(path, ".")
|
||||
field, err := resolvePath(segments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("config file %q does not exist, create one with `config generate`", file)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
||||
}
|
||||
if root.Kind == 0 || len(root.Content) == 0 {
|
||||
root = yaml.Node{
|
||||
Kind: yaml.DocumentNode,
|
||||
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
|
||||
}
|
||||
}
|
||||
if root.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("config file %q is not a YAML mapping", file)
|
||||
}
|
||||
|
||||
return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments}, nil
|
||||
}
|
||||
|
||||
func loadSequenceEdit(file, path string, values []string) (*editSession, error) {
|
||||
session, err := loadForEdit(file, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.field.kind != kindSequence {
|
||||
return nil, fmt.Errorf("%q is not a list, use `config set` instead", path)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%q needs at least one value", path)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *editSession) scalars(values []string) ([]*yaml.Node, error) {
|
||||
nodes := make([]*yaml.Node, 0, len(values))
|
||||
for _, value := range values {
|
||||
node, err := scalarNode(s.field.typ, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", s.path, err)
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func lookupNode(root *yaml.Node, segments []string) (*yaml.Node, error) {
|
||||
node := root.Content[0]
|
||||
for i, segment := range segments {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("%q is not set", strings.Join(segments[:i], "."))
|
||||
}
|
||||
value := mappingValue(node, segment)
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("%q is not set", strings.Join(segments[:i+1], "."))
|
||||
}
|
||||
node = value
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func ensureNode(root *yaml.Node, segments []string) (*yaml.Node, error) {
|
||||
node := root.Content[0]
|
||||
for i, segment := range segments {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
if node.Kind == yaml.ScalarNode && node.Tag == "!!null" {
|
||||
node.Kind, node.Tag, node.Style, node.Value = yaml.MappingNode, "!!map", 0, ""
|
||||
} else {
|
||||
return nil, fmt.Errorf("%q is not a section", strings.Join(segments[:i], "."))
|
||||
}
|
||||
}
|
||||
value := mappingValue(node, segment)
|
||||
if value == nil {
|
||||
value = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"}
|
||||
node.Content = append(node.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: segment},
|
||||
value)
|
||||
}
|
||||
node = value
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if mapping.Content[i].Value == key {
|
||||
return mapping.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderNode(node *yaml.Node) (string, error) {
|
||||
if !allScalars(node.Content) {
|
||||
encoded, err := encodeYAML(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(encoded), "\n"), nil
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.SequenceNode:
|
||||
lines := make([]string, 0, len(node.Content))
|
||||
for _, item := range node.Content {
|
||||
lines = append(lines, item.Value)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
case yaml.MappingNode:
|
||||
lines := make([]string, 0, len(node.Content)/2)
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
lines = append(lines, node.Content[i].Value+"="+node.Content[i+1].Value)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
default:
|
||||
return node.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func allScalars(nodes []*yaml.Node) bool {
|
||||
for _, node := range nodes {
|
||||
if node.Kind != yaml.ScalarNode {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func encodeYAML(node *yaml.Node) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// restoreBlankLines re-inserts the blank lines between top-level sections that the encoder drops.
|
||||
func restoreBlankLines(original, generated []byte) []byte {
|
||||
spaced := map[string]bool{}
|
||||
blank := false
|
||||
for line := range strings.Lines(string(original)) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case strings.TrimSpace(line) == "":
|
||||
blank = true
|
||||
case strings.HasPrefix(line, "#"): // the block belongs to the key below it
|
||||
default:
|
||||
if key, ok := topLevelKey(line); ok && blank {
|
||||
spaced[key] = true
|
||||
}
|
||||
blank = false
|
||||
}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for line := range strings.Lines(string(generated)) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if key, ok := topLevelKey(line); ok && spaced[key] {
|
||||
start := len(out)
|
||||
for start > 0 && strings.HasPrefix(out[start-1], "#") {
|
||||
start--
|
||||
}
|
||||
if start > 0 && strings.TrimSpace(out[start-1]) != "" {
|
||||
out = slices.Insert(out, start, "")
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if bytes.HasSuffix(generated, []byte("\n")) {
|
||||
out = append(out, "")
|
||||
}
|
||||
|
||||
return []byte(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
func topLevelKey(line string) (string, bool) {
|
||||
if line == "" || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-' {
|
||||
return "", false
|
||||
}
|
||||
key, _, ok := strings.Cut(line, ":")
|
||||
return key, ok
|
||||
}
|
||||
|
||||
func (s *editSession) write() error {
|
||||
generated, err := encodeYAML(s.root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A file the runner already refused to load stays the user's to fix, only a regression is rejected.
|
||||
if err := yaml.Unmarshal(generated, &Config{}); err != nil && yaml.Unmarshal(s.original, &Config{}) == nil {
|
||||
return fmt.Errorf("the edit would produce a config the runner cannot load: %w", err)
|
||||
}
|
||||
|
||||
content := restoreBlankLines(s.original, generated)
|
||||
if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF
|
||||
content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n"))
|
||||
}
|
||||
|
||||
file := s.file
|
||||
if resolved, err := filepath.EvalSymlinks(file); err == nil {
|
||||
file = resolved // keeps a config linked in from elsewhere intact
|
||||
}
|
||||
|
||||
var info os.FileInfo
|
||||
mode := os.FileMode(0o600)
|
||||
if stat, err := os.Stat(file); err == nil {
|
||||
info, mode = stat, stat.Mode().Perm()
|
||||
}
|
||||
|
||||
temp, err := os.CreateTemp(filepath.Dir(file), filepath.Base(file)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(temp.Name())
|
||||
|
||||
if _, err := temp.Write(content); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil { // before the chmod, as a chown can clear mode bits
|
||||
if err := preserveOwner(temp.Name(), info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(temp.Name(), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(temp.Name(), file)
|
||||
}
|
||||
|
||||
type fieldInfo struct {
|
||||
kind fieldKind
|
||||
typ reflect.Type // the element type for a sequence
|
||||
}
|
||||
|
||||
// resolvePath walks the Config struct through the yaml tags of a dotted path.
|
||||
func resolvePath(segments []string) (*fieldInfo, error) {
|
||||
typ := reflect.TypeFor[Config]()
|
||||
|
||||
for i, segment := range segments {
|
||||
switch typ.Kind() {
|
||||
case reflect.Struct:
|
||||
field, ok := fieldByYAMLName(typ, segment)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown config key %q, valid keys here: %s",
|
||||
strings.Join(segments[:i+1], "."), strings.Join(yamlNames(typ), ", "))
|
||||
}
|
||||
typ = field.Type
|
||||
case reflect.Map:
|
||||
// The segment names a user-defined entry, so the walk ends here.
|
||||
if i != len(segments)-1 {
|
||||
return nil, fmt.Errorf("%q has no sub-keys", strings.Join(segments[:i+1], "."))
|
||||
}
|
||||
return &fieldInfo{kind: kindScalar, typ: typ.Elem()}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%q is a value, not a section", strings.Join(segments[:i], "."))
|
||||
}
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Slice:
|
||||
return &fieldInfo{kind: kindSequence, typ: typ.Elem()}, nil
|
||||
case reflect.Map, reflect.Struct:
|
||||
return &fieldInfo{kind: kindSection}, nil
|
||||
default:
|
||||
return &fieldInfo{kind: kindScalar, typ: typ}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fieldByYAMLName(typ reflect.Type, name string) (reflect.StructField, bool) {
|
||||
for field := range typ.Fields() {
|
||||
if yamlName(field) == name {
|
||||
return field, true
|
||||
}
|
||||
}
|
||||
return reflect.StructField{}, false
|
||||
}
|
||||
|
||||
func yamlNames(typ reflect.Type) []string {
|
||||
names := make([]string, 0, typ.NumField())
|
||||
for field := range typ.Fields() {
|
||||
if name := yamlName(field); name != "-" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func yamlName(field reflect.StructField) string {
|
||||
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
||||
if name == "" {
|
||||
return strings.ToLower(field.Name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// scalarNode types the value, so a bad one is reported instead of landing in the file as a string.
|
||||
func scalarNode(typ reflect.Type, value string) (*yaml.Node, error) {
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
|
||||
if typ == durationType {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a duration such as 30s, 5m or 3h", value)
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: duration.String()}, nil
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a boolean", value)
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(parsed)}, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
parsed, err := strconv.ParseInt(value, 10, typ.Bits())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid %s", value, typ.Kind())
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.FormatInt(parsed, 10)}, nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
parsed, err := strconv.ParseUint(value, 10, typ.Bits())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid %s", value, typ.Kind())
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.FormatUint(parsed, 10)}, nil
|
||||
case reflect.String:
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported config value type %s", typ)
|
||||
}
|
||||
}
|
||||
13
internal/pkg/config/edit_other.go
Normal file
13
internal/pkg/config/edit_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows || plan9
|
||||
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
// preserveOwner is a no-op where a new file inherits its ownership from the directory.
|
||||
func preserveOwner(_ string, _ os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
267
internal/pkg/config/edit_test.go
Normal file
267
internal/pkg/config/edit_test.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const editFixture = `# A leading comment.
|
||||
log:
|
||||
# The logging level.
|
||||
level: info
|
||||
|
||||
runner:
|
||||
capacity: 1
|
||||
envs:
|
||||
EXISTING: value
|
||||
timeout: 3h
|
||||
labels:
|
||||
- ubuntu-latest:docker://node:20
|
||||
- self-hosted
|
||||
`
|
||||
|
||||
func writeEditFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(editFixture), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestEditValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(file string) error
|
||||
assert func(t *testing.T, cfg *Config, content string)
|
||||
}{
|
||||
{
|
||||
name: "set scalar",
|
||||
edit: func(file string) error { return SetValue(file, "runner.capacity", "4") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, 4, cfg.Runner.Capacity)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set duration",
|
||||
edit: func(file string) error { return SetValue(file, "runner.timeout", "90m") },
|
||||
assert: func(t *testing.T, cfg *Config, content string) {
|
||||
assert.Equal(t, 90*time.Minute, cfg.Runner.Timeout)
|
||||
assert.Contains(t, content, "timeout: 1h30m0s")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set a pointer field in a missing section",
|
||||
edit: func(file string) error {
|
||||
return SetValue(file, "container.network_create_options.enable_ipv4", "false")
|
||||
},
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
require.NotNil(t, cfg.Container.NetworkCreateOptions.EnableIPv4)
|
||||
assert.False(t, *cfg.Container.NetworkCreateOptions.EnableIPv4)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set map entry",
|
||||
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "yes") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set replaces a list",
|
||||
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, []string{"one", "two"}, cfg.Runner.Labels)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add appends to a list",
|
||||
edit: func(file string) error { return AddValue(file, "runner.labels", "ubuntu:docker://node:22") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, []string{"ubuntu-latest:docker://node:20", "self-hosted", "ubuntu:docker://node:22"}, cfg.Runner.Labels)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove drops a list entry",
|
||||
edit: func(file string) error { return RemoveValue(file, "runner.labels", "self-hosted") },
|
||||
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||
assert.Equal(t, []string{"ubuntu-latest:docker://node:20"}, cfg.Runner.Labels)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := writeEditFixture(t)
|
||||
require.NoError(t, tt.edit(file))
|
||||
|
||||
raw, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
content := string(raw)
|
||||
cfg, err := LoadDefault(file)
|
||||
require.NoError(t, err)
|
||||
|
||||
tt.assert(t, cfg, content)
|
||||
|
||||
assert.Contains(t, content, "# A leading comment.")
|
||||
assert.Contains(t, content, " # The logging level.")
|
||||
assert.Contains(t, content, "\n\nrunner:")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditValuesRejectsBadInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(file string) error
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unknown key",
|
||||
edit: func(file string) error { return SetValue(file, "runner.labl", "x") },
|
||||
wantErr: `unknown config key "runner.labl"`,
|
||||
},
|
||||
{
|
||||
name: "value is not a number",
|
||||
edit: func(file string) error { return SetValue(file, "runner.capacity", "many") },
|
||||
wantErr: `"many" is not a valid int`,
|
||||
},
|
||||
{
|
||||
name: "value is not a duration",
|
||||
edit: func(file string) error { return SetValue(file, "runner.timeout", "soon") },
|
||||
wantErr: `"soon" is not a duration`,
|
||||
},
|
||||
{
|
||||
name: "value is not a boolean",
|
||||
edit: func(file string) error { return SetValue(file, "runner.insecure", "maybe") },
|
||||
wantErr: `"maybe" is not a boolean`,
|
||||
},
|
||||
{
|
||||
name: "set needs a single value",
|
||||
edit: func(file string) error { return SetValue(file, "runner.capacity", "1", "2") },
|
||||
wantErr: "takes exactly one value",
|
||||
},
|
||||
{
|
||||
name: "set on a section",
|
||||
edit: func(file string) error { return SetValue(file, "runner", "x") },
|
||||
wantErr: "is a section",
|
||||
},
|
||||
{
|
||||
name: "add on a scalar",
|
||||
edit: func(file string) error { return AddValue(file, "runner.capacity", "4") },
|
||||
wantErr: "is not a list",
|
||||
},
|
||||
{
|
||||
name: "add a duplicate",
|
||||
edit: func(file string) error { return AddValue(file, "runner.labels", "self-hosted") },
|
||||
wantErr: `already contains "self-hosted"`,
|
||||
},
|
||||
{
|
||||
name: "remove a missing entry",
|
||||
edit: func(file string) error { return RemoveValue(file, "runner.labels", "absent") },
|
||||
wantErr: `does not contain "absent"`,
|
||||
},
|
||||
{
|
||||
name: "sub-key of a free-form map entry",
|
||||
edit: func(file string) error { return SetValue(file, "runner.envs.A.B", "x") },
|
||||
wantErr: "has no sub-keys",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := writeEditFixture(t)
|
||||
err := tt.edit(file)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
|
||||
content, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, editFixture, string(content), "a rejected edit must leave the file untouched")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValue(t *testing.T) {
|
||||
file := writeEditFixture(t)
|
||||
|
||||
value, err := GetValue(file, "runner.capacity")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1", value)
|
||||
|
||||
value, err = GetValue(file, "runner.labels")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ubuntu-latest:docker://node:20\nself-hosted", value)
|
||||
|
||||
value, err = GetValue(file, "runner.envs")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "EXISTING=value", value)
|
||||
|
||||
// A section has no single-line rendering.
|
||||
value, err = GetValue(file, "runner")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, value, "labels:\n - ubuntu-latest:docker://node:20")
|
||||
|
||||
_, err = GetValue(file, "metrics.addr")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not set")
|
||||
}
|
||||
|
||||
func TestEditValuesFileHandling(t *testing.T) {
|
||||
t.Run("reports a missing file", func(t *testing.T) {
|
||||
err := SetValue(filepath.Join(t.TempDir(), "absent.yaml"), "runner.capacity", "4")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not exist")
|
||||
})
|
||||
|
||||
t.Run("writes through a symlink", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.yaml")
|
||||
link := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(target, []byte(editFixture), 0o600))
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
require.NoError(t, SetValue(link, "runner.capacity", "4"))
|
||||
|
||||
info, err := os.Lstat(link)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, info.Mode()&os.ModeSymlink, "the symlink must not be replaced by a regular file")
|
||||
|
||||
content, err := os.ReadFile(target)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "capacity: 4")
|
||||
})
|
||||
|
||||
t.Run("keeps CRLF line endings", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(file, []byte(strings.ReplaceAll(editFixture, "\n", "\r\n")), 0o600))
|
||||
|
||||
require.NoError(t, SetValue(file, "runner.capacity", "4"))
|
||||
|
||||
content, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "capacity: 4\r\n")
|
||||
assert.NotContains(t, strings.ReplaceAll(string(content), "\r\n", ""), "\n")
|
||||
})
|
||||
}
|
||||
|
||||
// The example config is the file users edit, so it has to stay written the way
|
||||
// the encoder emits it, down to the single space before a trailing comment.
|
||||
func TestEditValuesKeepsExampleConfigIntact(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(file, Example, 0o600))
|
||||
|
||||
require.NoError(t, AddValue(file, "runner.labels", "ubuntu:docker://node:22"))
|
||||
|
||||
content, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
withoutAdded := strings.Replace(string(content), " - ubuntu:docker://node:22\n", "", 1)
|
||||
assert.Equal(t, string(Example), withoutAdded, "only the appended label may differ")
|
||||
}
|
||||
25
internal/pkg/config/edit_unix.go
Normal file
25
internal/pkg/config/edit_unix.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows && !plan9
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// preserveOwner keeps a config that root edits owned by the service user it was created for.
|
||||
func preserveOwner(file string, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// A caller that may replace the file but not chown it is no worse off than before.
|
||||
if err := os.Chown(file, int(stat.Uid), int(stat.Gid)); err != nil && !errors.Is(err, os.ErrPermission) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user