diff --git a/internal/connection/registry.go b/internal/connection/registry.go new file mode 100644 index 00000000..e6884ea7 --- /dev/null +++ b/internal/connection/registry.go @@ -0,0 +1,190 @@ +package connection + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// Machine represents a managed machine in the federation. +type Machine struct { + Name string `json:"name"` + Type string `json:"type"` // "local", "ssh" + Host string `json:"host"` // for ssh: user@host + KeyPath string `json:"key_path"` // SSH private key path + TownPath string `json:"town_path"` // Path to town root on remote +} + +// registryData is the JSON file structure. +type registryData struct { + Version int `json:"version"` + Machines map[string]*Machine `json:"machines"` +} + +// MachineRegistry manages machine configurations and provides Connection instances. +type MachineRegistry struct { + path string + machines map[string]*Machine + mu sync.RWMutex +} + +// NewMachineRegistry creates a registry from the given config file path. +// If the file doesn't exist, an empty registry is created. +func NewMachineRegistry(configPath string) (*MachineRegistry, error) { + r := &MachineRegistry{ + path: configPath, + machines: make(map[string]*Machine), + } + + // Load existing config if present + if err := r.load(); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("loading registry: %w", err) + } + + // Ensure "local" machine always exists + if _, ok := r.machines["local"]; !ok { + r.machines["local"] = &Machine{ + Name: "local", + Type: "local", + } + } + + return r, nil +} + +// load reads the registry from disk. +func (r *MachineRegistry) load() error { + data, err := os.ReadFile(r.path) + if err != nil { + return err + } + + var rd registryData + if err := json.Unmarshal(data, &rd); err != nil { + return fmt.Errorf("parsing registry: %w", err) + } + + r.machines = rd.Machines + if r.machines == nil { + r.machines = make(map[string]*Machine) + } + + // Populate machine names from keys + for name, m := range r.machines { + m.Name = name + } + + return nil +} + +// save writes the registry to disk. +func (r *MachineRegistry) save() error { + rd := registryData{ + Version: 1, + Machines: r.machines, + } + + data, err := json.MarshalIndent(rd, "", " ") + if err != nil { + return fmt.Errorf("marshaling registry: %w", err) + } + + // Ensure parent directory exists + dir := filepath.Dir(r.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + if err := os.WriteFile(r.path, data, fs.FileMode(0644)); err != nil { + return fmt.Errorf("writing registry: %w", err) + } + + return nil +} + +// Get returns a machine by name. +func (r *MachineRegistry) Get(name string) (*Machine, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + m, ok := r.machines[name] + if !ok { + return nil, fmt.Errorf("machine not found: %s", name) + } + return m, nil +} + +// Add adds or updates a machine in the registry. +func (r *MachineRegistry) Add(m *Machine) error { + if m.Name == "" { + return fmt.Errorf("machine name is required") + } + if m.Type == "" { + return fmt.Errorf("machine type is required") + } + if m.Type == "ssh" && m.Host == "" { + return fmt.Errorf("ssh machine requires host") + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.machines[m.Name] = m + return r.save() +} + +// Remove removes a machine from the registry. +func (r *MachineRegistry) Remove(name string) error { + if name == "local" { + return fmt.Errorf("cannot remove local machine") + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.machines[name]; !ok { + return fmt.Errorf("machine not found: %s", name) + } + + delete(r.machines, name) + return r.save() +} + +// List returns all machines in the registry. +func (r *MachineRegistry) List() []*Machine { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*Machine, 0, len(r.machines)) + for _, m := range r.machines { + result = append(result, m) + } + return result +} + +// Connection returns a Connection for the named machine. +func (r *MachineRegistry) Connection(name string) (Connection, error) { + m, err := r.Get(name) + if err != nil { + return nil, err + } + + switch m.Type { + case "local": + return NewLocalConnection(), nil + case "ssh": + // SSH connection not yet implemented + return nil, fmt.Errorf("ssh connections not yet implemented") + default: + return nil, fmt.Errorf("unknown machine type: %s", m.Type) + } +} + +// LocalConnection returns the local connection. +// This is a convenience method for the common case. +func (r *MachineRegistry) LocalConnection() *LocalConnection { + return NewLocalConnection() +}