Step 28: Machine identity and targeting core.

Entry gains Only/Never fields for per-machine targeting. Machine
identity = short hostname + os:<GOOS> + arch:<GOARCH> + tag:<name>.
Tags stored in local <repo>/tags file (added to .gitignore by init).
EntryApplies() matching: only=any-match, never=no-match, both=error.
13 tests covering matching, identity, tags CRUD, gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 22:47:02 -07:00
parent 7797de7d48
commit 589f76c10e
7 changed files with 393 additions and 3 deletions

View File

@@ -48,7 +48,7 @@ func Init(root string) (*Garden, error) {
}
gitignorePath := filepath.Join(absRoot, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte("blobs/\n"), 0o644); err != nil {
if err := os.WriteFile(gitignorePath, []byte("blobs/\ntags\n"), 0o644); err != nil {
return nil, fmt.Errorf("creating .gitignore: %w", err)
}

View File

@@ -33,8 +33,8 @@ func TestInitCreatesStructure(t *testing.T) {
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Errorf(".gitignore not found: %v", err)
} else if string(gitignore) != "blobs/\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
} else if string(gitignore) != "blobs/\ntags\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
}
if g.manifest.Version != 1 {

37
garden/identity.go Normal file
View File

@@ -0,0 +1,37 @@
package garden
import (
"os"
"runtime"
"strings"
)
// Identity returns the machine's label set: short hostname, os:<GOOS>,
// arch:<GOARCH>, and tag:<name> for each tag in <repo>/tags.
func (g *Garden) Identity() []string {
labels := []string{
shortHostname(),
"os:" + runtime.GOOS,
"arch:" + runtime.GOARCH,
}
tags := g.LoadTags()
for _, tag := range tags {
labels = append(labels, "tag:"+tag)
}
return labels
}
// shortHostname returns the hostname before the first dot, lowercased.
func shortHostname() string {
host, err := os.Hostname()
if err != nil {
return "unknown"
}
host = strings.ToLower(host)
if idx := strings.IndexByte(host, '.'); idx >= 0 {
host = host[:idx]
}
return host
}

65
garden/tags.go Normal file
View File

@@ -0,0 +1,65 @@
package garden
import (
"os"
"path/filepath"
"strings"
)
// LoadTags reads the tags from <repo>/tags, one per line.
func (g *Garden) LoadTags() []string {
data, err := os.ReadFile(filepath.Join(g.root, "tags"))
if err != nil {
return nil
}
var tags []string
for _, line := range strings.Split(string(data), "\n") {
tag := strings.TrimSpace(line)
if tag != "" {
tags = append(tags, tag)
}
}
return tags
}
// SaveTag adds a tag to <repo>/tags if not already present.
func (g *Garden) SaveTag(tag string) error {
tag = strings.TrimSpace(tag)
if tag == "" {
return nil
}
tags := g.LoadTags()
for _, existing := range tags {
if existing == tag {
return nil // already present
}
}
tags = append(tags, tag)
return g.writeTags(tags)
}
// RemoveTag removes a tag from <repo>/tags.
func (g *Garden) RemoveTag(tag string) error {
tag = strings.TrimSpace(tag)
tags := g.LoadTags()
var filtered []string
for _, t := range tags {
if t != tag {
filtered = append(filtered, t)
}
}
return g.writeTags(filtered)
}
func (g *Garden) writeTags(tags []string) error {
content := strings.Join(tags, "\n")
if content != "" {
content += "\n"
}
return os.WriteFile(filepath.Join(g.root, "tags"), []byte(content), 0o644)
}

48
garden/targeting.go Normal file
View File

@@ -0,0 +1,48 @@
package garden
import (
"fmt"
"strings"
"github.com/kisom/sgard/manifest"
)
// EntryApplies reports whether the given entry should be active on a
// machine with the given labels. Returns an error if both Only and
// Never are set on the same entry.
func EntryApplies(entry *manifest.Entry, labels []string) (bool, error) {
if len(entry.Only) > 0 && len(entry.Never) > 0 {
return false, fmt.Errorf("entry %s has both only and never set", entry.Path)
}
if len(entry.Only) > 0 {
for _, matcher := range entry.Only {
if matchesLabel(matcher, labels) {
return true, nil
}
}
return false, nil
}
if len(entry.Never) > 0 {
for _, matcher := range entry.Never {
if matchesLabel(matcher, labels) {
return false, nil
}
}
}
return true, nil
}
// matchesLabel checks if a matcher string matches any label in the set.
// Matching is case-insensitive.
func matchesLabel(matcher string, labels []string) bool {
matcher = strings.ToLower(matcher)
for _, label := range labels {
if strings.ToLower(label) == matcher {
return true
}
}
return false
}

238
garden/targeting_test.go Normal file
View File

@@ -0,0 +1,238 @@
package garden
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/kisom/sgard/manifest"
)
func TestEntryApplies_NoTargeting(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc"}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Error("entry with no targeting should always apply")
}
}
func TestEntryApplies_OnlyMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match os:linux")
}
}
func TestEntryApplies_OnlyNoMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:darwin"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("os:darwin should not match os:linux machine")
}
}
func TestEntryApplies_OnlyHostname(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"vade"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match hostname vade")
}
}
func TestEntryApplies_OnlyTag(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"tag:work"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "tag:work"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match tag:work")
}
ok, err = EntryApplies(entry, []string{"vade", "os:linux"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("should not match without tag:work")
}
}
func TestEntryApplies_NeverMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Never: []string{"arch:arm64"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:arm64"})
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("should be excluded by never:arch:arm64")
}
}
func TestEntryApplies_NeverNoMatch(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Never: []string{"arch:arm64"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("arch:amd64 machine should not be excluded by never:arch:arm64")
}
}
func TestEntryApplies_BothError(t *testing.T) {
entry := &manifest.Entry{
Path: "~/.bashrc",
Only: []string{"os:linux"},
Never: []string{"arch:arm64"},
}
_, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err == nil {
t.Fatal("should error when both only and never are set")
}
}
func TestEntryApplies_CaseInsensitive(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"OS:Linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("matching should be case-insensitive")
}
}
func TestEntryApplies_OnlyMultiple(t *testing.T) {
entry := &manifest.Entry{Path: "~/.bashrc", Only: []string{"os:darwin", "os:linux"}}
ok, err := EntryApplies(entry, []string{"vade", "os:linux", "arch:amd64"})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("should match if any label in only matches")
}
}
func TestIdentity(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
labels := g.Identity()
// Should contain os and arch.
found := make(map[string]bool)
for _, l := range labels {
found[l] = true
}
osLabel := "os:" + runtime.GOOS
archLabel := "arch:" + runtime.GOARCH
if !found[osLabel] {
t.Errorf("identity should contain %s", osLabel)
}
if !found[archLabel] {
t.Errorf("identity should contain %s", archLabel)
}
// Should contain a hostname (non-empty, no dots).
hostname := labels[0]
if hostname == "" || strings.Contains(hostname, ".") || strings.Contains(hostname, ":") {
t.Errorf("first label should be short hostname, got %q", hostname)
}
}
func TestTags(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// No tags initially.
if tags := g.LoadTags(); len(tags) != 0 {
t.Fatalf("expected no tags, got %v", tags)
}
// Add tags.
if err := g.SaveTag("work"); err != nil {
t.Fatalf("SaveTag: %v", err)
}
if err := g.SaveTag("desktop"); err != nil {
t.Fatalf("SaveTag: %v", err)
}
tags := g.LoadTags()
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %v", tags)
}
// Duplicate add is idempotent.
if err := g.SaveTag("work"); err != nil {
t.Fatalf("SaveTag duplicate: %v", err)
}
if tags := g.LoadTags(); len(tags) != 2 {
t.Fatalf("expected 2 tags after duplicate add, got %v", tags)
}
// Remove.
if err := g.RemoveTag("work"); err != nil {
t.Fatalf("RemoveTag: %v", err)
}
tags = g.LoadTags()
if len(tags) != 1 || tags[0] != "desktop" {
t.Fatalf("expected [desktop], got %v", tags)
}
// Tags appear in identity.
labels := g.Identity()
found := false
for _, l := range labels {
if l == "tag:desktop" {
found = true
}
}
if !found {
t.Errorf("identity should contain tag:desktop, got %v", labels)
}
}
func TestInitCreatesGitignoreWithTags(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("Init: %v", err)
}
data, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Fatalf("reading .gitignore: %v", err)
}
if !strings.Contains(string(data), "tags") {
t.Error(".gitignore should contain 'tags'")
}
}

View File

@@ -20,6 +20,8 @@ type Entry struct {
Mode string `yaml:"mode,omitempty"`
Target string `yaml:"target,omitempty"`
Updated time.Time `yaml:"updated"`
Only []string `yaml:"only,omitempty"`
Never []string `yaml:"never,omitempty"`
}
// KekSlot describes a single KEK source that can unwrap the DEK.