Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ This is useful for injecting runtime configuration into static front-end apps wi

If any placeholders have no matching environment variable, the server still writes the file (substituting empty strings for missing values) and logs an error listing the unresolved variables.

**Important:** Since the static-server image runs as `nonroot:nonroot`, the config file must be writable by the `nonroot` user. Use `COPY --chown=nonroot:nonroot` when copying the config file in your Dockerfile (see [example](#1-build-a-docker-image) below).

#### Example

Given a `config.json` template:
Expand Down Expand Up @@ -60,7 +62,9 @@ FROM zopdev/static-server:v0.0.8
COPY /app/out /static

# Set the path to the config file for environment variable hydration at startup
# The config file must be writable by nonroot for hydration to work
ENV CONFIG_FILE_PATH=/static/config.json
COPY --chown=nonroot:nonroot /app/out/config.json $CONFIG_FILE_PATH

# The server listens on port 8000 by default; set HTTP_PORT to change it

Expand Down
83 changes: 36 additions & 47 deletions internal/config/hydrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -13,30 +12,30 @@ import (
"gofr.dev/pkg/gofr/logging"
)

func writeTempFile(t *testing.T, fs file.FileSystem, content string) string {
func writeTempFile(t *testing.T, content string, permissions os.FileMode) string {
t.Helper()

dir := t.TempDir()
path := filepath.Join(dir, "config.json")

f, err := fs.Create(path)
err := os.WriteFile(path, []byte(content), 0644)
require.NoError(t, err)

_, err = f.Write([]byte(content))
require.NoError(t, err)

require.NoError(t, f.Close())
if permissions != 0 {
require.NoError(t, os.Chmod(path, permissions))
}

return path
}

func TestConfig(t *testing.T) {
tests := []struct {
name string
template string
vars map[string]string
expected string
wantErr error
name string
template string
vars map[string]string
permissions os.FileMode
expected string
wantErr error
}{
{
name: "all vars present",
Expand All @@ -45,9 +44,9 @@ func TestConfig(t *testing.T) {
expected: `{"a":"1","b":"2"}`,
},
{
name: "no config path is a no-op",
vars: map[string]string{},
wantErr: nil,
name: "no config path is a no-op",
template: `{"a":"${A}","b":"${B}"}`,
vars: map[string]string{"CONFIG_FILE_PATH": ""},
},
{
name: "extra vars ignored",
Expand All @@ -70,52 +69,42 @@ func TestConfig(t *testing.T) {
wantErr: errMissingVars,
},
{
name: "invalid config path",
vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"},
wantErr: errReadConfig,
name: "invalid config path",
template: `{"a":"${A}"}`,
vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"},
wantErr: errReadConfig,
},
{
name: "write error on read-only file",
template: `{"a":"${A}"}`,
vars: map[string]string{"A": "1"},
permissions: 0444,
wantErr: errWriteConfig,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := file.New(logging.NewMockLogger(logging.ERROR))

if tt.template != "" {
path := writeTempFile(t, fs, tt.template)
tt.vars[filePathVar] = path
// To not overwrite the file path if already present in the test case
if _, ok := tt.vars[filePathVar]; !ok {
tt.vars[filePathVar] = writeTempFile(t, tt.template, tt.permissions)
}

err := HydrateFile(fs, config.NewMockConfig(tt.vars))

require.ErrorIs(t, err, tt.wantErr)

if tt.expected != "" {
rf, readErr := fs.Open(tt.vars[filePathVar])
require.NoError(t, readErr)
got, readErr := io.ReadAll(rf)
require.NoError(t, readErr)
require.Equal(t, tt.expected, string(got))
if tt.vars[filePathVar] == "" || tt.wantErr != nil {
return
}
})
}
}

func TestHydrateFile_WriteError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod not effective on Windows")
}

fs := file.New(logging.NewMockLogger(logging.ERROR))
path := writeTempFile(t, fs, `{"a":"${A}"}`)

err := os.Chmod(path, 0444)
require.NoError(t, err)

vars := map[string]string{
filePathVar: path,
"A": "1",
rf, readErr := os.Open(tt.vars[filePathVar])
require.NoError(t, readErr)
got, readErr := io.ReadAll(rf)
require.NoError(t, readErr)
require.Equal(t, tt.expected, string(got))
})
}

err = HydrateFile(fs, config.NewMockConfig(vars))
require.ErrorIs(t, err, errWriteConfig)
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func main() {

app.OnStart(func(ctx *gofr.Context) error {
if err := config.HydrateFile(ctx.File, app.Config); err != nil {
ctx.Logger.Error(err)
ctx.Logger.Error(err.Error())
}

return nil
Expand Down