diff --git a/CHANGELOG.md b/CHANGELOG.md index 036d934..1f10d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,57 @@ useful than saying where the real one is. ## [Unreleased] +### Added + +- **An engine per project** ([#539](https://github.com/wslkit/skrog/issues/539)). + `skrog target create ` installs a second, fully independent engine: its own WSL + distro and virtual disk, supervisor, pipe (`\\.\pipe\skrog-`), docker context + (`skrog-`), images, containers, volumes, settings and snapshots. A poisoned + image in one cannot be run from another, and each can pin its own dockerd version. + `skrog use |local` switches docker (and everything that follows the docker + context) between the shared engine, a named engine and a remote; `skrog target list` + shows all three with state, dockerd version and disk, per engine and in total; + `skrog target remove` deletes one, and nothing else. An engine is created stopped, + started by `skrog use`, and idle-stopped like any other, so the cost is disk rather + than memory. `skrog remote use` still works and is the same command. Names are + lowercase letters, digits, `-` and `_`; `local` and `engine` are reserved, and a name + cannot be both an engine and a remote. See [docs/targets.md](docs/targets.md). +- **`target:` in `skrog.yaml`.** `skrog install --config` creates the named engine when + it is missing, converges the file's settings onto it, and selects it. It is `target:` + rather than the `engine:` the design drafted, because `engine:` already holds + `daemon.json` settings. +- **`skrog autostart --engine `** (and `skrog target create --autostart`) gives one + named engine its own logon entry, a separate Run-key value, leaving the default + engine's alone. Named engines do not start at logon unless asked. +- **`skrog engine`, `skrog config` and `skrog status` act on the current target** when + `--state-dir` is not given, so `skrog engine upgrade` changes the engine docker is + talking to. Nothing changes, and nothing extra is run, on a machine with no named + engines. `skrog status --json` gains `target`, additively. +- **The tray follows the current target.** The dot describes the engine docker is on, the + tooltip names it when it is a named engine, and Start, Stop, Restart and Open logs act + on it. The menu is still the same seven items. +- **`skrog doctor` judges the disk floor against every engine.** The free-space check + uses the fullest volume any engine lives on, lists what each holds, and names + `skrog target remove` as the way out. + +### Known limits + +- Emulation (`emulation.platforms`) registers `binfmt_misc` handlers, which are kernel + state shared by every WSL2 distro. Stopping or removing any engine deregisters them + for all, so with several engines running an idle-stop of one can take foreign + architecture support away from another until an engine next starts. + +### Changed + +- `skrog uninstall` also removes the named engines under the same state directory, and its + prompt lists them; running it against a named engine's own state directory is refused in + favour of `skrog target remove`. +- The install flags that choose which engine to install and from where (`--locked`, + `--offline`, `--rootfs-url`, `--engine-version`) are now one shared code path used by + `skrog install` and `skrog target create`. Behaviour is unchanged. +- The verified rootfs download is cached in the base state directory when installing a + named engine, so a second engine does not download it again. + ## [0.10.0] — 2026-10-12 The engine moves to 29.9.0, built with the Go release that fixes five diff --git a/README.md b/README.md index a98bc80..eb73e54 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,14 @@ docker context. Nothing else on the system is touched. teammate or CI runner, reachable only by holders of a client cert this machine's CA signed — off by default; on the client, `skrog remote add/use` makes it docker's default in one command ([docs/remote-engine.md](docs/remote-engine.md)) +- **An engine per project**: `skrog target create proj-a` makes a second, fully isolated + engine — its own distro, disk, supervisor, pipe, docker context, images and volumes — + and `skrog use proj-a` points docker, and anything following the context, at it. A + poisoned image in one cannot be run from another, and each can pin its own dockerd + version. Created stopped, started on demand, idle-stopped like any engine, so the cost + is disk rather than memory, and `skrog target list` shows it per engine and in total. + `target: proj-a` in a `skrog.yaml` selects it per workspace. Docker Desktop is one + global engine ([docs/targets.md](docs/targets.md)) - **`skrog status --stats`**: container/image/volume counts and reclaimable space, the VHDX footprint, VM memory and CPUs (configured versus actual), engine and supervisor uptime with idle-stop history, and bridge counters including **which transport is live** @@ -327,6 +335,13 @@ it: Compose, Testcontainers, Dev Containers, buildx, `act`, `gitlab-ci-local`, D Skrog serves that endpoint — the real `docker` API on `\\.\pipe\docker_engine` — which is why that list works here without any of those tools knowing Skrog exists. +**Per-project engines** are something Docker Desktop structurally cannot do: it is one +global engine, with one pool of images and containers for every project. Rancher Desktop +runs one engine as well. `skrog target create` makes an isolated engine per project, and +`skrog use` moves docker between them; [docs/targets.md](docs/targets.md) says what that +costs. Podman can create several machines of its own, and this comparison has not tried +to weigh that against it. + **What none of them do** is let you pin the engine. `skrog lock` writes the exact dockerd, containerd, runc and BuildKit commits; `setup-skrog` installs that same file on the runner. Docker Desktop ships whatever version it ships, and updates it on its own schedule. @@ -408,6 +423,7 @@ page is reachable from here. [CI runners](docs/ci-runners.md) · [Unattended / auto-logon runners](docs/auto-logon-runner.md) · [Declarative install](docs/declarative-install.md) · +[Per-workspace engines](docs/targets.md) · [Profiles](docs/profiles.md) · [Remote engine over mTLS](docs/remote-engine.md) · [Local CI](docs/local-ci.md) · @@ -426,7 +442,7 @@ page is reachable from here. [Releasing](RELEASING.md) · [Bumping the engine and docker CLI](docs/bumping-upstream.md) · [Design notes](docs/design/) · -[Per-workspace engines](docs/design/per-workspace-engine.md) +[Design: per-workspace engines](docs/design/per-workspace-engine.md) Editing the docs edits the site: `pwsh -File scripts/build-docs.ps1 -Serve` previews it locally, and merging to `main` publishes it. Nothing in `site/` is diff --git a/cmd/skrog/autostart.go b/cmd/skrog/autostart.go index 8f78476..4731c34 100644 --- a/cmd/skrog/autostart.go +++ b/cmd/skrog/autostart.go @@ -11,8 +11,9 @@ import ( func runAutostart(args []string) int { fs := flag.NewFlagSet("autostart", flag.ContinueOnError) + engine := fs.String("engine", "", "act on this named engine's logon entry instead of the default engine's") fs.Usage = func() { - fmt.Fprint(os.Stderr, `usage: skrog autostart enable|disable|status + fmt.Fprint(os.Stderr, `usage: skrog autostart [--engine ] enable|disable|status Controls whether the supervisor starts at logon, via a per-user Run entry (visible and switchable in Task Manager's Startup tab; no admin needed). The @@ -22,11 +23,19 @@ install registers this by default; uninstall removes it. enable and disable also record the choice, exactly as `+"`skrog config set\nautostart on|off`"+` does, so `+"`skrog doctor`"+` can tell an entry turned off on purpose from one that went missing, and put a missing one back with --fix. + +--engine acts on a named engine's own logon entry (skrog target create +--autostart does the same at creation) and leaves the default engine's alone. +A named engine starts at logon only when you ask: by default it starts when you +`+"`skrog use`"+` it, and stops itself when idle. `) } if err := fs.Parse(args); err != nil { return exitUsage } + if *engine != "" { + return autostartEngine(*engine, fs.Arg(0)) + } // Both verbs record the choice as well as changing the Run entry (#515), // so doctor can tell an entry turned off on purpose from one that went diff --git a/cmd/skrog/config.go b/cmd/skrog/config.go index 732f906..b47c995 100644 --- a/cmd/skrog/config.go +++ b/cmd/skrog/config.go @@ -124,7 +124,7 @@ so they live under `+"`skrog wsl-config --help`"+` with the diff and the caveats return exitUsage } - opts := optsWithResolvedStateDir(provision.Options{StateDir: *stateDir}) + opts := optsWithResolvedStateDir(provision.Options{StateDir: scopedStateDir(*stateDir)}) rest := fs.Args() switch { diff --git a/cmd/skrog/declare.go b/cmd/skrog/declare.go index 90f4037..d907a99 100644 --- a/cmd/skrog/declare.go +++ b/cmd/skrog/declare.go @@ -16,6 +16,7 @@ import ( "github.com/wslkit/skrog/internal/provision" "github.com/wslkit/skrog/internal/release" "github.com/wslkit/skrog/internal/skrogfile" + tgt "github.com/wslkit/skrog/internal/target" "github.com/wslkit/skrog/internal/wsl" ) @@ -146,6 +147,20 @@ func applySkrogFile(ctx context.Context, f skrogfile.File, opts provision.Option } } + // A named engine (#539) is never autostarted by default: it starts when + // you `skrog use` it and stops when idle, so ten engines do not become ten + // logon entries. Only an explicit autostart: true gives it one, and the + // default engine's entry is not this file's to touch. + if name, ok := tgt.NameOf(sd); ok { + if f.Autostart == nil { + return nil + } + if err := applyEngineAutostart(name, sd, *f.Autostart && !noAutostart); err != nil { + log.Warn("autostart not applied", "reason", err) + } + return nil + } + // Autostart: enabled by default (matching plain install), overridden by the // file's explicit value, and forced off by --no-autostart which always wins. want := !noAutostart diff --git a/cmd/skrog/engine.go b/cmd/skrog/engine.go index a63c356..9dbebb0 100644 --- a/cmd/skrog/engine.go +++ b/cmd/skrog/engine.go @@ -105,7 +105,7 @@ func runEngineList(args []string) int { fmt.Fprintf(os.Stderr, "skrog: %v\n", err) return exitError } - opts := optsWithResolvedStateDir(provision.Options{StateDir: *stateDir}) + opts := optsWithResolvedStateDir(provision.Options{StateDir: scopedStateDir(*stateDir)}) p := &provision.Provisioner{Logger: cliLogger(false)} installed, previous := "", "" if im, err := p.ReadManifest(opts); err == nil { @@ -238,7 +238,7 @@ flags: ctx, stop := interruptible() defer stop() - opts := optsWithResolvedStateDir(provision.Options{StateDir: *stateDir}) + opts := optsWithResolvedStateDir(provision.Options{StateDir: scopedStateDir(*stateDir)}) p := &provision.Provisioner{Logger: cliLogger(false)} distro, ok := requireDistroInstall(p, opts) if !ok { diff --git a/cmd/skrog/install.go b/cmd/skrog/install.go index f6d08b5..bad01f5 100644 --- a/cmd/skrog/install.go +++ b/cmd/skrog/install.go @@ -9,18 +9,17 @@ import ( "os" "os/signal" "path/filepath" + "strings" "github.com/wslkit/skrog/internal/autostart" - "github.com/wslkit/skrog/internal/bundle" "github.com/wslkit/skrog/internal/config" "github.com/wslkit/skrog/internal/dockerctx" "github.com/wslkit/skrog/internal/integrate" - "github.com/wslkit/skrog/internal/lockfile" "github.com/wslkit/skrog/internal/logging" "github.com/wslkit/skrog/internal/pipeproxy" "github.com/wslkit/skrog/internal/provision" - "github.com/wslkit/skrog/internal/release" "github.com/wslkit/skrog/internal/supervise" + tgt "github.com/wslkit/skrog/internal/target" "github.com/wslkit/skrog/internal/wsl" ) @@ -96,88 +95,16 @@ flags: Headless: *headless, } - if n := boolCount(*locked != "", *rootfsURL != "", *offline != ""); n > 1 { - fmt.Fprintln(os.Stderr, "skrog: choose at most one of --locked, --offline, --rootfs-url") - return exitUsage - } - - // An explicit URL bypasses the manifest, so it must carry its own digest: - // there is no code path that imports an unverified rootfs. - switch { - case *offline != "": - b, err := bundle.Open(*offline) - if err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - return exitError - } - defer b.Close() - l := b.Lock() - // Extract the bundled rootfs beside the state dir; the install below then - // copies it into the cache and verifies its SHA-256 — the same verified - // path a networked install uses, so "offline" adds no unchecked import. - sd := optsWithResolvedStateDir(opts).StateDir - extracted := filepath.Join(sd, "offline", bundle.ExtractedRootfsName) - if err := b.ExtractRootfs(extracted); err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - return exitError - } - defer os.RemoveAll(filepath.Dir(extracted)) - opts.RootfsURL = extracted - opts.RootfsSHA256 = l.Rootfs.SHA256 - opts.EngineVersion = l.EngineVersion - log.Info("installing offline from bundle", "path", *offline, "engine", l.EngineVersion) - case *locked != "": - l, err := lockfile.Load(*locked) - if err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - return exitError - } - // The lock's rootfs URL + SHA-256 pin the artifact; the existing verified - // download refuses on any mismatch, so a tampered lock cannot install - // something else. - opts.RootfsURL = l.Rootfs.URL - opts.RootfsSHA256 = l.Rootfs.SHA256 - opts.EngineVersion = l.EngineVersion - log.Info("installing from lockfile", "path", *locked, "engine", l.EngineVersion) - case *rootfsURL != "" && *rootfsSHA == "": - fmt.Fprintln(os.Stderr, "skrog: --rootfs-url requires --rootfs-sha256") - return exitUsage - case *rootfsURL != "": - opts.RootfsURL = *rootfsURL - opts.RootfsSHA256 = *rootfsSHA - opts.EngineVersion = *engineVersion - log.Warn("using an overridden rootfs; this is a development path, not a release install", - "url", *rootfsURL) - default: - m, err := release.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - return exitError - } - engine, err := m.Engine(*engineVersion) - if err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - return exitUsage - } - // Pick the rootfs for this host's architecture, and refuse HERE rather - // than after a 200 MB download, a passing SHA-256 and a successful - // `wsl --import` that leaves the user with a distro whose every binary - // is the wrong ISA (#388). - // - // Only this branch selects: --rootfs-url, --offline and --locked all - // carry bytes the caller chose, and skrog does not second-guess those. - rootfs, err := engine.HostRootfs() - if err != nil { - fmt.Fprintf(os.Stderr, "skrog: %v\n", err) - var unsupported *release.ErrUnsupportedHostArch - if errors.As(err, &unsupported) { - return exitUnsupported - } - return exitError - } - opts.RootfsURL = rootfs.URL - opts.RootfsSHA256 = rootfs.SHA256 - opts.EngineVersion = engine.Version + opts, cleanup, code := applyInstallSource(opts, installSource{ + engineVersion: *engineVersion, + locked: *locked, + offline: *offline, + rootfsURL: *rootfsURL, + rootfsSHA: *rootfsSHA, + }, log) + defer cleanup() + if code != exitOK { + return code } // Signature verification is opt-in (#147) and lives in config so a fleet @@ -382,6 +309,10 @@ state. Nothing else on the system is touched. This DELETES the distro, and with it every image, container and volume it holds. Export anything you want to keep first. +Named engines (`+"`skrog target create`"+`) under the same state directory are +removed too, and the prompt lists them. To remove just one, use +`+"`skrog target remove `"+`. + Exit codes: 0 ok, %d error. flags: @@ -396,6 +327,19 @@ flags: log := cliLogger(false) p := &provision.Provisioner{Logger: log} + // A named engine is removed with `skrog target remove`; uninstalling one as + // if it were an install would leave its context and logon entry behind. + baseDir := optsWithResolvedStateDir(opts).StateDir + if name, ok := tgt.NameOf(baseDir); ok { + fmt.Fprintf(os.Stderr, "skrog: %s is the state of the engine %q; remove it with `skrog target remove %s`\n", + baseDir, name, name) + return exitUsage + } + // The default engine's uninstall promises to remove everything Skrog created, + // and the named engines under this state dir are Skrog's. They go too, and the + // prompt says so. + namedEngines, _ := tgt.List(baseDir) + // Say what will actually be destroyed, using the recorded manifest rather // than a guess, before asking. ownManifest, _ := p.ReadManifest(opts) @@ -408,7 +352,12 @@ flags: if !*yes { fmt.Printf("This will unregister the WSL distro %q and delete all images, "+ - "containers and volumes in it.\nThis cannot be undone. Continue? [y/N] ", target) + "containers and volumes in it.\n", target) + if len(namedEngines) > 0 { + fmt.Printf("It will also remove the %d named engine(s) %s, with everything in them.\n", + len(namedEngines), strings.Join(namedEngines, ", ")) + } + fmt.Print("This cannot be undone. Continue? [y/N] ") var answer string fmt.Scanln(&answer) if answer != "y" && answer != "Y" { @@ -420,6 +369,13 @@ flags: ctx, stop := interruptible() defer stop() + for _, name := range namedEngines { + if code := targetRemove(ctx, baseDir, name, true); code != exitOK { + fmt.Fprintf(os.Stderr, "skrog: could not remove engine %q; the default engine was not uninstalled\n", name) + return code + } + } + // Autostart goes first: a Run entry pointing at a binary that is about to // stop having anything to supervise would relaunch a supervisor into an // empty install at next logon. Ownership-checked, so uninstalling a diff --git a/cmd/skrog/installsource.go b/cmd/skrog/installsource.go new file mode 100644 index 0000000..ef8ad4f --- /dev/null +++ b/cmd/skrog/installsource.go @@ -0,0 +1,121 @@ +package main + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/wslkit/skrog/internal/bundle" + "github.com/wslkit/skrog/internal/lockfile" + "github.com/wslkit/skrog/internal/provision" + "github.com/wslkit/skrog/internal/release" +) + +// installSource is the "which engine, from where" half of an install's flags. +// `skrog install` and `skrog target create` take the same ones, so the choice +// is made in one place: two copies of "an explicit URL must carry its own +// digest" is how one of them stops being true. +type installSource struct { + engineVersion string + locked string // --locked: a skrog.lock + offline string // --offline: an air-gap bundle + rootfsURL string // --rootfs-url, with rootfsSHA + rootfsSHA string +} + +// applyInstallSource fills the rootfs fields of opts from src. The returned +// cleanup must be called when the install is over (it removes an extracted +// offline rootfs); it is never nil. The int is exitOK when the install should +// go on, and the exit code to return otherwise, with the message already +// printed. +func applyInstallSource(opts provision.Options, src installSource, log *slog.Logger) (provision.Options, func(), int) { + cleanup := func() {} + + if n := boolCount(src.locked != "", src.rootfsURL != "", src.offline != ""); n > 1 { + fmt.Fprintln(os.Stderr, "skrog: choose at most one of --locked, --offline, --rootfs-url") + return opts, cleanup, exitUsage + } + + // An explicit URL bypasses the manifest, so it must carry its own digest: + // there is no code path that imports an unverified rootfs. + switch { + case src.offline != "": + b, err := bundle.Open(src.offline) + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return opts, cleanup, exitError + } + defer b.Close() + l := b.Lock() + // Extract the bundled rootfs beside the state dir; the install below then + // copies it into the cache and verifies its SHA-256 — the same verified + // path a networked install uses, so "offline" adds no unchecked import. + sd := optsWithResolvedStateDir(opts).StateDir + extracted := filepath.Join(sd, "offline", bundle.ExtractedRootfsName) + if err := b.ExtractRootfs(extracted); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return opts, cleanup, exitError + } + defer os.RemoveAll(filepath.Dir(extracted)) + opts.RootfsURL = extracted + opts.RootfsSHA256 = l.Rootfs.SHA256 + opts.EngineVersion = l.EngineVersion + log.Info("installing offline from bundle", "path", src.offline, "engine", l.EngineVersion) + case src.locked != "": + l, err := lockfile.Load(src.locked) + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return opts, cleanup, exitError + } + // The lock's rootfs URL + SHA-256 pin the artifact; the existing verified + // download refuses on any mismatch, so a tampered lock cannot install + // something else. + opts.RootfsURL = l.Rootfs.URL + opts.RootfsSHA256 = l.Rootfs.SHA256 + opts.EngineVersion = l.EngineVersion + log.Info("installing from lockfile", "path", src.locked, "engine", l.EngineVersion) + case src.rootfsURL != "" && src.rootfsSHA == "": + fmt.Fprintln(os.Stderr, "skrog: --rootfs-url requires --rootfs-sha256") + return opts, cleanup, exitUsage + case src.rootfsURL != "": + opts.RootfsURL = src.rootfsURL + opts.RootfsSHA256 = src.rootfsSHA + opts.EngineVersion = src.engineVersion + log.Warn("using an overridden rootfs; this is a development path, not a release install", + "url", src.rootfsURL) + default: + m, err := release.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return opts, cleanup, exitError + } + engine, err := m.Engine(src.engineVersion) + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return opts, cleanup, exitUsage + } + // Pick the rootfs for this host's architecture, and refuse HERE rather + // than after a 200 MB download, a passing SHA-256 and a successful + // `wsl --import` that leaves the user with a distro whose every binary + // is the wrong ISA (#388). + // + // Only this branch selects: --rootfs-url, --offline and --locked all + // carry bytes the caller chose, and skrog does not second-guess those. + rootfs, err := engine.HostRootfs() + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + var unsupported *release.ErrUnsupportedHostArch + if errors.As(err, &unsupported) { + return opts, cleanup, exitUnsupported + } + return opts, cleanup, exitError + } + opts.RootfsURL = rootfs.URL + opts.RootfsSHA256 = rootfs.SHA256 + opts.EngineVersion = engine.Version + } + + return opts, cleanup, exitOK +} diff --git a/cmd/skrog/jsonshapes.go b/cmd/skrog/jsonshapes.go index 1d33880..c6227b8 100644 --- a/cmd/skrog/jsonshapes.go +++ b/cmd/skrog/jsonshapes.go @@ -4,6 +4,7 @@ import ( "github.com/wslkit/skrog/internal/policy" "github.com/wslkit/skrog/internal/remote" "github.com/wslkit/skrog/internal/runner" + tgt "github.com/wslkit/skrog/internal/target" "github.com/wslkit/skrog/internal/vmtop" ) @@ -21,14 +22,19 @@ type statusJSON struct { // session backend was removed (#451), and still always present once // installed: dropping a key from a pinned contract (#179) breaks a // consumer that switches on it, where an unchanging value does not. - Backend string `json:"backend,omitempty"` - Distro string `json:"distro,omitempty"` - StateDir string `json:"stateDir"` - Supervisor string `json:"supervisor"` // running | stopped - Engine string `json:"engine"` // running | idle | stopped - Desired string `json:"desired"` // running | stopped - Profile string `json:"profile,omitempty"` - GPU gpuJSON `json:"gpu"` + Backend string `json:"backend,omitempty"` + Distro string `json:"distro,omitempty"` + StateDir string `json:"stateDir"` + Supervisor string `json:"supervisor"` // running | stopped + Engine string `json:"engine"` // running | idle | stopped + Desired string `json:"desired"` // running | stopped + Profile string `json:"profile,omitempty"` + // Target is the named engine this status describes (#539), absent for the + // default shared engine. Without --state-dir, status describes the engine + // docker is currently on, so a consumer that follows the docker context + // (the tray) reads the engine it is actually talking to. + Target string `json:"target,omitempty"` + GPU gpuJSON `json:"gpu"` // Endpoint is where the engine is answering (#273). Absent when no // supervisor is running: nothing is being served then, and naming a pipe // would be a guess rather than a report. @@ -437,3 +443,43 @@ type topJSON struct { // was running; the stream carries on rather than ending on one bad read. Error string `json:"error,omitempty"` } + +// targetListJSON is `skrog target list --json` (#539). current is the target +// docker is on now: "local", a named engine, or a remote's name, and "" when +// docker is on some context that is none of them. +type targetListJSON struct { + Current string `json:"current"` + Targets []targetEntryJSON `json:"targets"` + // TotalDiskBytes is the disk the local engines hold together; the figure + // the free-space floor has to be compared against once there are several. + TotalDiskBytes int64 `json:"totalDiskBytes"` +} + +// targetEntryJSON is one target. kind is "local" (the default shared engine), +// "engine" (a named local engine) or "remote". state is running | stopped | +// incomplete | not-installed, and absent for a remote. +type targetEntryJSON struct { + Name string `json:"name"` + Kind string `json:"kind"` + Current bool `json:"current"` + State string `json:"state,omitempty"` + Context string `json:"context"` + Distro string `json:"distro,omitempty"` + StateDir string `json:"stateDir,omitempty"` + Pipe string `json:"pipe,omitempty"` + Host string `json:"host,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + DiskBytes int64 `json:"diskBytes,omitempty"` + // Autostart is present for named engines only: whether this engine's + // supervisor starts at logon. + Autostart *bool `json:"autostart,omitempty"` +} + +// targetCreateJSON is `skrog target create --json`. +type targetCreateJSON struct { + tgt.Layout + EngineVersion string `json:"engineVersion"` + // DockerContext reports whether the skrog- context was created; false + // on a machine with no docker CLI to hold it. + DockerContext bool `json:"dockerContext"` +} diff --git a/cmd/skrog/main.go b/cmd/skrog/main.go index c51fb51..cde35e5 100644 --- a/cmd/skrog/main.go +++ b/cmd/skrog/main.go @@ -67,7 +67,9 @@ func commands() []command { {"stop", "stop the engine; it stays stopped until start", runStop}, {"supervise", "serve the pipe and keep the engine alive (the always-on layer)", runSupervise}, {"top", "where the WSL VM's memory and CPU go: containers, engine, page cache, Vmmem", runTop}, + {"target", "per-workspace engines: create, list, remove isolated engines (and remotes) to switch between", runTarget}, {"uninstall", "remove the engine distro and Skrog's state", runUninstall}, + {"use", "point docker at a target: the shared engine, a named engine, or a remote", runUse}, {"upgrade", "am I current? app, engine and bundled CLI in one answer", runUpgrade}, {"wsl-integrate", "point docker inside your own WSL distros at the engine", runWSLIntegrate}, {"wsl-config", "right-size the WSL2 VM: show and apply ~/.wslconfig sizing, with consent", runWSLConfig}, @@ -198,6 +200,7 @@ var subcommands = map[string][]string{ "profile": {"list", "create", "switch", "show", "delete"}, "remote": {"list", "add", "use", "test", "remove"}, "runner": {"check"}, + "target": {"list", "create", "remove"}, "serve": {"cert"}, "snapshot": {"list", "save", "restore", "delete"}, "wsl-config": {"show", "apply"}, diff --git a/cmd/skrog/remote.go b/cmd/skrog/remote.go index 51741e9..d36e4cd 100644 --- a/cmd/skrog/remote.go +++ b/cmd/skrog/remote.go @@ -12,8 +12,8 @@ import ( "time" "github.com/wslkit/skrog/internal/dockerctx" - "github.com/wslkit/skrog/internal/provision" "github.com/wslkit/skrog/internal/remote" + tgt "github.com/wslkit/skrog/internal/target" ) // runRemote is `skrog remote`: the client side of `skrog serve` (#138). @@ -50,8 +50,8 @@ flags: return exitUsage } - opts := optsWithResolvedStateDir(provision.Options{StateDir: *stateDir}) - rm := &remote.Manager{StateDir: opts.StateDir} + base := targetBase(*stateDir) + rm := &remote.Manager{StateDir: base} dc := &dockerctx.Manager{} ctx := context.Background() rest := fs.Args() @@ -60,9 +60,9 @@ flags: case len(rest) == 0 || (rest[0] == "list" && len(rest) == 1): return remoteList(ctx, rm, dc, *asJSON) case rest[0] == "add" && len(rest) == 2: - return remoteAdd(ctx, rm, dc, rest[1], *host, *certs) + return remoteAdd(ctx, rm, dc, base, rest[1], *host, *certs) case rest[0] == "use" && len(rest) == 2: - return remoteUse(ctx, rm, dc, rest[1]) + return useTarget(ctx, base, rest[1]) case rest[0] == "test" && len(rest) == 2: return remoteTest(ctx, rm, rest[1], *asJSON) case rest[0] == "remove" && len(rest) == 2: @@ -73,7 +73,11 @@ flags: } } -func remoteAdd(ctx context.Context, rm *remote.Manager, dc *dockerctx.Manager, name, host, certs string) int { +func remoteAdd(ctx context.Context, rm *remote.Manager, dc *dockerctx.Manager, base, name, host, certs string) int { + if tgt.ValidName(name) == nil && tgt.Exists(base, name) { + fmt.Fprintf(os.Stderr, "skrog: %q is already a local engine; targets share one namespace, pick another name\n", name) + return exitError + } if host == "" || certs == "" { fmt.Fprintln(os.Stderr, "skrog: remote add needs --host tcp://: and --certs ") return exitUsage diff --git a/cmd/skrog/supervise.go b/cmd/skrog/supervise.go index f65cd94..a28e7a8 100644 --- a/cmd/skrog/supervise.go +++ b/cmd/skrog/supervise.go @@ -24,6 +24,7 @@ import ( "github.com/wslkit/skrog/internal/provision" "github.com/wslkit/skrog/internal/selfexe" "github.com/wslkit/skrog/internal/supervise" + tgt "github.com/wslkit/skrog/internal/target" ) // engineAdapter satisfies supervise.Engine with the provisioner's primitives. @@ -240,6 +241,15 @@ flags: "error", err) } + // A named engine (#539) serves its own pipe and owns its own docker + // context. Both are derived from the state dir, so every way a supervisor + // gets started -- `skrog start`, the logon entry, the watchdog -- agrees + // without each having to be told. An explicit --pipe still wins. + engineName, wiring, named := engineWiring(opts.StateDir) + if named && *pipeName == "" { + *pipeName = wiring.Pipe + } + selected, reason := pipeproxy.SelectPipeName(*pipeName) listener, err := pipeproxy.Listen(selected, "") if err != nil { @@ -268,8 +278,18 @@ flags: }() if !*noContext { - if err := (&dockerctx.Manager{}).Ensure(context.Background(), - pipeproxy.DockerHostFor(selected)); err != nil { + mgr := &dockerctx.Manager{} + host := pipeproxy.DockerHostFor(selected) + var err error + if named { + // Never the shared `skrog` context: that one belongs to the + // default engine, and repointing it from here is the collision + // #217 was about. + err = mgr.EnsureNamed(context.Background(), wiring.Context, "Skrog engine "+engineName, host) + } else { + err = mgr.Ensure(context.Background(), host) + } + if err != nil { log.Warn("docker context not wired", "reason", err) } } @@ -849,7 +869,7 @@ discard exactly the reading worth having. *withStats = true } - opts := optsWithResolvedStateDir(provision.Options{StateDir: *stateDir}) + opts := optsWithResolvedStateDir(provision.Options{StateDir: scopedStateDir(*stateDir)}) p := &provision.Provisioner{Logger: cliLogger(true)} st := statusJSON{ @@ -859,6 +879,9 @@ discard exactly the reading worth having. Desired: string(supervise.ReadDesired(opts.StateDir)), Profile: (&profile.Manager{StateDir: opts.StateDir}).Active(), } + if name, ok := tgt.NameOf(opts.StateDir); ok { + st.Target = name + } if c, err := config.Load(opts.StateDir); err == nil { st.GPU.Enabled = c.GPU st.GPU.Vendor = c.GPUVendor diff --git a/cmd/skrog/target.go b/cmd/skrog/target.go new file mode 100644 index 0000000..c62efc2 --- /dev/null +++ b/cmd/skrog/target.go @@ -0,0 +1,623 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "text/tabwriter" + + "github.com/wslkit/skrog/internal/autostart" + "github.com/wslkit/skrog/internal/dockerctx" + "github.com/wslkit/skrog/internal/integrate" + "github.com/wslkit/skrog/internal/pipeproxy" + "github.com/wslkit/skrog/internal/provision" + "github.com/wslkit/skrog/internal/remote" + "github.com/wslkit/skrog/internal/skrogfile" + tgt "github.com/wslkit/skrog/internal/target" + "github.com/wslkit/skrog/internal/wsl" +) + +// runTarget is `skrog target`: per-workspace engines (#539, design in +// docs/design/per-workspace-engine.md). +// +// A target is which engine docker talks to: the default shared engine +// (`local`), a named local engine, or a remote registered with `skrog remote`. +// The two nouns the CLI already has stay as they were: `skrog engine` is the +// dockerd VERSION of the current target, `skrog target` is WHICH engine. +func runTarget(args []string) int { + fs := flag.NewFlagSet("target", flag.ContinueOnError) + var ( + stateDir = fs.String("state-dir", "", "override Skrog's state directory") + asJSON = fs.Bool("json", false, "emit machine-readable JSON (list, create)") + yes = fs.Bool("yes", false, "skip the confirmation prompt (remove)") + engineVersion = fs.String("engine-version", "", "dockerd version for create (default: this build's default)") + locked = fs.String("locked", "", "create from the exact engine pinned in a skrog.lock") + offline = fs.String("offline", "", "create entirely from an air-gap bundle .zip") + rootfsURL = fs.String("rootfs-url", "", "override the rootfs URL (development)") + rootfsSHA = fs.String("rootfs-sha256", "", "expected rootfs SHA-256; required with --rootfs-url") + headless = fs.Bool("headless", false, "never prompt; for unattended use") + autostartOn = fs.Bool("autostart", false, "create: also start this engine's supervisor at logon") + ) + fs.Usage = func() { + fmt.Fprintf(os.Stderr, `usage: skrog target list targets + skrog target create [flags] a new, isolated engine + skrog target remove [--yes] delete an engine and everything in it + +A target is which engine docker talks to: the default shared engine (`+"`local`"+`), +a named local engine, or a remote from `+"`skrog remote`"+`. Switch with `+"`skrog use`"+`. + +A named engine is a complete, independent install: its own WSL distro and virtual +disk, its own supervisor and pipe, its own docker context (skrog-), its own +images, containers, volumes, settings and snapshots. A poisoned image pulled in one +cannot be run from another, and each can pin its own dockerd version. + +create installs the engine and leaves it stopped: it starts when you `+"`skrog use`"+` +it and stops itself when idle, so many engines cost memory only while one is in +use. They do cost disk (hundreds of MB each before any image is pulled); `+"`list`"+` +shows each engine's disk and the total. + +Names: lowercase letters, digits, - and _ (1 to 32). %q and "engine" are reserved, +and a name may not be one of your remotes. + +remove DELETES the engine's distro and with it every image, container and volume +in it. The default engine and remotes are never touched. + +Flags come before the verb. Exit codes: 0 ok, %d error, %d usage, %d no such target, +%d unsupported platform. + +flags: +`, tgt.Local, exitError, exitUsage, exitNotFound, exitUnsupported) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return exitUsage + } + + base := targetBase(*stateDir) + ctx, stop := interruptible() + defer stop() + rest := fs.Args() + + switch { + case len(rest) == 0 || (rest[0] == "list" && len(rest) == 1): + return targetList(ctx, base, *asJSON) + case rest[0] == "create" && len(rest) == 2: + return targetCreate(ctx, base, rest[1], createFlags{ + src: installSource{ + engineVersion: *engineVersion, + locked: *locked, + offline: *offline, + rootfsURL: *rootfsURL, + rootfsSHA: *rootfsSHA, + }, + headless: *headless, + autostart: *autostartOn, + asJSON: *asJSON, + }) + case rest[0] == "remove" && len(rest) == 2: + return targetRemove(ctx, base, rest[1], *yes) + default: + fs.Usage() + return exitUsage + } +} + +// targetBase is the base state dir, whichever state dir the caller named: an +// engine's own directory resolves to the install it hangs off, so remotes, the +// engine registry and the rootfs cache are found from either. +func targetBase(stateDirFlag string) string { + return tgt.BaseOf(optsWithResolvedStateDir(provision.Options{StateDir: stateDirFlag}).StateDir) +} + +type createFlags struct { + src installSource + headless bool + autostart bool + asJSON bool +} + +// engineOptions are the provisioner options of a named engine. +func engineOptions(base string, l tgt.Layout) provision.Options { + return provision.Options{ + Distro: l.Distro, + StateDir: l.StateDir, + CacheDir: base, + } +} + +func targetCreate(ctx context.Context, base, name string, f createFlags) int { + if err := tgt.ValidName(name); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitUsage + } + if tgt.Exists(base, name) { + fmt.Fprintf(os.Stderr, "skrog: engine %q already exists (`skrog use %s` to switch to it, "+ + "`skrog target remove %s` to start over)\n", name, name, name) + return exitError + } + if (&remote.Manager{StateDir: base}).Exists(name) { + fmt.Fprintf(os.Stderr, "skrog: %q is already a remote; targets share one namespace, pick another name\n", name) + return exitError + } + + l := tgt.For(base, name) + log := cliLogger(f.asJSON) + opts := engineOptions(base, l) + opts.Headless = f.headless + + opts, cleanup, code := applyInstallSource(opts, f.src, log) + defer cleanup() + if code != exitOK { + return code + } + + p := &provision.Provisioner{Logger: log} + log.Info("creating engine", "name", name, "distro", l.Distro, "state", l.StateDir) + manifest, err := p.Install(ctx, opts) + if err != nil { + var pfe *provision.PreflightError + if errors.As(err, &pfe) { + // Nothing was imported, and this directory was created by us a + // moment ago, so leaving it would only make `target list` show an + // engine that never existed. + _ = os.RemoveAll(l.StateDir) + fmt.Fprint(os.Stderr, "skrog: cannot create the engine yet.\n\n") + for _, pr := range pfe.Report.Problems { + fmt.Fprintf(os.Stderr, " %s\n fix: %s\n\n", pr.Summary, pr.Remedy) + } + return exitError + } + fmt.Fprintf(os.Stderr, "skrog: creating engine %q failed: %v\n"+ + " `skrog target remove %s --yes` cleans up whatever was left\n", name, err, name) + return exitError + } + + // Install leaves dockerd running. A created engine has no supervisor yet, + // so nothing would ever stop it: an engine nobody is using would hold + // memory until the next `wsl --shutdown`. `skrog use` starts it again. + if err := p.StopEngine(ctx, opts); err != nil { + log.Warn("could not stop the new engine; `skrog stop` will", "error", err) + } + + // Its own context, on its own pipe, so creating an engine can never + // repoint the default engine's `skrog` context (#217). + host := pipeproxy.DockerHostFor(l.Pipe) + ctxReady := false + if err := (&dockerctx.Manager{}).EnsureNamed(ctx, l.Context, "Skrog engine "+name, host); err != nil { + var noCLI *dockerctx.ErrNoDockerCLI + if errors.As(err, &noCLI) { + log.Warn("docker context not created", "reason", err) + } else { + log.Warn("could not wire the docker context", "error", err) + } + } else { + ctxReady = true + manifest.DockerContextHost = host + if err := p.SaveManifest(opts, manifest); err != nil { + log.Warn("could not record the docker context endpoint", "error", err) + } + } + + if f.autostart { + if err := applyEngineAutostart(name, l.StateDir, true); err != nil { + log.Warn("autostart not registered", "reason", err) + } + } + + if f.asJSON { + return emitJSON(targetCreateJSON{Layout: l, EngineVersion: manifest.EngineVersion, DockerContext: ctxReady}) + } + fmt.Printf("\nEngine %q created (dockerd %s), stopped.\n\n"+ + " distro %s\n state %s\n pipe %s\n context %s\n\n"+ + "Switch to it (this starts it):\n\n skrog use %s\n\n"+ + "and back with `skrog use %s`. It stops itself when idle; "+ + "`skrog target remove %s` deletes it.\n", + name, manifest.EngineVersion, l.Distro, l.StateDir, l.Pipe, l.Context, name, tgt.Local, name) + if !ctxReady { + fmt.Printf("\nNo docker CLI to hold the context; point a client at it with\n $env:DOCKER_HOST = %q\n", host) + } + return exitOK +} + +// applyEngineAutostart registers or removes the logon entry of one engine and +// records the choice in that engine's own settings, the way `skrog autostart` +// does for the default engine (#515). +func applyEngineAutostart(name, stateDir string, on bool) error { + if on { + exe, err := os.Executable() + if err != nil { + return err + } + if err := autostart.EnableEngine(exe, name, stateDir); err != nil { + return err + } + } else if err := autostart.DisableEngine(name); err != nil { + return err + } + return recordAutostart(stateDir, on) +} + +// gatherTargets reads every target on the machine: the default engine, each +// named engine, and each remote. +func gatherTargets(ctx context.Context, base string) (rows []targetEntryJSON, current string) { + dc := &dockerctx.Manager{} + engines, _ := tgt.List(base) + if cur, err := dc.Current(ctx); err == nil { + current = tgt.CurrentName(cur, engines) + if current == "" { + current = currentRemote(ctx, dc) + } + } + p := &provision.Provisioner{} + + local := targetEntryJSON{Name: tgt.Local, Kind: "local", Context: dockerctx.Name, StateDir: base} + lopts := provision.Options{StateDir: base} + if m, err := p.ReadManifest(lopts); err == nil { + local.Distro, local.EngineVersion = m.Distro, m.EngineVersion + local.State = engineState(ctx, p, provision.Options{Distro: m.Distro, StateDir: base}) + dataDir := m.DataDir + if dataDir == "" { + dataDir = base + `\distro` + } + local.DiskBytes = tgt.DiskUsage(dataDir) + } else { + local.State = "not-installed" + } + local.Current = current == tgt.Local + rows = append(rows, local) + + for _, n := range engines { + l := tgt.For(base, n) + e := targetEntryJSON{ + Name: n, Kind: "engine", Context: l.Context, StateDir: l.StateDir, + Distro: l.Distro, Pipe: l.Pipe, + DiskBytes: tgt.DiskUsage(l.StateDir), + Current: current == n, + } + if m, err := p.ReadManifest(provision.Options{StateDir: l.StateDir}); err == nil { + e.EngineVersion = m.EngineVersion + e.State = engineState(ctx, p, provision.Options{Distro: l.Distro, StateDir: l.StateDir}) + } else { + e.State = "incomplete" + } + if on, _, err := autostart.EngineStatus(n); err == nil { + e.Autostart = &on + } + rows = append(rows, e) + } + + if infos, err := (&remote.Manager{StateDir: base}).List(); err == nil { + for _, i := range infos { + rows = append(rows, targetEntryJSON{ + Name: i.Name, Kind: "remote", Context: remote.ContextName(i.Name), + Host: i.Host, Current: current == i.Name, + }) + } + } + return rows, current +} + +// engineState is running or stopped for an installed engine, and incomplete +// when its distro is not registered (a create that died half way, or a distro +// removed by hand). A listing only: it never boots the distro it asks about. +func engineState(ctx context.Context, p *provision.Provisioner, opts provision.Options) string { + running, err := p.DistroRunning(ctx, opts) + switch { + case err != nil: + return "incomplete" + case running: + return "running" + default: + return "stopped" + } +} + +func targetList(ctx context.Context, base string, asJSON bool) int { + rows, current := gatherTargets(ctx, base) + + var total int64 + for _, r := range rows { + total += r.DiskBytes + } + + if asJSON { + return emitJSON(targetListJSON{Current: current, Targets: rows, TotalDiskBytes: total}) + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " \tNAME\tKIND\tSTATE\tENGINE\tDISK\tWHERE") + for _, r := range rows { + mark := " " + if r.Current { + mark = "*" + } + disk, where := "", r.StateDir + if r.Kind != "remote" { + disk = humanBytes(r.DiskBytes) + } else { + where = r.Host + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + mark, r.Name, r.Kind, r.State, r.EngineVersion, disk, where) + } + tw.Flush() + fmt.Printf("\ntotal disk across local engines: %s\n", humanBytes(total)) + if current == "" { + fmt.Println("(docker currently targets a context that is not one of these)") + } + return exitOK +} + +func targetRemove(ctx context.Context, base, name string, yes bool) int { + if err := tgt.ValidName(name); err != nil || !tgt.Exists(base, name) { + fmt.Fprintf(os.Stderr, "skrog: no such engine %q (`skrog target` lists them)\n", name) + return exitNotFound + } + l := tgt.For(base, name) + log := cliLogger(false) + p := &provision.Provisioner{Logger: log} + opts := engineOptions(base, l) + + if !yes { + fmt.Printf("This will unregister the WSL distro %q and delete all images, containers and "+ + "volumes in engine %q.\nThis cannot be undone. Continue? [y/N] ", l.Distro, name) + var answer string + fmt.Scanln(&answer) + if answer != "y" && answer != "Y" { + fmt.Println("aborted") + return exitOK + } + } + + // Logon entry first, for the reason uninstall gives: a Run entry that + // outlives the engine would relaunch a supervisor into nothing. + if err := autostart.DisableEngine(name); err != nil { + log.Warn("could not remove the logon entry", "error", err) + } + if err := stopSupervisor(l.StateDir); err != nil { + log.Warn("continuing with the supervisor still running", "error", err) + } + (&integrate.Manager{WSL: &wsl.Local{}, StateDir: l.StateDir, Logger: log}).RemoveAll(ctx) + + // The context, which only this engine uses. If docker is on it, move to the + // default engine's first: a context pointing at a pipe nobody serves would + // break every later docker command. + dc := &dockerctx.Manager{} + restore := "default" + if ok, _ := dc.ExistsNamed(ctx, dockerctx.Name); ok { + restore = dockerctx.Name + } + if err := dc.RemoveNamed(ctx, l.Context, restore); err != nil { + var noCLI *dockerctx.ErrNoDockerCLI + if !errors.As(err, &noCLI) { + log.Warn("could not remove the docker context", "error", err) + } + } + + // Distro, VHDX and manifest; then the engine's directory, which holds only + // this engine's logs, settings, snapshots and the state files around them. + if err := p.Uninstall(ctx, opts); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitError + } + if err := os.RemoveAll(l.StateDir); err != nil { + fmt.Fprintf(os.Stderr, "skrog: removing %s: %v\n", l.StateDir, err) + return exitError + } + fmt.Printf("Removed engine %q. Nothing else on the system was modified.\n", name) + return exitOK +} + +// runUse is `skrog use`: make docker, and everything that follows the docker +// context (VS Code Dev Containers, compose), talk to a target. +func runUse(args []string) int { + fs := flag.NewFlagSet("use", flag.ContinueOnError) + stateDir := fs.String("state-dir", "", "override Skrog's state directory") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, `usage: skrog use |%s + +Points docker at a target: the default shared engine (%s), a named engine from +`+"`skrog target create`"+`, or a remote from `+"`skrog remote add`"+`. Switching to a local +engine starts it first, so the next docker command finds it ready. + +It switches the docker CONTEXT and nothing else. It does not export DOCKER_HOST +(a process cannot set a variable in its parent shell, and DOCKER_HOST overrides the +context, which would leave the CLI and your editor on different engines); use +DOCKER_CONTEXT for a script that must name one explicitly. + +Exit codes: 0 ok, %d error, %d usage, %d no such target. + +flags: +`, tgt.Local, tgt.Local, exitError, exitUsage, exitNotFound) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return exitUsage + } + if fs.NArg() != 1 { + fs.Usage() + return exitUsage + } + ctx, stop := interruptible() + defer stop() + return useTarget(ctx, targetBase(*stateDir), fs.Arg(0)) +} + +// useTarget switches docker to a target. Shared by `skrog use`, +// `skrog remote use` (kept as an alias so nothing documented breaks) and the +// skrog.yaml `target:` key. +func useTarget(ctx context.Context, base, name string) int { + dc := &dockerctx.Manager{} + rm := &remote.Manager{StateDir: base} + + switch { + case name == tgt.Local: + return remoteUse(ctx, rm, dc, tgt.Local) + + case tgt.ValidName(name) == nil && tgt.Exists(base, name): + return useEngine(ctx, base, name, dc) + + case rm.Exists(name): + return remoteUse(ctx, rm, dc, name) + } + fmt.Fprintf(os.Stderr, "skrog: no such target %q (`skrog target` lists them)\n", name) + return exitNotFound +} + +// useEngine starts a named engine and points docker at it. +// +// Started BEFORE the switch: a `docker` that follows `use` should find a ready +// engine, and a start that fails must not leave docker aimed at one that is +// down. The context is ensured here as well as by the supervisor, so the +// switch does not depend on how quickly a freshly spawned supervisor gets to +// wire it. +func useEngine(ctx context.Context, base, name string, dc *dockerctx.Manager) int { + l := tgt.For(base, name) + p := &provision.Provisioner{} + if _, err := p.ReadManifest(provision.Options{StateDir: l.StateDir}); err != nil { + fmt.Fprintf(os.Stderr, "skrog: engine %q is incomplete (no install recorded); "+ + "`skrog target remove %s --yes` and create it again\n", name, name) + return exitError + } + + host := pipeproxy.DockerHostFor(l.Pipe) + if err := dc.EnsureNamed(ctx, l.Context, "Skrog engine "+name, host); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitError + } + if code := runStart([]string{"--state-dir", l.StateDir}); code != exitOK { + return code + } + if err := dc.UseNamed(ctx, l.Context); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitError + } + fmt.Printf("docker now targets engine %q (context %s)\n", name, l.Context) + return exitOK +} + +// scopedStateDir is the state dir a command acts on when --state-dir is not +// given: the current target's, so `skrog engine upgrade` and `skrog config set +// engine.*` change the engine docker is talking to and not a different one. +// +// Costs nothing on a machine with no named engines (the common case): the +// registry is checked first, and docker is only asked which context is current +// when there is more than one engine to be confused between. Any failure falls +// back to the default engine, which is what these commands always did. +func scopedStateDir(flagVal string) string { + if flagVal != "" { + return flagVal + } + base := optsWithResolvedStateDir(provision.Options{}).StateDir + if names, _ := tgt.List(base); len(names) == 0 { + return flagVal + } + cur, err := (&dockerctx.Manager{}).Current(context.Background()) + if err != nil { + return flagVal + } + if name := tgt.CurrentName(cur, mustList(base)); name != "" && name != tgt.Local { + return tgt.For(base, name).StateDir + } + return flagVal +} + +func mustList(base string) []string { + names, _ := tgt.List(base) + return names +} + +// autostartEngine is `skrog autostart --engine enable|disable|status`. +func autostartEngine(name, verb string) int { + base := targetBase("") + if err := tgt.ValidName(name); err != nil || !tgt.Exists(base, name) { + fmt.Fprintf(os.Stderr, "skrog: no such engine %q (`skrog target` lists them)\n", name) + return exitNotFound + } + l := tgt.For(base, name) + switch verb { + case "enable", "disable": + if err := applyEngineAutostart(name, l.StateDir, verb == "enable"); err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitError + } + if verb == "enable" { + fmt.Printf("autostart enabled for engine %q: its supervisor starts at your next logon\n", name) + } else { + fmt.Printf("autostart disabled for engine %q (a running supervisor is not stopped; use `skrog stop --state-dir %q`)\n", name, l.StateDir) + } + return exitOK + case "status", "": + enabled, cmd, err := autostart.EngineStatus(name) + if err != nil { + fmt.Fprintf(os.Stderr, "skrog: %v\n", err) + return exitError + } + if enabled { + fmt.Printf("enabled: %s\n", cmd) + return exitOK + } + fmt.Println("disabled") + return exitNotFound + default: + fmt.Fprintln(os.Stderr, "usage: skrog autostart --engine enable|disable|status") + return exitUsage + } +} + +// installTargetFromConfig is `skrog install --config` for a skrog.yaml that +// names a `target:` (#539): create the engine when it is missing, converge the +// file's settings onto it, and point docker at it. Idempotent, like the +// default-engine path, so the same file drives first use and later convergence. +func installTargetFromConfig(configPath string, f skrogfile.File, opts provision.Options, engineVersion string, noAutostart bool, log *slog.Logger) int { + if opts.Distro != "" || opts.DataDir != "" || opts.StateDir != "" { + fmt.Fprintln(os.Stderr, "skrog: --distro, --data-dir and --state-dir do not apply to a skrog.yaml with a target:; "+ + "a named engine's locations are derived from its name") + return exitUsage + } + if engineVersion == "" { + engineVersion = f.EngineVersion + } + base := targetBase("") + l := tgt.For(base, f.Target) + ctx, stop := interruptible() + defer stop() + + if tgt.Exists(base, f.Target) { + log.Info("engine already exists; converging settings from config", "target", f.Target) + } else if code := targetCreate(ctx, base, f.Target, createFlags{ + src: installSource{engineVersion: engineVersion}, + headless: opts.Headless, + }); code != exitOK { + return code + } + + eopts := engineOptions(base, l) + if err := applySkrogFile(ctx, f, eopts, noAutostart, log); err != nil { + fmt.Fprintf(os.Stderr, "skrog: applying config: %v\n", err) + return exitError + } + if code := useTarget(ctx, base, f.Target); code != exitOK { + return code + } + fmt.Printf("Engine %q converged from %s and selected.\n", f.Target, configPath) + return exitOK +} + +// engineWiring is what a supervisor for stateDir must serve and wire: the +// named engine's own pipe and docker context, or ok=false for the default +// engine, whose pipe selection and `skrog` context are unchanged. +// +// Derived from the state dir, rather than passed in, so every way a supervisor +// gets started -- `skrog start`, the logon entry, the watchdog restarting it -- +// agrees without each having to be told, and cannot disagree after a crash. +func engineWiring(stateDir string) (name string, l tgt.Layout, ok bool) { + name, ok = tgt.NameOf(stateDir) + if !ok { + return "", tgt.Layout{}, false + } + return name, tgt.For(tgt.BaseOf(stateDir), name), true +} diff --git a/cmd/skrog/target_test.go b/cmd/skrog/target_test.go new file mode 100644 index 0000000..6889f3e --- /dev/null +++ b/cmd/skrog/target_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/wslkit/skrog/internal/provision" + tgt "github.com/wslkit/skrog/internal/target" +) + +// The supervisor for a named engine must serve that engine's own pipe and wire +// that engine's own context, and the default engine's supervisor must be left +// exactly as it was (#539). Both are derived from the state dir. +func TestEngineWiring(t *testing.T) { + base := filepath.Join(t.TempDir(), "Skrog") + + name, l, ok := engineWiring(tgt.For(base, "proj-a").StateDir) + if !ok || name != "proj-a" { + t.Fatalf("a named engine's state dir was not recognised: %q %v", name, ok) + } + if l.Pipe != `\\.\pipe\skrog-proj-a` || l.Context != "skrog-proj-a" { + t.Errorf("wiring = %+v", l) + } + if l.Context == "skrog" { + t.Error("a named engine must never wire the default engine's `skrog` context") + } + + if _, _, ok := engineWiring(base); ok { + t.Error("the default engine's state dir was treated as a named engine") + } +} + +// A new engine downloads nothing the machine already has: the rootfs cache is +// the base state dir's, while everything else is the engine's own. +func TestEngineOptionsShareTheRootfsCache(t *testing.T) { + base := filepath.Join(t.TempDir(), "Skrog") + l := tgt.For(base, "proj-a") + o := engineOptions(base, l) + if o.CacheDir != base { + t.Errorf("CacheDir = %q, want the shared base %q", o.CacheDir, base) + } + if o.StateDir != l.StateDir || o.Distro != "skrog-proj-a" { + t.Errorf("options = %+v", o) + } + if o.StateDir == base { + t.Error("an engine must not share the default engine's state dir") + } +} + +// With --state-dir given, a command acts there and nothing is looked up; and +// with no named engines there is nothing to disambiguate, so no docker call is +// made and the default engine is used, as before. +func TestScopedStateDirIsInertWithoutEngines(t *testing.T) { + t.Setenv("LOCALAPPDATA", t.TempDir()) + if got := scopedStateDir(`C:\explicit`); got != `C:\explicit` { + t.Errorf("an explicit --state-dir was overridden: %q", got) + } + if got := scopedStateDir(""); got != "" { + t.Errorf("with no engines the default must stay the default, got %q", got) + } +} + +// targetBase resolves an engine's own state dir back to the install it hangs +// off, so remotes and the engine registry are found from either. +func TestTargetBaseResolvesAnEnginesDir(t *testing.T) { + base := filepath.Join(t.TempDir(), "Skrog") + if got := targetBase(tgt.For(base, "proj-a").StateDir); got != base { + t.Errorf("targetBase(engine dir) = %q, want %q", got, base) + } + if got := targetBase(base); got != base { + t.Errorf("targetBase(base) = %q", got) + } +} + +// Creating, listing and using work from an engine's own dir as well as from the +// base: the base is the registry. +func TestNamedEngineDirsAreTheRegistry(t *testing.T) { + base := t.TempDir() + opts := provision.Options{StateDir: tgt.For(base, "a").StateDir} + if got := tgt.BaseOf(opts.StateDir); got != base { + t.Errorf("BaseOf = %q, want %q", got, base) + } +} + +func TestTargetShapes(t *testing.T) { + m := roundTrip(t, targetListJSON{Current: "local", Targets: []targetEntryJSON{}}) + requireKeys(t, m, "current", "targets", "totalDiskBytes") + if a, ok := m["targets"].([]any); !ok || a == nil { + t.Errorf("targets must be an array even when empty, got %v", m["targets"]) + } + + e := roundTrip(t, targetEntryJSON{Name: "a", Kind: "engine", Context: "skrog-a"}) + requireKeys(t, e, "name", "kind", "current", "context") + for _, k := range []string{"host", "autostart", "pipe"} { + if _, ok := e[k]; ok { + t.Errorf("%q should be omitted when unset", k) + } + } + + c := roundTrip(t, targetCreateJSON{Layout: tgt.For(`C:\s`, "a")}) + requireKeys(t, c, "name", "stateDir", "distro", "pipe", "context", "engineVersion", "dockerContext") +} diff --git a/cmd/skrogtray/main.go b/cmd/skrogtray/main.go index bf5dc36..66c61d5 100644 --- a/cmd/skrogtray/main.go +++ b/cmd/skrogtray/main.go @@ -15,10 +15,12 @@ import ( "os" "os/exec" "path/filepath" + "sync" "time" "fyne.io/systray" "github.com/wslkit/skrog/internal/selfexe" + tgt "github.com/wslkit/skrog/internal/target" "github.com/wslkit/skrog/internal/tray" ) @@ -41,6 +43,19 @@ func skrogExe() string { func onReady() { cli := tray.CLI{Exe: skrogExe()} + // The engine the dot describes (#539): the one docker is on. Menu clicks act + // on it, so they read it from here rather than assuming the default. + var ( + shownMu sync.Mutex + shownStateDir string + shownTarget string + ) + shown := func() (string, string) { + shownMu.Lock() + defer shownMu.Unlock() + return shownStateDir, shownTarget + } + systray.SetTitle("Skrog") systray.SetIcon(iconGrey) @@ -67,13 +82,15 @@ func onReady() { for i, item := range lifecycle { go func(item *systray.MenuItem, a tray.Action) { for range item.ClickedCh { - go cli.Run(context.Background(), a) + dir, _ := shown() + go cli.RunOn(context.Background(), a, dir) } }(item, tray.Actions[i]) } go func() { for range openLogs.ClickedCh { - go openLog(cli) + dir, _ := shown() + go openLog(cli, dir) } }() go func() { @@ -117,12 +134,16 @@ func onReady() { if st.StateDir != "" { stateDir = st.StateDir } + shownMu.Lock() + shownStateDir, shownTarget = st.StateDir, st.Target + shownMu.Unlock() return st.State() } paint := func(s tray.State) { header.SetTitle("Engine: " + label(s)) - systray.SetTooltip(tray.Tooltip(s)) + _, target := shown() + systray.SetTooltip(tray.TooltipFor(s, target)) systray.SetIcon(iconFor(s)) } paint(fallback()) @@ -130,6 +151,15 @@ func onReady() { t := time.NewTicker(paintInterval) defer t.Stop() for range t.C { + // With named engines the dot follows whichever one docker is on, and + // that changes without the tray being told (`skrog use`, an editor + // switching context). The published reading belongs to one engine, so + // ask the CLI which is current now and then. A machine with no named + // engines never pays for this: there is nothing to switch between. + if hasNamedEngines(stateDir) && time.Since(lastFallback) >= fallbackInterval { + paint(fallback()) + continue + } if s, ok := tray.PollPublished(stateDir); ok { paint(s) continue @@ -143,6 +173,16 @@ func onReady() { }() } +// hasNamedEngines reports whether the install the state dir belongs to has any +// named engine, which is the only time the dot can change engine under us. +func hasNamedEngines(stateDir string) bool { + if stateDir == "" { + return false + } + names, _ := tgt.List(tgt.BaseOf(stateDir)) + return len(names) > 0 +} + func iconFor(s tray.State) []byte { switch { case tray.Healthy(s): @@ -169,8 +209,13 @@ func label(s tray.State) string { } } -func openLog(cli tray.CLI) { - // The supervisor writes supervisor.log under the default state dir. +func openLog(cli tray.CLI, stateDir string) { + // The supervisor writes supervisor.log into its own state dir: the shown + // engine's, which is the default one unless docker is on a named engine. + if stateDir != "" { + browse(filepath.Join(stateDir, "supervisor.log")) + return + } base := os.Getenv("LOCALAPPDATA") if base == "" { return diff --git a/completions/skrog.bash b/completions/skrog.bash index d123b3a..c23cec8 100644 --- a/completions/skrog.bash +++ b/completions/skrog.bash @@ -15,7 +15,7 @@ _skrog() { cur="${COMP_WORDS[COMP_CWORD]}" local commands subs flags - commands="audit autostart bundle cache cli compact config doctor enable-gpu engine healthcheck install lock logs migrate prewarm policy profile proxy prune relocate remote reset restart runner serve snapshot start status stop supervise top uninstall upgrade wsl-integrate wsl-config version" + commands="audit autostart bundle cache cli compact config doctor enable-gpu engine healthcheck install lock logs migrate prewarm policy profile proxy prune relocate remote reset restart runner serve snapshot start status stop supervise top target uninstall use upgrade wsl-integrate wsl-config version" # First positional is the command; the second, if any, the subcommand. local command="" sub="" i seen=0 @@ -66,7 +66,9 @@ _skrog() { stop) flags="--state-dir --supervisor --timeout" ;; supervise) flags="--distro --no-context --pipe --state-dir" ;; top) flags="--interval --json --no-stream --once --state-dir --stream" ;; + target) flags="--autostart --engine-version --headless --json --locked --offline --rootfs-sha256 --rootfs-url --state-dir --yes" ;; uninstall) flags="--distro --state-dir --yes" ;; + use) flags="--state-dir" ;; upgrade) flags="--apply --check --dry-run --force --json --offline --state-dir --timeout --yes" ;; wsl-integrate) flags="--remove --state-dir" ;; version) flags="--json --state-dir" ;; @@ -118,6 +120,7 @@ _skrog() { runner) subs="check" ;; serve) subs="cert" ;; snapshot) subs="list save restore delete" ;; + target) subs="list create remove" ;; wsl-config) subs="show apply" ;; *) subs="" ;; esac diff --git a/completions/skrog.ps1 b/completions/skrog.ps1 index e535d04..7c08c4c 100644 --- a/completions/skrog.ps1 +++ b/completions/skrog.ps1 @@ -41,7 +41,9 @@ $script:SkrogCommands = @( 'stop' 'supervise' 'top' + 'target' 'uninstall' + 'use' 'upgrade' 'wsl-integrate' 'wsl-config' @@ -61,6 +63,7 @@ $script:SkrogSubcommands = @{ 'runner' = @('check') 'serve' = @('cert') 'snapshot' = @('list', 'save', 'restore', 'delete') + 'target' = @('list', 'create', 'remove') 'wsl-config' = @('show', 'apply') } @@ -93,7 +96,9 @@ $script:SkrogFlags = @{ 'stop' = @('--state-dir', '--supervisor', '--timeout') 'supervise' = @('--distro', '--no-context', '--pipe', '--state-dir') 'top' = @('--interval', '--json', '--no-stream', '--once', '--state-dir', '--stream') + 'target' = @('--autostart', '--engine-version', '--headless', '--json', '--locked', '--offline', '--rootfs-sha256', '--rootfs-url', '--state-dir', '--yes') 'uninstall' = @('--distro', '--state-dir', '--yes') + 'use' = @('--state-dir') 'upgrade' = @('--apply', '--check', '--dry-run', '--force', '--json', '--offline', '--state-dir', '--timeout', '--yes') 'wsl-integrate' = @('--remove', '--state-dir') 'version' = @('--json', '--state-dir') diff --git a/docs/cli-json.md b/docs/cli-json.md index 432222f..08bb0ef 100644 --- a/docs/cli-json.md +++ b/docs/cli-json.md @@ -47,6 +47,10 @@ Shapes are pinned by `cmd/skrog/jsonshapes_test.go`. it from broken. - `desired`: `running` | `stopped` (what `skrog start`/`stop` last asked for). - `profile`: omitted when no profile is active. +- `target`: the named engine this status describes ([targets.md](targets.md)); omitted + for the default shared engine. Without `--state-dir`, status describes the engine + docker is on now, so a consumer that follows the docker context reads the engine it is + talking to. - `gpu`: `visible` and `specInstalled` are **only probed while the engine is running and `enabled` is true** — status never boots a stopped distro (#82). `probed: false` means they are not authoritative. @@ -220,6 +224,43 @@ from the request line only, never the body. The resulting install manifest: `{ "distro", "dataDir", "rootfsUrl", "rootfsSha256", "engineVersion", "installedAt", "wslVersion" }`. +## `skrog target … --json` + +- `target --json` (list) → + + ```json + { + "current": "proj-a", + "targets": [ + { "name": "local", "kind": "local", "current": false, "state": "running", + "context": "skrog", "distro": "skrog-engine", "stateDir": "C:\\...\\Skrog", + "engineVersion": "29.9.0", "diskBytes": 1395864371 }, + { "name": "proj-a", "kind": "engine", "current": true, "state": "running", + "context": "skrog-proj-a", "distro": "skrog-proj-a", + "stateDir": "C:\\...\\Skrog\\engines\\proj-a", "pipe": "\\\\.\\pipe\\skrog-proj-a", + "engineVersion": "29.9.0", "diskBytes": 641728512, "autostart": false }, + { "name": "desk", "kind": "remote", "current": false, "context": "skrog-desk", + "host": "tcp://desk.corp:2376" } + ], + "totalDiskBytes": 2037592883 + } + ``` + + `current` is `"local"` when docker is on the `skrog` context, a named engine's or + remote's name when on `skrog-`, and `""` when docker is on some other context + entirely. `kind` is `local` (the default shared engine, always listed), `engine` or + `remote`. `state` is `running` | `stopped` | `incomplete` (no install recorded, or + its distro is gone) | `not-installed`, and is absent for a remote. `diskBytes`, + `engineVersion`, `distro`, `stateDir` and `pipe` are absent for a remote; + `autostart` is present for named engines only. `targets` is always an array, and + `totalDiskBytes` is what the local engines hold together. +- `target create --json` → `{ "name", "stateDir", "distro", "pipe", + "context", "engineVersion", "dockerContext" }`. `dockerContext` is `false` on a + machine with no docker CLI to hold the context. Exits `2` for a bad or reserved + name, `1` when the name is taken (by an engine or a remote). +- `target remove`, `use` and `autostart --engine` have no JSON; they exit `0` on + success and `3` when there is no such target. + ## `skrog remote … --json` - `remote --json` (list) → diff --git a/docs/declarative-install.md b/docs/declarative-install.md index 7c40451..c4d6095 100644 --- a/docs/declarative-install.md +++ b/docs/declarative-install.md @@ -35,6 +35,9 @@ Every field maps to an existing flag or config key; nothing new is invented, and unknown fields fail loudly rather than being silently ignored. ```yaml +# Which engine (omit for the default shared engine; see targets.md): +target: proj-a # a named engine, created if missing and selected + # Install-time (used only on a fresh install): distro: skrog-engine # --distro data-dir: D:\skrog # --data-dir @@ -59,6 +62,25 @@ integrations: # distros to wsl-integrate All fields are optional: an absent field is left at its default or untouched, so a partial file is valid. +### `target:`, a named engine per workspace + +With `target: `, the file describes **that engine** rather than the default one +([targets.md](targets.md)). `skrog install --config` creates the engine if it does not +exist, converges every setting below onto it (its own `idle-timeout`, `engine.*` keys, +hooks, integrations), and switches docker to it. Re-running converges. + +- `engine-version:` pins the engine's dockerd when it is created; an existing engine is + not re-created. +- `distro:` and `data-dir:` are rejected beside `target:`: a named engine's locations + come from its name. +- `--distro`, `--data-dir` and `--state-dir` do not apply to such a file, for the same + reason. +- `autostart:` is not defaulted on for a named engine, as it is for the default one: an + engine starts at logon only with an explicit `autostart: true`, and `--no-autostart` + still wins. +- The key is `target:` and not `engine:` because `engine:` already holds `daemon.json` + settings. + ## CI / fleet example A GitHub Actions self-hosted runner image, provisioned in one step: diff --git a/docs/design/per-workspace-engine.md b/docs/design/per-workspace-engine.md index 869079b..dee2a81 100644 --- a/docs/design/per-workspace-engine.md +++ b/docs/design/per-workspace-engine.md @@ -1,13 +1,15 @@ # Design: per-workspace engines -Status: **approved, not scheduled**. The shape and trade-offs for -[#140](https://github.com/wslkit/skrog/issues/140); every open question is -settled below. +Status: **implemented** ([#539](https://github.com/wslkit/skrog/issues/539)), +following the design settled for +[#140](https://github.com/wslkit/skrog/issues/140). The user guide is +[docs/targets.md](../targets.md); this page keeps the reasoning, and the +[implementation notes](#implementation-notes) at the end record where the +shipped version differs from the draft. The `target` namespace below is **approved and binding** — settled early because a rename is cheap before `engine` and `target` appear in anyone's -scripts and expensive after. The implementation is deliberately deferred; see -Sequencing. +scripts and expensive after. ## The capability @@ -118,10 +120,12 @@ are thin: the work is in `provision`, which already takes these as options. `skrog.yaml` (declarative install, #69) gains one key: ```yaml -engine: proj-a # this workspace uses the engine named proj-a +target: proj-a # this workspace uses the engine named proj-a engine-version: 29.7.2 # …pinned, as today ``` +(Drafted as `engine:`; shipped as `target:`, see the implementation notes.) + `skrog install --config skrog.yaml` in that directory creates the engine if missing and switches to it; the VS Code extension (skrog-vscode #8) reads the key on workspace open and runs `skrog use`. A workspace without the key uses @@ -209,10 +213,11 @@ rather than one. A cap would be an arbitrary number a fleet eventually needs to exceed; sprawl you can see is a problem you can act on. Both conditions are in the phasing below. -## Sequencing: why this is designed, not built +## Sequencing: why it waited -The design is ready. Building it should wait, for two reasons that are about -the rest of the project rather than this feature. +*Historical: this is the reasoning that held the build back until #539.* The +design was ready, and building it was held for two reasons that were about the +rest of the project rather than this feature. **The CLI surface is not settled yet.** This adds a second top-level noun (`target`, plus `use`). The naming collisions found in a single day of work — @@ -247,3 +252,51 @@ clears, is `target list` on its own: read-only, showing remotes, local engines and `local` with disk and the current target marked. It makes Decision 2 visible, breaks nothing, and would show whether the `target` vocabulary reads well before anything depends on it. + +## Implementation notes + +What shipped in [#539](https://github.com/wslkit/skrog/issues/539), and the few +places it differs from the draft above. + +- **The workspace key is `target:`, not `engine:`.** `skrog.yaml` already uses + `engine:` for a map of `daemon.json` settings, so `engine: proj-a` would have + been a string and a map under one key. `target:` is also the noun the CLI settled + on. `distro:` and `data-dir:` are rejected beside it, since an engine's locations + derive from its name. +- **Names are lowercase only**: letters, digits, `-` and `_`, 1 to 32 characters, + starting and ending with a letter or digit. The name is a + directory, a distro, a docker context and a pipe, and Windows is case-insensitive + for three of those. `local` and `engine` are reserved (`skrog-engine` is the + default engine's distro). +- **The supervisor derives its pipe and context from its state dir** + (`\engines\` means pipe `\.\pipe\skrog-` and context + `skrog-`, via `internal/target`), rather than being told. `skrog start`, the + logon entry and the watchdog's restart all launch it with only `--state-dir`, so + nothing else has to carry the pipe and nothing can disagree after a crash. An + explicit `--pipe` still wins. A named engine's supervisor never touches the shared + `skrog` context (#217). +- **"Current target" is the docker context**, per "contexts are the contract". + `engine`, `config` and `status` resolve a missing `--state-dir` to the current + target; the lookup is skipped entirely on a machine with no named engines, so the + common case pays nothing. The design scoped only `engine` and `config`; `status` was + added because the tray reads it to learn which engine the dot describes. +- **The rootfs download cache is shared** (`provision.Options.CacheDir`), which the + design listed as the one thing engines share. `engine upgrade` still caches under the + engine's own directory. +- **Created stopped.** `target create` stops the engine it just installed: with no + supervisor yet, nothing would ever idle-stop it. `skrog use` starts it. +- **Per-engine logon entries** are separate Run-key values (`Skrog-`, opt-in), so + the default engine's single `Skrog` entry keeps its meaning. +- **Disk accounting** is `target list`'s per-engine and total columns and doctor's + free-space check across every engine's volume, as the design required in place of a + cap. +- **Known limit, not fixed:** emulation (`binfmt_misc`) is kernel state shared by every + WSL2 distro, and stopping or removing any engine deregisters the handlers for all of + them. With several engines running at once, an idle-stop of one can take foreign + architecture support away from another until an engine next starts. + +### Sequencing + +The draft held this back until the CLI vocabulary settled (#77 package IDs, #1 the +product name). It was built with the `target` namespace as approved; those two remain +open and do not touch it. diff --git a/docs/engine-upgrade.md b/docs/engine-upgrade.md index 4959465..c15365a 100644 --- a/docs/engine-upgrade.md +++ b/docs/engine-upgrade.md @@ -13,6 +13,11 @@ skrog engine rollback # back to the engine installed before the last Every step is checksum-mandatory and reversible, and none of it touches your data. +With [named engines](targets.md), `skrog engine` acts on the **current target**, the engine +docker is on: `skrog use proj-a` then `skrog engine upgrade --to 29.8.0` upgrades `proj-a` +and leaves the others alone. Pass `--state-dir` to act on a specific engine. This is how +two projects run two dockerd versions at once. + ## What an upgrade actually does It replaces the **engine binaries** — `dockerd`, `containerd`, @@ -142,6 +147,7 @@ would move the engine from 29.8.1-3 to 29.8.0-2: ## See also +- [targets.md](targets.md) — a dockerd version per project, with one engine each - [snapshots.md](snapshots.md) — engine *state* save/restore, which is a different axis from the engine *version* - [housekeeping.md](housekeeping.md) — `skrog prune` and `skrog compact` diff --git a/docs/housekeeping.md b/docs/housekeeping.md index 10d6270..67a2ae5 100644 --- a/docs/housekeeping.md +++ b/docs/housekeeping.md @@ -117,6 +117,11 @@ Raise it on a runner with a small disk so a filling volume is flagged — with `skrog prune` named as the remedy — before pulls start failing deep inside dockerd. +With [named engines](targets.md) each one is its own virtual disk, so the floor is judged +against the fullest volume **any** engine lives on, and the report lists what each engine +holds. `skrog target list` shows every engine's disk and the total, and +`skrog target remove` frees an unused engine's whole disk. + ## Giving space back to the host: `skrog compact` `skrog prune` frees space *inside* the engine. It does not shrink the file on diff --git a/docs/reference.md b/docs/reference.md index fa8551b..d5b7215 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -40,7 +40,9 @@ Run `skrog --help` for the same text in your terminal. - [`stop`](#stop) — stop the engine; it stays stopped until start - [`supervise`](#supervise) — serve the pipe and keep the engine alive (the always-on layer) - [`top`](#top) — where the WSL VM's memory and CPU go: containers, engine, page cache, Vmmem +- [`target`](#target) — per-workspace engines: create, list, remove isolated engines (and remotes) to switch between - [`uninstall`](#uninstall) — remove the engine distro and Skrog's state +- [`use`](#use) — point docker at a target: the shared engine, a named engine, or a remote - [`upgrade`](#upgrade) — am I current? app, engine and bundled CLI in one answer - [`wsl-integrate`](#wsl-integrate) — point docker inside your own WSL distros at the engine - [`wsl-config`](#wsl-config) — right-size the WSL2 VM: show and apply ~/.wslconfig sizing, with consent @@ -105,7 +107,7 @@ flags: start the supervisor at logon: enable, disable, status ``` -usage: skrog autostart enable|disable|status +usage: skrog autostart [--engine ] enable|disable|status Controls whether the supervisor starts at logon, via a per-user Run entry (visible and switchable in Task Manager's Startup tab; no admin needed). The @@ -116,6 +118,11 @@ install registers this by default; uninstall removes it. enable and disable also record the choice, exactly as `skrog config set autostart on|off` does, so `skrog doctor` can tell an entry turned off on purpose from one that went missing, and put a missing one back with --fix. + +--engine acts on a named engine's own logon entry (skrog target create +--autostart does the same at creation) and leaves the default engine's alone. +A named engine starts at logon only when you ask: by default it starts when you +`skrog use` it, and stops itself when idle. ``` ## bundle @@ -1180,6 +1187,60 @@ Exit codes: 0 engine running or idle, 1 engine stopped or unreadable, 2 usage, with --json, keep printing: one JSON object per line, every --interval ``` +## target + +per-workspace engines: create, list, remove isolated engines (and remotes) to switch between + +``` +usage: skrog target list targets + skrog target create [flags] a new, isolated engine + skrog target remove [--yes] delete an engine and everything in it + +A target is which engine docker talks to: the default shared engine (`local`), +a named local engine, or a remote from `skrog remote`. Switch with `skrog use`. + +A named engine is a complete, independent install: its own WSL distro and virtual +disk, its own supervisor and pipe, its own docker context (skrog-), its own +images, containers, volumes, settings and snapshots. A poisoned image pulled in one +cannot be run from another, and each can pin its own dockerd version. + +create installs the engine and leaves it stopped: it starts when you `skrog use` +it and stops itself when idle, so many engines cost memory only while one is in +use. They do cost disk (hundreds of MB each before any image is pulled); `list` +shows each engine's disk and the total. + +Names: lowercase letters, digits, - and _ (1 to 32). "local" and "engine" are reserved, +and a name may not be one of your remotes. + +remove DELETES the engine's distro and with it every image, container and volume +in it. The default engine and remotes are never touched. + +Flags come before the verb. Exit codes: 0 ok, 1 error, 2 usage, 3 no such target, +4 unsupported platform. + +flags: + -autostart + create: also start this engine's supervisor at logon + -engine-version string + dockerd version for create (default: this build's default) + -headless + never prompt; for unattended use + -json + emit machine-readable JSON (list, create) + -locked string + create from the exact engine pinned in a skrog.lock + -offline string + create entirely from an air-gap bundle .zip + -rootfs-sha256 string + expected rootfs SHA-256; required with --rootfs-url + -rootfs-url string + override the rootfs URL (development) + -state-dir string + override Skrog's state directory + -yes + skip the confirmation prompt (remove) +``` + ## uninstall remove the engine distro and Skrog's state @@ -1193,6 +1254,10 @@ state. Nothing else on the system is touched. This DELETES the distro, and with it every image, container and volume it holds. Export anything you want to keep first. +Named engines (`skrog target create`) under the same state directory are +removed too, and the prompt lists them. To remove just one, use +`skrog target remove `. + Exit codes: 0 ok, 1 error. flags: @@ -1204,6 +1269,29 @@ flags: skip the confirmation prompt ``` +## use + +point docker at a target: the shared engine, a named engine, or a remote + +``` +usage: skrog use |local + +Points docker at a target: the default shared engine (local), a named engine from +`skrog target create`, or a remote from `skrog remote add`. Switching to a local +engine starts it first, so the next docker command finds it ready. + +It switches the docker CONTEXT and nothing else. It does not export DOCKER_HOST +(a process cannot set a variable in its parent shell, and DOCKER_HOST overrides the +context, which would leave the CLI and your editor on different engines); use +DOCKER_CONTEXT for a script that must name one explicitly. + +Exit codes: 0 ok, 1 error, 2 usage, 3 no such target. + +flags: + -state-dir string + override Skrog's state directory +``` + ## upgrade am I current? app, engine and bundled CLI in one answer diff --git a/docs/remote-engine.md b/docs/remote-engine.md index 6566d74..13fdc4b 100644 --- a/docs/remote-engine.md +++ b/docs/remote-engine.md @@ -82,6 +82,7 @@ skrog remote --host tcp://my-desktop.corp:2376 --certs C:\path\to\certs add desk skrog remote test desktop # engine version + round-trip time skrog remote use desktop # plain `docker` now targets the remote skrog remote use local # ...and back to the local engine +skrog use desktop # the same, spelled like any other target ``` `add` validates the certificates (a wrong file fails here, not on first @@ -94,6 +95,10 @@ and which one docker is on; `skrog doctor` reports `remote:desktop` and warns two weeks before the client certificate expires. `skrog remote remove desktop` removes the context and the copied material. +A remote is one kind of **target**, beside the shared engine and any per-project engines +you create with `skrog target create`; they share one namespace and one switch, +`skrog use`, and `skrog target` lists all three. See [targets.md](targets.md). + Both certificate layouts are accepted: what `skrog serve cert` writes (`ca.pem`, `client.pem`, `client-key.pem`) and docker's own (`ca.pem`, `cert.pem`, `key.pem`). diff --git a/docs/targets.md b/docs/targets.md new file mode 100644 index 0000000..250d231 --- /dev/null +++ b/docs/targets.md @@ -0,0 +1,232 @@ +# Per-workspace engines (targets) + +By default every project on the machine shares one engine: one pool of images, +containers and volumes. That is what Docker Desktop does too, and it has three +costs: + +- **Trust.** An image pulled while working on project A can be `docker run` from + project B. A poisoned base image, or a container you forgot, is in reach of + everything else you do. +- **Hygiene.** Images and volumes pile up across projects, and nothing says which + project owns which. +- **Reproducibility.** Project A needs dockerd 29.7, project B needs 29.9, and + there is one engine. + +A **named engine** is a second, third or tenth Skrog engine, each completely +independent of the others. Docker Desktop is one global engine and cannot do this. + +```powershell +skrog target create proj-a # a new, isolated engine; created stopped +skrog use proj-a # start it and point docker at it +docker run --rm hello-world # runs on proj-a's dockerd +skrog use local # back to the shared engine +``` + +## What "independent" means + +An engine named `proj-a` is a complete Skrog install of its own: + +| | the default engine | engine `proj-a` | +|---|---|---| +| WSL distro | `skrog-engine` | `skrog-proj-a` | +| state directory | `%LOCALAPPDATA%\Skrog` | `%LOCALAPPDATA%\Skrog\engines\proj-a` | +| virtual disk (VHDX) | `\distro` | `\engines\proj-a\distro` | +| named pipe | `\\.\pipe\docker_engine` (or Skrog's own) | `\\.\pipe\skrog-proj-a` | +| docker context | `skrog` | `skrog-proj-a` | +| settings, snapshots, audit log, supervisor log | in its state dir | in its state dir | +| supervisor process | one | one per running engine | + +Its images, containers, volumes and networks are its own. Nothing is shared between +engines except the verified rootfs download: a second engine imports the tarball the +first one already fetched, rather than downloading it again (the checksum is checked +on every use). + +## Targets + +A **target** is *which engine docker talks to*. There are three kinds, and `skrog use` +switches between all of them: + +| kind | what it is | created by | +|---|---|---| +| `local` | the default shared engine (docker context `skrog`) | `skrog install` | +| `engine` | a named local engine (context `skrog-`) | `skrog target create` | +| `remote` | an engine on another machine, over mutual TLS (context `skrog-`) | `skrog remote add` | + +They share one namespace, because they share the `skrog-` context shape: a name +cannot be both an engine and a remote, and `local` is reserved. `engine` is reserved +too, since `skrog-engine` is the default engine's distro. + +`skrog engine` and `skrog target` answer different questions. `skrog target` is +*which engine*; `skrog engine list|upgrade|rollback` is *which dockerd version* the +current one runs. + +## Commands + +### `skrog target create ` + +Installs a new engine and leaves it **stopped**. It starts the first time you +`skrog use` it and stops itself when idle (the same [idle-timeout](housekeeping.md) +as any engine), so ten engines cost memory only while one is in use. + +```powershell +skrog target create proj-a +skrog target create proj-b --engine-version 29.7.2 # pin its dockerd +skrog target create proj-c --locked skrog.lock # exactly the engine a lock names +skrog target create proj-d --offline skrog-bundle.zip # from an air-gap bundle +skrog target create proj-e --autostart # also start it at logon +``` + +It takes the same engine sources as `skrog install`: `--engine-version`, `--locked`, +`--offline`, or `--rootfs-url` with `--rootfs-sha256`. A new engine gets **this +build's default engine version**, not whatever the shared engine has been upgraded to, +so the same command on two machines creates the same engine. Pin a version when you +want a different one. + +Names are lowercase letters, digits, `-` and `_` (1 to 32 characters), starting and +ending with a letter or digit. Lowercase only, because the name becomes a directory, a +distro, a context and a pipe, and Windows would treat `Proj` and `proj` as one. + +### `skrog use |local` + +Points docker at a target. For a local engine it starts the engine first, so the next +docker command finds it ready, and a start that fails leaves docker where it was. + +`skrog use` switches the docker **context**, and nothing else. It does not export +`DOCKER_HOST`, deliberately: a process cannot set a variable in its parent shell, and +`DOCKER_HOST` overrides the context, which is what VS Code Dev Containers and the VS Code +extension read. Setting it would leave your terminal and your editor on different +engines, which is the confusion this feature exists to remove. For a script that must +name an engine explicitly, use `DOCKER_CONTEXT=skrog-proj-a`. + +`skrog remote use ` still works and is the same command. + +### `skrog target` / `skrog target list` + +``` + NAME KIND STATE ENGINE DISK WHERE + local local running 29.9.0 1.3 GiB C:\Users\me\AppData\Local\Skrog + * proj-a engine running 29.9.0 612.0 MiB C:\Users\me\AppData\Local\Skrog\engines\proj-a + proj-b engine stopped 29.7.2 538.0 MiB C:\Users\me\AppData\Local\Skrog\engines\proj-b + desk remote tcp://desk.corp:2376 + +total disk across local engines: 2.4 GiB +``` + +`*` marks the target docker is on now. `STATE` is `running`, `stopped`, `incomplete` +(a create that died halfway, or a distro removed by hand; `target remove` cleans it up) +or `not-installed`. `--json` is the same data, documented in +[cli-json.md](cli-json.md#skrog-target-json). + +### `skrog target remove ` + +Deletes the engine: stops its supervisor, removes its logon entry, unwires any +`wsl-integrate` distros, removes its docker context (moving docker back to the shared +engine first if it was on this one), unregisters the distro, and deletes its state +directory. **Every image, container and volume in it goes.** The default engine and +your remotes are never touched. `--yes` skips the prompt. + +## A workspace file: `target:` + +`skrog.yaml` ([declarative install](declarative-install.md)) has a `target:` key that +names the engine a workspace uses: + +```yaml +target: proj-a +engine-version: 29.7.2 +idle-timeout: 15m +engine: + registry-mirrors: https://mirror.corp.example.com +``` + +`skrog install --config skrog.yaml` creates the engine if it does not exist, converges +the file's settings **onto that engine**, and switches docker to it. Run again, it +converges. A file without `target:` behaves exactly as before. + +The key is `target:`, not `engine:`, because `engine:` already holds `daemon.json` +settings. `distro:` and `data-dir:` are rejected in a file that sets `target:`, since a +named engine's locations come from its name. + +## Which commands act on which engine + +`skrog engine upgrade|rollback|list`, `skrog config` and `skrog status` act on the +**current target**, the engine docker is on, so they change and report the engine you +are talking to rather than a different one. On a machine with no named engines this +costs nothing and behaves as it always did. + +Every other command (`start`, `stop`, `restart`, `snapshot`, `prune`, `compact`, +`doctor`, `logs`, ...) acts on the default engine unless you pass `--state-dir`. The +`WHERE` column of `skrog target list` is the path to give it: + +```powershell +skrog stop --state-dir "$env:LOCALAPPDATA\Skrog\engines\proj-a" +skrog snapshot --state-dir "$env:LOCALAPPDATA\Skrog\engines\proj-a" save before-upgrade +``` + +`--state-dir` always wins when you give it. Flags go before the verb for `skrog target`. + +## Starting at logon + +The default engine starts at logon. A named engine does not: it starts when you `use` +it. If you want one up at logon: + +```powershell +skrog target create proj-a --autostart +skrog autostart --engine proj-a enable # or later +skrog autostart --engine proj-a disable +skrog autostart --engine proj-a status +``` + +Each engine's logon entry is its own value in the per-user Run key (`Skrog-proj-a`, +visible in Task Manager's Startup tab), so enabling or removing one never touches the +default engine's or another engine's. + +## Uninstalling + +`skrog uninstall` removes the default engine **and every named engine under the same state +directory**, and its prompt lists them, so "removes everything Skrog created" stays true. +To remove one engine and keep the rest, use `skrog target remove `. Running +`skrog uninstall` against a named engine's own state directory is refused for that reason. + +## The tray + +The tray's dot follows the **current target**, and its tooltip names the engine when it +is a named one. Its Start, Stop and Restart items, and Open logs, act on the engine the +dot is showing. It stays the same seven items; there is no engine list in it. A list of +engines with per-engine state is a control panel, and `skrog target list` is that. + +## Disk + +RAM is not the cost of a second engine; **disk** is. Each engine is a WSL distro with +its own virtual disk: hundreds of MB before anything is pulled, plus whatever it holds. +There is no cap on the number of engines. Instead the cost is kept visible: + +- `skrog target list` shows each engine's disk and the total. +- `skrog doctor` judges the free-space floor ([`disk.warn-below`](housekeeping.md)) + against the fullest volume **any** engine lives on, lists what each engine holds, and + points at `skrog target remove` when space is short. +- `skrog prune` goes through whatever docker targets, so it prunes the current target; + `skrog compact --state-dir ` shrinks that engine's virtual disk. + +## Limits to know about + +- **Emulation is machine-wide.** `emulation.platforms` registers `binfmt_misc` handlers, + which are kernel state shared by every WSL2 distro. Stopping or removing *any* engine + removes them, so with several engines running, an idle-stop of one can take the + handlers away from another until an engine next starts. Foreign-architecture builds + that need no emulation setting are not affected. +- **One published-port relay per engine.** `network.publish-scope` is a per-engine + setting like any other; two engines publishing the same host port conflict exactly as + two containers would. +- **A named engine does not take over the `docker_engine` pipe.** Only the default + engine ever serves it. Named engines always use `\\.\pipe\skrog-`, so they + coexist with Docker Desktop with no fallback logic. +- **The VS Code extension and other tools** follow the docker context, so they follow + `skrog use`. Anything pinned to `DOCKER_HOST` stays on the engine it names. + +## Related + +- [Declarative install](declarative-install.md): the `target:` key +- [Remote engine over mTLS](remote-engine.md): the third kind of target +- [Engine upgrade and rollback](engine-upgrade.md): per-target dockerd versions +- [Housekeeping](housekeeping.md): prune, compact, and the free-space floor +- [Design: per-workspace engines](design/per-workspace-engine.md): why it is shaped this way diff --git a/internal/autostart/autostart_windows.go b/internal/autostart/autostart_windows.go index d621c16..9a21aa9 100644 --- a/internal/autostart/autostart_windows.go +++ b/internal/autostart/autostart_windows.go @@ -152,3 +152,68 @@ func resolvePath(p string) string { } return p } + +// EngineValueName is the Run-key entry name of a named engine's supervisor +// (#539). One entry per autostarted engine, beside the default engine's +// "Skrog": each is independent, so enabling or removing one never touches +// another, and Task Manager's Startup tab lists them by name. +func EngineValueName(engine string) string { return ValueName + "-" + engine } + +// EnableEngine registers the supervisor of a named engine to start at logon. +// The entry is the same windowless launcher as Enable's, told which state dir +// to serve; the supervisor derives the engine's pipe and docker context from +// that directory. +func EnableEngine(skrogExe, engine, stateDir string) error { + skrogExe = resolvePath(skrogExe) + launcher := filepath.Join(filepath.Dir(skrogExe), "skrogw.exe") + if _, err := os.Stat(launcher); err != nil { + return fmt.Errorf("autostart needs the skrogw.exe launcher next to skrog.exe "+ + "(a console binary at logon would flash a console window): %w", err) + } + k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening the Run key: %w", err) + } + defer k.Close() + cmd := `"` + launcher + `" --state-dir "` + stateDir + `"` + if err := k.SetStringValue(EngineValueName(engine), cmd); err != nil { + return fmt.Errorf("writing the Run entry: %w", err) + } + return nil +} + +// DisableEngine removes a named engine's logon entry; absent is success. +func DisableEngine(engine string) error { + k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if errors.Is(err, registry.ErrNotExist) || errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("opening the Run key: %w", err) + } + defer k.Close() + if err := k.DeleteValue(EngineValueName(engine)); err != nil && err != registry.ErrNotExist { + return fmt.Errorf("removing the Run entry: %w", err) + } + return nil +} + +// EngineStatus reports whether a named engine's logon entry exists. +func EngineStatus(engine string) (enabled bool, command string, err error) { + k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE) + if errors.Is(err, registry.ErrNotExist) || errors.Is(err, fs.ErrNotExist) { + return false, "", nil + } + if err != nil { + return false, "", fmt.Errorf("opening the Run key: %w", err) + } + defer k.Close() + v, _, err := k.GetStringValue(EngineValueName(engine)) + if err == registry.ErrNotExist { + return false, "", nil + } + if err != nil { + return false, "", fmt.Errorf("reading the Run entry: %w", err) + } + return true, v, nil +} diff --git a/internal/autostart/autostart_windows_test.go b/internal/autostart/autostart_windows_test.go index 9837bb6..b794dae 100644 --- a/internal/autostart/autostart_windows_test.go +++ b/internal/autostart/autostart_windows_test.go @@ -276,3 +276,54 @@ func TestDisableWithNoRunKeyIsSuccess(t *testing.T) { t.Errorf("Disable with no Run key: %v", err) } } + +// A named engine's logon entry (#539) is its own Run value: enabling it +// leaves the default engine's entry alone, it carries the state dir the +// supervisor must serve, and removing it takes only itself. +func TestEngineEntryIsIndependentOfTheDefaultEntry(t *testing.T) { + useScratchKey(t) + exe := fakeInstall(t, true) + stateDir := `C:\Users\x\AppData\Local\Skrog\engines\proj-a` + + if err := Enable(exe); err != nil { + t.Fatalf("Enable: %v", err) + } + if err := EnableEngine(exe, "proj-a", stateDir); err != nil { + t.Fatalf("EnableEngine: %v", err) + } + + enabled, cmd, err := EngineStatus("proj-a") + if err != nil || !enabled { + t.Fatalf("EngineStatus = %v, %v, %v", enabled, cmd, err) + } + if !strings.Contains(cmd, "skrogw.exe") || !strings.Contains(cmd, `--state-dir "`+stateDir+`"`) { + t.Errorf("Run entry = %q, want the launcher told which state dir to serve", cmd) + } + if on, _, _ := EngineStatus("proj-b"); on { + t.Error("an engine that was never enabled reports an entry") + } + + if err := DisableEngine("proj-a"); err != nil { + t.Fatalf("DisableEngine: %v", err) + } + if on, _, _ := EngineStatus("proj-a"); on { + t.Error("the engine entry survived DisableEngine") + } + if on, _, _ := Status(); !on { + t.Error("disabling an engine's entry removed the default engine's") + } + if err := DisableEngine("proj-a"); err != nil { + t.Errorf("disabling an absent entry must be success: %v", err) + } +} + +func TestEnableEngineRefusesWithoutLauncher(t *testing.T) { + useScratchKey(t) + exe := fakeInstall(t, false) + if err := EnableEngine(exe, "proj-a", `C:\s`); err == nil { + t.Fatal("registered a logon entry with no skrogw.exe beside skrog.exe") + } + if on, _, _ := EngineStatus("proj-a"); on { + t.Error("a refused EnableEngine left an entry behind") + } +} diff --git a/internal/doctor/check_disk.go b/internal/doctor/check_disk.go index ab4b403..68826d6 100644 --- a/internal/doctor/check_disk.go +++ b/internal/doctor/check_disk.go @@ -1,6 +1,10 @@ package doctor -import "fmt" +import ( + "fmt" + "path/filepath" + "strings" +) // Disk thresholds: the engine's VHDX grows on demand, and image pulls and // builds fail ugly when the volume runs out. Warn early, fail before docker @@ -26,6 +30,24 @@ func checkDisk() Check { detail := []string{fmt.Sprintf(" %s free of %s on %s", humanBytes(d.FreeBytes), humanBytes(d.TotalBytes), d.Path)} + // With named engines (#539) each is its own VHDX on its own growth + // path, so the floor is judged against the fullest volume any engine + // lives on, not just the one being examined. A drive that is fine for + // one engine can be full with five. + many := len(f.OtherEngines) > 0 + if many { + var used uint64 + for _, e := range f.OtherEngines { + used += e.UsedBytes + detail = append(detail, fmt.Sprintf(" engine %s holds %s on %s", + e.Name, humanBytes(e.UsedBytes), volumeOf(e.Disk.Path))) + if e.Disk.Err == "" && e.Disk.FreeBytes < d.FreeBytes { + d = e.Disk + } + } + detail = append(detail, fmt.Sprintf(" the other local engines hold %s together (`skrog target list`)", humanBytes(used))) + } + // The warn floor is configurable (`disk.warn-below`, #145) so a runner // with a small disk is flagged before pulls start failing; fail stays fixed. warn := uint64(diskWarnBytes) @@ -41,6 +63,9 @@ func checkDisk() Check { r.Remedy = "free space on that volume (`skrog prune --all --build-cache` reclaims " + "engine disk), or move the engine data with `--data-dir` on install; pulls " + "and builds fail when it runs out." + if many { + r.Remedy += " An engine you no longer use frees its whole disk with `skrog target remove`." + } return r case d.FreeBytes < warn: r := result(c, Warn, fmt.Sprintf("%s free for engine data (below the %s floor)", @@ -48,6 +73,9 @@ func checkDisk() Check { r.Detail = detail r.Remedy = "reclaim space with `skrog prune` (add --all --build-cache for the full " + "sweep); large images or builds may exhaust it." + if many { + r.Remedy += " An engine you no longer use frees its whole disk with `skrog target remove`." + } return r default: return result(c, OK, fmt.Sprintf("%s free for engine data", humanBytes(d.FreeBytes))) @@ -56,6 +84,14 @@ func checkDisk() Check { return c } +// volumeOf is the drive part of a path, for naming which volume an engine is on. +func volumeOf(p string) string { + if v := filepath.VolumeName(p); v != "" { + return v + } + return strings.TrimSpace(p) +} + func humanBytes(b uint64) string { const unit = 1024 if b < unit { diff --git a/internal/doctor/check_disk_test.go b/internal/doctor/check_disk_test.go index 76272ec..ab4d13f 100644 --- a/internal/doctor/check_disk_test.go +++ b/internal/doctor/check_disk_test.go @@ -1,6 +1,9 @@ package doctor -import "testing" +import ( + "strings" + "testing" +) func TestCheckDisk(t *testing.T) { c := checkDisk() @@ -36,3 +39,28 @@ func TestHumanBytes(t *testing.T) { } } } + +// With several local engines the floor is judged against the fullest volume any +// of them lives on (#539): the engine being examined has room, another engine +// is on a nearly full drive, and the answer is still a warning that names it. +func TestCheckDiskJudgesEveryEnginesVolume(t *testing.T) { + c := checkDisk() + roomy := DiskInfo{Path: `C:\a\distro`, FreeBytes: 50 << 30, TotalBytes: 100 << 30} + tight := DiskInfo{Path: `D:\b\distro`, FreeBytes: 3 << 30, TotalBytes: 100 << 30} + + r := c.Run(Facts{Disk: roomy, OtherEngines: []EngineDisk{{Name: "proj-a", UsedBytes: 8 << 30, Disk: tight}}}) + if r.Status != Warn { + t.Fatalf("status = %v, want Warn: another engine's drive is below the floor", r.Status) + } + if !strings.Contains(strings.Join(r.Detail, "\n"), "proj-a") || !strings.Contains(r.Remedy, "skrog target remove") { + t.Errorf("the warning must name the engine and the way out:\n%v\n%s", r.Detail, r.Remedy) + } + + // Plenty everywhere is still fine, and a machine with one engine reads as before. + if got := c.Run(Facts{Disk: roomy, OtherEngines: []EngineDisk{{Name: "x", Disk: roomy}}}).Status; got != OK { + t.Errorf("roomy engines: status = %v, want OK", got) + } + if r := c.Run(Facts{Disk: roomy}); r.Status != OK || len(r.Detail) != 0 { + t.Errorf("a single engine's report changed: %v %v", r.Status, r.Detail) + } +} diff --git a/internal/doctor/engines.go b/internal/doctor/engines.go new file mode 100644 index 0000000..73a3209 --- /dev/null +++ b/internal/doctor/engines.go @@ -0,0 +1,44 @@ +package doctor + +import ( + "path/filepath" + + "github.com/wslkit/skrog/internal/target" +) + +// otherEngineDisks lists every local engine other than the one whose state dir +// is given, with its use and the volume it lives on (#539). Empty on a machine +// that never created a named engine, which is the common case and costs one +// directory read. +func otherEngineDisks(stateDir string) []EngineDisk { + if stateDir == "" { + return nil + } + base := target.BaseOf(stateDir) + names, _ := target.List(base) + if len(names) == 0 { + return nil + } + self, _ := target.NameOf(stateDir) + + var out []EngineDisk + if self != "" { + // Doctoring a named engine: the default engine is one of the others. + dir := filepath.Join(base, "distro") + out = append(out, EngineDisk{ + Name: "local", UsedBytes: uint64(target.DiskUsage(dir)), Disk: diskInfo(dir), + }) + } + for _, n := range names { + if n == self { + continue + } + l := target.For(base, n) + out = append(out, EngineDisk{ + Name: n, + UsedBytes: uint64(target.DiskUsage(l.StateDir)), + Disk: diskInfo(filepath.Join(l.StateDir, "distro")), + }) + } + return out +} diff --git a/internal/doctor/host.go b/internal/doctor/host.go index 871790e..b76a603 100644 --- a/internal/doctor/host.go +++ b/internal/doctor/host.go @@ -77,6 +77,12 @@ type Facts struct { // Disk describes free space on the volume holding the engine's data. Disk DiskInfo + // OtherEngines are the local engines besides the one being examined (#539): + // the default engine when this is a named one, and every named engine. Each + // is a VHDX that grows on its own, so the free-space floor has to be judged + // against all of them -- a drive that is fine for one engine can be full + // with five. + OtherEngines []EngineDisk // DiskWarnBelow is the configured free-space floor (`disk.warn-below`, // #145) in bytes; 0 means checkDisk's built-in default. DiskWarnBelow uint64 @@ -223,6 +229,14 @@ type DiskInfo struct { Err string `json:"err,omitempty"` } +// EngineDisk is one local engine's disk use and the volume it grows on. +type EngineDisk struct { + Name string `json:"name"` + // UsedBytes is what the engine's data holds today. + UsedBytes uint64 `json:"usedBytes"` + Disk DiskInfo `json:"disk"` +} + // SSHAgentInfo is how `docker build --ssh default` would find an SSH agent. // Err is set when the question could not be asked (a non-Windows host). type SSHAgentInfo struct { @@ -329,6 +343,7 @@ func Gather(ctx context.Context, opts GatherOptions) Facts { f.CredHelpers = discoverCredHelpers(dockerConfigPath(), execLookPath) f.Disk = diskInfo(engineDataDir(stateDir)) + f.OtherEngines = otherEngineDisks(stateDir) f.SSHAgent = sshAgentInfo() if c, err := config.Load(stateDir); err == nil { diff --git a/internal/provision/provision.go b/internal/provision/provision.go index bb8c7cb..f52b7c3 100644 --- a/internal/provision/provision.go +++ b/internal/provision/provision.go @@ -70,6 +70,13 @@ type Options struct { // StateDir holds the manifest and the rootfs cache. // Defaults to %LOCALAPPDATA%\Skrog. StateDir string + // CacheDir is where the verified rootfs download is kept, shared by every + // engine of one machine (#539): a second engine imports the tarball the + // first one already fetched and checked, instead of downloading 200 MB + // again. Empty means StateDir, which is the layout every existing install + // has. The checksum is verified on every use, so a shared cache is only + // ever a place bytes are kept, never a thing trusted. + CacheDir string // DataDir is where the distro's VHDX lives. Defaults to StateDir\distro. // Exposed because "move it off C:" is a perennial request (PLAN §03). DataDir string @@ -278,7 +285,7 @@ func (p *Provisioner) Install(ctx context.Context, opts Options) (*Manifest, err return nil, &PreflightError{Report: report} } - rootfs := filepath.Join(opts.StateDir, "rootfs", filepath.Base(opts.RootfsURL)) + rootfs := filepath.Join(opts.rootfsCacheDir(), filepath.Base(opts.RootfsURL)) if err := p.fetchRootfs(ctx, opts.RootfsURL, opts.RootfsSHA256, rootfs); err != nil { return nil, err } @@ -1211,7 +1218,7 @@ func (p *Provisioner) verifyRootfsSignature(ctx context.Context, opts Options, t v := &rootfsverify.Verifier{ Fetch: p.fetcher(), Repo: signingRepo, - TempDir: filepath.Join(opts.StateDir, "rootfs"), + TempDir: opts.rootfsCacheDir(), } res, err := v.Verify(ctx, opts.RootfsURL, tarball) switch { @@ -1311,3 +1318,12 @@ func (p *Provisioner) DistroAddress(ctx context.Context, opts Options) (string, } return addr, nil } + +// rootfsCacheDir is the directory the rootfs download is cached in. +func (o Options) rootfsCacheDir() string { + base := o.CacheDir + if base == "" { + base = o.StateDir + } + return filepath.Join(base, "rootfs") +} diff --git a/internal/skrogfile/skrogfile.go b/internal/skrogfile/skrogfile.go index b1f5fd6..5042b22 100644 --- a/internal/skrogfile/skrogfile.go +++ b/internal/skrogfile/skrogfile.go @@ -14,6 +14,7 @@ import ( "github.com/wslkit/skrog/internal/config" "github.com/wslkit/skrog/internal/engineconfig" + "github.com/wslkit/skrog/internal/target" "gopkg.in/yaml.v3" ) @@ -24,6 +25,11 @@ import ( // JSON tags mirror the YAML keys, so `profile show --json` and `config export // --json` emit the same document a skrog.yaml holds (#137). type File struct { + // Target names the local engine this workspace uses (#539): `skrog install + // --config` creates it when missing, converges its settings, and points + // docker at it. Absent means the default shared engine. It is `target`, not + // `engine`, because `engine:` below already holds daemon.json settings. + Target string `yaml:"target,omitempty" json:"target,omitempty"` // Distro is the WSL distro name (install --distro). Distro string `yaml:"distro,omitempty" json:"distro,omitempty"` // DataDir is where the distro's VHDX lives (install --data-dir). @@ -77,6 +83,15 @@ func Parse(b []byte) (File, error) { // hooks. must name real settings, so a misspelled key is rejected at load // rather than silently dropped at apply. func (f File) Validate() error { + if f.Target != "" { + if err := target.ValidName(f.Target); err != nil { + return fmt.Errorf("target: %w", err) + } + if f.Distro != "" || f.DataDir != "" { + return fmt.Errorf("target: a named engine's distro and data dir are derived from its name; " + + "remove distro: and data-dir: from a file that sets target:") + } + } engineKeys := toSet(engineconfig.Keys()) for k := range f.Engine { if !engineKeys[k] { diff --git a/internal/skrogfile/skrogfile_test.go b/internal/skrogfile/skrogfile_test.go index 59fbcda..b22600e 100644 --- a/internal/skrogfile/skrogfile_test.go +++ b/internal/skrogfile/skrogfile_test.go @@ -110,3 +110,33 @@ func TestMarshalOmitsEmpty(t *testing.T) { t.Fatalf("empty fields should be omitted:\n%s", s) } } + +func TestTargetKey(t *testing.T) { + f, err := Parse([]byte("target: proj-a\nengine-version: 29.7.2\nengine:\n registry-mirrors: https://m.example\n")) + if err != nil { + t.Fatalf("a target beside the engine: settings map: %v", err) + } + if f.Target != "proj-a" || f.Engine["registry-mirrors"] == "" { + t.Errorf("parsed %+v", f) + } + + for name, doc := range map[string]string{ + "reserved": "target: local\n", + "bad characters": "target: Proj A\n", + "distro conflicts": "target: proj-a\ndistro: skrog-other\n", + "data-dir clashes": "target: proj-a\ndata-dir: D:/x\n", + } { + if _, err := Parse([]byte(doc)); err == nil { + t.Errorf("%s: %q was accepted", name, doc) + } + } + + // No target: nothing changes for a file that never had one. + b, err := File{Distro: "d"}.Marshal() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "target") { + t.Errorf("an unset target leaked into the YAML: %s", b) + } +} diff --git a/internal/target/target.go b/internal/target/target.go new file mode 100644 index 0000000..974645e --- /dev/null +++ b/internal/target/target.go @@ -0,0 +1,185 @@ +// Package target is the naming and layout of per-workspace engines (#539, +// design: docs/design/per-workspace-engine.md). +// +// A named engine is a complete, independent Skrog install living under +// \engines\: its own WSL distro, VHDX, supervisor, pipe, docker +// context, settings, snapshots and logs. Nothing here touches WSL or docker; +// it only decides where things are called and where they live, so every other +// layer asks one place instead of rebuilding the convention. +// +// The names matter more than they look. The docker context for engine +// is skrog-, the same shape `skrog remote add ` already uses, so +// remotes and local engines share one namespace ("targets") and one switch +// verb. Anything that would let two of them claim the same name is refused. +package target + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const ( + // Local is the reserved target name for the default shared engine, whose + // docker context is plain `skrog`. + Local = "local" + + // EnginesDir is the directory under the base state dir that holds one + // subdirectory per named engine. + EnginesDir = "engines" + + // DistroPrefix, ContextPrefix and PipePrefix are prepended to the engine + // name to form its WSL distro, docker context and named pipe. + DistroPrefix = "skrog-" + ContextPrefix = "skrog-" + PipePrefix = `\\.\pipe\skrog-` + + // defaultDistroSuffix is what the default engine's distro takes + // (skrog-engine), so no named engine may use it. + defaultDistroSuffix = "engine" +) + +// nameRE bounds an engine name. Lowercase only: the name becomes a directory, +// a WSL distro, a docker context and a pipe, and Windows treats the first, +// third and fourth case-insensitively, so "Proj" and "proj" would be one +// engine wearing two names. No leading or trailing separator, for the same +// reason a directory may not end in a dot. +var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]{0,30}[a-z0-9])?$`) + +// ValidName reports whether name may be used for a named engine. +func ValidName(name string) error { + switch { + case name == Local: + return fmt.Errorf("%q is reserved for the default shared engine", Local) + case name == defaultDistroSuffix: + return fmt.Errorf("%q is reserved: its distro would be the default engine's (%s%s)", + name, DistroPrefix, defaultDistroSuffix) + case !nameRE.MatchString(name): + return fmt.Errorf("invalid engine name %q: lowercase letters, digits, - and _ "+ + "(1 to 32), starting and ending with a letter or digit", name) + } + return nil +} + +// Layout is where one named engine lives and what it is called. +type Layout struct { + Name string `json:"name"` + StateDir string `json:"stateDir"` + Distro string `json:"distro"` + Pipe string `json:"pipe"` + Context string `json:"context"` +} + +// For returns the layout of engine name under the base state dir. It does not +// validate: callers that accept a name from a user call ValidName first. +func For(baseStateDir, name string) Layout { + return Layout{ + Name: name, + StateDir: filepath.Join(EnginesRoot(baseStateDir), name), + Distro: DistroPrefix + name, + Pipe: PipePrefix + name, + Context: ContextPrefix + name, + } +} + +// EnginesRoot is the directory holding every named engine of a base state dir. +func EnginesRoot(baseStateDir string) string { + return filepath.Join(baseStateDir, EnginesDir) +} + +// NameOf recognises a state dir that belongs to a named engine +// (\engines\) and returns the name. The default engine's state dir +// is not one, so it returns false for it, which is how the supervisor knows to +// keep its original pipe and `skrog` context behaviour untouched. +func NameOf(stateDir string) (string, bool) { + if stateDir == "" { + return "", false + } + clean := filepath.Clean(stateDir) + name := filepath.Base(clean) + if !strings.EqualFold(filepath.Base(filepath.Dir(clean)), EnginesDir) { + return "", false + } + if ValidName(name) != nil { + return "", false + } + return name, true +} + +// BaseOf returns the base state dir a state dir belongs to: the parent of +// `engines` for a named engine, the dir itself otherwise. Remotes, the rootfs +// download cache and the engine registry live in the base, so a command run +// against an engine's state dir still finds them. +func BaseOf(stateDir string) string { + if _, ok := NameOf(stateDir); ok { + return filepath.Dir(filepath.Dir(filepath.Clean(stateDir))) + } + return stateDir +} + +// List returns the names of the engines that exist under a base state dir, in +// name order. A directory counts whether or not its install finished: a +// half-created engine must still be visible so it can be removed. +func List(baseStateDir string) ([]string, error) { + entries, err := os.ReadDir(EnginesRoot(baseStateDir)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() && ValidName(e.Name()) == nil { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names, nil +} + +// Exists reports whether an engine of that name has a directory. +func Exists(baseStateDir, name string) bool { + st, err := os.Stat(For(baseStateDir, name).StateDir) + return err == nil && st.IsDir() +} + +// DiskUsage is the number of bytes under dir (the engine's VHDX dominates it). +// A missing directory is zero rather than an error, because a half-created +// engine is a case worth reporting, not failing on. +func DiskUsage(dir string) int64 { + var total int64 + _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, err := d.Info(); err == nil { + total += info.Size() + } + return nil + }) + return total +} + +// CurrentName maps docker's current context to a target: Local for `skrog`, +// the engine's name for skrog-, and "" for anything else (a remote, or +// a context that is not Skrog's). engines is the set of local engine names. +func CurrentName(dockerContext string, engines []string) string { + if dockerContext == "skrog" { + return Local + } + name, ok := strings.CutPrefix(dockerContext, ContextPrefix) + if !ok { + return "" + } + for _, e := range engines { + if e == name { + return name + } + } + return "" +} diff --git a/internal/target/target_test.go b/internal/target/target_test.go new file mode 100644 index 0000000..24da27b --- /dev/null +++ b/internal/target/target_test.go @@ -0,0 +1,132 @@ +package target + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestValidName(t *testing.T) { + for _, ok := range []string{"a", "proj-a", "proj_a", "p1", "a-b-c", "x2y"} { + if err := ValidName(ok); err != nil { + t.Errorf("ValidName(%q) = %v, want ok", ok, err) + } + } + for _, bad := range []string{ + "", "local", "engine", "Proj", "-a", "a-", "_a", "a.b", "a b", "a/b", `a\b`, + "..", "con.", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 33 + } { + if err := ValidName(bad); err == nil { + t.Errorf("ValidName(%q) accepted a bad name", bad) + } + } +} + +// The default engine's distro is skrog-engine; a named engine called "engine" +// would import over it. +func TestNoNameCollidesWithTheDefaultDistro(t *testing.T) { + if For("base", "x").Distro == "skrog-engine" { + t.Fatal("test premise broken") + } + if ValidName("engine") == nil { + t.Error("\"engine\" would collide with the default engine's distro") + } +} + +func TestForLayout(t *testing.T) { + l := For(`C:\s`, "proj-a") + want := Layout{ + Name: "proj-a", + StateDir: filepath.Join(`C:\s`, "engines", "proj-a"), + Distro: "skrog-proj-a", + Pipe: `\\.\pipe\skrog-proj-a`, + Context: "skrog-proj-a", + } + if l != want { + t.Errorf("got %+v, want %+v", l, want) + } +} + +func TestNameOfAndBaseOf(t *testing.T) { + base := filepath.Join(t.TempDir(), "Skrog") + l := For(base, "proj-a") + + if n, ok := NameOf(l.StateDir); !ok || n != "proj-a" { + t.Errorf("NameOf(engine dir) = %q, %v", n, ok) + } + if got := BaseOf(l.StateDir); got != base { + t.Errorf("BaseOf(engine dir) = %q, want %q", got, base) + } + // The default engine's dir is not a named engine. + if n, ok := NameOf(base); ok { + t.Errorf("NameOf(base) = %q, true", n) + } + if got := BaseOf(base); got != base { + t.Errorf("BaseOf(base) = %q", got) + } + // An engines dir whose child is not a valid name is not one either. + if _, ok := NameOf(filepath.Join(base, "engines", "Bad Name")); ok { + t.Error("an invalid directory name was taken for an engine") + } + if _, ok := NameOf(""); ok { + t.Error("empty state dir was taken for an engine") + } +} + +func TestListAndExists(t *testing.T) { + base := t.TempDir() + if got, err := List(base); err != nil || len(got) != 0 { + t.Fatalf("List on a base with no engines = %v, %v", got, err) + } + for _, n := range []string{"zeta", "alpha"} { + if err := os.MkdirAll(For(base, n).StateDir, 0o755); err != nil { + t.Fatal(err) + } + } + // Junk the registry must ignore: a file, and a directory with a bad name. + _ = os.WriteFile(filepath.Join(EnginesRoot(base), "stray.txt"), []byte("x"), 0o644) + _ = os.MkdirAll(filepath.Join(EnginesRoot(base), "Not Valid"), 0o755) + + got, err := List(base) + if err != nil { + t.Fatal(err) + } + if want := []string{"alpha", "zeta"}; !reflect.DeepEqual(got, want) { + t.Errorf("List = %v, want %v", got, want) + } + if !Exists(base, "alpha") || Exists(base, "nope") { + t.Error("Exists disagrees with List") + } +} + +func TestDiskUsage(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "distro"), 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, "a"), make([]byte, 100), 0o644) + _ = os.WriteFile(filepath.Join(dir, "distro", "ext4.vhdx"), make([]byte, 400), 0o644) + if got := DiskUsage(dir); got != 500 { + t.Errorf("DiskUsage = %d, want 500", got) + } + if got := DiskUsage(filepath.Join(dir, "missing")); got != 0 { + t.Errorf("DiskUsage(missing) = %d, want 0", got) + } +} + +func TestCurrentName(t *testing.T) { + engines := []string{"proj-a"} + for ctx, want := range map[string]string{ + "skrog": Local, + "skrog-proj-a": "proj-a", + "skrog-desk": "", // a remote, not an engine + "default": "", + "desktop-linux": "", + "": "", + } { + if got := CurrentName(ctx, engines); got != want { + t.Errorf("CurrentName(%q) = %q, want %q", ctx, got, want) + } + } +} diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 5af89e4..78ce5b7 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -36,6 +36,10 @@ type Status struct { // StateDir is where the supervisor publishes its stats. Learning it once // is what lets the tray stop shelling out for every refresh (#192). StateDir string `json:"stateDir"` + // Target is the named engine the dot describes (#539); empty for the default + // shared engine. The dot follows the engine docker is on, so the tooltip + // has to say which one that is. + Target string `json:"target"` } // State collapses a Status into the dot's State. @@ -71,6 +75,28 @@ func Tooltip(st State) string { } } +// TooltipFor is Tooltip naming the engine when it is a named one, so with +// several engines the dot never leaves you guessing which it describes. +func TooltipFor(st State, target string) string { + t := Tooltip(st) + if target == "" { + return t + } + return t + " (" + target + ")" +} + +// ActionArgs are the CLI arguments for an action aimed at the engine whose +// state dir is stateDir. The lifecycle verbs act on the default engine when not +// told otherwise, and the dot may be describing a named one: a menu that shows +// engine B's state and acts on engine A would be the worst kind of wrong. +func ActionArgs(a Action, stateDir string) []string { + args := append([]string(nil), a.Args...) + if stateDir != "" { + args = append(args, "--state-dir", stateDir) + } + return args +} + // Healthy reports whether the state should read as green: running or idle are // both "working as intended" (idle wakes on the next docker command). func Healthy(st State) bool { return st == StateRunning || st == StateIdle } @@ -121,9 +147,14 @@ var Actions = []Action{ // Run executes an action through the CLI and returns its combined output. The // tray shows failures as a notification rather than swallowing them. func (c CLI) Run(ctx context.Context, a Action) (string, error) { + return c.RunOn(ctx, a, "") +} + +// RunOn is Run against a specific engine's state dir; empty is the default. +func (c CLI) RunOn(ctx context.Context, a Action, stateDir string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() - out, err := hideWindow(exec.CommandContext(ctx, c.Exe, a.Args...)).CombinedOutput() + out, err := hideWindow(exec.CommandContext(ctx, c.Exe, ActionArgs(a, stateDir)...)).CombinedOutput() return strings.TrimSpace(string(out)), err } diff --git a/internal/tray/tray_test.go b/internal/tray/tray_test.go index 92c2c9e..360e18d 100644 --- a/internal/tray/tray_test.go +++ b/internal/tray/tray_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" + "strings" "testing" "time" @@ -147,3 +149,30 @@ func TestPollPublishedRefusesWhatItCannotTrust(t *testing.T) { } }) } + +// With several engines the dot follows the one docker is on, so the tooltip +// names a named engine and the default engine's tooltip is exactly what it was +// (#539). +func TestTooltipNamesTheTarget(t *testing.T) { + if got, want := TooltipFor(StateRunning, ""), Tooltip(StateRunning); got != want { + t.Errorf("the default engine's tooltip changed: %q", got) + } + if got := TooltipFor(StateRunning, "proj-a"); !strings.Contains(got, "proj-a") || !strings.Contains(got, "running") { + t.Errorf("tooltip = %q, want the state and the engine's name", got) + } +} + +// A lifecycle click must act on the engine the dot is showing, not the default. +func TestActionArgsAimAtTheShownEngine(t *testing.T) { + a := Action{Label: "Stop engine", Args: []string{"stop"}} + if got := ActionArgs(a, ""); !reflect.DeepEqual(got, []string{"stop"}) { + t.Errorf("no engine named: args = %v", got) + } + got := ActionArgs(a, `C:\s\engines\proj-a`) + if want := []string{"stop", "--state-dir", `C:\s\engines\proj-a`}; !reflect.DeepEqual(got, want) { + t.Errorf("args = %v, want %v", got, want) + } + if len(a.Args) != 1 { + t.Errorf("ActionArgs mutated the shared action table: %v", a.Args) + } +} diff --git a/site/data/nav.yaml b/site/data/nav.yaml index 99fe498..a47ff6d 100644 --- a/site/data/nav.yaml +++ b/site/data/nav.yaml @@ -8,7 +8,7 @@ # would link to it. sections: - section: Start - pages: [install, docker-cli, ports, devcontainers] + pages: [install, docker-cli, ports, devcontainers, targets] - section: Keep it healthy pages: [housekeeping, memory, snapshots, upgrading, engine-upgrade, vm-sizing] - section: At work @@ -30,4 +30,5 @@ short: security: Security model bumping-upstream: Bumping the engine declarative-install: Declarative install + targets: Per-workspace engines memory: skrog top diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 944a7bc..a850945 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -127,6 +127,7 @@ func TestAcceptance(t *testing.T) { {"RemoteEngineOverMutualTLS", stageServeMTLS}, {"EngineSnapshotSaveAndList", stageSnapshot}, {"UpgradeReportsCurrentAfterInstall", stageUpgradeCheck}, + {"PerWorkspaceEngineIsIsolated", stageTargets}, // Late on purpose: it runs `wsl --shutdown`, which takes every distro // on the machine down, so everything that wants a warm one has gone // first (#465). @@ -2125,6 +2126,12 @@ func forceCleanup(s *state) { exec.Command(s.skrog, "uninstall", "--state-dir", s.stateDir, "--yes").Run() } exec.Command("wsl.exe", "--unregister", distro).Run() + // The per-workspace engine stageTargets creates (#539), should that stage + // have died before removing it. + if s.skrog != "" && s.stateDir != "" { + exec.Command(s.skrog, "target", "remove", targetName, "--state-dir", s.stateDir, "--yes").Run() + } + exec.Command("wsl.exe", "--unregister", "skrog-"+targetName).Run() } // findDocker resolves the docker CLI: PATH first, then where Docker Desktop @@ -2593,3 +2600,113 @@ func emulationHandlerPresent(t *testing.T, handler string) bool { } return strings.Contains(strings.ReplaceAll(out, "\x00", ""), handler) } + +// targetName is the per-workspace engine stageTargets creates. Its distro is +// skrog-, its pipe \.\pipe\skrog-, its context skrog-. +const targetName = "e2e-two" + +// stageTargets is #539 end to end, against a real second engine: create it, +// switch to it, prove it is ISOLATED from the engine beside it, and remove it +// without leaving anything behind. The suite's own engine and the machine's +// docker context are put back exactly as they were found. +func stageTargets(t *testing.T, s *state) { + ctxName := "skrog-" + targetName + + // docker against a named context, with nothing in the environment that would + // override it (DOCKER_HOST and DOCKER_CONTEXT beat --context). + dockerCtx := func(timeout time.Duration, args ...string) (string, error) { + var env []string + for _, kv := range os.Environ() { + switch strings.ToUpper(strings.SplitN(kv, "=", 2)[0]) { + case "DOCKER_HOST", "DOCKER_CONTEXT": + continue + } + env = append(env, kv) + } + env = append(env, "PATH="+os.Getenv("PATH")+string(os.PathListSeparator)+filepath.Dir(s.docker)) + return runEnv(t, env, timeout, s.docker, append([]string{"--context", ctxName}, args...)...) + } + + // Put the machine's current docker context back afterwards, whatever + // happens: `skrog use` changes it, and a test must not. + before, _ := runEnv(t, os.Environ(), 30*time.Second, s.docker, "context", "show") + t.Cleanup(func() { + if before != "" { + runEnv(t, os.Environ(), 30*time.Second, s.docker, "context", "use", before) + } + }) + + out, err := run(t, 15*time.Minute, s.skrog, "target", "--state-dir", s.stateDir, "--headless", "create", targetName) + must(t, out, err, "skrog target create") + + // Listed, stopped (a created engine costs nothing until it is used), and + // holding disk. + out, err = run(t, time.Minute, s.skrog, "target", "--state-dir", s.stateDir, "--json", "list") + must(t, out, err, "skrog target list --json") + var list struct { + Targets []struct { + Name string `json:"name"` + Kind string `json:"kind"` + State string `json:"state"` + DiskBytes int64 `json:"diskBytes"` + } `json:"targets"` + TotalDiskBytes int64 `json:"totalDiskBytes"` + } + if err := json.Unmarshal([]byte(out), &list); err != nil { + t.Fatalf("target list --json is not JSON: %v\n%s", err, out) + } + found := false + for _, e := range list.Targets { + if e.Name == targetName { + found = true + if e.Kind != "engine" || e.State != "stopped" || e.DiskBytes <= 0 { + t.Errorf("new engine listed as %+v, want kind engine, stopped, with disk", e) + } + } + } + if !found { + t.Fatalf("target list does not show %q:\n%s", targetName, out) + } + if list.TotalDiskBytes <= 0 { + t.Errorf("totalDiskBytes = %d", list.TotalDiskBytes) + } + + // Switching starts it, and docker through its context reaches ITS dockerd. + out, err = run(t, 5*time.Minute, s.skrog, "use", "--state-dir", s.stateDir, targetName) + must(t, out, err, "skrog use") + if v, err := dockerCtx(time.Minute, "version", "--format", "{{.Server.Version}}"); err != nil || v == "" { + t.Fatalf("docker through %s did not reach the new engine: %v\n%s", ctxName, err, v) + } + if cur, _ := runEnv(t, os.Environ(), 30*time.Second, s.docker, "context", "show"); cur != ctxName { + t.Errorf("docker context after `skrog use` = %q, want %q", cur, ctxName) + } + + // The point of the feature: what the suite's own engine pulled is not here. + // HelloWorld ran earlier, so hello-world is an image there and must not be one here. + if img, err := dockerE(t, s, time.Minute, "image", "ls", "-q", "hello-world"); err != nil || img == "" { + t.Fatalf("the suite's own engine lost hello-world: %v %q", err, img) + } + if img, err := dockerCtx(time.Minute, "image", "ls", "-q", "hello-world"); err != nil || img != "" { + t.Errorf("the new engine can see the other engine's image: %v %q", err, img) + } + + // And the suite's own engine is untouched by all of it. + if v, err := dockerE(t, s, 30*time.Second, "version", "--format", "{{.Server.Version}}"); err != nil || v == "" { + t.Errorf("the suite's engine stopped answering: %v %s", err, v) + } + + out, err = run(t, 5*time.Minute, s.skrog, "target", "--state-dir", s.stateDir, "--yes", "remove", targetName) + must(t, out, err, "skrog target remove") + + // Nothing left behind: no distro, no context, no directory, no listing. + list2, _ := run(t, 30*time.Second, "wsl.exe", "--list", "--quiet") + if strings.Contains(strings.ReplaceAll(list2, "\x00", ""), "skrog-"+targetName) { + t.Errorf("distro skrog-%s still registered after remove:\n%s", targetName, list2) + } + if ctxs, _ := runEnv(t, os.Environ(), 30*time.Second, s.docker, "context", "ls", "--format", "{{.Name}}"); strings.Contains(ctxs, ctxName) { + t.Errorf("docker context %s survived remove:\n%s", ctxName, ctxs) + } + if _, err := os.Stat(filepath.Join(s.stateDir, "engines", targetName)); err == nil { + t.Errorf("engine directory survived remove") + } +}