diff --git a/adr-004-capsule-domain-object.md b/adr-004-capsule-domain-object.md new file mode 100644 index 0000000..ef3acd2 --- /dev/null +++ b/adr-004-capsule-domain-object.md @@ -0,0 +1,36 @@ +# ADR-004: Promote Capsule to the central domain object + +## Status + +Accepted + +## Context + +The original `ResourceCapsule` stored only a name, version, and path. It could not own resources, track members, participate in lifecycle transitions, or prevent deletion while resources were still in use. + +At the same time, `CreateNetwork` registered a Capsule, but `DeleteNetwork` did not remove or reconcile that Capsule. This created two parallel identities for the same conceptual resource. + +## Decision + +A `Capsule` is now the central ownership and lifecycle boundary for engine resources. + +A Capsule: + +- owns one or more resources, such as a network; +- tracks active members, such as attached containers; +- has an explicit lifecycle status; +- is persisted durably; +- cannot be deleted while it has active members. + +Networks are owned by Capsules. Attaching a container to a network also attaches that container to the network's Capsule. Deleting a network deletes its Capsule only after every container has been detached. + +## Consequences + +- Network deletion is now reference-safe. +- Capsule membership provides a foundation for future CPU, memory, PID, storage, and network-policy limits. +- The legacy `ResourceCapsule` remains temporarily for compatibility with existing Kubernetes-related commands. +- Real networking, cgroup enforcement, and container lifecycle integration remain separate follow-up work. + +## Non-goals + +This ADR does not claim that Capsules currently enforce resource limits or provide kernel-level isolation. They establish ownership, membership, persistence, and deletion semantics first. diff --git a/capsule.go b/capsule.go new file mode 100644 index 0000000..8fbf5de --- /dev/null +++ b/capsule.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// CapsuleStatus represents the lifecycle state of a Capsule. +type CapsuleStatus string + +const ( + CapsuleActive CapsuleStatus = "active" + CapsuleTerminating CapsuleStatus = "terminating" + CapsuleTerminated CapsuleStatus = "terminated" +) + +// Capsule is the central ownership and lifecycle boundary for resources +// managed by the engine. A Capsule owns resources, tracks active members, +// and cannot be deleted while members remain attached. +type Capsule struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Status CapsuleStatus `json:"status"` + Owner string `json:"owner,omitempty"` + CreatedAt time.Time `json:"created_at"` + Resources map[string]string `json:"resources,omitempty"` + Members map[string]string `json:"members,omitempty"` +} + +// CapsuleStore provides concurrency-safe persistence and lifecycle operations +// for Capsules. +type CapsuleStore struct { + mu sync.RWMutex + capsules map[string]*Capsule + file string +} + +// NewCapsuleStore creates a CapsuleStore backed by baseDir/capsules.json. +func NewCapsuleStore() *CapsuleStore { + return &CapsuleStore{ + capsules: make(map[string]*Capsule), + file: filepath.Join(baseDir, "capsules.json"), + } +} + +// Load reads persisted Capsules. A missing file is not an error. +func (s *CapsuleStore) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := os.ReadFile(s.file) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read capsule store: %w", err) + } + + var capsules []*Capsule + if err := json.Unmarshal(data, &capsules); err != nil { + return fmt.Errorf("decode capsule store: %w", err) + } + + s.capsules = make(map[string]*Capsule, len(capsules)) + for _, capsule := range capsules { + s.capsules[capsule.ID] = capsule + } + return nil +} + +func (s *CapsuleStore) persistLocked() error { + capsules := make([]*Capsule, 0, len(s.capsules)) + for _, capsule := range s.capsules { + capsules = append(capsules, capsule) + } + + data, err := json.MarshalIndent(capsules, "", " ") + if err != nil { + return fmt.Errorf("encode capsule store: %w", err) + } + + tmp := s.file + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return fmt.Errorf("write capsule store: %w", err) + } + if err := os.Rename(tmp, s.file); err != nil { + return fmt.Errorf("replace capsule store: %w", err) + } + return nil +} + +// Create creates an active Capsule. +func (s *CapsuleStore) Create(name, version, owner string) (*Capsule, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if name == "" { + return nil, errors.New("capsule name is required") + } + + capsule := &Capsule{ + ID: fmt.Sprintf("cap-%d", time.Now().UnixNano()), + Name: name, + Version: version, + Status: CapsuleActive, + Owner: owner, + CreatedAt: time.Now().UTC(), + Resources: make(map[string]string), + Members: make(map[string]string), + } + + s.capsules[capsule.ID] = capsule + if err := s.persistLocked(); err != nil { + delete(s.capsules, capsule.ID) + return nil, err + } + return capsule, nil +} + +// Get returns a Capsule by ID. +func (s *CapsuleStore) Get(id string) (*Capsule, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + capsule, ok := s.capsules[id] + if !ok { + return nil, fmt.Errorf("capsule %q not found", id) + } + return capsule, nil +} + +// Attach records a member, such as a container, in a Capsule. +func (s *CapsuleStore) Attach(capsuleID, memberID, memberType string) error { + s.mu.Lock() + defer s.mu.Unlock() + + capsule, ok := s.capsules[capsuleID] + if !ok { + return fmt.Errorf("capsule %q not found", capsuleID) + } + if capsule.Status != CapsuleActive { + return fmt.Errorf("capsule %q is not active", capsuleID) + } + if memberID == "" || memberType == "" { + return errors.New("capsule member ID and type are required") + } + + capsule.Members[memberID] = memberType + return s.persistLocked() +} + +// Detach removes a member from a Capsule. +func (s *CapsuleStore) Detach(capsuleID, memberID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + capsule, ok := s.capsules[capsuleID] + if !ok { + return fmt.Errorf("capsule %q not found", capsuleID) + } + if _, exists := capsule.Members[memberID]; !exists { + return fmt.Errorf("member %q is not attached to capsule %q", memberID, capsuleID) + } + + delete(capsule.Members, memberID) + return s.persistLocked() +} + +// Delete removes a Capsule only after all members have been detached. +func (s *CapsuleStore) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + capsule, ok := s.capsules[id] + if !ok { + return fmt.Errorf("capsule %q not found", id) + } + if len(capsule.Members) != 0 { + return fmt.Errorf( + "capsule %q has %d active member(s); detach them before deletion", + id, len(capsule.Members), + ) + } + + capsule.Status = CapsuleTerminating + delete(s.capsules, id) + return s.persistLocked() +} diff --git a/capsule_test.go b/capsule_test.go new file mode 100644 index 0000000..d578159 --- /dev/null +++ b/capsule_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func newTestCapsuleStore(t *testing.T) *CapsuleStore { + t.Helper() + oldBaseDir := baseDir + baseDir = t.TempDir() + t.Cleanup(func() { baseDir = oldBaseDir }) + + store := NewCapsuleStore() + if err := store.Load(); err != nil { + t.Fatalf("Load() failed: %v", err) + } + return store +} + +func TestCapsuleLifecycle(t *testing.T) { + store := newTestCapsuleStore(t) + + capsule, err := store.Create("research", "1.0", "janardhan") + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + if capsule.Status != CapsuleActive { + t.Fatalf("expected active capsule, got %q", capsule.Status) + } + + if err := store.Attach(capsule.ID, "container-1", "container"); err != nil { + t.Fatalf("Attach() failed: %v", err) + } + + if err := store.Delete(capsule.ID); err == nil { + t.Fatal("expected deletion to fail while a member is attached") + } + + if err := store.Detach(capsule.ID, "container-1"); err != nil { + t.Fatalf("Detach() failed: %v", err) + } + + if err := store.Delete(capsule.ID); err != nil { + t.Fatalf("Delete() failed: %v", err) + } + + if _, err := store.Get(capsule.ID); err == nil { + t.Fatal("expected capsule to be absent after deletion") + } +} + +func TestCapsuleStorePersistsMembers(t *testing.T) { + base := t.TempDir() + oldBaseDir := baseDir + baseDir = base + t.Cleanup(func() { baseDir = oldBaseDir }) + + store := NewCapsuleStore() + if err := store.Load(); err != nil { + t.Fatalf("Load() failed: %v", err) + } + + capsule, err := store.Create("research", "1.0", "janardhan") + if err != nil { + t.Fatalf("Create() failed: %v", err) + } + if err := store.Attach(capsule.ID, "container-1", "container"); err != nil { + t.Fatalf("Attach() failed: %v", err) + } + + reloaded := NewCapsuleStore() + if err := reloaded.Load(); err != nil { + t.Fatalf("reload Load() failed: %v", err) + } + + got, err := reloaded.Get(capsule.ID) + if err != nil { + t.Fatalf("Get() after reload failed: %v", err) + } + if got.Members["container-1"] != "container" { + t.Fatalf("expected persisted member, got %#v", got.Members) + } + if _, err := filepath.Abs(base); err != nil { + t.Fatalf("temp base dir invalid: %v", err) + } +} diff --git a/network.go b/network.go index 97377ec..651e6ae 100644 --- a/network.go +++ b/network.go @@ -6,27 +6,28 @@ import ( "fmt" "os" "path/filepath" + "time" ) const networksFile = "networks.json" -// Updated Network struct to include IP addresses for containers +// Network represents a container network owned by a Capsule. type Network struct { - Name string - ID string - Containers map[string]string // Map of container IDs to their IP addresses + Name string `json:"name"` + ID string `json:"id"` + CapsuleID string `json:"capsule_id"` + Containers map[string]string `json:"containers"` } var networks = []Network{} -var capsuleManager = NewCapsuleManager() +var capsuleStore = NewCapsuleStore() -// loadNetworks loads the networks from the JSON file func loadNetworks() { filePath := filepath.Join(baseDir, networksFile) file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { - return // No networks file exists yet + return } fmt.Printf("Error loading networks: %v\n", err) return @@ -39,111 +40,161 @@ func loadNetworks() { } } -// saveNetworks saves the networks to the JSON file -func saveNetworks() { +func saveNetworks() error { filePath := filepath.Join(baseDir, networksFile) - file, err := os.Create(filePath) + data, err := json.MarshalIndent(networks, "", " ") if err != nil { - fmt.Printf("Error saving networks: %v\n", err) - return + return fmt.Errorf("encode networks: %w", err) } - defer file.Close() - encoder := json.NewEncoder(file) - if err := encoder.Encode(networks); err != nil { - fmt.Printf("Error encoding networks: %v\n", err) + tmp := filePath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return fmt.Errorf("write networks: %w", err) + } + if err := os.Rename(tmp, filePath); err != nil { + return fmt.Errorf("replace networks: %w", err) } + return nil } -// CreateNetwork creates a new network capsule +// CreateNetwork creates a network and a Capsule that owns it. func CreateNetwork(name string) { - id := fmt.Sprintf("net-%d", len(networks)+1) - network := Network{Name: name, ID: id, Containers: make(map[string]string)} + capsule, err := capsuleStore.Create(name, "1.0", "local") + if err != nil { + fmt.Printf("Error creating network capsule: %v\n", err) + return + } + + id := fmt.Sprintf("net-%d", time.Now().UnixNano()) + network := Network{ + Name: name, + ID: id, + CapsuleID: capsule.ID, + Containers: make(map[string]string), + } networks = append(networks, network) + capsule.Resources["network"] = id - // Register the network as a resource capsule - capsuleManager.AddCapsule(name, "1.0", id) - saveNetworks() - fmt.Printf("Network capsule %s created with ID %s\n", name, id) + if err := saveNetworks(); err != nil { + fmt.Printf("Error saving networks: %v\n", err) + return + } + + fmt.Printf("Network %s created and owned by capsule %s\n", id, capsule.ID) } -// ListNetworks lists all networks +// ListNetworks lists networks and their owning Capsules. func ListNetworks() { fmt.Println("Available Networks:") for _, network := range networks { - fmt.Printf("- %s (ID: %s)\n", network.Name, network.ID) + fmt.Printf("- %s (ID: %s, Capsule: %s)\n", network.Name, network.ID, network.CapsuleID) } } -// DeleteNetwork deletes a network by ID +// DeleteNetwork deletes a network and its owning Capsule. It refuses to +// delete either while containers remain attached. func DeleteNetwork(id string) { for i, network := range networks { - if network.ID == id { - networks = append(networks[:i], networks[i+1:]...) - saveNetworks() - fmt.Printf("Network with ID %s deleted\n", id) + if network.ID != id { + continue + } + + if err := capsuleStore.Delete(network.CapsuleID); err != nil { + fmt.Printf("Error: %v\n", err) return } + + networks = append(networks[:i], networks[i+1:]...) + if err := saveNetworks(); err != nil { + fmt.Printf("Error saving networks: %v\n", err) + return + } + + fmt.Printf("Network %s and its Capsule deleted\n", id) + return } fmt.Printf("Network with ID %s not found\n", id) } -// Updated AttachContainerToNetwork to assign IP addresses +// AttachContainerToNetwork attaches a container to a network and records it +// as a member of the network's Capsule. func AttachContainerToNetwork(networkID, containerID string) error { for i, network := range networks { - if network.ID == networkID { - // Check if the container is already attached - if _, exists := network.Containers[containerID]; exists { - return errors.New("container is already attached to the network") - } - - // Assign an IP address to the container - ipAddress := fmt.Sprintf("192.168.%d.%d", i+1, len(network.Containers)+2) - networks[i].Containers[containerID] = ipAddress - saveNetworks() - fmt.Printf("Container %s attached to network %s with IP %s\n", containerID, networkID, ipAddress) - return nil + if network.ID != networkID { + continue + } + + if _, exists := network.Containers[containerID]; exists { + return errors.New("container is already attached to the network") + } + + if err := capsuleStore.Attach(network.CapsuleID, containerID, "container"); err != nil { + return err } + + ipAddress := fmt.Sprintf("192.168.%d.%d", i+1, len(network.Containers)+2) + networks[i].Containers[containerID] = ipAddress + if err := saveNetworks(); err != nil { + _ = capsuleStore.Detach(network.CapsuleID, containerID) + return err + } + + fmt.Printf("Container %s attached to network %s with IP %s\n", containerID, networkID, ipAddress) + return nil } return errors.New("network not found") } -// DetachContainerFromNetwork detaches a container from a network capsule +// DetachContainerFromNetwork removes a container from a network and its +// owning Capsule. func DetachContainerFromNetwork(networkID, containerID string) error { for i, network := range networks { - if network.ID == networkID { - // Find and remove the container - if _, exists := network.Containers[containerID]; exists { - delete(networks[i].Containers, containerID) - saveNetworks() - fmt.Printf("Container %s detached from network %s\n", containerID, networkID) - return nil - } + if network.ID != networkID { + continue + } + + if _, exists := network.Containers[containerID]; !exists { return errors.New("container not found in the network") } + + if err := capsuleStore.Detach(network.CapsuleID, containerID); err != nil { + return err + } + + delete(networks[i].Containers, containerID) + if err := saveNetworks(); err != nil { + return err + } + + fmt.Printf("Container %s detached from network %s\n", containerID, networkID) + return nil } return errors.New("network not found") } -// New Ping function to test connectivity between containers +// Ping reports whether two containers are members of the same network. +// It does not yet perform a real network probe. func Ping(networkID, sourceContainerID, targetContainerID string) error { for _, network := range networks { - if network.ID == networkID { - sourceIP, sourceExists := network.Containers[sourceContainerID] - targetIP, targetExists := network.Containers[targetContainerID] - - if !sourceExists || !targetExists { - return errors.New("one or both containers are not in the network") - } + if network.ID != networkID { + continue + } - fmt.Printf("Pinging from %s to %s: Success\n", sourceIP, targetIP) - return nil + sourceIP, sourceExists := network.Containers[sourceContainerID] + targetIP, targetExists := network.Containers[targetContainerID] + if !sourceExists || !targetExists { + return errors.New("one or both containers are not in the network") } + + fmt.Printf("Pinging from %s to %s: Success\n", sourceIP, targetIP) + return nil } return errors.New("network not found") } -// Call loadNetworks during initialization func init() { loadNetworks() -} \ No newline at end of file + if err := capsuleStore.Load(); err != nil { + fmt.Printf("Error loading capsules: %v\n", err) + } +}