diff --git a/backend/server/apps/kea/configmodule.go b/backend/server/apps/kea/configmodule.go index 1f2b71dc0e4622667858d598c059122b16173691..401cb2469494db8ce3ac600918ba43e84fdee354 100644 --- a/backend/server/apps/kea/configmodule.go +++ b/backend/server/apps/kea/configmodule.go @@ -142,30 +142,44 @@ func (module *ConfigModule) ApplyHostUpdate(ctx context.Context, host *dbmodel.H if len(host.LocalHosts) == 0 { return ctx, pkgerrors.Errorf("applied host %d is not associated with any daemon", host.ID) } + // Retrieve existing host from the context. We will need it for sending + // the reservation-del commands, in case the DHCP identifier changes. + existingHostIface, err := config.GetValueForUpdate(ctx, 0, "host_before_update") + if err != nil { + return ctx, err + } + existingHost := existingHostIface.(dbmodel.Host) var ( commands []any lookup dbmodel.DHCPOptionDefinitionLookup ) - for _, lh := range host.LocalHosts { + // First, delete all instances of the host on all Kea servers. + for _, lh := range existingHost.LocalHosts { if lh.Daemon == nil { - return ctx, pkgerrors.Errorf("applied host %d is associated with nil daemon", host.ID) + return ctx, pkgerrors.Errorf("updated host %d is associated with nil daemon", host.ID) } if lh.Daemon.App == nil { - return ctx, pkgerrors.Errorf("applied host %d is associated with nil app", host.ID) + return ctx, pkgerrors.Errorf("updated host %d is associated with nil app", host.ID) } // Convert the host information to Kea reservation. - deletedReservation, err := keaconfig.CreateHostCmdsDeletedReservation(lh.DaemonID, host) + deleteArguments, err := keaconfig.CreateHostCmdsDeletedReservation(lh.DaemonID, existingHost) if err != nil { return ctx, err } - // Create command arguments. - deleteArguments := deletedReservation // Associate the command with an app receiving this command. appCommand := make(map[string]any) appCommand["command"] = keactrl.NewCommand("reservation-del", []string{lh.Daemon.Name}, deleteArguments) appCommand["app"] = lh.Daemon.App commands = append(commands, appCommand) - + } + // Re-create the host reservations. + for _, lh := range host.LocalHosts { + if lh.Daemon == nil { + return ctx, pkgerrors.Errorf("applied host %d is associated with nil daemon", host.ID) + } + if lh.Daemon.App == nil { + return ctx, pkgerrors.Errorf("applied host %d is associated with nil app", host.ID) + } // Convert the updated host information to Kea reservation. reservation, err := keaconfig.CreateHostCmdsReservation(lh.DaemonID, lookup, host) if err != nil { @@ -174,7 +188,7 @@ func (module *ConfigModule) ApplyHostUpdate(ctx context.Context, host *dbmodel.H // Create command arguments. addArguments := make(map[string]any) addArguments["reservation"] = reservation - appCommand = make(map[string]any) + appCommand := make(map[string]any) appCommand["command"] = keactrl.NewCommand("reservation-add", []string{lh.Daemon.Name}, addArguments) appCommand["app"] = lh.Daemon.App commands = append(commands, appCommand) diff --git a/backend/server/apps/kea/configmodule_test.go b/backend/server/apps/kea/configmodule_test.go index a279f4d50b3abd460b8e834d370ab54bb8547b10..450b6fd400a09dd0025a3b91ddfe05cf79e615e6 100644 --- a/backend/server/apps/kea/configmodule_test.go +++ b/backend/server/apps/kea/configmodule_test.go @@ -558,7 +558,46 @@ func TestApplyHostUpdate(t *testing.T) { // Create dummy host to be stored in the context. We will later check if // it is preserved after applying host update. host := &dbmodel.Host{ - ID: 1, + ID: 1, + Hostname: "cool.example.org", + HostIdentifiers: []dbmodel.HostIdentifier{ + { + Type: "hw-address", + Value: []byte{1, 2, 3, 4, 5, 6}, + }, + }, + LocalHosts: []dbmodel.LocalHost{ + { + DaemonID: 1, + Daemon: &dbmodel.Daemon{ + Name: "dhcp4", + App: &dbmodel.App{ + AccessPoints: []*dbmodel.AccessPoint{ + { + Type: dbmodel.AccessPointControl, + Address: "192.0.2.1", + Port: 1234, + }, + }, + }, + }, + }, + { + DaemonID: 2, + Daemon: &dbmodel.Daemon{ + Name: "dhcp4", + App: &dbmodel.App{ + AccessPoints: []*dbmodel.AccessPoint{ + { + Type: dbmodel.AccessPointControl, + Address: "192.0.2.2", + Port: 2345, + }, + }, + }, + }, + }, + }, } module := NewConfigModule(nil) @@ -572,15 +611,14 @@ func TestApplyHostUpdate(t *testing.T) { require.NoError(t, err) ctx = context.WithValue(ctx, config.StateContextKey, *state) - // Simulate updating host entry. The host is associated with - // two different daemons/apps. + // Simulate updating host entry. We change host identifier and hostname. host = &dbmodel.Host{ ID: 1, - Hostname: "cool.example.org", + Hostname: "foo.example.org", HostIdentifiers: []dbmodel.HostIdentifier{ { Type: "hw-address", - Value: []byte{1, 2, 3, 4, 5, 6}, + Value: []byte{2, 3, 4, 5, 6, 7}, }, }, LocalHosts: []dbmodel.LocalHost{ @@ -645,10 +683,10 @@ func TestApplyHostUpdate(t *testing.T) { require.True(t, ok) marshalled := command.Marshal() - // Every even command is the reservation-del sent to respective servers. - // Every odd command is the reservation-add. - switch i % 2 { - case 0: + // First are the reservation-del commands sent to respective servers. + // Other are reservation-add commands. + switch { + case i < 2: require.JSONEq(t, `{ "command": "reservation-del", @@ -668,8 +706,8 @@ func TestApplyHostUpdate(t *testing.T) { "arguments": { "reservation": { "subnet-id": 0, - "hw-address": "010203040506", - "hostname": "cool.example.org" + "hw-address": "020304050607", + "hostname": "foo.example.org" } } }`, @@ -678,27 +716,13 @@ func TestApplyHostUpdate(t *testing.T) { // Verify they are associated with appropriate apps. app, ok := commands[i].(map[string]interface{})["app"].(*dbmodel.App) require.True(t, ok) - require.Equal(t, app, host.LocalHosts[i/2].Daemon.App) + require.Equal(t, app, host.LocalHosts[i%2].Daemon.App) } } // Test committing updated host, i.e. actually sending control commands to Kea. func TestCommitHostUpdate(t *testing.T) { - // Create the config manager instance "connected to" fake agents. - agents := agentcommtest.NewKeaFakeAgents() - manager := newTestManager(nil, agents) - - // Create Kea config module. - module := NewConfigModule(manager) - require.NotNil(t, module) - - daemonIDs := []int64{1} - ctx := context.WithValue(context.Background(), config.DaemonsContextKey, daemonIDs) - - state := config.NewTransactionStateWithUpdate("kea", "host_update", daemonIDs...) - ctx = context.WithValue(ctx, config.StateContextKey, *state) - - // Create host reservation and store it in the context. + // Create host reservation. host := &dbmodel.Host{ ID: 1, Hostname: "cool.example.org", @@ -741,7 +765,24 @@ func TestCommitHostUpdate(t *testing.T) { }, }, } - ctx, err := module.ApplyHostUpdate(ctx, host) + + // Create the config manager instance "connected to" fake agents. + agents := agentcommtest.NewKeaFakeAgents() + manager := newTestManager(nil, agents) + + // Create Kea config module. + module := NewConfigModule(manager) + require.NotNil(t, module) + + daemonIDs := []int64{1} + ctx := context.WithValue(context.Background(), config.DaemonsContextKey, daemonIDs) + + state := config.NewTransactionStateWithUpdate("kea", "host_update", daemonIDs...) + err := state.SetValueForUpdate(0, "host_before_update", *host) + require.NoError(t, err) + ctx = context.WithValue(ctx, config.StateContextKey, *state) + + ctx, err = module.ApplyHostUpdate(ctx, host) require.NoError(t, err) // Committing the host should result in sending control commands to Kea servers. @@ -754,17 +795,16 @@ func TestCommitHostUpdate(t *testing.T) { // Validate the sent commands and URLS. for i, command := range agents.RecordedCommands { - // First two commands sent to one server, another two sent to another. - switch { - case i < 2: + switch i % 2 { + case 0: require.Equal(t, "http://192.0.2.1:1234/", agents.RecordedURLs[i]) default: require.Equal(t, "http://192.0.2.2:2345/", agents.RecordedURLs[i]) } marshalled := command.Marshal() // Every event command is reservation-del. Every odd command is reservation-add. - switch i % 2 { - case 0: + switch { + case i < 2: require.JSONEq(t, `{ @@ -797,6 +837,35 @@ func TestCommitHostUpdate(t *testing.T) { // Test that error is returned when Kea response contains error status code. func TestCommitHostUpdateResponseWithErrorStatus(t *testing.T) { + // Create new host reservation. + host := &dbmodel.Host{ + ID: 1, + Hostname: "cool.example.org", + HostIdentifiers: []dbmodel.HostIdentifier{ + { + Type: "hw-address", + Value: []byte{1, 2, 3, 4, 5, 6}, + }, + }, + LocalHosts: []dbmodel.LocalHost{ + { + DaemonID: 1, + Daemon: &dbmodel.Daemon{ + Name: "dhcp4", + App: &dbmodel.App{ + AccessPoints: []*dbmodel.AccessPoint{ + { + Type: dbmodel.AccessPointControl, + Address: "192.0.2.1", + Port: 1234, + }, + }, + Name: "kea@192.0.2.1", + }, + }, + }, + }, + } // Create the config manager instance "connected to" fake agents. agents := agentcommtest.NewKeaFakeAgents(func(callNo int, cmdResponses []interface{}) { json := []byte(`[ @@ -819,11 +888,33 @@ func TestCommitHostUpdateResponseWithErrorStatus(t *testing.T) { ctx := context.WithValue(context.Background(), config.DaemonsContextKey, daemonIDs) state := config.NewTransactionStateWithUpdate("kea", "host_update", daemonIDs...) + err := state.SetValueForUpdate(0, "host_before_update", *host) + require.NoError(t, err) ctx = context.WithValue(ctx, config.StateContextKey, *state) - // Create new host reservation and store it in the context. + ctx, err = module.ApplyHostUpdate(ctx, host) + require.NoError(t, err) + + _, err = module.Commit(ctx) + require.ErrorContains(t, err, "reservation-del command to kea@192.0.2.1 failed: error status (1) returned by Kea dhcp4 daemon with text: 'error is error'") + + // Other commands should not be sent in this case. + require.Len(t, agents.RecordedCommands, 1) +} + +// Test scheduling config changes in the database, retrieving and committing it. +func TestCommitScheduledHostUpdate(t *testing.T) { + // Create the host. host := &dbmodel.Host{ - ID: 1, + ID: 1, + Subnet: &dbmodel.Subnet{ + LocalSubnets: []*dbmodel.LocalSubnet{ + { + DaemonID: 1, + LocalSubnetID: 123, + }, + }, + }, Hostname: "cool.example.org", HostIdentifiers: []dbmodel.HostIdentifier{ { @@ -844,24 +935,12 @@ func TestCommitHostUpdateResponseWithErrorStatus(t *testing.T) { Port: 1234, }, }, - Name: "kea@192.0.2.1", }, }, }, }, } - ctx, err := module.ApplyHostUpdate(ctx, host) - require.NoError(t, err) - - _, err = module.Commit(ctx) - require.ErrorContains(t, err, "reservation-del command to kea@192.0.2.1 failed: error status (1) returned by Kea dhcp4 daemon with text: 'error is error'") - - // Other commands should not be sent in this case. - require.Len(t, agents.RecordedCommands, 1) -} -// Test scheduling config changes in the database, retrieving and committing it. -func TestCommitScheduledHostUpdate(t *testing.T) { db, _, teardown := dbtest.SetupDatabaseTestCase(t) defer teardown() @@ -886,45 +965,12 @@ func TestCommitScheduledHostUpdate(t *testing.T) { daemonIDs := []int64{1} ctx := context.WithValue(context.Background(), config.DaemonsContextKey, daemonIDs) ctx = context.WithValue(ctx, config.UserContextKey, int64(user.ID)) + state := config.NewTransactionStateWithUpdate("kea", "host_update", daemonIDs...) + err = state.SetValueForUpdate(0, "host_before_update", *host) + require.NoError(t, err) ctx = context.WithValue(ctx, config.StateContextKey, *state) - // Create the host and store it in the context. - host := &dbmodel.Host{ - ID: 1, - Subnet: &dbmodel.Subnet{ - LocalSubnets: []*dbmodel.LocalSubnet{ - { - DaemonID: 1, - LocalSubnetID: 123, - }, - }, - }, - Hostname: "cool.example.org", - HostIdentifiers: []dbmodel.HostIdentifier{ - { - Type: "hw-address", - Value: []byte{1, 2, 3, 4, 5, 6}, - }, - }, - LocalHosts: []dbmodel.LocalHost{ - { - DaemonID: 1, - Daemon: &dbmodel.Daemon{ - Name: "dhcp4", - App: &dbmodel.App{ - AccessPoints: []*dbmodel.AccessPoint{ - { - Type: dbmodel.AccessPointControl, - Address: "192.0.2.1", - Port: 1234, - }, - }, - }, - }, - }, - }, - } ctx, err = module.ApplyHostUpdate(ctx, host) require.NoError(t, err) diff --git a/backend/server/config/context.go b/backend/server/config/context.go index d582136400a2227456f81ae01975fb2a8813ca5c..923f74754529da4508b77c903112b8950ba1a91c 100644 --- a/backend/server/config/context.go +++ b/backend/server/config/context.go @@ -40,6 +40,21 @@ func GetTransactionState(ctx context.Context) (state TransactionState, ok bool) return } +// Gets a value from the transaction state for a given update index, under the +// specified name in the recipe. It returns an error if the specified index +// is out of bounds or when the value doesn't exist. +func GetValueForUpdate(ctx context.Context, updateIndex int, valueName string) (any, error) { + state, ok := GetTransactionState(ctx) + if !ok { + return nil, pkgerrors.New("transaction state does not exist in the context") + } + value, err := state.GetValueForUpdate(updateIndex, valueName) + if err != nil { + return nil, err + } + return value, nil +} + // Sets a value in the transaction state for a given update index, under the // specified name in the recipe. It returns an error if the context does not // contain a transaction state or the specified index is out of bounds. It diff --git a/backend/server/config/context_test.go b/backend/server/config/context_test.go index a6ffc1f919759085913addb3dced6c154da2f748..5c4cf0f595d85f99cec398eaafc1779df5e5d0e6 100644 --- a/backend/server/config/context_test.go +++ b/backend/server/config/context_test.go @@ -84,3 +84,36 @@ func TestSetValueForUpdateInContextIndexOutOfBounds(t *testing.T) { _, err := SetValueForUpdate(ctx, 1, "foo", "bar") require.Error(t, err) } + +// Test getting a value for update from the context. +func TestGetValueForUpdateInContext(t *testing.T) { + state := NewTransactionStateWithUpdate("kea", "host_update", 1) + ctx := context.WithValue(context.Background(), StateContextKey, *state) + ctx, err := SetValueForUpdate(ctx, 0, "foo", "bar") + require.NoError(t, err) + + value, err := GetValueForUpdate(ctx, 0, "foo") + require.NoError(t, err) + require.Equal(t, value, "bar") +} + +// Test that an error is returned when trying to get a value for update from the +// context when the state does not exist. +func TestGetValueForUpdateInContextNoState(t *testing.T) { + value, err := GetValueForUpdate(context.Background(), 0, "foo") + require.Error(t, err) + require.Nil(t, value) +} + +// Test that an error is returned when trying to get a value for update from the +// context when update index is out of bounds. +func TestGetValueForUpdateInContextIndexOutOfBounds(t *testing.T) { + state := NewTransactionStateWithUpdate("kea", "host_update", 1) + ctx := context.WithValue(context.Background(), StateContextKey, *state) + ctx, err := SetValueForUpdate(ctx, 0, "foo", "bar") + require.NoError(t, err) + + value, err := GetValueForUpdate(ctx, 1, "foo") + require.Error(t, err) + require.Nil(t, value) +} diff --git a/backend/server/restservice/hosts.go b/backend/server/restservice/hosts.go index e9923a2a1bd069dbaec6d354cab0d1ccfdf9ba2b..e6e498ca7840e51bd6feb16d4bd16922deb890c4 100644 --- a/backend/server/restservice/hosts.go +++ b/backend/server/restservice/hosts.go @@ -58,6 +58,7 @@ func convertFromHost(dbHost *dbmodel.Host) *models.Host { localHost := models.LocalHost{ AppID: dbLocalHost.Daemon.AppID, AppName: dbLocalHost.Daemon.App.Name, + DaemonID: dbLocalHost.Daemon.ID, DataSource: dbLocalHost.DataSource.String(), OptionsHash: dbLocalHost.DHCPOptionSetHash, } diff --git a/backend/server/restservice/hosts_test.go b/backend/server/restservice/hosts_test.go index 84db58948771adccdfad93fbbd4caa8e044f14bb..a1be15a1a9428a9196e5ada84ca93f418e1cce1a 100644 --- a/backend/server/restservice/hosts_test.go +++ b/backend/server/restservice/hosts_test.go @@ -709,6 +709,10 @@ func TestUpdateHostBeginSubmit(t *testing.T) { // Make sure we have some Kea apps in the database. hosts, apps := testutil.AddTestHosts(t, db) + err = dbmodel.AddDaemonToHost(db, &hosts[0], apps[0].Daemons[0].ID, dbmodel.HostDataSourceAPI) + require.NoError(t, err) + err = dbmodel.AddDaemonToHost(db, &hosts[0], apps[1].Daemons[0].ID, dbmodel.HostDataSourceAPI) + require.NoError(t, err) // Begin transaction. params := dhcp.UpdateHostBeginParams{ @@ -759,7 +763,7 @@ func TestUpdateHostBeginSubmit(t *testing.T) { for i, c := range fa.RecordedCommands { switch { - case i%2 == 0: + case i < 2: require.JSONEq(t, `{ "command": "reservation-del", "service": ["dhcp4"], diff --git a/docker/config/agent-kea-premium/init_mysql_query.sql b/docker/config/agent-kea-premium-one/init_mysql_query.sql similarity index 100% rename from docker/config/agent-kea-premium/init_mysql_query.sql rename to docker/config/agent-kea-premium-one/init_mysql_query.sql diff --git a/docker/config/agent-kea-premium/kea-dhcp4.conf b/docker/config/agent-kea-premium-one/kea-dhcp4.conf similarity index 93% rename from docker/config/agent-kea-premium/kea-dhcp4.conf rename to docker/config/agent-kea-premium-one/kea-dhcp4.conf index 0fc911e362116c15618db2e7b024ca030323e401..3dea426cc6c0fd68de13dd1d8f8b1e6065c2a0b3 100644 --- a/docker/config/agent-kea-premium/kea-dhcp4.conf +++ b/docker/config/agent-kea-premium-one/kea-dhcp4.conf @@ -50,9 +50,9 @@ "hosts-databases": [{ "type": "mysql", "host": "mariadb", - "name": "agent_kea_premium", - "user": "agent_kea_premium", - "password": "agent_kea_premium" + "name": "agent_kea_premium_one", + "user": "agent_kea_premium_one", + "password": "agent_kea_premium_one" }], "subnet4": [{ diff --git a/docker/config/agent-kea-premium/kea-dhcp6.conf b/docker/config/agent-kea-premium-one/kea-dhcp6.conf similarity index 94% rename from docker/config/agent-kea-premium/kea-dhcp6.conf rename to docker/config/agent-kea-premium-one/kea-dhcp6.conf index 974b19f988627bffece189d22eec3ca2af3ca30e..7dafb7264e09c7b67aa384f3757239dd6cc0cd09 100644 --- a/docker/config/agent-kea-premium/kea-dhcp6.conf +++ b/docker/config/agent-kea-premium-one/kea-dhcp6.conf @@ -14,9 +14,9 @@ "hosts-databases": [{ "type": "mysql", "host": "mariadb", - "name": "agent_kea_premium", - "user": "agent_kea_premium", - "password": "agent_kea_premium" + "name": "agent_kea_premium_one", + "user": "agent_kea_premium_one", + "password": "agent_kea_premium_one" }], "expired-leases-processing": { "reclaim-timer-wait-time": 10, diff --git a/docker/config/agent-kea-premium-two/init_mysql_query.sql b/docker/config/agent-kea-premium-two/init_mysql_query.sql new file mode 100644 index 0000000000000000000000000000000000000000..65f6a993ba10d8503a3791caf59963557824e49e --- /dev/null +++ b/docker/config/agent-kea-premium-two/init_mysql_query.sql @@ -0,0 +1,22 @@ +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('010101010101'), 0, 123, inet_aton('192.0.10.230')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname) values (unhex('020202020202'), 0, 123, inet_aton('192.0.10.231'), 'fish.example.org'); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname) values (unhex('030303030303'), 0, 123, inet_aton('192.0.10.232'), 'gibberish'); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('040404040404'), 0, 123, inet_aton('192.0.10.233')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('050505050505'), 0, 123, inet_aton('192.0.10.234')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('060606060606'), 0, 123, inet_aton('192.0.10.235')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('07070707'), 2, 123, inet_aton('192.0.10.236')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('08080808'), 2, 123, inet_aton('192.0.10.237')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('09090909'), 1, 123, inet_aton('192.0.10.238')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('0a0a0a0a'), 2, 123, inet_aton('192.0.10.239')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('080808080808'), 0, 0, inet_aton('192.0.10.240')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address) values (unhex('090909090909'), 0, 0, inet_aton('192.0.10.241')); +insert into hosts(dhcp_identifier, dhcp_identifier_type, dhcp6_subnet_id) values (unhex('abc76efabdeaae'), 1, 1); + +select host_id from hosts where ipv4_address = inet_aton('192.0.10.230') into @selected_host; +insert into dhcp4_options(code, formatted_value, space, persistent, host_id, scope_id) values(14, '/tmp/dump/dhcp', 'dhcp4', 0, @selected_host, 3); +insert into dhcp4_options(code, formatted_value, space, persistent, host_id, scope_id) values(3, '10.2.12.1', 'dhcp4', 1, @selected_host, 3); +insert into dhcp4_options(code, formatted_value, space, persistent, host_id, scope_id) values(20, 'true', 'dhcp4', 0, @selected_host, 3); + +select host_id from hosts where hex(dhcp_identifier) = 'abc76efabdeaae' into @selected_host; +insert into dhcp6_options(code, formatted_value, space, persistent, host_id, scope_id) values(23, '2001:db8:1::1,2001:db8:1::1', 'dhcp6', 1, @selected_host, 3); +insert into dhcp6_options(code, formatted_value, space, persistent, host_id, scope_id) values(51, 'foo.example.org.', 'dhcp6', 1, @selected_host, 3); diff --git a/docker/config/agent-kea-premium-two/kea-dhcp4.conf b/docker/config/agent-kea-premium-two/kea-dhcp4.conf new file mode 100644 index 0000000000000000000000000000000000000000..0676443b21dd453c73e5cf987715e23b4f30298a --- /dev/null +++ b/docker/config/agent-kea-premium-two/kea-dhcp4.conf @@ -0,0 +1,76 @@ +{ + +"Dhcp4": { + "interfaces-config": { + "interfaces": [ "eth0" ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/tmp/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "lfc-interval": 3600 + }, + "expired-leases-processing": { + "reclaim-timer-wait-time": 10, + "flush-reclaimed-timer-wait-time": 25, + "hold-reclaimed-time": 3600, + "max-reclaim-leases": 100, + "max-reclaim-time": 250, + "unwarned-reclaim-cycles": 5 + }, + + // We want very small timers here, so even small traffic (such as 1 pkt/sec) will + // be able to fill the pool reasonably quickly. And then we could demonstrate + // the addresses being expired. + "renew-timer": 90, + "rebind-timer": 120, + "valid-lifetime": 180, + + "hooks-libraries": [ + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_stat_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_host_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_legal_log.so", + "parameters": { + "path": "/tmp", + "base-name": "kea-legal-log" + } + } + ], + + "hosts-databases": [{ + "type": "mysql", + "host": "mariadb", + "name": "agent_kea_premium_two", + "user": "agent_kea_premium_two", + "password": "agent_kea_premium_two" + }], + + "subnet4": [{ + "id": 123, + "subnet": "192.0.10.0/24" + }], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "stdout", + "pattern": "%-5p %m\n" + } + ], + "severity": "DEBUG", + "debuglevel": 0 + } + ] +} +} diff --git a/docker/config/agent-kea-premium-two/kea-dhcp6.conf b/docker/config/agent-kea-premium-two/kea-dhcp6.conf new file mode 100644 index 0000000000000000000000000000000000000000..3bc775903b96123c191391065f30bea8c2f87be8 --- /dev/null +++ b/docker/config/agent-kea-premium-two/kea-dhcp6.conf @@ -0,0 +1,88 @@ +{ +"Dhcp6": { + "interfaces-config": { + "interfaces": [ ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/tmp/kea6-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "lfc-interval": 3600 + }, + "hosts-databases": [{ + "type": "mysql", + "host": "mariadb", + "name": "agent_kea_premium_two", + "user": "agent_kea_premium_two", + "password": "agent_kea_premium_two" + }], + "expired-leases-processing": { + "reclaim-timer-wait-time": 10, + "flush-reclaimed-timer-wait-time": 25, + "hold-reclaimed-time": 3600, + "max-reclaim-leases": 100, + "max-reclaim-time": 250, + "unwarned-reclaim-cycles": 5 + }, + "renew-timer": 90, + "rebind-timer": 120, + "preferred-lifetime": 150, + "valid-lifetime": 180, + "option-data": [ + { + "name": "dns-servers", + "data": "2001:db8:2::45, 2001:db8:2::100" + } + ], + "hooks-libraries": [ + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_stat_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_host_cmds.so" + }, + { + "library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_legal_log.so", + "parameters": { + "path": "/tmp", + "base-name": "kea-legal-log" + } + } + ], + "subnet6": [ + { + "subnet": "3008:db8:1::/64", + "id": 1, +# "interface": "eth1", + "pools": [ { "pool": "3008:db8:1:0:1::/80" } ] + }, + { + "subnet": "3010:db8:1::/64", + "id": 2, +# "interface": "eth1", + "pools": [ { "pool": "3010:db8:1::/80" } ] + } + ], + "loggers": [ + { + "name": "kea-dhcp6", + "output_options": [ + { + "output": "stdout", + "pattern": "%-5p %m\n" + }, + { + "output": "/tmp/kea-dhcp6.log" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ] +} +} diff --git a/docker/docker-compose-premium.yaml b/docker/docker-compose-premium.yaml index aaa79f809f74b1e0d32fcbbf5ca753d57aff0778..9048c413634eab8594dc584f7b82faa83104baf4 100644 --- a/docker/docker-compose-premium.yaml +++ b/docker/docker-compose-premium.yaml @@ -1,7 +1,7 @@ version: '2.1' services: - agent-kea-premium: + agent-kea-premium-one: restart: always build: context: . @@ -10,8 +10,8 @@ services: args: KEA_REPO: ${CS_REPO_ACCESS_TOKEN}/isc/kea-2-0-prv KEA_PREMIUM: "premium" - image: registry.gitlab.isc.org/isc-private/stork/agent-kea-premium:latest - hostname: agent-kea-premium + image: registry.gitlab.isc.org/isc-private/stork/agent-kea-premium-one:latest + hostname: agent-kea-premium-one networks: storknet: ipv4_address: 172.20.0.103 @@ -22,19 +22,63 @@ services: environment: DB_TYPE: mysql DB_HOST: mariadb - DB_USER: agent_kea_premium - DB_PASSWORD: agent_kea_premium + DB_USER: agent_kea_premium_one + DB_PASSWORD: agent_kea_premium_one DB_ROOT_USER: root DB_ROOT_PASSWORD: root - DB_NAME: agent_kea_premium + DB_NAME: agent_kea_premium_one STORK_AGENT_SERVER_URL: ${STORK_SERVER_URL-http://server:8080} - STORK_AGENT_HOST: agent-kea-premium + STORK_AGENT_HOST: agent-kea-premium-one STORK_AGENT_PORT: "8881" volumes: - ./docker/config/agent-kea/kea-ctrl-agent.conf:/etc/kea/kea-ctrl-agent.conf - - ./docker/config/agent-kea-premium/kea-dhcp4.conf:/etc/kea/kea-dhcp4.conf - - ./docker/config/agent-kea-premium/kea-dhcp6.conf:/etc/kea/kea-dhcp6.conf - - ./docker/config/agent-kea-premium/init_mysql_query.sql:/var/lib/db/init_mysql_query.sql + - ./docker/config/agent-kea-premium-one/kea-dhcp4.conf:/etc/kea/kea-dhcp4.conf + - ./docker/config/agent-kea-premium-one/kea-dhcp6.conf:/etc/kea/kea-dhcp6.conf + - ./docker/config/agent-kea-premium-one/init_mysql_query.sql:/var/lib/db/init_mysql_query.sql + - ./docker/config/supervisor/supervisord.conf:/etc/supervisor/supervisord.conf + - ./docker/config/supervisor/kea-agent.conf:/etc/supervisor/conf.d/kea-agent.conf + - ./docker/config/supervisor/kea-dhcp4.conf:/etc/supervisor/conf.d/kea-dhcp4.conf + - ./docker/config/supervisor/kea-dhcp6.conf:/etc/supervisor/conf.d/kea-dhcp6.conf + - ./docker/config/supervisor/stork-agent.conf:/etc/supervisor/conf.d/stork-agent.conf + - ./docker/config/supervisor/prometheus.conf:/etc/supervisor/conf.d/prometheus.conf + depends_on: + - mariadb + - server + + agent-kea-premium-two: + restart: always + build: + context: . + dockerfile: docker/images/stork.Dockerfile + target: kea + args: + KEA_REPO: ${CS_REPO_ACCESS_TOKEN}/isc/kea-2-0-prv + KEA_PREMIUM: "premium" + image: registry.gitlab.isc.org/isc-private/stork/agent-kea-premium-two:latest + hostname: agent-kea-premium-two + networks: + storknet: + ipv4_address: 172.20.0.104 + expose: + - "8889" # stork server to agent + ports: + - "8889:8889" # publish ports for development purposes + environment: + DB_TYPE: mysql + DB_HOST: mariadb + DB_USER: agent_kea_premium_two + DB_PASSWORD: agent_kea_premium_two + DB_ROOT_USER: root + DB_ROOT_PASSWORD: root + DB_NAME: agent_kea_premium_two + STORK_AGENT_SERVER_URL: ${STORK_SERVER_URL-http://server:8080} + STORK_AGENT_HOST: agent-kea-premium-two + STORK_AGENT_PORT: "8889" + volumes: + - ./docker/config/agent-kea/kea-ctrl-agent.conf:/etc/kea/kea-ctrl-agent.conf + - ./docker/config/agent-kea-premium-two/kea-dhcp4.conf:/etc/kea/kea-dhcp4.conf + - ./docker/config/agent-kea-premium-two/kea-dhcp6.conf:/etc/kea/kea-dhcp6.conf + - ./docker/config/agent-kea-premium-two/init_mysql_query.sql:/var/lib/db/init_mysql_query.sql - ./docker/config/supervisor/supervisord.conf:/etc/supervisor/supervisord.conf - ./docker/config/supervisor/kea-agent.conf:/etc/supervisor/conf.d/kea-agent.conf - ./docker/config/supervisor/kea-dhcp4.conf:/etc/supervisor/conf.d/kea-dhcp4.conf diff --git a/rakelib/60_docker_demo.rake b/rakelib/60_docker_demo.rake index f321f66423af04da4634aacaa5aa84e29da57c3e..67325a5dae0899cfd17f618ee43d9428a03c0e6a 100644 --- a/rakelib/60_docker_demo.rake +++ b/rakelib/60_docker_demo.rake @@ -202,7 +202,7 @@ namespace :demo do if !ENV["CS_REPO_ACCESS_TOKEN"] fail 'You need to provide the CloudSmith access token in CS_REPO_ACCESS_TOKEN environment variable.' end - docker_up_services("agent-kea-premium") + docker_up_services("agent-kea-premium-one", "agent-kea-premium-two") end desc 'Build and run container with Stork Agent and BIND 9 diff --git a/webui/src/app/dhcp-option-form/dhcp-option-form.component.sass b/webui/src/app/dhcp-option-form/dhcp-option-form.component.sass index 4f2769440036466bb3d052112942ec72ae66ba81..81fd8654fccc8c20f85dc636268461ac6ef80e67 100644 --- a/webui/src/app/dhcp-option-form/dhcp-option-form.component.sass +++ b/webui/src/app/dhcp-option-form/dhcp-option-form.component.sass @@ -20,6 +20,7 @@ ::ng-deep .empty-option-tag &:hover cursor: help + background: var(--gray-300) ::ng-deep .p-togglebutton.p-button.p-highlight background: #dadada diff --git a/webui/src/app/dhcp-option-form/dhcp-option-form.component.ts b/webui/src/app/dhcp-option-form/dhcp-option-form.component.ts index 627dab1209e158090dbc75a544a0cab25e364295..c222888d24fb22dfce3fa8e1619aef7770a123bb 100644 --- a/webui/src/app/dhcp-option-form/dhcp-option-form.component.ts +++ b/webui/src/app/dhcp-option-form/dhcp-option-form.component.ts @@ -15,6 +15,7 @@ import { MenuItem } from 'primeng/api' import { LinkedFormGroup } from '../forms/linked-form-group' import { DhcpOptionField, DhcpOptionFieldFormGroup, DhcpOptionFieldType } from '../forms/dhcp-option-field' import { DhcpOptionsService } from '../dhcp-options.service' +import { DhcpOptionSetFormService } from '../forms/dhcp-option-set-form.service' import { createDefaultDhcpOptionFormGroup } from '../forms/dhcp-option-form' import { IPType } from '../iptype' import { StorkValidators } from '../validators' @@ -123,10 +124,16 @@ export class DhcpOptionFormComponent implements OnInit { * Constructor. * * @param _formBuilder a form builder instance used in this component. + * @param _optionSetFormService a service providing functions to convert + * options from and to reactive forms. * @param optionsService a service exposing a list of standard DHCP options - * to configure. + * to configure. */ - constructor(private _formBuilder: FormBuilder, public optionsService: DhcpOptionsService) {} + constructor( + private _formBuilder: FormBuilder, + private _optionSetFormService: DhcpOptionSetFormService, + public optionsService: DhcpOptionsService + ) {} /** * A component lifecycle hook called on component initialization. @@ -279,116 +286,89 @@ export class DhcpOptionFormComponent implements OnInit { } /** - * Adds a single control to the array of the option fields. + * Adds a single control the array of the option fields. * - * It is called for option fields which come with a single control, - * e.g. a string option fields. - * - * @param fieldType DHCP option field type. - * @param control a control associated with the option field. - */ - private _addSimpleField(fieldType: DhcpOptionFieldType, control: FormControl): void { - this.optionFields.push(new DhcpOptionFieldFormGroup(fieldType, { control: control })) - } - - /** - * Adds multiple controls to the array of the option fields. - * - * it is called for the option fields which require multiple controls, - * e.g. delegated prefix option field requires an input for the prefix - * and another input for the prefix length. - * - * @param fieldType DHCP option field type. - * @param controls the controls associated with the option field. + * @param optionField an option field form group. */ - private _addComplexField(fieldType: DhcpOptionFieldType, controls: { [key: string]: AbstractControl }): void { - this.optionFields.push(new DhcpOptionFieldFormGroup(fieldType, controls)) + private _addField(optionField: DhcpOptionFieldFormGroup) { + this.optionFields.push(optionField) } /** * Adds a control for option field specified in hex-bytes format. */ addHexBytesField(): void { - this._addSimpleField(this.FieldType.HexBytes, this._formBuilder.control('', StorkValidators.hexIdentifier())) + this._addField(this._optionSetFormService.createHexBytesField()) } /** * Adds a control for option field specified as a string. */ addStringField(): void { - this._addSimpleField(this.FieldType.String, this._formBuilder.control('', Validators.required)) + this._addField(this._optionSetFormService.createStringField()) } /** * Adds a control for option field specified as a boolean. */ addBoolField(): void { - this._addSimpleField(this.FieldType.Bool, this._formBuilder.control('')) + this._addField(this._optionSetFormService.createBoolField()) } /** * Adds a control for option field specified as uint8. */ addUint8Field(): void { - this._addSimpleField(this.FieldType.Uint8, this._formBuilder.control(null, Validators.required)) + this._addField(this._optionSetFormService.createUint8Field()) } /** * Adds a control for option field specified as uint16. */ addUint16Field(): void { - this._addSimpleField(this.FieldType.Uint16, this._formBuilder.control(null, Validators.required)) + this._addField(this._optionSetFormService.createUint16Field()) } /** * Adds a control for option field specified as uint32. */ addUint32Field(): void { - this._addSimpleField(this.FieldType.Uint32, this._formBuilder.control(null, Validators.required)) + this._addField(this._optionSetFormService.createUint32Field()) } /** * Adds a control for option field containing an IPv4 address. */ addIPv4AddressField(): void { - this._addSimpleField(this.FieldType.IPv4Address, this._formBuilder.control('', StorkValidators.ipv4())) + this._addField(this._optionSetFormService.createIPv4AddressField()) } /** * Adds a control for option field containing an IPv6 address. */ addIPv6AddressField(): void { - this._addSimpleField(this.FieldType.IPv6Address, this._formBuilder.control('', StorkValidators.ipv6())) + this._addField(this._optionSetFormService.createIPv6AddressField()) } /** * Adds controls for option field containing an IPv6 prefix. */ addIPv6PrefixField(): void { - this._addComplexField(this.FieldType.IPv6Prefix, { - prefix: this._formBuilder.control('', StorkValidators.ipv6()), - prefixLength: this._formBuilder.control('64', Validators.required), - }) + this._addField(this._optionSetFormService.createIPv6PrefixField()) } /** * Adds controls for option field containing a PSID. */ addPsidField(): void { - this._addComplexField(this.FieldType.Psid, { - psid: this._formBuilder.control(null, Validators.required), - psidLength: this._formBuilder.control(null, Validators.required), - }) + this._addField(this._optionSetFormService.createPsidField()) } /** * Adds a control for option field containing an FQDN. */ addFqdnField(): void { - this._addSimpleField( - this.FieldType.Fqdn, - this._formBuilder.control('', [Validators.required, StorkValidators.fullFqdn]) - ) + this._addField(this._optionSetFormService.createFqdnField()) } /** diff --git a/webui/src/app/forms/dhcp-option-set-form.service.spec.ts b/webui/src/app/forms/dhcp-option-set-form.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9371385523577030746d42ecec883ea01b958cb2 --- /dev/null +++ b/webui/src/app/forms/dhcp-option-set-form.service.spec.ts @@ -0,0 +1,778 @@ +import { TestBed } from '@angular/core/testing' +import { FormArray, FormBuilder } from '@angular/forms' +import { DHCPOption } from '../backend/model/dHCPOption' +import { DhcpOptionSetFormService } from './dhcp-option-set-form.service' +import { DhcpOptionFieldFormGroup, DhcpOptionFieldType } from './dhcp-option-field' +import { IPType } from '../iptype' + +describe('DhcpOptionSetFormService', () => { + let service: DhcpOptionSetFormService + let formBuilder: FormBuilder = new FormBuilder() + let formArray: FormArray + + beforeEach(() => { + TestBed.configureTestingModule({}) + service = TestBed.inject(DhcpOptionSetFormService) + + formArray = formBuilder.array([ + formBuilder.group({ + alwaysSend: formBuilder.control(true), + optionCode: formBuilder.control(1024), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { + prefix: formBuilder.control('3000::'), + prefixLength: formBuilder.control(64), + }), + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { + psid: formBuilder.control(12), + psidLength: formBuilder.control(8), + }), + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.HexBytes, { + control: formBuilder.control('01:02:03'), + }), + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.String, { + control: formBuilder.control('foobar'), + }), + ]), + }), + formBuilder.group({ + alwaysSend: formBuilder.control(false), + optionCode: formBuilder.control(2024), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint8, { + control: formBuilder.control(101), + }), + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint16, { + control: formBuilder.control(16523), + }), + ]), + }), + formBuilder.group({ + alwaysSend: formBuilder.control(true), + optionCode: formBuilder.control(3087), + suboptions: formBuilder.array([ + formBuilder.group({ + alwaysSend: formBuilder.control(false), + optionCode: formBuilder.control(1), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint16, { + control: formBuilder.control(1111), + }), + ]), + }), + formBuilder.group({ + alwaysSend: formBuilder.control(false), + optionCode: formBuilder.control(0), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint32, { + control: formBuilder.control(2222), + }), + ]), + }), + ]), + }), + ]) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) + + it('copies a complex form control with multiple nesting levels', () => { + let clonedArray = service.cloneControl(formArray) + + expect(clonedArray).toBeTruthy() + expect(clonedArray.length).toBe(3) + + // Option 1024. + expect(clonedArray.at(0).get('alwaysSend')).toBeTruthy() + expect(clonedArray.at(0).get('optionCode')).toBeTruthy() + expect(clonedArray.at(0).get('optionFields')).toBeTruthy() + + expect(clonedArray.at(0).get('alwaysSend').value).toBeTrue() + expect(clonedArray.at(0).get('optionCode').value).toBe(1024) + + // Option 1024 fields. + expect(clonedArray.at(0).get('optionFields')).toBeInstanceOf(FormArray) + let fields = clonedArray.at(0).get('optionFields') as FormArray + expect(fields.controls.length).toBe(4) + + // Option 1024 field 0. + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.IPv6Prefix) + expect(fields.at(0).get('prefix')).toBeTruthy() + expect(fields.at(0).get('prefixLength')).toBeTruthy() + expect(fields.at(0).get('prefix').value).toBe('3000::') + expect(fields.at(0).get('prefixLength').value).toBe(64) + + // Option 1024 field 1. + expect(fields.at(1)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(1) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Psid) + expect(fields.at(1).get('psid')).toBeTruthy() + expect(fields.at(1).get('psidLength')).toBeTruthy() + expect(fields.at(1).get('psid').value).toBe(12) + expect(fields.at(1).get('psidLength').value).toBe(8) + + // Option 1024 field 2. + expect(fields.at(2)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(2) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.HexBytes) + expect(fields.at(2).get('control')).toBeTruthy() + expect(fields.at(2).get('control').value).toBe('01:02:03') + + // Option 1024 field 3. + expect(fields.at(3)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(3) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.String) + expect(fields.at(3).get('control')).toBeTruthy() + expect(fields.at(3).get('control').value).toBe('foobar') + + // Option 2024. + expect(clonedArray.at(1).get('alwaysSend')).toBeTruthy() + expect(clonedArray.at(1).get('optionCode')).toBeTruthy() + expect(clonedArray.at(1).get('optionFields')).toBeTruthy() + + expect(clonedArray.at(1).get('alwaysSend').value).toBeFalse() + expect(clonedArray.at(1).get('optionCode').value).toBe(2024) + + // Option 2024 fields. + expect(clonedArray.at(1).get('optionFields')).toBeInstanceOf(FormArray) + fields = clonedArray.at(1).get('optionFields') as FormArray + expect(fields.controls.length).toBe(2) + + // Option 2024 field 0. + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint8) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe(101) + + // Option 2024 field 1. + expect(fields.at(1)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(1) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(fields.at(1).get('control')).toBeTruthy() + expect(fields.at(1).get('control').value).toBe(16523) + + // Option 3087. + expect(clonedArray.at(2).get('alwaysSend')).toBeTruthy() + expect(clonedArray.at(2).get('optionCode')).toBeTruthy() + expect(clonedArray.at(2).get('suboptions')).toBeTruthy() + + expect(clonedArray.at(2).get('alwaysSend').value).toBeTrue() + expect(clonedArray.at(2).get('optionCode').value).toBe(3087) + + // Option 3087 suboptions. + expect(clonedArray.at(2).get('suboptions')).toBeInstanceOf(FormArray) + expect((clonedArray.at(2).get('suboptions') as FormArray).controls.length).toBe(2) + + // Option 3087.1. + expect(clonedArray.at(2).get('suboptions.0.alwaysSend')).toBeTruthy() + expect(clonedArray.at(2).get('suboptions.0.optionCode')).toBeTruthy() + expect(clonedArray.at(2).get('suboptions.0.optionFields')).toBeTruthy() + + // Option 3087.1 field 0. + fields = clonedArray.at(2).get('suboptions.0.optionFields') as FormArray + expect(fields.controls.length).toBe(1) + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe(1111) + + // Option 3087.0. + expect(clonedArray.at(2).get('suboptions.1.alwaysSend')).toBeTruthy() + expect(clonedArray.at(2).get('suboptions.1.optionCode')).toBeTruthy() + expect(clonedArray.at(2).get('suboptions.1.optionFields')).toBeTruthy() + + expect(clonedArray.at(2).get('suboptions.1.alwaysSend').value).toBeFalse() + expect(clonedArray.at(2).get('suboptions.1.optionCode').value).toBe(0) + + // Option 3087.0 field 0. + fields = clonedArray.at(2).get('suboptions.1.optionFields') as FormArray + expect(fields.controls.length).toBe(1) + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint32) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe(2222) + }) + + it('converts specified DHCP options to REST API format', () => { + // Extract the options from the form and make sure there are + // three of them. + const serialized = service.convertFormToOptions(IPType.IPv4, formArray) + expect(serialized.length).toBe(3) + + expect(serialized[0].hasOwnProperty('alwaysSend')).toBeTrue() + expect(serialized[0].hasOwnProperty('code')).toBeTrue() + expect(serialized[0].hasOwnProperty('encapsulate')).toBeTrue() + expect(serialized[0].hasOwnProperty('fields')).toBeTrue() + expect(serialized[0].hasOwnProperty('options')).toBeTrue() + expect(serialized[0].alwaysSend).toBeTrue() + expect(serialized[0].code).toBe(1024) + expect(serialized[0].encapsulate.length).toBe(0) + // It should have 4 option fields of different types. + expect(serialized[0].fields.length).toBe(4) + expect(serialized[0].fields[0].fieldType).toBe(DhcpOptionFieldType.IPv6Prefix) + expect(serialized[0].fields[0].values.length).toBe(2) + expect(serialized[0].fields[0].values[0]).toBe('3000::') + expect(serialized[0].fields[0].values[1]).toBe('64') + expect(serialized[0].fields[1].fieldType).toBe(DhcpOptionFieldType.Psid) + expect(serialized[0].fields[1].values[0]).toBe('12') + expect(serialized[0].fields[1].values[1]).toBe('8') + expect(serialized[0].fields[1].values.length).toBe(2) + expect(serialized[0].fields[2].fieldType).toBe(DhcpOptionFieldType.HexBytes) + expect(serialized[0].fields[2].values.length).toBe(1) + expect(serialized[0].fields[2].values[0]).toBe('01:02:03') + expect(serialized[0].fields[3].fieldType).toBe(DhcpOptionFieldType.String) + expect(serialized[0].fields[3].values.length).toBe(1) + expect(serialized[0].fields[3].values[0]).toBe('foobar') + + expect(serialized[1].hasOwnProperty('alwaysSend')).toBeTrue() + expect(serialized[1].hasOwnProperty('code')).toBeTrue() + expect(serialized[1].hasOwnProperty('encapsulate')).toBeTrue() + expect(serialized[1].hasOwnProperty('fields')).toBeTrue() + expect(serialized[1].hasOwnProperty('options')).toBeTrue() + expect(serialized[1].alwaysSend).toBeFalse() + expect(serialized[1].code).toBe(2024) + expect(serialized[1].encapsulate.length).toBe(0) + expect(serialized[1].fields.length).toBe(2) + expect(serialized[1].fields[0].values.length).toBe(1) + expect(serialized[1].fields[0].values[0]).toBe('101') + expect(serialized[1].fields[1].values.length).toBe(1) + expect(serialized[1].fields[1].values[0]).toBe('16523') + expect(serialized[1].hasOwnProperty('options')).toBeTrue() + + expect(serialized[2].hasOwnProperty('alwaysSend')).toBeTrue() + expect(serialized[2].hasOwnProperty('code')).toBeTrue() + expect(serialized[2].hasOwnProperty('encapsulate')).toBeTrue() + expect(serialized[2].hasOwnProperty('fields')).toBeTrue() + expect(serialized[2].hasOwnProperty('options')).toBeTrue() + expect(serialized[2].alwaysSend).toBeTrue() + expect(serialized[2].code).toBe(3087) + expect(serialized[2].encapsulate).toBe('option-3087') + expect(serialized[2].fields.length).toBe(0) + // The option should contain a suboptions + expect(serialized[2].options.length).toBe(2) + expect(serialized[2].options[0].hasOwnProperty('code')).toBeTrue() + expect(serialized[2].options[0].hasOwnProperty('encapsulate')).toBeTrue() + expect(serialized[2].options[0].hasOwnProperty('fields')).toBeTrue() + expect(serialized[2].options[0].hasOwnProperty('options')).toBeTrue() + expect(serialized[2].options[0].code).toBe(1) + expect(serialized[2].options[0].encapsulate.length).toBe(0) + expect(serialized[2].options[0].fields.length).toBe(1) + expect(serialized[2].options[0].fields[0].fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(serialized[2].options[0].options.length).toBe(0) + expect(serialized[2].options[1].hasOwnProperty('code')).toBeTrue() + expect(serialized[2].options[1].hasOwnProperty('encapsulate')).toBeTrue() + expect(serialized[2].options[1].hasOwnProperty('fields')).toBeTrue() + expect(serialized[2].options[1].hasOwnProperty('options')).toBeTrue() + expect(serialized[2].options[1].code).toBe(0) + expect(serialized[2].options[1].encapsulate.length).toBe(0) + expect(serialized[2].options[1].fields.length).toBe(1) + expect(serialized[2].options[1].fields[0].fieldType).toBe(DhcpOptionFieldType.Uint32) + expect(serialized[2].options[1].options.length).toBe(0) + }) + + it('throws on too much recursion when converting a form', () => { + // Add an option with three nesting levels. It should throw because + // we merely support first level suboptions. + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(1024), + optionFields: formBuilder.array([]), + suboptions: formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(1), + optionFields: formBuilder.array([]), + suboptions: formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(2), + optionFields: formBuilder.array([]), + suboptions: formBuilder.array([]), + }), + ]), + }), + ]), + }), + ]) + + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when there is no option code when converting a form', () => { + const formArray = formBuilder.array([formBuilder.group({})]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when prefix field lacks prefix control', () => { + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(3087), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { + prefixLength: formBuilder.control(64), + }), + ]), + }), + ]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when prefix field lacks prefix control when converting a form', () => { + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(3087), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { + prefix: formBuilder.control('3000::'), + }), + ]), + }), + ]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when psid field lacks psid control when converting a form', () => { + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(3087), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { + psidLength: formBuilder.control(8), + }), + ]), + }), + ]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when psid field lacks psid length control when converting a form', () => { + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(3087), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { + psid: formBuilder.control(100), + }), + ]), + }), + ]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('throws when a single value field lacks control when converting a form', () => { + const formArray = formBuilder.array([ + formBuilder.group({ + optionCode: formBuilder.control(3087), + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint8, { + psid: formBuilder.control(100), + }), + ]), + }), + ]) + expect(() => service.convertFormToOptions(IPType.IPv4, formArray)).toThrow() + }) + + it('converts received DHCP options from REST API format to a form', () => { + let options: Array = [ + { + alwaysSend: true, + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.IPv6Prefix, + values: ['3000::', '64'], + }, + { + fieldType: DhcpOptionFieldType.Psid, + values: ['12', '8'], + }, + { + fieldType: DhcpOptionFieldType.HexBytes, + values: ['01:02:03'], + }, + { + fieldType: DhcpOptionFieldType.String, + values: ['foobar'], + }, + ], + options: [], + }, + { + alwaysSend: false, + code: 2024, + fields: [ + { + fieldType: DhcpOptionFieldType.Uint8, + values: ['101'], + }, + { + fieldType: DhcpOptionFieldType.Uint16, + values: ['16523'], + }, + ], + options: [], + }, + { + alwaysSend: true, + code: 3087, + fields: [], + options: [ + { + code: 1, + fields: [ + { + fieldType: DhcpOptionFieldType.Uint16, + values: ['1111'], + }, + ], + }, + { + code: 0, + fields: [ + { + fieldType: DhcpOptionFieldType.Uint32, + values: ['2222'], + }, + ], + }, + ], + }, + ] + let formArray = service.convertOptionsToForm(IPType.IPv4, options) + expect(formArray).toBeTruthy() + expect(formArray.length).toBe(3) + + // Option 1024. + expect(formArray.at(0).get('alwaysSend')).toBeTruthy() + expect(formArray.at(0).get('optionCode')).toBeTruthy() + expect(formArray.at(0).get('optionFields')).toBeTruthy() + expect(formArray.at(0).get('suboptions')).toBeTruthy() + + expect(formArray.at(0).get('alwaysSend').value).toBeTrue() + expect(formArray.at(0).get('optionCode').value).toBe(1024) + + // Option 1024 fields. + expect(formArray.at(0).get('optionFields')).toBeInstanceOf(FormArray) + let fields = formArray.at(0).get('optionFields') as FormArray + expect(fields.controls.length).toBe(4) + + // Option 1024 field 0. + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.IPv6Prefix) + expect(fields.at(0).get('prefix')).toBeTruthy() + expect(fields.at(0).get('prefixLength')).toBeTruthy() + expect(fields.at(0).get('prefix').value).toBe('3000::') + expect(fields.at(0).get('prefixLength').value).toBe('64') + + // Option 1024 field 1. + expect(fields.at(1)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(1) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Psid) + expect(fields.at(1).get('psid')).toBeTruthy() + expect(fields.at(1).get('psidLength')).toBeTruthy() + expect(fields.at(1).get('psid').value).toBe('12') + expect(fields.at(1).get('psidLength').value).toBe('8') + + // Option 1024 field 2. + expect(fields.at(2)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(2) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.HexBytes) + expect(fields.at(2).get('control')).toBeTruthy() + expect(fields.at(2).get('control').value).toBe('01:02:03') + + // Option 1024 field 3. + expect(fields.at(3)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(3) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.String) + expect(fields.at(3).get('control')).toBeTruthy() + expect(fields.at(3).get('control').value).toBe('foobar') + + // Option 1024 suboptions. + expect(formArray.at(0).get('suboptions')).toBeInstanceOf(FormArray) + expect((formArray.at(0).get('suboptions') as FormArray).controls.length).toBe(0) + + // Option 2024. + expect(formArray.at(1).get('alwaysSend')).toBeTruthy() + expect(formArray.at(1).get('optionCode')).toBeTruthy() + expect(formArray.at(1).get('optionFields')).toBeTruthy() + expect(formArray.at(1).get('suboptions')).toBeTruthy() + + expect(formArray.at(1).get('alwaysSend').value).toBeFalse() + expect(formArray.at(1).get('optionCode').value).toBe(2024) + + // Option 2024 fields. + expect(formArray.at(1).get('optionFields')).toBeInstanceOf(FormArray) + fields = formArray.at(1).get('optionFields') as FormArray + expect(fields.controls.length).toBe(2) + + // Option 2024 field 0. + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint8) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe('101') + + // Option 2024 field 1. + expect(fields.at(1)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(1) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(fields.at(1).get('control')).toBeTruthy() + expect(fields.at(1).get('control').value).toBe('16523') + + // Option 3087. + expect(formArray.at(2).get('alwaysSend')).toBeTruthy() + expect(formArray.at(2).get('optionCode')).toBeTruthy() + expect(formArray.at(2).get('optionFields')).toBeTruthy() + expect(formArray.at(2).get('suboptions')).toBeTruthy() + + expect(formArray.at(2).get('alwaysSend').value).toBeTrue() + expect(formArray.at(2).get('optionCode').value).toBe(3087) + + // Option 3087 fields. + expect(formArray.at(2).get('optionFields')).toBeInstanceOf(FormArray) + fields = formArray.at(2).get('optionFields') as FormArray + expect(fields.controls.length).toBe(0) + + // Option 3087 suboptions. + expect(formArray.at(2).get('suboptions')).toBeInstanceOf(FormArray) + expect((formArray.at(2).get('suboptions') as FormArray).controls.length).toBe(2) + + // Option 3087.1. + expect(formArray.at(2).get('suboptions.0.alwaysSend')).toBeTruthy() + expect(formArray.at(2).get('suboptions.0.optionCode')).toBeTruthy() + expect(formArray.at(2).get('suboptions.0.optionFields')).toBeTruthy() + expect(formArray.at(2).get('suboptions.0.suboptions')).toBeTruthy() + + expect(formArray.at(2).get('suboptions.0.alwaysSend').value).toBeFalse() + expect(formArray.at(2).get('suboptions.0.optionCode').value).toBe(1) + + // Option 3087.1 field 0. + fields = formArray.at(2).get('suboptions.0.optionFields') as FormArray + expect(fields.controls.length).toBe(1) + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe('1111') + + // Option 3087.0. + expect(formArray.at(2).get('suboptions.1.alwaysSend')).toBeTruthy() + expect(formArray.at(2).get('suboptions.1.optionCode')).toBeTruthy() + expect(formArray.at(2).get('suboptions.1.optionFields')).toBeTruthy() + expect(formArray.at(2).get('suboptions.1.suboptions')).toBeTruthy() + + expect(formArray.at(2).get('suboptions.1.alwaysSend').value).toBeFalse() + expect(formArray.at(2).get('suboptions.1.optionCode').value).toBe(0) + + // Option 3087.0 field 0. + fields = formArray.at(2).get('suboptions.1.optionFields') as FormArray + expect(fields.controls.length).toBe(1) + expect(fields.at(0)).toBeInstanceOf(DhcpOptionFieldFormGroup) + expect((fields.at(0) as DhcpOptionFieldFormGroup).data.fieldType).toBe(DhcpOptionFieldType.Uint32) + expect(fields.at(0).get('control')).toBeTruthy() + expect(fields.at(0).get('control').value).toBe('2222') + }) + + it('returns empty array for null options', () => { + let formArray = service.convertOptionsToForm(IPType.IPv4, null) + expect(formArray).toBeTruthy() + expect(formArray.length).toBe(0) + }) + + it('throws on too much recursion when converting options', () => { + // Add an option with three nesting levels. It should throw because + // we merely support first level suboptions. + let options: Array = [ + { + code: 1024, + fields: [], + options: [ + { + code: 1, + fields: [], + options: [ + { + code: 2, + fields: [], + options: [], + }, + ], + }, + ], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('throws when IPv6 prefix field has only one value', () => { + let options: Array = [ + { + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.IPv6Prefix, + values: ['3000::'], + }, + ], + options: [], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('throws when IPv6 prefix field has three values', () => { + let options: Array = [ + { + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.IPv6Prefix, + values: ['3000::', '64', '5'], + }, + ], + options: [], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('throws when PSID field has only one value', () => { + let options: Array = [ + { + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.Psid, + values: ['12'], + }, + ], + options: [], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('throws when PSID field has three values', () => { + let options: Array = [ + { + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.Psid, + values: ['12', '8', '5'], + }, + ], + options: [], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('throws when string field has two values', () => { + let options: Array = [ + { + code: 1024, + fields: [ + { + fieldType: DhcpOptionFieldType.String, + values: ['foo', 'bar'], + }, + ], + options: [], + }, + ] + expect(() => service.convertOptionsToForm(IPType.IPv4, options)).toThrow() + }) + + it('creates hex-bytes field', () => { + let formGroup = service.createHexBytesField('01:02:03') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.HexBytes) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe('01:02:03') + }) + + it('creates string field', () => { + let formGroup = service.createStringField('foo') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.String) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe('foo') + }) + + it('creates boolean field from string', () => { + let formGroup = service.createBoolField('true') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Bool) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBeTrue() + }) + + it('creates boolean field from boolean', () => { + let formGroup = service.createBoolField(false) + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Bool) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBeFalse() + }) + + it('creates uint8 field', () => { + let formGroup = service.createUint8Field(123) + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Uint8) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe(123) + }) + + it('creates uint16 field', () => { + let formGroup = service.createUint16Field(234) + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Uint16) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe(234) + }) + + it('creates uint32 field', () => { + let formGroup = service.createUint32Field(345) + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Uint32) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe(345) + }) + + it('creates ipv4-address field', () => { + let formGroup = service.createIPv4AddressField('192.0.2.1') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.IPv4Address) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe('192.0.2.1') + }) + + it('creates ipv6-address field', () => { + let formGroup = service.createIPv6AddressField('2001:db8:1::1') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.IPv6Address) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe('2001:db8:1::1') + }) + + it('creates ipv6-prefix field', () => { + let formGroup = service.createIPv6PrefixField('3000::', '64') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.IPv6Prefix) + expect(formGroup.contains('prefix')).toBeTrue() + expect(formGroup.contains('prefixLength')).toBeTrue() + expect(formGroup.get('prefix').value).toBe('3000::') + expect(formGroup.get('prefixLength').value).toBe('64') + }) + + it('creates psid field', () => { + let formGroup = service.createPsidField('12', '8') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Psid) + expect(formGroup.contains('psid')).toBeTrue() + expect(formGroup.contains('psidLength')).toBeTrue() + expect(formGroup.get('psid').value).toBe('12') + expect(formGroup.get('psidLength').value).toBe('8') + }) + + it('creates fqdn field', () => { + let formGroup = service.createFqdnField('foo.example.org') + expect(formGroup).toBeTruthy() + expect(formGroup.data.fieldType).toBe(DhcpOptionFieldType.Fqdn) + expect(formGroup.contains('control')).toBeTrue() + expect(formGroup.get('control').value).toBe('foo.example.org') + }) +}) diff --git a/webui/src/app/forms/dhcp-option-set-form.service.ts b/webui/src/app/forms/dhcp-option-set-form.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..060902d828cd9304bf6e15e4f668cfc45802f827 --- /dev/null +++ b/webui/src/app/forms/dhcp-option-set-form.service.ts @@ -0,0 +1,492 @@ +import { Injectable } from '@angular/core' +import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms' +import { createDefaultDhcpOptionFormGroup } from './dhcp-option-form' +import { DhcpOptionFieldFormGroup, DhcpOptionFieldType } from './dhcp-option-field' +import { IPType } from '../iptype' +import { DHCPOption } from '../backend/model/dHCPOption' +import { DHCPOptionField } from '../backend/model/dHCPOptionField' +import { StorkValidators } from '../validators' + +/** + * A service for converting reactive forms with DHCP options to the REST API + * format and vice-versa. + */ +@Injectable({ + providedIn: 'root', +}) +export class DhcpOptionSetFormService { + /** + * Form builder instance used by the service to create the reactive forms. + */ + _formBuilder: FormBuilder + + /** + * Constructor. + * + * Creates form builder instance. + */ + constructor() { + this._formBuilder = new FormBuilder() + } + + /** + * Performs deep copy of the form array holding DHCP options or its fragment. + * + * I copies all controls, including DhcpOptionFieldFormGroup, with their + * validators. Controls belonging to forms or arrays are copied recursively. + * + * This function implementation is derived from the following article: + * https://newbedev.com/deep-copy-of-angular-reactive-form + * + * @param control top-level control to be copied. + * @returns copied control instance. + */ + public cloneControl(control: T): T { + let newControl: T + + if (control instanceof DhcpOptionFieldFormGroup) { + const formGroup = new DhcpOptionFieldFormGroup( + (control as DhcpOptionFieldFormGroup).data.fieldType, + {}, + control.validator, + control.asyncValidator + ) + + const controls = control.controls + + Object.keys(controls).forEach((key) => { + formGroup.addControl(key, this.cloneControl(controls[key])) + }) + + newControl = formGroup as any + } else if (control instanceof FormGroup) { + const formGroup = new FormGroup({}, control.validator, control.asyncValidator) + const controls = control.controls + + Object.keys(controls).forEach((key) => { + formGroup.addControl(key, this.cloneControl(controls[key])) + }) + + newControl = formGroup as any + } else if (control instanceof FormArray) { + const formArray = new FormArray([], control.validator, control.asyncValidator) + + control.controls.forEach((formControl) => formArray.push(this.cloneControl(formControl))) + + newControl = formArray as any + } else if (control instanceof FormControl) { + newControl = new FormControl(control.value, control.validator, control.asyncValidator) as any + } else { + throw new Error('Error: unexpected control value') + } + + if (control.disabled) { + newControl.disable({ emitEvent: false }) + } + + return newControl + } + + /** + * Implements convertion of the DHCP options from the reactive form to + * the REST API format. + * + * @param universe options universe (i.e., IPv4 or IPv6). + * @param nestingLevel nesting level of the currently processed options. + * @param formArray form array containing the options. + * @returns options in the REST API format. + * @throw An error for nesting level higher than 1 or if option data is invalid + * or missing. + */ + private _convertFormToOptions( + universe: IPType, + formArray: FormArray, + nestingLevel: number, + optionSpace?: string + ): Array { + // To avoid too much recursion, we only parse first level of suboptions. + if (formArray.length > 0 && nestingLevel > 1) { + throw new Error('options serialization supports up to two nesting levels') + } + let serialized = new Array() + for (let o of formArray.controls) { + const option = o as FormGroup + // Option code is mandatory. + if (!option.contains('optionCode') || option.get('optionCode').value === null) { + throw new Error('form group does not contain control with an option code') + } + let optionCode = 0 + if (typeof option.get('optionCode').value === 'string') { + optionCode = parseInt(option.get('optionCode').value, 10) + if (isNaN(optionCode)) { + throw new Error(`specified option code ${option.get('optionCode').value} is not a valid number`) + } + } else { + optionCode = option.get('optionCode').value + } + const item: DHCPOption = { + alwaysSend: option.get('alwaysSend').value, + code: optionCode, + encapsulate: '', + fields: new Array(), + universe: universe, + options: new Array(), + } + const optionFieldsArray = option.get('optionFields') as FormArray + // Option fields are not mandatory. It is possible to have an empty option. + if (optionFieldsArray) { + for (const f of optionFieldsArray.controls) { + const field = f as DhcpOptionFieldFormGroup + let values: Array = [] + switch (field.data.fieldType) { + case DhcpOptionFieldType.Bool: + if (!field.contains('control')) { + throw new Error(field.data.fieldType + ' option field must contain control') + } + let value = field.get('control').value.toString() + if (value.length === 0) { + value = 'false' + } + values = [value] + break + case DhcpOptionFieldType.IPv6Prefix: + // IPv6 prefix field contains a prefix and length. + if (!field.contains('prefix') || !field.contains('prefixLength')) { + throw new Error( + 'IPv6 prefix option field must contain prefix and prefixLength controls' + ) + } + values = [field.get('prefix').value.trim(), field.get('prefixLength').value.toString()] + break + case DhcpOptionFieldType.Psid: + // PSID field contains PSID and PSID length. + if (!field.contains('psid') || !field.contains('psidLength')) { + throw new Error('psid option field must contain psid and psidLength controls') + } + values = [field.get('psid').value.toString(), field.get('psidLength').value.toString()] + break + default: + // Other fields contain a single value. + if (!field.contains('control')) { + throw new Error(field.data.fieldType + ' option field must contain control') + } + values = [field.get('control').value.toString().trim()] + break + } + item.fields.push({ + fieldType: field.data.fieldType, + values: values, + }) + } + } + const suboptions = option.get('suboptions') as FormArray + // Suboptions are not mandatory. + if (suboptions && suboptions.length > 0) { + item.encapsulate = optionSpace ? `${optionSpace}.${item.code}` : `option-${item.code}` + item.options = this._convertFormToOptions(universe, suboptions, nestingLevel + 1, item.encapsulate) + } + // Done extracting an option. + serialized.push(item) + } + return serialized + } + + /** + * Converts top-level DHCP options with suboptions contained in the reactive form + * to the REST API format. + * + * @param universe options universe (i.e., IPv4 or IPv6). + * @param formArray form array containing the options. + * @returns options in the REST API format. + */ + public convertFormToOptions(universe: IPType, formArray: FormArray): Array { + return this._convertFormToOptions(universe, formArray, 0) + } + + /** + * Implements conversion of the DHCP options from the REST API format to a reactive form. + * + * @param universe options universe (i.e., IPv4 or IPv6). + * @param nestingLevel nesting level of the currently processed options. + * @param options a set of DHCP options at certain nesting level. + * @returns form array comprising converted options. + * @throw an error when parsed option field contain an invalid number of + * values. Typically, they contain a single value. They contain two values + * when they are IPv6 prefixes or PSIDs. + */ + private _convertOptionsToForm(universe: IPType, nestingLevel: number, options: Array): FormArray { + // To avoid too much recursion, we only convert first level of suboptions. + if (options?.length > 0 && nestingLevel > 1) { + throw new Error('options serialization supports up to two nesting levels') + } + let formArray = this._formBuilder.array([]) + if (!options || options.length === 0) { + return formArray + } + for (let option of options) { + let optionFormGroup = createDefaultDhcpOptionFormGroup(universe) + if (!isNaN(option.code)) { + optionFormGroup.get('optionCode').setValue(option.code) + } + if (option.alwaysSend) { + optionFormGroup.get('alwaysSend').setValue(option.alwaysSend) + } + for (let field of option.fields) { + // Sanity check option field values. + if ( + field.fieldType === DhcpOptionFieldType.IPv6Prefix || + field.fieldType === DhcpOptionFieldType.Psid + ) { + if (field.values.length !== 2) { + throw new Error(`expected two option field values for the option field type ${field.fieldType}`) + } + } else if (field.values.length !== 1) { + throw new Error(`expected one option field value for the option field type ${field.fieldType}`) + } + // For each option field create an appropriate form group. + let fieldGroup: DhcpOptionFieldFormGroup + switch (field.fieldType as DhcpOptionFieldType) { + case DhcpOptionFieldType.HexBytes: + fieldGroup = this.createHexBytesField(field.values[0]) + break + case DhcpOptionFieldType.String: + fieldGroup = this.createStringField(field.values[0]) + break + case DhcpOptionFieldType.Bool: + fieldGroup = this.createBoolField(field.values[0]) + break + case DhcpOptionFieldType.Uint8: + fieldGroup = this.createUint8Field(field.values[0]) + break + case DhcpOptionFieldType.Uint16: + fieldGroup = this.createUint16Field(field.values[0]) + break + case DhcpOptionFieldType.Uint32: + fieldGroup = this.createUint32Field(field.values[0]) + break + case DhcpOptionFieldType.IPv4Address: + fieldGroup = this.createIPv4AddressField(field.values[0]) + break + case DhcpOptionFieldType.IPv6Address: + fieldGroup = this.createIPv6AddressField(field.values[0]) + break + case DhcpOptionFieldType.IPv6Prefix: + fieldGroup = this.createIPv6PrefixField(field.values[0], field.values[1]) + break + case DhcpOptionFieldType.Psid: + fieldGroup = this.createPsidField(field.values[0], field.values[1]) + break + case DhcpOptionFieldType.Fqdn: + fieldGroup = this.createFqdnField(field.values[0]) + break + default: + continue + } + ;(optionFormGroup.get('optionFields') as FormArray).push(fieldGroup) + } + if (option.options?.length > 0) { + optionFormGroup.setControl( + 'suboptions', + this._convertOptionsToForm(universe, nestingLevel + 1, option.options) + ) + } + formArray.push(optionFormGroup) + } + return formArray + } + + /** + * Converts DHCP options from the REST API format to the reactive form. + * + * @param universe options universe (i.e., IPv4 or IPv6). + * @param options a set of DHCP options at certain nesting level. + * @returns form array comprising converted options. + */ + public convertOptionsToForm(universe: IPType, options: Array): FormArray { + return this._convertOptionsToForm(universe, 0, options) + } + + /** + * Creates a form group instance comprising one control representing + * an option field. + * + * It is called for option fields which require a single control, + * e.g. a string option fields. + * + * @param fieldType DHCP option field type. + * @param control a control associated with the option field. + * @returns created form group instance. + */ + private _createSimpleField(fieldType: DhcpOptionFieldType, control: FormControl): DhcpOptionFieldFormGroup { + return new DhcpOptionFieldFormGroup(fieldType, { control: control }) + } + + /** + * Creates a form group instance comprising multiple controls representing + * an option field. + * + * It is called for the option fields which require multiple controls, + * e.g. delegated prefix option field requires an input for the prefix + * and another input for the prefix length. + * + * @param fieldType DHCP option field type. + * @param controls the controls associated with the option field. + * @returns created form group instance. + */ + private _createComplexField( + fieldType: DhcpOptionFieldType, + controls: { [key: string]: AbstractControl } + ): DhcpOptionFieldFormGroup { + return new DhcpOptionFieldFormGroup(fieldType, controls) + } + + /** + * Creates a control for option field using hex-bytes format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createHexBytesField(value: string = ''): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.HexBytes, + this._formBuilder.control(value, StorkValidators.hexIdentifier()) + ) + } + + /** + * Creates a control for option field using string format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createStringField(value: string = ''): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.String, + this._formBuilder.control(value, Validators.required) + ) + } + + /** + * Creates a control for option field using boolean format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createBoolField(value: string | boolean = false): DhcpOptionFieldFormGroup { + let boolValue = false + switch (value) { + case 'true': + case 'TRUE': + case true: + boolValue = true + break + default: + break + } + return this._createSimpleField(DhcpOptionFieldType.Bool, this._formBuilder.control(boolValue)) + } + + /** + * Creates a control for option field using uint8 format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createUint8Field(value: string | number | null = null): DhcpOptionFieldFormGroup { + return this._createSimpleField(DhcpOptionFieldType.Uint8, this._formBuilder.control(value, Validators.required)) + } + + /** + * Creates a control for option field using uint16 format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createUint16Field(value: string | number | null = null): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.Uint16, + this._formBuilder.control(value, Validators.required) + ) + } + + /** + * Creates a control for option field using uint32 format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createUint32Field(value: string | number | null = null): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.Uint32, + this._formBuilder.control(value, Validators.required) + ) + } + + /** + * Creates a control for option field using IPv4 address format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createIPv4AddressField(value: string = ''): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.IPv4Address, + this._formBuilder.control(value, [Validators.required, StorkValidators.ipv4()]) + ) + } + + /** + * Creates a control for option field using IPv6 address format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createIPv6AddressField(value: string = ''): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.IPv6Address, + this._formBuilder.control(value, [Validators.required, StorkValidators.ipv6()]) + ) + } + + /** + * Creates a control for option field using IPv6 prefix format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createIPv6PrefixField(prefix: string = '', prefixLen: string | number | null = 64): DhcpOptionFieldFormGroup { + return this._createComplexField(DhcpOptionFieldType.IPv6Prefix, { + prefix: this._formBuilder.control(prefix, [Validators.required, StorkValidators.ipv6()]), + prefixLength: this._formBuilder.control(prefixLen, Validators.required), + }) + } + + /** + * Creates a control for option field using PSID format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createPsidField( + psid: string | number | null = null, + psidLen: string | number | null = null + ): DhcpOptionFieldFormGroup { + return this._createComplexField(DhcpOptionFieldType.Psid, { + psid: this._formBuilder.control(psid, Validators.required), + psidLength: this._formBuilder.control(psidLen, Validators.required), + }) + } + + /** + * Creates a control for option field using FQDN format. + * + * @param value option field value to set. + * @returns created form group instance. + */ + createFqdnField(value: string = ''): DhcpOptionFieldFormGroup { + return this._createSimpleField( + DhcpOptionFieldType.Fqdn, + this._formBuilder.control(value, [Validators.required, StorkValidators.fullFqdn]) + ) + } +} diff --git a/webui/src/app/forms/dhcp-option-set-form.spec.ts b/webui/src/app/forms/dhcp-option-set-form.spec.ts deleted file mode 100644 index 6d1dadc972e4c281a1eedb068106bc9ca847aed6..0000000000000000000000000000000000000000 --- a/webui/src/app/forms/dhcp-option-set-form.spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { FormBuilder } from '@angular/forms' -import { DhcpOptionFieldFormGroup, DhcpOptionFieldType } from './dhcp-option-field' -import { DhcpOptionSetForm } from './dhcp-option-set-form' -import { IPType } from '../iptype' - -describe('DhcpOptionSetForm', () => { - let formBuilder: FormBuilder = new FormBuilder() - - it('serializes an option set', () => { - // Add a form with three options. - const formArray = formBuilder.array([ - formBuilder.group({ - alwaysSend: formBuilder.control(true), - optionCode: formBuilder.control(1024), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { - prefix: formBuilder.control('3000::'), - prefixLength: formBuilder.control(64), - }), - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { - psid: formBuilder.control(12), - psidLength: formBuilder.control(8), - }), - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.HexBytes, { - control: formBuilder.control('01:02:03'), - }), - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.String, { - control: formBuilder.control('foobar'), - }), - ]), - }), - formBuilder.group({ - alwaysSend: formBuilder.control(false), - optionCode: formBuilder.control(2024), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint8, { - control: formBuilder.control(101), - }), - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint16, { - control: formBuilder.control(16523), - }), - ]), - }), - formBuilder.group({ - alwaysSend: formBuilder.control(true), - optionCode: formBuilder.control(3087), - suboptions: formBuilder.array([ - formBuilder.group({ - alwaysSend: formBuilder.control(false), - optionCode: formBuilder.control(1), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint16, { - control: formBuilder.control(1111), - }), - ]), - }), - formBuilder.group({ - alwaysSend: formBuilder.control(false), - optionCode: formBuilder.control(0), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint32, { - control: formBuilder.control(2222), - }), - ]), - }), - ]), - }), - ]) - - // Extract the options from the form and make sure there are - // three of them. - const options = new DhcpOptionSetForm(formArray) - options.process(IPType.IPv4) - const serialized = options.getSerializedOptions() - expect(serialized.length).toBe(3) - - expect(serialized[0].hasOwnProperty('alwaysSend')).toBeTrue() - expect(serialized[0].hasOwnProperty('code')).toBeTrue() - expect(serialized[0].hasOwnProperty('encapsulate')).toBeTrue() - expect(serialized[0].hasOwnProperty('fields')).toBeTrue() - expect(serialized[0].hasOwnProperty('options')).toBeTrue() - expect(serialized[0].alwaysSend).toBeTrue() - expect(serialized[0].code).toBe(1024) - expect(serialized[0].encapsulate.length).toBe(0) - // It should have 4 option fields of different types. - expect(serialized[0].fields.length).toBe(4) - expect(serialized[0].fields[0].fieldType).toBe(DhcpOptionFieldType.IPv6Prefix) - expect(serialized[0].fields[0].values.length).toBe(2) - expect(serialized[0].fields[0].values[0]).toBe('3000::') - expect(serialized[0].fields[0].values[1]).toBe('64') - expect(serialized[0].fields[1].fieldType).toBe(DhcpOptionFieldType.Psid) - expect(serialized[0].fields[1].values[0]).toBe('12') - expect(serialized[0].fields[1].values[1]).toBe('8') - expect(serialized[0].fields[1].values.length).toBe(2) - expect(serialized[0].fields[2].fieldType).toBe(DhcpOptionFieldType.HexBytes) - expect(serialized[0].fields[2].values.length).toBe(1) - expect(serialized[0].fields[2].values[0]).toBe('01:02:03') - expect(serialized[0].fields[3].fieldType).toBe(DhcpOptionFieldType.String) - expect(serialized[0].fields[3].values.length).toBe(1) - expect(serialized[0].fields[3].values[0]).toBe('foobar') - - expect(serialized[1].hasOwnProperty('alwaysSend')).toBeTrue() - expect(serialized[1].hasOwnProperty('code')).toBeTrue() - expect(serialized[1].hasOwnProperty('encapsulate')).toBeTrue() - expect(serialized[1].hasOwnProperty('fields')).toBeTrue() - expect(serialized[1].hasOwnProperty('options')).toBeTrue() - expect(serialized[1].alwaysSend).toBeFalse() - expect(serialized[1].code).toBe(2024) - expect(serialized[1].encapsulate.length).toBe(0) - expect(serialized[1].fields.length).toBe(2) - expect(serialized[1].fields[0].values.length).toBe(1) - expect(serialized[1].fields[0].values[0]).toBe('101') - expect(serialized[1].fields[1].values.length).toBe(1) - expect(serialized[1].fields[1].values[0]).toBe('16523') - expect(serialized[1].hasOwnProperty('options')).toBeTrue() - - expect(serialized[2].hasOwnProperty('alwaysSend')).toBeTrue() - expect(serialized[2].hasOwnProperty('code')).toBeTrue() - expect(serialized[2].hasOwnProperty('encapsulate')).toBeTrue() - expect(serialized[2].hasOwnProperty('fields')).toBeTrue() - expect(serialized[2].hasOwnProperty('options')).toBeTrue() - expect(serialized[2].alwaysSend).toBeTrue() - expect(serialized[2].code).toBe(3087) - expect(serialized[2].encapsulate).toBe('option-3087') - expect(serialized[2].fields.length).toBe(0) - // The option should contain a suboptions - expect(serialized[2].options.length).toBe(2) - expect(serialized[2].options[0].hasOwnProperty('code')).toBeTrue() - expect(serialized[2].options[0].hasOwnProperty('encapsulate')).toBeTrue() - expect(serialized[2].options[0].hasOwnProperty('fields')).toBeTrue() - expect(serialized[2].options[0].hasOwnProperty('options')).toBeTrue() - expect(serialized[2].options[0].code).toBe(1) - expect(serialized[2].options[0].encapsulate.length).toBe(0) - expect(serialized[2].options[0].fields.length).toBe(1) - expect(serialized[2].options[0].fields[0].fieldType).toBe(DhcpOptionFieldType.Uint16) - expect(serialized[2].options[0].options.length).toBe(0) - expect(serialized[2].options[1].hasOwnProperty('code')).toBeTrue() - expect(serialized[2].options[1].hasOwnProperty('encapsulate')).toBeTrue() - expect(serialized[2].options[1].hasOwnProperty('fields')).toBeTrue() - expect(serialized[2].options[1].hasOwnProperty('options')).toBeTrue() - expect(serialized[2].options[1].code).toBe(0) - expect(serialized[2].options[1].encapsulate.length).toBe(0) - expect(serialized[2].options[1].fields.length).toBe(1) - expect(serialized[2].options[1].fields[0].fieldType).toBe(DhcpOptionFieldType.Uint32) - expect(serialized[2].options[1].options.length).toBe(0) - }) - - it('throws on too much recursion', () => { - // Add an option with three nesting levels. It should throw because - // we merely support first level suboptions. - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(1024), - optionFields: formBuilder.array([]), - suboptions: formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(1), - optionFields: formBuilder.array([]), - suboptions: formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(2), - optionFields: formBuilder.array([]), - suboptions: formBuilder.array([]), - }), - ]), - }), - ]), - }), - ]) - - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when there is no option code', () => { - const formArray = formBuilder.array([formBuilder.group({})]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when prefix field lacks prefix control', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { - prefixLength: formBuilder.control(64), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when prefix field lacks prefix control', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Prefix, { - prefix: formBuilder.control('3000::'), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when psid field lacks psid control', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { - psidLength: formBuilder.control(8), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when psid field lacks psid length control', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Psid, { - psid: formBuilder.control(100), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when a single value field lacks control', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint8, { - psid: formBuilder.control(100), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.process(IPType.IPv4)).toThrow() - }) - - it('throws when options have not been processed', () => { - const formArray = formBuilder.array([ - formBuilder.group({ - optionCode: formBuilder.control(3087), - optionFields: formBuilder.array([ - new DhcpOptionFieldFormGroup(DhcpOptionFieldType.Uint8, { - control: formBuilder.control(100), - }), - ]), - }), - ]) - const options = new DhcpOptionSetForm(formArray) - expect(() => options.getSerializedOptions()).toThrow() - }) -}) diff --git a/webui/src/app/forms/dhcp-option-set-form.ts b/webui/src/app/forms/dhcp-option-set-form.ts deleted file mode 100644 index 5d2bdcbed1b6f450f3834ec3ec3d8aee4ced0bcb..0000000000000000000000000000000000000000 --- a/webui/src/app/forms/dhcp-option-set-form.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { FormArray, FormGroup } from '@angular/forms' -import { DhcpOptionFieldFormGroup, DhcpOptionFieldType } from './dhcp-option-field' -import { IPType } from '../iptype' -import { DHCPOption } from '../backend/model/dHCPOption' - -/** - * A class processing DHCP options forms. - * - * The main purpose of this class is to extract the DHCP options - * from the FormArray object. The FormArray must contain a - * collection of the DhcpOptionFieldFormGroup objects, each - * representing a single option. These options may contain - * suboptions. - * - * @todo Extend this class to detect option definitions from the - * specified options. - */ -export class DhcpOptionSetForm { - /** - * Extracted DHCP options into the REST API format. - */ - private _serializedOptions: DHCPOption[] - - /** - * Constructor. - * - * @param _formArray input form array holding DHCP options. - */ - constructor(private _formArray: FormArray) {} - - /** - * DHCP options form processing implementation. - * - * @param universe options universe (i.e., IPv4 or IPv6). - * @param nestingLevel nesting level of the currently processed options. - * Its value is 0 for top-level options, 1 for top-level option suboptions etc. - * @param optionSpace option space encapsulated by a parent option. - * @throw An error for nesting level higher than 1 or if option data is invalid - * or missing. - */ - private _process(universe: IPType, nestingLevel: number, optionSpace?: string) { - // To avoid too much recursion, we only parse first level of suboptions. - if (this._formArray.length > 0 && nestingLevel > 1) { - throw new Error('options serialization supports up to two nesting levels') - } - const serialized = [] - for (let o of this._formArray.controls) { - const option = o as FormGroup - // Option code is mandatory. - if (!option.contains('optionCode') || option.get('optionCode').value === null) { - throw new Error('form group does not contain control with an option code') - } - let optionCode = 0 - if (typeof option.get('optionCode').value === 'string') { - optionCode = parseInt(option.get('optionCode').value, 10) - if (isNaN(optionCode)) { - throw new Error(`specified option code ${option.get('optionCode').value} is not a valid number`) - } - } else { - optionCode = option.get('optionCode').value - } - - const item = { - alwaysSend: option.get('alwaysSend').value, - code: optionCode, - encapsulate: '', - fields: [], - universe: universe, - options: [], - } - const optionFieldsArray = option.get('optionFields') as FormArray - // Option fields are not mandatory. It is possible to have an empty option. - if (optionFieldsArray) { - for (const f of optionFieldsArray.controls) { - const field = f as DhcpOptionFieldFormGroup - let values: unknown[] - switch (field.data.fieldType) { - case DhcpOptionFieldType.Bool: - if (!field.contains('control')) { - throw new Error(field.data.fieldType + ' option field must contain control') - } - let value = field.get('control').value.toString() - if (value.length === 0) { - value = 'false' - } - values = [value] - break - case DhcpOptionFieldType.IPv6Prefix: - // IPv6 prefix field contains a prefix and length. - if (!field.contains('prefix') || !field.contains('prefixLength')) { - throw new Error( - 'IPv6 prefix option field must contain prefix and prefixLength controls' - ) - } - values = [field.get('prefix').value.trim(), field.get('prefixLength').value.toString()] - break - case DhcpOptionFieldType.Psid: - // PSID field contains PSID and PSID length. - if (!field.contains('psid') || !field.contains('psidLength')) { - throw new Error('psid option field must contain psid and psidLength controls') - } - values = [field.get('psid').value.toString(), field.get('psidLength').value.toString()] - break - default: - // Other fields contain a single value. - if (!field.contains('control')) { - throw new Error(field.data.fieldType + ' option field must contain control') - } - values = [field.get('control').value.toString().trim()] - break - } - item.fields.push({ - fieldType: field.data.fieldType, - values: values, - }) - } - } - const suboptions = option.get('suboptions') as FormArray - // Suboptions are not mandatory. - if (suboptions && suboptions.length > 0) { - item.encapsulate = optionSpace ? `${optionSpace}.${item.code}` : `option-${item.code}` - const suboptionsForm = new DhcpOptionSetForm(suboptions) - suboptionsForm._process(universe, nestingLevel + 1, item.encapsulate) - item.options = suboptionsForm.getSerializedOptions() - } - // Done extracting an option. - serialized.push(item) - } - // All options extracted. Save the result. - this._serializedOptions = serialized - } - - /** - * Processes top-level DHCP options with their suboptions. - * - * The result of the processing can be retrieved with getSerializedOptions(). - * - * @param universe options universe (i.e., IPv4 or IPv6). - */ - process(universe: IPType) { - this._process(universe, 0) - } - - /** - * Returns serialized DHCP options. - * - * @returns serialized options (in the REST API format). - * @throws an error when process() function hasn't been called. - */ - getSerializedOptions(): DHCPOption[] { - if (!this._serializedOptions) { - throw new Error('options form has not been processed') - } - return this._serializedOptions - } -} diff --git a/webui/src/app/forms/host-form.spec.ts b/webui/src/app/forms/host-form.spec.ts index 22df58c8c527df64de7ebe537c2e37a485909f54..2178efcf76d309bf52142387e55e75ccb00295ad 100644 --- a/webui/src/app/forms/host-form.spec.ts +++ b/webui/src/app/forms/host-form.spec.ts @@ -13,6 +13,129 @@ describe('HostForm', () => { }) }) + it('Returns daemon by ID', () => { + form.allDaemons = [ + { + id: 1, + appId: 1, + appType: 'kea', + name: 'dhcp4', + label: 'server1', + }, + { + id: 2, + appId: 3, + appType: 'bind9', + name: 'named', + label: 'server2', + }, + ] + + let daemon = form.getDaemonById(1) + expect(daemon).toBeTruthy() + expect(daemon.id).toBe(1) + expect(daemon.appId).toBe(1) + expect(daemon.name).toBe('dhcp4') + expect(daemon.label).toBe('server1') + + daemon = form.getDaemonById(2) + expect(daemon).toBeTruthy() + expect(daemon.id).toBe(2) + expect(daemon.appId).toBe(3) + expect(daemon.name).toBe('named') + expect(daemon.label).toBe('server2') + + expect(form.getDaemonById(3)).toBeFalsy() + }) + + it('Correctly updates form for selected daemons', () => { + form.allDaemons = [ + { + id: 1, + appId: 1, + appType: 'kea', + name: 'dhcp4', + label: 'server1', + }, + { + id: 2, + appId: 1, + appType: 'kea', + name: 'dhcp6', + label: 'server2', + }, + { + id: 3, + appId: 2, + appType: 'kea', + name: 'dhcp4', + label: 'server3', + }, + { + id: 4, + appId: 2, + appType: 'kea', + name: 'dhcp6', + label: 'server4', + }, + ] + // Select a DHCPv4 daemon. It is not a breaking change because + // DHCPv4 options are displayed by default. + let breakingChange = form.updateFormForSelectedDaemons([1]) + expect(breakingChange).toBeFalse() + expect(form.filteredDaemons.length).toBe(2) + expect(form.filteredDaemons[0].id).toBe(1) + expect(form.filteredDaemons[1].id).toBe(3) + + // Add another DHCPv4 daemon to our selection. It is not a breaking + // change because we were already in the DHCPv4 mode. + breakingChange = form.updateFormForSelectedDaemons([1, 3]) + expect(breakingChange).toBeFalse() + expect(form.filteredDaemons.length).toBe(2) + expect(form.filteredDaemons[0].id).toBe(1) + expect(form.filteredDaemons[1].id).toBe(3) + + // Reduce selected DHCPv4 daemons. It is not a breaking change. + breakingChange = form.updateFormForSelectedDaemons([3]) + expect(breakingChange).toBeFalse() + expect(form.filteredDaemons.length).toBe(2) + expect(form.filteredDaemons[0].id).toBe(1) + expect(form.filteredDaemons[1].id).toBe(3) + + // Unselect all daemons. It is a breaking change. + breakingChange = form.updateFormForSelectedDaemons([]) + expect(breakingChange).toBeTrue() + expect(form.filteredDaemons.length).toBe(4) + expect(form.filteredDaemons[0].id).toBe(1) + expect(form.filteredDaemons[1].id).toBe(2) + expect(form.filteredDaemons[2].id).toBe(3) + expect(form.filteredDaemons[3].id).toBe(4) + + // Select DHCPv6 daemon. It is a breaking change because by default + // we display DHCPv4 options. + breakingChange = form.updateFormForSelectedDaemons([2]) + expect(breakingChange).toBeTrue() + expect(form.filteredDaemons.length).toBe(2) + expect(form.filteredDaemons[0].id).toBe(2) + expect(form.filteredDaemons[1].id).toBe(4) + + // Select another DHCPv6 daemon. It is not a breaking change. + breakingChange = form.updateFormForSelectedDaemons([2]) + expect(breakingChange).toBeFalse() + expect(form.filteredDaemons.length).toBe(2) + expect(form.filteredDaemons[0].id).toBe(2) + expect(form.filteredDaemons[1].id).toBe(4) + + // Unselect DHCPv6 daemons. + breakingChange = form.updateFormForSelectedDaemons([]) + expect(breakingChange).toBeTrue() + expect(form.filteredDaemons.length).toBe(4) + expect(form.filteredDaemons[0].id).toBe(1) + expect(form.filteredDaemons[1].id).toBe(2) + expect(form.filteredDaemons[2].id).toBe(3) + expect(form.filteredDaemons[3].id).toBe(4) + }) + it('Returns correct ipv4 selected subnet range', () => { form.filteredSubnets = [ { diff --git a/webui/src/app/forms/host-form.ts b/webui/src/app/forms/host-form.ts index 57e698a35377a39e40808d11dc9d7477cd4cabcb..86dbf0f32de29be937ff191ca5cb0d3e9c9bb1d0 100644 --- a/webui/src/app/forms/host-form.ts +++ b/webui/src/app/forms/host-form.ts @@ -2,6 +2,7 @@ import { FormGroup } from '@angular/forms' import { IPv4CidrRange, IPv6CidrRange, Validator } from 'ip-num' import { KeaDaemon } from '../backend/model/keaDaemon' import { Subnet } from '../backend/model/subnet' +import { SelectableDaemon } from '../forms/selectable-daemon' import { IPType } from '../iptype' /** @@ -42,7 +43,7 @@ export class HostForm { /** * A list of all daemons that can be selected from the drop down list. */ - allDaemons: KeaDaemon[] + allDaemons: SelectableDaemon[] /** * A filtered list of daemons comprising only those that match the @@ -51,7 +52,7 @@ export class HostForm { * Maintaining a filtered list prevents the user from selecting the * servers of different kinds, e.g. one DHCPv4 and one DHCPv6 server. */ - filteredDaemons: KeaDaemon[] + filteredDaemons: SelectableDaemon[] /** * A list of subnets that can be selected from the drop down list. @@ -80,6 +81,62 @@ export class HostForm { */ dhcpv6: boolean = false + /** + * Returns a daemon having the specified ID. + * + * @param id daemon ID. + * @returns specified daemon or null if it doesn't exist. + */ + getDaemonById(id: number): SelectableDaemon | null { + return this.allDaemons.find((d) => d.id === id) + } + + /** + * Updates the form state according to the daemons selection. + * + * Depending on whether the user selected DHCPv4 or DHCPv6 servers, the + * list of filtered daemons must be updated to prevent selecting different + * daemon types. The boolean dhcpv4 and dhcpv6 flags also have to be tuned. + * Based on the impact on these flags, the function checks whether or not + * the new selection is a breaking change. Such a change requires that + * some states of the form must be reset. In particular, if a user already + * specified some options, the breaking change indicates that these options + * must be removed from the form because they may be in conflict with the + * new daemons selection. + * + * @param selectedDaemons new set of selected daemons' ids. + * @returns true if the update results in a breaking change, false otherwise. + */ + updateFormForSelectedDaemons(selectedDaemons: number[]): boolean { + let dhcpv6 = false + let dhcpv4 = selectedDaemons.some((ss) => { + return this.allDaemons.find((d) => d.id === ss && d.name === 'dhcp4') + }) + if (!dhcpv4) { + // If user selected no DHCPv4 server, perhaps selected a DHCPv6 server? + dhcpv6 = selectedDaemons.some((ss) => { + return this.allDaemons.find((d) => d.id === ss && d.name === 'dhcp6') + }) + } + // If user unselected DHCPv4 servers, unselected DHCPv6 servers or selected + // DHCPv6 servers, it is a breaking change. + let breakingChange = (this.dhcpv4 && !dhcpv4) || this.dhcpv6 !== dhcpv6 + + // Remember new states. + this.dhcpv4 = dhcpv4 + this.dhcpv6 = dhcpv6 + + // Filter selectable other selectable servers based on the current selection. + if (dhcpv4) { + this.filteredDaemons = this.allDaemons.filter((d) => d.name === 'dhcp4') + } else if (this.dhcpv6) { + this.filteredDaemons = this.allDaemons.filter((d) => d.name === 'dhcp6') + } else { + this.filteredDaemons = this.allDaemons + } + return breakingChange + } + /** * Returns an address range of a selected subnet. * diff --git a/webui/src/app/forms/selectable-daemon.ts b/webui/src/app/forms/selectable-daemon.ts new file mode 100644 index 0000000000000000000000000000000000000000..667ac61b18b066b2b0f63479656a1e2fa41da648 --- /dev/null +++ b/webui/src/app/forms/selectable-daemon.ts @@ -0,0 +1,34 @@ +/** + * An interface to a representation of a daemon that can be selected + * via a multi-select list. + */ +export interface SelectableDaemon { + /** + * Daemon ID. + */ + id: number + + /** + * App ID. + * + * It is used to construct the links to the apps. + */ + appId: number + + /** + * App type. + * + * It is used to construct the links to the apps. + */ + appType: string + + /** + * Daemon name. + */ + name: string + + /** + * Daemon label presented in the multi-select list. + */ + label: string +} diff --git a/webui/src/app/host-form/host-form.component.html b/webui/src/app/host-form/host-form.component.html index e5148c70079ff4f1cfb6f053fcf90c301455846e..d578f244f74f5d9724ede6a0395a72c02a7365b6 100644 --- a/webui/src/app/host-form/host-form.component.html +++ b/webui/src/app/host-form/host-form.component.html @@ -1,3 +1,9 @@ + +
+ +
Toggle editing DHCP options individually for each server.
+
+
@@ -267,19 +273,44 @@
- -
-
- -
-
-
+ + + + + DHCP Options +  /  + + {{ form.getDaemonById(selectedDaemons.value[i]).label }} + + + + +
+ +
+
+
+
-
+
+ +

diff --git a/webui/src/app/host-form/host-form.component.sass b/webui/src/app/host-form/host-form.component.sass index 4d8207b25a89bf604c312fb1644a8241e4ed2b53..b5f7f9c637c9236b72918a0a7e8e67ad84ce466c 100644 --- a/webui/src/app/host-form/host-form.component.sass +++ b/webui/src/app/host-form/host-form.component.sass @@ -3,3 +3,7 @@ ::ng-deep .full-width width: 100% + +::ng-deep .p-multiselect.p-multiselect-chip .p-multiselect-token + background: var(--primary-100) + color: var(--text-color) \ No newline at end of file diff --git a/webui/src/app/host-form/host-form.component.spec.ts b/webui/src/app/host-form/host-form.component.spec.ts index 563ac7648bf764c507d62a784359e2b19e899bec..dbfdbd4eea3a9b52a0c9bba8dd9fb74fd193e177 100644 --- a/webui/src/app/host-form/host-form.component.spec.ts +++ b/webui/src/app/host-form/host-form.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing' -import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { RouterTestingModule } from '@angular/router/testing' +import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms' import { HttpClientTestingModule } from '@angular/common/http/testing' import { By } from '@angular/platform-browser' import { NoopAnimationsModule } from '@angular/platform-browser/animations' @@ -10,6 +11,7 @@ import { CheckboxModule } from 'primeng/checkbox' import { DropdownModule } from 'primeng/dropdown' import { FieldsetModule } from 'primeng/fieldset' import { InputNumberModule } from 'primeng/inputnumber' +import { InputSwitchModule } from 'primeng/inputswitch' import { MessagesModule } from 'primeng/messages' import { MultiSelectModule } from 'primeng/multiselect' import { OverlayPanelModule } from 'primeng/overlaypanel' @@ -22,6 +24,7 @@ import { DhcpOptionFieldFormGroup, DhcpOptionFieldType } from '../forms/dhcp-opt import { HelpTipComponent } from '../help-tip/help-tip.component' import { HostForm } from '../forms/host-form' import { DHCPService } from '../backend' +import { Host } from '../backend/model/host' describe('HostFormComponent', () => { let component: HostFormComponent @@ -126,11 +129,13 @@ describe('HostFormComponent', () => { FormsModule, HttpClientTestingModule, InputNumberModule, + InputSwitchModule, MessagesModule, MultiSelectModule, NoopAnimationsModule, OverlayPanelModule, ReactiveFormsModule, + RouterTestingModule, SplitButtonModule, ToggleButtonModule, ], @@ -768,7 +773,7 @@ describe('HostFormComponent', () => { component.formGroup.get('hostIdGroup.idInputHex').setValue('01:02:03:04:05:06') component.ipGroups.at(0).get('inputIPv4').setValue('192.0.2.4') component.formGroup.get('hostname').setValue('example.org') - component.optionsArray.push( + component.getOptionSetArray(0).push( formBuilder.group({ optionCode: [5], alwaysSend: true, @@ -1001,6 +1006,125 @@ describe('HostFormComponent', () => { expect(messageService.add).toHaveBeenCalled() })) + it('should submit new dhcpv6 host with different opion sets', fakeAsync(() => { + spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(cannedResponseBegin)) + component.ngOnInit() + tick() + fixture.detectChanges() + + component.formGroup.get('selectedDaemons').setValue([4, 5]) + component.formGroup.get('globalReservation').setValue(true) + component.onDaemonsChange() + component.onSelectedSubnetChange() + fixture.detectChanges() + + component.addIPInput() + + component.formGroup.get('hostIdGroup.idType').setValue('flex-id') + component.formGroup.get('hostIdGroup.idFormat').setValue('text') + component.formGroup.get('hostIdGroup.idInputText').setValue(' foobar ') + + component.splitFormMode = true + component.onSplitModeChange() + fixture.detectChanges() + + expect(component.optionsArray.length).toBe(2) + + component.getOptionSetArray(0).push( + formBuilder.group({ + optionCode: [23], + alwaysSend: true, + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Address, { + control: formBuilder.control('2001:db8:1::1'), + }), + ]), + suboptions: formBuilder.array([]), + }) + ) + + component.getOptionSetArray(1).push( + formBuilder.group({ + optionCode: [23], + alwaysSend: true, + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv6Address, { + control: formBuilder.control('2001:db8:1::2'), + }), + ]), + suboptions: formBuilder.array([]), + }) + ) + + expect(component.formGroup.valid).toBeTrue() + + const okResp: any = { + status: 200, + } + spyOn(dhcpApi, 'createHostSubmit').and.returnValue(of(okResp)) + spyOn(component.formSubmit, 'emit') + spyOn(messageService, 'add') + component.onSubmit() + tick() + fixture.detectChanges() + + const host: any = { + subnetId: 0, + hostIdentifiers: [ + { + idType: 'flex-id', + idHexValue: '66:6f:6f:62:61:72', + }, + ], + addressReservations: [], + prefixReservations: [], + hostname: '', + localHosts: [ + { + daemonId: 4, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 23, + encapsulate: '', + fields: [ + { + fieldType: 'ipv6-address', + values: ['2001:db8:1::1'], + }, + ], + options: [], + universe: 6, + }, + ], + }, + { + daemonId: 5, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 23, + encapsulate: '', + fields: [ + { + fieldType: 'ipv6-address', + values: ['2001:db8:1::2'], + }, + ], + options: [], + universe: 6, + }, + ], + }, + ], + } + expect(dhcpApi.createHostSubmit).toHaveBeenCalledWith(component.form.transactionId, host) + expect(component.formSubmit.emit).toHaveBeenCalled() + expect(messageService.add).toHaveBeenCalled() + })) + it('should present an error message when processing options fails', fakeAsync(() => { spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(cannedResponseBegin)) component.ngOnInit() @@ -1011,9 +1135,9 @@ describe('HostFormComponent', () => { component.formGroup.get('selectedSubnet').setValue(1) component.formGroup.get('hostIdGroup.idInputHex').setValue('01:02:03:04:05:06') component.ipGroups.at(0).get('inputIPv4').setValue('192.0.2.4') - component.optionsArray.push( + component.getOptionSetArray(0).push( formBuilder.group({ - optionCode: [], + optionCode: ['abc'], alwaysSend: false, optionFields: formBuilder.array([ new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv4Address, { @@ -1080,4 +1204,350 @@ describe('HostFormComponent', () => { expect(optionsForm).toBeTruthy() expect(optionsForm.componentInstance.v6).toBeTrue() })) + + it('should enable split editing mode of options', fakeAsync(() => { + spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(cannedResponseBegin)) + component.ngOnInit() + tick() + fixture.detectChanges() + + component.splitFormMode = true + component.onSplitModeChange() + fixture.detectChanges() + + let optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(1) + + component.formGroup.get('selectedDaemons').setValue([2]) + component.onDaemonsChange() + fixture.detectChanges() + + optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(1) + + component.formGroup.get('selectedDaemons').setValue([2, 1]) + component.onDaemonsChange() + fixture.detectChanges() + + optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(2) + + expect(component.optionsArray.length).toBe(2) + expect((component.optionsArray.at(0) as FormArray).length).toBe(0) + expect((component.optionsArray.at(1) as FormArray).length).toBe(0) + + component.formGroup.get('selectedDaemons').setValue([1]) + component.onDaemonsChange() + fixture.detectChanges() + + optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(1) + + component.formGroup.get('selectedDaemons').setValue([]) + component.onDaemonsChange() + fixture.detectChanges() + + optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(1) + })) + + it('should toggle split editing mode of options', fakeAsync(() => { + spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(cannedResponseBegin)) + component.ngOnInit() + tick() + fixture.detectChanges() + + component.formGroup.get('selectedDaemons').setValue([3, 4, 5]) + component.onDaemonsChange() + fixture.detectChanges() + expect(component.optionsArray.length).toBe(1) + expect(component.formGroup.get('selectedDaemons').value.length).toBe(3) + + component.splitFormMode = true + component.onSplitModeChange() + fixture.detectChanges() + + let optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(3) + + const optionSetLeft = component.optionsArray.at(0) + component.splitFormMode = false + component.onSplitModeChange() + fixture.detectChanges() + + optionForms = fixture.debugElement.queryAll(By.css('app-dhcp-option-set-form')) + expect(optionForms).toBeTruthy() + expect(optionForms.length).toBe(1) + expect(component.optionsArray.at(0)).toBe(optionSetLeft) + })) + + it('should clone options upon switching to split mode', fakeAsync(() => { + spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(cannedResponseBegin)) + component.ngOnInit() + tick() + fixture.detectChanges() + + component.formGroup.get('selectedDaemons').setValue([1, 2]) + component.onDaemonsChange() + component.formGroup.get('selectedSubnet').setValue(1) + component.formGroup.get('hostIdGroup.idInputHex').setValue('01:02:03:04:05:06') + component.getOptionSetArray(0).push( + formBuilder.group({ + optionCode: [5], + alwaysSend: true, + optionFields: formBuilder.array([ + new DhcpOptionFieldFormGroup(DhcpOptionFieldType.IPv4Address, { + control: formBuilder.control('192.0.2.1'), + }), + ]), + suboptions: formBuilder.array([]), + }) + ) + fixture.detectChanges() + + component.splitFormMode = true + component.onSplitModeChange() + fixture.detectChanges() + + expect(component.formGroup.valid).toBeTrue() + expect(component.optionsArray.length).toBe(2) + + expect(component.getOptionSetArray(0).get('0.optionCode')).toBeTruthy() + expect(component.getOptionSetArray(1).get('0.optionCode')).toBeTruthy() + expect(component.getOptionSetArray(0).get('0.optionCode').value).toBe(5) + expect(component.getOptionSetArray(1).get('0.optionCode').value).toBe(5) + })) + + it('should open a form for editing dhcpv4 host', fakeAsync(() => { + component.hostId = 123 + + let beginResponse = cannedResponseBegin + beginResponse.host = { + id: 123, + subnetId: 1, + subnetPrefix: '192.0.2.0/24', + hostIdentifiers: [ + { + idType: 'hw-address', + idHexValue: '01:02:03:04:05:06', + }, + ], + addressReservations: [ + { + address: '192.0.2.4', + }, + ], + prefixReservations: [], + hostname: 'foo.example.org', + localHosts: [ + { + daemonId: 1, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 5, + encapsulate: '', + fields: [ + { + fieldType: 'ipv4-address', + values: ['192.0.2.1'], + }, + ], + options: [], + universe: 4, + }, + ], + optionsHash: '123', + }, + { + daemonId: 2, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 5, + encapsulate: '', + fields: [ + { + fieldType: 'ipv4-address', + values: ['192.0.2.2'], + }, + ], + options: [], + universe: 4, + }, + ], + optionsHash: '234', + }, + ], + } + spyOn(dhcpApi, 'updateHostBegin').and.returnValue(of(beginResponse)) + component.ngOnInit() + tick() + fixture.detectChanges() + + expect(dhcpApi.updateHostBegin).toHaveBeenCalled() + expect(component.formGroup.valid).toBeTrue() + expect(component.splitFormMode).toBeTrue() + expect(component.formGroup.get('globalReservation').value).toBeFalse() + expect(component.formGroup.get('selectedDaemons').value.length).toBe(2) + expect(component.formGroup.get('selectedSubnet').value).toBe(1) + expect(component.ipGroups.length).toBe(1) + expect(component.ipGroups.get('0.inputIPv4').value).toBe('192.0.2.4') + expect(component.formGroup.get('hostname').value).toBe('foo.example.org') + expect(component.optionsArray.length).toBe(2) + expect(component.getOptionSetArray(0).length).toBe(1) + expect(component.getOptionSetArray(0).get('0.alwaysSend').value).toBeTrue() + expect(component.getOptionSetArray(0).get('0.optionCode').value).toBe(5) + let optionFields = component.getOptionSetArray(0).get('0.optionFields') as FormArray + expect(optionFields.length).toBe(1) + expect(optionFields.get('0.control').value).toBe('192.0.2.1') + expect(component.getOptionSetArray(1).length).toBe(1) + expect(component.getOptionSetArray(1).get('0.alwaysSend').value).toBeTrue() + expect(component.getOptionSetArray(1).get('0.optionCode').value).toBe(5) + optionFields = component.getOptionSetArray(1).get('0.optionFields') as FormArray + expect(optionFields.length).toBe(1) + expect(optionFields.get('0.control').value).toBe('192.0.2.2') + + const okResp: any = { + status: 200, + } + spyOn(dhcpApi, 'updateHostSubmit').and.returnValue(of(okResp)) + spyOn(component.formSubmit, 'emit') + spyOn(messageService, 'add') + component.onSubmit() + tick() + fixture.detectChanges() + + let host = { + id: 123, + subnetId: 1, + hostIdentifiers: [ + { + idType: 'hw-address', + idHexValue: '01:02:03:04:05:06', + }, + ], + addressReservations: [ + { + address: '192.0.2.4/32', + }, + ], + prefixReservations: [], + hostname: 'foo.example.org', + localHosts: [ + { + daemonId: 1, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 5, + encapsulate: '', + fields: [ + { + fieldType: 'ipv4-address', + values: ['192.0.2.1'], + }, + ], + options: [], + universe: 4, + }, + ], + }, + { + daemonId: 2, + dataSource: 'api', + options: [ + { + alwaysSend: true, + code: 5, + encapsulate: '', + fields: [ + { + fieldType: 'ipv4-address', + values: ['192.0.2.2'], + }, + ], + options: [], + universe: 4, + }, + ], + }, + ], + } + expect(dhcpApi.updateHostSubmit).toHaveBeenCalledWith(component.hostId, component.form.transactionId, host) + expect(component.formSubmit.emit).toHaveBeenCalled() + expect(messageService.add).toHaveBeenCalled() + })) + + it('should revert host changes', fakeAsync(() => { + component.hostId = 123 + + let beginResponse = cannedResponseBegin + beginResponse.host = { + id: 123, + subnetId: 1, + subnetPrefix: '192.0.2.0/24', + hostIdentifiers: [ + { + idType: 'hw-address', + idHexValue: '01:02:03:04:05:06', + }, + ], + addressReservations: [ + { + address: '192.0.2.4', + }, + ], + prefixReservations: [], + hostname: 'foo.example.org', + localHosts: [ + { + daemonId: 1, + dataSource: 'api', + options: [], + optionsHash: '', + }, + ], + } + spyOn(dhcpApi, 'updateHostBegin').and.returnValue(of(beginResponse)) + component.ngOnInit() + tick() + fixture.detectChanges() + + expect(component.ipGroups.length).toBe(1) + expect(component.ipGroups.get('0.inputIPv4').value).toBe('192.0.2.4') + expect(component.formGroup.get('hostname').value).toBe('foo.example.org') + expect(component.formGroup.valid).toBeTrue() + + // Apply some changes. + component.ipGroups.get('0.inputIPv4').setValue('192.0.') + component.formGroup.get('hostname').setValue('xyz') + fixture.detectChanges() + expect(component.formGroup.valid).toBeFalse() + + // Revert the changes. + component.onRevert() + fixture.detectChanges() + + // Ensure that the changes have been reverted. + expect(component.ipGroups.length).toBe(1) + expect(component.ipGroups.get('0.inputIPv4').value).toBe('192.0.2.4') + expect(component.formGroup.get('hostname').value).toBe('foo.example.org') + expect(component.formGroup.valid).toBeTrue() + })) + + it('should emit cancel event', () => { + spyOn(component.formCancel, 'emit') + component.onCancel() + expect(component.formCancel.emit).toHaveBeenCalled() + }) }) diff --git a/webui/src/app/host-form/host-form.component.ts b/webui/src/app/host-form/host-form.component.ts index 5869037e25c9db47534dc1d0785020d9769d6717..32acaafb681548fa112331d65382db044b81103d 100644 --- a/webui/src/app/host-form/host-form.component.ts +++ b/webui/src/app/host-form/host-form.component.ts @@ -13,13 +13,18 @@ import { map } from 'rxjs/operators' import { collapseIPv6Number, isIPv4, IPv4, IPv4CidrRange, IPv6, IPv6CidrRange, Validator } from 'ip-num' import { StorkValidators } from '../validators' import { DHCPService } from '../backend/api/api' +import { CreateHostBeginResponse } from '../backend/model/createHostBeginResponse' +import { DHCPOption } from '../backend/model/dHCPOption' import { Host } from '../backend/model/host' import { IPReservation } from '../backend/model/iPReservation' +import { KeaDaemon } from '../backend/model/keaDaemon' import { LocalHost } from '../backend/model/localHost' +import { UpdateHostBeginResponse } from '../backend/model/updateHostBeginResponse' import { Subnet } from '../backend/model/subnet' import { HostForm } from '../forms/host-form' import { createDefaultDhcpOptionFormGroup } from '../forms/dhcp-option-form' -import { DhcpOptionSetForm } from '../forms/dhcp-option-set-form' +import { DhcpOptionSetFormService } from '../forms/dhcp-option-set-form.service' +import { SelectableDaemon } from '../forms/selectable-daemon' import { IPType } from '../iptype' import { stringToHex } from '../utils' @@ -141,6 +146,24 @@ function addressInSubnetValidator(ipType: IPType, hostForm: HostForm): Validator } } +/** + * Converted data from the server's response to the createHostAdd or + * createHostUpdate call. + * + * The received data is processed by this component to create a list of + * selectable daemons. The list of selectable daemons comprises labels + * of the servers that the user sees in the multi-select dropdown. The + * object implementing this interface may optionally contain a host + * instance, that is returned only in response to the createHostUpdate + * call. + */ +export interface MappedHostBeginData { + id: number + subnets: Array + daemons: Array + host?: Host +} + /** * A component providing a form for editing and adding new host * reservation. @@ -161,6 +184,15 @@ export class HostFormComponent implements OnInit, OnDestroy { */ @Input() form: HostForm = null + /** + * Host identifier. + * + * It should be set in cases when the form is used to update an existing + * host reservation. It is not set when the form is used to create new + * host reservation + */ + @Input() hostId: number = 0 + /** * An event emitter notifying that the component is destroyed. * @@ -174,6 +206,11 @@ export class HostFormComponent implements OnInit, OnDestroy { */ @Output() formSubmit = new EventEmitter() + /** + * An event emitter notifying that form editing has been canceled. + */ + @Output() formCancel = new EventEmitter() + /** * Different IP reservation types listed in the drop down. */ @@ -218,16 +255,44 @@ export class HostFormComponent implements OnInit, OnDestroy { */ ipv6Placeholder = HostFormComponent.defaultIPv6Placeholder + /** + * Indicates whether the user chose to specify some configuration data + * for the servers respectively. + * + * Stork data layout allows for specifying some host reservation data + * (e.g., DHCP options) for the servers individually. As a result, + * different servers may receive different set of DHCP options for the + * same host reservation. By default, a user specifies a single set + * of the DHCP options and the same options are assigned to each server. + * However, if the user explicitly enables the 'split-mode' or Stork + * finds that the host reservation comprises different DHCP option sets + * for different servers, the form provides a way to specify the options + * individually. + * + * This flag enables the described mode. + */ + splitFormMode: boolean = false + + /** + * Holds the received server's response to the updateHostBegin call. + * + * It is required to revert host reservation edits. + */ + savedUpdateHostBeginData: MappedHostBeginData + /** * Constructor. * * @param _formBuilder private form builder instance. * @param _dhcpApi REST API server service. + * @param _optionSetFormService service providing functions to convert the + * host reservation information between the form and REST API formats. * @param _messageService service displaying error and success messages. */ constructor( private _formBuilder: FormBuilder, private _dhcpApi: DHCPService, + private _optionSetFormService: DhcpOptionSetFormService, private _messageService: MessageService ) {} @@ -255,6 +320,25 @@ export class HostFormComponent implements OnInit, OnDestroy { } // New form. + this._createDefaultFormGroup() + + // Begin transaction. + if (this.hostId) { + // Send POST to /hosts/{id}/transaction/new. + this._updateHostBegin() + } else { + // Send POST to /hosts/new/transaction/new. + this._createHostBegin() + } + } + + /** + * Creates a default form group. + * + * It is used during the component initialization and when the current + * changes are reverted on user's request. + */ + private _createDefaultFormGroup(): void { this.formGroup = this._formBuilder.group( { globalReservation: [false], @@ -273,22 +357,22 @@ export class HostFormComponent implements OnInit, OnDestroy { ), ipGroups: this._formBuilder.array([this._createNewIPGroup()]), hostname: ['', StorkValidators.fqdn], - options: this._formBuilder.array([]), + // The outer array holds different option sets for different servers. + // The inner array holds the actual option sets. If the split-mode + // is disabled, there is only one outer array. + options: this._formBuilder.array([this._formBuilder.array([])]), }, { validators: [subnetRequiredValidator], } ) - - // Begin the transaction. It sends POST to /hosts/new/transaction/new. - this._createHostBegin() } /** * Sends a request to the server to begin a new transaction for adding - * a new host reservation. + * new host reservation. * - * If the call is successful, the form components initialized with the + * If the call is successful, the form components are initialized with the * returned data, e.g. a list of available servers, subnets etc. * If an error occurs, the error text is remembered and displayed along * with the retry button. @@ -300,37 +384,12 @@ export class HostFormComponent implements OnInit, OnDestroy { map((data) => { // We have to mangle the returned information and store them // in the format usable by the component. - - let daemons = [] - for (const d of data.daemons) { - let daemon = { - id: d.id, - name: d.name, - label: `${d.app.name}/${d.name}`, - } - daemons.push(daemon) - } - const mappedData: any = { - id: data.id, - subnets: data.subnets, - daemons: daemons, - } - return mappedData + return this._mapHostBeginData(data) }) ) .toPromise() .then((data) => { - // Success. Clear any existing errors. - this.form.initError = null - // The server should return new transaction id and a current list of - // daemons and subnets to select. - this.form.transactionId = data.id - this.form.allDaemons = data.daemons - this.form.allSubnets = data.subnets - // Initially, list all daemons. - this.form.filteredDaemons = this.form.allDaemons - // Initially, show all subnets. - this.form.filteredSubnets = this.form.allSubnets + this._initializeForm(data) }) .catch((err) => { let msg = err.statusText @@ -350,6 +409,187 @@ export class HostFormComponent implements OnInit, OnDestroy { }) } + /** + * Sends a request to the server to begin a new transaction for updating + * a host reservation. + * + * If the call is successful, the form components initialized wih the + * returned data, i.e., a list of available servers, subnets, host reservation + * information. If an error occurs, the error text is remembered and displayed + * along with the retry button. + */ + private _updateHostBegin(): void { + this._dhcpApi + .updateHostBegin(this.hostId) + .pipe( + map((data) => { + // We have to mangle the returned information and store them + // in the format usable by the component. + return this._mapHostBeginData(data) + }) + ) + .toPromise() + .then((data) => { + this._initializeForm(data) + }) + .catch((err) => { + let msg = err.statusText + if (err.error && err.error.message) { + msg = err.error.message + } + if (!msg) { + msg = `status: ${err.status}` + } + this._messageService.add({ + severity: 'error', + summary: 'Cannot create new transaction', + detail: `Failed to create transaction for updating host ${this.hostId}: ` + msg, + life: 10000, + }) + this.form.initError = msg + }) + } + + /** + * Processes and converts received data when new transaction is begun. + * + * For each daemon, it generates a user friendly label by concatenating + * app name and daemon name. The list of friendly names is displayed in + * the dropdown where a user selects servers. Other data is returned with + * no change. + * + * @param data a response received as a result of beginning a transaction + * to create a new host or to update an existing host. + * @returns processed data that includes friendly daemon names. + */ + private _mapHostBeginData(data: CreateHostBeginResponse | UpdateHostBeginResponse): MappedHostBeginData { + const daemons: Array = [] + for (const d of data.daemons) { + const daemon = { + id: d.id, + appId: d.app.id, + appType: 'kea', + name: d.name, + label: `${d.app.name}/${d.name}`, + } + daemons.push(daemon) + } + const mappedData: MappedHostBeginData = { + id: data.id, + subnets: data.subnets, + daemons: daemons, + } + if ('host' in data) { + mappedData.host = data.host + } + return mappedData + } + + /** + * Initializes the from with the data received when the transaction is begun. + * + * It sets transaction ID and a list of available daemons and subnets. If the + * received data comprises host information, the form controls pertaining to + * the host information are also filled. + * + * @param data a response received as a result of beginning a transaction + * to create a new host or to update an existing host. + */ + private _initializeForm(data: MappedHostBeginData): void { + // Success. Clear any existing errors. + this.form.initError = null + // The server should return new transaction id and a current list of + // daemons and subnets to select. + this.form.transactionId = data.id + this.form.allDaemons = data.daemons + this.form.allSubnets = data.subnets + // Initially, list all daemons. + this.form.filteredDaemons = this.form.allDaemons + // Initially, show all subnets. + this.form.filteredSubnets = this.form.allSubnets + // Initialize host-specific controls if the host information is available. + if (this.hostId && 'host' in data && data.host) { + this.savedUpdateHostBeginData = data + this._initializeHost(data.host) + } + } + + /** + * Initializes host reservation specific controls in the form. + * + * @param host host information received from the server and to be updated. + */ + private _initializeHost(host: Host): void { + const selectedDaemons: number[] = [] + if (host.localHosts?.length > 0) { + for (let lh of host.localHosts) { + selectedDaemons.push(lh.daemonId) + } + this.formGroup.get('selectedDaemons').setValue(selectedDaemons) + this._handleDaemonsChange() + } + if (!host.subnetId) { + this.formGroup.get('globalReservation').setValue(true) + } + if (host.subnetId) { + this.formGroup.get('selectedSubnet').setValue(host.subnetId) + } + if (host.hostIdentifiers?.length > 0) { + this.formGroup.get('hostIdGroup.idType').setValue(host.hostIdentifiers[0].idType) + this.formGroup.get('hostIdGroup.idFormat').setValue('hex') + this.formGroup.get('hostIdGroup.idInputHex').setValue(host.hostIdentifiers[0].idHexValue) + } + if (host.addressReservations?.length > 0 && (this.form.dhcpv4 || this.form.dhcpv6)) { + for (let i = 0; i < host.addressReservations.length; i++) { + if (this.ipGroups.length <= i) { + this.addIPInput() + } + if (this.form.dhcpv4) { + this.ipGroups.at(i).get('inputIPv4').setValue(host.addressReservations[i].address) + } else { + this.ipGroups.at(i).get('inputIPv6').setValue(host.addressReservations[i].address) + } + } + } + if (host.prefixReservations?.length > 0) { + for (let i = 0; i < host.prefixReservations.length; i++) { + if (this.ipGroups.length <= i) { + this.addIPInput() + } + let pdSplit = host.prefixReservations[i].address.split('/', 2) + if (pdSplit.length == 2) { + let pdLen = parseInt(pdSplit[1], 10) + if (!isNaN(pdLen) && pdLen <= 128) { + this.ipGroups.at(i).get('inputPDLen').setValue(pdLen) + this.ipGroups.at(i).get('inputPD').setValue(pdSplit[0]) + } + } + } + } + if (host.hostname) { + this.formGroup.get('hostname').setValue(host.hostname) + } + + // Split form mode is only set when there are multiple servers associated + // with the edited host and at least one of the servers has different + // set of DHCP options. + this.splitFormMode = + host.localHosts?.length > 1 && + host.localHosts.slice(1).some((lh) => lh.optionsHash !== host.localHosts[0].optionsHash) + + for (let i = 0; i < (this.splitFormMode ? host.localHosts.length : 1); i++) { + let converted = this._optionSetFormService.convertOptionsToForm( + this.form.dhcpv4 ? IPType.IPv4 : IPType.IPv6, + host.localHosts[i].options + ) + if (this.optionsArray.length > i) { + this.optionsArray.setControl(0, converted) + } else { + this.optionsArray.push(converted) + } + } + } + /** * Component lifecycle hook invoked when the component is destroyed. * @@ -534,16 +774,72 @@ export class HostFormComponent implements OnInit, OnDestroy { } /** - * Convenience function returning the form array with DHCP options. + * Convenience function returning the form array with DHCP option sets. * - * @returns form array with DHCP options. + * Each item in the array comprises another form array representing a + * DHCP option set for one of the servers (when split edit mode enabled) + * or for all servers (when split edit mode disabled). + * + * @returns Form array comprising option sets for different servers. */ get optionsArray(): FormArray { return this.formGroup.get('options') as FormArray } /** - * A callback invoked when selected DHCP servers have changed. + * Convenience function returning the form array with DHCP options. + * + * @returns form array with DHCP options. + */ + getOptionSetArray(index: number): FormArray { + return this.optionsArray.at(index) as FormArray + } + + /** + * Resets the part of the form comprising DHCP options. + * + * It removes all existing option sets and re-creates the default one. + */ + private _resetOptionsArray() { + this.optionsArray.clear() + this.optionsArray.push(this._formBuilder.array([])) + } + + /** + * A callback invoked when user toggles the split mode button. + * + * When the user turns on the split mode editing mode, this function + * ensures that each selected DHCP server is associated with its own + * options form. When the split mode is off, this function leaves only + * one form, common for all servers. + */ + onSplitModeChange(): void { + if (this.splitFormMode) { + const selectedDaemons = this.formGroup.get('selectedDaemons').value + const itemsToAdd = selectedDaemons.length - this.optionsArray.length + if (selectedDaemons.length >= this.optionsArray.length) { + for (let i = 0; i < itemsToAdd; i++) { + this.optionsArray.push(this._optionSetFormService.cloneControl(this.optionsArray.at(0))) + } + } + } else { + for (let i = this.optionsArray.length; i >= 1; i--) { + this.optionsArray.removeAt(i) + } + } + } + + /** + * Convenience function returning the control with selected daemons. + * + * returns form control with selected daemon IDs. + */ + get selectedDaemons(): AbstractControl { + return this.formGroup.get('selectedDaemons') + } + + /** + * Adjusts the form state based on the selected daemons. * * Servers selection affects available subnets. If no servers are selected, * all subnets are listed for selection. However, if one or more servers @@ -552,32 +848,18 @@ export class HostFormComponent implements OnInit, OnDestroy { * servers. If selected servers have no common subnets, no subnets are * listed. */ - onDaemonsChange(): void { + private _handleDaemonsChange(): void { // Capture the servers selected by the user. const selectedDaemons = this.formGroup.get('selectedDaemons').value - // It is important to determine what type of a server the user selected. - // Check if any of the selected servers are DHCPv4. - this.form.dhcpv4 = selectedDaemons.some((ss) => { - return this.form.allDaemons.find((d) => d.id === ss && d.name === 'dhcp4') - }) - if (!this.form.dhcpv4) { - // If user selected no DHCPv4 server, perhaps selected a DHCPv6 server? - this.form.dhcpv6 = selectedDaemons.some((ss) => { - return this.form.allDaemons.find((d) => d.id === ss && d.name === 'dhcp6') - }) - } else { - // If user selected DHCPv4 server he didn't select a DHCPv6 server. - this.form.dhcpv6 = false - } - - // Filter selectable other selectable servers based on the current selection. - if (this.form.dhcpv4) { - this.form.filteredDaemons = this.form.allDaemons.filter((d) => d.name === 'dhcp4') - } else if (this.form.dhcpv6) { - this.form.filteredDaemons = this.form.allDaemons.filter((d) => d.name === 'dhcp6') - } else { - this.form.filteredDaemons = this.form.allDaemons + // Selecting new daemons may have a large impact on the data already + // inserted to the form. Update the form state accordingly and see + // if it is breaking change. + if (this.form.updateFormForSelectedDaemons(this.formGroup.get('selectedDaemons').value)) { + // The breaking change puts us at risk of having option data that + // no longer matches the servers selection. Let's reset the option + // data. + this._resetOptionsArray() } // Selectable host identifier types depend on the selected server types. @@ -621,6 +903,23 @@ export class HostFormComponent implements OnInit, OnDestroy { if (!this.form.filteredSubnets.find((fs) => fs.id === this.formGroup.get('selectedSubnet').value)) { this.formGroup.get('selectedSubnet').patchValue(null) } + + if (this.splitFormMode) { + let optionSets: FormArray[] = [] + for (let i = 0; i < selectedDaemons.length; i++) { + optionSets.push(this._formBuilder.array([])) + } + this.formGroup.setControl('options', this._formBuilder.array(optionSets)) + } + } + + /** + * A callback invoked when selected DHCP servers have changed. + * + * Adjusts the form state based on the selected daemons. + */ + onDaemonsChange(): void { + this._handleDaemonsChange() } /** @@ -669,15 +968,19 @@ export class HostFormComponent implements OnInit, OnDestroy { * * It creates a new default form group for the option. */ - onOptionAdd(): void { - this.optionsArray.push(createDefaultDhcpOptionFormGroup(this.form.dhcpv6 ? IPType.IPv6 : IPType.IPv4)) + onOptionAdd(index: number): void { + this.getOptionSetArray(index).push( + createDefaultDhcpOptionFormGroup(this.form.dhcpv6 ? IPType.IPv6 : IPType.IPv4) + ) } /** - * A function called when a user attempts to submit the new host reservation. + * A function called when a user attempts to submit the new host reservation + * or update an existing one. * * It collects the data from the form and sends the request to commit the - * current transaction (hosts/new/transaction/{id}/submit). + * current transaction (hosts/new/transaction/{id}/submit or + * hosts/{id}/transaction/{id}/submit). */ onSubmit(): void { // Check if it is global reservation or subnet-level reservation. @@ -686,12 +989,19 @@ export class HostFormComponent implements OnInit, OnDestroy { : this.formGroup.get('selectedSubnet').value // DHCP options. - let options = [] - if (this.optionsArray) { + let options: Array> = [] + for (let arr of this.optionsArray.controls) { try { - const optionsForm = new DhcpOptionSetForm(this.optionsArray) - optionsForm.process(this.form.dhcpv4 ? IPType.IPv4 : IPType.IPv6) - options = optionsForm.getSerializedOptions() + options.push( + this._optionSetFormService.convertFormToOptions( + this.form.dhcpv4 ? IPType.IPv4 : IPType.IPv6, + arr as FormArray + ) + ) + // There should be only one option set when the split mode is disabled. + if (!this.splitFormMode) { + break + } } catch (err) { this._messageService.add({ severity: 'error', @@ -706,11 +1016,11 @@ export class HostFormComponent implements OnInit, OnDestroy { // Create associations with the daemons. let localHosts: LocalHost[] = [] const selectedDaemons = this.formGroup.get('selectedDaemons').value - for (let id of selectedDaemons) { + for (let i = 0; i < selectedDaemons.length; i++) { localHosts.push({ - daemonId: id, + daemonId: selectedDaemons[i], dataSource: 'api', - options: options, + options: this.splitFormMode ? options[i] : options[0], }) } @@ -767,7 +1077,36 @@ export class HostFormComponent implements OnInit, OnDestroy { localHosts: localHosts, } - // Submit the host. + // Update the existing host. + if (this.hostId) { + host.id = this.hostId + this._dhcpApi + .updateHostSubmit(this.hostId, this.form.transactionId, host) + .toPromise() + .then(() => { + this._messageService.add({ + severity: 'success', + summary: 'Host reservation successfully updated', + detail: 'The updated host reservation may appear in Stork with some delay.', + }) + // Notify the parent component about successful submission. + this.formSubmit.emit(this.form) + }) + .catch((err) => { + let msg = err.statusText + if (err.error && err.error.message) { + msg = err.error.message + } + this._messageService.add({ + severity: 'error', + summary: 'Cannot commit host updates', + detail: 'The transaction updating the host failed: ' + msg, + life: 10000, + }) + }) + return + } + // Submit new host. this._dhcpApi .createHostSubmit(this.form.transactionId, host) .toPromise() @@ -799,6 +1138,27 @@ export class HostFormComponent implements OnInit, OnDestroy { * a new transaction. */ onRetry(): void { - this._createHostBegin() + if (this.hostId) { + this._updateHostBegin() + } else { + this._createHostBegin() + } + } + + /** + * A function called when user clicks the button to revert host edit changes. + */ + onRevert(): void { + this._createDefaultFormGroup() + this._initializeForm(this.savedUpdateHostBeginData) + } + + /** + * A function called when user clicks cancel button. + * + * It causes the parent component to close the form. + */ + onCancel(): void { + this.formCancel.emit(this.hostId) } } diff --git a/webui/src/app/host-tab/host-tab.component.html b/webui/src/app/host-tab/host-tab.component.html index 19db78a43252316b0941bdedcceea1f85cddd4b0..2dc316ce1deec2d2a3abb6edfdcb4a0a3f088c65 100644 --- a/webui/src/app/host-tab/host-tab.component.html +++ b/webui/src/app/host-tab/host-tab.component.html @@ -216,29 +216,34 @@

-
+ + -
-
-
+
diff --git a/webui/src/app/host-tab/host-tab.component.ts b/webui/src/app/host-tab/host-tab.component.ts index 8afdca13362633d4b7f238d3f0fba746507c572e..a38b84d8b3308622f7a469a84e09b09a2024156e 100644 --- a/webui/src/app/host-tab/host-tab.component.ts +++ b/webui/src/app/host-tab/host-tab.component.ts @@ -35,6 +35,16 @@ enum HostReservationUsage { styleUrls: ['./host-tab.component.sass'], }) export class HostTabComponent { + /** + * An event emitter notifying a parent that user has clicked the + * Edit button to modify the host reservation. + */ + @Output() hostEditBegin = new EventEmitter() + + /** + * An event emitter notifying a parent that user has clicked the + * Delete button to delete the host reservation. + */ @Output() hostDelete = new EventEmitter() Usage = HostReservationUsage @@ -393,6 +403,16 @@ export class HostTabComponent { this._fetchLeases(this.host.id) } + /** + * Event handler called when user begins host editing. + * + * It emits an event to the parent component to notify that host is + * is now edited. + */ + onHostEditBegin(): void { + this.hostEditBegin.emit(this.host) + } + /* * Displays a dialog to confirm host deletion. */ diff --git a/webui/src/app/hosts-page/hosts-page.component.html b/webui/src/app/hosts-page/hosts-page.component.html index 5ff8e5c91aa6ecc6ebe4d3c9bd3b45a0eb8f7db0..3f4913c1a02bef9291ccdd30d9d123f34a79a5b1 100644 --- a/webui/src/app/hosts-page/hosts-page.component.html +++ b/webui/src/app/hosts-page/hosts-page.component.html @@ -190,14 +190,24 @@ + diff --git a/webui/src/app/hosts-page/hosts-page.component.spec.ts b/webui/src/app/hosts-page/hosts-page.component.spec.ts index 8b9799641effea30fdd25e6d9910e150b2cd0706..022c78afe0f3d474d081b4e76f35a5a4ab0b5aca 100644 --- a/webui/src/app/hosts-page/hosts-page.component.spec.ts +++ b/webui/src/app/hosts-page/hosts-page.component.spec.ts @@ -249,6 +249,172 @@ describe('HostsPageComponent', () => { expect(component.activeTabIndex).toBe(0) }) + it('should emit error message when there is an error deleting transaction for new host', fakeAsync(() => { + // Open the tab for creating a host. + paramMapSubject.next(convertToParamMap({ id: 'new' })) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + + // Ensure an error is emitted when transaction is deleted. + component.openedTabs[0].form.transactionId = 123 + spyOn(dhcpApi, 'createHostDelete').and.returnValue(throwError({ status: 404 })) + spyOn(messageService, 'add') + + // Close the tab for adding new host. + component.closeHostTab(null, 1) + tick() + fixture.detectChanges() + expect(component.tabs.length).toBe(1) + expect(component.activeTabIndex).toBe(0) + expect(messageService.add).toHaveBeenCalled() + })) + + it('should switch a tab to host editing mode', () => { + // Create a list with two hosts. + component.hosts = [ + { + id: 1, + hostIdentifiers: [ + { + idType: 'duid', + idHexValue: '01:02:03:04', + }, + ], + addressReservations: [ + { + address: '192.0.2.1', + }, + ], + localHosts: [ + { + appId: 1, + appName: 'frog', + dataSource: 'config', + }, + ], + }, + { + id: 2, + hostIdentifiers: [ + { + idType: 'duid', + idHexValue: '11:12:13:14', + }, + ], + addressReservations: [ + { + address: '192.0.2.2', + }, + ], + localHosts: [ + { + appId: 2, + appName: 'mouse', + dataSource: 'config', + }, + ], + }, + ] + fixture.detectChanges() + + // Ensure that we don't fetch the host information from the server upon + // opening a new tab. We should use the information available in the + // hosts structure. + spyOn(dhcpApi, 'getHost') + + // Open tab with host with id 1. + paramMapSubject.next(convertToParamMap({ id: 1 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + + component.onHostEditBegin(component.hosts[0]) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + + // Make sure the tab includes the host reservation form. + expect(component.tabs[1].icon).toBe('pi pi-pencil') + let form = fixture.debugElement.query(By.css('form')) + expect(form).toBeTruthy() + + // Open tab with host with id 2. + paramMapSubject.next(convertToParamMap({ id: 2 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(3) + expect(component.activeTabIndex).toBe(2) + // This tab should have no form. + form = fixture.debugElement.query(By.css('form')) + expect(form).toBeFalsy() + + // Return to the previous tab and make sure the form is still open. + paramMapSubject.next(convertToParamMap({ id: 1 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(3) + expect(component.activeTabIndex).toBe(1) + form = fixture.debugElement.query(By.css('form')) + expect(form).toBeTruthy() + }) + + it('should emit an error when deleting transaction for updating a host fails', fakeAsync(() => { + // Create a list with two hosts. + component.hosts = [ + { + id: 1, + hostIdentifiers: [ + { + idType: 'duid', + idHexValue: '01:02:03:04', + }, + ], + addressReservations: [ + { + address: '192.0.2.1', + }, + ], + localHosts: [ + { + appId: 1, + appName: 'frog', + dataSource: 'config', + }, + ], + }, + ] + fixture.detectChanges() + + // Ensure that we don't fetch the host information from the server upon + // opening a new tab. We should use the information available in the + // hosts structure. + spyOn(dhcpApi, 'getHost') + + // Open tab with host with id 1. + paramMapSubject.next(convertToParamMap({ id: 1 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + + // Simulate clicking on Edit. + component.onHostEditBegin(component.hosts[0]) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + + // Make sure an error is returned when closing the tab. + component.openedTabs[0].form.transactionId = 123 + spyOn(dhcpApi, 'updateHostDelete').and.returnValue(throwError({ status: 404 })) + spyOn(messageService, 'add') + + // Close the tab. + component.closeHostTab(null, 1) + tick() + fixture.detectChanges() + expect(component.tabs.length).toBe(1) + expect(component.activeTabIndex).toBe(0) + expect(messageService.add).toHaveBeenCalled() + })) + it('should open a tab when hosts have not been loaded', () => { const host: any = { id: 1, @@ -563,6 +729,242 @@ describe('HostsPageComponent', () => { expect(dhcpApi.createHostDelete).toHaveBeenCalled() })) + it('should cancel transaction when cancel button is clicked', fakeAsync(() => { + const createHostBeginResp: any = { + id: 123, + subnets: [ + { + id: 1, + subnet: '192.0.2.0/24', + localSubnets: [ + { + daemonId: 1, + }, + ], + }, + ], + daemons: [ + { + id: 1, + name: 'dhcp4', + app: { + name: 'first', + }, + }, + ], + } + const okResp: any = { + status: 200, + } + spyOn(dhcpApi, 'createHostBegin').and.returnValue(of(createHostBeginResp)) + spyOn(dhcpApi, 'createHostDelete').and.returnValue(of(okResp)) + + paramMapSubject.next(convertToParamMap({ id: 'new' })) + tick() + fixture.detectChanges() + + paramMapSubject.next(convertToParamMap({})) + tick() + fixture.detectChanges() + + expect(component.openedTabs.length).toBe(1) + expect(component.openedTabs[0].form.hasOwnProperty('transactionId')).toBeTrue() + expect(component.openedTabs[0].form.transactionId).toBe(123) + + // Cancel editing. It should close the tab and the transaction should be deleted. + component.onHostFormCancel(0) + tick() + fixture.detectChanges() + expect(component.tabs.length).toBe(1) + expect(component.activeTabIndex).toBe(0) + expect(dhcpApi.createHostDelete).toHaveBeenCalled() + })) + + it('should cancel update transaction when a tab is closed', fakeAsync(() => { + component.hosts = [ + { + id: 1, + subnetId: 1, + subnetPrefix: '192.0.2.0/24', + hostIdentifiers: [ + { + idType: 'duid', + idHexValue: '01:02:03:04', + }, + ], + addressReservations: [ + { + address: '192.0.2.1', + }, + ], + prefixReservations: [], + localHosts: [ + { + daemonId: 1, + dataSource: 'api', + options: [], + }, + ], + }, + ] + fixture.detectChanges() + + // Ensure that we don't fetch the host information from the server upon + // opening a new tab. We should use the information available in the + // hosts structure. + spyOn(dhcpApi, 'getHost') + + // Open tab with host with id 1. + paramMapSubject.next(convertToParamMap({ id: 1 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + expect(component.openedTabs.length).toBe(1) + + const updateHostBeginResp: any = { + id: 123, + subnets: [ + { + id: 1, + subnet: '192.0.2.0/24', + localSubnets: [ + { + daemonId: 1, + }, + ], + }, + ], + daemons: [ + { + id: 1, + name: 'dhcp4', + app: { + name: 'first', + }, + }, + ], + host: component.hosts[0], + } + const okResp: any = { + status: 200, + } + spyOn(dhcpApi, 'updateHostBegin').and.returnValue(of(updateHostBeginResp)) + spyOn(dhcpApi, 'updateHostDelete').and.returnValue(of(okResp)) + + component.onHostEditBegin(component.hosts[0]) + fixture.detectChanges() + tick() + + expect(dhcpApi.updateHostBegin).toHaveBeenCalled() + expect(dhcpApi.updateHostDelete).not.toHaveBeenCalled() + + expect(component.openedTabs.length).toBe(1) + expect(component.openedTabs[0].form.hasOwnProperty('transactionId')).toBeTrue() + expect(component.openedTabs[0].form.transactionId).toBe(123) + + component.closeHostTab(null, 1) + tick() + fixture.detectChanges() + expect(component.tabs.length).toBe(1) + expect(component.activeTabIndex).toBe(0) + + expect(dhcpApi.updateHostDelete).toHaveBeenCalled() + })) + + it('should cancel update transaction cancel button is clicked', fakeAsync(() => { + component.hosts = [ + { + id: 1, + subnetId: 1, + subnetPrefix: '192.0.2.0/24', + hostIdentifiers: [ + { + idType: 'duid', + idHexValue: '01:02:03:04', + }, + ], + addressReservations: [ + { + address: '192.0.2.1', + }, + ], + prefixReservations: [], + localHosts: [ + { + daemonId: 1, + dataSource: 'api', + options: [], + }, + ], + }, + ] + fixture.detectChanges() + + // Ensure that we don't fetch the host information from the server upon + // opening a new tab. We should use the information available in the + // hosts structure. + spyOn(dhcpApi, 'getHost') + + // Open tab with host with id 1. + paramMapSubject.next(convertToParamMap({ id: 1 })) + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + expect(component.openedTabs.length).toBe(1) + + const updateHostBeginResp: any = { + id: 123, + subnets: [ + { + id: 1, + subnet: '192.0.2.0/24', + localSubnets: [ + { + daemonId: 1, + }, + ], + }, + ], + daemons: [ + { + id: 1, + name: 'dhcp4', + app: { + name: 'first', + }, + }, + ], + host: component.hosts[0], + } + const okResp: any = { + status: 200, + } + spyOn(dhcpApi, 'updateHostBegin').and.returnValue(of(updateHostBeginResp)) + spyOn(dhcpApi, 'updateHostDelete').and.returnValue(of(okResp)) + + component.onHostEditBegin(component.hosts[0]) + fixture.detectChanges() + tick() + + expect(dhcpApi.updateHostBegin).toHaveBeenCalled() + expect(dhcpApi.updateHostDelete).not.toHaveBeenCalled() + + expect(component.openedTabs.length).toBe(1) + expect(component.openedTabs[0].form.hasOwnProperty('transactionId')).toBeTrue() + expect(component.openedTabs[0].form.transactionId).toBe(123) + + component.onHostFormCancel(component.hosts[0].id) + tick() + fixture.detectChanges() + expect(component.tabs.length).toBe(2) + expect(component.activeTabIndex).toBe(1) + expect(dhcpApi.updateHostDelete).toHaveBeenCalled() + + // Ensure that the form was closed and the tab now shows the host + // reservation view. + expect(fixture.debugElement.query(By.css('app-host-tab'))).toBeTruthy() + })) + it('should close a tab after deleting a host', () => { // Create a list with two hosts. component.hosts = [ diff --git a/webui/src/app/hosts-page/hosts-page.component.ts b/webui/src/app/hosts-page/hosts-page.component.ts index 8b218b96b81b80526f64b65f7c11df1e4eb9d694..ef64128d91d5fd8ea8940feaf406dbc8d0a8071d 100644 --- a/webui/src/app/hosts-page/hosts-page.component.ts +++ b/webui/src/app/hosts-page/hosts-page.component.ts @@ -16,6 +16,7 @@ import { HostForm } from '../forms/host-form' export enum HostTabType { List = 1, NewHost, + EditHost, Host, } @@ -40,7 +41,39 @@ export class HostTab { * @param host host information displayed in the tab. */ constructor(public tabType: HostTabType, public host?: any) { - this.form = new HostForm() + this._setHostTabType(tabType) + } + + /** + * Sets new host tab type and initializes the form accordingly. + * + * It is a private function variant that does not check whether the type + * is already set to the desired value. + */ + private _setHostTabType(tabType: HostTabType): void { + switch (tabType) { + case HostTabType.NewHost: + case HostTabType.EditHost: + this.form = new HostForm() + break + default: + this.form = null + break + } + this.submitted = false + this.tabType = tabType + } + + /** + * Sets new host tab type and initializes the form accordingly. + * + * It does nothing when the type is already set to the desired value. + */ + public setHostTabType(tabType: HostTabType): void { + if (this.tabType === tabType) { + return + } + this._setHostTabType(tabType) } } @@ -255,7 +288,9 @@ export class HostsPageComponent implements OnInit, OnDestroy { * @param id host ID. */ private openHostTab(id) { - let index = this.openedTabs.findIndex((t) => t.tabType === HostTabType.Host && t.host.id === id) + let index = this.openedTabs.findIndex( + (t) => (t.tabType === HostTabType.Host || t.tabType === HostTabType.EditHost) && t.host.id === id + ) if (index >= 0) { this.switchToTab(index + 1) return @@ -322,7 +357,45 @@ export class HostsPageComponent implements OnInit, OnDestroy { this.openedTabs[tabIndex - 1].form.transactionId > 0 && !this.openedTabs[tabIndex - 1].submitted ) { - this.dhcpApi.createHostDelete(this.openedTabs[tabIndex - 1].form.transactionId).toPromise() + this.dhcpApi + .createHostDelete(this.openedTabs[tabIndex - 1].form.transactionId) + .toPromise() + .catch((err) => { + let msg = err.statusText + if (err.error && err.error.message) { + msg = err.error.message + } + this.messageService.add({ + severity: 'error', + summary: 'Failed to delete configuration transaction', + detail: 'Failed to delete configuration transaction: ' + msg, + life: 10000, + }) + }) + } else if ( + this.openedTabs[tabIndex - 1].tabType === HostTabType.EditHost && + this.openedTabs[tabIndex - 1].host.id > 0 && + this.openedTabs[tabIndex - 1].form.transactionId > 0 && + !this.openedTabs[tabIndex - 1].submitted + ) { + this.dhcpApi + .updateHostDelete( + this.openedTabs[tabIndex - 1].host.id, + this.openedTabs[tabIndex - 1].form.transactionId + ) + .toPromise() + .catch((err) => { + let msg = err.statusText + if (err.error && err.error.message) { + msg = err.error.message + } + this.messageService.add({ + severity: 'error', + summary: 'Failed to delete configuration transaction', + detail: 'Failed to delete configuration transaction: ' + msg, + life: 10000, + }) + }) } // Remove the MenuItem representing the tab. @@ -511,6 +584,56 @@ export class HostsPageComponent implements OnInit, OnDestroy { } } + /** + * Event handler triggered when host form editing is canceled. + * + * If the event comes from the new host form, the tab is closed. If the + * event comes from the host update form, the tab is turned into the + * host view. In both cases, the transaction is deleted in the server. + * + * @param hostId host identifier or zero for new host case. + */ + onHostFormCancel(hostId): void { + // Find the form matching the form for which the notification has + // been sent. + const index = this.openedTabs.findIndex( + (t) => (t.host && t.host.id === hostId) || (t.tabType === HostTabType.NewHost && !hostId) + ) + if (index >= 0) { + if ( + hostId && + this.openedTabs[index].form?.transactionId && + this.openedTabs[index].tabType !== HostTabType.Host + ) { + this.dhcpApi.updateHostDelete(hostId, this.openedTabs[index].form.transactionId).toPromise() + this.tabs[index + 1].icon = '' + this.openedTabs[index].setHostTabType(HostTabType.Host) + } else { + this.closeHostTab(null, index + 1) + } + } + } + + /** + * Event handler triggered when a user starts editing a host reservation. + * + * It replaces the host view with the host edit form in the current tab. + * + * @param host an instance carrying host information. + */ + onHostEditBegin(host): void { + let index = this.openedTabs.findIndex( + (t) => (t.tabType === HostTabType.Host || t.tabType === HostTabType.EditHost) && t.host.id === host.id + ) + if (index >= 0) { + if (this.openedTabs[index].tabType !== HostTabType.EditHost) { + this.tabs[index + 1].icon = 'pi pi-pencil' + this.openedTabs[index].setHostTabType(HostTabType.EditHost) + } + this.switchToTab(index + 1) + } + } + /** * Event handler triggered when a host was deleted using a delete * button on one of the tabs.