Skip to content
Open
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
15 changes: 15 additions & 0 deletions docs/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ commandTemplates:
up: '{{ .DockerCompose }} up -d'
down: '{{ .DockerCompose }} down'
downWithVolumes: '{{ .DockerCompose }} down --volumes'
restart: '{{ .DockerCompose }} restart'
upService: '{{ .DockerCompose }} up -d {{ .Service.Name }}'
startService: '{{ .DockerCompose }} start {{ .Service.Name }}'
stopService: '{{ .DockerCompose }} stop {{ .Service.Name }}'
Expand All @@ -95,6 +96,20 @@ stats:
color: green
```

## Docker Compose profiles

When a docker-compose profile is selected in the project panel, the project-scoped
templates (`up`, `down`, `downWithVolumes`, `restart`, `allLogs`, `viewAlLogs`,
`dockerComposeConfig`) automatically have `--profile <name>` appended via the
`{{ .DockerCompose }}` substitution. There are no separate profile templates
to configure — any custom override of those templates will pick up the profile
flag for free.

Selecting a profile row in the project panel also filters the services and
containers panels to the services that activate with that profile (default
services plus profile-tagged services, matching `docker compose --profile X
config --services`).

## To see what all of the config options mean, and what other options you can set, see [here](https://godoc.org/github.com/jesseduffield/lazydocker/pkg/config)

## Color Attributes:
Expand Down
90 changes: 90 additions & 0 deletions docs/adr/0001-docker-compose-profile-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 1. Docker-compose profile support

## Status

Proposed.

## Context

[Issue #423](https://github.com/jesseduffield/lazydocker/issues/423) requests that lazydocker understand docker-compose [profiles](https://docs.docker.com/compose/profiles/). Today it shows every service in the project, with no way to scope to a specific profile and no way to invoke profile-aware `up`/`down`/`restart`.

A community PR — [#638](https://github.com/jesseduffield/lazydocker/pull/638), branch `saravanabalagi/feat/docker_profiles` — attempted this in March 2025 and has sat open since. Two problems with picking it up:

1. **Master moved on substantially.** Since the PR was authored, master has gained multi-project discovery from container labels (`com.docker.compose.project`), a project-filter on the services and containers panels, a `Hide` on the project panel keyed off `IsProjectScoped()`, and a `-p` flag injection in `NewCommandObject`. The PR's structural changes overlap with those refactors; merging or rebasing produces large, gnarly conflicts in `project_panel.go`.
2. **The PR has a latent bug.** It adds a `CheckDockerComposeProfiles` template that uses a shell pipeline (`... | grep -q ...; echo $?`), but `OSCommand.RunCommand` parses commands with `str.ToArgv` and exec's directly — no shell. So the precheck always fails, `HasProfiles` stays false, and the UI never shows profile rows even though the rest of the feature is wired up. Anyone testing the branch sees "nothing happens".

The cleanest path is to redesign on master rather than rescue the PR. Master's `getDiscoveredProjects()` already gives us a list-of-rows model in the project panel; profiles slot in as additional rows of the same `*commands.Project` type. And master's `NewCommandObject` already injects `-p <name>` from a `Project`/`Service` field — we can extend the same mechanism to inject `--profile <name>`, removing the need for the PR's separate `UpProfile`/`DownProfile`/... template family.

## Decision

Implement profile support as an additive change on master, with the following architectural choices:

### Profile is a flag on `Project`, not a new type

Add `IsProfile bool` to `pkg/commands/project.go`'s `Project` struct. Profiles render as pseudo-projects in the panel. This avoids a generic-type split across the `SideListPanel[*commands.Project]`, `CommandObject`, presentation, services and containers panels — all of which already pass `*Project` around.

### Reuse existing command templates; inject `--profile X` via `NewCommandObject`

Add a `Profile string` field to `CommandObject` and have `NewCommandObject` append `--profile X` (in addition to the existing `-p <name>` for the local project) when a profile context is set. This means the existing `Up`, `Down`, `DownWithVolumes`, `AllLogs`, `DockerComposeConfig` templates work unchanged for profile invocations — no `UpProfile` / `DownProfile` / `RestartProfile` / `AllLogsProfile` template family. The single new template is `Restart` (master doesn't have a project-level restart at all today).

This is the key reason the change is small. The original PR added ~12 new templates and ~12 corresponding i18n strings; this design needs one new template and a handful of i18n strings (for confirmation/status messages on profile actions).

### Discovery via shelling out, cached for the session

Add `GetProfiles()` and `GetProfileServices(profile)` methods on `DockerCommand` that shell out to `docker compose config --profiles` and `docker compose --profile X config --services` respectively. Cache results on the `DockerCommand` struct under a mutex; never invalidate during a session. Compose files rarely change mid-session, and `GetServices()` already has the same property — users restart lazydocker to pick up changes.

**No precheck.** The PR's `CheckDockerComposeProfiles` template (which was the bug source) is unnecessary — we just enumerate. Empty result → no profile rows. This also means no shell-pipe escape hatch is needed; everything stays compatible with `OSCommand.RunCommand`'s argv-exec model.

### Soft-fail discovery

If `GetProfiles()` errors (e.g. `docker compose` is missing, compose v1 too old), log once and cache empty. No popup, no warning row. The UI degrades to "no profile rows" — same UX as a compose project with no profiles declared. This matches master's existing `GetServices()` behaviour.

### Selecting a profile filters the services and containers panels

Master's project filter on `services_panel.go` and `containers_panel.go` keeps items where `ProjectName == selectedProject`. Add a profile branch: when a profile is selected, keep items where `ProjectName == LocalProjectName` AND `ServiceName ∈ GetProfileServices(profile)`. The set returned by `docker compose --profile X config --services` includes the profile's services plus the default (no-profile) services — that's docker's own semantics and matches what users expect ("show me what would be running if I activated this profile").

### Profiles render after projects in the panel

Sort callback returns non-profiles first, then alphabetical within each group. No per-project grouping (profiles always belong to the local project, of which there is exactly one). Selection restore keys on the `(Name, IsProfile)` tuple so a profile that happens to share a project's name doesn't cross-select.

### Visual: cyan "profile" prefix label

Mirrors the existing two-column row layout. Re-uses `utils.ColoredString(..., color.FgCyan)`.

### English-only i18n initially

Add new strings to `pkg/i18n/english.go`. If `go build ./...` shows other locales fail (they shouldn't — they inherit empty strings for new fields by default), backfill defaults; otherwise leave localization for a follow-up.

### Scope limits

- No new keybinding for project/profile restart in this PR — leave for maintainer discussion. The handler exists; binding it is one line.
- No profile-aware custom service/container commands. A service belongs to a specific project, not a profile; threading profile context into per-service custom commands would change semantics surprisingly.
- No cache invalidation on compose-file change. Defer until requested.

## Consequences

### Positive

- **Small diff.** ~80-100 lines added across 5-6 files, vs. the PR's ~350 lines. Easier review, easier maintenance.
- **No new template family.** `Up`/`Down`/`Restart`/`AllLogs`/`DockerComposeConfig` work unchanged for profile invocations because `--profile X` is injected at the `NewCommandObject` layer. Users who've customized these templates get profile support for free.
- **No latent shell-pipe bug.** No precheck template; no shell-metacharacter dependencies in `OSCommand.RunCommand`.
- **Slots into master's existing architecture.** Profiles are just more rows; selection still flows through `OnSelect`; filter still flows through the existing `Filter` callback. No new panel, no new lifecycle.
- **PR-able cleanly upstream.** Additive across well-bounded extension points.

### Negative / risks

- **Re-shells `docker compose config` once per session.** Both `--profiles` (once) and `--profile X --services` (once per profile selected). Both can be slow on large compose files. Mitigated by single-call caching.
- **`Restart` template is new.** Users with a heavily customized `~/.config/lazydocker/config.yml` that wipes defaults will need to add it. Default value is `"{{ .DockerCompose }} restart"` — boring and obvious.
- **Selection-restore tuple change.** If anything downstream of `refreshProject` assumes project names are unique within the panel, it breaks. Audit shows nothing relies on this today, but worth noting in PR review.
- **No per-profile cache invalidation.** If a user edits `compose.yml` to add a new profile mid-session, lazydocker won't see it until restart. Same constraint as `GetServices()`.
- **Profile-aware filtering issues an extra shell call per profile selection.** At 100ms refresh tick and cached results, this is one shell per profile per session — negligible.

### Risks if rejected

If upstream wants separate `UpProfile`/etc. templates for customization symmetry with the existing pattern, the design has to grow back toward the original PR's shape. Worth raising in PR discussion before sinking time into the larger version.

## References

- Issue: [jesseduffield/lazydocker#423](https://github.com/jesseduffield/lazydocker/issues/423)
- Prior PR (stale): [jesseduffield/lazydocker#638](https://github.com/jesseduffield/lazydocker/pull/638)
- Implementation plan: see this repo's branch and corresponding PR.
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
go = "1.26.3"
127 changes: 123 additions & 4 deletions pkg/commands/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ type DockerCommand struct {
ContainerMutex deadlock.Mutex
ServiceMutex deadlock.Mutex

// Profile discovery cache. Populated lazily; never invalidated within a
// session — compose file changes are rare and the user can restart
// lazydocker. Mirrors the existing GetServices() pattern.
profileMutex deadlock.Mutex
profilesCached bool
profiles []string
profileSvcs map[string][]string

// runOutput is an injection seam for tests. Production code leaves it
// nil and falls through to OSCommand.RunCommandWithOutput. Tests can
// set this to return canned output without spinning up a real shell.
runOutput func(string) (string, error)

Closers []io.Closer
}

Expand All @@ -66,6 +79,7 @@ type CommandObject struct {
Volume *Volume
Network *Network
Project *Project
Profile string
}

// NewCommandObject takes a command object and returns a default command object with the passed command object merged in
Expand All @@ -77,10 +91,25 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
// docker compose targets the correct project.
if obj.Service != nil && obj.Service.ProjectName != "" {
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Service.ProjectName)
} else if obj.Project != nil && obj.Project.Name != "" {
} else if obj.Project != nil && obj.Project.Name != "" && !obj.Project.IsProfile {
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Project.Name)
}

// Profile pseudo-projects always belong to the local project, so emit
// `-p <LocalProjectName> --profile <name>`. Allow the caller to supply
// the profile name either via the explicit Profile field or via a
// Project{IsProfile: true} pseudo-project.
profile := obj.Profile
if profile == "" && obj.Project != nil && obj.Project.IsProfile {
profile = obj.Project.Name
}
if profile != "" {
if c.LocalProjectName != "" && !strings.Contains(defaultObj.DockerCompose, " -p ") {
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, c.LocalProjectName)
}
defaultObj.DockerCompose = fmt.Sprintf("%s --profile %s", defaultObj.DockerCompose, profile)
}

return defaultObj
}

Expand Down Expand Up @@ -490,12 +519,14 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) {
wg.Wait()
}

// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose
func (c *DockerCommand) ViewAllLogs(project *Project) (*exec.Cmd, error) {
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose.
// The caller supplies a CommandObject so it can request a profile-scoped view
// (`{Profile: name}`) or a plain project view (`{Project: project}`).
func (c *DockerCommand) ViewAllLogs(obj CommandObject) (*exec.Cmd, error) {
cmd := c.OSCommand.ExecutableFromString(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs,
c.NewCommandObject(CommandObject{Project: project}),
c.NewCommandObject(obj),
),
)

Expand Down Expand Up @@ -523,6 +554,94 @@ func (c *DockerCommand) DockerComposeConfigForProject(project *Project) string {
return output
}

// DockerComposeConfigForProfile renders the resolved compose YAML with a
// specific profile activated. The existing DockerComposeConfig template is
// reused; NewCommandObject appends `--profile <name>`.
func (c *DockerCommand) DockerComposeConfigForProfile(profile string) string {
output, err := c.OSCommand.RunCommandWithOutput(
utils.ApplyTemplate(
c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig,
c.NewCommandObject(CommandObject{Profile: profile}),
),
)
if err != nil {
output = err.Error()
}
return output
}

// runCmdOutput is the injection seam used by the profile-discovery methods.
func (c *DockerCommand) runCmdOutput(command string) (string, error) {
if c.runOutput != nil {
return c.runOutput(command)
}
return c.OSCommand.RunCommandWithOutput(command)
}

// GetProfiles returns the docker-compose profiles declared in the local
// compose project. Cached per session. Returns nil when not in a compose
// project. Soft-fails: an error from the underlying compose call is logged
// once and the cache is marked populated with an empty list, so the UI
// degrades to "no profile rows".
func (c *DockerCommand) GetProfiles() ([]string, error) {
c.profileMutex.Lock()
defer c.profileMutex.Unlock()
if c.profilesCached {
return c.profiles, nil
}
c.profilesCached = true
if !c.InDockerComposeProject {
return nil, nil
}
composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose
output, err := c.runCmdOutput(fmt.Sprintf("%s config --profiles", composeCmd))
if err != nil {
c.Log.Warn("Failed to list compose profiles: " + err.Error())
return nil, nil
}
seen := map[string]bool{}
for _, line := range utils.SplitLines(strings.TrimSpace(output)) {
line = strings.TrimSpace(line)
if line == "" || seen[line] {
continue
}
seen[line] = true
c.profiles = append(c.profiles, line)
}
sort.Strings(c.profiles)
return c.profiles, nil
}

// GetProfileServices returns the service names that activate when the given
// profile is enabled (per docker compose semantics: default services + that
// profile's services). Cached per profile for the session.
func (c *DockerCommand) GetProfileServices(profile string) ([]string, error) {
c.profileMutex.Lock()
defer c.profileMutex.Unlock()
if c.profileSvcs == nil {
c.profileSvcs = map[string][]string{}
}
if svcs, ok := c.profileSvcs[profile]; ok {
return svcs, nil
}
composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose
output, err := c.runCmdOutput(fmt.Sprintf("%s --profile %s config --services", composeCmd, profile))
if err != nil {
// Cache the empty slice so we don't re-shell every refresh tick.
c.profileSvcs[profile] = []string{}
return nil, err
}
lines := []string{}
for _, line := range utils.SplitLines(strings.TrimSpace(output)) {
if line = strings.TrimSpace(line); line != "" {
lines = append(lines, line)
}
}
sort.Strings(lines)
c.profileSvcs[profile] = lines
return lines, nil
}

// determineDockerHost tries to the determine the docker host that we should connect to
// in the following order of decreasing precedence:
// - value of "DOCKER_HOST" environment variable
Expand Down
Loading