diff --git a/tool/tctl/common/collection_test.go b/tool/tctl/common/collection_test.go index cc77305744353..9c54303b8d76f 100644 --- a/tool/tctl/common/collection_test.go +++ b/tool/tctl/common/collection_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api" @@ -132,10 +133,10 @@ func testKubeServerCollection_writeText(t *testing.T) { types.DiscoveredNameLabel: "cluster3", } kubeServers := []types.KubeServer{ - mustCreateNewKubeServer(t, "cluster1", nil), - mustCreateNewKubeServer(t, "cluster2", longLabelFixture), - mustCreateNewKubeServer(t, "afirstCluster", longLabelFixture), - mustCreateNewKubeServer(t, "cluster3-eks-us-west-1-123456789012", eksDiscoveredNameLabel), + mustCreateNewKubeServer(t, "cluster1", "_", nil), + mustCreateNewKubeServer(t, "cluster2", "_", longLabelFixture), + mustCreateNewKubeServer(t, "afirstCluster", "_", longLabelFixture), + mustCreateNewKubeServer(t, "cluster3-eks-us-west-1-123456789012", "_", eksDiscoveredNameLabel), } test := writeTextTest{ collection: &kubeServerCollection{servers: kubeServers}, @@ -307,10 +308,10 @@ func mustCreateNewKubeCluster(t *testing.T, name string, extraStaticLabels map[s return cluster } -func mustCreateNewKubeServer(t *testing.T, name string, extraStaticLabels map[string]string) types.KubeServer { +func mustCreateNewKubeServer(t *testing.T, name, hostname string, extraStaticLabels map[string]string) *types.KubernetesServerV3 { t.Helper() cluster := mustCreateNewKubeCluster(t, name, extraStaticLabels) - kubeServer, err := types.NewKubernetesServerV3FromCluster(cluster, "some-host", "some-hostid") + kubeServer, err := types.NewKubernetesServerV3FromCluster(cluster, hostname, uuid.New().String()) require.NoError(t, err) return kubeServer } diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index e602b9a1d7e40..2380a94e63fdd 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -264,6 +264,9 @@ func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth } func waitForDatabases(t *testing.T, auth *service.TeleportProcess, dbs []servicecfg.Database) { + if len(dbs) == 0 { + return + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() for { diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go index 2f9853499bc35..13aa003c26069 100644 --- a/tool/tctl/common/resource_command.go +++ b/tool/tctl/common/resource_command.go @@ -24,6 +24,7 @@ import ( "math" "os" "sort" + "strings" "time" "github.com/alecthomas/kingpin/v2" @@ -1093,23 +1094,23 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client auth.ClientI) (err } fmt.Printf("lock %q has been deleted\n", name) case types.KindDatabaseServer: - dbServers, err := client.GetDatabaseServers(ctx, apidefaults.Namespace) + servers, err := client.GetDatabaseServers(ctx, apidefaults.Namespace) if err != nil { return trace.Wrap(err) } - deleted := false - for _, server := range dbServers { - if server.GetName() == rc.ref.Name { - if err := client.DeleteDatabaseServer(ctx, apidefaults.Namespace, server.GetHostID(), server.GetName()); err != nil { - return trace.Wrap(err) - } - deleted = true - } + resDesc := "database server" + servers = filterByNameOrPrefix(servers, rc.ref.Name) + name, err := getOneResourceNameToDelete(servers, rc.ref, resDesc) + if err != nil { + return trace.Wrap(err) } - if !deleted { - return trace.NotFound("database server %q not found", rc.ref.Name) + for _, s := range servers { + err := client.DeleteDatabaseServer(ctx, apidefaults.Namespace, s.GetHostID(), name) + if err != nil { + return trace.Wrap(err) + } } - fmt.Printf("database server %q has been deleted\n", rc.ref.Name) + fmt.Printf("%s %q has been deleted\n", resDesc, name) case types.KindNetworkRestrictions: if err = resetNetworkRestrictions(ctx, client); err != nil { return trace.Wrap(err) @@ -1121,15 +1122,35 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client auth.ClientI) (err } fmt.Printf("application %q has been deleted\n", rc.ref.Name) case types.KindDatabase: - if err = client.DeleteDatabase(ctx, rc.ref.Name); err != nil { + databases, err := client.GetDatabases(ctx) + if err != nil { + return trace.Wrap(err) + } + resDesc := "database" + databases = filterByNameOrPrefix(databases, rc.ref.Name) + name, err := getOneResourceNameToDelete(databases, rc.ref, resDesc) + if err != nil { + return trace.Wrap(err) + } + if err := client.DeleteDatabase(ctx, name); err != nil { return trace.Wrap(err) } - fmt.Printf("database %q has been deleted\n", rc.ref.Name) + fmt.Printf("%s %q has been deleted\n", resDesc, name) case types.KindKubernetesCluster: - if err = client.DeleteKubernetesCluster(ctx, rc.ref.Name); err != nil { + clusters, err := client.GetKubernetesClusters(ctx) + if err != nil { return trace.Wrap(err) } - fmt.Printf("kubernetes cluster %q has been deleted\n", rc.ref.Name) + resDesc := "kubernetes cluster" + clusters = filterByNameOrPrefix(clusters, rc.ref.Name) + name, err := getOneResourceNameToDelete(clusters, rc.ref, resDesc) + if err != nil { + return trace.Wrap(err) + } + if err := client.DeleteKubernetesCluster(ctx, name); err != nil { + return trace.Wrap(err) + } + fmt.Printf("%s %q has been deleted\n", resDesc, name) case types.KindWindowsDesktopService: if err = client.DeleteWindowsDesktopService(ctx, rc.ref.Name); err != nil { return trace.Wrap(err) @@ -1182,23 +1203,23 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client auth.ClientI) (err } fmt.Printf("%s '%s/%s' has been deleted\n", types.KindCertAuthority, rc.ref.SubKind, rc.ref.Name) case types.KindKubeServer: - kubeServers, err := client.GetKubernetesServers(ctx) + servers, err := client.GetKubernetesServers(ctx) if err != nil { return trace.Wrap(err) } - deleted := false - for _, server := range kubeServers { - if server.GetName() == rc.ref.Name { - if err := client.DeleteKubernetesServer(ctx, server.GetHostID(), server.GetName()); err != nil { - return trace.Wrap(err) - } - deleted = true - } + resDesc := "kubernetes server" + servers = filterByNameOrPrefix(servers, rc.ref.Name) + name, err := getOneResourceNameToDelete(servers, rc.ref, resDesc) + if err != nil { + return trace.Wrap(err) } - if !deleted { - return trace.NotFound("kubernetes server %q not found", rc.ref.Name) + for _, s := range servers { + err := client.DeleteKubernetesServer(ctx, s.GetHostID(), name) + if err != nil { + return trace.Wrap(err) + } } - fmt.Printf("kubernetes server %q has been deleted\n", rc.ref.Name) + fmt.Printf("%s %q has been deleted\n", resDesc, name) case types.KindUIConfig: err := client.DeleteUIConfig(ctx) if err != nil { @@ -1658,16 +1679,11 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client auth.Client return &databaseServerCollection{servers: servers}, nil } - var out []types.DatabaseServer - for _, server := range servers { - if server.GetName() == rc.ref.Name { - out = append(out, server) - } - } - if len(out) == 0 { + servers = filterByNameOrPrefix(servers, rc.ref.Name) + if len(servers) == 0 { return nil, trace.NotFound("database server %q not found", rc.ref.Name) } - return &databaseServerCollection{servers: out}, nil + return &databaseServerCollection{servers: servers}, nil case types.KindKubeServer: servers, err := client.GetKubernetesServers(ctx) if err != nil { @@ -1676,17 +1692,14 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client auth.Client if rc.ref.Name == "" { return &kubeServerCollection{servers: servers}, nil } - - var out []types.KubeServer - for _, server := range servers { - if server.GetName() == rc.ref.Name || server.GetHostname() == rc.ref.Name { - out = append(out, server) - } + altNameFn := func(r types.KubeServer) string { + return r.GetHostname() } - if len(out) == 0 { + servers = filterByNameOrPrefix(servers, rc.ref.Name, altNameFn) + if len(servers) == 0 { return nil, trace.NotFound("kubernetes server %q not found", rc.ref.Name) } - return &kubeServerCollection{servers: out}, nil + return &kubeServerCollection{servers: servers}, nil case types.KindAppServer: servers, err := client.GetApplicationServers(ctx, rc.namespace) @@ -1727,31 +1740,31 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client auth.Client } return &appCollection{apps: []types.Application{app}}, nil case types.KindDatabase: + databases, err := client.GetDatabases(ctx) + if err != nil { + return nil, trace.Wrap(err) + } if rc.ref.Name == "" { - databases, err := client.GetDatabases(ctx) - if err != nil { - return nil, trace.Wrap(err) - } return &databaseCollection{databases: databases}, nil } - database, err := client.GetDatabase(ctx, rc.ref.Name) + databases = filterByNameOrPrefix(databases, rc.ref.Name) + if len(databases) == 0 { + return nil, trace.NotFound("database %q not found", rc.ref.Name) + } + return &databaseCollection{databases: databases}, nil + case types.KindKubernetesCluster: + clusters, err := client.GetKubernetesClusters(ctx) if err != nil { return nil, trace.Wrap(err) } - return &databaseCollection{databases: []types.Database{database}}, nil - case types.KindKubernetesCluster: if rc.ref.Name == "" { - clusters, err := client.GetKubernetesClusters(ctx) - if err != nil { - return nil, trace.Wrap(err) - } return &kubeClusterCollection{clusters: clusters}, nil } - cluster, err := client.GetKubernetesCluster(ctx, rc.ref.Name) - if err != nil { - return nil, trace.Wrap(err) + clusters = filterByNameOrPrefix(clusters, rc.ref.Name) + if len(clusters) == 0 { + return nil, trace.NotFound("kubernetes cluster %q not found", rc.ref.Name) } - return &kubeClusterCollection{clusters: []types.KubeCluster{cluster}}, nil + return &kubeClusterCollection{clusters: clusters}, nil case types.KindWindowsDesktopService: services, err := client.GetWindowsDesktopServices(ctx) if err != nil { @@ -2130,3 +2143,111 @@ func findDeviceByIDOrTag(ctx context.Context, remote devicepb.DeviceTrustService return nil, trace.BadParameter("found multiple devices for asset tag %q, please retry using the device ID instead", idOrTag) } + +// keepFn is a predicate function that returns true if a resource should be +// retained by filterResources. +type keepFn[T types.ResourceWithLabels] func(T) bool + +// filterResources takes a list of resources and returns a filtered list of +// resources for which the `keep` predicate function returns true. +func filterResources[T types.ResourceWithLabels](resources []T, keep keepFn[T]) []T { + out := make([]T, 0, len(resources)) + for _, r := range resources { + if keep(r) { + out = append(out, r) + } + } + return out +} + +// altNameFn is a func that returns an alternative name for a resource. +type altNameFn[T types.ResourceWithLabels] func(T) string + +// filterByNameOrPrefix filters resources by name or a prefix of the name. +// It prefers exact name filtering first - if none of the resource names match +// exactly (i.e. all of the resources are filtered out), then it retries and +// filters the resources by prefix of resource name instead. +// This is to avoid an annoying UX, for example: +// resources: [foo, foobar] +// $ tctl rm foo <- should select foo by exact name instead of matching both by +// prefix "foo". +func filterByNameOrPrefix[T types.ResourceWithLabels](resources []T, prefixOrName string, extra ...altNameFn[T]) []T { + // prefer exact names + out := filterByName(resources, prefixOrName, extra...) + if len(out) == 0 { + // fallback to looking for prefixes + out = filterByPrefix(resources, prefixOrName, extra...) + } + return out +} + +// filterByName filters resources by exact name match. +func filterByName[T types.ResourceWithLabels](resources []T, name string, altNameFns ...altNameFn[T]) []T { + return filterResources(resources, func(r T) bool { + if r.GetName() == name { + return true + } + for _, altName := range altNameFns { + if altName(r) == name { + return true + } + } + return false + }) +} + +// filterByPrefix filters resources by a prefix of the resource name. +func filterByPrefix[T types.ResourceWithLabels](resources []T, prefix string, altNameFns ...altNameFn[T]) []T { + return filterResources(resources, func(r T) bool { + if strings.HasPrefix(r.GetName(), prefix) { + return true + } + for _, altName := range altNameFns { + if strings.HasPrefix(altName(r), prefix) { + return true + } + } + return false + }) +} + +// getOneResourceNameToDelete checks a list of resources to ensure there is +// exactly one resource name among them, and returns that name or an error. +// Heartbeat resources can have the same name but different host ID, so this +// still allows a user to delete multiple heartbeats of the same name, for +// example `$ tctl rm db_server/someDB`. +func getOneResourceNameToDelete[T types.ResourceWithLabels](rs []T, ref services.Ref, resDesc string) (string, error) { + seen := make(map[string]struct{}) + for _, r := range rs { + seen[r.GetName()] = struct{}{} + } + switch len(seen) { + case 1: // need exactly one. + return rs[0].GetName(), nil + case 0: + return "", trace.NotFound("%v %q not found", resDesc, ref.Name) + default: + names := make([]string, 0, len(rs)) + for _, r := range rs { + names = append(names, r.GetName()) + } + msg := formatAmbiguousDeleteMessage(ref, resDesc, names) + return "", trace.BadParameter(msg) + } +} + +// formatAmbiguousDeleteMessage returns a formatted message when a user is +// attempting to delete multiple resources by an ambiguous prefix of the +// resource names. +func formatAmbiguousDeleteMessage(ref services.Ref, resDesc string, names []string) string { + slices.Sort(names) + // choose an actual resource for the example in the error. + exampleRef := ref + exampleRef.Name = names[0] + return fmt.Sprintf(`%s matches multiple %vs as a name prefix: +%v + +Use either a full resource name or an unambiguous prefix, for example: +$ tctl rm %s`, + ref.String(), resDesc, strings.Join(names, "\n"), exampleRef.String()) +} diff --git a/tool/tctl/common/resource_command_test.go b/tool/tctl/common/resource_command_test.go index af931355c12d8..090d3b5dce7ef 100644 --- a/tool/tctl/common/resource_command_test.go +++ b/tool/tctl/common/resource_command_test.go @@ -39,6 +39,7 @@ import ( "github.com/gravitational/teleport/lib/config" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/fixtures" + "github.com/gravitational/teleport/lib/services" ) // TestDatabaseServerResource tests tctl db_server rm/get commands. @@ -76,6 +77,12 @@ func TestDatabaseServerResource(t *testing.T) { CACertFile: caCertFilePath, }, }, + { + Name: "db3", + Description: "Example MySQL", + Protocol: "mysql", + URI: "localhost:33308", + }, }, }, Proxy: config.Proxy{ @@ -93,7 +100,26 @@ func TestDatabaseServerResource(t *testing.T) { }, } - wantDB, err := types.NewDatabaseV3(types.Metadata{ + db1, err := types.NewDatabaseV3(types.Metadata{ + Name: "example", + Description: "Example MySQL", + Labels: map[string]string{types.OriginLabel: types.OriginConfigFile}, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolMySQL, + URI: "localhost:33306", + CACert: fixtures.TLSCACertPEM, + AdminUser: &types.DatabaseAdminUser{ + Name: "", + }, + TLS: types.DatabaseTLS{ + Mode: types.DatabaseTLSMode_VERIFY_FULL, + ServerName: "db.example.com", + CACert: fixtures.TLSCACertPEM, + }, + }) + require.NoError(t, err) + + db2, err := types.NewDatabaseV3(types.Metadata{ Name: "example2", Description: "Example PostgreSQL", Labels: map[string]string{types.OriginLabel: types.OriginConfigFile}, @@ -112,6 +138,25 @@ func TestDatabaseServerResource(t *testing.T) { }) require.NoError(t, err) + db3, err := types.NewDatabaseV3(types.Metadata{ + Name: "db3", + Description: "Example MySQL", + Labels: map[string]string{types.OriginLabel: types.OriginConfigFile}, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolMySQL, + URI: "localhost:33308", + CACert: fixtures.TLSCACertPEM, + AdminUser: &types.DatabaseAdminUser{ + Name: "", + }, + TLS: types.DatabaseTLS{ + Mode: types.DatabaseTLSMode_VERIFY_FULL, + ServerName: "db.example.com", + CACert: fixtures.TLSCACertPEM, + }, + }) + require.NoError(t, err) + _ = makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.descriptors)) var out []*types.DatabaseServerV3 @@ -120,128 +165,53 @@ func TestDatabaseServerResource(t *testing.T) { buff, err := runResourceCommand(t, fileConfig, []string{"get", types.KindDatabaseServer, "--format=json"}) require.NoError(t, err) mustDecodeJSON(t, buff, &out) - require.Len(t, out, 2) - - wantServer := fmt.Sprintf("%v/%v", types.KindDatabaseServer, wantDB.GetName()) + require.Len(t, out, 3) // get specific database server + wantServer := fmt.Sprintf("%v/%v", types.KindDatabaseServer, db2.GetName()) buff, err = runResourceCommand(t, fileConfig, []string{"get", wantServer, "--format=json"}) require.NoError(t, err) mustDecodeJSON(t, buff, &out) require.Len(t, out, 1) gotDB := out[0].GetDatabase() - require.Empty(t, cmp.Diff([]types.Database{wantDB}, []types.Database{gotDB}, + require.Empty(t, cmp.Diff([]types.Database{db2}, []types.Database{gotDB}, cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace", "Expires"), )) - // remove database server - _, err = runResourceCommand(t, fileConfig, []string{"rm", wantServer}) - require.NoError(t, err) - - _, err = runResourceCommand(t, fileConfig, []string{"get", wantServer, "--format=json"}) - require.Error(t, err) - require.IsType(t, &trace.NotFoundError{}, err.(*trace.TraceErr).OrigError()) - - buff, err = runResourceCommand(t, fileConfig, []string{"get", "db", "--format=json"}) + // get database servers by prefix of name + wantServersPrefix := fmt.Sprintf("%v/%v", types.KindDatabaseServer, "exam") + buff, err = runResourceCommand(t, fileConfig, []string{"get", wantServersPrefix, "--format=json"}) require.NoError(t, err) mustDecodeJSON(t, buff, &out) - require.Len(t, out, 0) -} - -// TestDatabaseResource tests tctl commands that manage database resources. -func TestDatabaseResource(t *testing.T) { - dynAddr := newDynamicServiceAddr(t) - - fileConfig := &config.FileConfig{ - Global: config.Global{ - DataDir: t.TempDir(), - }, - Databases: config.Databases{ - Service: config.Service{ - EnabledFlag: "true", - }, - }, - Proxy: config.Proxy{ - Service: config.Service{ - EnabledFlag: "true", - }, - WebAddr: dynAddr.webAddr, - TunAddr: dynAddr.tunnelAddr, - }, - Auth: config.Auth{ - Service: config.Service{ - EnabledFlag: "true", - ListenAddress: dynAddr.authAddr, - }, - }, - } - - makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.descriptors)) - - dbA, err := types.NewDatabaseV3(types.Metadata{ - Name: "db-a", - Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, - }, types.DatabaseSpecV3{ - Protocol: defaults.ProtocolPostgres, - URI: "localhost:5432", - }) - require.NoError(t, err) - - dbB, err := types.NewDatabaseV3(types.Metadata{ - Name: "db-b", - Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, - }, types.DatabaseSpecV3{ - Protocol: defaults.ProtocolMySQL, - URI: "localhost:3306", - TLS: types.DatabaseTLS{ - Mode: types.DatabaseTLSMode_VERIFY_CA, - }, - }) - require.NoError(t, err) - - var out []*types.DatabaseV3 - - // Initially there are no databases. - buf, err := runResourceCommand(t, fileConfig, []string{"get", types.KindDatabase, "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Len(t, out, 0) - - // Create the databases. - dbYAMLPath := filepath.Join(t.TempDir(), "db.yaml") - require.NoError(t, os.WriteFile(dbYAMLPath, []byte(dbYAML), 0644)) - _, err = runResourceCommand(t, fileConfig, []string{"create", dbYAMLPath}) - require.NoError(t, err) - - // Fetch the databases, should have 2. - buf, err = runResourceCommand(t, fileConfig, []string{"get", types.KindDatabase, "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) require.Len(t, out, 2) - require.Empty(t, cmp.Diff([]*types.DatabaseV3{dbA, dbB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + gotDBs := types.DatabaseServers{out[0], out[1]}.ToDatabases() + require.Empty(t, cmp.Diff([]types.Database{db1, db2}, gotDBs, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace", "Expires"), )) - // Fetch specific database. - buf, err = runResourceCommand(t, fileConfig, []string{"get", fmt.Sprintf("%v/db-b", types.KindDatabase), "--format=json"}) + // remove database servers by prefix is an error + _, err = runResourceCommand(t, fileConfig, []string{"rm", wantServersPrefix}) + require.ErrorContains(t, err, "db_server/exam matches multiple database servers") + + // remove database server by name + _, err = runResourceCommand(t, fileConfig, []string{"rm", wantServer}) require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Len(t, out, 1) - require.Empty(t, cmp.Diff([]*types.DatabaseV3{dbB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), - )) - // Remove a database. - _, err = runResourceCommand(t, fileConfig, []string{"rm", fmt.Sprintf("%v/db-a", types.KindDatabase)}) + _, err = runResourceCommand(t, fileConfig, []string{"get", wantServer, "--format=json"}) + require.Error(t, err) + require.IsType(t, &trace.NotFoundError{}, err.(*trace.TraceErr).OrigError()) + + // remove database server by prefix name. + _, err = runResourceCommand(t, fileConfig, []string{"rm", wantServersPrefix}) require.NoError(t, err) - // Fetch all databases again, should have 1. - buf, err = runResourceCommand(t, fileConfig, []string{"get", types.KindDatabase, "--format=json"}) + buff, err = runResourceCommand(t, fileConfig, []string{"get", "db_server", "--format=json"}) require.NoError(t, err) - mustDecodeJSON(t, buf, &out) + mustDecodeJSON(t, buff, &out) require.Len(t, out, 1) - require.Empty(t, cmp.Diff([]*types.DatabaseV3{dbB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + gotDBs = types.DatabaseServers{out[0]}.ToDatabases() + require.Empty(t, cmp.Diff([]types.Database{db3}, gotDBs, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace", "Expires"), )) } @@ -441,98 +411,6 @@ func TestIntegrationResource(t *testing.T) { }) } -// TestAppResource tests tctl commands that manage application resources. -func TestAppResource(t *testing.T) { - dynAddr := newDynamicServiceAddr(t) - - fileConfig := &config.FileConfig{ - Global: config.Global{ - DataDir: t.TempDir(), - Logger: config.Log{ - Severity: "debug", - }, - }, - Apps: config.Apps{ - Service: config.Service{ - EnabledFlag: "true", - }, - }, - Proxy: config.Proxy{ - Service: config.Service{ - EnabledFlag: "true", - }, - WebAddr: dynAddr.webAddr, - TunAddr: dynAddr.tunnelAddr, - }, - Auth: config.Auth{ - Service: config.Service{ - EnabledFlag: "true", - ListenAddress: dynAddr.authAddr, - }, - }, - } - - makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.descriptors)) - - appA, err := types.NewAppV3(types.Metadata{ - Name: "appA", - Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, - }, types.AppSpecV3{ - URI: "localhost1", - }) - require.NoError(t, err) - - appB, err := types.NewAppV3(types.Metadata{ - Name: "appB", - Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, - }, types.AppSpecV3{ - URI: "localhost2", - }) - require.NoError(t, err) - - var out []*types.AppV3 - - // Initially there are no apps. - buf, err := runResourceCommand(t, fileConfig, []string{"get", types.KindApp, "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Len(t, out, 0) - - // Create the apps. - appYAMLPath := filepath.Join(t.TempDir(), "app.yaml") - require.NoError(t, os.WriteFile(appYAMLPath, []byte(appYAML), 0644)) - _, err = runResourceCommand(t, fileConfig, []string{"create", appYAMLPath}) - require.NoError(t, err) - - // Fetch the apps, should have 2. - buf, err = runResourceCommand(t, fileConfig, []string{"get", types.KindApp, "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Empty(t, cmp.Diff([]*types.AppV3{appA, appB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), - )) - - // Fetch specific app. - buf, err = runResourceCommand(t, fileConfig, []string{"get", fmt.Sprintf("%v/appB", types.KindApp), "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Empty(t, cmp.Diff([]*types.AppV3{appB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), - )) - - // Remove an app. - _, err = runResourceCommand(t, fileConfig, []string{"rm", fmt.Sprintf("%v/appA", types.KindApp)}) - require.NoError(t, err) - - // Fetch all apps again, should have 1. - buf, err = runResourceCommand(t, fileConfig, []string{"get", types.KindApp, "--format=json"}) - require.NoError(t, err) - mustDecodeJSON(t, buf, &out) - require.Empty(t, cmp.Diff([]*types.AppV3{appB}, out, - cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), - )) -} - func TestCreateLock(t *testing.T) { dynAddr := newDynamicServiceAddr(t) fileConfig := &config.FileConfig{ @@ -648,34 +526,70 @@ const ( dbYAML = `kind: db version: v3 metadata: - name: db-a + name: foo spec: - protocol: "postgres" - uri: "localhost:5432" + protocol: "mysql" + uri: "localhost:3306" + tls: + mode: "verify-ca" --- kind: db version: v3 metadata: - name: db-b + name: foo-bar spec: - protocol: "mysql" - uri: "localhost:3306" + protocol: "postgres" + uri: "localhost:5433" tls: - mode: "verify-ca"` + mode: "verify-full" +--- +kind: db +version: v3 +metadata: + name: foo-bar-baz +spec: + protocol: "postgres" + uri: "localhost:5432"` appYAML = `kind: app version: v3 metadata: - name: appA + name: foo spec: uri: "localhost1" --- kind: app version: v3 metadata: - name: appB + name: foo-bar spec: - uri: "localhost2"` + uri: "localhost2" +--- +kind: app +version: v3 +metadata: + name: foo-bar-baz +spec: + uri: "localhost3"` + + kubeYAML = ` +kind: kube_cluster +version: v3 +metadata: + name: foo +spec: {} +--- +kind: kube_cluster +version: v3 +metadata: + name: foo-bar +spec: {} +--- +kind: kube_cluster +version: v3 +metadata: + name: foo-bar-baz +spec: {}` lockYAML = `kind: lock version: v2 @@ -925,6 +839,336 @@ func TestUpsertVerb(t *testing.T) { } } +type dynamicResourceTest[T types.ResourceWithLabels] struct { + kind string + resourceYAML string + fooResource T + fooBarResource T + fooBarBazResource T + runPrefixNameChecks bool +} + +func (test *dynamicResourceTest[T]) setup(t *testing.T) *config.FileConfig { + t.Helper() + requireResource := func(t *testing.T, r T, name string) { + t.Helper() + require.NotNil(t, r, "dynamicResourceTest requires a resource named %q", name) + require.Equal(t, r.GetName(), name, "dynamicResourceTest requires a resource named %q", name) + } + requireResource(t, test.fooResource, "foo") + requireResource(t, test.fooBarResource, "foo-bar") + requireResource(t, test.fooBarBazResource, "foo-bar-baz") + dynAddr := newDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Proxy: config.Proxy{ + Service: config.Service{ + EnabledFlag: "true", + }, + WebAddr: dynAddr.webAddr, + TunAddr: dynAddr.tunnelAddr, + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.authAddr, + }, + }, + } + _ = makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.descriptors)) + return fileConfig +} + +func (test *dynamicResourceTest[T]) run(t *testing.T) { + t.Helper() + fileConfig := test.setup(t) + var out []T + + // Initially there are no resources. + buf, err := runResourceCommand(t, fileConfig, []string{"get", test.kind, "--format=json"}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + require.Len(t, out, 0) + + // Create the resources. + yamlPath := filepath.Join(t.TempDir(), "resources.yaml") + require.NoError(t, os.WriteFile(yamlPath, []byte(test.resourceYAML), 0644)) + _, err = runResourceCommand(t, fileConfig, []string{"create", yamlPath}) + require.NoError(t, err) + + // Fetch all resources. + buf, err = runResourceCommand(t, fileConfig, []string{"get", test.kind, "--format=json"}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + require.Len(t, out, 3) + require.Empty(t, cmp.Diff([]T{test.fooResource, test.fooBarResource, test.fooBarBazResource}, out, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + )) + + // Fetch specific resource. + buf, err = runResourceCommand(t, fileConfig, + []string{"get", fmt.Sprintf("%v/%v", test.kind, test.fooResource.GetName()), "--format=json"}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + require.Len(t, out, 1) + require.Empty(t, cmp.Diff([]T{test.fooResource}, out, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + )) + + // Remove a resource. + _, err = runResourceCommand(t, fileConfig, []string{"rm", fmt.Sprintf("%v/%v", test.kind, test.fooBarResource.GetName())}) + require.NoError(t, err) + + // Fetch all resources again. + buf, err = runResourceCommand(t, fileConfig, []string{"get", test.kind, "--format=json"}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + require.Len(t, out, 2) + require.Empty(t, cmp.Diff([]T{test.fooResource, test.fooBarBazResource}, out, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + )) + + if !test.runPrefixNameChecks { + return + } + + // Test prefix name behavior. + // Removing multiple resources ("foo" and "foo-bar-baz")by prefix name is an error. + _, err = runResourceCommand(t, fileConfig, []string{"rm", fmt.Sprintf("%v/%v", test.kind, "f")}) + require.ErrorContains(t, err, "matches multiple") + + // Remove "foo-bar-baz" resource by a prefix of its name. + _, err = runResourceCommand(t, fileConfig, []string{"rm", fmt.Sprintf("%v/%v", test.kind, "foo-bar-b")}) + require.NoError(t, err) + + // Fetch all resources again. + buf, err = runResourceCommand(t, fileConfig, []string{"get", test.kind, "--format=json"}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + require.Len(t, out, 1) + require.Empty(t, cmp.Diff([]T{test.fooResource}, out, + cmpopts.IgnoreFields(types.Metadata{}, "ID", "Namespace"), + )) +} + +// TestDatabaseResource tests tctl commands that manage database resources. +func TestDatabaseResource(t *testing.T) { + t.Parallel() + dbFoo, err := types.NewDatabaseV3(types.Metadata{ + Name: "foo", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolMySQL, + URI: "localhost:3306", + TLS: types.DatabaseTLS{ + Mode: types.DatabaseTLSMode_VERIFY_CA, + }, + }) + require.NoError(t, err) + dbFooBar, err := types.NewDatabaseV3(types.Metadata{ + Name: "foo-bar", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolPostgres, + URI: "localhost:5433", + TLS: types.DatabaseTLS{ + Mode: types.DatabaseTLSMode_VERIFY_FULL, + }, + }) + require.NoError(t, err) + dbFooBarBaz, err := types.NewDatabaseV3(types.Metadata{ + Name: "foo-bar-baz", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.DatabaseSpecV3{ + Protocol: defaults.ProtocolPostgres, + URI: "localhost:5432", + }) + require.NoError(t, err) + + require.NoError(t, err) + test := dynamicResourceTest[*types.DatabaseV3]{ + kind: types.KindDatabase, + resourceYAML: dbYAML, + fooResource: dbFoo, + fooBarResource: dbFooBar, + fooBarBazResource: dbFooBarBaz, + runPrefixNameChecks: true, + } + test.run(t) +} + +// TestKubeClusterResource tests tctl commands that manage dynamic kube cluster resources. +func TestKubeClusterResource(t *testing.T) { + t.Parallel() + kubeFoo, err := types.NewKubernetesClusterV3(types.Metadata{ + Name: "foo", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.KubernetesClusterSpecV3{}) + require.NoError(t, err) + kubeFooBar, err := types.NewKubernetesClusterV3(types.Metadata{ + Name: "foo-bar", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.KubernetesClusterSpecV3{}) + require.NoError(t, err) + kubeFooBarBaz, err := types.NewKubernetesClusterV3(types.Metadata{ + Name: "foo-bar-baz", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.KubernetesClusterSpecV3{}) + require.NoError(t, err) + test := dynamicResourceTest[*types.KubernetesClusterV3]{ + kind: types.KindKubernetesCluster, + resourceYAML: kubeYAML, + fooResource: kubeFoo, + fooBarResource: kubeFooBar, + fooBarBazResource: kubeFooBarBaz, + runPrefixNameChecks: true, + } + test.run(t) +} + +// TestAppResource tests tctl commands that manage application resources. +func TestAppResource(t *testing.T) { + t.Parallel() + appFoo, err := types.NewAppV3(types.Metadata{ + Name: "foo", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.AppSpecV3{ + URI: "localhost1", + }) + require.NoError(t, err) + appFooBar, err := types.NewAppV3(types.Metadata{ + Name: "foo-bar", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.AppSpecV3{ + URI: "localhost2", + }) + require.NoError(t, err) + appFooBarBaz, err := types.NewAppV3(types.Metadata{ + Name: "foo-bar-baz", + Labels: map[string]string{types.OriginLabel: types.OriginDynamic}, + }, types.AppSpecV3{ + URI: "localhost3", + }) + require.NoError(t, err) + test := dynamicResourceTest[*types.AppV3]{ + kind: types.KindApp, + resourceYAML: appYAML, + fooResource: appFoo, + fooBarResource: appFooBar, + fooBarBazResource: appFooBarBaz, + } + test.run(t) +} + +func TestGetOneResourceNameToDelete(t *testing.T) { + foo1 := mustCreateNewKubeServer(t, "foo", "host-foo", nil) + foo2 := mustCreateNewKubeServer(t, "foo", "host-foo", nil) + fooBar := mustCreateNewKubeServer(t, "foo-bar", "host-foo-bar", nil) + baz := mustCreateNewKubeServer(t, "baz", "host-baz", nil) + tests := []struct { + desc string + refName string + wantErrContains string + resources []types.KubeServer + wantName string + }{ + { + desc: "one resource is ok", + refName: "baz", + resources: []types.KubeServer{baz}, + wantName: "baz", + }, + { + desc: "multiple resources with same name is ok", + refName: "foo", + resources: []types.KubeServer{foo1, foo2}, + wantName: "foo", + }, + { + desc: "zero resources is an error", + refName: "xxx", + wantErrContains: `kubernetes server "xxx" not found`, + }, + { + desc: "multiple resources with different names is an error", + refName: "f", + resources: []types.KubeServer{foo1, foo2, fooBar}, + wantErrContains: "matches multiple", + }, + } + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + ref := services.Ref{Kind: types.KindKubeServer, Name: test.refName} + resDesc := "kubernetes server" + name, err := getOneResourceNameToDelete(test.resources, ref, resDesc) + if test.wantErrContains != "" { + require.ErrorContains(t, err, test.wantErrContains) + return + } + require.Equal(t, test.wantName, name) + }) + } +} + +func TestFilterByNameOrPrefix(t *testing.T) { + foo1 := mustCreateNewKubeServer(t, "foo", "host-foo", nil) + foo2 := mustCreateNewKubeServer(t, "foo", "host-foo", nil) + fooBar := mustCreateNewKubeServer(t, "foo-bar", "host-foo-bar", nil) + baz := mustCreateNewKubeServer(t, "baz", "host-baz", nil) + resources := []types.KubeServer{ + foo1, foo2, fooBar, baz, + } + hostNameGetter := func(ks types.KubeServer) string { return ks.GetHostname() } + tests := []struct { + desc string + filter string + altNameGetters []altNameFn[types.KubeServer] + want []types.KubeServer + }{ + { + desc: "filters by exact name first", + filter: "foo", + want: []types.KubeServer{foo1, foo2}, + }, + { + desc: "filters by prefix name", + filter: "fo", + want: []types.KubeServer{foo1, foo2, fooBar}, + }, + { + desc: "checks alt names for exact matches first", + filter: "host-foo", + altNameGetters: []altNameFn[types.KubeServer]{hostNameGetter}, + want: []types.KubeServer{foo1, foo2}, + }, + { + desc: "checks alt names for prefix matches", + filter: "host-f", + altNameGetters: []altNameFn[types.KubeServer]{hostNameGetter}, + want: []types.KubeServer{foo1, foo2, fooBar}, + }, + } + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + got := filterByNameOrPrefix(resources, test.filter, test.altNameGetters...) + require.Empty(t, cmp.Diff(test.want, got)) + }) + } +} + +func TestFormatAmbiguousDeleteMessage(t *testing.T) { + ref := services.Ref{Kind: types.KindDatabase, Name: "x"} + resDesc := "database" + names := []string{"xbbb", "xaaa", "xccc", "xb"} + got := formatAmbiguousDeleteMessage(ref, resDesc, names) + require.Contains(t, got, "db/x matches multiple databases", "should have formated the ref used and pluralized the resource description") + wantSortedNames := strings.Join([]string{"xaaa", "xb", "xbbb", "xccc"}, "\n") + require.Contains(t, got, wantSortedNames, "should have sorted the matching names") + require.Contains(t, got, "$ tctl rm db/xaaa", "should have contained an example command") +} + // requireEqual creates an assertion function with a bound `expected` value // for use with table-driven tests func requireEqual(expected interface{}) require.ValueAssertionFunc {