Skip to content

Commit 883b525

Browse files
authored
Merge pull request #10 from IFRCGo/fix/consistency-improvements
Refactor: improve overall consistency
2 parents 7fe8c83 + a5de165 commit 883b525

16 files changed

Lines changed: 2181 additions & 240 deletions

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jobs:
4343
- name: Vet
4444
run: go vet ./...
4545

46+
- name: Test
47+
run: go test ./...
48+
4649
- name: Build
4750
run: go build -trimpath -ldflags="-s -w" -o cacheppuccino .
4851

@@ -84,13 +87,31 @@ jobs:
8487
type=sha,format=short,prefix=0.1.0-c
8588
type=ref,event=tag
8689
90+
- name: Determine build version
91+
id: ver
92+
shell: bash
93+
run: |
94+
set -euo pipefail
95+
96+
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
97+
# Tag format: v0.1.0 -> 0.1.0
98+
VERSION="${GITHUB_REF_NAME#v}"
99+
else
100+
SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
101+
VERSION="0.1.0-c${SHORT_SHA}"
102+
fi
103+
104+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
105+
87106
- name: Build and push docker image
88107
uses: docker/build-push-action@v6
89108
with:
90109
context: .
91110
push: true
92111
tags: ${{ steps.meta.outputs.tags }}
93112
labels: ${{ steps.meta.outputs.labels }}
113+
build-args: |
114+
VERSION=${{ steps.ver.outputs.version }}
94115
cache-from: type=gha
95116
cache-to: type=gha,mode=max
96117
helm:

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# -------- build stage --------
22
FROM golang:1.23 AS build
33

4+
ARG VERSION=dev
5+
46
ENV CGO_ENABLED=0 \
57
GOOS=linux \
68
GOARCH=amd64
@@ -12,7 +14,7 @@ RUN go mod download
1214

1315
COPY . .
1416

15-
RUN go build -trimpath -ldflags="-s -w" -o /out/cacheppuccino .
17+
RUN go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/cacheppuccino .
1618

1719
# -------- runtime stage --------
1820

README.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ It periodically downloads an XLSX export from a translation service, stores the
77
## Features
88

99
- XLSX import from external translation service
10-
- Periodic background sync
10+
- Periodic background sync (full replace per import; the XLSX is the source of truth)
1111
- SQLite-backed cache
1212
- Fetch translations by page(s) + language
13+
- ETag / If-None-Match support on `/strings`
1314
- Consistent JSON response envelope
1415
- OpenAPI 3 schema generation (via kin-openapi)
1516
- Health + readiness endpoints
@@ -81,11 +82,24 @@ curl "http://localhost:8080/strings?pages=home,about&lang=en"
8182

8283
On startup:
8384

84-
1. Performs an initial XLSX pull.
85-
2. If successful → service becomes ready.
85+
1. If the SQLite cache already holds data from a previous run, `/status` reports `ready: true` immediately.
86+
2. Performs an initial XLSX pull. A successful pull also sets `ready: true`.
8687
3. Periodically refreshes based on `PULL_INTERVAL`.
8788

88-
The service avoids re-importing unchanged XLSX files by hashing the downloaded content.
89+
Each import fully replaces the cached strings inside a single transaction, so rows removed
90+
from the XLSX disappear from the cache. The service avoids re-importing unchanged XLSX
91+
files by hashing the downloaded content.
92+
93+
### XLSX format
94+
95+
The export must contain a sheet named `Translations` (falls back to the first sheet), with
96+
a header row of exactly `page | key | <lang> | <lang> | ...` (case-insensitive). Files with
97+
other header names (e.g. `Namespace`) are rejected.
98+
99+
### Response caching
100+
101+
`/strings` responses carry an `ETag` derived from the last import hash and
102+
`Cache-Control: public, max-age=60`. Requests with a matching `If-None-Match` get `304 Not Modified`.
89103

90104

91105
## Environment Variables
@@ -94,7 +108,7 @@ The service avoids re-importing unchanged XLSX files by hashing the downloaded c
94108
|----------|----------|------------|
95109
| `TRANSLATION_BASE_URL` | Yes | Base URL of translation service |
96110
| `TRANSLATION_APPLICATION_ID` | Yes | Translation application ID |
97-
| `TRANSLATION_API_KEY` | No | Sent as `X-API-KEY` header |
111+
| `TRANSLATION_API_KEY` | Yes | Sent as `X-API-KEY` header |
98112
| `SQLITE_PATH` | No | Default: `/data/cacheppuccino.db` |
99113
| `PULL_INTERVAL` | No | Default: `10m` |
100114
| `HTTP_TIMEOUT` | No | Default: `30s` |
@@ -135,7 +149,10 @@ docker compose down -v
135149
## Health Checks
136150

137151
- `GET /healthz` → service running
138-
- `GET /readyz` → initial sync completed
152+
- `GET /readyz` → always `200` while the process is up. Deliberately not gated on data:
153+
the deploy tooling restarts pods that stay unready, and with a single replica there is
154+
no alternative pod to route to. Whether the cache actually holds servable data is
155+
reported as `ready` on `GET /status`.
139156
- Docker healthcheck uses internal `--healthcheck` flag
140157

