mirror of
https://github.com/gravitational/teleport
synced 2024-10-19 16:53:57 +00:00
[Web:Discover] Bring back db service checker (#21062)
* [Backend]: allow more db fields to be updatable * Add preventing duplicate keys for label creator * Add sort compare fn helper * Update db request update object * Update CreateDatabase component - Uncomment and touch up db service checker - Fix db service label checking - Allow user to make changes to updatable db fields - Split defining db name and the other fields into two views, to prevent user from changing the db name which is not allowed with the update action * Address CR: use nil value to determine CACert update * Few more touch ups - address CR: don't OR update db request field CACert and use consistent camelCasing - remove the skip button for deploying service since we have the service checker now, which auto skips it for user if detects service - refactor label matching for both register database and deploy service steps * Address CR: make all fields updatable * Update service polling after rebase
This commit is contained in:
parent
9a605331f3
commit
8aefef1ae6
|
@ -6067,7 +6067,7 @@ func TestCreateDatabase(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUpdateDatabase(t *testing.T) {
|
||||
func TestUpdateDatabase_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
@ -6105,18 +6105,10 @@ func TestUpdateDatabase(t *testing.T) {
|
|||
expectedStatus int
|
||||
errAssert require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: fakeValidTLSCert,
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
errAssert: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "empty ca_cert",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: "",
|
||||
CACert: strPtr(""),
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
errAssert: func(tt require.TestingT, err error, i ...interface{}) {
|
||||
|
@ -6126,30 +6118,206 @@ func TestUpdateDatabase(t *testing.T) {
|
|||
{
|
||||
name: "invalid certificate",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: "Not a certificate",
|
||||
CACert: strPtr("Not a certificate"),
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
errAssert: func(tt require.TestingT, err error, i ...interface{}) {
|
||||
require.ErrorContains(tt, err, "could not parse provided CA as X.509 PEM certificate")
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "invalid awsRDS missing resourceID field",
|
||||
req: updateDatabaseRequest{
|
||||
AWSRDS: &awsRDS{
|
||||
AccountID: "123123123123",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
errAssert: func(tt require.TestingT, err error, i ...interface{}) {
|
||||
require.ErrorContains(tt, err, "missing aws rds field resource id")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid awsRDS missing accountID field",
|
||||
req: updateDatabaseRequest{
|
||||
AWSRDS: &awsRDS{
|
||||
ResourceID: "123123123123",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
errAssert: func(tt require.TestingT, err error, i ...interface{}) {
|
||||
require.ErrorContains(tt, err, "missing aws rds field account id")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no fields defined",
|
||||
req: updateDatabaseRequest{},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
errAssert: func(tt require.TestingT, err error, i ...interface{}) {
|
||||
require.ErrorContains(tt, err, "missing fields to update the database")
|
||||
},
|
||||
},
|
||||
} {
|
||||
// Update database's CA Cert
|
||||
updateDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases", databaseName)
|
||||
resp, err := pack.clt.PutJSON(ctx, updateDatabaseEndpoint, tt.req)
|
||||
tt.errAssert(t, err)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Update database's CA Cert
|
||||
updateDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases", databaseName)
|
||||
resp, err := pack.clt.PutJSON(ctx, updateDatabaseEndpoint, tt.req)
|
||||
tt.errAssert(t, err)
|
||||
|
||||
require.Equal(t, resp.Code(), tt.expectedStatus, "invalid status code received")
|
||||
require.Equal(t, resp.Code(), tt.expectedStatus, "invalid status code received")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
func TestUpdateDatabase_NonErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Ensure database was updated
|
||||
database, err := env.proxies[0].client.GetDatabase(ctx, databaseName)
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
databaseName := "somedb"
|
||||
username := "someuser"
|
||||
roleCreateUpdateDatabase, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
||||
Allow: types.RoleConditions{
|
||||
Rules: []types.Rule{
|
||||
types.NewRule(types.KindDatabase,
|
||||
[]string{types.VerbCreate, types.VerbUpdate, types.VerbRead}),
|
||||
},
|
||||
DatabaseLabels: types.Labels{
|
||||
types.Wildcard: {types.Wildcard},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, database.GetCA(), fakeValidTLSCert)
|
||||
env := newWebPack(t, 1)
|
||||
clusterName := env.server.ClusterName()
|
||||
pack := env.proxies[0].authPack(t, username, []types.Role{roleCreateUpdateDatabase})
|
||||
|
||||
// Create a database.
|
||||
dbProtocol := "mysql"
|
||||
database, err := getNewDatabaseResource(createDatabaseRequest{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
URI: "someuri:3306",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, env.server.Auth().CreateDatabase(ctx, database))
|
||||
|
||||
requiredOriginLabel := ui.Label{Name: types.OriginLabel, Value: types.OriginDynamic}
|
||||
|
||||
// Each test case builds on top of each other.
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
req updateDatabaseRequest
|
||||
expectedFields ui.Database
|
||||
expectedAWSRDS awsRDS
|
||||
}{
|
||||
{
|
||||
name: "update caCert",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: &fakeValidTLSCert,
|
||||
},
|
||||
expectedFields: ui.Database{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
Type: "self-hosted",
|
||||
Hostname: "someuri",
|
||||
Labels: []ui.Label{requiredOriginLabel},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update URI",
|
||||
req: updateDatabaseRequest{
|
||||
URI: "something-else:3306",
|
||||
},
|
||||
expectedFields: ui.Database{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
Type: "self-hosted",
|
||||
Hostname: "something-else",
|
||||
Labels: []ui.Label{requiredOriginLabel},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update aws rds fields",
|
||||
req: updateDatabaseRequest{
|
||||
URI: "llama.cgi8.us-west-2.rds.amazonaws.com:3306",
|
||||
AWSRDS: &awsRDS{
|
||||
AccountID: "123123123123",
|
||||
ResourceID: "db-1234",
|
||||
},
|
||||
},
|
||||
expectedAWSRDS: awsRDS{
|
||||
AccountID: "123123123123",
|
||||
ResourceID: "db-1234",
|
||||
},
|
||||
expectedFields: ui.Database{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
Type: "rds",
|
||||
Hostname: "llama.cgi8.us-west-2.rds.amazonaws.com",
|
||||
Labels: []ui.Label{requiredOriginLabel},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update labels",
|
||||
req: updateDatabaseRequest{
|
||||
Labels: []ui.Label{{Name: "env", Value: "prod"}},
|
||||
},
|
||||
expectedAWSRDS: awsRDS{
|
||||
AccountID: "123123123123",
|
||||
ResourceID: "db-1234",
|
||||
},
|
||||
expectedFields: ui.Database{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
Type: "rds",
|
||||
Hostname: "llama.cgi8.us-west-2.rds.amazonaws.com",
|
||||
Labels: []ui.Label{{Name: "env", Value: "prod"}, requiredOriginLabel},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update multiple fields",
|
||||
req: updateDatabaseRequest{
|
||||
URI: "alpaca.cgi8.us-east-1.rds.amazonaws.com:3306",
|
||||
AWSRDS: &awsRDS{
|
||||
AccountID: "000000000000",
|
||||
ResourceID: "db-0000",
|
||||
},
|
||||
},
|
||||
expectedAWSRDS: awsRDS{
|
||||
AccountID: "000000000000",
|
||||
ResourceID: "db-0000",
|
||||
},
|
||||
expectedFields: ui.Database{
|
||||
Name: databaseName,
|
||||
Protocol: dbProtocol,
|
||||
Type: "rds",
|
||||
Hostname: "alpaca.cgi8.us-east-1.rds.amazonaws.com",
|
||||
Labels: []ui.Label{{Name: "env", Value: "prod"}, requiredOriginLabel},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
updateDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases", databaseName)
|
||||
resp, err := pack.clt.PutJSON(ctx, updateDatabaseEndpoint, tt.req)
|
||||
require.NoError(t, err)
|
||||
var dbResp ui.Database
|
||||
require.NoError(t, json.Unmarshal(resp.Bytes(), &dbResp))
|
||||
require.Equal(t, tt.expectedFields, dbResp)
|
||||
|
||||
// Ensure database was updated
|
||||
database, err := env.proxies[0].client.GetDatabase(ctx, databaseName)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, database.GetCA(), fakeValidTLSCert) // should not have changed
|
||||
require.Equal(t, database.GetType(), tt.expectedFields.Type)
|
||||
require.Equal(t, database.GetProtocol(), tt.expectedFields.Protocol)
|
||||
require.Equal(t, database.GetURI(), fmt.Sprintf("%s:3306", tt.expectedFields.Hostname))
|
||||
|
||||
require.Equal(t, database.GetAWS().AccountID, tt.expectedAWSRDS.AccountID)
|
||||
require.Equal(t, database.GetAWS().RDS.ResourceID, tt.expectedAWSRDS.ResourceID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -86,35 +86,11 @@ func (h *Handler) handleDatabaseCreate(w http.ResponseWriter, r *http.Request, p
|
|||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for _, label := range req.Labels {
|
||||
labels[label.Name] = label.Value
|
||||
}
|
||||
|
||||
dbSpec := types.DatabaseSpecV3{
|
||||
Protocol: req.Protocol,
|
||||
URI: req.URI,
|
||||
}
|
||||
|
||||
if req.AWSRDS != nil {
|
||||
dbSpec.AWS = types.AWS{
|
||||
AccountID: req.AWSRDS.AccountID,
|
||||
RDS: types.RDS{
|
||||
ResourceID: req.AWSRDS.ResourceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
database, err := types.NewDatabaseV3(
|
||||
types.Metadata{
|
||||
Name: req.Name,
|
||||
Labels: labels,
|
||||
}, dbSpec)
|
||||
database, err := getNewDatabaseResource(*req)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
database.SetOrigin(types.OriginDynamic)
|
||||
clt, err := sctx.GetUserClient(r.Context(), site)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
@ -141,16 +117,35 @@ func (h *Handler) handleDatabaseCreate(w http.ResponseWriter, r *http.Request, p
|
|||
|
||||
// updateDatabaseRequest contains some updatable fields of a database resource.
|
||||
type updateDatabaseRequest struct {
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
CACert *string `json:"caCert,omitempty"`
|
||||
Labels []ui.Label `json:"labels,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
AWSRDS *awsRDS `json:"awsRds,omitempty"`
|
||||
}
|
||||
|
||||
func (r *updateDatabaseRequest) checkAndSetDefaults() error {
|
||||
if r.CACert == "" {
|
||||
return trace.BadParameter("missing CA certificate data")
|
||||
if r.CACert != nil {
|
||||
if *r.CACert == "" {
|
||||
return trace.BadParameter("missing CA certificate data")
|
||||
}
|
||||
|
||||
if _, err := tlsutils.ParseCertificatePEM([]byte(*r.CACert)); err != nil {
|
||||
return trace.BadParameter("could not parse provided CA as X.509 PEM certificate")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tlsutils.ParseCertificatePEM([]byte(r.CACert)); err != nil {
|
||||
return trace.BadParameter("could not parse provided CA as X.509 PEM certificate")
|
||||
// These fields can't be empty if set.
|
||||
if r.AWSRDS != nil {
|
||||
if r.AWSRDS.ResourceID == "" {
|
||||
return trace.BadParameter("missing aws rds field resource id")
|
||||
}
|
||||
if r.AWSRDS.AccountID == "" {
|
||||
return trace.BadParameter("missing aws rds field account id")
|
||||
}
|
||||
}
|
||||
|
||||
if r.CACert == nil && r.AWSRDS == nil && r.Labels == nil && r.URI == "" {
|
||||
return trace.BadParameter("missing fields to update the database")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -182,7 +177,45 @@ func (h *Handler) handleDatabaseUpdate(w http.ResponseWriter, r *http.Request, p
|
|||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
database.SetCA(req.CACert)
|
||||
savedOrNewCaCert := database.GetCA()
|
||||
if req.CACert != nil {
|
||||
savedOrNewCaCert = *req.CACert
|
||||
}
|
||||
|
||||
savedOrNewAWSRDS := awsRDS{
|
||||
AccountID: database.GetAWS().AccountID,
|
||||
ResourceID: database.GetAWS().RDS.ResourceID,
|
||||
}
|
||||
if req.AWSRDS != nil {
|
||||
savedOrNewAWSRDS = awsRDS{
|
||||
AccountID: req.AWSRDS.AccountID,
|
||||
ResourceID: req.AWSRDS.ResourceID,
|
||||
}
|
||||
}
|
||||
|
||||
savedOrNewURI := req.URI
|
||||
if len(savedOrNewURI) == 0 {
|
||||
savedOrNewURI = database.GetURI()
|
||||
}
|
||||
|
||||
savedLabels := database.GetStaticLabels()
|
||||
|
||||
// Make a new database to reset the check and set defaulted fields.
|
||||
database, err = getNewDatabaseResource(createDatabaseRequest{
|
||||
Name: databaseName,
|
||||
Protocol: database.GetProtocol(),
|
||||
URI: savedOrNewURI,
|
||||
Labels: req.Labels,
|
||||
AWSRDS: &savedOrNewAWSRDS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
database.SetCA(savedOrNewCaCert)
|
||||
if len(req.Labels) == 0 {
|
||||
database.SetStaticLabels(savedLabels)
|
||||
}
|
||||
|
||||
if err := clt.UpdateDatabase(r.Context(), database); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
@ -271,3 +304,37 @@ func fetchDatabaseWithName(ctx context.Context, clt resourcesAPIGetter, r *http.
|
|||
return servers[0].GetDatabase(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func getNewDatabaseResource(req createDatabaseRequest) (*types.DatabaseV3, error) {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range req.Labels {
|
||||
labels[label.Name] = label.Value
|
||||
}
|
||||
|
||||
dbSpec := types.DatabaseSpecV3{
|
||||
Protocol: req.Protocol,
|
||||
URI: req.URI,
|
||||
}
|
||||
|
||||
if req.AWSRDS != nil {
|
||||
dbSpec.AWS = types.AWS{
|
||||
AccountID: req.AWSRDS.AccountID,
|
||||
RDS: types.RDS{
|
||||
ResourceID: req.AWSRDS.ResourceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
database, err := types.NewDatabaseV3(
|
||||
types.Metadata{
|
||||
Name: req.Name,
|
||||
Labels: labels,
|
||||
}, dbSpec)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
database.SetOrigin(types.OriginDynamic)
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
|
|
@ -170,14 +170,14 @@ func TestUpdateDatabaseRequestParameters(t *testing.T) {
|
|||
{
|
||||
desc: "valid",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: fakeValidTLSCert,
|
||||
CACert: &fakeValidTLSCert,
|
||||
},
|
||||
errAssert: require.NoError,
|
||||
},
|
||||
{
|
||||
desc: "invalid missing ca_cert",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: "",
|
||||
CACert: strPtr(""),
|
||||
},
|
||||
errAssert: func(t require.TestingT, err error, i ...interface{}) {
|
||||
require.Error(t, err)
|
||||
|
@ -187,7 +187,7 @@ func TestUpdateDatabaseRequestParameters(t *testing.T) {
|
|||
{
|
||||
desc: "invalid ca_cert format",
|
||||
req: updateDatabaseRequest{
|
||||
CACert: "ca_cert",
|
||||
CACert: strPtr("ca_cert"),
|
||||
},
|
||||
errAssert: func(t require.TestingT, err error, i ...interface{}) {
|
||||
require.Error(t, err)
|
||||
|
@ -363,3 +363,7 @@ func requireDatabaseIAMPolicyAWS(t *testing.T, respBody []byte, database types.D
|
|||
require.Equal(t, expectedPolicyDocument, actualPolicyDocument)
|
||||
require.Equal(t, []string(expectedPlaceholders), resp.AWS.Placeholders)
|
||||
}
|
||||
|
||||
func strPtr(str string) *string {
|
||||
return &str
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ const FieldInput = forwardRef<HTMLInputElement, Props>(
|
|||
readonly = false,
|
||||
toolTipContent = null,
|
||||
disabled = false,
|
||||
markAsError = false,
|
||||
...styles
|
||||
},
|
||||
ref
|
||||
|
@ -47,13 +48,12 @@ const FieldInput = forwardRef<HTMLInputElement, Props>(
|
|||
const { valid, message } = useRule(rule(value));
|
||||
const hasError = !valid;
|
||||
const labelText = hasError ? message : label;
|
||||
|
||||
const $inputElement = (
|
||||
<Input
|
||||
mt={1}
|
||||
ref={ref}
|
||||
type={type}
|
||||
hasError={hasError}
|
||||
hasError={hasError || markAsError}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
|
@ -128,6 +128,10 @@ type Props = {
|
|||
max?: number;
|
||||
toolTipContent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
// markAsError is a flag to highlight an
|
||||
// input box as error color before validator
|
||||
// runs (which marks it as error)
|
||||
markAsError?: boolean;
|
||||
// TS: temporary handles ...styles
|
||||
[key: string]: any;
|
||||
};
|
||||
|
|
|
@ -75,7 +75,8 @@ const props: State = {
|
|||
clearAttempt: () => null,
|
||||
registerDatabase: () => null,
|
||||
canCreateDatabase: true,
|
||||
// pollTimeout: Date.now() + 30000,
|
||||
pollTimeout: Date.now() + 30000,
|
||||
dbEngine: DatabaseEngine.PostgreSQL,
|
||||
dbLocation: DatabaseLocation.SelfHosted,
|
||||
isDbCreateErr: false,
|
||||
};
|
||||
|
|
|
@ -14,26 +14,24 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
|
||||
// AnimatedProgressBar,
|
||||
// ButtonPrimary,
|
||||
// ButtonSecondary,
|
||||
AnimatedProgressBar,
|
||||
ButtonPrimary,
|
||||
ButtonSecondary,
|
||||
} from 'design';
|
||||
import { Danger } from 'design/Alert';
|
||||
|
||||
// import Dialog, { DialogContent } from 'design/DialogConfirmation';
|
||||
// import * as Icons from 'design/Icon';
|
||||
import Dialog, { DialogContent } from 'design/DialogConfirmation';
|
||||
import * as Icons from 'design/Icon';
|
||||
import Validation, { Validator } from 'shared/components/Validation';
|
||||
import FieldInput from 'shared/components/FieldInput';
|
||||
import { requiredField } from 'shared/components/Validation/rules';
|
||||
import TextEditor from 'shared/components/TextEditor';
|
||||
|
||||
// import { Timeout } from 'teleport/Discover/Shared/Timeout';
|
||||
import { Timeout } from 'teleport/Discover/Shared/Timeout';
|
||||
|
||||
import {
|
||||
ActionButtons,
|
||||
|
@ -41,7 +39,7 @@ import {
|
|||
Header,
|
||||
LabelsCreater,
|
||||
Mark,
|
||||
// TextIcon,
|
||||
TextIcon,
|
||||
} from '../../Shared';
|
||||
import { dbCU } from '../../yamlTemplates';
|
||||
import {
|
||||
|
@ -54,7 +52,7 @@ import { useCreateDatabase, State } from './useCreateDatabase';
|
|||
|
||||
import type { AgentStepProps } from '../../types';
|
||||
import type { AgentLabel } from 'teleport/services/agents';
|
||||
// import type { Attempt } from 'shared/hooks/useAttemptNext';
|
||||
import type { Attempt } from 'shared/hooks/useAttemptNext';
|
||||
import type { AwsRds } from 'teleport/services/databases';
|
||||
|
||||
export function CreateDatabase(props: AgentStepProps) {
|
||||
|
@ -64,12 +62,13 @@ export function CreateDatabase(props: AgentStepProps) {
|
|||
|
||||
export function CreateDatabaseView({
|
||||
attempt,
|
||||
// clearAttempt,
|
||||
clearAttempt,
|
||||
registerDatabase,
|
||||
canCreateDatabase,
|
||||
// pollTimeout,
|
||||
pollTimeout,
|
||||
dbEngine,
|
||||
dbLocation,
|
||||
isDbCreateErr,
|
||||
}: State) {
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [dbUri, setDbUri] = useState('');
|
||||
|
@ -80,11 +79,28 @@ export function CreateDatabaseView({
|
|||
const [awsAccountId, setAwsAccountId] = useState('');
|
||||
const [awsResourceId, setAwsResourceId] = useState('');
|
||||
|
||||
function handleOnProceed(validator: Validator) {
|
||||
const [finishedFirstStep, setFinishedFirstStep] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If error resulted from creating a db, reset the view
|
||||
// to the beginning as the error could be from duplicate
|
||||
// db name.
|
||||
if (isDbCreateErr) {
|
||||
setFinishedFirstStep(false);
|
||||
}
|
||||
}, [isDbCreateErr]);
|
||||
|
||||
function handleOnProceed(validator: Validator, retry = false) {
|
||||
if (!validator.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!retry && !finishedFirstStep) {
|
||||
setFinishedFirstStep(true);
|
||||
validator.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
let awsRds: AwsRds;
|
||||
if (dbLocation === DatabaseLocation.AWS) {
|
||||
awsRds = {
|
||||
|
@ -102,6 +118,7 @@ export function CreateDatabaseView({
|
|||
});
|
||||
}
|
||||
|
||||
const isAws = dbLocation === DatabaseLocation.AWS;
|
||||
return (
|
||||
<Validation>
|
||||
{({ validator }) => (
|
||||
|
@ -110,9 +127,6 @@ export function CreateDatabaseView({
|
|||
<HeaderSubtitle>
|
||||
Create a new database resource for the database server.
|
||||
</HeaderSubtitle>
|
||||
{attempt.status === 'failed' && (
|
||||
<Danger children={attempt.statusText} />
|
||||
)}
|
||||
{!canCreateDatabase && (
|
||||
<Box>
|
||||
<Text>
|
||||
|
@ -131,85 +145,112 @@ export function CreateDatabaseView({
|
|||
)}
|
||||
{canCreateDatabase && (
|
||||
<>
|
||||
<Box width="500px">
|
||||
<FieldInput
|
||||
label="Database Name"
|
||||
// We need this name to comply with AWS policy name
|
||||
// since it will be used as part of the policy name
|
||||
// for the AWS flow.
|
||||
rule={requiredField('database name is required')}
|
||||
autoFocus
|
||||
value={dbName}
|
||||
placeholder="Enter database name"
|
||||
onChange={e => setDbName(e.target.value)}
|
||||
toolTipContent="An identifier name for this new database for Teleport."
|
||||
/>
|
||||
</Box>
|
||||
<Flex width="500px">
|
||||
<FieldInput
|
||||
label="Database Connection Endpoint"
|
||||
rule={requiredField('connection endpoint is required')}
|
||||
value={dbUri}
|
||||
placeholder="db.example.com"
|
||||
onChange={e => setDbUri(e.target.value)}
|
||||
toolTipContent="Database location and connection information."
|
||||
width="50%"
|
||||
mr={2}
|
||||
/>
|
||||
<FieldInput
|
||||
label="Endpoint Port"
|
||||
rule={requirePort}
|
||||
value={dbPort}
|
||||
placeholder="5432"
|
||||
onChange={e => setDbPort(e.target.value)}
|
||||
width="50%"
|
||||
/>
|
||||
</Flex>
|
||||
{dbLocation === DatabaseLocation.AWS && (
|
||||
{!finishedFirstStep && (
|
||||
<Box width="500px">
|
||||
<FieldInput
|
||||
label="Database Name"
|
||||
rule={requiredField('database name is required')}
|
||||
autoFocus
|
||||
value={dbName}
|
||||
placeholder="Enter database name"
|
||||
onChange={e => setDbName(e.target.value)}
|
||||
toolTipContent="An identifier name for this new database for Teleport."
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{finishedFirstStep && (
|
||||
<>
|
||||
<Box width="500px">
|
||||
<Flex width="500px">
|
||||
<FieldInput
|
||||
label="AWS Account ID"
|
||||
rule={requiredAwsAccountId}
|
||||
value={awsAccountId}
|
||||
placeholder="123456789012"
|
||||
onChange={e => setAwsAccountId(e.target.value)}
|
||||
toolTipContent="A 12-digit number that uniquely identifies your AWS account."
|
||||
/>
|
||||
</Box>
|
||||
<Box width="500px" mb={6}>
|
||||
<FieldInput
|
||||
label="Resource ID"
|
||||
value={awsResourceId}
|
||||
rule={requiredField('database resource ID is required')}
|
||||
placeholder="db-ABCDE1234567..."
|
||||
onChange={e => setAwsResourceId(e.target.value)}
|
||||
toolTipContent={
|
||||
<Text>
|
||||
The unique identifier for your resource. For AWS
|
||||
database, may have the prefix <Mark light>db-</Mark>{' '}
|
||||
then follow with alphanumerics.
|
||||
</Text>
|
||||
autoFocus
|
||||
label="Database Connection Endpoint"
|
||||
rule={
|
||||
isAws
|
||||
? requireAwsEndpoint
|
||||
: requiredField('connection endpoint is required')
|
||||
}
|
||||
value={dbUri}
|
||||
placeholder={
|
||||
isAws
|
||||
? 'db.example.us-west-1.rds.amazonaws.com'
|
||||
: 'db.example.com'
|
||||
}
|
||||
onChange={e => setDbUri(e.target.value)}
|
||||
width="70%"
|
||||
mr={2}
|
||||
toolTipContent={
|
||||
isAws ? (
|
||||
<Text>
|
||||
Database location and connection information.
|
||||
Typically in the format:{' '}
|
||||
<Mark
|
||||
light
|
||||
>{`<your-db-identifier>.<random-id>.<your-region>.rds.amazonaws.com`}</Mark>
|
||||
</Text>
|
||||
) : (
|
||||
'Database location and connection information.'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label="Endpoint Port"
|
||||
rule={requirePort}
|
||||
value={dbPort}
|
||||
placeholder="5432"
|
||||
onChange={e => setDbPort(e.target.value)}
|
||||
width="30%"
|
||||
/>
|
||||
</Flex>
|
||||
{dbLocation === DatabaseLocation.AWS && (
|
||||
<>
|
||||
<Box width="500px">
|
||||
<FieldInput
|
||||
label="AWS Account ID"
|
||||
rule={requiredAwsAccountId}
|
||||
value={awsAccountId}
|
||||
placeholder="123456789012"
|
||||
onChange={e => setAwsAccountId(e.target.value)}
|
||||
toolTipContent="A 12-digit number that uniquely identifies your AWS account."
|
||||
/>
|
||||
</Box>
|
||||
<Box width="500px" mb={6}>
|
||||
<FieldInput
|
||||
label="Resource ID"
|
||||
value={awsResourceId}
|
||||
rule={requiredField(
|
||||
'database resource ID is required'
|
||||
)}
|
||||
placeholder="db-ABCDE1234567..."
|
||||
onChange={e => setAwsResourceId(e.target.value)}
|
||||
toolTipContent={
|
||||
<Text>
|
||||
The unique identifier for your resource. May have
|
||||
the prefix <Mark light>db-</Mark> then follow with
|
||||
alphanumerics.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box mt={3}>
|
||||
<Text bold>Labels (optional)</Text>
|
||||
<Text mb={2}>
|
||||
Labels make this new database discoverable by the database
|
||||
service. <br />
|
||||
Not defining labels is equivalent to asteriks (any
|
||||
database service can discover this database).
|
||||
</Text>
|
||||
<LabelsCreater
|
||||
labels={labels}
|
||||
setLabels={setLabels}
|
||||
isLabelOptional={true}
|
||||
disableBtns={attempt.status === 'processing'}
|
||||
noDuplicateKey={true}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box mt={3}>
|
||||
<Text bold>Labels (optional)</Text>
|
||||
<Text mb={2}>
|
||||
Labels make this new database discoverable by the database
|
||||
service. <br />
|
||||
Not defining labels is equivalent to asteriks (any database
|
||||
service can discover this database).
|
||||
</Text>
|
||||
<LabelsCreater
|
||||
labels={labels}
|
||||
setLabels={setLabels}
|
||||
isLabelOptional={true}
|
||||
disableBtns={attempt.status === 'processing'}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<ActionButtons
|
||||
|
@ -219,82 +260,82 @@ export function CreateDatabaseView({
|
|||
attempt.status === 'processing' || !canCreateDatabase
|
||||
}
|
||||
/>
|
||||
{/* {(attempt.status === 'processing' || attempt.status === 'failed') && (
|
||||
{(attempt.status === 'processing' || attempt.status === 'failed') && (
|
||||
<CreateDatabaseDialog
|
||||
pollTimeout={pollTimeout}
|
||||
attempt={attempt}
|
||||
retry={() => handleOnProceed(validator)}
|
||||
retry={() => handleOnProceed(validator, true /* retry */)}
|
||||
close={clearAttempt}
|
||||
/>
|
||||
)} */}
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Validation>
|
||||
);
|
||||
}
|
||||
|
||||
// const CreateDatabaseDialog = ({
|
||||
// pollTimeout,
|
||||
// attempt,
|
||||
// retry,
|
||||
// close,
|
||||
// }: {
|
||||
// pollTimeout: number;
|
||||
// attempt: Attempt;
|
||||
// retry(): void;
|
||||
// close(): void;
|
||||
// }) => {
|
||||
// return (
|
||||
// <Dialog disableEscapeKeyDown={false} open={true}>
|
||||
// <DialogContent
|
||||
// width="400px"
|
||||
// alignItems="center"
|
||||
// mb={0}
|
||||
// textAlign="center"
|
||||
// >
|
||||
// {attempt.status !== 'failed' ? (
|
||||
// <>
|
||||
// {' '}
|
||||
// <Text bold caps mb={4}>
|
||||
// Registering Database
|
||||
// </Text>
|
||||
// <AnimatedProgressBar />
|
||||
// <TextIcon
|
||||
// css={`
|
||||
// white-space: pre;
|
||||
// `}
|
||||
// >
|
||||
// <Icons.Restore fontSize={4} />
|
||||
// <Timeout
|
||||
// timeout={pollTimeout}
|
||||
// message=""
|
||||
// tailMessage={' seconds left'}
|
||||
// />
|
||||
// </TextIcon>
|
||||
// </>
|
||||
// ) : (
|
||||
// <Box width="100%">
|
||||
// <Text bold caps mb={3}>
|
||||
// Database Register Failed
|
||||
// </Text>
|
||||
// <Text mb={5}>
|
||||
// <Icons.Warning ml={1} mr={2} color="danger" />
|
||||
// Error: {attempt.statusText}
|
||||
// </Text>
|
||||
// <Flex>
|
||||
// <ButtonPrimary mr={2} width="50%" onClick={retry}>
|
||||
// Retry
|
||||
// </ButtonPrimary>
|
||||
// <ButtonSecondary width="50%" onClick={close}>
|
||||
// Close
|
||||
// </ButtonSecondary>
|
||||
// </Flex>
|
||||
// </Box>
|
||||
// )}
|
||||
// </DialogContent>
|
||||
// </Dialog>
|
||||
// );
|
||||
// };
|
||||
const CreateDatabaseDialog = ({
|
||||
pollTimeout,
|
||||
attempt,
|
||||
retry,
|
||||
close,
|
||||
}: {
|
||||
pollTimeout: number;
|
||||
attempt: Attempt;
|
||||
retry(): void;
|
||||
close(): void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog disableEscapeKeyDown={false} open={true}>
|
||||
<DialogContent
|
||||
width="400px"
|
||||
alignItems="center"
|
||||
mb={0}
|
||||
textAlign="center"
|
||||
>
|
||||
{attempt.status !== 'failed' ? (
|
||||
<>
|
||||
{' '}
|
||||
<Text bold caps mb={4}>
|
||||
Registering Database
|
||||
</Text>
|
||||
<AnimatedProgressBar />
|
||||
<TextIcon
|
||||
css={`
|
||||
white-space: pre;
|
||||
`}
|
||||
>
|
||||
<Icons.Restore fontSize={4} />
|
||||
<Timeout
|
||||
timeout={pollTimeout}
|
||||
message=""
|
||||
tailMessage={' seconds left'}
|
||||
/>
|
||||
</TextIcon>
|
||||
</>
|
||||
) : (
|
||||
<Box width="100%">
|
||||
<Text bold caps mb={3}>
|
||||
Database Register Failed
|
||||
</Text>
|
||||
<Text mb={5}>
|
||||
<Icons.Warning ml={1} mr={2} color="danger" />
|
||||
Error: {attempt.statusText}
|
||||
</Text>
|
||||
<Flex>
|
||||
<ButtonPrimary mr={2} width="50%" onClick={retry}>
|
||||
Retry
|
||||
</ButtonPrimary>
|
||||
<ButtonSecondary width="50%" onClick={close}>
|
||||
Close
|
||||
</ButtonSecondary>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// PORT_REGEXP only allows digits with length 4.
|
||||
export const PORT_REGEX = /^\d{4}$/;
|
||||
|
@ -326,6 +367,25 @@ const requiredAwsAccountId = value => () => {
|
|||
};
|
||||
};
|
||||
|
||||
const requireAwsEndpoint = value => () => {
|
||||
const parts = value.split('.');
|
||||
// Following possible format (bare mininum len has to be 6):
|
||||
// (len 6) test.abcd.us-west-2.rds.amazonaws.com
|
||||
// (len 7) test.abcd.suffix.us-west-2.rds.amazonaws.com
|
||||
// (len 8) test.abcd.suffix.us-west-2.rds.amazonaws.com.cn
|
||||
const hasCorrectLen = parts.length >= 6; // loosely match
|
||||
if (!hasCorrectLen || !value.includes('.rds.amazonaws.com')) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'invalid connection endpoint format',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
};
|
||||
};
|
||||
|
||||
// TODO(lisa): this check and the backend check does not match
|
||||
// re-visit and let backend do the checking for now.
|
||||
//
|
||||
|
|
|
@ -14,19 +14,38 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// import React from 'react';
|
||||
// import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
// import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
// import { ContextProvider } from 'teleport';
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import { ContextProvider } from 'teleport';
|
||||
import { DiscoverProvider } from 'teleport/Discover/useDiscover';
|
||||
import api from 'teleport/services/api';
|
||||
import { FeaturesContextProvider } from 'teleport/FeaturesContext';
|
||||
import {
|
||||
DiscoverEvent,
|
||||
DiscoverEventResource,
|
||||
userEventService,
|
||||
} from 'teleport/services/userEvent';
|
||||
import cfg from 'teleport/config';
|
||||
|
||||
import {
|
||||
// useCreateDatabase,
|
||||
useCreateDatabase,
|
||||
findActiveDatabaseSvc,
|
||||
// WAITING_TIMEOUT,
|
||||
WAITING_TIMEOUT,
|
||||
} from './useCreateDatabase';
|
||||
|
||||
// import type { CreateDatabaseRequest } from 'teleport/services/databases';
|
||||
import type { CreateDatabaseRequest } from 'teleport/services/databases';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// eslint-disable-next-line jest/require-hook
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: {
|
||||
randomUUID: () => crypto.randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
const dbLabels = [
|
||||
{ name: 'env', value: 'prod' },
|
||||
|
@ -34,236 +53,412 @@ const dbLabels = [
|
|||
{ name: 'tag', value: 'v11.0.0' },
|
||||
];
|
||||
|
||||
const emptyAwsIdentity = {
|
||||
accountId: '',
|
||||
arn: '',
|
||||
resourceType: '',
|
||||
resourceName: '',
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { os: ['windows', 'linux'], env: ['staging'] },
|
||||
matcherLabels: { os: ['windows', 'mac'], env: ['staging'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc2', // match
|
||||
matcherLabels: {
|
||||
os: ['windows', 'mac', 'linux'],
|
||||
tag: ['v11.0.0'],
|
||||
env: ['staging', 'prod'],
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc3',
|
||||
matcherLabels: { env: ['prod'], fruit: ['orange'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{ name: 'svc2', matcherLabels: { fruit: ['orange'] } },
|
||||
{ name: 'svc3', matcherLabels: { os: ['windows', 'mac'] } }, // match
|
||||
];
|
||||
|
||||
describe('findActiveDatabaseSvc', () => {
|
||||
test.each`
|
||||
desc | dbLabels | services | expected
|
||||
${'match with multi elements'} | ${dbLabels} | ${services} | ${true}
|
||||
${'match by asteriks'} | ${[]} | ${[{ matcherLabels: { '*': ['*'] } }]} | ${true}
|
||||
${'match by asteriks with labels defined'} | ${dbLabels} | ${[{ matcherLabels: { id: ['123', '123'], '*': ['*'] } }]} | ${true}
|
||||
${'match by any key, matching val'} | ${dbLabels} | ${[{ matcherLabels: { '*': ['windows', 'mac'] } }]} | ${true}
|
||||
${'match by any key, no matching val'} | ${dbLabels} | ${[{ matcherLabels: { '*': ['windows', 'linux'] } }]} | ${false}
|
||||
${'match by any val, matching key'} | ${dbLabels} | ${[{ matcherLabels: { test: ['*'], tag: ['*'] } }]} | ${true}
|
||||
${'match by any val, no matching key'} | ${dbLabels} | ${[{ matcherLabels: { test: ['*'], test2: ['*'] } }]} | ${false}
|
||||
${'no match'} | ${dbLabels} | ${[{ matcherLabels: { os: ['linux', 'windows'] } }]} | ${false}
|
||||
${'no match with empty lists'} | ${[]} | ${[]} | ${false}
|
||||
${'no match with empty fields'} | ${[{ name: '', value: '' }]} | ${[{ matcherLabels: {} }]} | ${false}
|
||||
${'no match with any key'} | ${[]} | ${[{ matcherLabels: { '*': ['mac'] } }]} | ${false}
|
||||
${'no match with any val'} | ${[]} | ${[{ matcherLabels: { os: ['*'] } }]} | ${false}
|
||||
`('$desc', ({ dbLabels, services, expected }) => {
|
||||
expect(findActiveDatabaseSvc(dbLabels, services)).toEqual(expected);
|
||||
});
|
||||
const testCases = [
|
||||
{
|
||||
name: 'match in multiple services',
|
||||
newLabels: dbLabels,
|
||||
services,
|
||||
expectedMatch: 'svc2',
|
||||
},
|
||||
{
|
||||
name: 'no match despite matching all labels when a svc has a non-matching label',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { os: ['windows', 'mac'], env: ['staging'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc2',
|
||||
matcherLabels: {
|
||||
os: ['windows', 'mac', 'linux'],
|
||||
tag: ['v11.0.0'],
|
||||
env: ['staging', 'prod'],
|
||||
fruit: ['apple', '*'], // the non-matching label
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc3',
|
||||
matcherLabels: { env: ['prod'], fruit: ['orange'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
{
|
||||
name: 'match by all asteriks',
|
||||
newLabels: [],
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { '*': ['dev'], env: ['*'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc2',
|
||||
matcherLabels: { '*': ['*'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: 'svc2',
|
||||
},
|
||||
{
|
||||
name: 'match by asteriks, despite labels being defined',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { id: ['env', 'dev'], a: [], '*': ['*'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: 'svc1',
|
||||
},
|
||||
{
|
||||
name: 'match by any key, matching its val',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { env: ['*'], '*': ['dev'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
{
|
||||
name: 'svc2',
|
||||
matcherLabels: {
|
||||
os: ['linux', 'mac'],
|
||||
'*': ['prod', 'apple', 'v11.0.0'],
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: 'svc2',
|
||||
},
|
||||
{
|
||||
name: 'no matching value for any key',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: { '*': ['windows', 'mac'] },
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
{
|
||||
name: 'match by any val, matching its key',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: {
|
||||
env: ['dev', '*'],
|
||||
os: ['windows', 'mac'],
|
||||
tag: ['*'],
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: 'svc1',
|
||||
},
|
||||
{
|
||||
name: 'no matching key for any value',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: {
|
||||
fruit: ['*'],
|
||||
os: ['mac'],
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
{
|
||||
name: 'no match',
|
||||
newLabels: dbLabels,
|
||||
services: [
|
||||
{
|
||||
name: 'svc1',
|
||||
matcherLabels: {
|
||||
fruit: ['*'],
|
||||
},
|
||||
awsIdentity: emptyAwsIdentity,
|
||||
},
|
||||
],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
{
|
||||
name: 'no match with empty service list',
|
||||
newLabels: dbLabels,
|
||||
services: [],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
{
|
||||
name: 'no match with empty label fields',
|
||||
newLabels: dbLabels,
|
||||
services: [{ name: '', matcherLabels: {}, awsIdentity: emptyAwsIdentity }],
|
||||
expectedMatch: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testCases)('$name', ({ newLabels, services, expectedMatch }) => {
|
||||
const foundSvc = findActiveDatabaseSvc(newLabels, services);
|
||||
expect(foundSvc?.name).toEqual(expectedMatch);
|
||||
});
|
||||
|
||||
// const newDatabaseReq: CreateDatabaseRequest = {
|
||||
// name: 'db-name',
|
||||
// protocol: 'postgres',
|
||||
// uri: 'https://localhost:5432',
|
||||
// labels: dbLabels,
|
||||
// };
|
||||
const newDatabaseReq: CreateDatabaseRequest = {
|
||||
name: 'db-name',
|
||||
protocol: 'postgres',
|
||||
uri: 'https://localhost:5432',
|
||||
labels: dbLabels,
|
||||
};
|
||||
|
||||
// jest.useFakeTimers();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// describe('registering new databases, mainly error checking', () => {
|
||||
// const props = {
|
||||
// agentMeta: {} as any,
|
||||
// updateAgentMeta: jest.fn(x => x),
|
||||
// nextStep: jest.fn(x => x),
|
||||
// resourceState: {},
|
||||
// };
|
||||
// const ctx = createTeleportContext();
|
||||
describe('registering new databases, mainly error checking', () => {
|
||||
const props = {
|
||||
agentMeta: {} as any,
|
||||
updateAgentMeta: jest.fn(x => x),
|
||||
nextStep: jest.fn(x => x),
|
||||
resourceState: {},
|
||||
eventState: {
|
||||
currEventName: DiscoverEvent.DatabaseRegister,
|
||||
id: '1234',
|
||||
resource: DiscoverEventResource.DatabaseMysqlSelfHosted,
|
||||
},
|
||||
};
|
||||
const ctx = createTeleportContext();
|
||||
|
||||
// let wrapper;
|
||||
let wrapper;
|
||||
|
||||
// beforeEach(() => {
|
||||
// jest
|
||||
// .spyOn(ctx.databaseService, 'fetchDatabases')
|
||||
// .mockResolvedValue({ agents: [{ name: 'new-db' } as any] });
|
||||
// jest.spyOn(ctx.databaseService, 'createDatabase').mockResolvedValue(null); // ret val not used
|
||||
// jest
|
||||
// .spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
// .mockResolvedValue({ services });
|
||||
beforeEach(() => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue([]); // required for fetchClusterAlerts
|
||||
|
||||
// wrapper = ({ children }) => (
|
||||
// <ContextProvider ctx={ctx}>{children}</ContextProvider>
|
||||
// );
|
||||
// });
|
||||
jest
|
||||
.spyOn(userEventService, 'captureDiscoverEvent')
|
||||
.mockResolvedValue(null as never); // return value does not matter but required by ts
|
||||
jest
|
||||
.spyOn(ctx.databaseService, 'fetchDatabases')
|
||||
.mockResolvedValue({ agents: [{ name: 'new-db' } as any] });
|
||||
jest.spyOn(ctx.databaseService, 'createDatabase').mockResolvedValue(null); // ret val not used
|
||||
jest.spyOn(ctx.databaseService, 'updateDatabase').mockResolvedValue(null); // ret val not used
|
||||
jest
|
||||
.spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
.mockResolvedValue({ services });
|
||||
|
||||
// afterEach(() => {
|
||||
// jest.clearAllMocks();
|
||||
// });
|
||||
wrapper = ({ children }) => (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'database' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<FeaturesContextProvider value={[]}>
|
||||
<DiscoverProvider>{children}</DiscoverProvider>
|
||||
</FeaturesContextProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('with matching service, activates polling', async () => {
|
||||
// const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
// wrapper,
|
||||
// });
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// // Check polling hasn't started.
|
||||
// expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
test('with matching service, activates polling', async () => {
|
||||
const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase(newDatabaseReq);
|
||||
// });
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// Check polling hasn't started.
|
||||
expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
|
||||
// await act(async () => jest.advanceTimersByTime(3000));
|
||||
// expect(ctx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1);
|
||||
// expect(props.nextStep).toHaveBeenCalledWith(2);
|
||||
// expect(props.updateAgentMeta).toHaveBeenCalledWith({
|
||||
// resourceName: 'db-name',
|
||||
// agentMatcherLabels: dbLabels,
|
||||
// db: { name: 'new-db' },
|
||||
// });
|
||||
// });
|
||||
await act(async () => {
|
||||
result.current.registerDatabase(newDatabaseReq);
|
||||
});
|
||||
expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('when there are no services, skips polling', async () => {
|
||||
// jest
|
||||
// .spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
// .mockResolvedValue({ services: [] } as any);
|
||||
// const { result, waitFor } = renderHook(() => useCreateDatabase(props), {
|
||||
// wrapper,
|
||||
// });
|
||||
await act(async () => jest.advanceTimersByTime(3000));
|
||||
expect(ctx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1);
|
||||
expect(props.nextStep).toHaveBeenCalledWith(2);
|
||||
expect(props.updateAgentMeta).toHaveBeenCalledWith({
|
||||
resourceName: 'db-name',
|
||||
agentMatcherLabels: dbLabels,
|
||||
db: { name: 'new-db' },
|
||||
});
|
||||
});
|
||||
|
||||
// act(() => {
|
||||
// result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
// });
|
||||
test('when there are no services, skips polling', async () => {
|
||||
jest
|
||||
.spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
.mockResolvedValue({ services: [] } as any);
|
||||
const { result, waitFor } = renderHook(() => useCreateDatabase(props), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// });
|
||||
act(() => {
|
||||
result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
});
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(
|
||||
// 1
|
||||
// );
|
||||
// });
|
||||
await waitFor(() => {
|
||||
expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// expect(props.nextStep).toHaveBeenCalledWith();
|
||||
// expect(props.updateAgentMeta).toHaveBeenCalledWith({
|
||||
// resourceName: 'db-name',
|
||||
// agentMatcherLabels: [],
|
||||
// });
|
||||
// expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
// });
|
||||
await waitFor(() => {
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('when failed to create db, stops flow', async () => {
|
||||
// jest.spyOn(ctx.databaseService, 'createDatabase').mockRejectedValue(null);
|
||||
// const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
// wrapper,
|
||||
// });
|
||||
expect(props.nextStep).toHaveBeenCalledWith();
|
||||
expect(props.updateAgentMeta).toHaveBeenCalledWith({
|
||||
resourceName: 'db-name',
|
||||
agentMatcherLabels: [],
|
||||
});
|
||||
expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
// });
|
||||
test('when failed to create db, stops flow', async () => {
|
||||
jest.spyOn(ctx.databaseService, 'createDatabase').mockRejectedValue(null);
|
||||
const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
// expect(props.nextStep).not.toHaveBeenCalled();
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
// });
|
||||
await act(async () => {
|
||||
result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('when failed to fetch services, stops flow and retries properly', async () => {
|
||||
// jest
|
||||
// .spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
// .mockRejectedValue(null);
|
||||
// const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
// wrapper,
|
||||
// });
|
||||
expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
expect(props.nextStep).not.toHaveBeenCalled();
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
});
|
||||
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
// });
|
||||
test('when failed to fetch services, stops flow and retries properly', async () => {
|
||||
jest
|
||||
.spyOn(ctx.databaseService, 'fetchDatabaseServices')
|
||||
.mockRejectedValue(null);
|
||||
const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
// expect(props.nextStep).not.toHaveBeenCalled();
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
await act(async () => {
|
||||
result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
});
|
||||
|
||||
// // Test retrying with same request, skips creating database since it's been already created.
|
||||
// jest.clearAllMocks();
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
// });
|
||||
// expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabases).not.toHaveBeenCalled();
|
||||
expect(props.nextStep).not.toHaveBeenCalled();
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
|
||||
// // Test retrying with a new db request (new name), triggers create database.
|
||||
// jest.clearAllMocks();
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase({
|
||||
// ...newDatabaseReq,
|
||||
// labels: [],
|
||||
// name: 'new-db-name',
|
||||
// });
|
||||
// });
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
// });
|
||||
// Test retrying with same request, skips creating database since it's been already created.
|
||||
jest.clearAllMocks();
|
||||
await act(async () => {
|
||||
result.current.registerDatabase({ ...newDatabaseReq, labels: [] });
|
||||
});
|
||||
expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('when polling timeout, retries properly', async () => {
|
||||
// jest
|
||||
// .spyOn(ctx.databaseService, 'fetchDatabases')
|
||||
// .mockResolvedValue({ agents: [] });
|
||||
// const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
// wrapper,
|
||||
// });
|
||||
// Test retrying with updated field, triggers create database.
|
||||
jest.clearAllMocks();
|
||||
await act(async () => {
|
||||
result.current.registerDatabase({
|
||||
...newDatabaseReq,
|
||||
labels: [],
|
||||
uri: 'diff-uri',
|
||||
});
|
||||
});
|
||||
expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
expect(ctx.databaseService.updateDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
});
|
||||
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase(newDatabaseReq);
|
||||
// });
|
||||
test('when polling timeout, retries properly', async () => {
|
||||
jest
|
||||
.spyOn(ctx.databaseService, 'fetchDatabases')
|
||||
.mockResolvedValue({ agents: [] });
|
||||
const { result } = renderHook(() => useCreateDatabase(props), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
await act(async () => {
|
||||
result.current.registerDatabase(newDatabaseReq);
|
||||
});
|
||||
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
// expect(props.nextStep).not.toHaveBeenCalled();
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
// expect(result.current.attempt.statusText).toContain('could not detect');
|
||||
act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
|
||||
// // Test retrying with same request, skips creating database.
|
||||
// jest.clearAllMocks();
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase(newDatabaseReq);
|
||||
// });
|
||||
// act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
expect(props.nextStep).not.toHaveBeenCalled();
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
expect(result.current.attempt.statusText).toContain('could not detect');
|
||||
|
||||
// expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
// Test retrying with same request, skips creating database.
|
||||
jest.clearAllMocks();
|
||||
await act(async () => {
|
||||
result.current.registerDatabase(newDatabaseReq);
|
||||
});
|
||||
act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
|
||||
// // Test retrying with request with diff db name, creates and fetches new services.
|
||||
// jest.clearAllMocks();
|
||||
// await act(async () => {
|
||||
// result.current.registerDatabase({
|
||||
// ...newDatabaseReq,
|
||||
// name: 'new-db-name',
|
||||
// });
|
||||
// });
|
||||
// act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
|
||||
// expect(ctx.databaseService.createDatabase).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
// expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
// expect(result.current.attempt.status).toBe('failed');
|
||||
// });
|
||||
// });
|
||||
// Test retrying with request with updated fields, updates db and fetches new services.
|
||||
jest.clearAllMocks();
|
||||
await act(async () => {
|
||||
result.current.registerDatabase({
|
||||
...newDatabaseReq,
|
||||
uri: 'diff-uri',
|
||||
});
|
||||
});
|
||||
act(() => jest.advanceTimersByTime(WAITING_TIMEOUT + 1));
|
||||
|
||||
expect(ctx.databaseService.updateDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.createDatabase).not.toHaveBeenCalled();
|
||||
expect(ctx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.databaseService.fetchDatabases).toHaveBeenCalled();
|
||||
expect(result.current.attempt.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,19 +13,22 @@
|
|||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import useAttempt from 'shared/hooks/useAttemptNext';
|
||||
|
||||
import useTeleport from 'teleport/useTeleport';
|
||||
import { useDiscover } from 'teleport/Discover/useDiscover';
|
||||
// import { usePoll } from 'teleport/Discover/Shared/usePoll';
|
||||
import { usePoll } from 'teleport/Discover/Shared/usePoll';
|
||||
import { compareByString } from 'teleport/lib/util';
|
||||
|
||||
import { Database } from '../resources';
|
||||
import { matchLabels, makeLabelMaps } from '../util';
|
||||
|
||||
import type { AgentStepProps } from '../../types';
|
||||
import type {
|
||||
CreateDatabaseRequest,
|
||||
// Database as DatabaseResource,
|
||||
Database as DatabaseResource,
|
||||
DatabaseService,
|
||||
} from 'teleport/services/databases';
|
||||
import type { AgentLabel } from 'teleport/services/agents';
|
||||
|
@ -39,8 +42,13 @@ export function useCreateDatabase(props: AgentStepProps) {
|
|||
const { attempt, setAttempt } = useAttempt('');
|
||||
const { emitErrorEvent } = useDiscover();
|
||||
|
||||
// const [pollTimeout, setPollTimeout] = useState(0);
|
||||
// const [pollActive, setPollActive] = useState(false);
|
||||
// isDbCreateErr is a flag that indicates
|
||||
// attempt failed from trying to create a database.
|
||||
const [isDbCreateErr, setIsDbCreateErr] = useState(false);
|
||||
|
||||
const [pollTimeout, setPollTimeout] = useState(0);
|
||||
const [pollActive, setPollActive] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
// Required persisted states to determine if we can skip a request
|
||||
// because there can be multiple failed points:
|
||||
|
@ -50,151 +58,174 @@ export function useCreateDatabase(props: AgentStepProps) {
|
|||
// - timed out due to combined previous requests taking longer than WAITING_TIMEOUT
|
||||
// - timed out due to failure to query (this would most likely be some kind of
|
||||
// backend error or network failure)
|
||||
// const [newDb, setNewDb] = useState<CreateDatabaseRequest>();
|
||||
const [createdDb, setCreatedDb] = useState<CreateDatabaseRequest>();
|
||||
|
||||
// const { timedOut, result } = usePoll<DatabaseResource>(
|
||||
// signal => fetchDatabaseServer(signal),
|
||||
// pollTimeout,
|
||||
// pollActive,
|
||||
// 3000 // interval: poll every 3 seconds
|
||||
// );
|
||||
const result = usePoll<DatabaseResource>(
|
||||
signal => fetchDatabaseServer(signal),
|
||||
pollActive,
|
||||
3000 // interval: poll every 3 seconds
|
||||
);
|
||||
|
||||
// // Handles polling timeout.
|
||||
// useEffect(() => {
|
||||
// if (pollActive && Date.now() > pollTimeout) {
|
||||
// setPollActive(false);
|
||||
// setAttempt({
|
||||
// status: 'failed',
|
||||
// statusText:
|
||||
// 'Teleport could not detect your new database in time. Please try again.',
|
||||
// });
|
||||
// }
|
||||
// }, [pollActive, pollTimeout, timedOut]);
|
||||
// Handles polling timeout.
|
||||
useEffect(() => {
|
||||
if (pollActive && pollTimeout > Date.now()) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTimedOut(true);
|
||||
}, pollTimeout - Date.now());
|
||||
|
||||
// // Handles when polling successfully gets
|
||||
// // a response.
|
||||
// useEffect(() => {
|
||||
// if (!result) return;
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [pollActive, pollTimeout]);
|
||||
|
||||
// setPollTimeout(null);
|
||||
// setPollActive(false);
|
||||
useEffect(() => {
|
||||
if (timedOut) {
|
||||
// reset timer fields and set errors.
|
||||
setPollTimeout(null);
|
||||
setPollActive(false);
|
||||
setTimedOut(false);
|
||||
setAttempt({
|
||||
status: 'failed',
|
||||
statusText:
|
||||
'Teleport could not detect your new database in time. Please try again.',
|
||||
});
|
||||
emitErrorEvent(
|
||||
`timeout polling for new database with an existing service`
|
||||
);
|
||||
}
|
||||
}, [timedOut]);
|
||||
|
||||
// const numStepsToSkip = 2;
|
||||
// props.updateAgentMeta({
|
||||
// ...(props.agentMeta as DbMeta),
|
||||
// resourceName: newDb.name,
|
||||
// agentMatcherLabels: newDb.labels,
|
||||
// db: result,
|
||||
// });
|
||||
// Handles when polling successfully gets
|
||||
// a response.
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
|
||||
// props.nextStep(numStepsToSkip);
|
||||
// }, [result]);
|
||||
setPollTimeout(null);
|
||||
setPollActive(false);
|
||||
|
||||
// function fetchDatabaseServer(signal: AbortSignal) {
|
||||
// const request = {
|
||||
// search: newDb.name,
|
||||
// limit: 1,
|
||||
// };
|
||||
// return ctx.databaseService
|
||||
// .fetchDatabases(clusterId, request, signal)
|
||||
// .then(res => {
|
||||
// if (res.agents.length) {
|
||||
// return res.agents[0];
|
||||
// }
|
||||
// return null;
|
||||
// });
|
||||
// }
|
||||
const numStepsToSkip = 2;
|
||||
props.updateAgentMeta({
|
||||
...(props.agentMeta as DbMeta),
|
||||
resourceName: createdDb.name,
|
||||
agentMatcherLabels: createdDb.labels,
|
||||
db: result,
|
||||
});
|
||||
|
||||
props.nextStep(numStepsToSkip);
|
||||
}, [result]);
|
||||
|
||||
function fetchDatabaseServer(signal: AbortSignal) {
|
||||
const request = {
|
||||
search: createdDb.name,
|
||||
limit: 1,
|
||||
};
|
||||
return ctx.databaseService
|
||||
.fetchDatabases(clusterId, request, signal)
|
||||
.then(res => {
|
||||
if (res.agents.length) {
|
||||
return res.agents[0];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function registerDatabase(db: CreateDatabaseRequest) {
|
||||
// // Set the timeout now, because this entire registering process
|
||||
// // should take less than WAITING_TIMEOUT.
|
||||
// setPollTimeout(Date.now() + WAITING_TIMEOUT);
|
||||
// setAttempt({ status: 'processing' });
|
||||
// Set the timeout now, because this entire registering process
|
||||
// should take less than WAITING_TIMEOUT.
|
||||
setPollTimeout(Date.now() + WAITING_TIMEOUT);
|
||||
setAttempt({ status: 'processing' });
|
||||
setIsDbCreateErr(false);
|
||||
|
||||
// Attempt creating a new Database resource.
|
||||
// Handles a case where if there was a later failure point
|
||||
// and user decides to change the database fields, a new database
|
||||
// is created (ONLY if the database name has changed since this
|
||||
// request operation is only a CREATE operation).
|
||||
// if (!newDb || db.name != newDb.name) {
|
||||
try {
|
||||
const createdDb = await ctx.databaseService
|
||||
.createDatabase(clusterId, db)
|
||||
.catch((error: Error) => {
|
||||
emitErrorEvent(error.message);
|
||||
throw error;
|
||||
if (!createdDb) {
|
||||
try {
|
||||
await ctx.databaseService.createDatabase(clusterId, db);
|
||||
setCreatedDb(db);
|
||||
} catch (err) {
|
||||
handleRequestError(err, 'failed to create database: ');
|
||||
setIsDbCreateErr(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function requiresDbUpdate() {
|
||||
if (!createdDb) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (createdDb.labels.length === db.labels.length) {
|
||||
// Sort by label keys.
|
||||
const a = createdDb.labels.sort((a, b) =>
|
||||
compareByString(a.name, b.name)
|
||||
);
|
||||
const b = db.labels.sort((a, b) => compareByString(a.name, b.name));
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
createdDb.uri !== db.uri ||
|
||||
createdDb.awsRds?.accountId !== db.awsRds?.accountId ||
|
||||
createdDb.awsRds?.resourceId !== db.awsRds?.resourceId
|
||||
);
|
||||
}
|
||||
|
||||
// Check and see if database resource need to be updated.
|
||||
if (requiresDbUpdate()) {
|
||||
try {
|
||||
await ctx.databaseService.updateDatabase(clusterId, {
|
||||
...db,
|
||||
});
|
||||
// setNewDb(db);
|
||||
props.updateAgentMeta({
|
||||
...(props.agentMeta as DbMeta),
|
||||
resourceName: db.name,
|
||||
agentMatcherLabels: db.labels,
|
||||
db: createdDb,
|
||||
});
|
||||
props.nextStep();
|
||||
return;
|
||||
setCreatedDb(db);
|
||||
} catch (err) {
|
||||
handleRequestError(err, 'failed to update database: ');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// See if this new database can be picked up by an existing
|
||||
// database service. If there is no active database service,
|
||||
// user is led to the next step.
|
||||
try {
|
||||
const { services } = await ctx.databaseService.fetchDatabaseServices(
|
||||
clusterId
|
||||
);
|
||||
|
||||
if (!findActiveDatabaseSvc(db.labels, services)) {
|
||||
props.updateAgentMeta({
|
||||
...(props.agentMeta as DbMeta),
|
||||
resourceName: db.name,
|
||||
agentMatcherLabels: db.labels,
|
||||
});
|
||||
props.nextStep();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
handleRequestError(err);
|
||||
handleRequestError(err, 'failed to fetch database services: ');
|
||||
return;
|
||||
}
|
||||
// }
|
||||
|
||||
// TODO(lisa): temporary see if we can query this database.
|
||||
// try {
|
||||
// const { services } = await ctx.databaseService.fetchDatabaseServices(
|
||||
// clusterId
|
||||
// );
|
||||
|
||||
// if (!findActiveDatabaseSvc(db.labels, services)) {
|
||||
// props.updateAgentMeta({
|
||||
// ...(props.agentMeta as DbMeta),
|
||||
// resourceName: db.name,
|
||||
// agentMatcherLabels: db.labels,
|
||||
// });
|
||||
// props.nextStep();
|
||||
// return;
|
||||
// }
|
||||
// } catch (err) {
|
||||
// handleRequestError(err);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // See if this new database can be picked up by an existing
|
||||
// // database service. If there is no active database service,
|
||||
// // user is led to the next step.
|
||||
// try {
|
||||
// const { services } = await ctx.databaseService.fetchDatabaseServices(
|
||||
// clusterId
|
||||
// );
|
||||
|
||||
// if (!findActiveDatabaseSvc(db.labels, services)) {
|
||||
// props.updateAgentMeta({
|
||||
// ...(props.agentMeta as DbMeta),
|
||||
// resourceName: db.name,
|
||||
// agentMatcherLabels: db.labels,
|
||||
// });
|
||||
// props.nextStep();
|
||||
// return;
|
||||
// }
|
||||
// } catch (err) {
|
||||
// handleRequestError(err);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Start polling until new database is picked up by an
|
||||
// // existing database service.
|
||||
// setPollActive(true);
|
||||
// Start polling until new database is picked up by an
|
||||
// existing database service.
|
||||
setPollActive(true);
|
||||
}
|
||||
|
||||
function clearAttempt() {
|
||||
setAttempt({ status: '' });
|
||||
}
|
||||
|
||||
function handleRequestError(err) {
|
||||
let message;
|
||||
function handleRequestError(err: Error, preErrMsg = '') {
|
||||
let message = 'something went wrong';
|
||||
if (err instanceof Error) message = err.message;
|
||||
else message = String(err);
|
||||
setAttempt({ status: 'failed', statusText: message });
|
||||
emitErrorEvent(`${preErrMsg}${message}`);
|
||||
}
|
||||
|
||||
const access = ctx.storeUser.getDatabaseAccess();
|
||||
|
@ -204,9 +235,10 @@ export function useCreateDatabase(props: AgentStepProps) {
|
|||
clearAttempt,
|
||||
registerDatabase,
|
||||
canCreateDatabase: access.create,
|
||||
// pollTimeout,
|
||||
pollTimeout,
|
||||
dbEngine: dbState.engine,
|
||||
dbLocation: dbState.location,
|
||||
isDbCreateErr,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -217,58 +249,29 @@ export function findActiveDatabaseSvc(
|
|||
dbServices: DatabaseService[]
|
||||
) {
|
||||
if (!dbServices.length) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a map for db labels for easy lookup.
|
||||
let dbKeyMap = {};
|
||||
let dbValMap = {};
|
||||
// Create maps for easy lookup and matching.
|
||||
const { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap } =
|
||||
makeLabelMaps(newDbLabels);
|
||||
|
||||
newDbLabels.forEach(label => {
|
||||
dbKeyMap[label.name] = label.value;
|
||||
dbValMap[label.value] = label.name;
|
||||
});
|
||||
|
||||
// Check if any service contains an asterik for labels.
|
||||
// This means the service with asterik
|
||||
// can pick up any database despite labels.
|
||||
const hasLabelsToMatch = newDbLabels.length > 0;
|
||||
for (let i = 0; i < dbServices.length; i++) {
|
||||
for (const [key, vals] of Object.entries(dbServices[i].matcherLabels)) {
|
||||
const foundAsterikAsValue = vals.includes('*');
|
||||
// Check if this service contains any labels with asteriks,
|
||||
// which means this service can pick up any database regardless
|
||||
// of labels.
|
||||
if (key === '*' && foundAsterikAsValue) {
|
||||
return true;
|
||||
}
|
||||
// Loop through the current service label keys and its value set.
|
||||
const currService = dbServices[i];
|
||||
const match = matchLabels({
|
||||
hasLabelsToMatch,
|
||||
labelKeysToMatchMap,
|
||||
labelValsToMatchMap,
|
||||
labelToMatchSeenMap,
|
||||
matcherLabels: currService.matcherLabels,
|
||||
});
|
||||
|
||||
// If no newDbLabels labels were defined, no need to look for other matches
|
||||
// continue to next key.
|
||||
if (!newDbLabels.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start matching every combination.
|
||||
|
||||
// This means any key is fine, as long as a
|
||||
// value matches.
|
||||
if (key === '*' && vals.find(val => dbValMap[val])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// This means any value is fine, as long as a
|
||||
// key matches.
|
||||
if (foundAsterikAsValue && dbKeyMap[key]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match against key and value.
|
||||
const dbVal = dbKeyMap[key];
|
||||
if (dbVal && vals.find(val => val === dbVal)) {
|
||||
return true;
|
||||
}
|
||||
if (match) {
|
||||
return currService;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { matchLabels } from './DownloadScript';
|
||||
import { hasMatchingLabels } from './DownloadScript';
|
||||
|
||||
const dbLabels = [
|
||||
{ name: 'os', value: 'mac' },
|
||||
|
@ -22,27 +22,93 @@ const dbLabels = [
|
|||
{ name: 'tag', value: 'v11.0.0' },
|
||||
];
|
||||
|
||||
const agentLabels = [
|
||||
{ name: 'a', value: 'b' },
|
||||
{ name: 'aa', value: 'bb' },
|
||||
{ name: 'tag', value: 'v11.0.0' }, // match
|
||||
{ name: 'aaa', value: 'bbb' },
|
||||
const testCases = [
|
||||
{
|
||||
name: 'match with exact values',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
{ name: 'tag', value: 'v11.0.0' }, // match
|
||||
{ name: 'os', value: 'linux' },
|
||||
{ name: 'os', value: 'windows' },
|
||||
{ name: 'env', value: 'prod' }, // match
|
||||
{ name: 'env', value: 'dev' },
|
||||
{ name: 'os', value: 'mac' }, // match
|
||||
{ name: 'tag', value: 'v12.0.0' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'match by all asteriks',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
{ name: 'fruit', value: 'apple' },
|
||||
{ name: '*', value: '*' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'match by all asteriks, with no dbLabels defined',
|
||||
dbLabels: [],
|
||||
agentLabels: [
|
||||
{ name: 'fruit', value: 'apple' },
|
||||
{ name: '*', value: '*' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'match by key asteriks',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
{ name: 'os', value: '*' },
|
||||
{ name: 'env', value: '*' },
|
||||
{ name: 'tag', value: '*' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'match by value asteriks',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
{ name: '*', value: 'prod' },
|
||||
{ name: '*', value: 'mac' },
|
||||
{ name: '*', value: 'v11.0.0' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'match by asteriks and exacts',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
{ name: 'os', value: 'windows' },
|
||||
{ name: '*', value: 'prod' },
|
||||
{ name: 'os', value: '*' },
|
||||
{ name: 'tag', value: 'v11.0.0' },
|
||||
{ name: 'tag', value: 'v12.0.0' },
|
||||
{ name: '*', value: 'banana' },
|
||||
],
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: 'no match despite having all matching labels',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [
|
||||
...dbLabels,
|
||||
{ name: 'fruit', value: 'banana' }, // the culprit
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'no matches',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [{ name: 'fruit', value: 'banana' }],
|
||||
},
|
||||
{
|
||||
name: 'no matches with empty agentLabels list',
|
||||
dbLabels: dbLabels,
|
||||
agentLabels: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('matchLabels', () => {
|
||||
test.each`
|
||||
desc | agentLabels | expected
|
||||
${'match with multi elements'} | ${agentLabels} | ${true}
|
||||
${'match by both fields'} | ${[{ name: 'os', value: 'mac' }]} | ${true}
|
||||
${'match by asteriks'} | ${[{ name: '*', value: '*' }]} | ${true}
|
||||
${'match by value'} | ${[{ name: '*', value: 'mac' }]} | ${true}
|
||||
${'match by key'} | ${[{ name: 'os', value: '*' }]} | ${true}
|
||||
${'no match'} | ${[{ name: 'os', value: 'windows' }]} | ${false}
|
||||
${'no match with any key'} | ${[{ name: '*', value: 'windows' }]} | ${false}
|
||||
${'no match with any val'} | ${[{ name: 'id', value: '*' }]} | ${false}
|
||||
${'no match with empty list'} | ${[]} | ${false}
|
||||
${'no match with empty fields'} | ${[{ name: '', value: '' }]} | ${false}
|
||||
`('$desc', ({ agentLabels, expected }) => {
|
||||
expect(matchLabels(dbLabels, agentLabels)).toEqual(expected);
|
||||
});
|
||||
test.each(testCases)('$name', ({ dbLabels, agentLabels, expectedMatch }) => {
|
||||
const match = hasMatchingLabels(dbLabels, agentLabels);
|
||||
expect(match).toEqual(Boolean(expectedMatch));
|
||||
});
|
||||
|
|
|
@ -50,6 +50,7 @@ import {
|
|||
TextIcon,
|
||||
useShowHint,
|
||||
} from '../../Shared';
|
||||
import { makeLabelMaps, matchLabels } from '../util';
|
||||
|
||||
import type { AgentStepProps } from '../../types';
|
||||
|
||||
|
@ -84,11 +85,7 @@ export default function Container(props: AgentStepProps) {
|
|||
Encountered Error: {fbProps.error.message}
|
||||
</TextIcon>
|
||||
</Box>
|
||||
<ActionButtons
|
||||
onProceed={() => null}
|
||||
disableProceed={true}
|
||||
onSkip={() => props.nextStep(0)}
|
||||
/>
|
||||
<ActionButtons onProceed={() => null} disableProceed={true} />
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
|
@ -97,11 +94,7 @@ export default function Container(props: AgentStepProps) {
|
|||
<Box>
|
||||
<Heading />
|
||||
<Labels {...labelProps} disableBtns={true} />
|
||||
<ActionButtons
|
||||
onProceed={() => null}
|
||||
disableProceed={true}
|
||||
onSkip={() => props.nextStep(0)}
|
||||
/>
|
||||
<ActionButtons onProceed={() => null} disableProceed={true} />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
|
@ -115,11 +108,7 @@ export default function Container(props: AgentStepProps) {
|
|||
>
|
||||
Generate Command
|
||||
</ButtonSecondary>
|
||||
<ActionButtons
|
||||
onProceed={() => null}
|
||||
disableProceed={true}
|
||||
onSkip={() => props.nextStep(0)}
|
||||
/>
|
||||
<ActionButtons onProceed={() => null} disableProceed={true} />
|
||||
</Box>
|
||||
)}
|
||||
{showScript && (
|
||||
|
@ -234,7 +223,6 @@ export function DownloadScript(
|
|||
<ActionButtons
|
||||
onProceed={handleNextStep}
|
||||
disableProceed={!result || props.labels.length === 0}
|
||||
onSkip={() => props.nextStep(0)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
@ -300,58 +288,50 @@ function createBashCommand(tokenId: string) {
|
|||
|
||||
const requireMatchingLabels =
|
||||
(dbLabels: AgentLabel[], agentLabels: AgentLabel[]) => () => {
|
||||
if (!matchLabels(dbLabels, agentLabels)) {
|
||||
if (!hasMatchingLabels(dbLabels, agentLabels)) {
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
'At least one matching label needs to be defined. \
|
||||
Asteriks can also be used to match any databases.',
|
||||
message: `Labels must match with the labels defined for the database resource. \
|
||||
To match any key, and/or any value, asteriks can be used.`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// matchLabels will go through 'agentLabels' and find a match from
|
||||
// 'dbLabels' (if an agent label matches with a db label, then the
|
||||
// db will be discoverable by the agent).
|
||||
export function matchLabels(dbLabels: AgentLabel[], agentLabels: AgentLabel[]) {
|
||||
let dbKeyMap = {};
|
||||
let dbValMap = {};
|
||||
|
||||
dbLabels.forEach(label => {
|
||||
dbKeyMap[label.name] = label.value;
|
||||
dbValMap[label.value] = label.name;
|
||||
// hasMatchingLabels will go through each 'agentLabels' and find matches from
|
||||
// 'dbLabels'. The 'agentLabels' must have same amount of matching labels
|
||||
// with 'dbLabels' either with asteriks (match all) or by exact match.
|
||||
//
|
||||
// `agentLabels` have OR comparison eg:
|
||||
// - If agent labels was defined like this [`fruit: apple`, `fruit: banana`]
|
||||
// it's translated as `fruit: [apple OR banana]`.
|
||||
//
|
||||
// Asteriks can be used for keys, values, or both key and value eg:
|
||||
// - `fruit: *` match by key `fruit` with any value
|
||||
// - `*: apple` match by value `apple` with any key
|
||||
// - `*: *` match by any key and any value
|
||||
export function hasMatchingLabels(
|
||||
dbLabels: AgentLabel[],
|
||||
agentLabels: AgentLabel[]
|
||||
) {
|
||||
// Convert agentLabels into a map of key of value arrays.
|
||||
const matcherLabels: Record<string, string[]> = {};
|
||||
agentLabels.forEach(l => {
|
||||
if (!matcherLabels[l.name]) {
|
||||
matcherLabels[l.name] = [];
|
||||
}
|
||||
matcherLabels[l.name] = [...matcherLabels[l.name], l.value];
|
||||
});
|
||||
|
||||
for (let i = 0; i < agentLabels.length; i++) {
|
||||
const agentLabel = agentLabels[i];
|
||||
const agentLabelKey = agentLabel.name;
|
||||
const agentLabelVal = agentLabel.value;
|
||||
// Create maps for easy lookup and matching.
|
||||
const { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap } =
|
||||
makeLabelMaps(dbLabels);
|
||||
|
||||
// All asterik means an agent can discover any database
|
||||
// by any labels (or no labels).
|
||||
if (agentLabelKey === '*' && agentLabelVal === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Key only asterik means an agent can discover any database
|
||||
// with any matching value.
|
||||
if (agentLabelKey === '*' && dbValMap[agentLabelVal]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Value only asterik means an agent can discover any database
|
||||
// with any matching key.
|
||||
if (agentLabelVal === '*' && dbKeyMap[agentLabelKey]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match against words.
|
||||
const dbVal = dbKeyMap[agentLabel.name];
|
||||
if (dbVal && dbVal === agentLabelVal) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return matchLabels({
|
||||
hasLabelsToMatch: dbLabels.length > 0,
|
||||
labelKeysToMatchMap,
|
||||
labelValsToMatchMap,
|
||||
labelToMatchSeenMap,
|
||||
matcherLabels,
|
||||
});
|
||||
}
|
||||
|
|
118
web/packages/teleport/src/Discover/Database/util.ts
Normal file
118
web/packages/teleport/src/Discover/Database/util.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Copyright 2023 Gravitational, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { AgentLabel } from 'teleport/services/agents';
|
||||
|
||||
// makeLabelMaps makes a few lookup tables out of the label prop
|
||||
// for easy lookup:
|
||||
// - lookup table with label.name as key, and label.value as value
|
||||
// - lookup table with label.value as key, and label.key as value
|
||||
// - lookup table of flags with label.name as key, and booleans as value
|
||||
// which serves to record seen labels.
|
||||
export function makeLabelMaps(labels: AgentLabel[]) {
|
||||
let labelKeysToMatchMap: Record<string, string> = {};
|
||||
let labelValsToMatchMap: Record<string, string> = {};
|
||||
let labelToMatchSeenMap: Record<string, boolean> = {};
|
||||
|
||||
labels.forEach(label => {
|
||||
labelKeysToMatchMap[label.name] = label.value;
|
||||
labelToMatchSeenMap[label.name] = false;
|
||||
labelValsToMatchMap[label.value] = label.name;
|
||||
});
|
||||
|
||||
return { labelKeysToMatchMap, labelValsToMatchMap, labelToMatchSeenMap };
|
||||
}
|
||||
|
||||
// matchLabels will go through each `matcherlabels` and record matched labels.
|
||||
// If all labels are matched (or all asteriks exists in `matcherLabels`),
|
||||
// returns true.
|
||||
//
|
||||
// It will return false when:
|
||||
// - there were no labels to match
|
||||
// - `matcherLabel` contains a label that isn't seen in the `xxxToMatchMap`
|
||||
export function matchLabels({
|
||||
hasLabelsToMatch,
|
||||
matcherLabels,
|
||||
labelKeysToMatchMap,
|
||||
labelValsToMatchMap,
|
||||
labelToMatchSeenMap,
|
||||
}: {
|
||||
hasLabelsToMatch: boolean;
|
||||
matcherLabels: Record<string, string[]>;
|
||||
labelKeysToMatchMap: Record<string, string>;
|
||||
labelValsToMatchMap: Record<string, string>;
|
||||
labelToMatchSeenMap: Record<string, boolean>;
|
||||
}) {
|
||||
const matchedLabelMap = { ...labelToMatchSeenMap };
|
||||
// Sorted to have asteriks be the first label key to test.
|
||||
const entries = Object.entries({ ...matcherLabels }).sort();
|
||||
for (const [key, vals] of entries) {
|
||||
// Check if the label contains asteriks, which means match all eg:
|
||||
// a service with match all can pick up any database regardless of other labels
|
||||
// or no labels.
|
||||
const foundAsterikAsValue = vals.includes('*');
|
||||
if (key === '*' && foundAsterikAsValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasLabelsToMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start matching by value.
|
||||
|
||||
// This means any key is fine, as long as value matches.
|
||||
if (key === '*') {
|
||||
let found = false;
|
||||
vals.forEach(val => {
|
||||
const key = labelValsToMatchMap[val];
|
||||
if (key) {
|
||||
matchedLabelMap[key] = true;
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
if (found) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// This means any value is fine, as long as a key matches.
|
||||
// Note that db resource labels can't have duplicate keys
|
||||
// (but db service can).
|
||||
else if (foundAsterikAsValue && labelKeysToMatchMap[key]) {
|
||||
matchedLabelMap[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match against actual values of key and its value.
|
||||
else {
|
||||
const dbVal = labelKeysToMatchMap[key];
|
||||
if (dbVal && vals.find(val => val === dbVal)) {
|
||||
matchedLabelMap[key] = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, the current label did not match any criteria,
|
||||
// we can abort, since it takes only one mismatch to fail.
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
hasLabelsToMatch &&
|
||||
Object.keys(matchedLabelMap).every(key => matchedLabelMap[key])
|
||||
);
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Flex, ButtonIcon, ButtonText } from 'design';
|
||||
import { Box, Flex, ButtonIcon, ButtonText, Text } from 'design';
|
||||
import * as Icons from 'design/Icon';
|
||||
import FieldInput from 'shared/components/FieldInput';
|
||||
import { useValidation, Validator } from 'shared/components/Validation';
|
||||
|
@ -28,11 +28,13 @@ export function LabelsCreater({
|
|||
setLabels,
|
||||
disableBtns = false,
|
||||
isLabelOptional = false,
|
||||
noDuplicateKey = false,
|
||||
}: {
|
||||
labels: DiscoverLabel[];
|
||||
setLabels(l: DiscoverLabel[]): void;
|
||||
disableBtns?: boolean;
|
||||
isLabelOptional?: boolean;
|
||||
noDuplicateKey?: boolean;
|
||||
}) {
|
||||
const validator = useValidation() as Validator;
|
||||
|
||||
|
@ -73,10 +75,29 @@ export function LabelsCreater({
|
|||
) => {
|
||||
const { value } = event.target;
|
||||
const newList = [...labels];
|
||||
newList[index] = { ...newList[index], [labelField]: value };
|
||||
|
||||
// Check for any dup key:
|
||||
if (noDuplicateKey && labelField === 'name') {
|
||||
const isDupKey = labels.some(l => l.name === value);
|
||||
newList[index] = { ...newList[index], [labelField]: value, isDupKey };
|
||||
} else {
|
||||
newList[index] = { ...newList[index], [labelField]: value };
|
||||
}
|
||||
setLabels(newList);
|
||||
};
|
||||
|
||||
const requiredUniqueKey = value => () => {
|
||||
// Check for empty length and duplicate key.
|
||||
let notValid = !value || value.length === 0;
|
||||
if (noDuplicateKey) {
|
||||
notValid = notValid || labels.some(l => l.isDupKey);
|
||||
}
|
||||
return {
|
||||
valid: !notValid,
|
||||
message: '', // err msg doesn't matter as it isn't diaplsyed.
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{labels.length > 0 && (
|
||||
|
@ -102,7 +123,7 @@ export function LabelsCreater({
|
|||
<Flex alignItems="center">
|
||||
<FieldInput
|
||||
Input
|
||||
rule={requiredField('required')}
|
||||
rule={requiredUniqueKey}
|
||||
autoFocus
|
||||
value={label.name}
|
||||
placeholder="label key"
|
||||
|
@ -111,6 +132,7 @@ export function LabelsCreater({
|
|||
mb={0}
|
||||
onChange={e => handleChange(e, index, 'name')}
|
||||
readonly={disableBtns || label.isFixed}
|
||||
markAsError={label.isDupKey}
|
||||
/>
|
||||
<FieldInput
|
||||
rule={requiredField('required')}
|
||||
|
@ -139,6 +161,11 @@ export function LabelsCreater({
|
|||
</ButtonIcon>
|
||||
)}
|
||||
</Flex>
|
||||
{label.isDupKey && (
|
||||
<Text color="red" fontSize="12px">
|
||||
Duplicate key not allowed
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
@ -178,4 +205,7 @@ export type DiscoverLabel = AgentLabel & {
|
|||
// isFixed is a flag to mean label is
|
||||
// unmodifiable and undeletable.
|
||||
isFixed?: boolean;
|
||||
// isDupKey is a flag to mean this label
|
||||
// has duplicate key.
|
||||
isDupKey?: boolean;
|
||||
};
|
||||
|
|
|
@ -14,7 +14,12 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { generateTshLoginCommand, arrayStrDiff, compareSemVers } from './util';
|
||||
import {
|
||||
generateTshLoginCommand,
|
||||
arrayStrDiff,
|
||||
compareSemVers,
|
||||
compareByString,
|
||||
} from './util';
|
||||
|
||||
let windowSpy;
|
||||
|
||||
|
@ -96,3 +101,37 @@ test('compareSemVers', () => {
|
|||
'11.1.0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sortByString with simple string array', () => {
|
||||
const arr = ['cats', 'cat', 'x', 'ape', 'apes'];
|
||||
expect(arr.sort((a, b) => compareByString(a, b))).toStrictEqual([
|
||||
'ape',
|
||||
'apes',
|
||||
'cat',
|
||||
'cats',
|
||||
'x',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sortByString with objects with string fields', () => {
|
||||
const arr = [
|
||||
{ name: 'cats', value: 'persian' },
|
||||
{ name: 'ape', value: 'kingkong' },
|
||||
{ name: 'cat', value: 'siamese' },
|
||||
{ name: 'apes', value: 'donkeykong' },
|
||||
];
|
||||
|
||||
expect(arr.sort((a, b) => compareByString(a.name, b.name))).toStrictEqual([
|
||||
{ name: 'ape', value: 'kingkong' },
|
||||
{ name: 'apes', value: 'donkeykong' },
|
||||
{ name: 'cat', value: 'siamese' },
|
||||
{ name: 'cats', value: 'persian' },
|
||||
]);
|
||||
|
||||
expect(arr.sort((a, b) => compareByString(a.value, b.value))).toStrictEqual([
|
||||
{ name: 'apes', value: 'donkeykong' },
|
||||
{ name: 'ape', value: 'kingkong' },
|
||||
{ name: 'cats', value: 'persian' },
|
||||
{ name: 'cat', value: 'siamese' },
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -114,3 +114,15 @@ export const compareSemVers = (a: string, b: string): -1 | 1 => {
|
|||
|
||||
return 1;
|
||||
};
|
||||
|
||||
// compareByString is a sort compare function that
|
||||
// compares by string.
|
||||
export function compareByString(a: string, b: string) {
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (a > b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -75,7 +75,7 @@ class DatabaseService {
|
|||
req: UpdateDatabaseRequest
|
||||
): Promise<Database> {
|
||||
return api
|
||||
.put(cfg.getDatabaseUrl(clusterId, req.name), { ca_cert: req.caCert })
|
||||
.put(cfg.getDatabaseUrl(clusterId, req.name), req)
|
||||
.then(makeDatabase);
|
||||
}
|
||||
|
||||
|
|
|
@ -35,9 +35,11 @@ export type DatabasesResponse = {
|
|||
totalCount?: number;
|
||||
};
|
||||
|
||||
export type UpdateDatabaseRequest = {
|
||||
name: string;
|
||||
caCert: string;
|
||||
export type UpdateDatabaseRequest = Omit<
|
||||
Partial<CreateDatabaseRequest>,
|
||||
'protocol'
|
||||
> & {
|
||||
caCert?: string;
|
||||
};
|
||||
|
||||
export type CreateDatabaseRequest = {
|
||||
|
|
Loading…
Reference in a new issue