Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v17] HTTP Endpoint to return Integration Summary (Dashboard) #49490

Merged
merged 4 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ func (h *Handler) bindDefaultEndpoints() {
h.POST("/webapi/sites/:site/integrations", h.WithClusterAuth(h.integrationsCreate))
h.GET("/webapi/sites/:site/integrations/:name", h.WithClusterAuth(h.integrationsGet))
h.PUT("/webapi/sites/:site/integrations/:name", h.WithClusterAuth(h.integrationsUpdate))
h.GET("/webapi/sites/:site/integrations/:name/stats", h.WithClusterAuth(h.integrationStats))
h.DELETE("/webapi/sites/:site/integrations/:name_or_subkind", h.WithClusterAuth(h.integrationsDelete))

// GET the Microsoft Teams plugin app.zip file.
Expand Down
122 changes: 122 additions & 0 deletions lib/web/integrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@
package web

import (
"context"
"net/http"
"net/url"
"slices"
"time"

"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"

discoveryconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/discoveryconfig/v1"
pluginspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/discoveryconfig"
"github.com/gravitational/teleport/integrations/access/msteams"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/httplib"
Expand Down Expand Up @@ -197,6 +202,123 @@ func (h *Handler) integrationsGet(w http.ResponseWriter, r *http.Request, p http
return uiIg, nil
}

// integrationStats returns the integration stats.
func (h *Handler) integrationStats(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
integrationName := p.ByName("name")
if integrationName == "" {
return nil, trace.BadParameter("an integration name is required")
}

clt, err := sctx.GetUserClient(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}

ig, err := clt.GetIntegration(r.Context(), integrationName)
if err != nil {
return nil, trace.Wrap(err)
}

summary, err := collectAWSOIDCAutoDiscoverStats(r.Context(), ig, clt.DiscoveryConfigClient())
if err != nil {
return nil, trace.Wrap(err)
}

return summary, nil
}

func collectAWSOIDCAutoDiscoverStats(
ctx context.Context,
integration types.Integration,
clt interface {
ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error)
},
) (ui.IntegrationWithSummary, error) {
var ret ui.IntegrationWithSummary

uiIg, err := ui.MakeIntegration(integration)
if err != nil {
return ret, err
}
ret.Integration = uiIg

var nextPage string
for {
discoveryConfigs, nextToken, err := clt.ListDiscoveryConfigs(ctx, 0, nextPage)
if err != nil {
return ret, trace.Wrap(err)
}
for _, dc := range discoveryConfigs {
discoveredResources, ok := dc.Status.IntegrationDiscoveredResources[integration.GetName()]
if !ok {
continue
}

if matchers := rulesWithIntegration(dc, types.AWSMatcherEC2, integration.GetName()); matchers != 0 {
ret.AWSEC2.RulesCount += matchers
mergeResourceTypeSummary(&ret.AWSEC2, dc.Status.LastSyncTime, discoveredResources.AwsEc2)
}

if matchers := rulesWithIntegration(dc, types.AWSMatcherRDS, integration.GetName()); matchers != 0 {
ret.AWSRDS.RulesCount += matchers
mergeResourceTypeSummary(&ret.AWSRDS, dc.Status.LastSyncTime, discoveredResources.AwsRds)
}

if matchers := rulesWithIntegration(dc, types.AWSMatcherEKS, integration.GetName()); matchers != 0 {
ret.AWSEKS.RulesCount += matchers
mergeResourceTypeSummary(&ret.AWSEKS, dc.Status.LastSyncTime, discoveredResources.AwsEks)
}
}

if nextToken == "" {
break
}
nextPage = nextToken
}

// TODO(marco): add total number of ECS Database Services.
ret.AWSRDS.ECSDatabaseServiceCount = 0

return ret, nil
}

func mergeResourceTypeSummary(in *ui.ResourceTypeSummary, lastSyncTime time.Time, new *discoveryconfigv1.ResourcesDiscoveredSummary) {
in.DiscoverLastSync = lastSync(in.DiscoverLastSync, lastSyncTime)
in.ResourcesFound += int(new.Found)
in.ResourcesEnrollmentSuccess += int(new.Enrolled)
in.ResourcesEnrollmentFailed += int(new.Failed)
}

func lastSync(current *time.Time, new time.Time) *time.Time {
if current == nil {
return &new
}

if current.Before(new) {
return &new
}

return current
}

// rulesWithIntegration returns the number of Rules for a given integration and matcher type in the DiscoveryConfig.
// A Rule is similar to a DiscoveryConfig's Matcher, eg DiscoveryConfig.Spec.AWS.[<Matcher>], however, a Rule has a single region.
// This means that the number of Rules for a given Matcher is equal to the number of regions on that Matcher.
func rulesWithIntegration(dc *discoveryconfig.DiscoveryConfig, matcherType string, integration string) int {
ret := 0

for _, matcher := range dc.Spec.AWS {
if matcher.Integration != integration {
continue
}
if !slices.Contains(matcher.Types, matcherType) {
continue
}
ret += len(matcher.Regions)
}
return ret
}

// integrationsList returns a page of Integrations
func (h *Handler) integrationsList(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
clt, err := sctx.GetUserClient(r.Context(), site)
Expand Down
131 changes: 131 additions & 0 deletions lib/web/intgrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import (
"context"
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/require"

discoveryconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/discoveryconfig/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/discoveryconfig"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/web/ui"
)
Expand Down Expand Up @@ -88,3 +91,131 @@ func TestIntegrationsCreateWithAudience(t *testing.T) {
})
}
}