141158

@@ -159,8 +176,8 @@ go run . --schema
159176
## Database
160177

161178
- SQLite
162-
- WAL mode enabled
163-
- Uses `mode=rwc`
179+
- WAL mode with `synchronous=NORMAL`
180+
- Single connection (`MaxOpenConns=1`)
164181
- Indexed by `(page, lang)`
165182
- Metadata table stores:
166183
- `last_pull_rfc3339`
@@ -175,6 +192,12 @@ go mod tidy
175192
go run .
176193
```
177194

195+
Run tests:
196+
197+
```bash
198+
go test ./...
199+
```
200+
178201
Build binary:
179202

180203
```bash

config.go

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package main
22

33
import (
4-
"log"
4+
"errors"
5+
"fmt"
56
"os"
7+
"slices"
68
"time"
79
)
810

@@ -18,44 +20,72 @@ type Config struct {
1820
LogLevel string
1921
}
2022

21-
func LoadConfig() Config {
22-
return Config{
23+
var validLogLevels = []string{"debug", "info", "warn", "error"}
24+
25+
// LoadConfig reads configuration from the environment.
26+
// It collects every problem instead of stopping at the first one,
27+
// so a misconfigured deployment reports all mistakes at once.
28+
func LoadConfig() (Config, error) {
29+
var errs []error
30+
31+
requireEnv := func(k string) string {
32+
v := os.Getenv(k)
33+
if v == "" {
34+
errs = append(errs, fmt.Errorf("missing required env: %s", k))
35+
}
36+
return v
37+
}
38+
39+
envDuration := func(k string, def time.Duration) time.Duration {
40+
v := os.Getenv(k)
41+
if v == "" {
42+
return def
43+
}
44+
d, err := time.ParseDuration(v)
45+
if err != nil {
46+
errs = append(errs, fmt.Errorf("invalid duration for %s: %q", k, v))
47+
return def
48+
}
49+
return d
50+
}
51+
52+
cfg := Config{
2353
ListenAddr: env("LISTEN_ADDR", ":8080"),
2454
SQLitePath: env("SQLITE_PATH", "/data/cacheppuccino.db"),
25-
TranslationBaseURL: mustEnv("TRANSLATION_BASE_URL"),
26-
TranslationApplicationID: mustEnv("TRANSLATION_APPLICATION_ID"),
27-
TranslationAPIKey: mustEnv("TRANSLATION_API_KEY"),
55+
TranslationBaseURL: requireEnv("TRANSLATION_BASE_URL"),
56+
TranslationApplicationID: requireEnv("TRANSLATION_APPLICATION_ID"),
57+
TranslationAPIKey: requireEnv("TRANSLATION_API_KEY"),
2858
HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second),
2959
PullInterval: envDuration("PULL_INTERVAL", 10*time.Minute),
30-
InitialPullDeadline: envDuration("INITIAL_PULL_DEADLINE", 60*time.Second),
60+
InitialPullDeadline: envDuration("INITIAL_PULL_DEADLINE", 45*time.Second),
3161
LogLevel: env("LOG_LEVEL", "info"),
3262
}
33-
}
3463

35-
func env(k, def string) string {
36-
v := os.Getenv(k)
37-
if v == "" {
38-
return def
64+
if cfg.HTTPTimeout <= 0 {
65+
errs = append(errs, fmt.Errorf("HTTP_TIMEOUT must be positive, got %s", cfg.HTTPTimeout))
66+
}
67+
if cfg.PullInterval <= 0 {
68+
errs = append(errs, fmt.Errorf("PULL_INTERVAL must be positive, got %s", cfg.PullInterval))
69+
}
70+
// Zero means "no deadline" for the initial pull; only negatives are invalid.
71+
if cfg.InitialPullDeadline < 0 {
72+
errs = append(errs, fmt.Errorf("INITIAL_PULL_DEADLINE must not be negative, got %s", cfg.InitialPullDeadline))
3973
}
40-
return v
41-
}
4274

43-
func mustEnv(k string) string {
44-
v := os.Getenv(k)
45-
if v == "" {
46-
log.Fatalf("missing required env: %s", k)
75+
if !slices.Contains(validLogLevels, cfg.LogLevel) {
76+
errs = append(errs, fmt.Errorf("invalid LOG_LEVEL: %q (valid: debug, info, warn, error)", cfg.LogLevel))
4777
}
48-
return v
78+
79+
if len(errs) > 0 {
80+
return Config{}, errors.Join(errs...)
81+
}
82+
return cfg, nil
4983
}
5084

51-
func envDuration(k string, def time.Duration) time.Duration {
85+
func env(k, def string) string {
5286
v := os.Getenv(k)
5387
if v == "" {
5488
return def
5589
}
56-
d, err := time.ParseDuration(v)
57-
if err != nil {
58-
return def
59-
}
60-
return d
90+
return v
6191
}

0 commit comments

Comments
 (0)