diff --git a/README.md b/README.md index 7f6ecbf..4c57c2d 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,12 @@ server_types: boot_disk: "/dev/nvme0n1" management_nic: "enp2s0" gpu: false - hostname_prefix: "pve-ms" minisforum-um790: display_name: "Minisforum UM790 Pro" boot_disk: "/dev/nvme0n1" management_nic: "enp1s0" gpu: true - hostname_prefix: "pve-um" ``` ### Run diff --git a/deploy/server-types.example.yaml b/deploy/server-types.example.yaml index 5519502..79e6419 100644 --- a/deploy/server-types.example.yaml +++ b/deploy/server-types.example.yaml @@ -4,7 +4,6 @@ server_types: boot_disk: "/dev/nvme0n1" management_nic: "enp2s0" gpu: false - hostname_prefix: "pve-ms" expected_nics: - name: "enp2s0" speed: "2500" @@ -16,7 +15,6 @@ server_types: boot_disk: "/dev/nvme0n1" management_nic: "enp1s0" gpu: true - hostname_prefix: "pve-um" expected_nics: - name: "enp1s0" speed: "2500" diff --git a/internal/api/hosts.go b/internal/api/hosts.go index 726dcb9..298265c 100644 --- a/internal/api/hosts.go +++ b/internal/api/hosts.go @@ -65,6 +65,14 @@ func (a *HostAPI) Create(w http.ResponseWriter, r *http.Request) { writeJSONErr(w, http.StatusBadRequest, "unknown server_type") return } + if existing, err := a.Hosts.GetByHostname(r.Context(), req.Hostname); err == nil && existing != nil { + writeJSONErr(w, http.StatusConflict, "a host with this hostname already exists") + return + } + if existing, err := a.Hosts.GetByMAC(r.Context(), req.MAC); err == nil && existing != nil { + writeJSONErr(w, http.StatusConflict, "a host with this MAC address already exists") + return + } id, err := a.Hosts.Create(r.Context(), model.Host{ Hostname: req.Hostname, diff --git a/internal/api/smoke_test.go b/internal/api/smoke_test.go index 7e88da2..3c8e14a 100644 --- a/internal/api/smoke_test.go +++ b/internal/api/smoke_test.go @@ -134,7 +134,6 @@ func mustLoadServerTypes(t *testing.T, dir string) *config.ServerTypeRegistry { boot_disk: "/dev/sda" management_nic: "eth0" gpu: false - hostname_prefix: "pve-test" `) if err := writeTestFile(path, content); err != nil { t.Fatal(err) @@ -337,6 +336,82 @@ func TestOperationNotFound(t *testing.T) { resp.Body.Close() } +func TestDuplicateHostnameRejected(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"hostname":"pve-dup-01","mac":"aa:bb:cc:dd:ee:d1","server_type":"test-type"}` + resp, err := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("first create: got %d, want %d", resp.StatusCode, http.StatusCreated) + } + resp.Body.Close() + + body = `{"hostname":"pve-dup-01","mac":"aa:bb:cc:dd:ee:d2","server_type":"test-type"}` + resp, err = http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusConflict { + t.Fatalf("dup hostname: got %d, want %d", resp.StatusCode, http.StatusConflict) + } + var errResp map[string]any + json.NewDecoder(resp.Body).Decode(&errResp) + resp.Body.Close() + if msg, _ := errResp["error"].(string); msg != "a host with this hostname already exists" { + t.Fatalf("dup hostname error = %q, want specific hostname message", msg) + } +} + +func TestDuplicateMACRejected(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"hostname":"pve-mac-01","mac":"aa:bb:cc:dd:ee:e1","server_type":"test-type"}` + resp, err := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("first create: got %d, want %d", resp.StatusCode, http.StatusCreated) + } + resp.Body.Close() + + body = `{"hostname":"pve-mac-02","mac":"aa:bb:cc:dd:ee:e1","server_type":"test-type"}` + resp, err = http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusConflict { + t.Fatalf("dup MAC: got %d, want %d", resp.StatusCode, http.StatusConflict) + } + var errResp map[string]any + json.NewDecoder(resp.Body).Decode(&errResp) + resp.Body.Close() + if msg, _ := errResp["error"].(string); msg != "a host with this MAC address already exists" { + t.Fatalf("dup MAC error = %q, want specific MAC message", msg) + } +} + +func TestDuplicateHostnameCaseInsensitive(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"hostname":"pve-case-01","mac":"aa:bb:cc:dd:ee:c1","server_type":"test-type"}` + resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + resp.Body.Close() + + body = `{"hostname":"PVE-CASE-01","mac":"aa:bb:cc:dd:ee:c2","server_type":"test-type"}` + resp, _ = http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("case-insensitive dup: got %d, want %d", resp.StatusCode, http.StatusConflict) + } + resp.Body.Close() +} + func itoa(i int64) string { return fmt.Sprintf("%d", i) } diff --git a/internal/api/ui.go b/internal/api/ui.go index 18c5c28..1daa01f 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -74,6 +74,22 @@ func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) { })) return } + if existing, err := u.Hosts.GetByHostname(r.Context(), hostname); err == nil && existing != nil { + errs = append(errs, "A host with this hostname already exists") + } + if existing, err := u.Hosts.GetByMAC(r.Context(), mac); err == nil && existing != nil { + errs = append(errs, "A host with this MAC address already exists") + } + if len(errs) > 0 { + types := u.ServerTypes.Keys() + renderHTML(w, hostFormPage(types, strings.Join(errs, "; "), &model.Host{ + Hostname: hostname, + MAC: mac, + ServerType: serverType, + Notes: notes, + })) + return + } _, err := u.Hosts.Create(r.Context(), model.Host{ Hostname: hostname, diff --git a/internal/model/model.go b/internal/model/model.go index 2a85175..332d5dd 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -85,13 +85,12 @@ type ActivityEntry struct { } type ServerType struct { - Key string - DisplayName string `yaml:"display_name"` - BootDisk string `yaml:"boot_disk"` - ManagementNIC string `yaml:"management_nic"` - GPU bool `yaml:"gpu"` - HostnamePrefix string `yaml:"hostname_prefix"` - ExpectedNICs []NICDef `yaml:"expected_nics"` + Key string + DisplayName string `yaml:"display_name"` + BootDisk string `yaml:"boot_disk"` + ManagementNIC string `yaml:"management_nic"` + GPU bool `yaml:"gpu"` + ExpectedNICs []NICDef `yaml:"expected_nics"` } type NICDef struct { diff --git a/internal/store/hosts.go b/internal/store/hosts.go index f89a55c..bb0b6fc 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -87,6 +87,18 @@ func (s *Hosts) GetByMAC(ctx context.Context, mac string) (*model.Host, error) { return &h, nil } +func (s *Hosts) GetByHostname(ctx context.Context, hostname string) (*model.Host, error) { + row := s.DB.QueryRowContext(ctx, `SELECT `+hostColumns+` FROM hosts WHERE LOWER(hostname) = ?`, normalizeHostname(hostname)) + var h model.Host + if err := scanHost(row, &h); err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get host by hostname: %w", err) + } + return &h, nil +} + func (s *Hosts) UpdateState(ctx context.Context, id int64, state model.HostState) error { res, err := s.DB.ExecContext(ctx, `UPDATE hosts SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`, state, id) if err != nil { @@ -143,3 +155,7 @@ func (s *Hosts) Delete(ctx context.Context, id int64) error { func normalizeMAC(m string) string { return strings.ToLower(strings.TrimSpace(m)) } + +func normalizeHostname(h string) string { + return strings.ToLower(strings.TrimSpace(h)) +}