From be533f73483524dd7e883a9eb0428880d67a045f Mon Sep 17 00:00:00 2001 From: Barnaby Keene Date: Mon, 11 Dec 2017 17:23:16 +0100 Subject: [PATCH] genesis --- .env | 16 ++++++ .github/workflows/main.yml | 26 ++++++++++ .gitignore | 3 ++ .goreleaser.yml | 34 ++++++++++++ Dockerfile | 3 ++ LICENSE | 21 ++++++++ README.md | 103 +++++++++++++++++++++++++++++++++++++ env2file.go | 103 +++++++++++++++++++++++++++++++++++++ env2file_test.go | 73 ++++++++++++++++++++++++++ go.mod | 3 ++ 10 files changed, 385 insertions(+) create mode 100644 .env create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 env2file.go create mode 100644 env2file_test.go create mode 100644 go.mod diff --git a/.env b/.env new file mode 100644 index 0000000..a326282 --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +# some examples + +E2F_name_someini=testfile.ini +E2F_data_someini=key=value + +E2F_name_somejson=testfile.json +E2F_data_somejson={"key": "value"} + +E2F_name_sometoml=testfile.toml +E2F_data_sometoml=key=value + +E2F_name_someyaml=testfile.yaml +E2F_data_someyaml=key: value + +E2F_name_somefullpath=/Users/barnabykeene/Desktop/env2file/fromfullpath.yaml +E2F_data_somefullpath=key: value diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..35695d5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: goreleaser + +on: + pull_request: + push: + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Set up Go + uses: actions/setup-go@v1 + - name: Docker Login + uses: azure/docker-login@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v1 + with: + version: latest + args: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35f280 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +test/* +!test/.gitkeep diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..b47c614 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,34 @@ +project_name: env2file +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm + - arm64 +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" +dockers: + - image_templates: + - southclaws/env2file:latest + dockerfile: Dockerfile +nfpms: + - vendor: Southclaws + homepage: https://github.com/Southclaws/env2file + maintainer: Southclaws + description: A tiny utility for turning environment variable values into files. + license: GPLv3 + formats: + - deb diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adc71c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM scratch +ADD /env2file /bin/env2file +ENTRYPOINT ["env2file"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9d02c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Barnaby "Southclaws" Keene + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d4a2fa --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# env2file + +A tiny utility for turning environment variable values into files. + +```sh +go get github.com/Southclaws/env2file +``` + +```sh +docker pull southclaws/env2file +``` + +## Why + +Sometimes you need to deploy a containerised application and it requires a file for configuration. That means you have +to think about where that file goes, how to automate its creation and maybe even version control so you can record +configuration change history. + +This simplifies that by allowing you to store small-ish configuration files as environment variables in your container +management system. + +It's kind of like ConfigMaps in Kubernetes. + +## How + +This is mainly designed for deployments that use Docker Compose. The intended usage is: + +1. Add env2file to your compose config +2. Mount a volume/directory to both env2file and the app that wants a file +3. Set some environment variables on the env2file container +4. Everything boots up, env2file creates the files, they are visible to the app, app is happy! + +env2file will search for environment variables that match the following format: + +```env +EF_(name|data)_(\w+) +``` + +Where the first group is either `name` or `data` and the second group is some unique name. + +Each "target" requires two variables: one for the filename and one for the contents. So if you wanted to create a file +named `config.json` with some JSON in it, you'd declare two variables: + +```env +EF_name_cfg=config.json +EF_data_cfg={"some":"json"} +``` + +The unique key (`cfg` in the above example) permits for as many targets as you want: + +```env +EF_name_cfg=config.json +EF_data_cfg={"some":"json"} + +EF_name_auth=auth.yaml +EF_data_auth=some: yaml + +EF_name_other=other_stuff +EF_data_other=my secret pizza recipe +``` + +```yaml +version: "3.5" +services: + someapp: + image: some/app + volumes: + - /shared/config:/etc/someapp/config + env2file: + image: southclaws/env2file + environment: + E2F_name_config: /config/someapp-config.json + E2F_data_config: | + { + "host": "127.0.0.1", + "port": 4444 + } + E2F_name_clientid: /config/client_identifier + E2F_data_clientid: 37d060be-fb2e-11e9-99d0-645aede9143b + volumes: + - /shared/config:/config +``` + +In the above example, `some/app` will see a file named `someapp-config.json` inside the `/etc/someapp/config` directory +with the contents: + +```json +{ + "host": "127.0.0.1", + "port": 4444 +} +``` + +And a file named `client_identifier` inside the same directory that contains simply +`37d060be-fb2e-11e9-99d0-645aede9143b`. + +--- + +You can demo/play locally with the following docker run line: + +```sh +docker run -v$(pwd)/files:/files -e EF_name_target=/files/target.json -e EF_data_target='{"a":"b"}' southclaws/env2file +``` diff --git a/env2file.go b/env2file.go new file mode 100644 index 0000000..36fcd07 --- /dev/null +++ b/env2file.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +const prefix = "EF_" + +// target represents a filename (full path) and its contents. +type target struct { + name string + data string +} + +// writes the contents of a target to its desired file. +func (t target) write() error { + f, err := os.Create(t.name) + if err != nil { + return err + } + if _, err := f.WriteString(t.data); err != nil { + return err + } + return nil +} + +// aggregateFromEnv collects a set of targets from the given env vars +// it only accepts pairs, so if a `name` is missing a `data` then it's ignored +func aggregateFromEnv(envs []string) ([]target, []error) { + targ := make(map[string]struct{}) + name := make(map[string]string) + data := make(map[string]string) + errs := []error{} + for _, e := range envs { + key, value := splitEnvironmentVariable(e) + if !strings.HasPrefix(key, prefix) { + continue + } + t, n, err := decodeKey(key) + if err != nil { + errs = append(errs, err) + continue + } + targ[n] = struct{}{} + switch t { + case "name": + name[n] = value + case "data": + data[n] = value + } + } + targets, joinErrors := join(targ, name, data) + return targets, append(errs, joinErrors...) +} + +// joins the aggregated sets of separate variables into a list of targets +func join(t map[string]struct{}, n, d map[string]string) ([]target, []error) { + all := []target{} + errs := []error{} + for key := range t { + var t target + var ok bool + if t.name, ok = n[key]; !ok { + errs = append(errs, fmt.Errorf("missing target config for %s", key)) + continue + } + if t.data, ok = d[key]; !ok { + errs = append(errs, fmt.Errorf("missing target config for %s", key)) + continue + } + all = append(all, t) + } + return all, errs +} + +func splitEnvironmentVariable(keyvalue string) (key, value string) { + v := strings.SplitN(keyvalue, "=", 2) + return v[0], v[1] +} + +// splits a key in the E2F format into a target type and a target name +// errors if the pattern is wrong such as having too few _ separators +func decodeKey(key string) (targetType string, name string, err error) { + v := strings.SplitN(key, "_", 3) + if len(v) != 3 { + return "", "", fmt.Errorf("%s has invalid pattern", key) + } + return v[1], v[2], nil +} + +func main() { + targets, errors := aggregateFromEnv(os.Environ()) + for _, e := range errors { + fmt.Println("Error:", e) + } + for _, t := range targets { + if err := t.write(); err != nil { + fmt.Println(err) + } + } +} diff --git a/env2file_test.go b/env2file_test.go new file mode 100644 index 0000000..00ad510 --- /dev/null +++ b/env2file_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "reflect" + "testing" +) + +func Test_aggregateFromEnv(t *testing.T) { + tests := []struct { + variables []string + wantTargs []target + wantError []error + }{ + { + []string{`EF_name_file=file.json`, `EF_data_file={"a":0}`}, + []target{{name: "file.json", data: `{"a":0}`}}, + []error{}, + }, + { + []string{`EF_name_file=fi_le.json`, `EF_data_file={"a":0}`}, + []target{{name: "fi_le.json", data: `{"a":0}`}}, + []error{}, + }, + { + []string{`EF_name_fi_le=fi_le.json`, `EF_data_fi_le={"a":0}`}, + []target{{name: "fi_le.json", data: `{"a":0}`}}, + []error{}, + }, + } + for ii, tt := range tests { + t.Run(fmt.Sprint(ii), func(t *testing.T) { + gotTargs, gotError := aggregateFromEnv(tt.variables) + equal(t, tt.wantTargs, gotTargs) + equal(t, tt.wantError, gotError) + }) + } +} + +func Test_decodeKey(t *testing.T) { + tests := []struct { + key string + wantType string + wantName string + wantError error + }{ + {"EF_name_target", "name", "target", nil}, + {"EF_data_target", "data", "target", nil}, + {"EF_name_target_underscore", "name", "target_underscore", nil}, + {"EF_name_", "name", "", nil}, + {"EF_name", "", "", fmt.Errorf("EF_name has invalid pattern")}, + {"EF_", "", "", fmt.Errorf("EF_ has invalid pattern")}, + {"EF", "", "", fmt.Errorf("EF has invalid pattern")}, + } + for ii, tt := range tests { + t.Run(fmt.Sprint(ii), func(t *testing.T) { + gotType, gotName, gotError := decodeKey(tt.key) + equal(t, tt.wantError, gotError) + if gotError == nil { + equal(t, tt.wantType, gotType) + equal(t, tt.wantName, gotName) + } + }) + } +} + +// helpers + +func equal(t *testing.T, expected, actual interface{}) { + if !reflect.DeepEqual(expected, actual) { + t.Errorf("%v != %v", expected, actual) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b0cb5f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Southclaws/env2file + +go 1.0