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
1 change: 1 addition & 0 deletions .github/command-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@
"--no-monorepo",
"--profile",
"--quiet",
"--remove-orphans",
"--security-report",
"--verbose"
]
Expand Down
1 change: 1 addition & 0 deletions .github/wiki/cmd-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Each long-running service gets a default pids limit of 100 to prevent fork-bomb
| `--no-monorepo` | `false` | Disable automatic monorepo backend detection |
| `--profile` | `""` | Service profile: curated subset of services to include in docker-compose.yml. app (default) — full service set, identical to pre-profile behaviour. ops — observability + CI server: postgres, hasura, auth, nginx, monitoring stack; excludes minio, mailpit, admin, functions, search. Overrides NSELF_PROFILE env var. Valid values: app, ops. |
| `--quiet`, `-q` | `false` | Suppress non-error output (for CI use) |
| `--remove-orphans` | `false` | Remove containers with no matching service in the freshly generated compose (G-014). Detection always runs; removal is opt-in. |
| `--security-report` | `false` | Generate security analysis |
| `--verbose`, `-v` | `false` | Show environment cascade |
| `--help`, `-h` | — | Show help |
Expand Down
1 change: 1 addition & 0 deletions .github/wiki/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Flags:
monitoring stack; excludes minio, mailpit, admin, functions, search.
Overrides NSELF_PROFILE env var. Valid values: app, ops.
- `--quiet` (default false) — Suppress non-error output (for CI use)
- `--remove-orphans` (default false) — Remove containers with no matching service in the freshly generated compose (G-014). Detection always runs; removal is opt-in.
- `--security-report` (default false) — Generate security analysis
- `--verbose` (default false) — Show environment cascade

