diff --git a/pkg/instance/api.go b/pkg/instance/api.go index 4deec089a..a19561bc6 100644 --- a/pkg/instance/api.go +++ b/pkg/instance/api.go @@ -10,7 +10,8 @@ func (i *Instance) setupInstanceAPI() { i.ForRole("instance", i.rootContext).AddEventListener(apitypes.EventTopicAPIMuxSetup, func(ev *roles.Event) { svc := ev.Payload.Data["svc"].(*web.Service) svc.Get("/api/v1/cluster", i.APIClusterInfo()) - svc.Get("/api/v1/cluster/instance", i.APIInstanceInfo()) + svc.Get("/api/v1/cluster/instance", i.APIInstanceGet()) + svc.Put("/api/v1/cluster/instance", i.APIInstancePut()) svc.Post("/api/v1/cluster/roles/restart", i.APIClusterRoleRestart()) }) } diff --git a/pkg/instance/api_instance.go b/pkg/instance/api_instance.go index b1159c0ad..cb46bdc83 100644 --- a/pkg/instance/api_instance.go +++ b/pkg/instance/api_instance.go @@ -74,7 +74,7 @@ type APIInstanceInfo struct { InstanceIP string `json:"instanceIP" required:"true"` } -func (i *Instance) APIInstanceInfo() usecase.Interactor { +func (i *Instance) APIInstanceGet() usecase.Interactor { u := usecase.NewInteractor(func(ctx context.Context, input struct{}, output *APIInstanceInfo) error { output.Version = extconfig.Version output.BuildHash = extconfig.BuildHash @@ -89,3 +89,32 @@ func (i *Instance) APIInstanceInfo() usecase.Interactor { u.SetExpectedErrors(status.Internal) return u } + +type APIInstancesPutInput struct { + Identifier string `query:"identifier" required:"true"` + + Roles []string `json:"roles" required:"true"` +} + +func (i *Instance) APIInstancePut() usecase.Interactor { + u := usecase.NewInteractor(func(ctx context.Context, input APIInstancesPutInput, output *struct{}) error { + _, err := i.kv.Put( + ctx, + i.kv.Key( + types.KeyInstance, + i.identifier, + types.KeyRoles, + ).String(), + string(strings.Join(input.Roles, types.RoleSeparator)), + ) + if err != nil { + return status.Wrap(err, status.Internal) + } + return nil + }) + u.SetName("cluster.put_instance") + u.SetTitle("Instance") + u.SetTags("cluster/instances") + u.SetExpectedErrors(status.Internal) + return u +} diff --git a/pkg/instance/api_instance_test.go b/pkg/instance/api_instance_test.go index 4068b32f6..78780c891 100644 --- a/pkg/instance/api_instance_test.go +++ b/pkg/instance/api_instance_test.go @@ -14,7 +14,7 @@ func TestAPIInstanceInfo(t *testing.T) { rootInst := instance.New() var output instance.APIInstanceInfo - assert.NoError(t, rootInst.APIInstanceInfo().Interact(tests.Context(), struct{}{}, &output)) + assert.NoError(t, rootInst.APIInstanceGet().Interact(tests.Context(), struct{}{}, &output)) assert.NotNil(t, output) assert.Equal(t, output.Version, extconfig.Version) } diff --git a/pkg/instance/instance.go b/pkg/instance/instance.go index 8c7faa75f..0d3969e39 100644 --- a/pkg/instance/instance.go +++ b/pkg/instance/instance.go @@ -154,57 +154,11 @@ func (i *Instance) getRoles(ctx context.Context) []string { roles := extconfig.Get().BootstrapRoles if err == nil && len(rr.Kvs) > 0 { roles = string(rr.Kvs[0].Value) - i.log.Info("roles configured for instance", zap.Strings("roles", strings.Split(roles, ";"))) + i.log.Info("roles configured for instance", zap.Strings("roles", strings.Split(roles, types.RoleSeparator))) } else { - i.log.Info("defaulting to bootstrap roles", zap.Strings("roles", strings.Split(roles, ";"))) + i.log.Info("defaulting to bootstrap roles", zap.Strings("roles", strings.Split(roles, types.RoleSeparator))) } - return strings.Split(roles, ";") -} - -func (i *Instance) bootstrap(ctx context.Context) { - i.log.Debug("bootstrapping instance") - i.keepAliveInstanceInfo(ctx) - i.setupInstanceAPI() - rootInstance := i.ForRole("root", ctx) - for _, roleId := range i.getRoles(ctx) { - instanceRoles.WithLabelValues(roleId).Add(1) - rctx, cancel := context.WithCancelCause(i.rootContext) - rc := RoleContext{ - RoleInstance: i.ForRole(roleId, rctx), - ContextCancelFunc: cancel, - } - switch roleId { - case "etcd": - // Special handling - continue - default: - span := sentry.StartSpan(ctx, "gravity.instance.bootstrap.role") - span.SetTag("gravity.role", roleId) - rc.Role = roles.GetRole(roleId)(rc.RoleInstance) - span.Finish() - } - i.rolesM.Lock() - i.roles[roleId] = rc - i.rolesM.Unlock() - } - rootInstance.AddEventListener(types.EventTopicRoleRestart, i.eventRoleRestart) - rootInstance.DispatchEvent( - types.EventTopicInstanceBootstrapped, - roles.NewEvent(i.rootContext, map[string]interface{}{}), - ) - i.checkFirstStart(ctx) - wg := sync.WaitGroup{} - for roleId := range i.roles { - wg.Add(1) - go i.startWatchRole(ctx, roleId, func() { - wg.Done() - }) - } - go func() { - wg.Wait() - i.DispatchEvent(types.EventTopicRolesStarted, roles.NewEvent(ctx, map[string]interface{}{})) - sentry.TransactionFromContext(ctx).Finish() - }() + return strings.Split(roles, types.RoleSeparator) } func (i *Instance) eventRoleRestart(ev *roles.Event) { diff --git a/pkg/instance/instance_bootstrap.go b/pkg/instance/instance_bootstrap.go new file mode 100644 index 000000000..76591a56a --- /dev/null +++ b/pkg/instance/instance_bootstrap.go @@ -0,0 +1,56 @@ +package instance + +import ( + "context" + "sync" + + "beryju.io/gravity/pkg/instance/types" + "beryju.io/gravity/pkg/roles" + "github.com/getsentry/sentry-go" +) + +func (i *Instance) bootstrap(ctx context.Context) { + i.log.Debug("bootstrapping instance") + i.keepAliveInstanceInfo(ctx) + i.setupInstanceAPI() + rootInstance := i.ForRole("root", ctx) + for _, roleId := range i.getRoles(ctx) { + instanceRoles.WithLabelValues(roleId).Add(1) + rctx, cancel := context.WithCancelCause(i.rootContext) + rc := RoleContext{ + RoleInstance: i.ForRole(roleId, rctx), + ContextCancelFunc: cancel, + } + switch roleId { + case "etcd": + // Special handling + continue + default: + span := sentry.StartSpan(ctx, "gravity.instance.bootstrap.role") + span.SetTag("gravity.role", roleId) + rc.Role = roles.GetRole(roleId)(rc.RoleInstance) + span.Finish() + } + i.rolesM.Lock() + i.roles[roleId] = rc + i.rolesM.Unlock() + } + rootInstance.AddEventListener(types.EventTopicRoleRestart, i.eventRoleRestart) + rootInstance.DispatchEvent( + types.EventTopicInstanceBootstrapped, + roles.NewEvent(i.rootContext, map[string]interface{}{}), + ) + i.checkFirstStart(ctx) + wg := sync.WaitGroup{} + for roleId := range i.roles { + wg.Add(1) + go i.startWatchRole(ctx, roleId, func() { + wg.Done() + }) + } + go func() { + wg.Wait() + i.DispatchEvent(types.EventTopicRolesStarted, roles.NewEvent(ctx, map[string]interface{}{})) + sentry.TransactionFromContext(ctx).Finish() + }() +} diff --git a/pkg/instance/types/role.go b/pkg/instance/types/role.go index a286b4857..6ba381722 100644 --- a/pkg/instance/types/role.go +++ b/pkg/instance/types/role.go @@ -7,3 +7,5 @@ const ( KeyCluster = "cluster" KeyMigration = "migration" ) + +const RoleSeparator = ";" diff --git a/pkg/roles/etcd/api.go b/pkg/roles/etcd/api.go index 0bd23240e..8535258d5 100644 --- a/pkg/roles/etcd/api.go +++ b/pkg/roles/etcd/api.go @@ -50,9 +50,9 @@ func (r *Role) APIClusterMembers() usecase.Interactor { } type APIMemberJoinInput struct { - Peer string `json:"peer" maxLength:"255"` - Roles string `json:"roles"` - Identifier string `json:"identifier"` + Peer string `json:"peer" maxLength:"255"` + Roles []string `json:"roles"` + Identifier string `json:"identifier"` } type APIMemberJoinOutput struct { EtcdInitialCluster string `json:"etcdInitialCluster"` @@ -80,11 +80,10 @@ func (r *Role) APIClusterJoin() usecase.Interactor { )) // Pre-configure roles for new node - roles := strings.Split(input.Roles, ",") - if input.Roles == "" { - roles = strings.Split(extconfig.Get().BootstrapRoles, ",") + if len(input.Roles) == 0 { + input.Roles = strings.Split(extconfig.Get().BootstrapRoles, ",") // If we're copying our roles, exclude backup - roles = slices.DeleteFunc(roles, func(role string) bool { + input.Roles = slices.DeleteFunc(input.Roles, func(role string) bool { return role == "backup" }) } @@ -95,7 +94,7 @@ func (r *Role) APIClusterJoin() usecase.Interactor { input.Identifier, types.KeyRoles, ).String(), - strings.Join(roles, ","), + strings.Join(input.Roles, ","), ) if err != nil { r.log.Warn("failed to put roles for node", zap.Error(err)) diff --git a/schema.yml b/schema.yml index 2a05ea2e9..df0d0eead 100644 --- a/schema.yml +++ b/schema.yml @@ -325,6 +325,31 @@ paths: summary: Instance tags: - cluster/instances + put: + operationId: cluster.put_instance + parameters: + - in: query + name: identifier + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InstanceAPIInstancesPutInput' + responses: + "204": + description: No Content + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrResponse' + description: Internal Server Error + summary: Instance + tags: + - cluster/instances /api/v1/cluster/node/logs: get: operationId: api.get_log_messages @@ -1769,12 +1794,13 @@ components: ApiAPIToolPingOutput: properties: avgRtt: - type: integer + $ref: '#/components/schemas/TimeDuration' maxRtt: - type: integer + $ref: '#/components/schemas/TimeDuration' minRtt: - type: integer + $ref: '#/components/schemas/TimeDuration' packetLoss: + format: double type: number packetsRecv: type: integer @@ -1783,7 +1809,7 @@ components: packetsSent: type: integer stdDevRtt: - type: integer + $ref: '#/components/schemas/TimeDuration' type: object ApiAPIToolPortmapInput: properties: @@ -1828,7 +1854,7 @@ components: address: type: string elapsedTime: - type: integer + $ref: '#/components/schemas/TimeDuration' success: type: boolean type: object @@ -1848,6 +1874,7 @@ components: oidc: $ref: '#/components/schemas/TypesOIDCConfig' port: + format: int32 type: integer sessionDuration: type: string @@ -1963,6 +1990,7 @@ components: BackupAPIBackupStatus: properties: duration: + format: int64 type: integer error: type: string @@ -1971,6 +1999,7 @@ components: node: type: string size: + format: int64 type: integer status: type: string @@ -2012,12 +2041,14 @@ components: BackupBackupStatus: properties: duration: + format: int64 type: integer error: type: string filename: type: string size: + format: int64 type: integer status: type: string @@ -2058,6 +2089,7 @@ components: dnsZone: type: string expiry: + format: int64 type: integer hostname: type: string @@ -2104,6 +2136,7 @@ components: maxLength: 255 type: string expiry: + format: int64 type: integer hostname: maxLength: 255 @@ -2152,6 +2185,7 @@ components: subnetCidr: type: string ttl: + format: int64 type: integer required: - ipam @@ -2226,6 +2260,7 @@ components: maxLength: 40 type: string ttl: + format: int64 type: integer required: - subnetCidr @@ -2506,6 +2541,7 @@ components: DnsRoleConfig: properties: port: + format: int32 type: integer type: object EtcdAPIMember: @@ -2527,7 +2563,10 @@ components: maxLength: 255 type: string roles: - type: string + items: + type: string + nullable: true + type: array type: object EtcdAPIMemberJoinOutput: properties: @@ -2588,6 +2627,16 @@ components: - instanceIdentifier - instanceIP type: object + InstanceAPIInstancesPutInput: + properties: + roles: + items: + type: string + nullable: true + type: array + required: + - roles + type: object InstanceAPIRoleRestartInput: properties: roleId: @@ -2629,6 +2678,7 @@ components: MonitoringRoleConfig: properties: port: + format: int32 type: integer type: object RestErrResponse: @@ -2711,8 +2761,12 @@ components: enableLocal: type: boolean port: + format: int32 type: integer type: object + TimeDuration: + format: int64 + type: integer TsdbAPIRoleConfigInput: properties: config: @@ -2732,8 +2786,10 @@ components: enabled: type: boolean expire: + format: int64 type: integer scrape: + format: int64 type: integer type: object TypesAPIMetricsGetOutput: @@ -2759,6 +2815,7 @@ components: format: date-time type: string value: + format: int64 type: integer required: - time diff --git a/web/src/pages/cluster/ClusterNodeForm.ts b/web/src/pages/cluster/ClusterNodeForm.ts new file mode 100644 index 000000000..27f814b81 --- /dev/null +++ b/web/src/pages/cluster/ClusterNodeForm.ts @@ -0,0 +1,54 @@ +import { ClusterApi, ClusterInstancesApi, InstanceAPIClusterInfoOutput } from "gravity-api"; +import { KeyUnknown } from "src/elements/forms/Form"; +import { ModelForm } from "src/elements/forms/ModelForm"; +import { Roles } from "src/pages/cluster/RolesPage"; + +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import { DEFAULT_CONFIG } from "../../api/Config"; +import "../../elements/forms/FormGroup"; +import "../../elements/forms/HorizontalFormElement"; + +@customElement("gravity-cluster-node-form") +export class ClusterNodeForm extends ModelForm { + async loadInstance(): Promise { + const config = await new ClusterApi(DEFAULT_CONFIG).clusterGetClusterInfo(); + return config; + } + + getSuccessMessage(): string { + if (this.instance) { + return "Successfully updated instance."; + } else { + return "Successfully created instance."; + } + } + + send = (data: InstanceAPIClusterInfoOutput): Promise => { + const d = data as unknown as KeyUnknown; + return new ClusterInstancesApi(DEFAULT_CONFIG).clusterPutInstance({ + identifier: this.instancePk, + instanceAPIInstancesPutInput: { + roles: d.roles as string[], + }, + }); + }; + + renderForm(): TemplateResult { + return html` + ${Roles.map((role) => { + return html`
+ + +
`; + })} +

Select which roles the new node should provide

+
`; + } +} diff --git a/web/src/pages/cluster/ClusterNodesPage.ts b/web/src/pages/cluster/ClusterNodesPage.ts index 8ee1ea796..389d58607 100644 --- a/web/src/pages/cluster/ClusterNodesPage.ts +++ b/web/src/pages/cluster/ClusterNodesPage.ts @@ -18,6 +18,7 @@ import { PaginatedResponse, TableColumn } from "../../elements/table/Table"; import "../../elements/table/TableChart"; import { TablePage } from "../../elements/table/TablePage"; import { PaginationWrapper } from "../../utils"; +import "./ClusterNodeForm"; import "./wizard/ClusterJoinWizard"; @customElement("gravity-cluster-nodes") @@ -49,6 +50,7 @@ export class ClusterNodePage extends TablePage { new TableColumn("Roles"), new TableColumn("IP"), new TableColumn("Version"), + new TableColumn("Actions"), new TableColumn(""), ]; } @@ -63,6 +65,15 @@ export class ClusterNodePage extends TablePage { >`, html`${item.ip}`, html`${item.version}`, + html` + ${"Update"} + ${"Update Lease"} + + + + `, html` { diff --git a/web/src/pages/cluster/RoleBackupConfigForm.ts b/web/src/pages/cluster/roles/RoleBackupConfigForm.ts similarity index 96% rename from web/src/pages/cluster/RoleBackupConfigForm.ts rename to web/src/pages/cluster/roles/RoleBackupConfigForm.ts index c94f11f38..ce1073004 100644 --- a/web/src/pages/cluster/RoleBackupConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleBackupConfigForm.ts @@ -4,9 +4,9 @@ import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-backup-config") export class RoleBackupConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleDHCPConfigForm.ts b/web/src/pages/cluster/roles/RoleDHCPConfigForm.ts similarity index 92% rename from web/src/pages/cluster/RoleDHCPConfigForm.ts rename to web/src/pages/cluster/roles/RoleDHCPConfigForm.ts index fb944faa9..5ba2449f2 100644 --- a/web/src/pages/cluster/RoleDHCPConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleDHCPConfigForm.ts @@ -3,10 +3,10 @@ import { DhcpRoleConfig, RolesDhcpApi } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-dhcp-config") export class RoleDHCPConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleDNSConfigForm.ts b/web/src/pages/cluster/roles/RoleDNSConfigForm.ts similarity index 88% rename from web/src/pages/cluster/RoleDNSConfigForm.ts rename to web/src/pages/cluster/roles/RoleDNSConfigForm.ts index 3c97507f0..efde50875 100644 --- a/web/src/pages/cluster/RoleDNSConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleDNSConfigForm.ts @@ -3,10 +3,10 @@ import { DnsRoleConfig, RolesDnsApi } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-dns-config") export class RoleDNSConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleDiscoveryConfigForm.ts b/web/src/pages/cluster/roles/RoleDiscoveryConfigForm.ts similarity index 89% rename from web/src/pages/cluster/RoleDiscoveryConfigForm.ts rename to web/src/pages/cluster/roles/RoleDiscoveryConfigForm.ts index b537eb7c8..505795b02 100644 --- a/web/src/pages/cluster/RoleDiscoveryConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleDiscoveryConfigForm.ts @@ -3,10 +3,10 @@ import { DiscoveryRoleConfig, RolesDiscoveryApi } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-discovery-config") export class RoleDiscoveryConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleMonitoringConfigForm.ts b/web/src/pages/cluster/roles/RoleMonitoringConfigForm.ts similarity index 89% rename from web/src/pages/cluster/RoleMonitoringConfigForm.ts rename to web/src/pages/cluster/roles/RoleMonitoringConfigForm.ts index 6e86a23b1..1c32b7204 100644 --- a/web/src/pages/cluster/RoleMonitoringConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleMonitoringConfigForm.ts @@ -3,10 +3,10 @@ import { MonitoringRoleConfig, RolesMonitoringApi } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-monitoring-config") export class RoleMonitoringConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleTFTPConfigForm.ts b/web/src/pages/cluster/roles/RoleTFTPConfigForm.ts similarity index 92% rename from web/src/pages/cluster/RoleTFTPConfigForm.ts rename to web/src/pages/cluster/roles/RoleTFTPConfigForm.ts index f38fc0b2c..48a042e75 100644 --- a/web/src/pages/cluster/RoleTFTPConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleTFTPConfigForm.ts @@ -3,10 +3,10 @@ import { RolesTftpApi, TftpRoleConfig } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-tftp-config") export class RoleTFTPConfigForm extends ModelForm { diff --git a/web/src/pages/cluster/RoleTSDBConfigForm.ts b/web/src/pages/cluster/roles/RoleTSDBConfigForm.ts similarity index 93% rename from web/src/pages/cluster/RoleTSDBConfigForm.ts rename to web/src/pages/cluster/roles/RoleTSDBConfigForm.ts index d432b47e0..941492f5d 100644 --- a/web/src/pages/cluster/RoleTSDBConfigForm.ts +++ b/web/src/pages/cluster/roles/RoleTSDBConfigForm.ts @@ -3,10 +3,10 @@ import { RolesTsdbApi, TsdbRoleConfig } from "gravity-api"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { first } from "../../common/utils"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { first } from "../../../common/utils"; +import { ModelForm } from "../../../elements/forms/ModelForm"; import "../../elements/forms/HorizontalFormElement"; -import { ModelForm } from "../../elements/forms/ModelForm"; @customElement("gravity-cluster-role-tsdb-config") export class RoleTSDBConfigForm extends ModelForm {