Skip to content

Group profiles into categories - #47

Merged
imanimanyara merged 8 commits into
mainfrom
feat/profile-categories
Oct 19, 2026
Merged

imanimanyara merged 8 commits into
mainfrom
feat/profile-categories

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

What & why

Profiles can now be grouped into categories. A profile with "category": "clients" keeps its keys under profiles/clients/<profile>/ and renders its Host blocks into profiles/clients/config, which the root config Includes. Profiles without a category, and every existing manifest, render byte-identical to before.

This came out of a real ~/.ssh whose profiles had been moved by hand into internal/, clients/ and others/. After that move, every IdentityFile sshmgr rendered was one directory too high. reconcile would have minted a replacement for every key that still worked, and config render would have deleted the hand-written category files, since they matched the profiles/*/config pattern it prunes.

Six commits, each reviewable on its own:

  1. Optional category on profiles. ProfileRel / ProfileDir / KeyPath are the one place the layout is decided. The field is omitted when unset, so manifests round-trip byte-identical. Validation rejects unsafe segments, a category that collides with an uncategorized profile's directory, and categorized profiles named config or old.
  2. Every key path goes through the manifest. 11 call sites built profiles/<profile>/<key> by hand: deploy, validate, rotate, recover, agent load, key listing, diff, reconcile, and both lifecycle deletes. Scans that read the tree without a manifest (doctor, snapshots, keyaudit) use GlobProfileTree, which covers both layouts and skips old/ and dot directories. A layering test fails any new filepath.Join with a computed segment after "profiles".
  3. Each category renders into its own Include file. The Includes open with Match all. An Include that follows a foreign Host block applies only to that host. A test runs real ssh -G against both forms to pin this. Pruning now only removes a profiles/*/config that carries the managed header or sits in a flat profile directory (the v1 slot, so TestLegacyPerProfileConfigsArePruned still holds).
  4. reconcile refuses to mint a key that only changed directory, and names where it is. doctor reports such a key as untracked at its real path, and its missing finding no longer advises reconcile.
  5. --category on profile add / profile edit. The edit moves the key directory and re-points inventory records. If the directory was already moved by hand, only the records change. It refuses if both directories exist.
  6. Docs: a new recipe, the configuration reference, and an architecture rationale. Also fixes the configuration page's stale Include profiles/*/config example.

Invariant checklist

  • Manifest stays the source of truth; no hand-edited ~/.ssh/config.
  • Config changes go through the single renderer (src/internal/core/renderer).
  • State I/O is atomic and under the advisory lock; no secrets added to git.
  • Perms (700/600/644) preserved; converging commands stay idempotent.

Checks

  • gofmt, go build ./... and go vet ./... are clean locally.
  • golangci-lint was not run locally: the installed binary was built with Go 1.26 and panics on this Go 1.27 toolchain, including on unmodified main. CI's lint step is the check.
  • go test ./...: the only failures are the same 10 that fail on unmodified main in the sandbox it ran in, all from local port binding being denied (httptest / netcheck listeners).
  • New tests: manifest category validation and paths, GlobProfileTree, IsArchivedPath, keyaudit for misplaced keys, reconcile refusing to mint, renderer output plus the ssh -G Match all proof, configsvc prune ownership, SetCategory, and the CLI --category flow. Each new guard was shown to fail with its check disabled.
  • End to end: the built binary, run against a throwaway home with the real layout (placeholder keys), migrated with five profile edit --category calls. Result: all 7 inventory records re-pointed, reconcile --dry-run reports "all 7 present (none minted)", and config check is in sync.
  • CHANGELOG.md and README.md updated.

A profile with a category keeps its keys under
profiles/<category>/<profile>/ instead of profiles/<profile>/.
ProfileRel, ProfileDir and KeyPath are now the one place that layout
is decided, and IdentityFile goes through them. A profile without a
category resolves exactly as before, and the field is omitted when
unset, so existing manifests round-trip byte-identical.

