diff --git a/garden/garden.go b/garden/garden.go index b7ae25b..5b437d0 100644 --- a/garden/garden.go +++ b/garden/garden.go @@ -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) } diff --git a/garden/garden_test.go b/garden/garden_test.go index e2bd6ae..ccfa5ea 100644 --- a/garden/garden_test.go +++ b/garden/garden_test.go @@ -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 { diff --git a/garden/identity.go b/garden/identity.go new file mode 100644 index 0000000..2efc25e --- /dev/null +++ b/garden/identity.go @@ -0,0 +1,37 @@ +package garden + +import ( + "os" + "runtime" + "strings" +) + +// Identity returns the machine's label set: short hostname, os:, +// arch:, and tag: for each tag in /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 +} diff --git a/garden/tags.go b/garden/tags.go new file mode 100644 index 0000000..2510467 --- /dev/null +++ b/garden/tags.go @@ -0,0 +1,65 @@ +package garden + +import ( + "os" + "path/filepath" + "strings" +) + +// LoadTags reads the tags from /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 /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 /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) +} diff --git a/garden/targeting.go b/garden/targeting.go new file mode 100644 index 0000000..a8d9cbc --- /dev/null +++ b/garden/targeting.go @@ -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 +} diff --git a/garden/targeting_test.go b/garden/targeting_test.go new file mode 100644 index 0000000..b9330da --- /dev/null +++ b/garden/targeting_test.go @@ -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'") + } +} diff --git a/manifest/manifest.go b/manifest/manifest.go index 8a1a414..ed164c2 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -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.