diff --git a/cmd/idea.go b/cmd/idea.go
index 33efc97..c62113d 100644
--- a/cmd/idea.go
+++ b/cmd/idea.go
@@ -1,11 +1,14 @@
package cmd
import (
+ "fmt"
"github.com/Graylog2/graylog-project-cli/config"
"github.com/Graylog2/graylog-project-cli/idea"
"github.com/Graylog2/graylog-project-cli/manifest"
p "github.com/Graylog2/graylog-project-cli/project"
+ "github.com/Graylog2/graylog-project-cli/utils"
"github.com/spf13/cobra"
+ "github.com/spf13/viper"
)
var ideaCmd = &cobra.Command{
@@ -17,8 +20,9 @@ Commands to help working with the IntelliJ IDE.
}
var ideaSetupCmd = &cobra.Command{
- Use: "setup",
- Short: "Setup IntelliJ IDEA",
+ Deprecated: `use the "run-config create" command instead`,
+ Use: "setup",
+ Short: "Setup IntelliJ IDEA",
Long: `
This command will do the following:
@@ -30,8 +34,47 @@ This command will do the following:
Run: ideaSetupCommand,
}
+var ideaRunConfigsCmd = &cobra.Command{
+ Use: "run-configs",
+ Aliases: []string{"rc"},
+ Short: "Manage IntelliJ IDEA run configurations",
+}
+
+var ideaRunConfigsCreateCmd = &cobra.Command{
+ Use: "create",
+ Aliases: []string{"c"},
+ Short: "Create IntelliJ IDEA run configurations",
+ Long: `This command adds default IntelliJ run configurations for Graylog Server,
+Data Node, and the web development server.
+
+The run configurations are created in the $PWD/.run/ directory.
+
+Examples:
+ # Create default run configurations
+ graylog-project idea run-configs create
+
+ # Create default run configurations and .env files (requires installation of EnvFile plugin in IntelliJ)
+ graylog-project idea run-configs create -E
+
+ # Create run configurations for two Server and three Data Node instances
+ graylog-project idea run-configs create --instances server=2,data-node=3
+`,
+ RunE: ideaRunConfigCreateCommand,
+}
+
func init() {
+ ideaRunConfigsCreateCmd.Flags().BoolP("force", "f", false, "Overwrite existing run configurations")
+ ideaRunConfigsCreateCmd.Flags().BoolP("env-file", "E", false, "Use .env files (requires the IntelliJ EnvFile plugin)")
+ ideaRunConfigsCreateCmd.Flags().StringToIntP("instances", "i", idea.DefaultInstanceCounts, "Number of instances - example: server=1,data-node=3")
+ ideaRunConfigsCreateCmd.Flags().String("root-password", idea.DefaultRootPassword, "The root user password")
+ ideaRunConfigsCmd.AddCommand(ideaRunConfigsCreateCmd)
+
+ if err := viper.BindPFlags(ideaRunConfigsCreateCmd.Flags()); err != nil {
+ panic(err)
+ }
+
ideaCmd.AddCommand(ideaSetupCmd)
+ ideaCmd.AddCommand(ideaRunConfigsCmd)
RootCmd.AddCommand(ideaCmd)
}
@@ -41,3 +84,19 @@ func ideaSetupCommand(cmd *cobra.Command, args []string) {
idea.Setup(project)
}
+
+func ideaRunConfigCreateCommand(cmd *cobra.Command, args []string) error {
+ var cfg idea.RunConfig
+ if err := viper.Unmarshal(&cfg); err != nil {
+ return fmt.Errorf("couldn't parse configuration: %w", err)
+ }
+
+ wd, err := utils.GetCwdE()
+ if err != nil {
+ return err
+ }
+
+ cfg.Workdir = wd
+
+ return idea.CreateRunConfigurations(cfg)
+}
diff --git a/go.mod b/go.mod
index 3770d09..8e717ed 100644
--- a/go.mod
+++ b/go.mod
@@ -8,9 +8,10 @@ require (
github.com/fatih/color v1.18.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/go-github/v66 v66.0.0
+ github.com/google/renameio/v2 v2.0.0
github.com/hashicorp/go-version v1.7.0
github.com/imdario/mergo v0.3.16
- github.com/k0kubun/pp/v3 v3.3.0
+ github.com/k0kubun/pp/v3 v3.4.1
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.20
github.com/pelletier/go-toml/v2 v2.2.3
@@ -20,10 +21,12 @@ require (
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
+ github.com/subosito/gotenv v1.6.0
github.com/yuin/goldmark v1.7.8
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
golang.org/x/oauth2 v0.24.0
golang.org/x/text v0.20.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -45,12 +48,10 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
// Workaround for https://github.com/golang/go/issues/30831
diff --git a/go.sum b/go.sum
index be192c8..2e49df5 100644
--- a/go.sum
+++ b/go.sum
@@ -28,6 +28,8 @@ github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
+github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -36,8 +38,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/k0kubun/pp/v3 v3.3.0 h1:/Unrck5tDGUSjsUJsmx9GUL64pNKOY5UEdoP1F7FBq8=
-github.com/k0kubun/pp/v3 v3.3.0/go.mod h1:wJadGBvcY6JKaiUkB89VzUACKDmTX1r4aQTPERpZc6w=
+github.com/k0kubun/pp/v3 v3.4.1 h1:1WdFZDRRqe8UsR61N/2RoOZ3ziTEqgTPVqKrHeb779Y=
+github.com/k0kubun/pp/v3 v3.4.1/go.mod h1:+SiNiqKnBfw1Nkj82Lh5bIeKQOAkPy6Xw9CAZUZ8npI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
diff --git a/idea/run_configs.go b/idea/run_configs.go
new file mode 100644
index 0000000..71cdb02
--- /dev/null
+++ b/idea/run_configs.go
@@ -0,0 +1,410 @@
+package idea
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/xml"
+ "fmt"
+ "github.com/Graylog2/graylog-project-cli/logger"
+ "github.com/Graylog2/graylog-project-cli/utils"
+ "github.com/fatih/color"
+ "github.com/samber/lo"
+ "github.com/subosito/gotenv"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+ "gopkg.in/yaml.v3"
+ "io/fs"
+ "maps"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "text/template"
+)
+
+var DefaultRootPassword = "admin"
+var DefaultInstanceCounts = map[string]int{
+ "server": 2,
+ "data-node": 2,
+}
+
+var runConfigTemplateDir = filepath.Join(".config", "idea", "templates", "run-configurations")
+var configFile = filepath.Join(".config", "idea", "config.yml")
+
+const runConfigDir = ".run"
+const runConfigSuffix = ".run.xml"
+const runConfigTemplateSuffix = ".run.xml.template"
+const envFileSuffix = ".env.template"
+const generatedFilePrefix = "project-generated-"
+
+// We use a static password secret to ensure that different setups can use the same database.
+const staticPasswordSecret = "hCXFTrzZFF88gnVon2fSV6WmAoQANRUqsYFTRbac8WStamVeJkjTXSykWv6FiXDbTYQQnvdTn59iALnkiT6m93BfhDju9Uqh"
+
+type RunConfig struct {
+ Workdir string `mapstructure:"workdir"`
+ Instances map[string]int `mapstructure:"instances"`
+ Force bool `mapstructure:"force"`
+ EnvFile bool `mapstructure:"env-file"`
+ RootPassword string `mapstructure:"root-password"`
+}
+
+type ConfigData struct {
+ DataDirectories map[string][]string `yaml:"data-directories"`
+ CompoundConfigs map[string]CompoundConfig `yaml:"compound-configs"`
+ HostnameEnv []string `yaml:"hostname-env"`
+}
+
+type CompoundConfig struct {
+ Name string `yaml:"name"`
+ InstanceTypes []string `yaml:"instance-types"`
+}
+
+type templateData struct {
+ ConfigName string
+ InstanceType string
+ InstanceNumber int
+ UseEnvFile bool
+ Env map[string]string
+ PortOffset int
+ PasswordSecret string
+ RootPasswordHash string
+ DataDir string
+ IsLeaderNode bool
+ IsLinux bool
+ IsDarwin bool
+ IsWindows bool
+}
+
+var mathTemplateFuncs = map[string]any{
+ "add": func(a, b int) int {
+ return a + b
+ },
+ "sub": func(a, b int) int {
+ return a - b
+ },
+}
+
+// Template string for an IntelliJ compound run configuration entry.
+var compoundTemplate = `
+
+{{- range .ToRun }}
+
+{{- end }}
+
+
+
+`
+
+type RunConfigEntry struct {
+ Name string
+ InstanceType string
+ InstanceNumber int
+ PortOffset int
+ Template *template.Template
+ RenderedTemplate bytes.Buffer
+ EnvTemplate *template.Template
+ RenderedEnvTemplate bytes.Buffer
+ DataDirectories []string
+ Filename string
+ EnvFilename string
+ DataDir string
+}
+
+// XMLRunConfig is used to parse the component type out of run configuration files.
+type XMLRunConfig struct {
+ XMLName xml.Name `xml:"component"`
+ Configuration struct {
+ Type string `xml:"type,attr"`
+ } `xml:"configuration"`
+}
+
+type CompoundToRun struct {
+ Name string
+ Type string
+}
+
+func CreateRunConfigurations(config RunConfig) error {
+ rcDir := filepath.Join(config.Workdir, runConfigDir)
+ tmplDir := filepath.Join(config.Workdir, runConfigTemplateDir)
+
+ if _, err := os.Stat(tmplDir); os.IsNotExist(err) {
+ return fmt.Errorf("template directory %q doesn't exist; update your repository", tmplDir)
+ }
+
+ if err := os.MkdirAll(rcDir, 0755); err != nil {
+ return fmt.Errorf("couldn't create run configurations directory: %w", err)
+ }
+
+ templates, err := findRunConfigTemplates(tmplDir)
+ if err != nil {
+ return err
+ }
+
+ invalidParams := make([]string, 0)
+ for name := range config.Instances {
+ if _, found := templates[name]; !found {
+ invalidParams = append(invalidParams, name)
+ }
+ }
+ if len(invalidParams) > 0 {
+ return fmt.Errorf("invalid instance count parameter(s): %s (available: %s)",
+ strings.Join(invalidParams, ", "), strings.Join(slices.Sorted(maps.Keys(templates)), ", "))
+ }
+
+ configData, err := parseConfigFile(filepath.Join(config.Workdir, configFile))
+ if err != nil {
+ return err
+ }
+
+ entries := make([]RunConfigEntry, 0)
+ hostnames := make(map[string]bool)
+
+ // Build all run configuration entries in memory
+ for instanceType, tmpl := range templates {
+ totalCount := getInstanceCount(config, instanceType)
+ for i := range totalCount {
+ num := i + 1
+
+ envTmpl, err := findEnvFileTemplate(filepath.Join(config.Workdir, runConfigTemplateDir), instanceType)
+ if err != nil {
+ return err
+ }
+
+ entry := RunConfigEntry{
+ Name: generateConfigName(instanceType, num, totalCount),
+ InstanceType: instanceType,
+ InstanceNumber: num,
+ PortOffset: i,
+ Template: tmpl,
+ EnvTemplate: envTmpl,
+ DataDirectories: configData.DataDirectories[instanceType],
+ Filename: fmt.Sprintf("%s%s-%d%s", generatedFilePrefix, instanceType, num, runConfigSuffix),
+ EnvFilename: fmt.Sprintf(".env.%s-%d", instanceType, num),
+ DataDir: filepath.Join("data", fmt.Sprintf("%s-%d", instanceType, num)),
+ }
+
+ data := templateData{
+ ConfigName: entry.Name,
+ InstanceType: entry.InstanceType,
+ InstanceNumber: entry.InstanceNumber,
+ PortOffset: entry.PortOffset,
+ UseEnvFile: config.EnvFile,
+ PasswordSecret: staticPasswordSecret,
+ RootPasswordHash: fmt.Sprintf("%x", sha256.Sum256([]byte(config.RootPassword))),
+ DataDir: entry.DataDir,
+ IsLeaderNode: entry.InstanceNumber == 1, // Make the first node the leader node
+ IsLinux: runtime.GOOS == "linux",
+ IsDarwin: runtime.GOOS == "darwin",
+ IsWindows: runtime.GOOS == "windows",
+ }
+
+ if entry.EnvTemplate != nil {
+ if err := entry.EnvTemplate.Execute(&entry.RenderedEnvTemplate, data); err != nil {
+ return fmt.Errorf("couldn't render env-file template: %w", err)
+ }
+ }
+
+ data.Env = gotenv.Parse(bytes.NewReader(entry.RenderedEnvTemplate.Bytes()))
+
+ if err := entry.Template.Execute(&entry.RenderedTemplate, data); err != nil {
+ return fmt.Errorf("couldn't compile template: %w", err)
+ }
+
+ for key, value := range data.Env {
+ if slices.Contains(configData.HostnameEnv, key) {
+ hostnames[value] = true
+ }
+ }
+
+ entries = append(entries, entry)
+ }
+ }
+
+ // Write all run configuration entries to the file system
+ for _, entry := range entries {
+ if err := writeEntryFiles(rcDir, config, entry); err != nil {
+ return err
+ }
+ }
+
+ // Write all compound run configurations
+ for name, cfg := range configData.CompoundConfigs {
+ compoundFilename := fmt.Sprintf("%scompound-%s%s", generatedFilePrefix, name, runConfigSuffix)
+ compoundFilepath := filepath.Join(config.Workdir, runConfigDir, compoundFilename)
+
+ if _, err := os.Stat(compoundFilepath); !os.IsNotExist(err) && !config.Force {
+ logger.Info("Skipping existing compound configuration: %s", filepath.Join(runConfigDir, compoundFilename))
+ continue
+ }
+
+ filteredEntries := lo.Filter(entries, func(entry RunConfigEntry, index int) bool {
+ return slices.Contains(cfg.InstanceTypes, entry.InstanceType)
+ })
+
+ toRun := make([]CompoundToRun, 0)
+
+ for _, entry := range filteredEntries {
+ var xrc XMLRunConfig
+ if err := xml.NewDecoder(bytes.NewReader(entry.RenderedTemplate.Bytes())).Decode(&xrc); err != nil {
+ return fmt.Errorf("couldn't parse rendered run configuration %q: %w", entry.Filename, err)
+ }
+
+ toRun = append(toRun, CompoundToRun{Name: entry.Name, Type: xrc.Configuration.Type})
+ }
+
+ tmpl, err := template.New(cfg.Name).Parse(compoundTemplate)
+ if err != nil {
+ return fmt.Errorf("couldn't parse compound template %q: %w", cfg.Name, err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, map[string]any{"Name": cfg.Name, "ToRun": toRun}); err != nil {
+ return err
+ }
+
+ if err := utils.AtomicallyWriteFile(compoundFilepath, buf.Bytes(), 0600); err != nil {
+ return fmt.Errorf("couldn't write compound file %q: %w", compoundFilename, err)
+ }
+
+ logger.Info("Created compound configuration: %s", filepath.Join(runConfigDir, compoundFilename))
+ }
+
+ if len(hostnames) > 0 {
+ logger.Info("\nThe following template values seem to contain hostnames.")
+ for _, name := range slices.Sorted(maps.Keys(hostnames)) {
+ logger.Info(" - %v", name)
+ }
+ logger.Info("Please ensure all hostnames resolve correctly!")
+ if runtime.GOOS == "linux" {
+ // On Linux with systemd (basically all these days) *.localhost resolves to 127.0.0.1.
+ logger.ColorInfo(color.FgGreen, "You are on Linux, so the hostnames should work automatically.")
+ } else {
+ logger.ColorInfo(color.FgYellow, "You are on %s, please add the hostnames to your /etc/hosts file using 127.0.0.1 as the IP address.", runtime.GOOS)
+ }
+ }
+
+ return nil
+}
+
+func parseConfigFile(path string) (*ConfigData, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't open config %q: %w", path, err)
+ }
+ //goland:noinspection ALL
+ defer f.Close()
+
+ var value ConfigData
+ if err := yaml.NewDecoder(f).Decode(&value); err != nil {
+ return nil, fmt.Errorf("couldn't parse idea config %q: %w", path, err)
+ }
+
+ return &value, nil
+}
+
+func getInstanceCount(config RunConfig, instanceType string) int {
+ count, ok := config.Instances[instanceType]
+ if !ok {
+ defaultCount, defaultOk := DefaultInstanceCounts[instanceType]
+ if defaultOk {
+ return defaultCount
+ } else {
+ return 1
+ }
+ }
+ return count
+}
+
+func writeEntryFiles(configDir string, config RunConfig, entry RunConfigEntry) error {
+ if _, err := os.Stat(filepath.Join(configDir, entry.Filename)); !os.IsNotExist(err) && !config.Force {
+ logger.Info("Skipping existing run configuration: %s", filepath.Join(runConfigDir, entry.Filename))
+ return nil
+ }
+
+ if err := utils.AtomicallyWriteFile(filepath.Join(configDir, entry.Filename), entry.RenderedTemplate.Bytes(), 0600); err != nil {
+ return fmt.Errorf("couldn't write file %q: %w", entry.Filename, err)
+ }
+
+ logger.Info("Created run configuration: %s", filepath.Join(runConfigDir, entry.Filename))
+
+ for _, dir := range entry.DataDirectories {
+ dirToCreate := filepath.Join(entry.DataDir, dir)
+ if _, err := os.Stat(dirToCreate); os.IsNotExist(err) {
+ if err := os.MkdirAll(dirToCreate, 0755); err != nil {
+ return fmt.Errorf("couldn't create data dir %q: %w", dir, err)
+ }
+ logger.Info("Created data directory: %s", dirToCreate)
+ }
+ }
+
+ if config.EnvFile {
+ if err := utils.AtomicallyWriteFile(filepath.Join(config.Workdir, entry.EnvFilename), entry.RenderedEnvTemplate.Bytes(), 0600); err != nil {
+ return fmt.Errorf("couldn't write file %q: %w", entry.EnvFilename, err)
+ }
+
+ logger.Info("Created run env file: %s", entry.EnvFilename)
+ }
+
+ return nil
+}
+
+func generateConfigName(instanceType string, num int, total int) string {
+ // Data-Node -> Data Node
+ configName := strings.ReplaceAll(cases.Title(language.English).String(instanceType), "-", " ")
+
+ if total > 1 {
+ return fmt.Sprintf("%s %d", configName, num)
+ } else {
+ return configName
+ }
+}
+
+func findEnvFileTemplate(templateDir string, name string) (*template.Template, error) {
+ data, err := os.ReadFile(filepath.Join(templateDir, fmt.Sprintf("%s%s", name, envFileSuffix)))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("couldn't read env-file template for %q: %w", name, err)
+ }
+ tmpl, err := template.New(name).Option("missingkey=error").Funcs(mathTemplateFuncs).Parse(string(data))
+ if err != nil {
+ return nil, fmt.Errorf("couldn't parse template %q: %w", name, err)
+ }
+ return tmpl, nil
+}
+
+func findRunConfigTemplates(templateDir string) (map[string]*template.Template, error) {
+ templates := make(map[string]*template.Template)
+
+ err := filepath.WalkDir(templateDir, func(path string, entry fs.DirEntry, err error) error {
+ if entry.IsDir() {
+ return nil
+ }
+ if !strings.HasSuffix(entry.Name(), runConfigTemplateSuffix) {
+ return nil
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("couldn't read template: %w", err)
+ }
+
+ name := strings.TrimSuffix(entry.Name(), runConfigTemplateSuffix)
+
+ tmpl, err := template.New(name).Option("missingkey=error").Funcs(mathTemplateFuncs).Parse(string(data))
+ if err != nil {
+ return fmt.Errorf("couldn't parse template %q: %w", path, err)
+ }
+
+ templates[name] = tmpl
+
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("couldn't walk template dir: %w", err)
+ }
+
+ return templates, nil
+}
diff --git a/idea/run_configs_test.go b/idea/run_configs_test.go
new file mode 100644
index 0000000..b7abec8
--- /dev/null
+++ b/idea/run_configs_test.go
@@ -0,0 +1,275 @@
+package idea
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestCreateRunConfigurations(t *testing.T) {
+ workdir := t.TempDir()
+
+ config := RunConfig{
+ Workdir: workdir,
+ Instances: DefaultInstanceCounts,
+ Force: false,
+ EnvFile: false,
+ RootPassword: "test",
+ }
+
+ // No template files yet, should fail
+ require.ErrorContains(t, CreateRunConfigurations(config), "update your repository")
+
+ // Copy the templates
+ require.NoError(t, copyDir("testdata", workdir))
+
+ // Should not fail now
+ require.NoError(t, CreateRunConfigurations(config))
+
+ assert.DirExists(t, filepath.Join(workdir, ".run"))
+
+ for _, instance := range []string{
+ "compound-all", "compound-data-nodes", "compound-servers",
+ "data-node-1", "data-node-2", "server-1", "server-2", "web-1",
+ } {
+ assert.FileExists(t, filepath.Join(workdir, ".run", "project-generated-"+instance+".run.xml"))
+ }
+
+ for _, file := range []string{"data-node-1", "data-node-2", "server-1", "server-2", "web-1"} {
+ assert.NoFileExists(t, filepath.Join(workdir, ".env."+file))
+ }
+
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-all.run.xml"), "All Nodes", map[string]string{
+ "Server 1": "Application",
+ "Server 2": "Application",
+ "Data Node 1": "Application",
+ "Data Node 2": "Application",
+ "Web": "js.build_tools.npm",
+ })
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-servers.run.xml"), "Servers", map[string]string{
+ "Server 1": "Application",
+ "Server 2": "Application",
+ })
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-data-nodes.run.xml"), "Data Nodes", map[string]string{
+ "Data Node 1": "Application",
+ "Data Node 2": "Application",
+ })
+
+ assertFile(t, assert.Contains, filepath.Join(workdir, ".run", "project-generated-web-1.run.xml"),
+ ``)
+
+ for _, instance := range []string{"server-1", "server-2"} {
+ path := filepath.Join(workdir, ".run", fmt.Sprintf("project-generated-%s.run.xml", instance))
+ num := getInstanceNumber(t, instance)
+ portOffset := num - 1
+
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, num))
+ assertFile(t, assert.NotContains, path, fmt.Sprintf(`PATH="$PROJECT_DIR$/.env.%s`, instance))
+
+ assertRunConfigFileEnv(t, assert.Contains, path, map[string]any{
+ "GRAYLOG_NODE_ID_FILE": filepath.Join("data", instance, "node-id"),
+ "GRAYLOG_DATA_DIR": filepath.Join("data", instance),
+ "GRAYLOG_MESSAGE_JOURNAL_DIR": filepath.Join("data", instance, "journal"),
+ "GRAYLOG_IS_LEADER": num == 1, // First node should be the leader
+ "GRAYLOG_HTTP_BIND_ADDRESS": fmt.Sprintf("127.0.0.1:%d", 9000+portOffset),
+ "GRAYLOG_PASSWORD_SECRET": "hCXFTrzZFF88gnVon2fSV6WmAoQANRUqsYFTRbac8WStamVeJkjTXSykWv6FiXDbTYQQnvdTn59iALnkiT6m93BfhDju9Uqh",
+ "GRAYLOG_ROOT_PASSWORD_SHA2": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+ })
+ }
+ for _, instance := range []string{"data-node-1", "data-node-2"} {
+ path := filepath.Join(workdir, ".run", fmt.Sprintf("project-generated-%s.run.xml", instance))
+ num := getInstanceNumber(t, instance)
+ portOffset := num - 1
+
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, num))
+ assertFile(t, assert.NotContains, path, fmt.Sprintf(`PATH="$PROJECT_DIR$/.env.%s`, instance))
+
+ assertRunConfigFileEnv(t, assert.Contains, path, map[string]any{
+ "GRAYLOG_DATANODE_PASSWORD_SECRET": "hCXFTrzZFF88gnVon2fSV6WmAoQANRUqsYFTRbac8WStamVeJkjTXSykWv6FiXDbTYQQnvdTn59iALnkiT6m93BfhDju9Uqh",
+ "GRAYLOG_DATANODE_NODE_ID_FILE": filepath.Join("data", instance, "node-id"),
+ "GRAYLOG_DATANODE_CONFIG_LOCATION": filepath.Join("data", instance, "config"),
+ "GRAYLOG_DATANODE_NATIVE_LIB_DIR": filepath.Join("data", instance, "native_libs"),
+ "GRAYLOG_DATANODE_DATANODE_HTTP_PORT": 8999 - portOffset,
+ "GRAYLOG_DATANODE_OPENSEARCH_HTTP_PORT": 9200 + portOffset,
+ "GRAYLOG_DATANODE_OPENSEARCH_TRANSPORT_PORT": 9300 + portOffset,
+ })
+ }
+}
+
+func TestCreateRunConfigurationsWithEnv(t *testing.T) {
+ workdir := t.TempDir()
+
+ config := RunConfig{
+ Workdir: workdir,
+ Instances: DefaultInstanceCounts,
+ Force: false,
+ EnvFile: true,
+ RootPassword: "test",
+ }
+
+ // No template files yet, should fail
+ require.ErrorContains(t, CreateRunConfigurations(config), "update your repository")
+
+ // Copy the templates
+ require.NoError(t, copyDir("testdata", workdir))
+
+ // Should not fail now
+ require.NoError(t, CreateRunConfigurations(config))
+
+ assert.DirExists(t, filepath.Join(workdir, ".run"))
+
+ for _, file := range []string{
+ "compound-all", "compound-data-nodes", "compound-servers",
+ "data-node-1", "data-node-2", "server-1", "server-2", "web-1",
+ } {
+ assert.FileExists(t, filepath.Join(workdir, ".run", "project-generated-"+file+".run.xml"))
+ }
+
+ for _, file := range []string{"data-node-1", "data-node-2", "server-1", "server-2", "web-1"} {
+ assert.FileExists(t, filepath.Join(workdir, ".env."+file))
+ }
+
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-all.run.xml"), "All Nodes", map[string]string{
+ "Server 1": "Application",
+ "Server 2": "Application",
+ "Data Node 1": "Application",
+ "Data Node 2": "Application",
+ "Web": "js.build_tools.npm",
+ })
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-servers.run.xml"), "Servers", map[string]string{
+ "Server 1": "Application",
+ "Server 2": "Application",
+ })
+ assertCompoundRunConfigFile(t, filepath.Join(workdir, ".run", "project-generated-compound-data-nodes.run.xml"), "Data Nodes", map[string]string{
+ "Data Node 1": "Application",
+ "Data Node 2": "Application",
+ })
+
+ assertFile(t, assert.Contains, filepath.Join(workdir, ".run", "project-generated-web-1.run.xml"),
+ ``)
+
+ for _, instance := range []string{"server-1", "server-2"} {
+ path := filepath.Join(workdir, ".run", fmt.Sprintf("project-generated-%s.run.xml", instance))
+ envPath := filepath.Join(workdir, fmt.Sprintf(".env.%s", instance))
+ num := getInstanceNumber(t, instance)
+ portOffset := num - 1
+
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, num))
+ assertFile(t, assert.Contains, path, fmt.Sprintf(`PATH="$PROJECT_DIR$/.env.%s`, instance))
+
+ expectedData := map[string]any{
+ "GRAYLOG_NODE_ID_FILE": filepath.Join("data", instance, "node-id"),
+ "GRAYLOG_DATA_DIR": filepath.Join("data", instance),
+ "GRAYLOG_MESSAGE_JOURNAL_DIR": filepath.Join("data", instance, "journal"),
+ "GRAYLOG_IS_LEADER": num == 1, // First node should be the leader
+ "GRAYLOG_HTTP_BIND_ADDRESS": fmt.Sprintf("127.0.0.1:%d", 9000+portOffset),
+ "GRAYLOG_PASSWORD_SECRET": "hCXFTrzZFF88gnVon2fSV6WmAoQANRUqsYFTRbac8WStamVeJkjTXSykWv6FiXDbTYQQnvdTn59iALnkiT6m93BfhDju9Uqh",
+ "GRAYLOG_ROOT_PASSWORD_SHA2": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+ }
+ assertRunConfigFileEnv(t, assert.NotContains, path, expectedData)
+ assertEnvFile(t, assert.Contains, envPath, expectedData)
+ }
+ for _, instance := range []string{"data-node-1", "data-node-2"} {
+ path := filepath.Join(workdir, ".run", fmt.Sprintf("project-generated-%s.run.xml", instance))
+ envPath := filepath.Join(workdir, fmt.Sprintf(".env.%s", instance))
+ num := getInstanceNumber(t, instance)
+ portOffset := num - 1
+
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, num))
+ assertFile(t, assert.Contains, path, fmt.Sprintf(`PATH="$PROJECT_DIR$/.env.%s`, instance))
+
+ expectedData := map[string]any{
+ "GRAYLOG_DATANODE_NODE_ID_FILE": filepath.Join("data", instance, "node-id"),
+ "GRAYLOG_DATANODE_CONFIG_LOCATION": filepath.Join("data", instance, "config"),
+ "GRAYLOG_DATANODE_NATIVE_LIB_DIR": filepath.Join("data", instance, "native_libs"),
+ "GRAYLOG_DATANODE_DATANODE_HTTP_PORT": 8999 - portOffset,
+ "GRAYLOG_DATANODE_OPENSEARCH_HTTP_PORT": 9200 + portOffset,
+ "GRAYLOG_DATANODE_OPENSEARCH_TRANSPORT_PORT": 9300 + portOffset,
+ }
+ assertRunConfigFileEnv(t, assert.NotContains, path, expectedData)
+ assertEnvFile(t, assert.Contains, envPath, expectedData)
+ }
+}
+
+func getInstanceNumber(t *testing.T, name string) int {
+ parts := strings.SplitAfter(name, "-")
+ num, err := strconv.Atoi(parts[len(parts)-1])
+ require.NoError(t, err)
+ return num
+}
+
+func assertCompoundRunConfigFile(t *testing.T, path string, title string, values map[string]string) {
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, title), "wrong title")
+
+ for key, value := range values {
+ assertFile(t, assert.Contains, path, fmt.Sprintf(``, key, value), fmt.Sprintf("compound file %q - %s=%s", path, key, value))
+ }
+}
+
+func assertRunConfigFileEnv(t *testing.T, check func(assert.TestingT, interface{}, interface{}, ...interface{}) bool, path string, values map[string]any) {
+ for key, value := range values {
+ assertFile(t, check, path, fmt.Sprintf(``, key, value), fmt.Sprintf("run config file %q - %s=%s", path, key, value))
+ }
+}
+
+func assertEnvFile(t *testing.T, check func(assert.TestingT, interface{}, interface{}, ...interface{}) bool, path string, values map[string]any) {
+ for key, value := range values {
+ assertFile(t, check, path, fmt.Sprintf("%s=%v", key, value), fmt.Sprintf("env file %q - %s=%s", path, key, value))
+ }
+}
+
+func assertFile(t *testing.T, check func(assert.TestingT, interface{}, interface{}, ...interface{}) bool, path string, needle string, message ...string) {
+ require.FileExists(t, path)
+ buf, err := os.ReadFile(path)
+ require.NoError(t, err)
+ check(t, string(buf), needle, message)
+}
+
+func copyDir(srcPath, dstPath string) error {
+ walkFn := func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if path == srcPath {
+ return nil
+ }
+
+ dstFilePath := filepath.Join(dstPath, path[len(srcPath):])
+
+ if info.IsDir() {
+ if err := os.MkdirAll(dstFilePath, info.Mode()); err != nil {
+ return err
+ }
+ return nil
+ }
+
+ srcFile, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ //goland:noinspection ALL
+ defer srcFile.Close()
+
+ dstFile, err := os.Create(dstFilePath)
+ if err != nil {
+ return err
+ }
+ //goland:noinspection ALL
+ defer dstFile.Close()
+
+ if _, err := io.Copy(dstFile, srcFile); err != nil {
+ return err
+ }
+
+ return os.Chmod(dstFilePath, info.Mode())
+ }
+
+ return filepath.Walk(srcPath, walkFn)
+}
diff --git a/idea/testdata/.config/idea/config.yml b/idea/testdata/.config/idea/config.yml
new file mode 100644
index 0000000..c1467c4
--- /dev/null
+++ b/idea/testdata/.config/idea/config.yml
@@ -0,0 +1,41 @@
+---
+# Defines the list of data directories for each node type that should be
+# created by the "graylog-project idea run-config create" command.
+data-directories:
+ server:
+ - "journal"
+
+ data-node:
+ - "config"
+ - "logs"
+ - "opensearch-config"
+ - "opensearch-data"
+
+# Environment variables that might contain hostnames which must be configured correctly.
+# This is used by the CLI to notify users about setting up the hostnames. (e.g., adding them to /etc/hosts)
+hostname-env:
+ - "GRAYLOG_HTTP_PUBLISH_URI"
+ - "GRAYLOG_HTTP_EXTERNAL_URI"
+ - "GRAYLOG_HTTP_BIND_ADDRESS"
+ - "GRAYLOG_DATANODE_NODE_NAME"
+ - "GRAYLOG_DATANODE_HOSTNAME"
+ - "GRAYLOG_DATANODE_BIND_ADDRESS"
+
+# Describes all comound configs that should be created.
+compound-configs:
+ servers:
+ name: "Servers"
+ instance-types:
+ - "server"
+
+ data-nodes:
+ name: "Data Nodes"
+ instance-types:
+ - "data-node"
+
+ all:
+ name: "All Nodes"
+ instance-types:
+ - "server"
+ - "data-node"
+ - "web"
diff --git a/idea/testdata/.config/idea/templates/run-configurations/data-node.env.template b/idea/testdata/.config/idea/templates/run-configurations/data-node.env.template
new file mode 100644
index 0000000..fbbf894
--- /dev/null
+++ b/idea/testdata/.config/idea/templates/run-configurations/data-node.env.template
@@ -0,0 +1,20 @@
+# vim: ft=sh
+GRAYLOG_DATANODE_PASSWORD_SECRET={{ .PasswordSecret }}
+GRAYLOG_DATANODE_NODE_ID_FILE={{ .DataDir }}/node-id
+GRAYLOG_DATANODE_CONFIG_LOCATION={{ .DataDir }}/config
+GRAYLOG_DATANODE_NATIVE_LIB_DIR={{ .DataDir }}/native_libs
+
+GRAYLOG_DATANODE_NODE_NAME={{ .InstanceType }}-{{ .InstanceNumber }}.graylog.localhost
+GRAYLOG_DATANODE_HOSTNAME={{ .InstanceType }}-{{ .InstanceNumber }}.graylog.localhost
+GRAYLOG_DATANODE_BIND_ADDRESS={{ .InstanceType }}-{{ .InstanceNumber }}.graylog.localhost
+
+GRAYLOG_DATANODE_DATANODE_HTTP_PORT={{ sub 8999 .PortOffset }}
+GRAYLOG_DATANODE_OPENSEARCH_HTTP_PORT={{ add 9200 .PortOffset }}
+GRAYLOG_DATANODE_OPENSEARCH_TRANSPORT_PORT={{ add 9300 .PortOffset }}
+GRAYLOG_DATANODE_OPENSEARCH_HEAP=512m
+GRAYLOG_DATANODE_OPENSEARCH_LOCATION=../graylog-project-repos/graylog2-server/data-node/target/opensearch
+GRAYLOG_DATANODE_OPENSEARCH_LOGS_LOCATION={{ .DataDir }}/logs
+GRAYLOG_DATANODE_OPENSEARCH_CONFIG_LOCATION={{ .DataDir }}/opensearch-config
+GRAYLOG_DATANODE_OPENSEARCH_DATA_LOCATION={{ .DataDir }}/opensearch-data
+
+#GRAYLOG_DATANODE_INSECURE_STARTUP=true
diff --git a/idea/testdata/.config/idea/templates/run-configurations/data-node.run.xml.template b/idea/testdata/.config/idea/templates/run-configurations/data-node.run.xml.template
new file mode 100644
index 0000000..5ad7f67
--- /dev/null
+++ b/idea/testdata/.config/idea/templates/run-configurations/data-node.run.xml.template
@@ -0,0 +1,33 @@
+
+
+
+
+{{- if not .UseEnvFile }}
+ {{- range $key, $value := .Env }}
+
+ {{- end }}
+{{- end }}
+
+
+
+
+
+
+{{- if .UseEnvFile }}
+
+
+
+
+
+
+
+
+
+
+
+{{- end }}
+
+
+
+
+
diff --git a/idea/testdata/.config/idea/templates/run-configurations/server.env.template b/idea/testdata/.config/idea/templates/run-configurations/server.env.template
new file mode 100644
index 0000000..38d604a
--- /dev/null
+++ b/idea/testdata/.config/idea/templates/run-configurations/server.env.template
@@ -0,0 +1,17 @@
+# vim: ft=sh
+GRAYLOG_NODE_ID_FILE={{ .DataDir }}/node-id
+# Required to pass the pre-flight check for the Enterprise binaries.
+GRAYLOG_BIN_DIR=../graylog-project-repos/graylog-plugin-enterprise/enterprise/bin
+GRAYLOG_DATA_DIR={{ .DataDir }}
+GRAYLOG_MESSAGE_JOURNAL_DIR={{ .DataDir }}/journal
+GRAYLOG_PASSWORD_SECRET={{ .PasswordSecret }}
+GRAYLOG_ROOT_PASSWORD_SHA2={{ .RootPasswordHash }}
+GRAYLOG_HTTP_BIND_ADDRESS=127.0.0.1:{{ add 9000 .PortOffset }}
+GRAYLOG_IS_LEADER={{ .IsLeaderNode }}
+#GRAYLOG_LEADER_ELECTION_MODE=automatic
+GRAYLOG_LB_RECOGNITION_PERIOD_SECONDS=0
+GRAYLOG_VERSIONCHECKS=false
+GRAYLOG_TELEMETRY_ENABLED=false
+
+GRAYLOG_MONGODB_URI=mongodb://127.0.0.1:27017/graylog
+#GRAYLOG_ELASTICSEARCH_HOSTS=http://127.0.0.1:9200,http://127.0.0.1:9201
diff --git a/idea/testdata/.config/idea/templates/run-configurations/server.run.xml.template b/idea/testdata/.config/idea/templates/run-configurations/server.run.xml.template
new file mode 100644
index 0000000..ffd81de
--- /dev/null
+++ b/idea/testdata/.config/idea/templates/run-configurations/server.run.xml.template
@@ -0,0 +1,33 @@
+
+
+
+
+{{- if not .UseEnvFile }}
+ {{- range $key, $value := .Env }}
+
+ {{- end }}
+{{- end }}
+
+
+
+
+
+
+{{- if .UseEnvFile }}
+
+
+
+
+
+
+
+
+
+
+
+{{- end }}
+
+
+
+
+
diff --git a/idea/testdata/.config/idea/templates/run-configurations/web.run.xml.template b/idea/testdata/.config/idea/templates/run-configurations/web.run.xml.template
new file mode 100644
index 0000000..44d169c
--- /dev/null
+++ b/idea/testdata/.config/idea/templates/run-configurations/web.run.xml.template
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pomparse/pomparse.go b/pomparse/pomparse.go
index 3cbb3a7..24c2339 100644
--- a/pomparse/pomparse.go
+++ b/pomparse/pomparse.go
@@ -2,9 +2,11 @@ package pomparse
import (
"encoding/xml"
+ "fmt"
"github.com/Graylog2/graylog-project-cli/logger"
"github.com/Graylog2/graylog-project-cli/utils"
"io/ioutil"
+ "os"
"path/filepath"
"strings"
)
@@ -118,6 +120,20 @@ func ParsePom(filename string) MavenPom {
return mavenPom
}
+func ParsePomE(filename string) (*MavenPom, error) {
+ pomBytes, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't read %q: %w", filename, err)
+ }
+
+ var mavenPom MavenPom
+ if err := xml.Unmarshal(pomBytes, &mavenPom); err != nil {
+ return nil, fmt.Errorf("couldn't parse %q: %w", filename, err)
+ }
+
+ return &mavenPom, nil
+}
+
// Return pom.xml files for the given module directory and all its submodules. If the given path is empty, the current
// directory is assumed and the pom.xml file paths are relative.
func FindPomFiles(path string) []string {
diff --git a/utils/rename_test.go b/utils/rename_test.go
new file mode 100644
index 0000000..79ce4ab
--- /dev/null
+++ b/utils/rename_test.go
@@ -0,0 +1,21 @@
+package utils
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestAtomicallyWriteFile(t *testing.T) {
+ dir := t.TempDir()
+ filename := filepath.Join(dir, "test.txt")
+
+ require.NoError(t, AtomicallyWriteFile(filename, []byte("hello"), 0600))
+
+ data, err := os.ReadFile(filename)
+ require.NoError(t, err)
+
+ assert.Equal(t, []byte("hello"), data)
+}
diff --git a/utils/rename_unix.go b/utils/rename_unix.go
new file mode 100644
index 0000000..2179b1e
--- /dev/null
+++ b/utils/rename_unix.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package utils
+
+import (
+ "github.com/google/renameio/v2"
+ "os"
+)
+
+func AtomicallyWriteFile(filename string, data []byte, perm os.FileMode) error {
+ if err := renameio.WriteFile(filename, data, perm); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/utils/rename_windows.go b/utils/rename_windows.go
new file mode 100644
index 0000000..72bd3a0
--- /dev/null
+++ b/utils/rename_windows.go
@@ -0,0 +1,25 @@
+//go:build windows
+
+package utils
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func AtomicallyWriteFile(filename string, data []byte, perm os.FileMode) error {
+ // The github.com/google/renameio library doesn't support Windows so we have to do a best-effort implementation.
+ file, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".*")
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ return err
+ }
+ file.Close()
+ if err := os.Rename(file.Name(), filename); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/vendor/github.com/google/renameio/v2/.golangci.yml b/vendor/github.com/google/renameio/v2/.golangci.yml
new file mode 100644
index 0000000..abfb6ca
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/.golangci.yml
@@ -0,0 +1,5 @@
+linters:
+ disable:
+ - errcheck
+ enable:
+ - gofmt
diff --git a/vendor/github.com/google/renameio/v2/CONTRIBUTING.md b/vendor/github.com/google/renameio/v2/CONTRIBUTING.md
new file mode 100644
index 0000000..939e534
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/CONTRIBUTING.md
@@ -0,0 +1,28 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement. You (or your employer) retain the copyright to your contribution;
+this simply gives us permission to use and redistribute your contributions as
+part of the project. Head over to to see
+your current agreements on file or to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Community Guidelines
+
+This project follows [Google's Open Source Community
+Guidelines](https://opensource.google.com/conduct/).
diff --git a/vendor/github.com/google/renameio/v2/LICENSE b/vendor/github.com/google/renameio/v2/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/vendor/github.com/google/renameio/v2/README.md b/vendor/github.com/google/renameio/v2/README.md
new file mode 100644
index 0000000..703884c
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/README.md
@@ -0,0 +1,74 @@
+[![Build Status](https://github.com/google/renameio/workflows/Test/badge.svg)](https://github.com/google/renameio/actions?query=workflow%3ATest)
+[![PkgGoDev](https://pkg.go.dev/badge/github.com/google/renameio)](https://pkg.go.dev/github.com/google/renameio)
+[![Go Report Card](https://goreportcard.com/badge/github.com/google/renameio)](https://goreportcard.com/report/github.com/google/renameio)
+
+The `renameio` Go package provides a way to atomically create or replace a file or
+symbolic link.
+
+## Atomicity vs durability
+
+`renameio` concerns itself *only* with atomicity, i.e. making sure applications
+never see unexpected file content (a half-written file, or a 0-byte file).
+
+As a practical example, consider https://manpages.debian.org/: if there is a
+power outage while the site is updating, we are okay with losing the manpages
+which were being rendered at the time of the power outage. They will be added in
+a later run of the software. We are not okay with having a manpage replaced by a
+0-byte file under any circumstances, though.
+
+## Advantages of this package
+
+There are other packages for atomically replacing files, and sometimes ad-hoc
+implementations can be found in programs.
+
+A naive approach to the problem is to create a temporary file followed by a call
+to `os.Rename()`. However, there are a number of subtleties which make the
+correct sequence of operations hard to identify:
+
+* The temporary file should be removed when an error occurs, but a remove must
+ not be attempted if the rename succeeded, as a new file might have been
+ created with the same name. This renders a throwaway `defer
+ os.Remove(t.Name())` insufficient; state must be kept.
+
+* The temporary file must be created on the same file system (same mount point)
+ for the rename to work, but the TMPDIR environment variable should still be
+ respected, e.g. to direct temporary files into a separate directory outside of
+ the webserver’s document root but on the same file system.
+
+* On POSIX operating systems, the
+ [`fsync`](https://manpages.debian.org/stretch/manpages-dev/fsync.2) system
+ call must be used to ensure that the `os.Rename()` call will not result in a
+ 0-length file.
+
+This package attempts to get all of these details right, provides an intuitive,
+yet flexible API and caters to use-cases where high performance is required.
+
+## Major changes in v2
+
+With major version renameio/v2, `renameio.WriteFile` changes the way that
+permissions are handled. Before version 2, files were created with the
+permissions passed to the function, ignoring the
+[umask](https://en.wikipedia.org/wiki/Umask). From version 2 onwards, these
+permissions are further modified by process' umask (usually the user's
+preferred umask).
+
+If you were relying on the umask being ignored, add the
+`renameio.IgnoreUmask()` option to your `renameio.WriteFile` calls when
+upgrading to v2.
+
+## Windows support
+
+It is [not possible to reliably write files atomically on
+Windows](https://github.com/golang/go/issues/22397#issuecomment-498856679), and
+[`chmod` is not reliably supported by the Go standard library on
+Windows](https://github.com/google/renameio/issues/17).
+
+As it is not possible to provide a correct implementation, this package does not
+export any functions on Windows.
+
+## Disclaimer
+
+This is not an official Google product (experimental or otherwise), it
+is just code that happens to be owned by Google.
+
+This project is not affiliated with the Go project.
diff --git a/vendor/github.com/google/renameio/v2/doc.go b/vendor/github.com/google/renameio/v2/doc.go
new file mode 100644
index 0000000..67416df
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/doc.go
@@ -0,0 +1,21 @@
+// Copyright 2018 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package renameio provides a way to atomically create or replace a file or
+// symbolic link.
+//
+// Caveat: this package requires the file system rename(2) implementation to be
+// atomic. Notably, this is not the case when using NFS with multiple clients:
+// https://stackoverflow.com/a/41396801
+package renameio
diff --git a/vendor/github.com/google/renameio/v2/option.go b/vendor/github.com/google/renameio/v2/option.go
new file mode 100644
index 0000000..f825f6c
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/option.go
@@ -0,0 +1,79 @@
+// Copyright 2021 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+// +build !windows
+
+package renameio
+
+import "os"
+
+// Option is the interface implemented by all configuration function return
+// values.
+type Option interface {
+ apply(*config)
+}
+
+type optionFunc func(*config)
+
+func (fn optionFunc) apply(cfg *config) {
+ fn(cfg)
+}
+
+// WithTempDir configures the directory to use for temporary, uncommitted
+// files. Suitable for using a cached directory from
+// TempDir(filepath.Base(path)).
+func WithTempDir(dir string) Option {
+ return optionFunc(func(cfg *config) {
+ cfg.dir = dir
+ })
+}
+
+// WithPermissions sets the permissions for the target file while respecting
+// the umask(2). Bits set in the umask are removed from the permissions given
+// unless IgnoreUmask is used.
+func WithPermissions(perm os.FileMode) Option {
+ perm &= os.ModePerm
+ return optionFunc(func(cfg *config) {
+ cfg.createPerm = perm
+ })
+}
+
+// IgnoreUmask causes the permissions configured using WithPermissions to be
+// applied directly without applying the umask.
+func IgnoreUmask() Option {
+ return optionFunc(func(cfg *config) {
+ cfg.ignoreUmask = true
+ })
+}
+
+// WithStaticPermissions sets the permissions for the target file ignoring the
+// umask(2). This is equivalent to calling Chmod() on the file handle or using
+// WithPermissions in combination with IgnoreUmask.
+func WithStaticPermissions(perm os.FileMode) Option {
+ perm &= os.ModePerm
+ return optionFunc(func(cfg *config) {
+ cfg.chmod = &perm
+ })
+}
+
+// WithExistingPermissions configures the file creation to try to use the
+// permissions from an already existing target file. If the target file doesn't
+// exist yet or is not a regular file the default permissions are used unless
+// overridden using WithPermissions or WithStaticPermissions.
+func WithExistingPermissions() Option {
+ return optionFunc(func(c *config) {
+ c.attemptPermCopy = true
+ })
+}
diff --git a/vendor/github.com/google/renameio/v2/tempfile.go b/vendor/github.com/google/renameio/v2/tempfile.go
new file mode 100644
index 0000000..edc3e98
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/tempfile.go
@@ -0,0 +1,283 @@
+// Copyright 2018 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+// +build !windows
+
+package renameio
+
+import (
+ "io/ioutil"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "strconv"
+)
+
+// Default permissions for created files
+const defaultPerm os.FileMode = 0o600
+
+// nextrandom is a function generating a random number.
+var nextrandom = rand.Int63
+
+// openTempFile creates a randomly named file and returns an open handle. It is
+// similar to ioutil.TempFile except that the directory must be given, the file
+// permissions can be controlled and patterns in the name are not supported.
+// The name is always suffixed with a random number.
+func openTempFile(dir, name string, perm os.FileMode) (*os.File, error) {
+ prefix := filepath.Join(dir, name)
+
+ for attempt := 0; ; {
+ // Generate a reasonably random name which is unlikely to already
+ // exist. O_EXCL ensures that existing files generate an error.
+ name := prefix + strconv.FormatInt(nextrandom(), 10)
+
+ f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
+ if !os.IsExist(err) {
+ return f, err
+ }
+
+ if attempt++; attempt > 10000 {
+ return nil, &os.PathError{
+ Op: "tempfile",
+ Path: name,
+ Err: os.ErrExist,
+ }
+ }
+ }
+}
+
+// TempDir checks whether os.TempDir() can be used as a temporary directory for
+// later atomically replacing files within dest. If no (os.TempDir() resides on
+// a different mount point), dest is returned.
+//
+// Note that the returned value ceases to be valid once either os.TempDir()
+// changes (e.g. on Linux, once the TMPDIR environment variable changes) or the
+// file system is unmounted.
+func TempDir(dest string) string {
+ return tempDir("", filepath.Join(dest, "renameio-TempDir"))
+}
+
+func tempDir(dir, dest string) string {
+ if dir != "" {
+ return dir // caller-specified directory always wins
+ }
+
+ // Chose the destination directory as temporary directory so that we
+ // definitely can rename the file, for which both temporary and destination
+ // file need to point to the same mount point.
+ fallback := filepath.Dir(dest)
+
+ // The user might have overridden the os.TempDir() return value by setting
+ // the TMPDIR environment variable.
+ tmpdir := os.TempDir()
+
+ testsrc, err := ioutil.TempFile(tmpdir, "."+filepath.Base(dest))
+ if err != nil {
+ return fallback
+ }
+ cleanup := true
+ defer func() {
+ if cleanup {
+ os.Remove(testsrc.Name())
+ }
+ }()
+ testsrc.Close()
+
+ testdest, err := ioutil.TempFile(filepath.Dir(dest), "."+filepath.Base(dest))
+ if err != nil {
+ return fallback
+ }
+ defer os.Remove(testdest.Name())
+ testdest.Close()
+
+ if err := os.Rename(testsrc.Name(), testdest.Name()); err != nil {
+ return fallback
+ }
+ cleanup = false // testsrc no longer exists
+ return tmpdir
+}
+
+// PendingFile is a pending temporary file, waiting to replace the destination
+// path in a call to CloseAtomicallyReplace.
+type PendingFile struct {
+ *os.File
+
+ path string
+ done bool
+ closed bool
+}
+
+// Cleanup is a no-op if CloseAtomicallyReplace succeeded, and otherwise closes
+// and removes the temporary file.
+//
+// This method is not safe for concurrent use by multiple goroutines.
+func (t *PendingFile) Cleanup() error {
+ if t.done {
+ return nil
+ }
+ // An error occurred. Close and remove the tempfile. Errors are returned for
+ // reporting, there is nothing the caller can recover here.
+ var closeErr error
+ if !t.closed {
+ closeErr = t.Close()
+ }
+ if err := os.Remove(t.Name()); err != nil {
+ return err
+ }
+ t.done = true
+ return closeErr
+}
+
+// CloseAtomicallyReplace closes the temporary file and atomically replaces
+// the destination file with it, i.e., a concurrent open(2) call will either
+// open the file previously located at the destination path (if any), or the
+// just written file, but the file will always be present.
+//
+// This method is not safe for concurrent use by multiple goroutines.
+func (t *PendingFile) CloseAtomicallyReplace() error {
+ // Even on an ordered file system (e.g. ext4 with data=ordered) or file
+ // systems with write barriers, we cannot skip the fsync(2) call as per
+ // Theodore Ts'o (ext2/3/4 lead developer):
+ //
+ // > data=ordered only guarantees the avoidance of stale data (e.g., the previous
+ // > contents of a data block showing up after a crash, where the previous data
+ // > could be someone's love letters, medical records, etc.). Without the fsync(2)
+ // > a zero-length file is a valid and possible outcome after the rename.
+ if err := t.Sync(); err != nil {
+ return err
+ }
+ t.closed = true
+ if err := t.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(t.Name(), t.path); err != nil {
+ return err
+ }
+ t.done = true
+ return nil
+}
+
+// TempFile creates a temporary file destined to atomically creating or
+// replacing the destination file at path.
+//
+// If dir is the empty string, TempDir(filepath.Base(path)) is used. If you are
+// going to write a large number of files to the same file system, store the
+// result of TempDir(filepath.Base(path)) and pass it instead of the empty
+// string.
+//
+// The file's permissions will be 0600. You can change these by explicitly
+// calling Chmod on the returned PendingFile.
+func TempFile(dir, path string) (*PendingFile, error) {
+ return NewPendingFile(path, WithTempDir(dir), WithStaticPermissions(defaultPerm))
+}
+
+type config struct {
+ dir, path string
+ createPerm os.FileMode
+ attemptPermCopy bool
+ ignoreUmask bool
+ chmod *os.FileMode
+}
+
+// NewPendingFile creates a temporary file destined to atomically creating or
+// replacing the destination file at path.
+//
+// TempDir(filepath.Base(path)) is used to store the temporary file. If you are
+// going to write a large number of files to the same file system, use the
+// result of TempDir(filepath.Base(path)) with the WithTempDir option.
+//
+// The file's permissions will be (0600 & ^umask). Use WithPermissions,
+// IgnoreUmask, WithStaticPermissions and WithExistingPermissions to control
+// them.
+func NewPendingFile(path string, opts ...Option) (*PendingFile, error) {
+ cfg := config{
+ path: path,
+ createPerm: defaultPerm,
+ }
+
+ for _, o := range opts {
+ o.apply(&cfg)
+ }
+
+ if cfg.ignoreUmask && cfg.chmod == nil {
+ cfg.chmod = &cfg.createPerm
+ }
+
+ if cfg.attemptPermCopy {
+ // Try to determine permissions from an existing file.
+ if existing, err := os.Lstat(cfg.path); err == nil && existing.Mode().IsRegular() {
+ perm := existing.Mode() & os.ModePerm
+ cfg.chmod = &perm
+
+ // Try to already create file with desired permissions; at worst
+ // a chmod will be needed afterwards.
+ cfg.createPerm = perm
+ } else if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ }
+
+ f, err := openTempFile(tempDir(cfg.dir, cfg.path), "."+filepath.Base(cfg.path), cfg.createPerm)
+ if err != nil {
+ return nil, err
+ }
+
+ if cfg.chmod != nil {
+ if fi, err := f.Stat(); err != nil {
+ return nil, err
+ } else if fi.Mode()&os.ModePerm != *cfg.chmod {
+ if err := f.Chmod(*cfg.chmod); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return &PendingFile{File: f, path: cfg.path}, nil
+}
+
+// Symlink wraps os.Symlink, replacing an existing symlink with the same name
+// atomically (os.Symlink fails when newname already exists, at least on Linux).
+func Symlink(oldname, newname string) error {
+ // Fast path: if newname does not exist yet, we can skip the whole dance
+ // below.
+ if err := os.Symlink(oldname, newname); err == nil || !os.IsExist(err) {
+ return err
+ }
+
+ // We need to use ioutil.TempDir, as we cannot overwrite a ioutil.TempFile,
+ // and removing+symlinking creates a TOCTOU race.
+ d, err := ioutil.TempDir(filepath.Dir(newname), "."+filepath.Base(newname))
+ if err != nil {
+ return err
+ }
+ cleanup := true
+ defer func() {
+ if cleanup {
+ os.RemoveAll(d)
+ }
+ }()
+
+ symlink := filepath.Join(d, "tmp.symlink")
+ if err := os.Symlink(oldname, symlink); err != nil {
+ return err
+ }
+
+ if err := os.Rename(symlink, newname); err != nil {
+ return err
+ }
+
+ cleanup = false
+ return os.RemoveAll(d)
+}
diff --git a/vendor/github.com/google/renameio/v2/writefile.go b/vendor/github.com/google/renameio/v2/writefile.go
new file mode 100644
index 0000000..5450421
--- /dev/null
+++ b/vendor/github.com/google/renameio/v2/writefile.go
@@ -0,0 +1,41 @@
+// Copyright 2018 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+// +build !windows
+
+package renameio
+
+import "os"
+
+// WriteFile mirrors ioutil.WriteFile, replacing an existing file with the same
+// name atomically.
+func WriteFile(filename string, data []byte, perm os.FileMode, opts ...Option) error {
+ opts = append([]Option{
+ WithPermissions(perm),
+ WithExistingPermissions(),
+ }, opts...)
+
+ t, err := NewPendingFile(filename, opts...)
+ if err != nil {
+ return err
+ }
+ defer t.Cleanup()
+
+ if _, err := t.Write(data); err != nil {
+ return err
+ }
+
+ return t.CloseAtomicallyReplace()
+}
diff --git a/vendor/github.com/k0kubun/pp/v3/CHANGELOG.md b/vendor/github.com/k0kubun/pp/v3/CHANGELOG.md
index 0f19fc0..b1e6f51 100644
--- a/vendor/github.com/k0kubun/pp/v3/CHANGELOG.md
+++ b/vendor/github.com/k0kubun/pp/v3/CHANGELOG.md
@@ -1,6 +1,23 @@
# Changelog
-## [v3.3.0](https://github.com/k0kubun/pp/tree/HEAD)
+## [v3.4.1](https://github.com/k0kubun/pp/tree/v3.4.1) (2024-11-27)
+
+[Full Changelog](https://github.com/k0kubun/pp/compare/v3.4.0...v3.4.1)
+
+**Merged pull requests:**
+
+- Fix omitEmpty is not propagated [\#90](https://github.com/k0kubun/pp/pull/90) ([apstndb](https://github.com/apstndb))
+
+## [v3.4.0](https://github.com/k0kubun/pp/tree/v3.4.0) (2024-11-27)
+
+[Full Changelog](https://github.com/k0kubun/pp/compare/v3.3.0...v3.4.0)
+
+**Merged pull requests:**
+
+- Implement SetOmitEmpty\(\) [\#89](https://github.com/k0kubun/pp/pull/89) ([apstndb](https://github.com/apstndb))
+- Bump golang.org/x/text from 0.8.0 to 0.19.0 [\#88](https://github.com/k0kubun/pp/pull/88) ([dependabot[bot]](https://github.com/apps/dependabot))
+
+## [v3.3.0](https://github.com/k0kubun/pp/tree/v3.3.0) (2024-10-25)
[Full Changelog](https://github.com/k0kubun/pp/compare/v3.2.0...v3.3.0)
diff --git a/vendor/github.com/k0kubun/pp/v3/README.md b/vendor/github.com/k0kubun/pp/v3/README.md
index c5a081a..f22d9e2 100644
--- a/vendor/github.com/k0kubun/pp/v3/README.md
+++ b/vendor/github.com/k0kubun/pp/v3/README.md
@@ -45,6 +45,7 @@ You can also create individual instances that do not interfere with the default
mypp := pp.New()
mypp.SetColoringEnabled(false)
mypp.SetExportedOnly(true)
+mypp.SetOmitEmpty(true)
mypp.Println()
```
diff --git a/vendor/github.com/k0kubun/pp/v3/pp.go b/vendor/github.com/k0kubun/pp/v3/pp.go
index bac9958..ec4c9d4 100644
--- a/vendor/github.com/k0kubun/pp/v3/pp.go
+++ b/vendor/github.com/k0kubun/pp/v3/pp.go
@@ -49,6 +49,9 @@ type PrettyPrinter struct {
thousandsSeparator bool
// This skips unexported fields of structs.
exportedOnly bool
+
+ // This skips empty fields of structs.
+ omitEmpty bool
}
// New creates a new PrettyPrinter that can be used to pretty print values
@@ -66,6 +69,7 @@ func newPrettyPrinter(callerLevel int) *PrettyPrinter {
coloringEnabled: true,
decimalUint: true,
exportedOnly: false,
+ omitEmpty: false,
}
}
@@ -149,6 +153,11 @@ func (pp *PrettyPrinter) SetExportedOnly(enabled bool) {
pp.exportedOnly = enabled
}
+// SetOmitEmpty makes empty fields in struct not be printed.
+func (pp *PrettyPrinter) SetOmitEmpty(enabled bool) {
+ pp.omitEmpty = enabled
+}
+
func (pp *PrettyPrinter) SetThousandsSeparator(enabled bool) {
pp.thousandsSeparator = enabled
}
diff --git a/vendor/github.com/k0kubun/pp/v3/printer.go b/vendor/github.com/k0kubun/pp/v3/printer.go
index 13e0a0a..6f1214c 100644
--- a/vendor/github.com/k0kubun/pp/v3/printer.go
+++ b/vendor/github.com/k0kubun/pp/v3/printer.go
@@ -22,10 +22,10 @@ const (
)
func (pp *PrettyPrinter) format(object interface{}) string {
- return newPrinter(object, &pp.currentScheme, pp.maxDepth, pp.coloringEnabled, pp.decimalUint, pp.exportedOnly, pp.thousandsSeparator).String()
+ return newPrinter(object, &pp.currentScheme, pp.maxDepth, pp.coloringEnabled, pp.decimalUint, pp.exportedOnly, pp.thousandsSeparator, pp.omitEmpty).String()
}
-func newPrinter(object interface{}, currentScheme *ColorScheme, maxDepth int, coloringEnabled bool, decimalUint bool, exportedOnly bool, thousandsSeparator bool) *printer {
+func newPrinter(object interface{}, currentScheme *ColorScheme, maxDepth int, coloringEnabled bool, decimalUint bool, exportedOnly bool, thousandsSeparator bool, omitEmpty bool) *printer {
buffer := bytes.NewBufferString("")
tw := new(tabwriter.Writer)
tw.Init(buffer, indentWidth, 0, 1, ' ', 0)
@@ -42,6 +42,7 @@ func newPrinter(object interface{}, currentScheme *ColorScheme, maxDepth int, co
decimalUint: decimalUint,
exportedOnly: exportedOnly,
thousandsSeparator: thousandsSeparator,
+ omitEmpty: omitEmpty,
}
if thousandsSeparator {
@@ -63,6 +64,7 @@ type printer struct {
decimalUint bool
exportedOnly bool
thousandsSeparator bool
+ omitEmpty bool
localizedPrinter *message.Printer
}
@@ -212,7 +214,13 @@ func (p *printer) printStruct() {
if p.exportedOnly && field.PkgPath != "" {
continue
}
- // ignore fields if zero value, or explicitly set
+
+ // ignore empty fields if needed
+ if p.omitEmpty && valueIsZero(value) {
+ continue
+ }
+
+ // ignore fields with struct tags if zero value, or explicitly set
if tag := field.Tag.Get("pp"); tag != "" {
parts := strings.Split(tag, ",")
if len(parts) == 2 && parts[1] == "omitempty" && valueIsZero(value) {
@@ -469,7 +477,7 @@ func (p *printer) colorize(text string, color uint16) string {
}
func (p *printer) format(object interface{}) string {
- pp := newPrinter(object, p.currentScheme, p.maxDepth, p.coloringEnabled, p.decimalUint, p.exportedOnly, p.thousandsSeparator)
+ pp := newPrinter(object, p.currentScheme, p.maxDepth, p.coloringEnabled, p.decimalUint, p.exportedOnly, p.thousandsSeparator, p.omitEmpty)
pp.depth = p.depth
pp.visited = p.visited
if value, ok := object.(reflect.Value); ok {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index e0191ea..9f7cede 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -20,6 +20,9 @@ github.com/google/go-github/v66/github
# github.com/google/go-querystring v1.1.0
## explicit; go 1.10
github.com/google/go-querystring/query
+# github.com/google/renameio/v2 v2.0.0
+## explicit; go 1.13
+github.com/google/renameio/v2
# github.com/hashicorp/go-version v1.7.0
## explicit
github.com/hashicorp/go-version
@@ -41,7 +44,7 @@ github.com/imdario/mergo
# github.com/inconshreveable/mousetrap v1.1.0
## explicit; go 1.18
github.com/inconshreveable/mousetrap
-# github.com/k0kubun/pp/v3 v3.3.0
+# github.com/k0kubun/pp/v3 v3.4.1
## explicit; go 1.17
github.com/k0kubun/pp/v3
# github.com/magiconair/properties v1.8.7