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
2 changes: 1 addition & 1 deletion cmd/commands/functions_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func runFunctionsDeploy(cmd *cobra.Command, args []string) error {
// Signal container reload.
cfg, _, loadErr := loadHealthConfig()
if loadErr == nil {
containerID := fmt.Sprintf("%s_functions", cfg.ProjectName)
containerID := cfg.ServiceContainerName("functions")
reloadCtx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
// nhost/functions watches for a .reload file touch.
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/functions_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func runFunctionsLogs(cmd *cobra.Command, args []string) error {
cfg, _, cfgErr := loadHealthConfig()
containerID := "functions"
if cfgErr == nil {
containerID = fmt.Sprintf("%s_functions", cfg.ProjectName)
containerID = cfg.ServiceContainerName("functions")
}

dockerArgs := []string{"logs"}
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/start_containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func startPostgresPhase(ctx context.Context, opts startOpts, cfg *config.Config,
// Skip ComposeUp for an already healthy postgres unless a clean/fresh
// start cleared it. Compose may otherwise recreate it after config drift.
if !opts.cleanStart && !opts.fresh {
containerName := fmt.Sprintf("%s_postgres", cfg.ProjectName)
containerName := cfg.ServiceContainerName("postgres")
health, healthErr := docker.GetHealthStatus(ctx, containerName)
if healthErr == nil && postgresAlreadyRunning(health) {
sp.Success("PostgreSQL container already running and healthy")
Expand Down
1 change: 1 addition & 0 deletions cmd/commands/testdata/json/argv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Each `<fragment>.txt` file contains one command argv per line, without the leadi
- `legacy-json` compares v1.4 `--json` stdout and exit status byte for byte against merge-base.
- `volatile-json=timestamp` replaces only the top-level `timestamp` value in the raw legacy JSON bytes before comparison. The `health` row applies this automatically. Every other byte and the exit status must match.
- `no-json=<reason>` skips the v1.5 envelope check for a command assigned to a later conversion ticket.
- `error-json=<code>` marks a row that fails by design on the fixture (for example `start` without a compose file). Besides the human comparison, the head binary runs with `--json` in v1.5 mode and must exit non-zero, print exactly one error envelope that validates against `envelope.v1.schema.json`, and carry the named code (`E004`). It replaces `no-json` for such rows.
- `human-skip=<reason>` skips the base versus head human stdout, stderr and exit comparison when a documented difference is expected.

The harness checks human stdout, stderr and exit status in v1.4 and v1.5 modes, then requires a successful exit and exactly one valid v1 data envelope with the registered data shape for every row without `no-json`.
Expand Down
11 changes: 6 additions & 5 deletions cmd/commands/testdata/json/argv/lifecycle.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
# row goes first and writes AGENTS.md without a word, so the second row prints the
# same stderr on every one of its runs. start, stop and restart cannot finish on
# the fixture (no docker-compose.yml; --skip-build keeps start from generating
# one), so each exits 1 the same way in base and head and is tagged no-json; their
# success documents and their failures are covered by TestLifecycleJSON.
# one), so each fails the same way in base and head and is tagged error-json with its
# code, which the script checks as one valid error envelope; their success documents
# and other failures are covered by TestLifecycleJSON.
init --quiet --force --non-interactive --name smoke --domain example.test
init --force --non-interactive --name smoke --domain example.test
start --skip-build #| no-json=fixture-has-no-docker-compose-yml-see-json_lifecycle_test.go
stop #| no-json=fixture-has-no-docker-compose-yml-see-json_lifecycle_test.go
restart #| no-json=fixture-has-no-docker-compose-yml-see-json_lifecycle_test.go
start --skip-build #| error-json=E004
stop #| error-json=E400
restart #| error-json=E400
2 changes: 1 addition & 1 deletion internal/build/orchestrator_build_compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (st *buildState) generateCompose() error {
// ── Step 8.1: Warn if this regen would swap a running postgres image ──
// (cli#384 — advisory only, never blocks the build; see
// PostgresImageChangeWarning for the data-loss scenario this guards.)
postgresContainer := fmt.Sprintf("%s_postgres", st.cfg.ProjectName)
postgresContainer := st.cfg.ServiceContainerName("postgres")
generatedPostgresImage := compose.ResolveImage("postgres", compose.ResolvePostgresImage(st.cfg.Postgres))
// Plan mode skips it: the check runs `docker inspect`, a host effect.
if !st.planning() {
Expand Down
2 changes: 1 addition & 1 deletion internal/build/secrets_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func TestTemplateSecrets_CommandAndHealthcheckLiteralsRewritten(t *testing.T) {
}
for _, ref := range []string{
"REDISCLI_AUTH: ${REDIS_PASSWORD}",
`--requirepass "$$REDISCLI_AUTH"`,
`requirepass "$$esc"`,
"TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}",
} {
if !strings.Contains(doc, ref) {
Expand Down
2 changes: 1 addition & 1 deletion internal/builtin/admin/cmd_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func (r runner) adminContainerID() string {
if err != nil || cfg.ProjectName == "" {
return "nself_admin"
}
return fmt.Sprintf("%s_admin", cfg.ProjectName)
return cfg.ServiceContainerName("admin")
}

func rootCommand(r runner) *cobra.Command {
Expand Down
29 changes: 19 additions & 10 deletions internal/compose/cache_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ func (g *Generator) cacheImage(engine string) string {
return ResolveImage("redis", ImageRef("redis", version))
}

// redisPasswordScript is the `sh -c` body that starts the cache server with the
// password from $REDISCLI_AUTH on stdin as a `requirepass` config line. The
// value is escaped for the config parser (backslash and double quote), so any
// password round-trips. exec keeps the server as PID 1 for signal handling;
// $$ survives compose interpolation as $.
func redisPasswordScript(server string, poolSize int) string {
return fmt.Sprintf(`esc=$$(printf '%%s' "$$REDISCLI_AUTH" | sed 's/[\\"]/\\&/g'); `+
"exec %s --appendonly yes --protected-mode yes --maxclients %d - <<EOT\nrequirepass \"$$esc\"\nEOT\n",
server, poolSize)
}

// buildRedisService returns the cache/queue service configuration for the
// engine. Command and health check depend on whether a password is set; the
// valkey-* binaries are used by name (no reliance on redis-* symlinks).
Expand Down Expand Up @@ -173,16 +184,14 @@ func (g *Generator) buildRedisService() ServiceConfig {
var env map[string]string

if rc.Password != "" {
// D-0282: no argv carries the password. It reaches the container as
// REDISCLI_AUTH (Env, like every other secret), which the engine's CLI
// reads on its own (the health check needs no -a), and a shell expands it
// for the server ($$ survives compose interpolation as $). Valkey's CLI
// reads REDISCLI_AUTH too.
// D-0282, D-0587: no argv carries the password, not even the expanded
// one a host `ps` shows. It reaches the container as REDISCLI_AUTH (Env,
// like every other secret), which the engine's CLI reads on its own (the
// health check needs no -a). The server gets it as a config line on stdin
// (`-`), so it is never a process argument and never written to disk.
// Valkey's CLI and server behave the same.
env = map[string]string{"REDISCLI_AUTH": secretRef("REDIS_PASSWORD", rc.Password)}
command = []string{"sh", "-c", fmt.Sprintf(
`exec %s --appendonly yes --protected-mode yes --requirepass "$$REDISCLI_AUTH" --maxclients %d`,
server, poolSize,
)}
command = []string{"sh", "-c", redisPasswordScript(server, poolSize)}
} else {
command = fmt.Sprintf(
"%s --appendonly yes --protected-mode no --maxclients %d",
Expand All @@ -192,7 +201,7 @@ func (g *Generator) buildRedisService() ServiceConfig {

return ServiceConfig{
Image: g.cacheImage(engine),
ContainerName: fmt.Sprintf("%s_redis", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("redis"),
Restart: "unless-stopped",
User: "999:999",
Networks: []string{g.cfg.DockerNetwork},
Expand Down
2 changes: 1 addition & 1 deletion internal/compose/cache_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func TestCacheEngineExistingProjectNeverMoves(t *testing.T) {
if svc.Image != pin {
t.Errorf("image = %q, want the lock pin %q", svc.Image, pin)
}
wantCmd := []string{"sh", "-c", `exec redis-server --appendonly yes --protected-mode yes --requirepass "$$REDISCLI_AUTH" --maxclients 20`}
wantCmd := []string{"sh", "-c", redisPasswordScript("redis-server", 20)}
if got, _ := svc.Command.([]string); strings.Join(got, "\x00") != strings.Join(wantCmd, "\x00") {
t.Errorf("command = %q, want %q", svc.Command, wantCmd)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/compose/compose_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ func TestBuildRedisService_WithPassword(t *testing.T) {
svc := g.buildRedisService()

cmd, ok := svc.Command.([]string)
if !ok || len(cmd) != 3 || cmd[0] != "sh" || !strings.Contains(cmd[2], `--requirepass "$$REDISCLI_AUTH"`) {
if !ok || len(cmd) != 3 || cmd[0] != "sh" || !strings.Contains(cmd[2], `requirepass "$$esc"`) || strings.Contains(cmd[2], "--requirepass") {
t.Errorf("Redis with password: command = %#v, want sh -c with requirepass from REDISCLI_AUTH", svc.Command)
}
if strings.Contains(strings.Join(cmd, " "), cfg.Redis.Password) {
Expand Down
55 changes: 55 additions & 0 deletions internal/compose/container_name_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package compose

// container_name_guard_test.go — D-0130 guard: a service's container name is
// built by config.Config.ServiceContainerName alone. A builder that formats
// `<PROJECT_NAME>_<svc>` itself makes a naming change a dozen edits again.

import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)

// handRolledName matches a ContainerName field set from Sprintf("%s_<svc>", ...
// ProjectName) or ProjectName + "_<svc>". Network and volume names share the
// prefix but are not container names, so only ContainerName lines count.
var handRolledName = regexp.MustCompile(`ContainerName\b.*("%s_[^"]*"\s*,[^)]*ProjectName|ProjectName\s*\+\s*"_)`)

func TestContainerNamesComeFromOneHelper(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil || len(files) == 0 {
t.Fatalf("no compose sources found: %v", err)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(src), "\n") {
if handRolledName.MatchString(line) {
t.Errorf("%s:%d builds a container name by hand, use cfg.ServiceContainerName: %s", f, i+1, strings.TrimSpace(line))
}
}
}
}

// TestHandRolledNamePatternControl proves the guard matches both shapes it
// exists to stop.
func TestHandRolledNamePatternControl(t *testing.T) {
for _, bad := range []string{
`ContainerName: fmt.Sprintf("%s_redis", g.cfg.ProjectName),`,
`ContainerName: cfg.ProjectName + "_postgres",`,
} {
if !handRolledName.MatchString(bad) {
t.Errorf("pattern misses %q", bad)
}
}
if handRolledName.MatchString(`ContainerName: g.cfg.ServiceContainerName("redis"),`) {
t.Error("pattern flags the helper call")
}
}
4 changes: 2 additions & 2 deletions internal/compose/core_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (g *Generator) buildPostgresService() ServiceConfig {
}
return ServiceConfig{
Image: image,
ContainerName: fmt.Sprintf("%s_postgres", cfg.ProjectName),
ContainerName: cfg.ServiceContainerName("postgres"),
Restart: "unless-stopped",
Networks: []string{cfg.DockerNetwork},
User: user,
Expand Down Expand Up @@ -171,7 +171,7 @@ func (g *Generator) buildHasuraService() (ServiceConfig, error) {

return ServiceConfig{
Image: hasuraImage(cfg.Hasura.Version),
ContainerName: fmt.Sprintf("%s_hasura", cfg.ProjectName),
ContainerName: cfg.ServiceContainerName("hasura"),
Restart: "unless-stopped",
User: "1001:1001",
Networks: []string{cfg.DockerNetwork},
Expand Down
4 changes: 2 additions & 2 deletions internal/compose/core_services_auth_nginx.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func (g *Generator) buildAuthService() (ServiceConfig, error) {
}
return ServiceConfig{
Image: image,
ContainerName: fmt.Sprintf("%s_auth", cfg.ProjectName),
ContainerName: cfg.ServiceContainerName("auth"),
Restart: "unless-stopped",
Networks: []string{cfg.DockerNetwork},
DependsOn: map[string]DepOn{
Expand Down Expand Up @@ -146,7 +146,7 @@ func (g *Generator) buildNginxService(dc *DockerCompose) ServiceConfig {

return ServiceConfig{
Image: ResolveImage("nginx", ImageRef("nginx", "")),
ContainerName: fmt.Sprintf("%s_nginx", cfg.ProjectName),
ContainerName: cfg.ServiceContainerName("nginx"),
Restart: "unless-stopped",
// No User override on purpose. The nginx.conf this same tool generates
// declares `user nginx;` on line 4, which requires the MASTER process to
Expand Down
2 changes: 1 addition & 1 deletion internal/compose/custom_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func (g *Generator) buildCustomService(cs config.CustomService) (ServiceConfig,
}

svc := ServiceConfig{
ContainerName: fmt.Sprintf("%s_%s", cfg.ProjectName, cs.Name),
ContainerName: cfg.ServiceContainerName(cs.Name),
Restart: "unless-stopped",
Networks: []string{cfg.DockerNetwork},
DependsOn: map[string]DepOn{
Expand Down
4 changes: 2 additions & 2 deletions internal/compose/object_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (g *Generator) buildSeaweedService() (ServiceConfig, error) {
}
return ServiceConfig{
Image: ResolveImage("seaweedfs", ImageRef("seaweedfs", os.Getenv("SEAWEEDFS_VERSION"))),
ContainerName: g.cfg.ProjectName + "_seaweedfs", Restart: "unless-stopped",
ContainerName: g.cfg.ServiceContainerName("seaweedfs"), Restart: "unless-stopped",
Networks: []string{g.cfg.DockerNetwork},
// The image's seaweed user (uid 1000) owns /data. CapDrop ALL removes root's
// DAC_OVERRIDE, so root could not write a fresh volume; run as that user.
Expand Down Expand Up @@ -224,7 +224,7 @@ func objectStorageKeyProblem(value string) string {
func (g *Generator) buildSeaweedInit() ServiceConfig {
return ServiceConfig{
Image: ResolveImage("seaweedfs", ImageRef("seaweedfs", os.Getenv("SEAWEEDFS_VERSION"))),
ContainerName: g.cfg.ProjectName + "_seaweedfs_init", Restart: "no",
ContainerName: g.cfg.ServiceContainerName("seaweedfs_init"), Restart: "no",
// Shares the server's network namespace: it reaches the master on 127.0.0.1 and
// needs no network or port of its own (the master and filer stay closed to the network).
NetworkMode: "service:seaweedfs",
Expand Down
4 changes: 2 additions & 2 deletions internal/compose/optional_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (g *Generator) buildMinioService() ServiceConfig {
// The minio entry in images.yaml is registry-qualified (docker.io/pgsty/minio);
// the Docker Hub minio/minio repository no longer exists (ADR 0028).
Image: ResolveImage("minio", ImageRef("minio", version)),
ContainerName: fmt.Sprintf("%s_minio", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("minio"),
Restart: "unless-stopped",
Networks: []string{g.cfg.DockerNetwork},
Environment: map[string]string{
Expand Down Expand Up @@ -90,7 +90,7 @@ func (g *Generator) buildMailpitService() ServiceConfig {

return ServiceConfig{
Image: ResolveImage("mailpit", ImageRef("mailpit", version)),
ContainerName: fmt.Sprintf("%s_mailpit", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("mailpit"),
Restart: "unless-stopped",
User: "1000:1000",
Networks: []string{g.cfg.DockerNetwork},
Expand Down
4 changes: 2 additions & 2 deletions internal/compose/optional_services_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (g *Generator) buildAdminService() ServiceConfig {

return ServiceConfig{
Image: ResolveImage("admin", ImageRef("admin", version)),
ContainerName: fmt.Sprintf("%s_admin", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("admin"),
Restart: "unless-stopped",
User: adminUser,
GroupAdd: groupAdd,
Expand Down Expand Up @@ -145,7 +145,7 @@ func (g *Generator) buildFunctionsService() ServiceConfig {

svc := ServiceConfig{
Image: image,
ContainerName: fmt.Sprintf("%s_functions", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("functions"),
Restart: "unless-stopped",
Networks: []string{g.cfg.DockerNetwork},
DependsOn: map[string]DepOn{
Expand Down
6 changes: 3 additions & 3 deletions internal/compose/optional_services_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
func (g *Generator) buildMeiliInitService() ServiceConfig {
return ServiceConfig{
Image: ResolveImage("meilisearch-init", ImageRef("meilisearch-init", "")),
ContainerName: fmt.Sprintf("%s_meilisearch_init", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("meilisearch_init"),
Restart: "no",
User: "root",
Networks: []string{g.cfg.DockerNetwork},
Expand Down Expand Up @@ -51,7 +51,7 @@ func (g *Generator) buildMeiliService() ServiceConfig {

return ServiceConfig{
Image: ResolveImage("meilisearch", ImageRef("meilisearch", version)),
ContainerName: fmt.Sprintf("%s_meilisearch", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("meilisearch"),
Restart: "unless-stopped",
User: "1000:1000",
Networks: []string{g.cfg.DockerNetwork},
Expand Down Expand Up @@ -101,7 +101,7 @@ func (g *Generator) buildTypesenseService() ServiceConfig {

return ServiceConfig{
Image: ResolveImage("typesense", ImageRef("typesense", version)),
ContainerName: fmt.Sprintf("%s_typesense", g.cfg.ProjectName),
ContainerName: g.cfg.ServiceContainerName("typesense"),
Restart: "unless-stopped",
Networks: []string{g.cfg.DockerNetwork},
Environment: map[string]string{
Expand Down
Loading
Loading