Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions .github/wiki/Config-Custom-Services.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ All variables use the pattern `CS_N_*` where `N` is the slot number (1–10). Va
| `CS_N_HEALTHCHECK` | string | `/health` | Healthcheck override. A path (e.g. `/auth/health`) probes that path instead of `/health` on the service's own port. A full `CMD ...` / `CMD-SHELL ...` command is passed through verbatim (split on whitespace) for services that need curl, a non-HTTP probe, or a different port. `disabled` / `none` / `false` omits the healthcheck entirely. |
| `CS_N_TABLE_PREFIX` | string | *(empty)* | Database table prefix for this service's migrations |
| `CS_N_ENV_PASSTHROUGH` | string | *(empty)* | Comma-separated allowlist of project `.env` var names to forward into this container in addition to the fixed core set. `CS_N_ENV` still wins on a name conflict. |
| `CS_N_ENV` | string | *(empty)* | Additional env vars to inject, in `KEY=VALUE,KEY=VALUE` format. Always applied last — overrides both the fixed core set and `CS_N_ENV_PASSTHROUGH`. |
| `CS_N_ENV_FILE` | string | *(empty)* | Project-relative path to a dotenv-format file whose `KEY=VALUE` lines are injected into this container. Applied after `CS_N_ENV_PASSTHROUGH`, before `CS_N_ENV`. Use this instead of `CS_N_ENV` when a value itself contains a comma (e.g. some SMTP passwords) or when there are too many vars for one line. A missing file fails `nself build` rather than silently starting the service without those vars. |
| `CS_N_ENV` | string | *(empty)* | Additional env vars to inject, in `KEY=VALUE,KEY=VALUE` format. Always applied last — overrides the fixed core set, `CS_N_ENV_PASSTHROUGH`, and `CS_N_ENV_FILE`. |
| `CS_N_IMAGE` | string | *(empty)* | Run a pre-built image instead of building from a Dockerfile — e.g. `minio/minio:RELEASE.2024-01-16T16-07-38Z@sha256:...` to pin an exact digest. Mutually exclusive with `CS_N_PATH`; when set, no `build:` block is emitted at all. |
| `CS_N_VOLUMES` | string | *(empty)* | Comma-separated extra bind mounts in `host:container[:mode]` form, e.g. `./email-templates:/app/templates:ro`. Appended to the service's generated volume list. |

All `CS_*` variables are automatically exempt from "unknown env var" warnings.

Expand Down Expand Up @@ -251,11 +254,12 @@ CS_2_REPLICAS=3

## Notes

- Custom service images are built from the scaffolded Dockerfile in `./services/{name}/`. To use a pre-built image instead, set `CS_N_IMAGE` directly (advanced usage, see [[Guide-Custom-Services]]).
- Custom service images are built from the scaffolded Dockerfile in `./services/{name}/`. To use a pre-built image instead, set `CS_N_IMAGE` directly — a full image reference, optionally digest-pinned with `@sha256:...`.
- The `CS_N_TABLE_PREFIX` variable is used by `nself migrate` to scope migrations to a subdirectory, keeping custom service migrations separate from core schema changes.
- If a service's health endpoint isn't `/health` on its own port (e.g. an auth service serving `/auth/health`), set `CS_N_HEALTHCHECK=/auth/health` — otherwise Docker probes the wrong path and reports the service unhealthy forever regardless of its actual state.
- Custom services participate in `nself backup`, the backup bundle includes a dump of any tables matching the `CS_N_TABLE_PREFIX`.
- Logs from all custom service slots are included in `nself logs --all`.
- `CS_N_IMAGE`, `CS_N_ENV_FILE`, and `CS_N_VOLUMES` cover the cases that previously forced a hand-authored `docker-compose.override.yml`: a pinned third-party image digest, many/complex injected env vars (e.g. SMTP credentials), and an extra bind mount (e.g. an email-template directory) — see the reference table above.

---

Expand Down
3 changes: 3 additions & 0 deletions .github/wiki/Config-Env-Vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ These boolean flags enable optional bundled services. Each defaults to `false`.
| `CS_N_REPLICAS` | int | `1` | No | Number of container instances to run. |
| `CS_N_HEALTHCHECK` | string | `/health` | No | Healthcheck override: a path, a full `CMD ...`/`CMD-SHELL ...` command, or `disabled`/`none`/`false` to omit it. See [[Config-Custom-Services]]. |
| `CS_N_ENV_PASSTHROUGH` | string | *(empty)* | No | Comma-separated allowlist of project `.env` var names to forward into this service. `CS_N_ENV` wins on conflict. See [[Config-Custom-Services]]. |
| `CS_N_IMAGE` | string | *(empty)* | No | Run a pre-built image (optionally digest-pinned, e.g. `repo/name@sha256:...`) instead of building from a Dockerfile. Mutually exclusive with `CS_N_PATH`. |
| `CS_N_ENV_FILE` | string | *(empty)* | No | Project-relative path to a dotenv-format file of extra env vars, injected at build time. Applied after `CS_N_ENV_PASSTHROUGH`; `CS_N_ENV` always wins on conflict. Subject to the same path-traversal check as `CS_N_PATH`. |
| `CS_N_VOLUMES` | string | *(empty)* | No | Comma-separated `host:container[:mode]` bind mounts, subject to the same traversal check as `CS_N_PATH`. |

