From c94915b3607fdaedc4c0fc0f7bc8b59d4eaa44ad Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:28:42 +0530 Subject: [PATCH 01/17] Add multi-platform Docker image support (amd64 + arm64) Enable native arm64 support for Apple Silicon users by building multi-architecture images via buildx. Uses TARGETARCH for explicit Go cross-compilation. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 1 + Dockerfile | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index ac7802c..850e208 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -84,6 +84,7 @@ jobs: push: true context: . file: Dockerfile + platforms: linux/amd64,linux/arm64 provenance: true sbom: true cache-from: type=gha diff --git a/Dockerfile b/Dockerfile index af9b5cd..6b08421 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,9 @@ RUN go mod download # Copy source code (this layer changes frequently) COPY . . -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/main main.go + +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/main main.go # Final stage - distroless FROM gcr.io/distroless/static-debian12 From 24120979ec836aeae61d9533db2e982020c91286 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:29:13 +0530 Subject: [PATCH 02/17] Update README.md with tag v0.0.7 Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c418cc2..fe46474 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ To deploy the server, you need to build a Docker image using the provided `Docke ```dockerfile # Use the official static-server image as the base image # This will pull the prebuilt version of the static-server to run your static website -FROM zopdev/static-server:v0.0.6 +FROM zopdev/static-server:v0.0.7 # Copy static files into the container # The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory From aa0c39232c03491e1041396f02d6f9a76b84b523 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:37:58 +0530 Subject: [PATCH 03/17] Add QEMU setup for multi-platform Docker builds Required for arm64 emulation on amd64 runners. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 850e208..3d4896c 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -69,6 +69,8 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 From ecb09f791cace98229aeb06e2bfa9f99a729cc58 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 11:50:44 +0530 Subject: [PATCH 04/17] Use cross-compilation for multi-platform Docker builds Replace QEMU emulation with native cross-compilation by adding --platform=$BUILDPLATFORM to the build stage. Go cross-compiles to the target architecture natively, eliminating slow emulation. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 2 -- Dockerfile | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 3d4896c..850e208 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -69,8 +69,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 diff --git a/Dockerfile b/Dockerfile index 6b08421..8f1ed72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Build stage -FROM golang:1.26 AS build +# Build stage - runs on native platform, cross-compiles to target +FROM --platform=$BUILDPLATFORM golang:1.26 AS build WORKDIR /src From 3449b51b4db54fa74414ba6541a8cb2414556758 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 27 Feb 2026 12:04:59 +0530 Subject: [PATCH 05/17] Update GitHub Actions to latest versions and add image output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout v4 → v6 - actions/setup-go v4 → v6 (caching now enabled by default) - golangci/golangci-lint-action v8 → v9 - Add step to output pushed image registry path Co-Authored-By: Claude Opus 4.5 --- .github/workflows/go.yaml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 850e208..6316773 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -12,11 +12,10 @@ jobs: runs-on: ubuntu-latest name: Test and Build steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' - cache: true - name: Test run: go test ./... @@ -24,12 +23,11 @@ jobs: name: Code Quality🎖️ runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' - cache: true - - uses: golangci/golangci-lint-action@v8 + - uses: golangci/golangci-lint-action@v9 with: version: v2.10.1 args: --timeout 9m0s @@ -67,7 +65,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v3 @@ -91,3 +89,6 @@ jobs: cache-to: type=gha,mode=max tags: | ${{ vars.DOCKER_REGISTRY_TARGET }}:${{ env.RELEASE_VERSION }} + + - name: Output Image Path + run: echo "Image pushed to ${{ vars.DOCKER_REGISTRY_TARGET }}:${{ env.RELEASE_VERSION }}" From 519197faf980d57ccb296e26d5ec3b7dd17d61d7 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Thu, 5 Mar 2026 17:37:57 +0530 Subject: [PATCH 06/17] Added support for env hydration --- README.md | 35 +++++++++++++++++++++- main.go | 40 +++++++++++++++++++++++++ main_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe46474..9f0e608 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,36 @@ A simple and efficient solution for serving static files. - The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable. - Support all the confgs of the gofr framework - https://gofr.dev +## Config File Hydration + +When the `CONFIG_FILE_PATH` environment variable is set, the server replaces any `${VAR}` placeholders in that file at startup using values from the environment (including `.env` files). The file is rewritten in-place before serving begins. + +This is useful for injecting runtime configuration into static front-end apps without rebuilding them. + +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. + +#### Example + +Given a `config.json` template: + +```json +{ + "clientId": "${GOOGLE_CLIENT_ID}", + "apiUrl": "${API_BASE_URL}" +} +``` + +If `GOOGLE_CLIENT_ID=abc123` and `API_BASE_URL=https://api.example.com` are set, the file becomes: + +```json +{ + "clientId": "abc123", + "apiUrl": "https://api.example.com" +} +``` + +> See the [example Dockerfile](#1-build-a-docker-image) below for how to set `CONFIG_FILE_PATH`. + ## Usage ### 1. Build a Docker image @@ -22,13 +52,16 @@ To deploy the server, you need to build a Docker image using the provided `Docke ```dockerfile # Use the official static-server image as the base image # This will pull the prebuilt version of the static-server to run your static website -FROM zopdev/static-server:v0.0.7 +FROM zopdev/static-server:v0.0.8 # Copy static files into the container # The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory # which is where the static server expects to find the files to serve COPY /app/out /static +# Set the path to the config file for environment variable hydration at startup +ENV CONFIG_FILE_PATH=/static/config.json + # Expose the port on which the server will run # By default, the server listens on port 8000, so we expose that port to allow access from outside the container EXPOSE 8000 diff --git a/main.go b/main.go index f510abe..fb7778b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "strings" "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/config" ) const defaultStaticFilePath = `./static` @@ -15,9 +17,47 @@ const indexHTML = "/index.html" const htmlExtension = ".html" const rootPath = "/" +func hydrateConfig(cfg config.Config) error { + configPath := cfg.Get("CONFIG_FILE_PATH") + if configPath == "" { + return nil + } + + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Hydrate with available vars + result := os.Expand(string(content), cfg.Get) + + if err := os.WriteFile(configPath, []byte(result), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + // Detect vars that were missing (replaced with empty string) + re := regexp.MustCompile(`\$\{(\w+)\}`) + matches := re.FindAllStringSubmatch(string(content), -1) + var missing []string + for _, m := range matches { + if cfg.Get(m[1]) == "" { + missing = append(missing, m[1]) + } + } + if len(missing) > 0 { + return fmt.Errorf("missing config variables: %v", missing) + } + + return nil +} + func main() { app := gofr.New() + if err := hydrateConfig(app.Config); err != nil { + app.Logger().Error(err) + } + staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath) app.UseMiddleware(func(h http.Handler) http.Handler { diff --git a/main_test.go b/main_test.go index a85a037..82afacb 100644 --- a/main_test.go +++ b/main_test.go @@ -7,8 +7,92 @@ import ( "path/filepath" "testing" "time" + + "gofr.dev/pkg/gofr/config" ) +func TestHydrateConfig(t *testing.T) { + tests := []struct { + name string + template string + vars map[string]string + expected string + wantErr bool + }{ + { + name: "hydrates config in-place", + template: `{"a":"${A}","b":"${B}"}`, + vars: map[string]string{"A": "1", "B": "2"}, + expected: `{"a":"1","b":"2"}`, + wantErr: false, + }, + { + name: "no-op when config path empty", + vars: map[string]string{}, + wantErr: false, + }, + { + name: "extra config vars not in template", + template: `{"a":"${A}"}`, + vars: map[string]string{"A": "1", "EXTRA": "x"}, + expected: `{"a":"1"}`, + wantErr: false, + }, + { + name: "some template vars missing", + template: `{"a":"${A}","b":"${MISSING}"}`, + vars: map[string]string{"A": "1"}, + expected: `{"a":"1","b":""}`, + wantErr: true, + }, + { + name: "all template vars missing", + template: `{"a":"${X}","b":"${Y}"}`, + vars: map[string]string{}, + expected: `{"a":"","b":""}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vars := make(map[string]string) + for k, v := range tt.vars { + vars[k] = v + } + + if tt.template != "" { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.json") + if err := os.WriteFile(configFile, []byte(tt.template), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + vars["CONFIG_FILE_PATH"] = configFile + } + + cfg := config.NewMockConfig(vars) + err := hydrateConfig(cfg) + + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.template != "" { + output, err := os.ReadFile(vars["CONFIG_FILE_PATH"]) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + if string(output) != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, string(output)) + } + } + }) + } +} + func TestServer(t *testing.T) { // Create a temporary directory //nolint:staticcheck // Ignore as we are testing the server From 18fa6b99df84d5984c8e6a6ad157f547c7db100a Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Thu, 5 Mar 2026 17:42:25 +0530 Subject: [PATCH 07/17] Updated README.md --- README.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9f0e608..095d8d9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A simple and efficient solution for serving static files. - **Lightweight**: Minimal dependencies for optimal performance. - **Configurable**: You can easily configure the server or extend it based on your needs. - The server serves files from the `static` directory by default, but you can change this by setting the `STATIC_DIR_PATH` environment variable. - - Support all the confgs of the gofr framework - https://gofr.dev + - Support all the configs of the gofr framework - https://gofr.dev ## Config File Hydration @@ -55,20 +55,17 @@ To deploy the server, you need to build a Docker image using the provided `Docke FROM zopdev/static-server:v0.0.8 # Copy static files into the container -# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/website' directory +# The 'COPY' directive moves your static files (in this case, located at '/app/out') into the '/static' directory # which is where the static server expects to find the files to serve COPY /app/out /static # Set the path to the config file for environment variable hydration at startup ENV CONFIG_FILE_PATH=/static/config.json -# Expose the port on which the server will run -# By default, the server listens on port 8000, so we expose that port to allow access from outside the container -EXPOSE 8000 +# The server listens on port 8000 by default; set HTTP_PORT to change it # Define the command to run the server -# The static server is started with the '/main' binary included in the image, which will start serving -# the files from the '/website' directory on port 8000 +# The static server is started with the '/main' binary included in the image CMD ["/main"] ``` @@ -108,7 +105,7 @@ Your static files will be served, and the root (`/`) will typically display your ## Notes -- The server serves all files in the `website` directory, so make sure to avoid any sensitive files or configuration details in that directory. +- The server serves all files in the `static` directory, so make sure to avoid any sensitive files or configuration details in that directory. ## License From 5c8a53f3909196316bbcba431c9c4d9e0515bae2 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 14:51:12 +0530 Subject: [PATCH 08/17] Code refactor --- go.mod | 8 +- internal/config/hydrate.go | 65 +++++++++++++++ internal/config/hydrate_test.go | 137 ++++++++++++++++++++++++++++++++ main.go | 48 +++-------- main_test.go | 84 -------------------- 5 files changed, 216 insertions(+), 126 deletions(-) create mode 100644 internal/config/hydrate.go create mode 100644 internal/config/hydrate_test.go diff --git a/go.mod b/go.mod index d165ee2..b9457b2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module zop.dev/static-server go 1.25.0 -require gofr.dev v1.54.3 +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 + gofr.dev v1.54.3 +) require ( cloud.google.com/go v0.123.0 // indirect @@ -57,7 +61,6 @@ require ( github.com/redis/go-redis/v9 v9.17.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/kafka-go v0.4.50 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -76,7 +79,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go new file mode 100644 index 0000000..c41b523 --- /dev/null +++ b/internal/config/hydrate.go @@ -0,0 +1,65 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" +) + +var ( + ErrMissingVars = errors.New("missing config variables") + ErrReadConfig = errors.New("failed to read config file") + ErrWriteConfig = errors.New("failed to write config file") + ErrMissingFile = errors.New("file not found") + + envVarRe = regexp.MustCompile(`\$\{(\w+)\}`) +) + +const filePathVar = "CONFIG_FILE_PATH" + +func HydrateFile(fs file.FileSystem, cfg config.Config) error { + configPath := cfg.Get(filePathVar) + if configPath == "" { + return nil + } + + configFile, err := fs.Open(filepath.Clean(configPath)) + if err != nil { + return fmt.Errorf("%w: %w", ErrReadConfig, err) + } + + content, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("%w: %w", ErrReadConfig, err) + } + + // Hydrate with available vars + result := os.Expand(string(content), cfg.Get) + + if _, err = configFile.Write([]byte(result)); err != nil { + return fmt.Errorf("%w: %w", ErrWriteConfig, err) + } + + // Detect vars that were missing (replaced with empty string) + matches := envVarRe.FindAllStringSubmatch(string(content), -1) + + var missing []string + + for _, m := range matches { + if cfg.Get(m[1]) == "" { + missing = append(missing, m[1]) + } + } + + if len(missing) > 0 { + return fmt.Errorf("%w: %v", ErrMissingVars, missing) + } + + return nil +} diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go new file mode 100644 index 0000000..1d8945b --- /dev/null +++ b/internal/config/hydrate_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/datasource/file" +) + +var ( + errDiskFailure = errors.New("disk failure") + errDiskFull = errors.New("disk full") +) + +func TestConfig(t *testing.T) { + tests := []struct { + name string + template string + vars map[string]string + expected string + wantErr error + }{ + { + name: "all vars present", + template: `{"a":"${A}","b":"${B}"}`, + vars: map[string]string{"A": "1", "B": "2"}, + expected: `{"a":"1","b":"2"}`, + }, + { + name: "no config path is a no-op", + vars: map[string]string{}, + wantErr: nil, + }, + { + name: "extra vars ignored", + template: `{"a":"${A}"}`, + vars: map[string]string{"A": "1", "EXTRA": "x"}, + expected: `{"a":"1"}`, + }, + { + name: "partial vars missing", + template: `{"a":"${A}","b":"${MISSING}"}`, + vars: map[string]string{"A": "1"}, + expected: `{"a":"1","b":""}`, + wantErr: ErrMissingVars, + }, + { + name: "all vars missing", + template: `{"a":"${X}","b":"${Y}"}`, + vars: map[string]string{}, + expected: `{"a":"","b":""}`, + wantErr: ErrMissingVars, + }, + { + name: "invalid config path", + vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, + wantErr: ErrReadConfig, + }, + { + name: "read error", + template: "read-error", + vars: map[string]string{}, + wantErr: ErrReadConfig, + }, + { + name: "write error", + template: "write-error", + vars: map[string]string{}, + wantErr: ErrWriteConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockFS := file.NewMockFileSystem(ctrl) + + gotOutput := setupMocks(t, ctrl, mockFS, tt.template, tt.vars) + + err := HydrateFile(mockFS, config.NewMockConfig(tt.vars)) + + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, tt.expected, string(gotOutput())) + }) + } +} + +func setupMocks( + t *testing.T, ctrl *gomock.Controller, mockFS *file.MockFileSystem, + template string, vars map[string]string, +) func() []byte { + t.Helper() + + var output []byte + + switch { + case template != "": + vars[filePathVar] = "/mock/config.json" + mockFile := file.NewMockFile(ctrl) + mockFS.EXPECT().Open(vars[filePathVar]).Return(mockFile, nil) + + setupReadWrite(mockFile, template, &output) + + case vars[filePathVar] != "": + mockFS.EXPECT().Open(vars[filePathVar]).Return(nil, ErrMissingFile) + } + + return func() []byte { return output } +} + +func setupReadWrite(mockFile *file.MockFile, template string, output *[]byte) { + if template == "read-error" { + mockFile.EXPECT().Read(gomock.Any()).Return(0, errDiskFailure) + return + } + + templateBytes := []byte(template) + + mockFile.EXPECT().Read(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { + n := copy(p, templateBytes) + return n, io.EOF + }) + + if template == "write-error" { + mockFile.EXPECT().Write(gomock.Any()).Return(0, errDiskFull) + return + } + + mockFile.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { + *output = append([]byte{}, p...) + return len(p), nil + }) +} diff --git a/main.go b/main.go index fb7778b..249a62c 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "net/http" "os" "path/filepath" @@ -9,7 +8,8 @@ import ( "strings" "gofr.dev/pkg/gofr" - "gofr.dev/pkg/gofr/config" + + "zop.dev/static-server/internal/config" ) const defaultStaticFilePath = `./static` @@ -17,46 +17,16 @@ const indexHTML = "/index.html" const htmlExtension = ".html" const rootPath = "/" -func hydrateConfig(cfg config.Config) error { - configPath := cfg.Get("CONFIG_FILE_PATH") - if configPath == "" { - return nil - } - - content, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - // Hydrate with available vars - result := os.Expand(string(content), cfg.Get) - - if err := os.WriteFile(configPath, []byte(result), 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - // Detect vars that were missing (replaced with empty string) - re := regexp.MustCompile(`\$\{(\w+)\}`) - matches := re.FindAllStringSubmatch(string(content), -1) - var missing []string - for _, m := range matches { - if cfg.Get(m[1]) == "" { - missing = append(missing, m[1]) - } - } - if len(missing) > 0 { - return fmt.Errorf("missing config variables: %v", missing) - } - - return nil -} - func main() { app := gofr.New() - if err := hydrateConfig(app.Config); err != nil { - app.Logger().Error(err) - } + app.OnStart(func(ctx *gofr.Context) error { + if err := config.HydrateFile(ctx.File, app.Config); err != nil { + ctx.Logger.Error(err) + } + + return nil + }) staticFilePath := app.Config.GetOrDefault("STATIC_DIR_PATH", defaultStaticFilePath) diff --git a/main_test.go b/main_test.go index 82afacb..a85a037 100644 --- a/main_test.go +++ b/main_test.go @@ -7,92 +7,8 @@ import ( "path/filepath" "testing" "time" - - "gofr.dev/pkg/gofr/config" ) -func TestHydrateConfig(t *testing.T) { - tests := []struct { - name string - template string - vars map[string]string - expected string - wantErr bool - }{ - { - name: "hydrates config in-place", - template: `{"a":"${A}","b":"${B}"}`, - vars: map[string]string{"A": "1", "B": "2"}, - expected: `{"a":"1","b":"2"}`, - wantErr: false, - }, - { - name: "no-op when config path empty", - vars: map[string]string{}, - wantErr: false, - }, - { - name: "extra config vars not in template", - template: `{"a":"${A}"}`, - vars: map[string]string{"A": "1", "EXTRA": "x"}, - expected: `{"a":"1"}`, - wantErr: false, - }, - { - name: "some template vars missing", - template: `{"a":"${A}","b":"${MISSING}"}`, - vars: map[string]string{"A": "1"}, - expected: `{"a":"1","b":""}`, - wantErr: true, - }, - { - name: "all template vars missing", - template: `{"a":"${X}","b":"${Y}"}`, - vars: map[string]string{}, - expected: `{"a":"","b":""}`, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - vars := make(map[string]string) - for k, v := range tt.vars { - vars[k] = v - } - - if tt.template != "" { - dir := t.TempDir() - configFile := filepath.Join(dir, "config.json") - if err := os.WriteFile(configFile, []byte(tt.template), 0644); err != nil { - t.Fatalf("failed to write config: %v", err) - } - vars["CONFIG_FILE_PATH"] = configFile - } - - cfg := config.NewMockConfig(vars) - err := hydrateConfig(cfg) - - if tt.wantErr && err == nil { - t.Fatal("expected error, got nil") - } - if !tt.wantErr && err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if tt.template != "" { - output, err := os.ReadFile(vars["CONFIG_FILE_PATH"]) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - if string(output) != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, string(output)) - } - } - }) - } -} - func TestServer(t *testing.T) { // Create a temporary directory //nolint:staticcheck // Ignore as we are testing the server From fb75fc45f79df9932c2a4e4621eb77a48932576b Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 16:20:28 +0530 Subject: [PATCH 09/17] Fixed implementation --- internal/config/hydrate.go | 9 ++- internal/config/hydrate_test.go | 102 ++++++++++++-------------------- 2 files changed, 47 insertions(+), 64 deletions(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index c41b523..fa826af 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -39,10 +39,17 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { return fmt.Errorf("%w: %w", ErrReadConfig, err) } + configFile.Close() + // Hydrate with available vars result := os.Expand(string(content), cfg.Get) - if _, err = configFile.Write([]byte(result)); err != nil { + wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return fmt.Errorf("%w: %w", ErrWriteConfig, err) + } + + if _, err = wf.Write([]byte(result)); err != nil { return fmt.Errorf("%w: %w", ErrWriteConfig, err) } diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 1d8945b..784cd5d 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -1,20 +1,28 @@ package config import ( - "errors" - "io" + "os" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" "gofr.dev/pkg/gofr/config" "gofr.dev/pkg/gofr/datasource/file" + "gofr.dev/pkg/gofr/logging" ) -var ( - errDiskFailure = errors.New("disk failure") - errDiskFull = errors.New("disk full") -) +func writeTempFile(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + return path +} func TestConfig(t *testing.T) { tests := []struct { @@ -60,78 +68,46 @@ func TestConfig(t *testing.T) { vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, wantErr: ErrReadConfig, }, - { - name: "read error", - template: "read-error", - vars: map[string]string{}, - wantErr: ErrReadConfig, - }, - { - name: "write error", - template: "write-error", - vars: map[string]string{}, - wantErr: ErrWriteConfig, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - mockFS := file.NewMockFileSystem(ctrl) + fs := file.New(logging.NewMockLogger(logging.ERROR)) - gotOutput := setupMocks(t, ctrl, mockFS, tt.template, tt.vars) + if tt.template != "" { + path := writeTempFile(t, tt.template) + tt.vars[filePathVar] = path + } - err := HydrateFile(mockFS, config.NewMockConfig(tt.vars)) + err := HydrateFile(fs, config.NewMockConfig(tt.vars)) require.ErrorIs(t, err, tt.wantErr) - require.Equal(t, tt.expected, string(gotOutput())) - }) - } -} - -func setupMocks( - t *testing.T, ctrl *gomock.Controller, mockFS *file.MockFileSystem, - template string, vars map[string]string, -) func() []byte { - t.Helper() - var output []byte - - switch { - case template != "": - vars[filePathVar] = "/mock/config.json" - mockFile := file.NewMockFile(ctrl) - mockFS.EXPECT().Open(vars[filePathVar]).Return(mockFile, nil) - - setupReadWrite(mockFile, template, &output) - - case vars[filePathVar] != "": - mockFS.EXPECT().Open(vars[filePathVar]).Return(nil, ErrMissingFile) + if tt.expected != "" { + got, readErr := os.ReadFile(tt.vars[filePathVar]) + require.NoError(t, readErr) + require.Equal(t, tt.expected, string(got)) + } + }) } - - return func() []byte { return output } } -func setupReadWrite(mockFile *file.MockFile, template string, output *[]byte) { - if template == "read-error" { - mockFile.EXPECT().Read(gomock.Any()).Return(0, errDiskFailure) - return +func TestHydrateFile_WriteError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod not effective on Windows") } - templateBytes := []byte(template) + path := writeTempFile(t, `{"a":"${A}"}`) - mockFile.EXPECT().Read(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { - n := copy(p, templateBytes) - return n, io.EOF - }) + err := os.Chmod(path, 0444) + require.NoError(t, err) - if template == "write-error" { - mockFile.EXPECT().Write(gomock.Any()).Return(0, errDiskFull) - return + fs := file.New(logging.NewMockLogger(logging.ERROR)) + vars := map[string]string{ + filePathVar: path, + "A": "1", } - mockFile.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { - *output = append([]byte{}, p...) - return len(p), nil - }) + err = HydrateFile(fs, config.NewMockConfig(vars)) + require.ErrorIs(t, err, ErrWriteConfig) } From d54c4e368c13c21e9a4b702805b060a56643d338 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 16:34:46 +0530 Subject: [PATCH 10/17] Fixed linter issues --- internal/config/hydrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index fa826af..2af836b 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -39,7 +39,7 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { return fmt.Errorf("%w: %w", ErrReadConfig, err) } - configFile.Close() + _ = configFile.Close() // Hydrate with available vars result := os.Expand(string(content), cfg.Get) From 8f96c8e8c17c405e437f840666527117ca5c091b Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Fri, 6 Mar 2026 17:00:33 +0530 Subject: [PATCH 11/17] Converted fields to unexported --- internal/config/hydrate.go | 17 ++++++++--------- internal/config/hydrate_test.go | 8 ++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index 2af836b..4f62518 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -13,10 +13,9 @@ import ( ) var ( - ErrMissingVars = errors.New("missing config variables") - ErrReadConfig = errors.New("failed to read config file") - ErrWriteConfig = errors.New("failed to write config file") - ErrMissingFile = errors.New("file not found") + errMissingVars = errors.New("missing config variables") + errReadConfig = errors.New("failed to read config file") + errWriteConfig = errors.New("failed to write config file") envVarRe = regexp.MustCompile(`\$\{(\w+)\}`) ) @@ -31,12 +30,12 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { configFile, err := fs.Open(filepath.Clean(configPath)) if err != nil { - return fmt.Errorf("%w: %w", ErrReadConfig, err) + return fmt.Errorf("%w: %w", errReadConfig, err) } content, err := io.ReadAll(configFile) if err != nil { - return fmt.Errorf("%w: %w", ErrReadConfig, err) + return fmt.Errorf("%w: %w", errReadConfig, err) } _ = configFile.Close() @@ -46,11 +45,11 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { wf, err := fs.OpenFile(configPath, os.O_WRONLY|os.O_TRUNC, 0) if err != nil { - return fmt.Errorf("%w: %w", ErrWriteConfig, err) + return fmt.Errorf("%w: %w", errWriteConfig, err) } if _, err = wf.Write([]byte(result)); err != nil { - return fmt.Errorf("%w: %w", ErrWriteConfig, err) + return fmt.Errorf("%w: %w", errWriteConfig, err) } // Detect vars that were missing (replaced with empty string) @@ -65,7 +64,7 @@ func HydrateFile(fs file.FileSystem, cfg config.Config) error { } if len(missing) > 0 { - return fmt.Errorf("%w: %v", ErrMissingVars, missing) + return fmt.Errorf("%w: %v", errMissingVars, missing) } return nil diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 784cd5d..743ba07 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -54,19 +54,19 @@ func TestConfig(t *testing.T) { template: `{"a":"${A}","b":"${MISSING}"}`, vars: map[string]string{"A": "1"}, expected: `{"a":"1","b":""}`, - wantErr: ErrMissingVars, + wantErr: errMissingVars, }, { name: "all vars missing", template: `{"a":"${X}","b":"${Y}"}`, vars: map[string]string{}, expected: `{"a":"","b":""}`, - wantErr: ErrMissingVars, + wantErr: errMissingVars, }, { name: "invalid config path", vars: map[string]string{"CONFIG_FILE_PATH": "/no/such/file"}, - wantErr: ErrReadConfig, + wantErr: errReadConfig, }, } @@ -109,5 +109,5 @@ func TestHydrateFile_WriteError(t *testing.T) { } err = HydrateFile(fs, config.NewMockConfig(vars)) - require.ErrorIs(t, err, ErrWriteConfig) + require.ErrorIs(t, err, errWriteConfig) } From 46b3f7a3343609b5859caac4b3629afa1017de9d Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Sat, 7 Mar 2026 14:25:06 +0530 Subject: [PATCH 12/17] Updated tests --- internal/config/hydrate_test.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 743ba07..81fb99a 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -1,6 +1,7 @@ package config import ( + "io" "os" "path/filepath" "runtime" @@ -12,15 +13,20 @@ import ( "gofr.dev/pkg/gofr/logging" ) -func writeTempFile(t *testing.T, content string) string { +func writeTempFile(t *testing.T, fs file.FileSystem, content string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.json") - err := os.WriteFile(path, []byte(content), 0644) + f, err := fs.Create(path) require.NoError(t, err) + _, err = f.Write([]byte(content)) + require.NoError(t, err) + + require.NoError(t, f.Close()) + return path } @@ -75,7 +81,7 @@ func TestConfig(t *testing.T) { fs := file.New(logging.NewMockLogger(logging.ERROR)) if tt.template != "" { - path := writeTempFile(t, tt.template) + path := writeTempFile(t, fs, tt.template) tt.vars[filePathVar] = path } @@ -84,7 +90,9 @@ func TestConfig(t *testing.T) { require.ErrorIs(t, err, tt.wantErr) if tt.expected != "" { - got, readErr := os.ReadFile(tt.vars[filePathVar]) + 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)) } @@ -97,12 +105,11 @@ func TestHydrateFile_WriteError(t *testing.T) { t.Skip("chmod not effective on Windows") } - path := writeTempFile(t, `{"a":"${A}"}`) + fs := file.New(logging.NewMockLogger(logging.ERROR)) + path := writeTempFile(t, fs, `{"a":"${A}"}`) err := os.Chmod(path, 0444) require.NoError(t, err) - - fs := file.New(logging.NewMockLogger(logging.ERROR)) vars := map[string]string{ filePathVar: path, "A": "1", From ff25bad4c7c3d2950f24ad25687c0fb1c2ae63fb Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Sat, 7 Mar 2026 14:40:50 +0530 Subject: [PATCH 13/17] Fixed linter issue --- internal/config/hydrate_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 81fb99a..613e3f2 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -110,6 +110,7 @@ func TestHydrateFile_WriteError(t *testing.T) { err := os.Chmod(path, 0444) require.NoError(t, err) + vars := map[string]string{ filePathVar: path, "A": "1", From 43c00efa5abd852568d787914df8620faa494d25 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Mon, 9 Mar 2026 14:38:50 +0530 Subject: [PATCH 14/17] Added go doc for HydrateFile --- internal/config/hydrate.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/config/hydrate.go b/internal/config/hydrate.go index 4f62518..69fb91e 100644 --- a/internal/config/hydrate.go +++ b/internal/config/hydrate.go @@ -22,6 +22,18 @@ var ( const filePathVar = "CONFIG_FILE_PATH" +// HydrateFile reads the file at the path specified by the CONFIG_FILE_PATH config +// variable, substitutes every ${VAR} placeholder with the corresponding value +// obtained from cfg, and writes the result back to the same file. +// +// If CONFIG_FILE_PATH is not set (empty string), HydrateFile is a no-op and +// returns nil. If any referenced variable is not present in the config, an +// errMissingVars error is returned after the file has been written. +// +// cfg is accepted as a config.Config interface rather than as individual values +// so that the set of variables resolved is open-ended: the file may reference +// any variable, and HydrateFile resolves each one dynamically via cfg.Get +// without the caller needing to know which variables the file contains. func HydrateFile(fs file.FileSystem, cfg config.Config) error { configPath := cfg.Get(filePathVar) if configPath == "" { From b24eb4833515fd5ee0b78c2557ead9db3a134327 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Wed, 11 Mar 2026 12:44:45 +0530 Subject: [PATCH 15/17] Updated tests --- internal/config/hydrate_test.go | 82 +++++++++++++++------------------ 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 613e3f2..08277b4 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -4,7 +4,6 @@ import ( "io" "os" "path/filepath" - "runtime" "testing" "github.com/stretchr/testify/require" @@ -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", @@ -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", @@ -70,9 +69,17 @@ 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, }, } @@ -80,42 +87,25 @@ func TestConfig(t *testing.T) { 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}"}`) + 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 := os.Chmod(path, 0444) - require.NoError(t, err) - - vars := map[string]string{ - filePathVar: path, - "A": "1", + }) } - - err = HydrateFile(fs, config.NewMockConfig(vars)) - require.ErrorIs(t, err, errWriteConfig) } From 94e6a66c6091c0474155572ca88971404c7a0ba6 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Wed, 11 Mar 2026 12:58:53 +0530 Subject: [PATCH 16/17] Updated README.md and error logging --- README.md | 4 ++++ main.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 095d8d9..467e26f 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/main.go b/main.go index 249a62c..ed76816 100644 --- a/main.go +++ b/main.go @@ -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 From 3c4a1b8f79277931ca591bae020b8162338131d5 Mon Sep 17 00:00:00 2001 From: Akshat Singhal Date: Wed, 11 Mar 2026 13:07:17 +0530 Subject: [PATCH 17/17] Fixed linter issue --- internal/config/hydrate_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/config/hydrate_test.go b/internal/config/hydrate_test.go index 08277b4..f8906cb 100644 --- a/internal/config/hydrate_test.go +++ b/internal/config/hydrate_test.go @@ -105,7 +105,6 @@ func TestConfig(t *testing.T) { got, readErr := io.ReadAll(rf) require.NoError(t, readErr) require.Equal(t, tt.expected, string(got)) - }) } }