[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:
Lisa Kim 2023-02-06 10:44:39 -08:00 committed by GitHub
parent 9a605331f3
commit 8aefef1ae6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1422 additions and 673 deletions

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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;
};

View file

@ -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,
};

View file

@ -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.
//

View file

@ -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');
});
});

View file

@ -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;
}

View file

@ -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));
});

View file

@ -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,
});
}

View 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])
);
}

View file

@ -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;
};

View file

@ -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' },
]);
});

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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 = {