**Example** (from `web/`, `nself.org` infrastructure):

Expand Down
2 changes: 1 addition & 1 deletion internal/build/orchestrator_build_compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (st *buildState) generateCompose() error {
if profile == "" {
profile = compose.ProfileApp
}
composeGen := compose.NewGeneratorWithProfile(st.cfg, profile)
composeGen := compose.NewGeneratorWithProfile(st.cfg, profile).WithWorkDir(st.workdir)
composeYAML, err := composeGen.Generate()
if err != nil {
return fmt.Errorf("generating docker-compose.yml: %w", err)
Expand Down
57 changes: 57 additions & 0 deletions internal/compose/custom_service_extras.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package compose

import (
"fmt"
"path/filepath"
"strings"

"github.com/joho/godotenv"
)

// Purpose: filesystem/parsing helpers for the CS_N_ENV_FILE and CS_N_VOLUMES
// custom-service extensions (G-013). Split out of custom_services.go so that
// file keeps its focus on the fixed env-var set and the ServiceConfig
// builders.
// Inputs: a project-relative path (CS_N_ENV_FILE) or a raw CS_N_VOLUMES
// string, plus the Generator's workDir for resolving the former on disk.
// Outputs: a parsed env map or volume-mount slice ready to attach to a
// ServiceConfig.
// Constraints: CS_N_ENV_FILE's path traversal/absolute-path safety was
// already checked by config.parseCustomServices — this layer only resolves
// and reads it. CS_N_VOLUMES entries were similarly pre-validated; this
// layer only splits them into the []string form ServiceConfig.Volumes wants.

// loadCustomServiceEnvFile reads a dotenv-format file named by CS_N_ENV_FILE
// and returns its KEY=VALUE pairs. workDir anchors the (already-validated,
// project-relative) path; an empty workDir falls back to resolving relative
// to the process's current directory, matching how CS_N_PATH build contexts
// are implicitly resolved when no explicit project root is threaded through.
func loadCustomServiceEnvFile(workDir, relPath string) (map[string]string, error) {
path := relPath
if workDir != "" {
path = filepath.Join(workDir, relPath)
}
vars, err := godotenv.Read(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return vars, nil
}

// parseCustomServiceVolumes splits a CS_N_VOLUMES value ("host:container[:mode]"
// entries, comma-separated) into the []string form docker-compose's `volumes:`
// list expects. Returns nil for an empty input so ServiceConfig.Volumes stays
// unset (omitempty) rather than an empty-but-present list.
func parseCustomServiceVolumes(raw string) []string {
if raw == "" {
return nil
}
var out []string
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry != "" {
out = append(out, entry)
}
}
return out
}
186 changes: 186 additions & 0 deletions internal/compose/custom_service_extras_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package compose

import (
"os"
"path/filepath"
"testing"

"github.com/nself-org/cli/internal/config"
)

// Purpose: buildCustomService/coreEnvVars coverage for the G-013 additions —
// CS_N_IMAGE (pre-built image instead of Dockerfile build), CS_N_ENV_FILE
// (dotenv-sourced env injection), and CS_N_VOLUMES (extra bind mounts).
// Inputs: config.CustomService fixtures built via testCS() (custom_service_test.go).
// Outputs: none (t.Fatal/t.Error on mismatch).
// Constraints: co-located with custom_service_test.go's existing fixtures;
// reuses minimalConfigWithCS/testCS rather than redefining them.

// ── CS_N_IMAGE ───────────────────────────────────────────────────────────────

// TestBuildCustomService_ImageSkipsBuild verifies that setting CS_N_IMAGE
// emits `image:` and omits `build:` entirely.
func TestBuildCustomService_ImageSkipsBuild(t *testing.T) {
cfg := minimalConfigWithCS()
g := NewGenerator(cfg)
cs := testCS()
cs.Image = "minio/minio:RELEASE.2024-01-16T16-07-38Z@sha256:abc123"

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
if svc.Image != cs.Image {
t.Errorf("Image = %q, want %q", svc.Image, cs.Image)
}
if svc.Build != nil {
t.Errorf("Build = %+v, want nil when CS_N_IMAGE is set", svc.Build)
}
}

// TestBuildCustomService_NoImageStillBuilds is a regression check that the
// default (no CS_N_IMAGE) path is unchanged: it still emits a build: block.
func TestBuildCustomService_NoImageStillBuilds(t *testing.T) {
cfg := minimalConfigWithCS()
g := NewGenerator(cfg)
cs := testCS()

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
if svc.Image != "" {
t.Errorf("Image = %q, want empty when CS_N_IMAGE is unset", svc.Image)
}
if svc.Build == nil {
t.Fatal("Build is nil, want a build context when CS_N_IMAGE is unset")
}
}

// ── CS_N_VOLUMES ─────────────────────────────────────────────────────────────

// TestBuildCustomService_VolumesAppended verifies CS_N_VOLUMES entries are
// split and passed through to ServiceConfig.Volumes.
func TestBuildCustomService_VolumesAppended(t *testing.T) {
cfg := minimalConfigWithCS()
g := NewGenerator(cfg)
cs := testCS()
cs.Volumes = "./email-templates:/app/templates:ro, my_data:/data"

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
want := []string{"./email-templates:/app/templates:ro", "my_data:/data"}
if len(svc.Volumes) != len(want) {
t.Fatalf("Volumes = %v, want %v", svc.Volumes, want)
}
for i, v := range want {
if svc.Volumes[i] != v {
t.Errorf("Volumes[%d] = %q, want %q", i, svc.Volumes[i], v)
}
}
}

// TestBuildCustomService_NoVolumesIsNil verifies that an unset CS_N_VOLUMES
// leaves ServiceConfig.Volumes nil (so it's omitted from the generated YAML,
// not emitted as an empty list).
func TestBuildCustomService_NoVolumesIsNil(t *testing.T) {
cfg := minimalConfigWithCS()
g := NewGenerator(cfg)
cs := testCS()

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
if svc.Volumes != nil {
t.Errorf("Volumes = %v, want nil", svc.Volumes)
}
}

// ── CS_N_ENV_FILE ────────────────────────────────────────────────────────────

// TestBuildCustomService_EnvFileInjected verifies CS_N_ENV_FILE vars are
// read from disk (resolved against the Generator's workDir) and merged into
// the container environment.
func TestBuildCustomService_EnvFileInjected(t *testing.T) {
dir := t.TempDir()
envFile := "smtp.env"
content := "SMTP_HOST=smtp.example.com\nSMTP_PASS=has,a,comma\n"
if err := os.WriteFile(filepath.Join(dir, envFile), []byte(content), 0600); err != nil {
t.Fatalf("writing fixture env file: %v", err)
}

cfg := minimalConfigWithCS()
g := NewGenerator(cfg).WithWorkDir(dir)
cs := testCS()
cs.EnvFile = envFile

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
if got := svc.Environment["SMTP_HOST"]; got != "smtp.example.com" {
t.Errorf("SMTP_HOST = %q, want %q", got, "smtp.example.com")
}
// The value containing commas is exactly the case CS_N_ENV (a single
// comma-joined line) cannot represent safely — proves the env-file path
// handles it correctly.
if got := svc.Environment["SMTP_PASS"]; got != "has,a,comma" {
t.Errorf("SMTP_PASS = %q, want %q", got, "has,a,comma")
}
}

// TestBuildCustomService_EnvFilePrecedence verifies CS_N_ENV still wins over
// a conflicting CS_N_ENV_FILE value (fixed precedence order documented on
// coreEnvVars).
func TestBuildCustomService_EnvFilePrecedence(t *testing.T) {
dir := t.TempDir()
envFile := "extra.env"
if err := os.WriteFile(filepath.Join(dir, envFile), []byte("SHARED_KEY=from_file\n"), 0600); err != nil {
t.Fatalf("writing fixture env file: %v", err)
}

cfg := minimalConfigWithCS()
g := NewGenerator(cfg).WithWorkDir(dir)
cs := testCS()
cs.EnvFile = envFile
cs.ExtraEnv = "SHARED_KEY=from_cs_n_env"

svc, err := g.buildCustomService(cs)
if err != nil {
t.Fatalf("buildCustomService returned error: %v", err)
}
if got := svc.Environment["SHARED_KEY"]; got != "from_cs_n_env" {
t.Errorf("SHARED_KEY = %q, want %q (CS_N_ENV must win over CS_N_ENV_FILE)", got, "from_cs_n_env")
}
}

// TestBuildCustomService_EnvFileMissingErrors verifies a CS_N_ENV_FILE
// naming a nonexistent file fails the build loudly rather than silently
// dropping the vars the service needs.
func TestBuildCustomService_EnvFileMissingErrors(t *testing.T) {
cfg := minimalConfigWithCS()
g := NewGenerator(cfg).WithWorkDir(t.TempDir())
cs := testCS()
cs.EnvFile = "does-not-exist.env"

if _, err := g.buildCustomService(cs); err == nil {
t.Fatal("expected an error for a missing CS_N_ENV_FILE, got nil")
}
}

// TestGenerate_CustomServiceEnvFileError verifies a bad CS_N_ENV_FILE fails
// the whole Generate() call with a clear error rather than a partial compose.
func TestGenerate_CustomServiceEnvFileError(t *testing.T) {
cfg := minimalConfigWithCS()
cs := testCS()
cs.EnvFile = "does-not-exist.env"
cfg.CustomServices = []config.CustomService{cs}

g := NewGenerator(cfg).WithWorkDir(t.TempDir())
if _, err := g.Generate(); err == nil {
t.Fatal("expected Generate() to fail on a missing CS_N_ENV_FILE")
}
}
Loading
Loading