diff --git a/cmd/ctrlc/root/sync/aws/aws.go b/cmd/ctrlc/root/sync/aws/aws.go index 499eb01..b82c45c 100644 --- a/cmd/ctrlc/root/sync/aws/aws.go +++ b/cmd/ctrlc/root/sync/aws/aws.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/ec2" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/eks" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/networks" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/rds" "github.com/ctrlplanedev/cli/internal/cliutil" "github.com/spf13/cobra" @@ -33,5 +34,6 @@ func NewAWSCmd() *cobra.Command { cmd.AddCommand(cliutil.AddIntervalSupport(ec2.NewSyncEC2Cmd(), "")) cmd.AddCommand(cliutil.AddIntervalSupport(eks.NewSyncEKSCmd(), "")) cmd.AddCommand(cliutil.AddIntervalSupport(rds.NewSyncRDSCmd(), "")) + cmd.AddCommand(cliutil.AddIntervalSupport(networks.NewSyncNetworksCmd(), "")) return cmd } diff --git a/cmd/ctrlc/root/sync/aws/common/provider.go b/cmd/ctrlc/root/sync/aws/common/provider.go new file mode 100644 index 0000000..a44274f --- /dev/null +++ b/cmd/ctrlc/root/sync/aws/common/provider.go @@ -0,0 +1,42 @@ +package common + +import ( + "context" + "fmt" + "github.com/charmbracelet/log" + "strings" +) + +// EnsureProviderDetails generates a provider name and region string based on the provided parameters. +// The +func EnsureProviderDetails( + ctx context.Context, prefix string, regions []string, name *string, +) { + providerRegion := "all-regions" + // Use regions for name if none provided + if regions != nil && len(regions) > 0 { + providerRegion = strings.Join(regions, "-") + } + + // If name is not provided, try to get account ID to include in the provider name + if name == nil { + name = new(string) + } + if *name == "" { + // Get AWS account ID for provider name using common package + cfg, err := InitAWSConfig(ctx, regions[0]) + if err != nil { + log.Warn("Failed to load AWS config for account ID retrieval", "error", err) + *name = fmt.Sprintf("%s-%s", prefix, providerRegion) + } else { + accountID, err := GetAccountID(ctx, cfg) + if err == nil { + log.Info("Retrieved AWS account ID", "account_id", accountID) + *name = fmt.Sprintf("%s-%s-%s", prefix, accountID, providerRegion) + } else { + log.Warn("Failed to get AWS account ID", "error", err) + *name = fmt.Sprintf("%s-%s", prefix, providerRegion) + } + } + } +} diff --git a/cmd/ctrlc/root/sync/aws/eks/eks.go b/cmd/ctrlc/root/sync/aws/eks/eks.go index 2e95c20..8a7a32b 100644 --- a/cmd/ctrlc/root/sync/aws/eks/eks.go +++ b/cmd/ctrlc/root/sync/aws/eks/eks.go @@ -113,33 +113,10 @@ func runSync(regions *[]string, name *string) func(cmd *cobra.Command, args []st return nil } - // Use regions for name if none provided - providerRegion := "all-regions" - if regions != nil && len(*regions) > 0 { - providerRegion = strings.Join(*regions, "-") - } - - // If name is not provided, try to get account ID to include in the provider name - if *name == "" { - // Get AWS account ID for provider name using common package - cfg, err := common.InitAWSConfig(ctx, regionsToSync[0]) - if err != nil { - log.Warn("Failed to load AWS config for account ID retrieval", "error", err) - *name = fmt.Sprintf("aws-eks-%s", providerRegion) - } else { - accountID, err := common.GetAccountID(ctx, cfg) - if err == nil { - log.Info("Retrieved AWS account ID", "account_id", accountID) - *name = fmt.Sprintf("aws-eks-%s-%s", accountID, providerRegion) - } else { - log.Warn("Failed to get AWS account ID", "error", err) - *name = fmt.Sprintf("aws-eks-%s", providerRegion) - } - } - } + common.EnsureProviderDetails(ctx, "aws-eks", regionsToSync, name) // Upsert resources to Ctrlplane - return upsertToCtrlplane(ctx, allResources, &providerRegion, name) + return upsertToCtrlplane(ctx, allResources, name) } } @@ -239,18 +216,18 @@ func initClusterMetadata(cluster *types.Cluster, region string) map[string]strin log.Error("Failed to parse Kubernetes version", "version", *cluster.Version, "error", err) } - noramlizedStatus := "unknown" + normalizedStatus := "unknown" switch cluster.Status { case types.ClusterStatusActive: - noramlizedStatus = "running" + normalizedStatus = "running" case types.ClusterStatusUpdating: - noramlizedStatus = "updating" + normalizedStatus = "updating" case types.ClusterStatusCreating: - noramlizedStatus = "creating" + normalizedStatus = "creating" case types.ClusterStatusDeleting: - noramlizedStatus = "deleting" + normalizedStatus = "deleting" case types.ClusterStatusFailed: - noramlizedStatus = "failed" + normalizedStatus = "failed" } metadata := map[string]string{ @@ -264,7 +241,7 @@ func initClusterMetadata(cluster *types.Cluster, region string) map[string]strin kinds.K8SMetadataVersionMinor: strconv.FormatUint(uint64(version.Minor()), 10), kinds.K8SMetadataVersionPatch: strconv.FormatUint(uint64(version.Patch()), 10), kinds.K8SMetadataVersionPrerelease: version.Prerelease(), - kinds.K8SMetadataStatus: noramlizedStatus, + kinds.K8SMetadataStatus: normalizedStatus, "aws/region": region, "aws/resource-type": "eks:cluster", @@ -320,11 +297,7 @@ var relationshipRules = []api.CreateResourceRelationshipRule{ }, } -func upsertToCtrlplane(ctx context.Context, resources []api.CreateResource, region, name *string) error { - if *name == "" { - *name = fmt.Sprintf("aws-eks-%s", *region) - } - +func upsertToCtrlplane(ctx context.Context, resources []api.CreateResource, name *string) error { apiURL := viper.GetString("url") apiKey := viper.GetString("api-key") workspaceId := viper.GetString("workspace") diff --git a/cmd/ctrlc/root/sync/aws/networks/networks.go b/cmd/ctrlc/root/sync/aws/networks/networks.go new file mode 100644 index 0000000..218c706 --- /dev/null +++ b/cmd/ctrlc/root/sync/aws/networks/networks.go @@ -0,0 +1,446 @@ +package networks + +import ( + "context" + "fmt" + "github.com/MakeNowJust/heredoc/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/common" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "os" + "strconv" + "sync" +) + +// NewSyncNetworksCmd creates a new cobra command for syncing AWS Networks +func NewSyncNetworksCmd() *cobra.Command { + var name string + var regions []string + + cmd := &cobra.Command{ + Use: "networks", + Short: "Sync AWS VPC networks and subnets into Ctrlplane", + Example: heredoc.Doc(` + # Make sure AWS credentials are configured via environment variables or application default credentials + + # Sync all VPC networks and subnets from a region + $ ctrlc sync aws networks --region my-region + `), + RunE: runSync(®ions, &name), + } + + // Add command flags + cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider") + cmd.Flags().StringSliceVarP(®ions, "region", "r", []string{}, "AWS Region(s)") + + return cmd +} + +// runSync contains the main sync logic +func runSync(regions *[]string, name *string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Get the regions to sync from using common package + regionsToSync, err := common.GetRegions(ctx, *regions) + if err != nil { + return err + } + + allResources := make([]api.CreateResource, 0) + + var mu sync.Mutex + var wg sync.WaitGroup + var syncErrors []error + + for _, region := range regionsToSync { + wg.Add(1) + go func(regionName string) { + defer wg.Done() + log.Info("Syncing AWS Network resources into Ctrlplane", "region", regionName) + + ctx := context.Background() + + //apiURL := viper.GetString("url") + //apiKey := viper.GetString("api-key") + //workspaceId := viper.GetString("workspace") + + // Initialize compute client + ec2Client, cfg, err := initComputeClient(ctx, regionName) + if err != nil { + log.Error("Failed to initialize EC2 client", "region", regionName, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("region %s: %w", regionName, err)) + mu.Unlock() + return + } + + accountId, err := common.GetAccountID(ctx, cfg) + if err != nil { + log.Error("Failed get accountId", "region", regionName, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("region %s: %w", regionName, err)) + mu.Unlock() + return + } + + awsSubnets, err := getAwsSubnets(ctx, ec2Client, regionName) + if err != nil { + log.Error("Failed to get subnets", "region", regionName, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("region %s: %w", regionName, err)) + mu.Unlock() + return + } + + // List and process networks + vpcResources, err := processNetworks(ctx, ec2Client, awsSubnets, regionName, accountId) + if err != nil { + log.Error("Failed to process VPCs", "region", regionName, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("region %s: %w", regionName, err)) + mu.Unlock() + return + } + + // List and process subnets + subnetResources, err := processSubnets(ctx, awsSubnets, regionName) + if err != nil { + log.Error("Failed to process subnets", "region", regionName, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("region %s: %w", regionName, err)) + mu.Unlock() + return + } + + if len(vpcResources) > 0 { + mu.Lock() + allResources = append(allResources, vpcResources...) + mu.Unlock() + } + if len(subnetResources) > 0 { + mu.Lock() + allResources = append(allResources, subnetResources...) + mu.Unlock() + } + }(region) + } + wg.Wait() + + common.EnsureProviderDetails(ctx, "aws-networks", regionsToSync, name) + + // Upsert resources to Ctrlplane + return upsertToCtrlplane(ctx, allResources, name) + } +} + +// initComputeClient creates a new Compute Engine client +func initComputeClient(ctx context.Context, region string) (*ec2.Client, aws.Config, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, cfg, fmt.Errorf("failed to load AWS config: %w", err) + } + + credentials, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return nil, cfg, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + log.Info("AWS credentials loaded successfully", + "provider", credentials.Source, + "region", region, + "access_key_id", credentials.AccessKeyID[:4]+"****", + "expiration", credentials.Expires, + "type", credentials.Source, + "profile", os.Getenv("AWS_PROFILE"), + ) + + // Create EC2 client with retry options + ec2Client := ec2.NewFromConfig(cfg, func(o *ec2.Options) { + o.RetryMaxAttempts = 3 + o.RetryMode = aws.RetryModeStandard + }) + + return ec2Client, cfg, nil +} + +// processNetworks lists and processes all VPCs and subnets +func processNetworks( + ctx context.Context, ec2Client *ec2.Client, awsSubnets []types.Subnet, region string, accountId string, +) ([]api.CreateResource, error) { + var nextToken *string + vpcs := make([]types.Vpc, 0) + subnetsByVpc := make(map[string][]types.Subnet) + var awsVpcSubnets []types.Subnet + var exists bool + + for _, sn := range awsSubnets { + vpcId := *sn.VpcId + if _, exists = subnetsByVpc[vpcId]; !exists { + subnetsByVpc[vpcId] = []types.Subnet{} + } + subnetsByVpc[vpcId] = append(subnetsByVpc[vpcId], sn) + } + + for { + output, err := ec2Client.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{ + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to list VPCs: %w", err) + } + + vpcs = append(vpcs, output.Vpcs...) + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + + log.Info("Found vpcOutput", "count", len(vpcs)) + + resources := make([]api.CreateResource, 0) + for _, vpc := range vpcs { + if awsVpcSubnets, exists = subnetsByVpc[*vpc.VpcId]; !exists { + awsVpcSubnets = []types.Subnet{} + } + resource, err := processNetwork(vpc, awsVpcSubnets, region, accountId) + if err != nil { + log.Error("Failed to process vpc", "vpcId", vpc.VpcId, "error", err) + continue + } + resources = append(resources, resource) + } + + return resources, nil +} + +// processNetwork handles processing of a single VPC network +func processNetwork( + vpc types.Vpc, subnets []types.Subnet, region string, accountId string, +) (api.CreateResource, error) { + metadata := initNetworkMetadata(vpc, region, len(subnets)) + vpcName := getVpcName(vpc) + + // Build console URL + consoleUrl := getVpcConsoleUrl(vpc, region) + metadata["ctrlplane/links"] = fmt.Sprintf("{ \"AWS Console\": \"%s\" }", consoleUrl) + + return api.CreateResource{ + Version: "ctrlplane.dev/network/v1", + Kind: "AmazonNetwork", + Name: vpcName, + Identifier: *vpc.VpcId, + Config: map[string]any{ + // Common cross-provider options + "name": vpcName, + "type": "vpc", + "id": *vpc.VpcId, + + // Provider-specific implementation details + "awsVpc": map[string]any{ + "accountId": accountId, + "region": region, + "state": string(vpc.State), + "subnetCount": len(subnets), + }, + }, + Metadata: metadata, + }, nil +} + +// initNetworkMetadata initializes the base metadata for a network +func initNetworkMetadata(vpc types.Vpc, region string, subnetCount int) map[string]string { + vpcName := getVpcName(vpc) + consoleUrl := getVpcConsoleUrl(vpc, region) + + metadata := map[string]string{ + "network/type": "vpc", + "network/name": vpcName, + "network/subnet-count": strconv.Itoa(subnetCount), + "network/id": *vpc.VpcId, + "network/tenancy": string(vpc.InstanceTenancy), + + "aws/region": region, + "aws/resource-type": "vpc", + "aws/status": string(vpc.State), + "aws/console-url": consoleUrl, + "aws/id": *vpc.VpcId, + } + + // Tags + if vpc.Tags != nil { + for _, tag := range vpc.Tags { + metadata[fmt.Sprintf("tags/%s", *tag.Key)] = *tag.Value + } + } + + return metadata +} + +// getSubnetsForVpc retrieves subnets as AWS SDK objects +// these objects are processed differently for VPC and subnet resources +func getAwsSubnets(ctx context.Context, ec2Client *ec2.Client, region string) ([]types.Subnet, error) { + var subnets []types.Subnet + var nextToken *string + + for { + subnetInput := &ec2.DescribeSubnetsInput{ + Filters: []types.Filter{}, + NextToken: nextToken, + } + + subnetsOutput, err := ec2Client.DescribeSubnets(ctx, subnetInput) + if err != nil { + return nil, fmt.Errorf("failed to list subnets at region %s: %w", region, err) + } + + subnets = append(subnets, subnetsOutput.Subnets...) + if subnetsOutput.NextToken == nil { + break + } + nextToken = subnetsOutput.NextToken + } + + return subnets, nil +} + +// processSubnets lists and processes all subnetworks +func processSubnets(_ context.Context, subnets []types.Subnet, region string) ([]api.CreateResource, error) { + resources := make([]api.CreateResource, 0) + subnetCount := 0 + + // Process subnets from all regions + for _, subnet := range subnets { + resource, err := processSubnet(subnet, region) + if err != nil { + log.Error("Failed to process subnet", "subnetId", subnet.SubnetId, "error", err) + continue + } + resources = append(resources, resource) + subnetCount++ + } + + log.Info("Processed subnets", "count", subnetCount) + return resources, nil +} + +// processSubnet handles processing of a single subnet +func processSubnet(subnet types.Subnet, region string) (api.CreateResource, error) { + metadata := initSubnetMetadata(subnet, region) + subnetName := getSubnetName(subnet) + consoleUrl := getSubnetConsoleUrl(subnet, region) + metadata["ctrlplane/links"] = fmt.Sprintf("{ \"AWS Console\": \"%s\" }", consoleUrl) + + return api.CreateResource{ + Version: "ctrlplane.dev/network/subnet/v1", + Kind: "AmazonSubnet", + Name: subnetName, + Identifier: *subnet.SubnetArn, + Config: map[string]any{ + // Common cross-provider options + "name": subnetName, + "provider": "aws", + "type": "subnet", + "cidr": subnet.CidrBlock, + "region": region, + "id": *subnet.SubnetId, + "vpcId": *subnet.VpcId, + }, + Metadata: metadata, + }, nil +} + +// initSubnetMetadata initializes the base metadata for a subnet +func initSubnetMetadata(subnet types.Subnet, region string) map[string]string { + consoleUrl := getSubnetConsoleUrl(subnet, region) + subnetName := getSubnetName(subnet) + + metadata := map[string]string{ + "network/type": "subnet", + "network/name": subnetName, + "network/vpc": *subnet.VpcId, + "network/region": region, + "network/cidr": *subnet.CidrBlock, + "network/block-public-access": string(subnet.BlockPublicAccessStates.InternetGatewayBlockMode), + + "aws/resource-type": "aws/Subnet", + "aws/console-url": consoleUrl, + "aws/region": region, + "aws/id": *subnet.SubnetId, + } + + // Tags + if subnet.Tags != nil { + for _, tag := range subnet.Tags { + metadata[fmt.Sprintf("tags/%s", *tag.Key)] = *tag.Value + } + } + + return metadata +} + +func getVpcConsoleUrl(vpc types.Vpc, region string) string { + return fmt.Sprintf( + "https://%s.console.aws.amazon.com/vpcconsole/home?region=%s#VpcDetails:VpcId=%s", + region, region, *vpc.VpcId) +} + +func getVpcName(vpc types.Vpc) string { + vpcName := *vpc.VpcId + for _, tag := range vpc.Tags { + if *tag.Key == "Name" { + vpcName = *tag.Value + break + } + } + return vpcName +} + +func getSubnetConsoleUrl(subnet types.Subnet, region string) string { + return fmt.Sprintf( + "https://%s.console.aws.amazon.com/vpcconsole/home?region=%s#SubnetDetails:subnetId=%s", + region, region, *subnet.VpcId) +} + +func getSubnetName(subnet types.Subnet) string { + subnetName := *subnet.VpcId + for _, tag := range subnet.Tags { + if *tag.Key == "Name" { + subnetName = *tag.Value + break + } + } + return subnetName +} + +// upsertToCtrlplane handles upserting resources to Ctrlplane +func upsertToCtrlplane(ctx context.Context, resources []api.CreateResource, name *string) error { + apiURL := viper.GetString("url") + apiKey := viper.GetString("api-key") + workspaceId := viper.GetString("workspace") + + ctrlplaneClient, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + rp, err := api.NewResourceProvider(ctrlplaneClient, workspaceId, *name) + if err != nil { + return fmt.Errorf("failed to create resource provider: %w", err) + } + + upsertResp, err := rp.UpsertResource(ctx, resources) + if err != nil { + return fmt.Errorf("failed to upsert resources: %w", err) + } + + log.Info("Response from upserting resources", "status", upsertResp.Status) + return nil +} diff --git a/cmd/ctrlc/root/sync/azure/azure.go b/cmd/ctrlc/root/sync/azure/azure.go index 76e76a0..117a7b8 100644 --- a/cmd/ctrlc/root/sync/azure/azure.go +++ b/cmd/ctrlc/root/sync/azure/azure.go @@ -3,6 +3,7 @@ package azure import ( "github.com/MakeNowJust/heredoc/v2" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/azure/aks" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/azure/networks" "github.com/ctrlplanedev/cli/internal/cliutil" "github.com/spf13/cobra" ) @@ -32,6 +33,7 @@ func NewAzureCmd() *cobra.Command { // Add all Azure sync subcommands cmd.AddCommand(cliutil.AddIntervalSupport(aks.NewSyncAKSCmd(), "")) + cmd.AddCommand(cliutil.AddIntervalSupport(networks.NewSyncNetworksCmd(), "")) return cmd } diff --git a/cmd/ctrlc/root/sync/azure/common/resourceGroup.go b/cmd/ctrlc/root/sync/azure/common/resourceGroup.go new file mode 100644 index 0000000..4baa8a7 --- /dev/null +++ b/cmd/ctrlc/root/sync/azure/common/resourceGroup.go @@ -0,0 +1,42 @@ +package common + +import ( + "context" + "fmt" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" +) + +type ResourceGroupInfo struct { + Name string + Location string +} + +func GetResourceGroupInfo( + ctx context.Context, cred azcore.TokenCredential, subscriptionID string, +) ([]ResourceGroupInfo, error) { + + results := make([]ResourceGroupInfo, 0) + + client, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Resource Group client: %w", err) + } + pager := client.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get next page of resource groups: %v\n", err) + } + + // Iterate through the resource groups in the current page + for _, rg := range page.Value { + results = append(results, ResourceGroupInfo{ + Name: *rg.Name, + Location: *rg.Location, + }) + } + } + + return results, nil +} diff --git a/cmd/ctrlc/root/sync/azure/networks/networks.go b/cmd/ctrlc/root/sync/azure/networks/networks.go new file mode 100644 index 0000000..90fb581 --- /dev/null +++ b/cmd/ctrlc/root/sync/azure/networks/networks.go @@ -0,0 +1,432 @@ +package networks + +import ( + "context" + "fmt" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription" + "github.com/MakeNowJust/heredoc/v2" + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/azure/common" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/ctrlplanedev/cli/internal/kinds" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "os" + "strconv" + "sync" +) + +func NewSyncNetworksCmd() *cobra.Command { + var subscriptionID string + var name string + + cmd := &cobra.Command{ + Use: "networks", + Short: "Sync Azure Virtual Networks into Ctrlplane", + Example: heredoc.Doc(` + # Make sure Azure credentials are configured via environment variables or Azure CLI + + # Sync all AKS VPCs and subnets from the subscription + $ ctrlc sync azure networks + + # Sync all AKS VPCs and subnets from a specific subscription + $ ctrlc sync azure networks --subscription-id 00000000-0000-0000-0000-000000000000 + + # Sync all AKS VPCs and subnets every 5 minutes + $ ctrlc sync azure networks --interval 5m + `), + RunE: runSync(&subscriptionID, &name), + } + + cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider") + cmd.Flags().StringVarP(&subscriptionID, "subscription-id", "s", "", "Azure Subscription ID") + + return cmd +} + +func runSync(subscriptionID, name *string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Initialize Azure credential from environment or CLI + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return fmt.Errorf("failed to obtain Azure credential: %w", err) + } + + // If subscription ID is not provided, get the default one + if *subscriptionID == "" { + defaultSubscriptionID, err := getDefaultSubscriptionID(ctx, cred) + if err != nil { + return fmt.Errorf("failed to get default subscription ID: %w", err) + } + *subscriptionID = defaultSubscriptionID + log.Info("Using default subscription ID", "subscriptionID", *subscriptionID) + } + + // Get tenant ID from the subscription + tenantID, err := getTenantIDFromSubscription(ctx, cred, *subscriptionID) + if err != nil { + log.Warn("Failed to get tenant ID from subscription, falling back to environment variables", "error", err) + tenantID = getTenantIDFromEnv() + } + + log.Info("Syncing all Networks", "subscriptionID", *subscriptionID, "tenantID", tenantID) + + resources, err := processNetworks(ctx, cred, *subscriptionID, tenantID) + if err != nil { + return err + } + + if len(resources) == 0 { + log.Info("No Networks found") + return nil + } + + // If name is not provided, use subscription ID + if *name == "" { + *name = fmt.Sprintf("azure-networks-%s", *subscriptionID) + } + + // Upsert resources to Ctrlplane + return upsertToCtrlplane(ctx, resources, subscriptionID, name) + } +} + +func getTenantIDFromSubscription(ctx context.Context, cred azcore.TokenCredential, subscriptionID string) (string, error) { + // Create a subscriptions client + subsClient, err := armsubscriptions.NewClient(cred, nil) + if err != nil { + return "", fmt.Errorf("failed to create subscriptions client: %w", err) + } + + // Get the subscription details + resp, err := subsClient.Get(ctx, subscriptionID, nil) + if err != nil { + return "", fmt.Errorf("failed to get subscription details: %w", err) + } + + // Extract tenant ID from subscription + if resp.TenantID == nil || *resp.TenantID == "" { + return "", fmt.Errorf("subscription doesn't have a tenant ID") + } + + return *resp.TenantID, nil +} + +func getTenantIDFromEnv() string { + // Check environment variables + if tenantID := os.Getenv("AZURE_TENANT_ID"); tenantID != "" { + return tenantID + } + + // Check viper config + if tenantID := viper.GetString("azure.tenant-id"); tenantID != "" { + return tenantID + } + + return "" +} + +func getDefaultSubscriptionID(ctx context.Context, cred azcore.TokenCredential) (string, error) { + subClient, err := armsubscription.NewSubscriptionsClient(cred, nil) + if err != nil { + return "", fmt.Errorf("failed to create subscription client: %w", err) + } + + pager := subClient.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("failed to list subscriptions: %w", err) + } + + // Return the first subscription as default + if len(page.Value) > 0 && page.Value[0].SubscriptionID != nil { + return *page.Value[0].SubscriptionID, nil + } + } + + return "", fmt.Errorf("no subscriptions found") +} + +func processNetworks( + ctx context.Context, cred azcore.TokenCredential, subscriptionID string, tenantID string, +) ([]api.CreateResource, error) { + var allResources []api.CreateResource + var resourceGroups []common.ResourceGroupInfo + var mu sync.Mutex + var wg sync.WaitGroup + var err error + var syncErrors []error + + if resourceGroups, err = common.GetResourceGroupInfo(ctx, cred, subscriptionID); err != nil { + return nil, err + } + + // Create virtual network client + client, err := armnetwork.NewVirtualNetworksClient(subscriptionID, cred, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Virtual Network client: %w", err) + } + + for _, rg := range resourceGroups { + wg.Add(1) + rgName := rg.Name + go func(resourceGroup string) { + defer wg.Done() + + pager := client.NewListPager(resourceGroup, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("failed to list networks: %w", err)) + mu.Unlock() + return + } + for _, network := range page.Value { + resources, err := processNetwork(ctx, network, resourceGroup, subscriptionID, tenantID) + if err != nil { + log.Error("Failed to process network", "name", *network.Name, "error", err) + mu.Lock() + syncErrors = append(syncErrors, fmt.Errorf("network %s: %w", *network.Name, err)) + mu.Unlock() + return + } + mu.Lock() + allResources = append(allResources, resources...) + mu.Unlock() + } + } + }(rgName) + } + + wg.Wait() + + if len(syncErrors) > 0 { + log.Warn("Some clusters failed to sync", "errors", len(syncErrors)) + // Continue with the clusters that succeeded + } + + log.Info("Found network resources", "count", len(allResources)) + return allResources, nil +} + +func processNetwork( + _ context.Context, network *armnetwork.VirtualNetwork, resourceGroup string, subscriptionID string, tenantID string, +) ([]api.CreateResource, error) { + resources := make([]api.CreateResource, 0) + networkName := network.Name + metadata := initNetworkMetadata(network, resourceGroup, subscriptionID, tenantID) + + // Build console URL + consoleUrl := getNetworkConsoleUrl(resourceGroup, subscriptionID, *networkName) + metadata[kinds.CtrlplaneMetadataLinks] = fmt.Sprintf("{ \"Azure Portal\": \"%s\" }", consoleUrl) + + resources = append(resources, api.CreateResource{ + Version: "ctrlplane.dev/network/v1", + Kind: "AzureNetwork", + Name: *networkName, + Identifier: *network.ID, + Config: map[string]any{ + // Common cross-provider options + "name": networkName, + "type": "vpc", + "id": network.ID, + + // Provider-specific implementation details + "azureVirtualNetwork": map[string]any{ + "type": network.Type, + "region": network.Location, + "state": getNetworkState(network), + "subnetCount": getNetworkSubnetCount(network), + }, + }, + Metadata: metadata, + }) + if network.Properties != nil && network.Properties.Subnets != nil { + for _, subnet := range network.Properties.Subnets { + if res, err := processSubnet(network, subnet, resourceGroup, subscriptionID, tenantID); err != nil { + return nil, err + } else { + resources = append(resources, res) + } + } + } + return resources, nil +} + +func processSubnet( + network *armnetwork.VirtualNetwork, subnet *armnetwork.Subnet, resourceGroup string, subscriptionID string, tenantID string, +) (api.CreateResource, error) { + metadata := initSubnetMetadata(network, subnet, resourceGroup, subscriptionID, tenantID) + networkName := network.Name + subnetName := subnet.Name + + // Build console URL + consoleUrl := getSubnetConsoleUrl(resourceGroup, subscriptionID, *networkName) + metadata[kinds.CtrlplaneMetadataLinks] = fmt.Sprintf("{ \"Azure Portal\": \"%s\" }", consoleUrl) + + return api.CreateResource{ + Version: "ctrlplane.dev/network/subnet/v1", + Kind: "AzureSubnet", + Name: *subnetName, + Identifier: *subnet.ID, + Config: map[string]any{ + // Common cross-provider options + "name": subnetName, + "type": "subnet", + "id": subnet.ID, + + // Provider-specific implementation details + "azureSubnet": map[string]any{ + "type": subnet.Type, + "purpose": getSubnetPurpose(subnet), + "state": getSubnetState(subnet), + }, + }, + Metadata: metadata, + }, nil +} + +func initNetworkMetadata(network *armnetwork.VirtualNetwork, resourceGroup, subscriptionID, tenantID string) map[string]string { + subnetCount := 0 + if network.Properties != nil && network.Properties.Subnets != nil { + subnetCount = len(network.Properties.Subnets) + } + + metadata := map[string]string{ + "network/type": "vpc", + "network/name": *network.Name, + "network/subnet-count": strconv.Itoa(subnetCount), + "network/id": *network.ID, + "azure/subscription": subscriptionID, + "azure/tenant": tenantID, + "azure/resource-group": resourceGroup, + "azure/resource-type": "Microsoft.Network/virtualNetworks/subnets", + "azure/location": *network.Location, + "azure/status": getNetworkState(network), + "azure/id": *network.ID, + "azure/console-url": getNetworkConsoleUrl(resourceGroup, subscriptionID, *network.Name), + } + + // Tags + if network.Tags != nil { + for key, value := range network.Tags { + if value != nil { + metadata[fmt.Sprintf("tags/%s", key)] = *value + } + } + } + + return metadata +} + +func initSubnetMetadata(network *armnetwork.VirtualNetwork, subnet *armnetwork.Subnet, resourceGroup, subscriptionID, tenantID string) map[string]string { + + privateAccess := false + if subnet.Properties != nil { + if subnet.Properties.PrivateEndpoints != nil && len(subnet.Properties.PrivateEndpoints) > 0 { + privateAccess = true + } + } + + metadata := map[string]string{ + "network/type": "subnet", + "network/name": *subnet.Name, + "network/vpc": *network.Name, + "network/region": *network.Location, + "network/private-access": strconv.FormatBool(privateAccess), + "azure/subscription": subscriptionID, + "azure/tenant": tenantID, + "azure/resource-group": resourceGroup, + "azure/resource-type": "Microsoft.Network/virtualNetworks/subnets", + "azure/location": *network.Location, + "azure/status": getSubnetState(subnet), + "azure/id": *subnet.ID, + "azure/console-url": getSubnetConsoleUrl(resourceGroup, subscriptionID, *network.Name), + } + + return metadata +} + +func getNetworkConsoleUrl(resourceGroup, subscriptionID, networkName string) string { + return fmt.Sprintf( + "https://portal.azure.com/#@/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s", + subscriptionID, + resourceGroup, + networkName, + ) +} + +func getNetworkState(network *armnetwork.VirtualNetwork) string { + return func() string { + if network.Properties != nil && network.Properties.ProvisioningState != nil { + return string(*network.Properties.ProvisioningState) + } + return "" + }() +} + +func getNetworkSubnetCount(network *armnetwork.VirtualNetwork) int { + if network.Properties != nil && network.Properties.Subnets != nil { + return len(network.Properties.Subnets) + } + return 0 +} + +func getSubnetConsoleUrl(resourceGroup, subscriptionID, networkName string) string { + return fmt.Sprintf( + "https://portal.azure.com/#@/resource/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets", + subscriptionID, + resourceGroup, + networkName, + ) +} + +func getSubnetPurpose(subnet *armnetwork.Subnet) *string { + if subnet.Properties != nil && subnet.Properties.Purpose != nil { + return subnet.Properties.Purpose + } + return nil +} + +func getSubnetState(subnet *armnetwork.Subnet) string { + if subnet.Properties != nil && subnet.Properties.ProvisioningState != nil { + return string(*subnet.Properties.ProvisioningState) + } + return "" +} + +func upsertToCtrlplane(ctx context.Context, resources []api.CreateResource, subscriptionID, name *string) error { + if *name == "" { + *name = fmt.Sprintf("azure-networks-%s", *subscriptionID) + } + + apiURL := viper.GetString("url") + apiKey := viper.GetString("api-key") + workspaceId := viper.GetString("workspace") + + ctrlplaneClient, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + rp, err := api.NewResourceProvider(ctrlplaneClient, workspaceId, *name) + if err != nil { + return fmt.Errorf("failed to create resource provider: %w", err) + } + + upsertResp, err := rp.UpsertResource(ctx, resources) + if err != nil { + return fmt.Errorf("failed to upsert resources: %w", err) + } + + log.Info("Response from upserting resources", "status", upsertResp.Status) + return nil +} diff --git a/go.mod b/go.mod index c7bd946..9b7faa7 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,26 @@ module github.com/ctrlplanedev/cli go 1.24.2 require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/Masterminds/semver v1.5.0 github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go-v2 v1.36.3 github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 github.com/aws/aws-sdk-go-v2/service/ec2 v1.211.3 + github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 + github.com/aws/aws-sdk-go-v2/service/rds v1.95.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 github.com/charmbracelet/log v0.4.0 github.com/creack/pty v1.1.24 + github.com/google/go-github/v57 v57.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-tfe v1.73.1 @@ -20,8 +32,10 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/tailscale/tailscale-client-go/v2 v2.0.0-20241217012816-8143c7dc1766 + golang.org/x/oauth2 v0.29.0 google.golang.org/api v0.230.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.32.1 k8s.io/apimachinery v0.32.1 k8s.io/client-go v0.32.1 @@ -31,27 +45,18 @@ require ( cloud.google.com/go/auth v0.16.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/rds v1.95.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect github.com/aws/smithy-go v1.22.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/lipgloss v0.10.0 // indirect @@ -73,7 +78,6 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-github/v57 v57.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -130,7 +134,6 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect @@ -144,7 +147,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect diff --git a/go.sum b/go.sum index e38ff60..7cb7294 100644 --- a/go.sum +++ b/go.sum @@ -8,16 +8,30 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 h1:figxyQZXzZQIcP3njhC68bYUiTw45J8/SsHaLW8Ax0M= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1 h1:7CBQ+Ei8SP2c6ydQTGCCrS35bDxgTMfoP2miAwK++OU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1/go.mod h1:c/wcGeGx5FUPbM/JltUYHZcKmigwyVLJlDq+4HdtXaw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= @@ -64,6 +78,8 @@ github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxY github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= @@ -79,6 +95,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -121,7 +139,6 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -194,6 +211,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -271,6 +290,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=