From af99fc849ac3448b17fd0448999f9a60753ce89f Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Sat, 27 May 2023 13:31:40 -0600 Subject: [PATCH] Golang implementation with native git hook support - Re-implement with Golang in place of of Bash - Add `env-sample-sync install` to support native git hooks (instead of relying on `pre-commit`) --- .github/workflows/goreleaser.yml | 35 ++++ .github/workflows/post-release.yml | 33 ++++ .pre-commit-hooks.yaml | 6 +- env-sample-sync.sh | 75 -------- go.mod | 5 + go.sum | 2 + main.go | 291 +++++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/goreleaser.yml create mode 100644 .github/workflows/post-release.yml delete mode 100755 env-sample-sync.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..1757fb1 --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,35 @@ +name: goreleaser + +on: + push: + branches: + - main + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - + name: Bump version and push tag + uses: anothrNick/github-tag-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_V: true + DEFAULT_BUMP: minor + - + name: Set up Go + uses: actions/setup-go@v3 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml new file mode 100644 index 0000000..8fe5693 --- /dev/null +++ b/.github/workflows/post-release.yml @@ -0,0 +1,33 @@ +name: Update README.md with latest Version + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PATH_TOKEN }} # Needs write access to the repo + ref: main + + + - name: Update files + uses: MathieuSoysal/file-updater-for-release@v1.0.3 + with: + files: README.md # List of files to update + prefix: "rev: " # Prefix before the version, default is: "" + with-checkout: false # If you don't want to checkout the repo, default is: true + + - name: Push changes + uses: EndBug/add-and-commit@v9 + with: + committer_name: GitHub Actions + committer_email: actions@github.com + add: README.md + message: 'Updated README.md with latest version number' diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index aa6409c..80377fc 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,8 +1,8 @@ - id: env-sample-sync - name: env.sample sync + name: env-sample-sync env sample file synchronizer description: Automatically and safely synchronize .env files with env.sample - entry: env-sample-sync.sh - language: script + language: golang + entry: env-sample-sync always_run: true pass_filenames: false stages: [commit] diff --git a/env-sample-sync.sh b/env-sample-sync.sh deleted file mode 100755 index bb04433..0000000 --- a/env-sample-sync.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# A mapping of environment variables names to the the values used in `env.sample` as examples -declare -A examples - -for i in "$@"; do - case $i in - -e=*|--env-file=*) - ENV_FILE_NAME="${i#*=}" - shift - ;; - -s=*|--sample-file=*) - SAMPLE_FILE_NAME="${i#*=}" - shift - ;; - -x=*|--example=*) - varExample="${i#*=}" - IFS='='; exampleTuple=($varExample); unset IFS; - examples[${exampleTuple[0]}]=${exampleTuple[1]} - shift - ;; - -*|--*) - echo "Unknown option $i" - exit 1 - ;; - *) - ;; - esac -done - -ENV_FILE_NAME=${ENV_FILE_NAME:-.env} -SAMPLE_FILE_NAME=${SAMPLE_FILE_NAME:-env.sample} - -function scrub_env_file() { - local envFile=$1 - local sampleFile=$2 - local isComment='^[[:space:]]*#' - local isBlank='^[[:space:]]*$' - local sampleContent="" - - while IFS= read -r line; do - # If the line contains an environment variable definition, scrub the variable value - # and set a placeholder of the form: ENV_VAR= - if [[ ! $line =~ $isComment && ! $line =~ $isBlank ]]; then - envVar=$(echo "$line" | cut -d '=' -f 1) - - # If the user supplied an example for this environment variable, use their example - # instead of the environemnt variable name - if [[ ${examples[$envVar]+FOUND} == "FOUND" ]]; then - exampleValue="${examples[$envVar]}" - else - exampleValue="<$envVar>" - fi - - sampleContent="${sampleContent}${envVar}=$exampleValue\n" - else - sampleContent="${sampleContent}${line}\n" - fi - done < <( cat "$envFile" ) - - echo -e $sampleContent > $sampleFile -} - -repoDir=$(git rev-parse --show-toplevel) -envFilePath="$repoDir/$ENV_FILE_NAME" -sampleFilePath="$repoDir/$SAMPLE_FILE_NAME" - -if [[ -f $envFilePath ]]; then - scrub_env_file $envFilePath $sampleFilePath - git add $SAMPLE_FILE_NAME -else - echo "No .env file found at: $envFilePath" -fi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..524ff2e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/acaloiaro/env-sample-sync + +go 1.20 + +require github.com/hashicorp/go-envparse v0.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ea73bfa --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= +github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3033465 --- /dev/null +++ b/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/hashicorp/go-envparse" +) + +type exampleFlag map[string]string + +func (e exampleFlag) String() string { + var str string + for key, val := range e { + str = fmt.Sprintf(`%s --example="%s=%s"`, str, key, val) + } + + return str +} + +func (e exampleFlag) Set(value string) (err error) { + exampleParts := strings.Split(value, "=") + if len(exampleParts) != 2 { + err = errors.New("examples must be provided in KEY=VALUE format") + return + } + + e[exampleParts[0]] = exampleParts[1] + + return nil +} + +var envFileFlag string +var sampleFileFlag string +var examplesFlag = make(exampleFlag) + +func init() { + flag.StringVar(&envFileFlag, "env-file", ".env", "-env-file=.env_file") + flag.StringVar(&sampleFileFlag, "sample-file", "env.sample", "-sample-file=env_var.sample") + flag.Var(examplesFlag, "example", "--example=FOO=\"my foo value\" --example=BAR=\"my bar value\"") + + flag.Usage = func() { + cmd := os.Args[0] + fmt.Fprintf(os.Stderr, "Usage of %s:\n", cmd) + fmt.Fprintf(os.Stderr, "%s [-flags] %s \n\n", cmd, "[install|sync]") + flag.PrintDefaults() + } + + flag.Parse() +} + +func main() { + command := "sync" + args := flag.Args() + if len(args) > 0 { + command = args[0] + } + + projectPath, gitDirPath := gitDirPaths() + + switch command { + case "sync": + sync(projectPath) + cmd := exec.Command("git", "add", filepath.Join(projectPath, sampleFileFlag)) + err := cmd.Run() + if err != nil { + fmt.Printf("unable to add sample file '%s' to git: %v", sampleFileFlag, err) + os.Exit(1) + } + case "install": + err := installHook(gitDirPath) + if err != nil { + fmt.Println("unable to install pre-commit hook:", err) + os.Exit(1) + } + default: + fmt.Printf("env-sample-sync: unknown command '%s'\n\n", command) + flag.Usage() + } +} + +func sync(dir string) { + envFilePath := filepath.Join(dir, envFileFlag) + sampleFilePath := filepath.Join(dir, sampleFileFlag) + + envFileReader, err := os.Open(envFilePath) + if err != nil { + fmt.Printf("env file '%s' was not found. skipping sync.\n", envFilePath) + os.Exit(0) + } + + envFile, err := envparse.Parse(envFileReader) + if err != nil { + fmt.Println("unable to parse env file:", err) + os.Exit(1) + } + + scrubEnvFile(envFile, examplesFlag) + err = writeSampleFile(envFile, envFilePath, sampleFilePath) + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func writeSampleFile(sampleFileContent map[string]string, envFilePath, sampleFilePath string) (err error) { + sampleFile, err := os.Create(sampleFilePath) + if err != nil { + err = fmt.Errorf("unable to create sample file: %w", err) + return + } + defer sampleFile.Close() + + sampleWriter := bufio.NewWriter(sampleFile) + envFile, err := os.Open(envFilePath) + if err != nil { + err = fmt.Errorf("unable to read env file: %w", err) + return + } + defer envFile.Close() + + scanner := bufio.NewScanner(envFile) + + // interate through the env file line-by-line, searching for environment variables + // any line that starts with a key from `sampleFileContents` followed by an equal sign `=` is considered + // a line that contains a secret + for scanner.Scan() { + envFileLine := scanner.Text() + scrubbedLine := replaceSecrets(envFileLine, sampleFileContent) + sampleWriter.WriteString(fmt.Sprintf("%s\n", scrubbedLine)) + } + + sampleWriter.Flush() + + return +} + +func scrubEnvFile(envFile map[string]string, examples map[string]string) { + for envFileKey := range envFile { + exampleVal, ok := examples[envFileKey] + if ok { + envFile[envFileKey] = exampleVal + } else { + envFile[envFileKey] = fmt.Sprintf("<%s>", envFileKey) + } + } +} + +// replaceSecrets replaces the content of env file entries (lines) with a new line that is scrubbed of secrets +// +// any line containing a variable name followed by and equal sign is considered to contain a secret +func replaceSecrets(envFileEntry string, sampleFileContent map[string]string) (newLine string) { + var r *regexp.Regexp + newLine = envFileEntry + + for secretKey, secretPlaceholder := range sampleFileContent { + r = regexp.MustCompile(fmt.Sprintf("(%s.*=.*)", secretKey)) + if r.MatchString(envFileEntry) { + newLine = r.ReplaceAllString(envFileEntry, fmt.Sprintf("%s=%s", secretKey, secretPlaceholder)) + } + } + + return +} + +// gitDirPath returns the path the the GIT_DIR +func gitDirPaths() (projectPath, gitDir string) { + var outb, errb bytes.Buffer + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + if err != nil { + fmt.Println("unable to find project directory:", err) + os.Exit(1) + } + + projectPath = strings.TrimRight(outb.String(), "\r\n") + + outb.Reset() + errb.Reset() + + cmd = exec.Command("git", "rev-parse", "--git-dir") + cmd.Stdout = &outb + cmd.Stderr = &errb + err = cmd.Run() + if err != nil { + fmt.Println("unable to find GIT_DIR:", err) + os.Exit(1) + } + + gitDir = strings.TrimRight(outb.String(), "\r\n") + gitDir = filepath.Join(projectPath, gitDir) + + return +} + +var preCommitScriptTemplate = `#!/usr/bin/env bash +# File generated by env-sample-sync https://github.com/acaloiaro/env-sample-sync + +ARGS=({{ .Args }}) + +for SCRIPT in $(dirname -- "${BASH_SOURCE[0]}")/{{ .PreCommitHooksDir }}/*; do + exec "$SCRIPT" "${ARGS[@]}" +done +` + +// installHook installs the env-sample-sync git hook to 'dir' +func installHook(gitDirPath string) (err error) { + hooksScriptDirName := "pre-commit-hooks.d" + hooksDirName := filepath.Join(gitDirPath, "hooks") + hooksScriptDirPath := filepath.Join(hooksDirName, hooksScriptDirName) + preCommitHookScriptPath := filepath.Join(hooksDirName, "pre-commit") + + _, err = os.Stat(preCommitHookScriptPath) + if !os.IsNotExist(err) { + fmt.Printf("a pre-commit hook is already installed. Overwrite? [y/n]: ") + var response string + fmt.Scanln(&response) + if response != "y" { + os.Exit(0) + } + } + + // Create a directory to place pre-commit-hook executable within + if _, err = os.Stat(hooksScriptDirPath); os.IsNotExist(err) { + if err = os.Mkdir(hooksScriptDirPath, os.ModePerm); err != nil { + return + } + } + + preCommitHookPath := filepath.Join(hooksScriptDirPath, "0-env-sample-sync") + preCommitHook, err := os.OpenFile(preCommitHookPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + fmt.Println("unable to install pre-commit hook", err) + os.Exit(1) + } + + executablePath, err := exec.LookPath(os.Args[0]) + if err != nil { + fmt.Println("unable to find 'env-sample-sync' in $PATH:", err) + os.Exit(1) + } + + executable, err := os.Open(executablePath) + if err != nil { + return + } + + // copy the currently running executable wholsale into the pre commit hooks directory + io.Copy(preCommitHook, executable) + + tmpl, err := template.New("pre-commit-script").Parse(preCommitScriptTemplate) + if err != nil { + log.Println("unable to parse pre-commit script template:", err) + os.Exit(1) + } + + args := fmt.Sprintf("--env-file=%s --sample-file=%s %s", envFileFlag, sampleFileFlag, examplesFlag) + var buff = bytes.NewBufferString("") + err = tmpl.Execute(buff, map[string]string{ + "Args": args, + "PreCommitHooksDir": hooksScriptDirName, + }) + if err != nil { + return + } + + preCommitHookScript, err := os.OpenFile(preCommitHookScriptPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return + } + _, err = buff.WriteTo(preCommitHookScript) + if err != nil { + return + } + + fmt.Println("env-sample-sync pre-commit hook installed!") + return +}