buddy fix

This commit is contained in:
2026-09-25 11:55:50 +05:30
parent 276e12beb9
commit d562691f42
5 changed files with 134 additions and 12 deletions

View File

@@ -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 # `.env.production` IS copied in — see the Dockerfile's runtime stage. The
# credentials in `.env.production` were being baked into every image built # container reads its own configuration from that file, so the deployment does
# from this folder. The running container gets its environment from the # not depend on every variable having been typed into a hosting platform's
# platform (Dokploy / Kubernetes), never from a file. # 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 .git
.claude .claude

View File

@@ -78,3 +78,7 @@ REDIS_DB=0
# ── Auth ──────────────────────────────────────────────────────────────────── # ── Auth ────────────────────────────────────────────────────────────────────
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
# ── Nearle Buddy ────────────────────────────────────────────────────────────
# One variable. Provider, endpoint and model are constants in config.go.
ASSISTANT_API_KEY=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

View File

@@ -28,10 +28,22 @@ WORKDIR /app
COPY --from=builder /app/server /app COPY --from=builder /app/server /app
COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json . COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
# No `.env.*` is copied in (see .dockerignore), so this only decides which # The container's own configuration.
# 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 # Copied in so the deployment does not depend on every variable having been
# environment settings. # 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.<APP_ENV>` then `.env`.
ENV APP_ENV=production ENV APP_ENV=production
# Must match APP_PORT in the platform's environment (1009 in production). # Must match APP_PORT in the platform's environment (1009 in production).

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"os"
"strings" "strings"
"testing" "testing"
) )
@@ -206,3 +207,55 @@ func TestEachDefaultIsStillOverridable(t *testing.T) {
t.Fatalf("a local model was refused: %s", cfg.Why()) 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)
}
}

View File

@@ -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))
}