From 114d5b99afab17e1b274091801f4c45f18ab3c83 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 26 Apr 2026 10:00:45 +0300 Subject: [PATCH] layer1: add namespace manager skeleton step 8 --- .../2026-04-26-namespace-manager-step8.md | 25 +++ pkg/utils/namespace_manager.go | 143 ++++++++++++++++++ pkg/utils/namespace_manager_test.go | 96 ++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 doc/thinking/2026-04-26-namespace-manager-step8.md create mode 100644 pkg/utils/namespace_manager.go create mode 100644 pkg/utils/namespace_manager_test.go diff --git a/doc/thinking/2026-04-26-namespace-manager-step8.md b/doc/thinking/2026-04-26-namespace-manager-step8.md new file mode 100644 index 00000000..a538dc34 --- /dev/null +++ b/doc/thinking/2026-04-26-namespace-manager-step8.md @@ -0,0 +1,25 @@ +# 2026-04-26 — NamespaceManager rewrite, step 8 + +## Цель шага + +Добавить skeleton `NamespaceManager` с in-memory state и unit tests. + +## Что меняем + +1. Добавляем interface `NamespaceManager`. +2. Добавляем in-memory реализацию с mutex. +3. Добавляем операции: + - `Snapshot()` + - `SnapshotRecords()` + - `Get()` + - `Upsert()` + - `MarkPartState()` + - `Remove()` +4. Добавляем unit tests на snapshot/get/upsert/remove/part-state. + +## Что НЕ меняем + +- не подключаем manager к watcher-ам; +- не меняем текущий resolver path; +- не трогаем runtime components; +- не затрагиваем отдельное незакоммиченное изменение в `serviceaccount.go`. \ No newline at end of file diff --git a/pkg/utils/namespace_manager.go b/pkg/utils/namespace_manager.go new file mode 100644 index 00000000..91b800c4 --- /dev/null +++ b/pkg/utils/namespace_manager.go @@ -0,0 +1,143 @@ +package utils + +import ( + "sort" + "sync" + "time" +) + +type NamespaceManager interface { + Snapshot() []string + SnapshotRecords() []NamespaceRecord + Get(name string) (NamespaceRecord, bool) + Upsert(event NamespaceEvent) NamespaceRecord + MarkPartState(namespace string, part string, state NamespacePartState) (NamespaceRecord, bool) + Remove(name string) bool +} + +type inMemoryNamespaceManager struct { + mu sync.RWMutex + records map[string]NamespaceRecord +} + +func NewNamespaceManager() NamespaceManager { + return &inMemoryNamespaceManager{ + records: make(map[string]NamespaceRecord), + } +} + +func (m *inMemoryNamespaceManager) Snapshot() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + namespaces := make([]string, 0, len(m.records)) + for name, record := range m.records { + if record.Phase == NamespacePhaseRemoved { + continue + } + namespaces = append(namespaces, name) + } + sort.Strings(namespaces) + return namespaces +} + +func (m *inMemoryNamespaceManager) SnapshotRecords() []NamespaceRecord { + m.mu.RLock() + defer m.mu.RUnlock() + + records := make([]NamespaceRecord, 0, len(m.records)) + for _, record := range m.records { + records = append(records, record.Clone()) + } + sort.Slice(records, func(i, j int) bool { + return records[i].Name < records[j].Name + }) + return records +} + +func (m *inMemoryNamespaceManager) Get(name string) (NamespaceRecord, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + record, ok := m.records[name] + if !ok { + return NamespaceRecord{}, false + } + return record.Clone(), true +} + +func (m *inMemoryNamespaceManager) Upsert(event NamespaceEvent) NamespaceRecord { + m.mu.Lock() + defer m.mu.Unlock() + + now := event.ObservedAt + if now.IsZero() { + now = time.Now().UTC() + } + + record, exists := m.records[event.Name] + if !exists { + record = NamespaceRecord{ + Name: event.Name, + RegisteredParts: make(map[string]NamespacePartState), + } + if event.Type == NamespaceEventRemove { + record.Generation = 0 + } else { + record.Generation = 1 + } + } else if event.Type != NamespaceEventRemove { + record.Generation++ + } + + record.Name = event.Name + record.Source = event.Source + record.Labels = cloneStringMap(event.Labels) + record.UpdatedAt = now + + switch event.Type { + case NamespaceEventAdd, NamespaceEventUpdate, NamespaceEventResync: + record.Phase = NamespacePhaseDiscovered + record.LastError = "" + case NamespaceEventRemove: + record.Phase = NamespacePhaseRemoved + } + + if record.RegisteredParts == nil { + record.RegisteredParts = make(map[string]NamespacePartState) + } + + m.records[event.Name] = record + return record.Clone() +} + +func (m *inMemoryNamespaceManager) MarkPartState(namespace string, part string, state NamespacePartState) (NamespaceRecord, bool) { + m.mu.Lock() + defer m.mu.Unlock() + + record, ok := m.records[namespace] + if !ok { + return NamespaceRecord{}, false + } + if record.RegisteredParts == nil { + record.RegisteredParts = make(map[string]NamespacePartState) + } + if state.UpdatedAt.IsZero() { + state.UpdatedAt = time.Now().UTC() + } + record.RegisteredParts[part] = state + record.UpdatedAt = state.UpdatedAt + m.records[namespace] = record + return record.Clone(), true +} + +func (m *inMemoryNamespaceManager) Remove(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.records[name]; !ok { + return false + } + delete(m.records, name) + return true +} \ No newline at end of file diff --git a/pkg/utils/namespace_manager_test.go b/pkg/utils/namespace_manager_test.go new file mode 100644 index 00000000..b69e1d41 --- /dev/null +++ b/pkg/utils/namespace_manager_test.go @@ -0,0 +1,96 @@ +package utils + +import ( + "reflect" + "testing" + "time" +) + +func TestNamespaceManagerSnapshotAndGet(t *testing.T) { + manager := NewNamespaceManager() + now := time.Now().UTC() + manager.Upsert(NamespaceEvent{Type: NamespaceEventAdd, Name: "tenant-b", Source: NamespaceSourceWatcher, ObservedAt: now}) + manager.Upsert(NamespaceEvent{Type: NamespaceEventAdd, Name: "tenant-a", Source: NamespaceSourceWatcher, ObservedAt: now}) + + expected := []string{"tenant-a", "tenant-b"} + if !reflect.DeepEqual(expected, manager.Snapshot()) { + t.Fatalf("expected snapshot %v, got %v", expected, manager.Snapshot()) + } + + record, ok := manager.Get("tenant-a") + if !ok { + t.Fatalf("expected tenant-a to exist") + } + if record.Name != "tenant-a" || record.Phase != NamespacePhaseDiscovered { + t.Fatalf("unexpected record returned: %+v", record) + } +} + +func TestNamespaceManagerUpsertIncrementsGeneration(t *testing.T) { + manager := NewNamespaceManager() + now := time.Now().UTC() + + first := manager.Upsert(NamespaceEvent{Type: NamespaceEventAdd, Name: "tenant-a", Source: NamespaceSourceWatcher, ObservedAt: now}) + second := manager.Upsert(NamespaceEvent{Type: NamespaceEventUpdate, Name: "tenant-a", Source: NamespaceSourceWatcher, ObservedAt: now.Add(time.Second)}) + + if first.Generation != 1 { + t.Fatalf("expected first generation to be 1, got %d", first.Generation) + } + if second.Generation != 2 { + t.Fatalf("expected second generation to be 2, got %d", second.Generation) + } +} + +func TestNamespaceManagerMarkPartState(t *testing.T) { + manager := NewNamespaceManager() + manager.Upsert(NamespaceEvent{Type: NamespaceEventAdd, Name: "tenant-a", Source: NamespaceSourceWatcher}) + + record, ok := manager.MarkPartState("tenant-a", "router", NamespacePartState{State: "active"}) + if !ok { + t.Fatalf("expected mark part state to succeed") + } + if record.RegisteredParts["router"].State != "active" { + t.Fatalf("expected router part state to be stored") + } +} + +func TestNamespaceManagerRemove(t *testing.T) { + manager := NewNamespaceManager() + manager.Upsert(NamespaceEvent{Type: NamespaceEventAdd, Name: "tenant-a", Source: NamespaceSourceWatcher}) + + if !manager.Remove("tenant-a") { + t.Fatalf("expected remove to succeed") + } + if _, ok := manager.Get("tenant-a"); ok { + t.Fatalf("expected record to be removed") + } + if manager.Remove("tenant-a") { + t.Fatalf("expected second remove to report false") + } +} + +func TestNamespaceManagerSnapshotRecordsReturnsCopies(t *testing.T) { + manager := NewNamespaceManager() + manager.Upsert(NamespaceEvent{ + Type: NamespaceEventAdd, + Name: "tenant-a", + Source: NamespaceSourceWatcher, + Labels: map[string]string{"fission.io/managed": "true"}, + }) + _, _ = manager.MarkPartState("tenant-a", "router", NamespacePartState{State: "active"}) + + records := manager.SnapshotRecords() + records[0].Labels["fission.io/managed"] = "false" + records[0].RegisteredParts["router"] = NamespacePartState{State: "failed"} + + record, ok := manager.Get("tenant-a") + if !ok { + t.Fatalf("expected tenant-a to exist") + } + if record.Labels["fission.io/managed"] != "true" { + t.Fatalf("expected snapshot records to be detached copies") + } + if record.RegisteredParts["router"].State != "active" { + t.Fatalf("expected part states to be detached copies") + } +} \ No newline at end of file