diff --git a/README.md b/README.md index 62467641..5bb08692 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,11 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree): ```bash -./gitea-runner generate-config > config.yaml +./gitea-runner config generate > config.yaml ``` +> The top-level `generate-config` command still does the same thing, but is deprecated in favour of `config generate`. + Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`): ```bash @@ -143,7 +145,26 @@ Pass it with `-c` / `--config` on any command that loads configuration (`registe ./gitea-runner -c config.yaml cache-server ``` -Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints). +Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `config generate` prints). + +#### Editing a config file + +`config` changes an existing file in place, keeping its comments and key order, which is handy in provisioning scripts: + +```bash +./gitea-runner -c config.yaml config set runner.capacity 4 +./gitea-runner -c config.yaml config set runner.timeout 90m # written as 1h30m0s +./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 +``` + +`-c` is optional for these subcommands: without it they use `config.yaml` (or `config.yml`) from the working directory, falling back to the directory of the `gitea-runner` binary, and print which file they picked to stderr. + +Keys are the dotted YAML path and are validated against the known options, so a typo is rejected instead of being written. `add` and `remove` only work on list options such as `runner.labels` and `container.valid_volumes`, and fail if the value is already present or missing. `set` replaces the whole list when given several values. + +The file is re-encoded on every edit, so indentation is normalised to two spaces and blank lines inside a section are dropped. #### Without a config file diff --git a/examples/systemd/README.md b/examples/systemd/README.md index e7244f6e..cc6217da 100644 --- a/examples/systemd/README.md +++ b/examples/systemd/README.md @@ -16,7 +16,7 @@ the runner as a background service on a systemd host. `.runner` file ends up in the working directory: ```bash - sudo -u gitea-runner gitea-runner generate-config > /etc/gitea-runner/config.yaml + sudo -u gitea-runner gitea-runner config generate > /etc/gitea-runner/config.yaml cd /var/lib/gitea-runner sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml ``` diff --git a/examples/vm/rootless-docker.md b/examples/vm/rootless-docker.md index 7a1fdd0e..4f057cf4 100644 --- a/examples/vm/rootless-docker.md +++ b/examples/vm/rootless-docker.md @@ -52,7 +52,7 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock - Generate a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system. ```bash - gitea-runner generate-config >/home/rootless/gitea-runner/config + gitea-runner config generate >/home/rootless/gitea-runner/config ``` - Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents: diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index 18aaa373..4cb02420 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -5,10 +5,8 @@ package cmd import ( "context" - "fmt" "os" - "gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/ver" "github.com/spf13/cobra" @@ -23,7 +21,7 @@ func Execute(ctx context.Context) { SilenceUsage: true, } configFile := "" - rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path") + rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path. `config` subcommands fall back to config.yaml in the working directory or next to the executable") // ./gitea-runner register var regArgs registerArgs @@ -61,14 +59,12 @@ func Execute(ctx context.Context) { rootCmd.AddCommand(loadBugReportCmd()) // ./gitea-runner config - rootCmd.AddCommand(&cobra.Command{ - Use: "generate-config", - Short: "Generate an example config file", - Args: cobra.MaximumNArgs(0), - Run: func(_ *cobra.Command, _ []string) { - fmt.Printf("%s", config.Example) - }, - }) + rootCmd.AddCommand(loadConfigCmd(&configFile)) + + // ./gitea-runner generate-config + generateConfigCmd := loadGenerateConfigCmd("generate-config") + generateConfigCmd.Deprecated = "use `config generate` instead." + rootCmd.AddCommand(generateConfigCmd) // ./gitea-runner cache-server var cacheArgs cacheServerArgs diff --git a/internal/app/cmd/config.go b/internal/app/cmd/config.go new file mode 100644 index 00000000..65d9f0be --- /dev/null +++ b/internal/app/cmd/config.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "gitea.com/gitea/runner/internal/pkg/config" + + "github.com/spf13/cobra" +) + +func loadConfigCmd(configFile *string) *cobra.Command { + configCmd := &cobra.Command{ + Use: "config", + Short: "Generate, read and edit config files", + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + configCmd.AddCommand(loadGenerateConfigCmd("generate")) + + configCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Print the value of a config key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + file, err := resolveConfigFile(cmd, configFile) + if err != nil { + return err + } + value, err := config.GetValue(file, args[0]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), value) + return nil + }, + }) + + for _, sub := range []struct { + use string + short string + edit func(file, key string, values ...string) error + }{ + {"set ...", "Set the value of a config key", config.SetValue}, + {"add ...", "Append values to a list config key", config.AddValue}, + {"remove ...", "Remove values from a list config key", config.RemoveValue}, + } { + valueCmd := &cobra.Command{ + Use: sub.use, + Short: sub.short, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + file, err := resolveConfigFile(cmd, configFile) + if err != nil { + return err + } + return sub.edit(file, args[0], args[1:]...) + }, + } + valueCmd.Flags().SetInterspersed(false) // so a value such as `--cpus 2` is not parsed as a flag + configCmd.AddCommand(valueCmd) + } + + return configCmd +} + +func loadGenerateConfigCmd(use string) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: "Generate an example config file", + Args: cobra.MaximumNArgs(0), + Run: func(cmd *cobra.Command, _ []string) { + fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example) + }, + } +} + +var defaultConfigFileNames = []string{"config.yaml", "config.yml"} + +func resolveConfigFile(cmd *cobra.Command, configFile *string) (string, error) { + if *configFile != "" { + return *configFile, nil + } + + var dirs []string + if wd, err := os.Getwd(); err == nil { + dirs = append(dirs, wd) + } + if exe, err := os.Executable(); err == nil { + if dir := filepath.Dir(exe); !slices.Contains(dirs, dir) { + dirs = append(dirs, dir) + } + } + + for _, dir := range dirs { + for _, name := range defaultConfigFileNames { + candidate := filepath.Join(dir, name) + if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() { + fmt.Fprintf(cmd.ErrOrStderr(), "using config file %q\n", candidate) + return candidate, nil + } + } + } + + return "", fmt.Errorf("no %s found in %s, pass one with --config", + strings.Join(defaultConfigFileNames, " or "), strings.Join(dirs, " or ")) +} diff --git a/internal/app/cmd/config_test.go b/internal/app/cmd/config_test.go new file mode 100644 index 00000000..3109ef54 --- /dev/null +++ b/internal/app/cmd/config_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "gitea.com/gitea/runner/internal/pkg/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runConfigCmd(t *testing.T, configFile string, args ...string) (string, string, error) { + t.Helper() + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + cmd := loadConfigCmd(&configFile) + cmd.SetOut(out) + cmd.SetErr(errOut) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestConfigCmdGeneratePrintsTheExample(t *testing.T) { + out, _, err := runConfigCmd(t, "", "generate") + require.NoError(t, err) + assert.Equal(t, string(config.Example), out) +} + +// The subcommands only wire arguments through, so one pass over all of them is enough. +func TestConfigCmdEditsTheFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(file, []byte("runner:\n labels:\n - self-hosted\n"), 0o600)) + + _, _, err := runConfigCmd(t, file, "set", "container.options", "--cpus 2") + require.NoError(t, err) + _, _, err = runConfigCmd(t, file, "add", "runner.labels", "ubuntu:docker://node:22") + require.NoError(t, err) + _, _, err = runConfigCmd(t, file, "remove", "runner.labels", "self-hosted") + require.NoError(t, err) + + out, _, err := runConfigCmd(t, file, "get", "runner.labels") + require.NoError(t, err) + assert.Equal(t, "ubuntu:docker://node:22\n", out) + + out, _, err = runConfigCmd(t, file, "get", "container.options") + require.NoError(t, err) + assert.Equal(t, "--cpus 2\n", out) +} + +func TestConfigCmdResolvesTheConfigFile(t *testing.T) { + t.Run("falls back to the working directory", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("runner:\n capacity: 2\n"), 0o600)) + t.Chdir(dir) + + out, errOut, err := runConfigCmd(t, "", "get", "runner.capacity") + require.NoError(t, err) + assert.Equal(t, "2\n", out) + assert.Contains(t, errOut, "using config file") + }) + + t.Run("reports that none was found", func(t *testing.T) { + t.Chdir(t.TempDir()) + + _, _, err := runConfigCmd(t, "", "set", "runner.capacity", "4") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config") + }) +} diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 433dec29..ed61aace 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -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=, 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 diff --git a/internal/pkg/config/edit.go b/internal/pkg/config/edit.go new file mode 100644 index 00000000..f0a36b9d --- /dev/null +++ b/internal/pkg/config/edit.go @@ -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) + } +} diff --git a/internal/pkg/config/edit_other.go b/internal/pkg/config/edit_other.go new file mode 100644 index 00000000..ef4327ff --- /dev/null +++ b/internal/pkg/config/edit_other.go @@ -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 +} diff --git a/internal/pkg/config/edit_test.go b/internal/pkg/config/edit_test.go new file mode 100644 index 00000000..76adbcd8 --- /dev/null +++ b/internal/pkg/config/edit_test.go @@ -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") +} diff --git a/internal/pkg/config/edit_unix.go b/internal/pkg/config/edit_unix.go new file mode 100644 index 00000000..10d49eb7 --- /dev/null +++ b/internal/pkg/config/edit_unix.go @@ -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 +}