func TestCollectAWSOIDCAutoDiscoverStats(t *testing.T) {
ctx := context.Background()
integrationName := "my-integration"
integration, err := types.NewIntegrationAWSOIDC(
types.Metadata{Name: integrationName},
&types.AWSOIDCIntegrationSpecV1{
RoleARN: "arn:role",
},
)
require.NoError(t, err)

t.Run("without discovery configs, returns just the integration", func(t *testing.T) {
clt := &mockDiscoveryConfigsGetter{
discoveryConfigs: make([]*discoveryconfig.DiscoveryConfig, 0),
}

gotSummary, err := collectAWSOIDCAutoDiscoverStats(ctx, integration, clt)
require.NoError(t, err)
expectedSummary := ui.IntegrationWithSummary{
Integration: &ui.Integration{
Name: integrationName,
SubKind: "aws-oidc",
AWSOIDC: &ui.IntegrationAWSOIDCSpec{RoleARN: "arn:role"},
},
}
require.Equal(t, expectedSummary, gotSummary)
})

t.Run("collects multiple discovery configs", func(t *testing.T) {
syncTime := time.Now()
dcForEC2 := &discoveryconfig.DiscoveryConfig{
Spec: discoveryconfig.Spec{AWS: []types.AWSMatcher{{
Integration: integrationName,
Types: []string{"ec2"},
Regions: []string{"us-east-1"},
}}},
Status: discoveryconfig.Status{
LastSyncTime: syncTime,
DiscoveredResources: 2,
IntegrationDiscoveredResources: map[string]*discoveryconfigv1.IntegrationDiscoveredSummary{
integrationName: {
AwsEc2: &discoveryconfigv1.ResourcesDiscoveredSummary{Found: 2, Enrolled: 1, Failed: 1},
},
},
},
}
dcForRDS := &discoveryconfig.DiscoveryConfig{
Spec: discoveryconfig.Spec{AWS: []types.AWSMatcher{{
Integration: integrationName,
Types: []string{"rds"},
Regions: []string{"us-east-1", "us-east-2"},
}}},
Status: discoveryconfig.Status{
LastSyncTime: syncTime,
DiscoveredResources: 2,
IntegrationDiscoveredResources: map[string]*discoveryconfigv1.IntegrationDiscoveredSummary{
integrationName: {
AwsRds: &discoveryconfigv1.ResourcesDiscoveredSummary{Found: 2, Enrolled: 1, Failed: 1},
},
},
},
}
dcForEKS := &discoveryconfig.DiscoveryConfig{
Spec: discoveryconfig.Spec{AWS: []types.AWSMatcher{{
Integration: integrationName,
Types: []string{"eks"},
Regions: []string{"us-east-1"},
}}},
Status: discoveryconfig.Status{
LastSyncTime: syncTime,
DiscoveredResources: 2,
IntegrationDiscoveredResources: map[string]*discoveryconfigv1.IntegrationDiscoveredSummary{
integrationName: {
AwsEks: &discoveryconfigv1.ResourcesDiscoveredSummary{Found: 4, Enrolled: 0, Failed: 0},
},
},
},
}
clt := &mockDiscoveryConfigsGetter{
discoveryConfigs: []*discoveryconfig.DiscoveryConfig{
dcForEC2,
dcForRDS,
dcForEKS,
},
}

gotSummary, err := collectAWSOIDCAutoDiscoverStats(ctx, integration, clt)
require.NoError(t, err)
expectedSummary := ui.IntegrationWithSummary{
Integration: &ui.Integration{
Name: integrationName,
SubKind: "aws-oidc",
AWSOIDC: &ui.IntegrationAWSOIDCSpec{RoleARN: "arn:role"},
},
AWSEC2: ui.ResourceTypeSummary{
RulesCount: 1,
ResourcesFound: 2,
ResourcesEnrollmentFailed: 1,
ResourcesEnrollmentSuccess: 1,
DiscoverLastSync: &syncTime,
},
AWSRDS: ui.ResourceTypeSummary{
RulesCount: 2,
ResourcesFound: 2,
ResourcesEnrollmentFailed: 1,
ResourcesEnrollmentSuccess: 1,
DiscoverLastSync: &syncTime,
},
AWSEKS: ui.ResourceTypeSummary{
RulesCount: 1,
ResourcesFound: 4,
ResourcesEnrollmentFailed: 0,
ResourcesEnrollmentSuccess: 0,
DiscoverLastSync: &syncTime,
},
}
require.Equal(t, expectedSummary, gotSummary)
})
}