Validation rejects a category that is not a safe path segment, one
that collides with an uncategorized profile's directory, and a
categorized profile named "config".
Eleven call sites built profiles/<profile>/<key> by hand. For a profile
with a category each of them pointed one directory too high: deploy,
validate, rotate, recover, agent load, key listing, diff, reconcile and
both lifecycle deletes now go through Manifest.KeyPath / ProfileDir,
and the rotator's archived-key path through IdentityFile.

Scans that read the tree without a manifest (doctor's archive and
duplicate-key checks, snapshot residue cleanup, keyaudit's untracked
sweep) use GlobProfileTree, which matches both layouts and skips a flat
profile's old/ and dot directories. IsArchivedPath recognises the
categorized archive slot, and the importer reads a category off a key
found under profiles/<category>/<profile>/.

keyaudit now reports a declared key that sits in another directory than
the manifest resolves, naming where it is expected, instead of showing
only a missing key. A layering test fails any new filepath.Join that
puts a computed segment after "profiles".
A profile with a category now renders into profiles/<category>/config,
and the root config's managed block opens with one Include per category
ahead of the inline hosts and Host *. Uncategorized profiles stay
inline, and a manifest with no categories renders byte-identical.

The Includes are preceded by Match all: the managed block is composed
under whatever the user keeps above it, and an Include that follows a
Host block applies only to that host. A test runs ssh -G against both
forms so the reason is pinned to OpenSSH, not to a comment.

Category files carry the managed header. config render prunes a
profiles/<x>/config only when it carries that header or sits in a flat
profile directory (the v1 per-profile slot), so a hand-written category
file beside profile directories is never deleted or reported as an
orphan.
A profile moved into a category by hand, before the manifest names
the category, leaves its keys one directory deeper than sshmgr looks.
reconcile planned a fresh key for each, which would leave two keys for
one identity: the deployed one still live and the new one deployed
nowhere. planMint now finds the key in the other layout and refuses,
naming where it is.

The audit says the same thing: a declared key found elsewhere is
reported at its real path with where the manifest expects it, and its
missing finding no longer advises reconcile.
profile edit --category files a profile under profiles/<category>/
in one step: Editor.SetCategory renames the key directory when it is
still at the old location, re-points the profile's inventory records,
and saves the manifest, after which the usual re-render writes the
category file. When the directory was already moved by hand only the
records change. It refuses when both directories exist, and an empty
category returns the profile to the flat layout.

profile add --category files a new profile directly.
A recipe for grouping profiles into categories, including a tree whose
directories were moved by hand; the category field, its validation
rules and the Include it renders in the configuration reference; and
why categories use Include when the rest of the config is inline.

The configuration page's example of the managed block still showed
v1's Include profiles/*/config, and its profile section said each
profile directory held a rendered config and known_hosts; both now
match what is rendered.
Copilot AI balanced review requested due to automatic review settings October 19, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

On Windows the temp directory is a backslash path, and OpenSSH's config
parser reads a backslash as an escape, so the rewritten Include pointed
nowhere and the categorized alias fell back to the default identity.
The rendered config itself uses ~/.ssh/ with forward slashes, which
Windows OpenSSH reads; only the test's rewrite was affected.
Forward slashes did not fix it: Win32-OpenSSH resolves an Include path
that starts with neither / nor ~ relative to ~/.ssh, so the test's
C:/... temp path never resolves and both halves fail for a reason
unrelated to Match all. The OpenSSH ordering it pins is covered on
Linux and macOS, and the rendered config uses ~/.ssh/ paths, which
Windows reads. The comment that blamed backslash escaping is corrected.
@imanimanyara
imanimanyara merged commit d707ef1 into main Oct 19, 2026
9 checks passed
@imanimanyara imanimanyara mentioned this pull request Oct 19, 2026
3 tasks done
@imanimanyara
imanimanyara deleted the feat/profile-categories branch October 19, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants