Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` installs a second, fully independent engine: its own WSL
distro and virtual disk, supervisor, pipe (`\\.\pipe\skrog-<name>`), docker context
(`skrog-<name>`), 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 <name>|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 <name>`** (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
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) ·
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion cmd/skrog/autostart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>] 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
Expand All @@ -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 <name> 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
Expand Down
2 changes: 1 addition & 1 deletion cmd/skrog/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions cmd/skrog/declare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/skrog/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
128 changes: 42 additions & 86 deletions cmd/skrog/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>`"+`.

Exit codes: 0 ok, %d error.

flags:
Expand All @@ -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)
Expand All @@ -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" {
Expand All @@ -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
Expand Down
Loading
Loading