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")
|
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)
|
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"))
|
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf(".gitignore not found: %v", err)
|
t.Errorf(".gitignore not found: %v", err)
|
||||||
} else if string(gitignore) != "blobs/\n" {
|
} else if string(gitignore) != "blobs/\ntags\n" {
|
||||||
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
|
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.manifest.Version != 1 {
|
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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type Entry struct {
|
|||||||
Mode string `yaml:"mode,omitempty"`
|
Mode string `yaml:"mode,omitempty"`
|
||||||
Target string `yaml:"target,omitempty"`
|
Target string `yaml:"target,omitempty"`
|
||||||
Updated time.Time `yaml:"updated"`
|
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.
|
// KekSlot describes a single KEK source that can unwrap the DEK.
|
||||||
|
|||||||
Reference in New Issue
Block a user