Expand Down
78 changes: 8 additions & 70 deletions cmd/commands/build.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
package commands

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/nself-org/cli/internal/build"
"github.com/nself-org/cli/internal/compose"
"github.com/nself-org/cli/internal/config"
"github.com/nself-org/cli/internal/migration"
"github.com/nself-org/cli/internal/plugin"
"github.com/nself-org/cli/internal/ui"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -45,6 +42,7 @@ func init() {
buildCmd.Flags().Bool("no-migration-check", false, "Skip v1 artifact detection (for automation/CI)")
buildCmd.Flags().Bool("allow-legacy", false, "Bypass v0.9 artifact check and proceed with WARNING (not recommended)")
buildCmd.Flags().Bool("no-auto-redis", false, "Disable automatic Redis enablement when a BullMQ-backed plugin is detected")
buildCmd.Flags().Bool("remove-orphans", false, "Remove containers with no matching service in the freshly generated compose (G-014). Detection always runs; removal is opt-in.")
buildCmd.Flags().String("profile", "", `Service profile: curated subset of services to include in docker-compose.yml.
app (default) — full service set, identical to pre-profile behaviour.
ops — observability + CI server: postgres, hasura, auth, nginx,
Expand All @@ -67,6 +65,7 @@ func runBuild(cmd *cobra.Command, args []string) error {
noMigrationCheck, _ := cmd.Flags().GetBool("no-migration-check")
allowLegacy, _ := cmd.Flags().GetBool("allow-legacy")
noAutoRedis, _ := cmd.Flags().GetBool("no-auto-redis")
removeOrphans, _ := cmd.Flags().GetBool("remove-orphans")

// ── Profile resolution ────────────────────────────────────────────
// Priority: --profile flag > NSELF_PROFILE env var > default ("app").
Expand Down Expand Up @@ -227,73 +226,12 @@ func runBuild(cmd *cobra.Command, args []string) error {
ui.Info("Next step: nself start")
}

return nil
}

// runPluginLifecycleCheck loads the lifecycle store, transitions expired plugins,
// prints dormant banners, and auto-removes fully-expired plugins.
// Auto-removal is intentionally build-only (not start) — start is read-only on lifecycle.
func runPluginLifecycleCheck(quiet bool) {
store, err := plugin.LoadLifecycleStore()
if err != nil {
// Non-fatal: lifecycle store is advisory only.
if !quiet {
ui.Warn("Could not load plugin lifecycle store: " + err.Error())
}
return
// ── G-014: orphan container detection (always on) + removal (opt-in
// via --remove-orphans) — see build_orphans.go. Never runs for --check,
// which returns before ComposeFile is generated.
if !check && result.ComposeFile != "" {
reportAndHandleOrphans(workdir, result, removeOrphans, quiet)
}

now := time.Now()
dormant, autoRemove := store.CheckExpiry(now)

// Print dormant banners.
for _, name := range dormant {
if rec, ok := store.Records[name]; ok && !quiet {
ui.Warn(plugin.DormantBanner(rec, now))
}
}

// Print banners for already-dormant plugins (transitioned in a prior run).
for name, rec := range store.Records {
if rec.State == plugin.StateDormant {
alreadyPrinted := false
for _, d := range dormant {
if d == name {
alreadyPrinted = true
break
}
}
if !alreadyPrinted && !quiet {
ui.Warn(plugin.DormantBanner(rec, now))
}
}
}

// Auto-remove expired plugins.
for _, name := range autoRemove {
if !quiet {
ui.Warn(fmt.Sprintf("Removing expired plugin %q (grace period exhausted)", name))
}
cfg, cfgErr := config.Load(".")
if cfgErr != nil {
// Fall back to default plugin dir.
cfg = &config.Config{}
}
pluginDir := resolvePluginDir()
if removeErr := plugin.Remove(context.Background(), cfg, name, pluginDir, false, true); removeErr != nil {
if !quiet {
ui.Warn(fmt.Sprintf("Auto-remove of %q failed: %v", name, removeErr))
}
} else {
// Clear the record after successful removal.
delete(store.Records, name)
}
}

// Persist transitions (dormant → expired state changes).
if len(dormant) > 0 || len(autoRemove) > 0 {
if saveErr := store.Save(); saveErr != nil && !quiet {
ui.Warn("Could not save plugin lifecycle store: " + saveErr.Error())
}
}
return nil
}
132 changes: 132 additions & 0 deletions cmd/commands/build_orphans.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package commands

// build_orphans.go — G-014: `nself build` orphan-container detection
// (always on) and removal (opt-in via --remove-orphans).
//
// WHY: nself build only ever generated files; it never looked at the live
// daemon, so a service dropped from the generated compose (removed from
// nself.yaml, an uninstalled plugin, a rename) left its container running
// forever with no service definition, no nginx vhost, and no traffic —
// unreported. Measured live: nself-claw/notify/mux/cron survived an earlier
// build this way; two had been dead a full month (DB DNS failure) before
// anyone noticed, because a second bug made their healthchecks permanently
// meaningless too (see internal/doctor/deep_docker_healthcheck.go).
//
// Purpose: report every such container prominently after every build, and
// remove them only when the operator opted in.
// Inputs: workdir (the project root just built), result (the BuildResult
// from build.Build), removeOrphans (--remove-orphans), quiet.
// Outputs: none — prints to stdout/stderr; never returns an error, because
// orphan detection is advisory and must never fail `nself build`
// itself (a Docker-less CI image must still get a successful build).
// Constraints: project-scoped via internal/docker.DetectOrphans's compose
// project label filter (see that file's scoping note) — never
// touches a container from another project or a non-compose
// container.

import (
"context"
"fmt"
"log/slog"
"os"
"time"

"github.com/nself-org/cli/internal/build"
"github.com/nself-org/cli/internal/docker"
"github.com/nself-org/cli/internal/ui"
)

// detectOrphansForProject resolves the effective compose file set for
// workdir (base + plugin fragments, falling back to fallbackComposeFile
// when the manifest is missing) and returns the containers orphaned against
// it. Shared by `nself build` (reportAndHandleOrphans, below) and `nself
// status` (see status.go), which both need the same detection but differ on
// what to do with the result — build offers --remove-orphans, status is
// read-only. Returns (nil, nil) whenever detection is inconclusive (compose
// files unreadable, docker unreachable); callers must treat that as "nothing
// to report", never as a hard failure of their own command.
func detectOrphansForProject(ctx context.Context, workdir, projectName, fallbackComposeFile string) []docker.OrphanContainer {
composeFiles, err := build.ReadComposeManifest(workdir)
if err != nil || len(composeFiles) == 0 {
composeFiles = []string{fallbackComposeFile}
}

defined, err := docker.ComposeServiceNames(composeFiles)
if err != nil {
slog.Debug("G-014 orphan detection: could not read compose files", "err", err)
return nil
}

orphans, err := docker.DetectOrphans(ctx, projectName, defined)
if err != nil {
slog.Debug("G-014 orphan detection: docker unavailable, skipping", "err", err)
return nil
}
return orphans
}

// reportAndHandleOrphans runs orphan detection for the project just built
// and, if --remove-orphans was passed, removes what it finds. Best-effort
// throughout: any docker-level failure (daemon unreachable, docker not
// installed) is logged at debug level and swallowed so `nself build` itself
// never fails because of it.
func reportAndHandleOrphans(workdir string, result *build.BuildResult, removeOrphans, quiet bool) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

orphans := detectOrphansForProject(ctx, workdir, result.ProjectName, result.ComposeFile)
if len(orphans) == 0 {
return
}

if !quiet {
printOrphanWarning(orphans)
}

if !removeOrphans {
if !quiet {
ui.Warn(fmt.Sprintf("Run 'nself build --remove-orphans' to remove the %d container(s) above.", len(orphans)))
}
return
}

removeErrs := docker.RemoveOrphans(ctx, orphans)
if quiet {
return
}
removed := len(orphans) - len(removeErrs)
if removed > 0 {
ui.Success(fmt.Sprintf("Removed %d orphaned container(s)", removed))
}
for _, e := range removeErrs {
ui.Warn(e.Error())
}
}

// printOrphanWarning prints the prominent, always-on orphan report: one
// line per container so an operator scanning build output cannot miss it.
func printOrphanWarning(orphans []docker.OrphanContainer) {
ui.Warn(fmt.Sprintf("%d orphaned container(s) found — running but with no matching service in the generated compose:", len(orphans)))
for _, o := range orphans {
service := o.Service
if service == "" {
service = "(none)"
}
fmt.Fprintf(os.Stderr, " %-30s service=%-12s state=%s\n", o.Name, service, o.State)
}
}

// printOrphanStatusHint runs the same G-014 orphan detection for `nself
// status` (read-only — status never removes anything; that stays build's
// --remove-orphans opt-in) and, if any are found, prints a warning block
// pointing at the fix. Best-effort and silent on any docker-level failure —
// status must never fail or slow down because of this.
func printOrphanStatusHint(ctx context.Context, workdir, projectName, composeFile string) {
orphans := detectOrphansForProject(ctx, workdir, projectName, composeFile)
if len(orphans) == 0 {
return
}
fmt.Println()
printOrphanWarning(orphans)
ui.Warn("Run 'nself build --remove-orphans' to remove them.")
}
84 changes: 84 additions & 0 deletions cmd/commands/build_plugin_lifecycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package commands

// build_plugin_lifecycle.go — plugin dormant/expiry banner + auto-removal
// for `nself build`. Split out of build.go (kept build.go under the
// 300-line cap when G-014 orphan-container detection was added) as a pure
// move: same checks/output/errors/order, no behavior change.

import (
"context"
"fmt"
"time"

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

// runPluginLifecycleCheck loads the lifecycle store, transitions expired plugins,
// prints dormant banners, and auto-removes fully-expired plugins.
// Auto-removal is intentionally build-only (not start) — start is read-only on lifecycle.
func runPluginLifecycleCheck(quiet bool) {
store, err := plugin.LoadLifecycleStore()
if err != nil {
// Non-fatal: lifecycle store is advisory only.
if !quiet {
ui.Warn("Could not load plugin lifecycle store: " + err.Error())
}
return
}

now := time.Now()
dormant, autoRemove := store.CheckExpiry(now)

// Print dormant banners.
for _, name := range dormant {
if rec, ok := store.Records[name]; ok && !quiet {
ui.Warn(plugin.DormantBanner(rec, now))
}
}

// Print banners for already-dormant plugins (transitioned in a prior run).
for name, rec := range store.Records {
if rec.State == plugin.StateDormant {
alreadyPrinted := false
for _, d := range dormant {
if d == name {
alreadyPrinted = true
break
}
}
if !alreadyPrinted && !quiet {
ui.Warn(plugin.DormantBanner(rec, now))
}
}
}

// Auto-remove expired plugins.
for _, name := range autoRemove {
if !quiet {
ui.Warn(fmt.Sprintf("Removing expired plugin %q (grace period exhausted)", name))
}
cfg, cfgErr := config.Load(".")
if cfgErr != nil {
// Fall back to default plugin dir.
cfg = &config.Config{}
}
pluginDir := resolvePluginDir()
if removeErr := plugin.Remove(context.Background(), cfg, name, pluginDir, false, true); removeErr != nil {
if !quiet {
ui.Warn(fmt.Sprintf("Auto-remove of %q failed: %v", name, removeErr))
}
} else {
// Clear the record after successful removal.
delete(store.Records, name)
}
}

// Persist transitions (dormant → expired state changes).
if len(dormant) > 0 || len(autoRemove) > 0 {
if saveErr := store.Save(); saveErr != nil && !quiet {
ui.Warn("Could not save plugin lifecycle store: " + saveErr.Error())
}
}
}
8 changes: 8 additions & 0 deletions cmd/commands/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"time"

"github.com/nself-org/cli/internal/build"
Expand Down Expand Up @@ -101,6 +102,13 @@ Exit codes:

printStatusTable(report, verbose, healthOnly, metrics)

// G-014: surface orphaned containers (running with no matching
// service in the current compose) here too, not just at build time —
// a container can go orphaned between builds if someone removes it
// from nself.yaml and never reruns `nself build`. Read-only: status
// never removes anything itself.
printOrphanStatusHint(ctx, cwd, cfg.ProjectName, filepath.Join(cwd, "docker-compose.yml"))

// Show installed plugin versions below the service health table.
pluginDir := build.DefaultPluginDir()
if plugins, pluginErr := plugin.ListInstalled(pluginDir); pluginErr == nil && len(plugins) > 0 {
Expand Down
Loading
Loading