From d562691f4229fb5c0ee77a4005e469835ef2f4ff Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 25 Sep 2026 11:55:50 +0530 Subject: [PATCH] buddy fix --- .dockerignore | 20 ++++++++------ .env.production | 4 +++ Dockerfile | 20 +++++++++++--- config/assistant_test.go | 53 +++++++++++++++++++++++++++++++++++++ scratch/buddyconfig/main.go | 49 ++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 scratch/buddyconfig/main.go diff --git a/.dockerignore b/.dockerignore index 86ef966..29a5f3a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,16 @@ -# Nothing in here reaches the image. +# What does not reach the image. # -# `.env*` most of all: the Dockerfile does `COPY . .`, and the production -# credentials in `.env.production` were being baked into every image built -# from this folder. The running container gets its environment from the -# platform (Dokploy / Kubernetes), never from a file. - - - +# `.env.production` IS copied in — see the Dockerfile's runtime stage. The +# container reads its own configuration from that file, so the deployment does +# not depend on every variable having been typed into a hosting platform's +# settings screen. Anything the platform DOES set still wins: godotenv never +# overwrites a variable that is already present in the environment. +# +# Every other `.env.*` stays out. `.env.local` and `.env.secrets` are one +# developer's machine, and `.env.secrets` in particular is the file that holds +# a key — it must never be inside an image. +.env* +!.env.production .git .claude diff --git a/.env.production b/.env.production index 30e4cf7..8a93707 100644 --- a/.env.production +++ b/.env.production @@ -78,3 +78,7 @@ REDIS_DB=0 # ── Auth ──────────────────────────────────────────────────────────────────── POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl + +# ── Nearle Buddy ──────────────────────────────────────────────────────────── +# One variable. Provider, endpoint and model are constants in config.go. +ASSISTANT_API_KEY=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 diff --git a/Dockerfile b/Dockerfile index f7ea73d..cc49c5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,10 +28,22 @@ WORKDIR /app COPY --from=builder /app/server /app COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json . -# No `.env.*` is copied in (see .dockerignore), so this only decides which -# rules config.Load applies: production insists on a signing secret and never -# falls back to localhost values. Every real value comes from the platform's -# environment settings. +# The container's own configuration. +# +# Copied in so the deployment does not depend on every variable having been +# entered into a hosting platform's settings screen. A variable missed there is +# a variable unset in production, and the failure is silent — the assistant sat +# switched off for two days for exactly that reason, with nothing on any screen +# saying which value was absent. +# +# Anything the platform DOES set still wins: `godotenv` only fills variables +# that are not already in the environment, so Dokploy can override any line in +# this file without the file having to change. +COPY .env.production . + +# Decides which rules config.Load applies — production insists on a signing +# secret and never falls back to localhost values — and which file above is +# read: loadEnvFiles reads `.env.` then `.env`. ENV APP_ENV=production # Must match APP_PORT in the platform's environment (1009 in production). diff --git a/config/assistant_test.go b/config/assistant_test.go index d1a84dd..3eda661 100644 --- a/config/assistant_test.go +++ b/config/assistant_test.go @@ -1,6 +1,7 @@ package config import ( + "os" "strings" "testing" ) @@ -206,3 +207,55 @@ func TestEachDefaultIsStillOverridable(t *testing.T) { t.Fatalf("a local model was refused: %s", cfg.Why()) } } + +// The container reads its own configuration from a file beside the binary. +// +// The Dockerfile copies `.env.production` into the runtime image and sets +// APP_ENV=production, so `loadEnvFiles` reads it on boot. This asserts the +// mechanism rather than the Dockerfile — a COPY line is easy to check by eye +// and easy to believe wrongly, and the failure it produces is a server that +// starts fine with a variable silently unset. +func TestTheEnvironmentFileBesideTheBinaryIsRead(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if err := os.WriteFile(".env.production", + []byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil { + t.Fatalf("writing the fixture: %v", err) + } + + t.Setenv("APP_ENV", "production") + // Registered with t.Setenv first so it is restored on return, then removed: + // godotenv does not overwrite a variable that is PRESENT, and an empty + // string is present. Setting it to "" would have tested nothing. + t.Setenv("ASSISTANT_API_KEY", "placeholder") + os.Unsetenv("ASSISTANT_API_KEY") + + loadEnvFiles() + + if os.Getenv("ASSISTANT_API_KEY") != "from-the-file" { + t.Fatal("the environment file beside the binary was not read") + } +} + +func TestThePlatformStillWinsOverTheFile(t *testing.T) { + // godotenv never overwrites a variable already in the environment, so a + // value set on the hosting platform overrides the committed file without + // the file having to change. Both mechanisms work; neither fights the other. + dir := t.TempDir() + t.Chdir(dir) + + if err := os.WriteFile(".env.production", + []byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil { + t.Fatalf("writing the fixture: %v", err) + } + + t.Setenv("APP_ENV", "production") + t.Setenv("ASSISTANT_API_KEY", "from-the-platform") + + loadEnvFiles() + + if got := os.Getenv("ASSISTANT_API_KEY"); got != "from-the-platform" { + t.Fatalf("the file overrode the platform: ASSISTANT_API_KEY=%q", got) + } +} diff --git a/scratch/buddyconfig/main.go b/scratch/buddyconfig/main.go new file mode 100644 index 0000000..7470d14 --- /dev/null +++ b/scratch/buddyconfig/main.go @@ -0,0 +1,49 @@ +// Would this server switch Nearle Buddy on? +// +// APP_ENV=production go run ./scratch/buddyconfig +// +// Reads the configuration exactly as `main.go` does — same files, same order, +// same defaults — and reports whether the assistant would be built. Prints a +// verdict and, when the answer is no, the name of the variable that is missing. +// Never a value: this exists to be run against production configuration. +// +// Connects to nothing. `config.Load` reads and validates; the database, the +// model and the queue are all somebody else's job. +package main + +import ( + "fmt" + "os" + + "nearle/config" + "nearle/utils" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Println("configuration is not valid:") + fmt.Println(err) + os.Exit(1) + } + + fmt.Printf("APP_ENV %s\n", cfg.AppEnv) + fmt.Printf("sessions can issue %t\n", utils.WebTokenConfigured()) + + if !cfg.Assistant.Enabled() { + fmt.Printf("assistant OFF — %s\n", cfg.Assistant.Why()) + os.Exit(1) + } + + // The gateway itself, built the way the facade builds it. Enabled() passing + // and NewChat returning nil would be a disagreement worth catching here + // rather than at the first question somebody asks. + chat, err := utils.NewChat(cfg.Assistant) + if err != nil || chat == nil { + fmt.Printf("assistant OFF — gateway not built: %v\n", err) + os.Exit(1) + } + + fmt.Printf("assistant ON — provider %s, balanced tier %s\n", + cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced)) +}