Remove unused hostname_prefix from server types and add duplicate checking
The HostnamePrefix field on ServerType was loaded from YAML but never used — hostnames are user-provided. This removes the field and adds explicit duplicate checks (hostname + MAC) with clear per-field error messages in both the JSON API and web UI, backed by a new GetByHostname store method with case-insensitive matching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,14 +78,12 @@ server_types:
|
|||||||
boot_disk: "/dev/nvme0n1"
|
boot_disk: "/dev/nvme0n1"
|
||||||
management_nic: "enp2s0"
|
management_nic: "enp2s0"
|
||||||
gpu: false
|
gpu: false
|
||||||
hostname_prefix: "pve-ms"
|
|
||||||
|
|
||||||
minisforum-um790:
|
minisforum-um790:
|
||||||
display_name: "Minisforum UM790 Pro"
|
display_name: "Minisforum UM790 Pro"
|
||||||
boot_disk: "/dev/nvme0n1"
|
boot_disk: "/dev/nvme0n1"
|
||||||
management_nic: "enp1s0"
|
management_nic: "enp1s0"
|
||||||
gpu: true
|
gpu: true
|
||||||
hostname_prefix: "pve-um"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ server_types:
|
|||||||
boot_disk: "/dev/nvme0n1"
|
boot_disk: "/dev/nvme0n1"
|
||||||
management_nic: "enp2s0"
|
management_nic: "enp2s0"
|
||||||
gpu: false
|
gpu: false
|
||||||
hostname_prefix: "pve-ms"
|
|
||||||
expected_nics:
|
expected_nics:
|
||||||
- name: "enp2s0"
|
- name: "enp2s0"
|
||||||
speed: "2500"
|
speed: "2500"
|
||||||
@@ -16,7 +15,6 @@ server_types:
|
|||||||
boot_disk: "/dev/nvme0n1"
|
boot_disk: "/dev/nvme0n1"
|
||||||
management_nic: "enp1s0"
|
management_nic: "enp1s0"
|
||||||
gpu: true
|
gpu: true
|
||||||
hostname_prefix: "pve-um"
|
|
||||||
expected_nics:
|
expected_nics:
|
||||||
- name: "enp1s0"
|
- name: "enp1s0"
|
||||||
speed: "2500"
|
speed: "2500"
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ func (a *HostAPI) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONErr(w, http.StatusBadRequest, "unknown server_type")
|
writeJSONErr(w, http.StatusBadRequest, "unknown server_type")
|
||||||
return
|
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{
|
id, err := a.Hosts.Create(r.Context(), model.Host{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ func mustLoadServerTypes(t *testing.T, dir string) *config.ServerTypeRegistry {
|
|||||||
boot_disk: "/dev/sda"
|
boot_disk: "/dev/sda"
|
||||||
management_nic: "eth0"
|
management_nic: "eth0"
|
||||||
gpu: false
|
gpu: false
|
||||||
hostname_prefix: "pve-test"
|
|
||||||
`)
|
`)
|
||||||
if err := writeTestFile(path, content); err != nil {
|
if err := writeTestFile(path, content); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -337,6 +336,82 @@ func TestOperationNotFound(t *testing.T) {
|
|||||||
resp.Body.Close()
|
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 {
|
func itoa(i int64) string {
|
||||||
return fmt.Sprintf("%d", i)
|
return fmt.Sprintf("%d", i)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}))
|
}))
|
||||||
return
|
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{
|
_, err := u.Hosts.Create(r.Context(), model.Host{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ type ServerType struct {
|
|||||||
BootDisk string `yaml:"boot_disk"`
|
BootDisk string `yaml:"boot_disk"`
|
||||||
ManagementNIC string `yaml:"management_nic"`
|
ManagementNIC string `yaml:"management_nic"`
|
||||||
GPU bool `yaml:"gpu"`
|
GPU bool `yaml:"gpu"`
|
||||||
HostnamePrefix string `yaml:"hostname_prefix"`
|
|
||||||
ExpectedNICs []NICDef `yaml:"expected_nics"`
|
ExpectedNICs []NICDef `yaml:"expected_nics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,18 @@ func (s *Hosts) GetByMAC(ctx context.Context, mac string) (*model.Host, error) {
|
|||||||
return &h, nil
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
@@ -143,3 +155,7 @@ func (s *Hosts) Delete(ctx context.Context, id int64) error {
|
|||||||
func normalizeMAC(m string) string {
|
func normalizeMAC(m string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(m))
|
return strings.ToLower(strings.TrimSpace(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeHostname(h string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(h))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user