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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
37
garden/identity.go
Normal 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
65
garden/tags.go
Normal 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
48
garden/targeting.go
Normal 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
238
garden/targeting_test.go
Normal 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'")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user