Repository navigation
Group profiles into categories - #47
Merged
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Profiles can now be grouped into categories. A profile with
"category": "clients"keeps its keys underprofiles/clients/<profile>/and renders itsHostblocks intoprofiles/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
~/.sshwhose profiles had been moved by hand intointernal/,clients/andothers/. After that move, everyIdentityFilesshmgr rendered was one directory too high.reconcilewould have minted a replacement for every key that still worked, andconfig renderwould have deleted the hand-written category files, since they matched theprofiles/*/configpattern it prunes.Six commits, each reviewable on its own:
categoryon profiles.ProfileRel/ProfileDir/KeyPathare 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 namedconfigorold.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) useGlobProfileTree, which covers both layouts and skipsold/and dot directories. A layering test fails any newfilepath.Joinwith a computed segment after"profiles".Match all. An Include that follows a foreignHostblock applies only to that host. A test runs realssh -Gagainst both forms to pin this. Pruning now only removes aprofiles/*/configthat carries the managed header or sits in a flat profile directory (the v1 slot, soTestLegacyPerProfileConfigsArePrunedstill holds).reconcilerefuses to mint a key that only changed directory, and names where it is.doctorreports such a key as untracked at its real path, and its missing finding no longer advisesreconcile.--categoryonprofile 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.Include profiles/*/configexample.Invariant checklist
~/.ssh/config.src/internal/core/renderer).Checks
gofmt,go build ./...andgo vet ./...are clean locally.golangci-lintwas not run locally: the installed binary was built with Go 1.26 and panics on this Go 1.27 toolchain, including on unmodifiedmain. CI's lint step is the check.go test ./...: the only failures are the same 10 that fail on unmodifiedmainin the sandbox it ran in, all from local port binding being denied (httptest / netcheck listeners).GlobProfileTree,IsArchivedPath, keyaudit for misplaced keys, reconcile refusing to mint, renderer output plus thessh -GMatch allproof, configsvc prune ownership,SetCategory, and the CLI--categoryflow. Each new guard was shown to fail with its check disabled.profile edit --categorycalls. Result: all 7 inventory records re-pointed,reconcile --dry-runreports "all 7 present (none minted)", andconfig checkis in sync.CHANGELOG.mdandREADME.mdupdated.