type mockDiscoveryConfigsGetter struct {
discoveryConfigs []*discoveryconfig.DiscoveryConfig
}

func (m *mockDiscoveryConfigsGetter) ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error) {
return m.discoveryConfigs, "", nil
}
34 changes: 34 additions & 0 deletions lib/web/ui/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package ui
import (
"net/url"
"strings"
"time"

"github.com/gravitational/trace"

Expand Down Expand Up @@ -62,6 +63,39 @@ func (r *IntegrationAWSOIDCSpec) CheckAndSetDefaults() error {
return nil
}

// IntegrationWithSummary describes Integration fields and the fields required to return the summary.
type IntegrationWithSummary struct {
*Integration
// AWSEC2 contains the summary for the AWS EC2 resources for this integration.
AWSEC2 ResourceTypeSummary `json:"awsec2,omitempty"`
// AWSRDS contains the summary for the AWS RDS resources and agents for this integration.
AWSRDS ResourceTypeSummary `json:"awsrds,omitempty"`
// AWSEKS contains the summary for the AWS EKS resources for this integration.
AWSEKS ResourceTypeSummary `json:"awseks,omitempty"`
}

// ResourceTypeSummary contains the summary of the enrollment rules and found resources by the integration.
type ResourceTypeSummary struct {
// RulesCount is the number of enrollment rules that are using this integration.
// A rule is a matcher in a DiscoveryConfig that is being processed by a DiscoveryService.
// If the DiscoveryService is not reporting any Status, it means it is not being processed and it doesn't count for the number of rules.
// Example 1: a DiscoveryConfig with a matcher whose Type is "EC2" for two regions count as two EC2 rules.
// Example 2: a DiscoveryConfig with a matcher whose Types is "EC2,RDS" for one regions count as one EC2 rule.
// Example 3: a DiscoveryConfig with a matcher whose Types is "EC2,RDS", but has no DiscoveryService using it, it counts as 0 rules.
RulesCount int `json:"rulesCount,omitempty"`
// ResourcesFound contains the count of resources found by this integration.
ResourcesFound int `json:"resourcesFound,omitempty"`
// ResourcesEnrollmentFailed contains the count of resources that failed to enroll into the cluster.
ResourcesEnrollmentFailed int `json:"resourcesEnrollmentFailed,omitempty"`
// ResourcesEnrollmentSuccess contains the count of resources that succeeded to enroll into the cluster.
ResourcesEnrollmentSuccess int `json:"resourcesEnrollmentSuccess,omitempty"`
// DiscoverLastSync contains the time when this integration tried to auto-enroll resources.
DiscoverLastSync *time.Time `json:"discoverLastSync,omitempty"`
// ECSDatabaseServiceCount is the total number of DatabaseServices that were deployed into Amazon ECS.
// Only applicable for AWS RDS resource summary.
ECSDatabaseServiceCount int `json:"ecsDatabaseServiceCount,omitempty"`
}

// Integration describes Integration fields
type Integration struct {
// Name is the Integration name.
Expand Down
Loading